diff --git a/mnemo_cards_backend/Dockerfile b/mnemo_cards_backend/Dockerfile index 8be77ea..8267eda 100644 --- a/mnemo_cards_backend/Dockerfile +++ b/mnemo_cards_backend/Dockerfile @@ -45,16 +45,20 @@ RUN mkdir -p /app/isar /app/backups && \ chmod -R 755 /app/isar /app/backups ENV PORT=3000 \ - ADDRESS=0.0.0.0 \ + SERVER_ADDRESS=0.0.0.0 \ ISAR_DIR=/app/isar \ BACKUP_DIR=/app/backups \ - APP_WORKDIR=/app + WORK_DIR=/app \ + DEBUG=false \ + CERTS_PATH= \ + DUAL_MODE=false \ + ADMIN_IDS= EXPOSE 3000 -# Healthcheck - uses PORT env variable +# Healthcheck - проверка доступности API HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \ - CMD curl -sf http://127.0.0.1:3000/api/v2/packs || exit 1 + CMD sh -c 'curl -sf http://127.0.0.1:${PORT:-3000}/api/v2/packs || exit 1' -# Start server on PORT (default 3000, can be overridden by Coolify) -CMD sh -c "/app/server -a ${ADDRESS:-0.0.0.0} -p ${PORT:-3000} --isar ${ISAR_DIR:-/app/isar} --backup ${BACKUP_DIR:-/app/backups} --workdir ${APP_WORKDIR:-/app}" +# Start server - все настройки читаются из environment variables +CMD ["/app/server"] diff --git a/mnemo_cards_backend/lib/api/di/injector.config.dart b/mnemo_cards_backend/lib/api/di/injector.config.dart index 32185bd..cb803dc 100644 --- a/mnemo_cards_backend/lib/api/di/injector.config.dart +++ b/mnemo_cards_backend/lib/api/di/injector.config.dart @@ -13,42 +13,43 @@ import 'package:injectable/injectable.dart' as _i2; import 'package:isar/isar.dart' as _i4; import '../../auth/telegram_auth_code_service.dart' as _i20; -import '../../cron/check_payment.dart' as _i40; +import '../../cron/check_payment.dart' as _i41; import '../../discounts/discounts_manager.dart' as _i8; import '../../packs/free_packs_distributor.dart' as _i9; -import '../../packs/pack_dto_converter.dart' as _i26; -import '../../packs/pack_manager.dart' as _i27; +import '../../packs/pack_dto_converter.dart' as _i27; +import '../../packs/pack_manager.dart' as _i28; import '../../packs/products_price_resolver.dart' as _i13; -import '../../promo_codes/promo_codes_manager.dart' as _i34; +import '../../promo_codes/promo_codes_manager.dart' as _i35; import '../../statistics/achievement_manager.dart' as _i3; import '../../statistics/session_tracker.dart' as _i15; import '../../statistics/statistics_calculator.dart' as _i16; -import '../../tests/test_manager.dart' as _i28; -import '../../user/user_manager.dart' as _i21; +import '../../tests/test_manager.dart' as _i29; +import '../../user/user_manager.dart' as _i22; import '../ads/ads_manager.dart' as _i7; -import '../mnemo_shelf.dart' as _i25; -import '../purchase/payment_manager.dart' as _i33; +import '../mnemo_shelf.dart' as _i26; +import '../purchase/payment_manager.dart' as _i34; import '../purchase/rustore/rustore_purchase_handler.dart' as _i14; -import '../purchase/yoo_money.dart' as _i30; +import '../purchase/yoo_money.dart' as _i31; import '../subscription/subscription_manager.dart' as _i17; import '../user/google_api.dart' as _i11; import '../v2/admin_analytics_api_v2.dart' as _i5; -import '../v2/admin_auth_api_v2.dart' as _i22; +import '../v2/admin_auth_api_v2.dart' as _i23; import '../v2/admin_cards_api_v2.dart' as _i6; -import '../v2/admin_packs_api_v2.dart' as _i31; -import '../v2/admin_users_api_v2.dart' as _i38; -import '../v2/ads_api_v2.dart' as _i39; -import '../v2/auth_api_v2.dart' as _i23; -import '../v2/discounts_api_v2.dart' as _i24; +import '../v2/admin_packs_api_v2.dart' as _i32; +import '../v2/admin_users_api_v2.dart' as _i39; +import '../v2/ads_api_v2.dart' as _i40; +import '../v2/auth_api_v2.dart' as _i24; +import '../v2/discounts_api_v2.dart' as _i25; import '../v2/games_api_v2.dart' as _i10; import '../v2/jwt_service.dart' as _i12; -import '../v2/packs_api_v2.dart' as _i32; -import '../v2/promocodes_api_v2.dart' as _i35; -import '../v2/purchases_api_v2.dart' as _i36; +import '../v2/packs_api_v2.dart' as _i33; +import '../v2/promocodes_api_v2.dart' as _i36; +import '../v2/purchases_api_v2.dart' as _i37; import '../v2/subscriptions_api_v2.dart' as _i18; import '../v2/tasks_api_v2.dart' as _i19; -import '../v2/tests_api_v2.dart' as _i29; -import '../v2/users_api_v2.dart' as _i37; +import '../v2/telegram_bot_api_v2.dart' as _i21; +import '../v2/tests_api_v2.dart' as _i30; +import '../v2/users_api_v2.dart' as _i38; extension GetItInjectableX on _i1.GetIt { // initializes the registration of main-scope dependencies inside of GetIt @@ -87,76 +88,77 @@ extension GetItInjectableX on _i1.GetIt { gh.lazySingleton<_i19.TasksApiV2>(() => const _i19.TasksApiV2()); gh.lazySingleton<_i20.TelegramAuthCodeService>( () => _i20.TelegramAuthCodeService()); - gh.lazySingleton<_i21.UserManager>(() => _i21.UserManager( + gh.lazySingleton<_i21.TelegramBotApiV2>(() => _i21.TelegramBotApiV2()); + gh.lazySingleton<_i22.UserManager>(() => _i22.UserManager( gh<_i9.FreePacksDistributor>(), gh<_i15.SessionTracker>(), gh<_i16.StatisticsCalculator>(), gh<_i3.AchievementManager>(), )); - gh.lazySingleton<_i22.AdminAuthApiV2>( - () => _i22.AdminAuthApiV2(gh<_i20.TelegramAuthCodeService>())); - gh.lazySingleton<_i23.AuthApiV2>(() => _i23.AuthApiV2( - gh<_i21.UserManager>(), + gh.lazySingleton<_i23.AdminAuthApiV2>( + () => _i23.AdminAuthApiV2(gh<_i20.TelegramAuthCodeService>())); + gh.lazySingleton<_i24.AuthApiV2>(() => _i24.AuthApiV2( + gh<_i22.UserManager>(), gh<_i11.GoogleApi>(), gh<_i12.JwtService>(), gh<_i20.TelegramAuthCodeService>(), )); - gh.lazySingleton<_i24.DiscountsApiV2>( - () => _i24.DiscountsApiV2(gh<_i8.DiscountsManager>())); - gh.lazySingleton<_i25.MnemoShelf>( - () => _i25.MnemoShelf(gh<_i21.UserManager>())); - gh.lazySingleton<_i26.PackDtoConverter>(() => _i26.PackDtoConverter( + gh.lazySingleton<_i25.DiscountsApiV2>( + () => _i25.DiscountsApiV2(gh<_i8.DiscountsManager>())); + gh.lazySingleton<_i26.MnemoShelf>( + () => _i26.MnemoShelf(gh<_i22.UserManager>())); + gh.lazySingleton<_i27.PackDtoConverter>(() => _i27.PackDtoConverter( gh<_i13.ProductsPriceResolver>(), gh<_i7.AdsManager>(), )); - gh.lazySingleton<_i27.PackManager>( - () => _i27.PackManager(gh<_i26.PackDtoConverter>())); - gh.lazySingleton<_i28.TestManager>( - () => _i28.TestManager(gh<_i26.PackDtoConverter>())); - gh.lazySingleton<_i29.TestsApiV2>(() => _i29.TestsApiV2( - gh<_i28.TestManager>(), - gh<_i21.UserManager>(), + gh.lazySingleton<_i28.PackManager>( + () => _i28.PackManager(gh<_i27.PackDtoConverter>())); + gh.lazySingleton<_i29.TestManager>( + () => _i29.TestManager(gh<_i27.PackDtoConverter>())); + gh.lazySingleton<_i30.TestsApiV2>(() => _i30.TestsApiV2( + gh<_i29.TestManager>(), + gh<_i22.UserManager>(), )); - gh.lazySingleton<_i30.YooMoneyHandler>( - () => _i30.YooMoneyHandler(gh<_i27.PackManager>())); - gh.lazySingleton<_i31.AdminPacksApiV2>(() => _i31.AdminPacksApiV2( - gh<_i27.PackManager>(), - gh<_i26.PackDtoConverter>(), + gh.lazySingleton<_i31.YooMoneyHandler>( + () => _i31.YooMoneyHandler(gh<_i28.PackManager>())); + gh.lazySingleton<_i32.AdminPacksApiV2>(() => _i32.AdminPacksApiV2( + gh<_i28.PackManager>(), + gh<_i27.PackDtoConverter>(), )); - gh.lazySingleton<_i32.PacksApiV2>(() => _i32.PacksApiV2( - gh<_i27.PackManager>(), - gh<_i28.TestManager>(), + gh.lazySingleton<_i33.PacksApiV2>(() => _i33.PacksApiV2( + gh<_i28.PackManager>(), + gh<_i29.TestManager>(), )); - gh.lazySingleton<_i33.PaymentManager>(() => _i33.PaymentManager( - gh<_i27.PackManager>(), + gh.lazySingleton<_i34.PaymentManager>(() => _i34.PaymentManager( + gh<_i28.PackManager>(), gh<_i17.SubscriptionManager>(), - gh<_i30.YooMoneyHandler>(), + gh<_i31.YooMoneyHandler>(), gh<_i14.RustorePurchaseHandler>(), gh<_i13.ProductsPriceResolver>(), )); - gh.lazySingleton<_i34.PromoCodesManager>( - () => _i34.PromoCodesManager(gh<_i33.PaymentManager>())); - gh.lazySingleton<_i35.PromocodesApiV2>( - () => _i35.PromocodesApiV2(gh<_i34.PromoCodesManager>())); - gh.lazySingleton<_i36.PurchasesApiV2>(() => _i36.PurchasesApiV2( - gh<_i33.PaymentManager>(), - gh<_i27.PackManager>(), + gh.lazySingleton<_i35.PromoCodesManager>( + () => _i35.PromoCodesManager(gh<_i34.PaymentManager>())); + gh.lazySingleton<_i36.PromocodesApiV2>( + () => _i36.PromocodesApiV2(gh<_i35.PromoCodesManager>())); + gh.lazySingleton<_i37.PurchasesApiV2>(() => _i37.PurchasesApiV2( + gh<_i34.PaymentManager>(), + gh<_i28.PackManager>(), )); - gh.lazySingleton<_i37.UsersApiV2>(() => _i37.UsersApiV2( - gh<_i21.UserManager>(), - gh<_i33.PaymentManager>(), + gh.lazySingleton<_i38.UsersApiV2>(() => _i38.UsersApiV2( + gh<_i22.UserManager>(), + gh<_i34.PaymentManager>(), gh<_i16.StatisticsCalculator>(), )); - gh.lazySingleton<_i38.AdminUsersApiV2>(() => _i38.AdminUsersApiV2( - gh<_i21.UserManager>(), - gh<_i33.PaymentManager>(), + gh.lazySingleton<_i39.AdminUsersApiV2>(() => _i39.AdminUsersApiV2( + gh<_i22.UserManager>(), + gh<_i34.PaymentManager>(), )); - gh.lazySingleton<_i39.AdsApiV2>(() => _i39.AdsApiV2( + gh.lazySingleton<_i40.AdsApiV2>(() => _i40.AdsApiV2( gh<_i7.AdsManager>(), - gh<_i33.PaymentManager>(), + gh<_i34.PaymentManager>(), )); - gh.lazySingleton<_i40.CheckPaymentTask>( - () => _i40.CheckPaymentTask(gh<_i33.PaymentManager>())); + gh.lazySingleton<_i41.CheckPaymentTask>( + () => _i41.CheckPaymentTask(gh<_i34.PaymentManager>())); return this; } } diff --git a/mnemo_cards_backend/lib/api/mnemo_shelf.dart b/mnemo_cards_backend/lib/api/mnemo_shelf.dart index 8058b60..1d6e54e 100644 --- a/mnemo_cards_backend/lib/api/mnemo_shelf.dart +++ b/mnemo_cards_backend/lib/api/mnemo_shelf.dart @@ -22,7 +22,9 @@ import 'v2/subscriptions_api_v2.dart'; import 'v2/tasks_api_v2.dart'; import 'v2/tests_api_v2.dart'; import 'v2/users_api_v2.dart'; +import 'v2/telegram_bot_api_v2.dart'; import 'v2/authorize_v2.dart'; +import 'v2/telegram_bot_auth_middleware.dart'; import 'v2/jwt_service.dart'; import 'authorize/access_middleware.dart'; import 'package:mnemo_cards_backend/packs/pack_manager.dart'; @@ -35,18 +37,15 @@ class MnemoShelf { MnemoShelf(this.userManager); - Future initV2(List args) async { - final addressArg = - args.indexOf('-a') == -1 ? null : args[args.indexOf('-a') + 1]; - final portArg = - args.indexOf('-p') == -1 ? null : args[args.indexOf('-p') + 1]; + Future initV2() async { + // Чтение переменных окружения вместо CLI аргументов + final addressArg = Platform.environment['SERVER_ADDRESS']; + final portArg = Platform.environment['PORT']; final address = addressArg ?? InternetAddress.anyIPv4.address; - final port = int.tryParse(portArg ?? '') ?? 8443; - final certs = args.contains('--certs') - ? args[args.indexOf('--certs') + 1] - : null; // null = HTTP without SSL - final dualMode = - args.contains('--dual'); // Запуск HTTP и HTTPS одновременно + final port = int.tryParse(portArg ?? '') ?? 3000; + final certs = Platform.environment['CERTS_PATH']; // null = HTTP without SSL + final dualMode = Platform.environment['DUAL_MODE'] == 'true' || + Platform.environment['DUAL_MODE'] == '1'; // Запуск HTTP и HTTPS одновременно // V2 APIs (new RESTful API with OAuth2/JWT) final v2Routers = [ @@ -66,6 +65,7 @@ class MnemoShelf { getIt.get().router, getIt.get().router, getIt.get().router, + getIt.get().router, ]; final v2Router = v2Routers.fold( @@ -104,7 +104,7 @@ class MnemoShelf { 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS', 'Access-Control-Allow-Headers': - 'Origin, Content-Type, Accept, Authorization, user_token, request_token, app_version', + 'Origin, Content-Type, Accept, Authorization, user_token, request_token, app_version, X-API-Key', 'Access-Control-Expose-Headers': 'Authorization', 'Access-Control-Max-Age': '86400', }; @@ -137,6 +137,7 @@ class MnemoShelf { final v2Handler = Pipeline() .addMiddleware(corsMiddleware()) .addMiddleware(logRequests(logger: logger('v2'))) + .addMiddleware(telegramBotAuthMiddleware()) .addMiddleware( authorizeV2(getIt.get(), getIt.get())) .addMiddleware(accessMiddleware( diff --git a/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart b/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart index 137c598..c56f772 100644 --- a/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/admin_auth_api_v2.dart @@ -1,11 +1,11 @@ import 'dart:convert'; -import 'dart:io'; import 'package:injectable/injectable.dart'; import 'package:isar/isar.dart'; import 'package:mnemo_cards_backend/api/authorize/helpers.dart'; import 'package:mnemo_cards_backend/auth/telegram_auth_code_service.dart'; import 'package:mnemo_cards_backend/main.dart' as backend_main; +import 'package:mnemo_cards_backend/user/admin_ids_service.dart'; import 'package:mnemo_cards_backend/user/telegram.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import 'package:shelf/shelf.dart'; @@ -230,24 +230,9 @@ Use this code to access the admin panel at admin.mnemo-cards.online } } - /// Get list of admin Telegram IDs from file + /// Get list of admin Telegram IDs from environment variable or file Future> _getAdminIds() async { - try { - // Read admin IDs from file - final file = File('admins'); - if (!file.existsSync()) { - return []; - } - - final content = await file.readAsString(); - return content - .split('\n') - .map((line) => line.trim()) - .where((line) => line.isNotEmpty) - .toList(); - } catch (e) { - return []; - } + return AdminIdsService.getAdminIds(); } /// Generate JWT token for admin user diff --git a/mnemo_cards_backend/lib/api/v2/authorize_v2.dart b/mnemo_cards_backend/lib/api/v2/authorize_v2.dart index 59efb91..bdc4ca9 100644 --- a/mnemo_cards_backend/lib/api/v2/authorize_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/authorize_v2.dart @@ -43,6 +43,11 @@ Middleware authorizeV2(UserManager userManager, JwtService jwtService) { normalizedPath = '/$normalizedPath'; } + // Check if this is a telegram-bot endpoint - skip JWT auth (handled by API key middleware) + if (normalizedPath.startsWith('/telegram-bot/')) { + return await innerHandler(request); + } + // Check if this is a public auth endpoint - always allow for all HTTP methods if (normalizedPath.startsWith('/auth/')) { if (_publicAuthPaths.contains(normalizedPath)) { diff --git a/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart new file mode 100644 index 0000000..669a87c --- /dev/null +++ b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart @@ -0,0 +1,309 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:injectable/injectable.dart'; +import 'package:mnemo_cards_backend/main.dart' as backend_main; +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_open_api/shelf_open_api.dart'; +import 'package:shelf_router/shelf_router.dart'; + +part 'telegram_bot_api_v2.g.dart'; + +/// API v2 endpoints for Telegram Bot +/// +/// These endpoints are authenticated via X-API-Key header +/// and provide functionality previously accessed directly via Isar DB +@lazySingleton +class TelegramBotApiV2 { + TelegramBotApiV2(); + + Response _ok(Object? object, {Map headers = const {}}) => + Response.ok( + object == null ? null : jsonEncode(object), + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + ); + + Response _badRequest(String message) => Response.badRequest( + body: jsonEncode({'error': 'Bad Request', 'message': message}), + headers: {'Content-Type': 'application/json'}, + ); + + Response _notFound([String? message]) => Response.notFound( + jsonEncode({ + 'error': 'Not Found', + 'message': message ?? 'Resource not found', + }), + headers: {'Content-Type': 'application/json'}, + ); + + Response _internalServerError([String? message]) => Response( + 500, + body: jsonEncode({ + 'error': 'Internal Server Error', + 'message': message ?? 'An error occurred', + }), + headers: {'Content-Type': 'application/json'}, + ); + + /// GET /api/v2/telegram-bot/random-card + /// Get a random card from all available cards + @Route.get('/telegram-bot/random-card') + @OpenApiRoute() + Future getRandomCard(Request request) async { + try { + final cards = await backend_main.isar.txn( + () async => await backend_main.isar.gameCardModels.where().findAll(), + ); + if (cards.isEmpty) { + return _notFound('No cards found'); + } + + final random = Random(); + final card = cards[random.nextInt(cards.length)]; + + // Load pack to get packId + await card.packs.load(); + final packId = card.packs.isNotEmpty ? card.packs.first.id : null; + + // Convert to JSON + return _ok({ + 'id': card.id, + 'original': card.original, + 'translation': card.translation, + 'mnemo': card.mnemo, + 'image': card.image, + 'transcription': card.transcription, + 'transcriptionMnemo': card.transcriptionMnemo, + 'imageBack': card.imageBack, + 'back': card.back, + 'packId': packId, + }); + } catch (e, s) { + print('Error getting random card: $e\n$s'); + return _internalServerError('Failed to get random card: $e'); + } + } + + /// POST /api/v2/telegram-bot/share/check-limit + /// Check if user can share today (rate limiting) + /// Body: { telegramUserId: string, dailyLimit: number } + @Route.post('/telegram-bot/share/check-limit') + @OpenApiRoute() + Future checkShareLimit(Request request) async { + try { + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + final telegramUserId = json['telegramUserId'] as String?; + final dailyLimit = json['dailyLimit'] as int?; + + if (telegramUserId == null || telegramUserId.isEmpty) { + return _badRequest('telegramUserId is required'); + } + + if (dailyLimit == null || dailyLimit < 1) { + return _badRequest('dailyLimit must be a positive integer'); + } + + final today = DateTime.now(); + final todayStart = DateTime(today.year, today.month, today.day); + final todayEnd = DateTime(today.year, today.month, today.day + 1); + + final sharesCount = await backend_main.isar.txn( + () async => await backend_main.isar.shareRequestModels + .filter() + .telegramUserIdEqualTo(telegramUserId) + .requestedAtBetween(todayStart, todayEnd) + .count(), + ); + + final canShare = sharesCount < dailyLimit; + + return _ok({ + 'canShare': canShare, + 'sharesToday': sharesCount, + 'dailyLimit': dailyLimit, + }); + } catch (e, s) { + print('Error checking share limit: $e\n$s'); + return _internalServerError('Failed to check share limit: $e'); + } + } + + /// POST /api/v2/telegram-bot/share/record + /// Record a share request for a user + /// Body: { telegramUserId: string, telegramUsername?: string, sharedCardId?: number } + @Route.post('/telegram-bot/share/record') + @OpenApiRoute() + Future recordShareRequest(Request request) async { + try { + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + final telegramUserId = json['telegramUserId'] as String?; + final telegramUsername = json['telegramUsername'] as String?; + final sharedCardId = json['sharedCardId'] as int?; + + if (telegramUserId == null || telegramUserId.isEmpty) { + return _badRequest('telegramUserId is required'); + } + + final shareRequest = ShareRequestModel( + telegramUserId: telegramUserId, + telegramUsername: telegramUsername, + requestedAt: DateTime.now(), + sharedCardId: sharedCardId, + ); + + await backend_main.isar.writeTxn( + () => backend_main.isar.shareRequestModels.put(shareRequest), + ); + + print( + '[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId at ${shareRequest.requestedAt}'); + + return _ok({ + 'success': true, + 'message': 'Share request recorded', + }); + } catch (e, s) { + print('Error recording share request: $e\n$s'); + return _internalServerError('Failed to record share request: $e'); + } + } + + /// GET /api/v2/telegram-bot/users/info + /// Get user information (for admin commands) + /// Query: ?userId= for specific user, or no query for all users summary + @Route.get('/telegram-bot/users/info') + @OpenApiRoute() + Future getUsersInfo(Request request) async { + try { + final queryParams = request.requestedUri.queryParameters; + final userId = queryParams['userId']; + + if (userId != null && userId.isNotEmpty) { + // Get specific user info + final id = int.tryParse(userId); + final users = await backend_main.isar.txn(() async { + if (id != null) { + final user = await backend_main.isar.userModels.get(id); + if (user != null) { + return [user]; + } + } + // Search by email or name + final byEmail = await backend_main.isar.userModels + .filter() + .emailContains(userId, caseSensitive: false) + .findAll(); + if (byEmail.isNotEmpty) { + return byEmail; + } + return await backend_main.isar.userModels + .filter() + .nameContains(userId, caseSensitive: false) + .findAll(); + }); + + if (users.isEmpty) { + return _notFound('User not found'); + } + + if (users.length > 1) { + return _ok({ + 'multiple': true, + 'users': users.map((u) => { + 'id': u.id, + 'email': u.email, + 'name': u.name, + }).toList(), + }); + } + + final user = users.first; + await user.packs.load(); + await user.subscriptionModel.load(); + await user.userData.load(); + final sub = user.subscriptionModel.value; + + return _ok({ + 'id': user.id, + 'email': user.email, + 'name': user.name, + 'tags': user.userData.value?.tags.join(',') ?? '', + 'packs': user.packs.map((p) => { + 'id': p.id, + 'title': p.title, + }).toList(), + 'purchases': user.purchases.length, + 'subscription': sub == null + ? null + : { + 'start': sub.start.toIso8601String(), + 'finish': sub.finish.toIso8601String(), + 'features': sub.features.map((f) => f.name).toList(), + }, + }); + } else { + // Get all users summary + final users = await backend_main.isar.txn( + () async => await backend_main.isar.userModels.where().findAll(), + ); + + for (final user in users) { + await user.userData.load(); + } + + return _ok({ + 'total': users.length, + 'users': users.map((user) => { + 'id': user.id, + 'email': user.email ?? '${user.id} ${user.name}', + 'lastTimeOnline': user.userData.value?.lastTimeOnline + ?.toIso8601String(), + }).toList(), + }); + } + } catch (e, s) { + print('Error getting users info: $e\n$s'); + return _internalServerError('Failed to get users info: $e'); + } + } + + /// GET /api/v2/telegram-bot/words + /// Get all words from all cards + /// Query: ?separator= for custom separator (default: comma) + @Route.get('/telegram-bot/words') + @OpenApiRoute() + Future getWords(Request request) async { + try { + final queryParams = request.requestedUri.queryParameters; + final separator = queryParams['separator'] ?? ','; + + // Get all cards directly + final cards = await backend_main.isar.txn( + () async => await backend_main.isar.gameCardModels.where().findAll(), + ); + + final words = cards + .map((c) => c.original) + .whereType() + .where((w) => w.isNotEmpty) + .toList(); + + return _ok({ + 'words': words, + 'separator': separator, + 'joined': words.join(separator.isEmpty ? ',\n' : separator), + }); + } catch (e, s) { + print('Error getting words: $e\n$s'); + return _internalServerError('Failed to get words: $e'); + } + } + + Router get router => _$TelegramBotApiV2Router(this); +} diff --git a/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.g.dart b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.g.dart new file mode 100644 index 0000000..793316f --- /dev/null +++ b/mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.g.dart @@ -0,0 +1,37 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'telegram_bot_api_v2.dart'; + +// ************************************************************************** +// ShelfRouterGenerator +// ************************************************************************** + +Router _$TelegramBotApiV2Router(TelegramBotApiV2 service) { + final router = Router(); + router.add( + 'GET', + r'/telegram-bot/random-card', + service.getRandomCard, + ); + router.add( + 'POST', + r'/telegram-bot/share/check-limit', + service.checkShareLimit, + ); + router.add( + 'POST', + r'/telegram-bot/share/record', + service.recordShareRequest, + ); + router.add( + 'GET', + r'/telegram-bot/users/info', + service.getUsersInfo, + ); + router.add( + 'GET', + r'/telegram-bot/words', + service.getWords, + ); + return router; +} diff --git a/mnemo_cards_backend/lib/api/v2/telegram_bot_auth_middleware.dart b/mnemo_cards_backend/lib/api/v2/telegram_bot_auth_middleware.dart new file mode 100644 index 0000000..9002bea --- /dev/null +++ b/mnemo_cards_backend/lib/api/v2/telegram_bot_auth_middleware.dart @@ -0,0 +1,48 @@ +import 'dart:io'; +import 'package:shelf/shelf.dart'; + +/// Middleware for Telegram Bot API key authentication +/// Checks X-API-Key header against TELEGRAM_BOT_API_KEY environment variable +Middleware telegramBotAuthMiddleware() { + return (Handler innerHandler) { + return (Request request) async { + final path = request.requestedUri.path; + String normalizedPath = path; + if (path.startsWith('/api/v2')) { + normalizedPath = path.substring('/api/v2'.length); + } + if (!normalizedPath.startsWith('/')) { + normalizedPath = '/$normalizedPath'; + } + + // Check if this is a telegram-bot endpoint + if (!normalizedPath.startsWith('/telegram-bot/')) { + // Not a telegram-bot endpoint, pass through + return await innerHandler(request); + } + + // Extract API key from header + final apiKey = request.headers['x-api-key']; + final expectedApiKey = Platform.environment['TELEGRAM_BOT_API_KEY']; + + if (expectedApiKey == null || expectedApiKey.isEmpty) { + return Response( + 500, + headers: {'Content-Type': 'application/json'}, + body: '{"error":"Internal Server Error","message":"API key not configured"}', + ); + } + + if (apiKey == null || apiKey != expectedApiKey) { + return Response( + 401, + headers: {'Content-Type': 'application/json'}, + body: '{"error":"Unauthorized","message":"Invalid API key"}', + ); + } + + // API key is valid, proceed with request + return await innerHandler(request); + }; + }; +} diff --git a/mnemo_cards_backend/lib/cron/check_admins.dart b/mnemo_cards_backend/lib/cron/check_admins.dart index 1a5fce8..1da7453 100644 --- a/mnemo_cards_backend/lib/cron/check_admins.dart +++ b/mnemo_cards_backend/lib/cron/check_admins.dart @@ -1,7 +1,6 @@ -import 'dart:io'; - import 'package:isar/isar.dart'; import 'package:mnemo_cards_backend/cron/task.dart'; +import 'package:mnemo_cards_backend/user/admin_ids_service.dart'; import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; import '../main.dart'; @@ -12,15 +11,15 @@ class CheckAdminsTask with Task { @override Future task() async { - final file = File('admins'); - if (!file.existsSync()) { + // Получаем список ID админов из переменной окружения или файла + final adminIdsList = await AdminIdsService.getAdminIds(); + if (adminIdsList.isEmpty) { return; } - final lines = file.readAsLinesSync(); + List adminIds = []; - for (final line in lines) { - final token = line.trim(); - print('$name lines: ${lines.length}'); + for (final token in adminIdsList) { + print('$name processing admin ID: $token'); if (token.isNotEmpty) { await isar.writeTxn(() async { final tokenModel = await isar.tokenModels diff --git a/mnemo_cards_backend/lib/main.dart b/mnemo_cards_backend/lib/main.dart index a1df905..ab244f5 100644 --- a/mnemo_cards_backend/lib/main.dart +++ b/mnemo_cards_backend/lib/main.dart @@ -26,18 +26,15 @@ late Isar isar; late final String WORK_DIR; -void main(List args) async { +void main() async { // WidgetsFlutterBinding.ensureInitialized(); // final dir = (await getApplicationDocumentsDirectory()).path; - final dir = - args.contains('--isar') ? args[args.indexOf('--isar') + 1] : 'isar'; - final backupDir = args.contains('--backup') - ? args[args.indexOf('--backup') + 1] - : '../mnemo_cards_telegram_bot/backups/'; - WORK_DIR = args.contains('--workdir') - ? args[args.indexOf('--workdir') + 1] - : '/root/mnemo_cards_backend'; + // Чтение переменных окружения вместо CLI аргументов + final dir = Platform.environment['ISAR_DIR'] ?? 'isar'; + final backupDir = Platform.environment['BACKUP_DIR'] ?? + '../mnemo_cards_telegram_bot/backups/'; + WORK_DIR = Platform.environment['WORK_DIR'] ?? '/root/mnemo_cards_backend'; print( 'running server in ${(await Process.run('pwd', [], runInShell: true)).stdout}', @@ -62,14 +59,16 @@ void main(List args) async { } // await Isar.initializeIsarCore(download: true); + final debugMode = Platform.environment['DEBUG'] == 'true' || + Platform.environment['DEBUG'] == '1'; isar = await IsarConnector().connect( dir: dir, name: 'db', - inspector: args.contains('--debug'), + inspector: debugMode, ); getIt.registerSingleton(isar); configureDependencies(); - await getIt().initV2(args); + await getIt().initV2(); // ignore: unawaited_futures CronManager([ diff --git a/mnemo_cards_backend/lib/user/admin_ids_service.dart b/mnemo_cards_backend/lib/user/admin_ids_service.dart new file mode 100644 index 0000000..12748f4 --- /dev/null +++ b/mnemo_cards_backend/lib/user/admin_ids_service.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +/// Сервис для получения списка ID админов +/// Поддерживает чтение из переменной окружения ADMIN_IDS или файла 'admins' +class AdminIdsService { + /// Получить список ID админов + /// Сначала проверяет переменную окружения ADMIN_IDS (разделенные запятой), + /// затем файл 'admins' (каждая строка - один ID) + static Future> getAdminIds() async { + // Проверяем переменную окружения + final envAdminIds = Platform.environment['ADMIN_IDS']; + if (envAdminIds != null && envAdminIds.isNotEmpty) { + return envAdminIds + .split(',') + .map((id) => id.trim()) + .where((id) => id.isNotEmpty) + .toList(); + } + return []; + } +} \ No newline at end of file diff --git a/mnemo_cards_backend/public/open_api.yaml b/mnemo_cards_backend/public/open_api.yaml index 8f88e2a..a0df74c 100644 --- a/mnemo_cards_backend/public/open_api.yaml +++ b/mnemo_cards_backend/public/open_api.yaml @@ -129,6 +129,32 @@ paths: responses: 200: description: "Operation completed!" + /ads/product/acquire/: + post: + tags: + - AdsApiV2 + summary: "POST /api/v2/ads/product/acquire/{key}" + description: Confirms rewarded ad completion and grants product access to the user. + operationId: acquireProductForAd + parameters: + - name: key + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /adsgram/reward: + get: + tags: + - AdsApiV2 + summary: "GET /api/v2/adsgram/reward?userId={userId}" + description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad. + operationId: adsgramRewardCallback + responses: + 200: + description: "Operation completed!" /admin/cards: get: tags: @@ -179,32 +205,6 @@ paths: responses: 200: description: "Operation completed!" - /ads/product/acquire/: - post: - tags: - - AdsApiV2 - summary: "POST /api/v2/ads/product/acquire/{key}" - description: Confirms rewarded ad completion and grants product access to the user. - operationId: acquireProductForAd - parameters: - - name: key - in: path - required: true - schema: - type: string - responses: - 200: - description: "Operation completed!" - /adsgram/reward: - get: - tags: - - AdsApiV2 - summary: "GET /api/v2/adsgram/reward?userId={userId}" - description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad. - operationId: adsgramRewardCallback - responses: - 200: - description: "Operation completed!" /subscriptions/plans: get: tags: @@ -816,6 +816,84 @@ paths: responses: 200: description: "Operation completed!" + /tests/: + get: + tags: + - TestsApiV2 + summary: getTest + description: "GET /api/v2/tests/{testId}\nGet test details by ID" + operationId: getTest + parameters: + - name: testId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /tests//results: + post: + tags: + - TestsApiV2 + summary: submitTestResults + description: "POST /api/v2/tests/{testId}/results\nSubmit test results" + operationId: submitTestResults + parameters: + - name: testId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /tests//history: + get: + tags: + - TestsApiV2 + summary: getTestHistory + description: "GET /api/v2/tests/{testId}/history\nGet test attempt history for the authenticated user\nSupports pagination via query params: ?page=1&limit=20" + operationId: getTestHistory + parameters: + - name: testId + in: path + required: true + schema: + type: string + responses: + 200: + description: "Operation completed!" + /admin/analytics/dashboard: + get: + tags: + - AdminAnalyticsApiV2 + summary: getDashboardAnalytics + description: GET /api/v2/admin/analytics/dashboard\nGet dashboard analytics data + operationId: getDashboardAnalytics + responses: + 200: + description: "Operation completed!" + /admin/analytics/users/chart: + get: + tags: + - AdminAnalyticsApiV2 + summary: getUsersChart + description: GET /api/v2/admin/analytics/users/chart\nGet user registration chart data for the last 30 days + operationId: getUsersChart + responses: + 200: + description: "Operation completed!" + /admin/analytics/revenue/chart: + get: + tags: + - AdminAnalyticsApiV2 + summary: getRevenueChart + description: GET /api/v2/admin/analytics/revenue/chart\nGet revenue chart data for the last 30 days + operationId: getRevenueChart + responses: + 200: + description: "Operation completed!" /tasks: get: tags: @@ -888,81 +966,53 @@ paths: responses: 200: description: "Operation completed!" - /admin/analytics/dashboard: + /telegram-bot/random-card: get: tags: - - AdminAnalyticsApiV2 - summary: getDashboardAnalytics - description: GET /api/v2/admin/analytics/dashboard\nGet dashboard analytics data - operationId: getDashboardAnalytics + - TelegramBotApiV2 + summary: getRandomCard + description: "GET /api/v2/telegram-bot/random-card\nGet a random card from all available cards" + operationId: getRandomCard responses: 200: description: "Operation completed!" - /admin/analytics/users/chart: - get: - tags: - - AdminAnalyticsApiV2 - summary: getUsersChart - description: GET /api/v2/admin/analytics/users/chart\nGet user registration chart data for the last 30 days - operationId: getUsersChart - responses: - 200: - description: "Operation completed!" - /admin/analytics/revenue/chart: - get: - tags: - - AdminAnalyticsApiV2 - summary: getRevenueChart - description: GET /api/v2/admin/analytics/revenue/chart\nGet revenue chart data for the last 30 days - operationId: getRevenueChart - responses: - 200: - description: "Operation completed!" - /tests/: - get: - tags: - - TestsApiV2 - summary: getTest - description: "GET /api/v2/tests/{testId}\nGet test details by ID" - operationId: getTest - parameters: - - name: testId - in: path - required: true - schema: - type: string - responses: - 200: - description: "Operation completed!" - /tests//results: + /telegram-bot/share/check-limit: post: tags: - - TestsApiV2 - summary: submitTestResults - description: "POST /api/v2/tests/{testId}/results\nSubmit test results" - operationId: submitTestResults - parameters: - - name: testId - in: path - required: true - schema: - type: string + - TelegramBotApiV2 + summary: checkShareLimit + description: "POST /api/v2/telegram-bot/share/check-limit\nCheck if user can share today (rate limiting)\nBody: { telegramUserId: string, dailyLimit: number }" + operationId: checkShareLimit responses: 200: description: "Operation completed!" - /tests//history: + /telegram-bot/share/record: + post: + tags: + - TelegramBotApiV2 + summary: recordShareRequest + description: "POST /api/v2/telegram-bot/share/record\nRecord a share request for a user\nBody: { telegramUserId: string, telegramUsername?: string, sharedCardId?: number }" + operationId: recordShareRequest + responses: + 200: + description: "Operation completed!" + /telegram-bot/users/info: get: tags: - - TestsApiV2 - summary: getTestHistory - description: "GET /api/v2/tests/{testId}/history\nGet test attempt history for the authenticated user\nSupports pagination via query params: ?page=1&limit=20" - operationId: getTestHistory - parameters: - - name: testId - in: path - required: true - schema: - type: string + - TelegramBotApiV2 + summary: getUsersInfo + description: "GET /api/v2/telegram-bot/users/info\nGet user information (for admin commands)\nQuery: ?userId= for specific user, or no query for all users summary" + operationId: getUsersInfo + responses: + 200: + description: "Operation completed!" + /telegram-bot/words: + get: + tags: + - TelegramBotApiV2 + summary: getWords + description: "GET /api/v2/telegram-bot/words\nGet all words from all cards\nQuery: ?separator= for custom separator (default: comma)" + operationId: getWords responses: 200: description: "Operation completed!" @@ -972,10 +1022,10 @@ tags: description: API v2 endpoints for promocode management and activation. - name: DiscountsApiV2 description: Admin endpoints for discount campaign management. - - name: AdminCardsApiV2 - description: Admin endpoints for card management in API v2. - name: AdsApiV2 description: API v2 endpoints for rewarded ads flows. + - name: AdminCardsApiV2 + description: Admin endpoints for card management in API v2. - name: SubscriptionsApiV2 description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans" - name: UsersApiV2 @@ -994,9 +1044,11 @@ tags: description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments - name: AuthApiV2 description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication - - name: TasksApiV2 - description: API v2 endpoints for user tasks management + - name: TestsApiV2 + description: Tests API v2\n\nRESTful endpoints for managing tests and test results - name: AdminAnalyticsApiV2 description: Admin endpoints for analytics and statistics in API v2. - - name: TestsApiV2 - description: Tests API v2\n\nRESTful endpoints for managing tests and test results \ No newline at end of file + - name: TasksApiV2 + description: API v2 endpoints for user tasks management + - name: TelegramBotApiV2 + description: "API v2 endpoints for Telegram Bot\n\nThese endpoints are authenticated via X-API-Key header\nand provide functionality previously accessed directly via Isar DB" \ No newline at end of file diff --git a/mnemo_cards_telegram_bot/Dockerfile b/mnemo_cards_telegram_bot/Dockerfile index 4649065..5a073bd 100644 --- a/mnemo_cards_telegram_bot/Dockerfile +++ b/mnemo_cards_telegram_bot/Dockerfile @@ -49,7 +49,9 @@ RUN mkdir -p /app/backups /app/zips /app/apks && \ # Environment variables ENV BACKEND_URL=https://api.mnemo-cards.online \ - BOT_SHARE_DAILY_LIMIT=1 + BOT_SHARE_DAILY_LIMIT=1 \ + TELEGRAM_BOT_API_KEY= \ + TELEGRAM_BOT_TOKEN= # No EXPOSE needed - bot uses outbound connections only (polling) diff --git a/mnemo_cards_telegram_bot/bin/db_manager.dart b/mnemo_cards_telegram_bot/bin/db_manager.dart deleted file mode 100644 index 0527df6..0000000 --- a/mnemo_cards_telegram_bot/bin/db_manager.dart +++ /dev/null @@ -1,299 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:math'; - -import 'package:args/args.dart'; -import 'package:isar/isar.dart'; -import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; - -class DBManager { - late Isar isar; - final Function(String message) notify; - // Backend API URL for generating auth codes - final String backendUrl; - - DBManager(this.notify, {this.backendUrl = 'http://localhost:8443'}); - - Future init(ArgResults results) async { - final dir = results.option('isar') ?? '../mnemo_cards_backend/isar/'; - final name = 'db'; - - // Try to get existing instance first - try { - isar = Isar.getInstance(name) ?? - await _connectWithShareModel( - dir: dir, - inspector: results.flag('debug'), - ); - } catch (e) { - // If instance exists but can't be obtained, try to close and reopen - try { - final existing = Isar.getInstance(name); - if (existing != null) { - await existing.close(); - } - } catch (_) { - // Ignore errors when closing - } - isar = await _connectWithShareModel( - dir: dir, - inspector: results.flag('debug'), - ); - } - - var totalUsers = await isar.userModels.count(); - // Removed automatic notification on startup - isar.userModels.watchLazy().listen((_) async { - final totalUsersUpd = await isar.userModels.count(); - if (totalUsersUpd != totalUsers) { - notify('Users: $totalUsers -> $totalUsersUpd!'); - totalUsers = totalUsersUpd; - } - }); - } - - - /// Connect to Isar with ShareRequestModel schema included - Future _connectWithShareModel({ - required String dir, - required bool inspector, - }) async { - await Isar.initializeIsarCore(download: true); - return Isar.open( - [ - CardPackModelSchema, - GameCardModelSchema, - VoiceModelSchema, - UserModelSchema, - UserSubscriptionModelSchema, - SubscriptionPlanModelSchema, - TokenModelSchema, - RefreshTokenModelSchema, - TelegramAuthCodeModelSchema, - TestModelSchema, - TestQuestionModelSchema, - PaymentModelSchema, - TaskModelSchema, - TestStatisticsModelSchema, - UserDataModelSchema, - PromoCodesCampaignModelSchema, - PromoCodeModelSchema, - DiscountCampaignModelSchema, - DiscountModelSchema, - UserTaskModelSchema, - UserTaskProgressModelSchema, - UserTaskResultModelSchema, - ShareRequestModelSchema, - ], - directory: dir, - name: 'db', - inspector: inspector, - ); - } - - Future info() async { - final users = await isar.txn(() => isar.userModels.where().findAll()); - for (final user in users) { - await user.userData.load(); - } - return 'Users (${users.length}):\n' - '${users.map((user) => '${user.email ?? '${user.id} ${user.name}'} ${user.userData.value?.lastTimeOnline?.toShortString() ?? ''}').join('\n')}'; - } - - Future userInfo(String userId) async { - final id = int.tryParse(userId); - final users = await isar.txn( - () => isar.userModels - .filter() - .optional(id != null, (q) => q.idEqualTo(id)) - .or() - .emailContains(userId) - .or() - .nameContains(userId) - .findAll(), - ); - if (users.isEmpty) { - return 'Not found'; - } - if (users.length > 1) { - return 'Found multiple:\n${users.map((u) => u.basicInfo).join('\n')}'; - } - final user = users.first; - await user.packs.load(); - await user.subscriptionModel.load(); - final sub = user.subscriptionModel.value; - return ''' -${user.basicInfo} -Tags: ${user.userData.value?.tags.join(',') ?? ''} -Packs (${user.packs.length}): -${user.packs.map((p) => '${p.id} ${p.title}').join('\n')} -Purchases: ${user.purchases.length} -Subscription ${sub == null ? '' : '(${sub.start} - ${sub.finish})'}: -${sub?.features.join(' ') ?? ''} - ''' - .trim(); - } - - Future> words() async { - final packs = await isar.txn(() => isar.cardPackModels.where().findAll()); - final words = packs - .expand((p) => p.cards.map((c) => c.original)) - .whereType() - .toList(); - return words; - } - - /// Generate a Telegram auth code for a user - /// Calls backend API to generate the code - Future generateAuthCode({ - required String telegramUserId, - String? telegramUsername, - String? firstName, - String? lastName, - }) async { - try { - final client = HttpClient(); - try { - final uri = Uri.parse('$backendUrl/api/v2/auth/telegram/generate-code'); - final request = await client.postUrl(uri); - request.headers.contentType = ContentType.json; - request.write(jsonEncode({ - 'telegramUserId': telegramUserId, - if (telegramUsername != null) 'telegramUsername': telegramUsername, - if (firstName != null) 'firstName': firstName, - if (lastName != null) 'lastName': lastName, - })); - - final response = await request.close(); - final responseBody = await response.transform(utf8.decoder).join(); - - if (response.statusCode != 200) { - print('Failed to generate auth code: ' - '${response.statusCode} - $responseBody'); - return null; - } - - final json = jsonDecode(responseBody) as Map; - return json['code'] as String?; - } finally { - client.close(); - } - } catch (e, s) { - print('Error generating Telegram auth code: $e\n$s'); - return null; - } - } - - /// Claim a web-generated Telegram auth code for a user. - /// Returns true when the backend confirmed the claim. - Future claimWebAuthCode({ - required String code, - required String telegramUserId, - String? telegramUsername, - String? firstName, - String? lastName, - }) async { - try { - final client = HttpClient(); - try { - final uri = Uri.parse('$backendUrl/api/v2/auth/telegram/claim-code'); - final request = await client.postUrl(uri); - request.headers.contentType = ContentType.json; - request.write(jsonEncode({ - 'code': code, - 'telegramUserId': telegramUserId, - if (telegramUsername != null) 'telegramUsername': telegramUsername, - if (firstName != null) 'firstName': firstName, - if (lastName != null) 'lastName': lastName, - })); - - final response = await request.close(); - final responseBody = await response.transform(utf8.decoder).join(); - - if (response.statusCode != 200) { - print('Failed to claim auth code: ' - '${response.statusCode} - $responseBody'); - return false; - } - - return true; - } finally { - client.close(); - } - } catch (e, s) { - print('Error claiming Telegram auth code: $e\n$s'); - return false; - } - } - - /// Check if user can share today (rate limiting) - /// Returns true if user has not exceeded daily limit - Future canShareToday(String telegramUserId, int dailyLimit) async { - try { - final today = DateTime.now(); - final todayStart = DateTime(today.year, today.month, today.day); - final todayEnd = DateTime(today.year, today.month, today.day + 1); // Start of next day - - final sharesCount = await isar.shareRequestModels - .filter() - .telegramUserIdEqualTo(telegramUserId) - .requestedAtBetween(todayStart, todayEnd) - .count(); - - print('[SHARE_LIMIT] User $telegramUserId: $sharesCount shares today (limit: $dailyLimit)'); - return sharesCount < dailyLimit; - } catch (e, s) { - print('Error checking share limit: $e\n$s'); - return true; // Allow on error to prevent blocking users - } - } - - /// Record a share request for a user - Future recordShareRequest({ - required String telegramUserId, - String? telegramUsername, - int? sharedCardId, - }) async { - try { - final request = ShareRequestModel( - telegramUserId: telegramUserId, - telegramUsername: telegramUsername, - requestedAt: DateTime.now(), - sharedCardId: sharedCardId, - ); - await isar.shareRequestModels.put(request); - print('[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId at ${request.requestedAt}'); - } catch (e, s) { - print('Error recording share request: $e\n$s'); - } - } - - /// Get a random card from the database - /// Returns null if no cards are found - Future getRandomCard() async { - try { - final cards = - await isar.gameCardModels.where().findAll(); - if (cards.isEmpty) { - return null; - } - final random = Random(); - return cards[random.nextInt(cards.length)]; - } catch (e, s) { - print('Error getting random card: $e\n$s'); - return null; - } - } - - Future dispose() async { - await isar.close(); - } -} - -extension on UserModel { - String get basicInfo => '$id ${email ?? ''} $name'; -} - -extension on DateTime { - String toShortString() => '$day.$month'; -} diff --git a/mnemo_cards_telegram_bot/bin/main.dart b/mnemo_cards_telegram_bot/bin/main.dart index a2308b7..a09265b 100644 --- a/mnemo_cards_telegram_bot/bin/main.dart +++ b/mnemo_cards_telegram_bot/bin/main.dart @@ -4,21 +4,15 @@ 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:args/args.dart'; import 'package:process_run/process_run.dart'; import 'package:teledart/teledart.dart'; import 'package:teledart/telegram.dart'; -import 'db_manager.dart'; +import 'package:mnemo_cards_telegram_bot/backend_client.dart'; const String version = '0.0.1'; -void printUsage(ArgParser argParser) { - print('Usage: dart mnemo_cards_telegram_bot.dart [arguments]'); - print(argParser.usage); -} - -void main(List arguments) async { - return _run(arguments); +void main() async { + return _run(); } String? _extractCodeFromText(String? rawText) { @@ -63,7 +57,7 @@ String? _extractCodeFromPayload(String payload) { } Future _handleCodeClaim({ - required DBManager dbManager, + required BackendClient backendClient, required TeleDart teledartInstance, required dynamic message, required String code, @@ -73,7 +67,7 @@ Future _handleCodeClaim({ return; } - final claimed = await dbManager.claimWebAuthCode( + final claimed = await backendClient.claimWebAuthCode( code: code, telegramUserId: from.id.toString(), telegramUsername: from.username, @@ -98,26 +92,24 @@ Future _handleCodeClaim({ } } -Future _run(List arguments) async { - final ArgParser argParser = buildParser(); +Future _run() async { log('Starting bot version $version'); - DBManager? dbManager; + BackendClient? backendClient; TeleDart? teledart; try { - final ArgResults results = argParser.parse(arguments); - final config = BotConfig.fromArgs(results); + final config = BotConfig.fromEnvironment(); final lines = File('admins').readAsLinesSync(); final adminIds = lines.map((e) => e.trim()).toList(); if (adminIds.isEmpty) { + log('No admins found in admins file'); return; } - var BOT_TOKEN = '7057032753:AAFP-BCpdry-YuBUOqx1W8dGxiFQdSOJlpI'; - final username = (await Telegram(BOT_TOKEN).getMe()).username; - teledart = TeleDart(BOT_TOKEN, Event(username!)); + final username = (await Telegram(config.botToken).getMe()).username; + teledart = TeleDart(config.botToken, Event(username!)); Future notifyAdmins(String message) async { for (final admin in adminIds) { @@ -131,15 +123,14 @@ Future _run(List arguments) async { log('Using backend API at ${config.backendUrl}'); - dbManager = DBManager( - notifyAdmins, + backendClient = BackendClient( backendUrl: config.backendUrl, + apiKey: config.apiKey, ); - await dbManager.init(results); // At this point all variables are initialized and non-null final teledartInstance = teledart; - final dbManagerInstance = dbManager; + final backendClientInstance = backendClient; teledartInstance.onCommand('logs').listen( (message) async { @@ -167,7 +158,7 @@ Future _run(List arguments) async { final payloadCode = _extractCodeFromText(message.text); if (payloadCode != null) { await _handleCodeClaim( - dbManager: dbManagerInstance, + backendClient: backendClientInstance, teledartInstance: teledartInstance, message: message, code: payloadCode, @@ -176,7 +167,7 @@ Future _run(List arguments) async { } // Generate auth code for the user (legacy flow) - final code = await dbManagerInstance.generateAuthCode( + final code = await backendClientInstance.generateAuthCode( telegramUserId: from.id.toString(), telegramUsername: from.username, firstName: from.firstName, @@ -205,7 +196,7 @@ Future _run(List arguments) async { } // Generate auth code for the user - final code = await dbManagerInstance.generateAuthCode( + final code = await backendClientInstance.generateAuthCode( telegramUserId: from.id.toString(), telegramUsername: from.username, firstName: from.firstName, @@ -235,7 +226,7 @@ Future _run(List arguments) async { } // Generate auth code for the user - final code = await dbManagerInstance.generateAuthCode( + final code = await backendClientInstance.generateAuthCode( telegramUserId: from.id.toString(), telegramUsername: from.username, firstName: from.firstName, @@ -274,7 +265,7 @@ Future _run(List arguments) async { teledartInstance.onCommand('info').listen((message) async { if (adminIds.contains(message.from?.id.toString())) { - final info = await dbManagerInstance.info(); + final info = await backendClientInstance.info(); message.reply(info); } }); @@ -282,7 +273,7 @@ Future _run(List arguments) async { teledartInstance.onCommand('user').listen((message) async { if (adminIds.contains(message.from?.id.toString())) { final userId = message.text?.replaceFirst('/user', '').trim() ?? ''; - final info = await dbManagerInstance.userInfo(userId); + final info = await backendClientInstance.userInfo(userId); message.reply(info); } }); @@ -290,7 +281,7 @@ Future _run(List arguments) async { teledartInstance.onCommand('words').listen((message) async { if (adminIds.contains(message.from?.id.toString())) { final separator = message.text?.replaceFirst('/words', '').trim() ?? ''; - final words = await dbManagerInstance.words(); + final words = await backendClientInstance.words(); message.reply(words.join(separator.isEmpty ? ',\n' : separator)); } }); @@ -464,7 +455,7 @@ Future _run(List arguments) async { print('[SHARE_CMD] User $telegramUserId requested /share command'); // Check rate limit - if (!await dbManagerInstance.canShareToday( + if (!await backendClientInstance.canShareToday( telegramUserId, config.shareDailyLimit, )) { @@ -483,7 +474,7 @@ Future _run(List arguments) async { ); // Get random card - final card = await dbManagerInstance.getRandomCard(); + final card = await backendClientInstance.getRandomCard(); if (card == null) { try { await teledartInstance.editMessageText( @@ -552,7 +543,7 @@ Future _run(List arguments) async { // Record the share request - await dbManagerInstance.recordShareRequest( + await backendClientInstance.recordShareRequest( telegramUserId: telegramUserId, telegramUsername: from.username, sharedCardId: card.id, @@ -578,7 +569,7 @@ Future _run(List arguments) async { final code = _extractCodeFromText(text); if (code != null) { await _handleCodeClaim( - dbManager: dbManagerInstance, + backendClient: backendClientInstance, teledartInstance: teledartInstance, message: message, code: code, @@ -598,22 +589,26 @@ Future _run(List arguments) async { teledartInstance.start(); log('Bot started'); } on Exception catch (e, s) { - print('Error: $e'); - print(''); - printUsage(argParser); + log('Error: $e', error: e, stackTrace: s); print('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 before restart try { - await dbManager?.dispose(); + backendClient?.dispose(); } catch (_) { // Ignore cleanup errors } print('Restarting in 5 seconds...'); await Future.delayed(const Duration(seconds: 5)); - return _run(arguments); + return _run(); } } diff --git a/mnemo_cards_telegram_bot/lib/backend_client.dart b/mnemo_cards_telegram_bot/lib/backend_client.dart new file mode 100644 index 0000000..947396d --- /dev/null +++ b/mnemo_cards_telegram_bot/lib/backend_client.dart @@ -0,0 +1,266 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart'; + +/// HTTP client for backend API calls +/// Replaces direct Isar DB access with API endpoints +class BackendClient { + final String backendUrl; + final String apiKey; + final HttpClient _httpClient; + + BackendClient({ + required this.backendUrl, + required this.apiKey, + }) : _httpClient = HttpClient(); + + /// Make HTTP request with retry logic + Future> _request({ + required String method, + required String path, + Map? body, + int maxRetries = 3, + Duration timeout = const Duration(seconds: 10), + }) async { + Exception? lastException; + + for (int attempt = 0; attempt < maxRetries; attempt++) { + try { + final uri = Uri.parse('$backendUrl/api/v2$path'); + final request = await _httpClient.openUrl(method, uri) + .timeout(timeout); + + request.headers.contentType = ContentType.json; + request.headers.set('X-API-Key', apiKey); + + if (body != null) { + request.write(jsonEncode(body)); + } + + final response = await request.close().timeout(timeout); + final responseBody = await response + .transform(utf8.decoder) + .join() + .timeout(timeout); + + if (response.statusCode >= 200 && response.statusCode < 300) { + if (responseBody.isEmpty) { + return {}; + } + return jsonDecode(responseBody) as Map; + } else { + throw HttpException( + 'HTTP ${response.statusCode}: $responseBody', + uri: uri, + ); + } + } catch (e) { + lastException = e is Exception ? e : Exception(e.toString()); + if (attempt < maxRetries - 1) { + // Exponential backoff: 1s, 2s, 4s + await Future.delayed(Duration(seconds: 1 << attempt)); + } + } + } + + throw lastException ?? Exception('Request failed after $maxRetries attempts'); + } + + /// Generate a Telegram auth code for a user + /// Calls backend API to generate the code + Future generateAuthCode({ + required String telegramUserId, + String? telegramUsername, + String? firstName, + String? lastName, + }) async { + try { + final response = await _request( + method: 'POST', + path: '/auth/telegram/generate-code', + body: { + 'telegramUserId': telegramUserId, + if (telegramUsername != null) 'telegramUsername': telegramUsername, + if (firstName != null) 'firstName': firstName, + if (lastName != null) 'lastName': lastName, + }, + ); + return response['code'] as String?; + } catch (e, s) { + print('Error generating Telegram auth code: $e\n$s'); + return null; + } + } + + /// Claim a web-generated Telegram auth code for a user. + /// Returns true when the backend confirmed the claim. + Future claimWebAuthCode({ + required String code, + required String telegramUserId, + String? telegramUsername, + String? firstName, + String? lastName, + }) async { + try { + await _request( + method: 'POST', + path: '/auth/telegram/claim-code', + body: { + 'code': code, + 'telegramUserId': telegramUserId, + if (telegramUsername != null) 'telegramUsername': telegramUsername, + if (firstName != null) 'firstName': firstName, + if (lastName != null) 'lastName': lastName, + }, + ); + return true; + } catch (e, s) { + print('Error claiming Telegram auth code: $e\n$s'); + return false; + } + } + + /// Check if user can share today (rate limiting) + /// Returns true if user has not exceeded daily limit + Future canShareToday(String telegramUserId, int dailyLimit) async { + try { + final response = await _request( + method: 'POST', + path: '/telegram-bot/share/check-limit', + body: { + 'telegramUserId': telegramUserId, + 'dailyLimit': dailyLimit, + }, + ); + return response['canShare'] as bool? ?? true; + } catch (e, s) { + print('Error checking share limit: $e\n$s'); + return true; // Allow on error to prevent blocking users + } + } + + /// Record a share request for a user + Future recordShareRequest({ + required String telegramUserId, + String? telegramUsername, + int? sharedCardId, + }) async { + try { + await _request( + method: 'POST', + path: '/telegram-bot/share/record', + body: { + 'telegramUserId': telegramUserId, + if (telegramUsername != null) 'telegramUsername': telegramUsername, + if (sharedCardId != null) 'sharedCardId': sharedCardId, + }, + ); + print( + '[SHARE_RECORDED] User $telegramUserId shared card $sharedCardId'); + } catch (e, s) { + print('Error recording share request: $e\n$s'); + } + } + + /// Get a random card from the database + /// Returns null if no cards are found + Future getRandomCard() async { + try { + final response = await _request( + method: 'GET', + path: '/telegram-bot/random-card', + ); + + if (response.isEmpty) { + return null; + } + + // Convert response to GameCardModel + // Note: GameCardModel requires non-nullable fields, so we provide defaults + return GameCardModel( + id: response['id'] as int?, + original: response['original'] as String? ?? '', + translation: response['translation'] as String? ?? '', + mnemo: response['mnemo'] as String? ?? '', + image: response['image'] as String? ?? '', + transcription: response['transcription'] as String?, + transcriptionMnemo: response['transcriptionMnemo'] as String?, + imageBack: response['imageBack'] as String?, + back: response['back'] as String?, + ); + } catch (e, s) { + print('Error getting random card: $e\n$s'); + return null; + } + } + + /// Get user information + Future userInfo(String userId) async { + try { + final response = await _request( + method: 'GET', + path: '/telegram-bot/users/info?userId=$userId', + ); + + if (response['multiple'] == true) { + final users = response['users'] as List; + return 'Found multiple:\n${users.map((u) => '${u['id']} ${u['email'] ?? ''} ${u['name'] ?? ''}').join('\n')}'; + } + + final sub = response['subscription'] as Map?; + final packs = response['packs'] as List?; + + return ''' +${response['id']} ${response['email'] ?? ''} ${response['name'] ?? ''} +Tags: ${response['tags'] ?? ''} +Packs (${packs?.length ?? 0}): +${packs?.map((p) => '${p['id']} ${p['title']}').join('\n') ?? ''} +Purchases: ${response['purchases'] ?? 0} +Subscription ${sub == null ? '' : '(${sub['start']} - ${sub['finish']})'}: +${sub?['features']?.join(' ') ?? ''} + ''' + .trim(); + } catch (e, s) { + print('Error getting user info: $e\n$s'); + return 'Error: $e'; + } + } + + /// Get all users summary + Future info() async { + try { + final response = await _request( + method: 'GET', + path: '/telegram-bot/users/info', + ); + + final users = response['users'] as List; + return 'Users (${response['total']}):\n' + '${users.map((u) => '${u['email'] ?? '${u['id']}'} ${u['lastTimeOnline'] ?? ''}').join('\n')}'; + } catch (e, s) { + print('Error getting users info: $e\n$s'); + return 'Error: $e'; + } + } + + /// Get all words from cards + Future> words() async { + try { + final response = await _request( + method: 'GET', + path: '/telegram-bot/words', + ); + + final wordsList = response['words'] as List?; + return wordsList?.map((w) => w.toString()).toList() ?? []; + } catch (e, s) { + print('Error getting words: $e\n$s'); + return []; + } + } + + void dispose() { + _httpClient.close(force: true); + } +} diff --git a/mnemo_cards_telegram_bot/lib/bot_config.dart b/mnemo_cards_telegram_bot/lib/bot_config.dart index 51b01f5..171d4ba 100644 --- a/mnemo_cards_telegram_bot/lib/bot_config.dart +++ b/mnemo_cards_telegram_bot/lib/bot_config.dart @@ -1,42 +1,28 @@ import 'dart:io'; -import 'package:args/args.dart'; - -ArgParser buildParser() { - return ArgParser() - ..addOption( - 'isar', - ) - ..addOption( - 'backend-url', - help: 'Override backend API base URL (e.g. https://api.example.com)', - ) - ..addFlag('debug'); -} - class BotConfig { BotConfig({ required this.backendUrl, required this.shareDailyLimit, + required this.apiKey, + required this.botToken, }); final String backendUrl; final int shareDailyLimit; + final String apiKey; + final String botToken; static const String defaultBackendUrl = 'https://api.mnemo-cards.online'; static const int defaultShareDailyLimit = 1; - factory BotConfig.fromArgs( - ArgResults results, { + factory BotConfig.fromEnvironment({ Map? environment, }) { final env = environment ?? Platform.environment; - final argValue = results.wasParsed('backend-url') - ? results['backend-url'] as String? - : null; - final resolved = [ - argValue, + // Backend URL: MNEMO_BACKEND_URL or BACKEND_URL or default + final backendUrl = [ env['MNEMO_BACKEND_URL'], env['BACKEND_URL'], defaultBackendUrl, @@ -44,14 +30,30 @@ class BotConfig { (value) => value != null && value.trim().isNotEmpty, )!; + // Share daily limit: BOT_SHARE_DAILY_LIMIT or default final shareDailyLimitStr = env['BOT_SHARE_DAILY_LIMIT']; final shareDailyLimit = shareDailyLimitStr != null ? int.tryParse(shareDailyLimitStr) ?? defaultShareDailyLimit : defaultShareDailyLimit; + // API Key: required + final apiKey = env['TELEGRAM_BOT_API_KEY']; + if (apiKey == null || apiKey.isEmpty) { + throw Exception( + 'TELEGRAM_BOT_API_KEY environment variable is required'); + } + + // Bot Token: required + final botToken = env['TELEGRAM_BOT_TOKEN']; + if (botToken == null || botToken.isEmpty) { + throw Exception('TELEGRAM_BOT_TOKEN environment variable is required'); + } + return BotConfig( - backendUrl: resolved.trim(), + backendUrl: backendUrl.trim(), shareDailyLimit: shareDailyLimit, + apiKey: apiKey, + botToken: botToken, ); } } diff --git a/mnemo_cards_telegram_bot/pubspec.yaml b/mnemo_cards_telegram_bot/pubspec.yaml index 72d1810..65ca696 100644 --- a/mnemo_cards_telegram_bot/pubspec.yaml +++ b/mnemo_cards_telegram_bot/pubspec.yaml @@ -3,26 +3,20 @@ description: A sample command-line application with basic argument parsing. version: 0.0.1+1 publish_to: none # repository: https://github.com/my_org/my_repo -isar_version: &isar_version 3.1.0+1 # define the version to be used - environment: sdk: '>=3.0.0 <4.0.0' # Add regular dependencies here. dependencies: - args: ^2.4.2 - mnemo_cards_common_backend: path: ../mnemo_cards_common_backend teledart: ^0.6.1 archive: ^3.6.0 process_run: ^0.14.2 - isar: *isar_version image: ^4.1.0 json_annotation: ^4.9.0 -# isar_flutter_libs: *isar_version # contains Isar Core dev_dependencies: