env
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

This commit is contained in:
Dmitry 2025-12-11 20:49:15 +03:00
parent 18e4d6838b
commit d377a871fc
18 changed files with 1004 additions and 582 deletions

View file

@ -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"]

View file

@ -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;
}
}

View file

@ -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<void> initV2(List<String> 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<void> 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<AdminUsersApiV2>().router,
getIt.get<DiscountsApiV2>().router,
getIt.get<TasksApiV2>().router,
getIt.get<TelegramBotApiV2>().router,
];
final v2Router = v2Routers.fold<Router>(
@ -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<UserManager>(), getIt.get<JwtService>()))
.addMiddleware(accessMiddleware(

View file

@ -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<List<String>> _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

View file

@ -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)) {

View file

@ -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<String, String> 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<Response> 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<Response> checkShareLimit(Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
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<Response> recordShareRequest(Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
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=<id> for specific user, or no query for all users summary
@Route.get('/telegram-bot/users/info')
@OpenApiRoute()
Future<Response> 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=<string> for custom separator (default: comma)
@Route.get('/telegram-bot/words')
@OpenApiRoute()
Future<Response> 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<String>()
.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);
}

View file

@ -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;
}

View file

@ -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);
};
};
}

View file

@ -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<void> task() async {
final file = File('admins');
if (!file.existsSync()) {
// Получаем список ID админов из переменной окружения или файла
final adminIdsList = await AdminIdsService.getAdminIds();
if (adminIdsList.isEmpty) {
return;
}
final lines = file.readAsLinesSync();
List<int> 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

View file

@ -26,18 +26,15 @@ late Isar isar;
late final String WORK_DIR;
void main(List<String> 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<String> 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>(isar);
configureDependencies();
await getIt<MnemoShelf>().initV2(args);
await getIt<MnemoShelf>().initV2();
// ignore: unawaited_futures
CronManager([

View file

@ -0,0 +1,21 @@
import 'dart:io';
/// Сервис для получения списка ID админов
/// Поддерживает чтение из переменной окружения ADMIN_IDS или файла 'admins'
class AdminIdsService {
/// Получить список ID админов
/// Сначала проверяет переменную окружения ADMIN_IDS (разделенные запятой),
/// затем файл 'admins' (каждая строка - один ID)
static Future<List<String>> 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 [];
}
}

View file

@ -129,6 +129,32 @@ paths:
responses:
200:
description: "Operation completed!"
/ads/product/acquire/<key>:
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/<key>:
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/<testId>:
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/<testId>/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/<testId>/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/<testId>:
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/<testId>/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/<testId>/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=<id> 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=<string> 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: 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
- name: AdminAnalyticsApiV2
description: Admin endpoints for analytics and statistics in API v2.
- 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"

View file

@ -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)

View file

@ -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<void> 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<Isar> _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<String> 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<String> 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<List<String>> words() async {
final packs = await isar.txn(() => isar.cardPackModels.where().findAll());
final words = packs
.expand((p) => p.cards.map((c) => c.original))
.whereType<String>()
.toList();
return words;
}
/// Generate a Telegram auth code for a user
/// Calls backend API to generate the code
Future<String?> 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<String, dynamic>;
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<bool> 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<bool> 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<void> 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<GameCardModel?> 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<void> dispose() async {
await isar.close();
}
}
extension on UserModel {
String get basicInfo => '$id ${email ?? ''} $name';
}
extension on DateTime {
String toShortString() => '$day.$month';
}

View file

@ -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 <flags> [arguments]');
print(argParser.usage);
}
void main(List<String> arguments) async {
return _run(arguments);
void main() async {
return _run();
}
String? _extractCodeFromText(String? rawText) {
@ -63,7 +57,7 @@ String? _extractCodeFromPayload(String payload) {
}
Future<void> _handleCodeClaim({
required DBManager dbManager,
required BackendClient backendClient,
required TeleDart teledartInstance,
required dynamic message,
required String code,
@ -73,7 +67,7 @@ Future<void> _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<void> _handleCodeClaim({
}
}
Future<void> _run(List<String> arguments) async {
final ArgParser argParser = buildParser();
Future<void> _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<void> notifyAdmins(String message) async {
for (final admin in adminIds) {
@ -131,15 +123,14 @@ Future<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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<void> _run(List<String> 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();
}
}

View file

@ -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<Map<String, dynamic>> _request({
required String method,
required String path,
Map<String, dynamic>? 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<String, dynamic>;
} 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<String?> 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<bool> 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<bool> 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<void> 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<GameCardModel?> 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<String> 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<String, dynamic>?;
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<String> 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<List<String>> 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);
}
}

View file

@ -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<String, String>? environment,
}) {
final env = environment ?? Platform.environment;
final argValue = results.wasParsed('backend-url')
? results['backend-url'] as String?
: null;
final resolved = <String?>[
argValue,
// Backend URL: MNEMO_BACKEND_URL or BACKEND_URL or default
final backendUrl = <String?>[
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,
);
}
}

View file

@ -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: