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
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:
parent
18e4d6838b
commit
d377a871fc
18 changed files with 1004 additions and 582 deletions
|
|
@ -45,16 +45,20 @@ RUN mkdir -p /app/isar /app/backups && \
|
||||||
chmod -R 755 /app/isar /app/backups
|
chmod -R 755 /app/isar /app/backups
|
||||||
|
|
||||||
ENV PORT=3000 \
|
ENV PORT=3000 \
|
||||||
ADDRESS=0.0.0.0 \
|
SERVER_ADDRESS=0.0.0.0 \
|
||||||
ISAR_DIR=/app/isar \
|
ISAR_DIR=/app/isar \
|
||||||
BACKUP_DIR=/app/backups \
|
BACKUP_DIR=/app/backups \
|
||||||
APP_WORKDIR=/app
|
WORK_DIR=/app \
|
||||||
|
DEBUG=false \
|
||||||
|
CERTS_PATH= \
|
||||||
|
DUAL_MODE=false \
|
||||||
|
ADMIN_IDS=
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Healthcheck - uses PORT env variable
|
# Healthcheck - проверка доступности API
|
||||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=5 \
|
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)
|
# Start server - все настройки читаются из environment variables
|
||||||
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}"
|
CMD ["/app/server"]
|
||||||
|
|
|
||||||
|
|
@ -13,42 +13,43 @@ import 'package:injectable/injectable.dart' as _i2;
|
||||||
import 'package:isar/isar.dart' as _i4;
|
import 'package:isar/isar.dart' as _i4;
|
||||||
|
|
||||||
import '../../auth/telegram_auth_code_service.dart' as _i20;
|
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 '../../discounts/discounts_manager.dart' as _i8;
|
||||||
import '../../packs/free_packs_distributor.dart' as _i9;
|
import '../../packs/free_packs_distributor.dart' as _i9;
|
||||||
import '../../packs/pack_dto_converter.dart' as _i26;
|
import '../../packs/pack_dto_converter.dart' as _i27;
|
||||||
import '../../packs/pack_manager.dart' as _i27;
|
import '../../packs/pack_manager.dart' as _i28;
|
||||||
import '../../packs/products_price_resolver.dart' as _i13;
|
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/achievement_manager.dart' as _i3;
|
||||||
import '../../statistics/session_tracker.dart' as _i15;
|
import '../../statistics/session_tracker.dart' as _i15;
|
||||||
import '../../statistics/statistics_calculator.dart' as _i16;
|
import '../../statistics/statistics_calculator.dart' as _i16;
|
||||||
import '../../tests/test_manager.dart' as _i28;
|
import '../../tests/test_manager.dart' as _i29;
|
||||||
import '../../user/user_manager.dart' as _i21;
|
import '../../user/user_manager.dart' as _i22;
|
||||||
import '../ads/ads_manager.dart' as _i7;
|
import '../ads/ads_manager.dart' as _i7;
|
||||||
import '../mnemo_shelf.dart' as _i25;
|
import '../mnemo_shelf.dart' as _i26;
|
||||||
import '../purchase/payment_manager.dart' as _i33;
|
import '../purchase/payment_manager.dart' as _i34;
|
||||||
import '../purchase/rustore/rustore_purchase_handler.dart' as _i14;
|
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 '../subscription/subscription_manager.dart' as _i17;
|
||||||
import '../user/google_api.dart' as _i11;
|
import '../user/google_api.dart' as _i11;
|
||||||
import '../v2/admin_analytics_api_v2.dart' as _i5;
|
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_cards_api_v2.dart' as _i6;
|
||||||
import '../v2/admin_packs_api_v2.dart' as _i31;
|
import '../v2/admin_packs_api_v2.dart' as _i32;
|
||||||
import '../v2/admin_users_api_v2.dart' as _i38;
|
import '../v2/admin_users_api_v2.dart' as _i39;
|
||||||
import '../v2/ads_api_v2.dart' as _i39;
|
import '../v2/ads_api_v2.dart' as _i40;
|
||||||
import '../v2/auth_api_v2.dart' as _i23;
|
import '../v2/auth_api_v2.dart' as _i24;
|
||||||
import '../v2/discounts_api_v2.dart' as _i24;
|
import '../v2/discounts_api_v2.dart' as _i25;
|
||||||
import '../v2/games_api_v2.dart' as _i10;
|
import '../v2/games_api_v2.dart' as _i10;
|
||||||
import '../v2/jwt_service.dart' as _i12;
|
import '../v2/jwt_service.dart' as _i12;
|
||||||
import '../v2/packs_api_v2.dart' as _i32;
|
import '../v2/packs_api_v2.dart' as _i33;
|
||||||
import '../v2/promocodes_api_v2.dart' as _i35;
|
import '../v2/promocodes_api_v2.dart' as _i36;
|
||||||
import '../v2/purchases_api_v2.dart' as _i36;
|
import '../v2/purchases_api_v2.dart' as _i37;
|
||||||
import '../v2/subscriptions_api_v2.dart' as _i18;
|
import '../v2/subscriptions_api_v2.dart' as _i18;
|
||||||
import '../v2/tasks_api_v2.dart' as _i19;
|
import '../v2/tasks_api_v2.dart' as _i19;
|
||||||
import '../v2/tests_api_v2.dart' as _i29;
|
import '../v2/telegram_bot_api_v2.dart' as _i21;
|
||||||
import '../v2/users_api_v2.dart' as _i37;
|
import '../v2/tests_api_v2.dart' as _i30;
|
||||||
|
import '../v2/users_api_v2.dart' as _i38;
|
||||||
|
|
||||||
extension GetItInjectableX on _i1.GetIt {
|
extension GetItInjectableX on _i1.GetIt {
|
||||||
// initializes the registration of main-scope dependencies inside of 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<_i19.TasksApiV2>(() => const _i19.TasksApiV2());
|
||||||
gh.lazySingleton<_i20.TelegramAuthCodeService>(
|
gh.lazySingleton<_i20.TelegramAuthCodeService>(
|
||||||
() => _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<_i9.FreePacksDistributor>(),
|
||||||
gh<_i15.SessionTracker>(),
|
gh<_i15.SessionTracker>(),
|
||||||
gh<_i16.StatisticsCalculator>(),
|
gh<_i16.StatisticsCalculator>(),
|
||||||
gh<_i3.AchievementManager>(),
|
gh<_i3.AchievementManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i22.AdminAuthApiV2>(
|
gh.lazySingleton<_i23.AdminAuthApiV2>(
|
||||||
() => _i22.AdminAuthApiV2(gh<_i20.TelegramAuthCodeService>()));
|
() => _i23.AdminAuthApiV2(gh<_i20.TelegramAuthCodeService>()));
|
||||||
gh.lazySingleton<_i23.AuthApiV2>(() => _i23.AuthApiV2(
|
gh.lazySingleton<_i24.AuthApiV2>(() => _i24.AuthApiV2(
|
||||||
gh<_i21.UserManager>(),
|
gh<_i22.UserManager>(),
|
||||||
gh<_i11.GoogleApi>(),
|
gh<_i11.GoogleApi>(),
|
||||||
gh<_i12.JwtService>(),
|
gh<_i12.JwtService>(),
|
||||||
gh<_i20.TelegramAuthCodeService>(),
|
gh<_i20.TelegramAuthCodeService>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i24.DiscountsApiV2>(
|
gh.lazySingleton<_i25.DiscountsApiV2>(
|
||||||
() => _i24.DiscountsApiV2(gh<_i8.DiscountsManager>()));
|
() => _i25.DiscountsApiV2(gh<_i8.DiscountsManager>()));
|
||||||
gh.lazySingleton<_i25.MnemoShelf>(
|
gh.lazySingleton<_i26.MnemoShelf>(
|
||||||
() => _i25.MnemoShelf(gh<_i21.UserManager>()));
|
() => _i26.MnemoShelf(gh<_i22.UserManager>()));
|
||||||
gh.lazySingleton<_i26.PackDtoConverter>(() => _i26.PackDtoConverter(
|
gh.lazySingleton<_i27.PackDtoConverter>(() => _i27.PackDtoConverter(
|
||||||
gh<_i13.ProductsPriceResolver>(),
|
gh<_i13.ProductsPriceResolver>(),
|
||||||
gh<_i7.AdsManager>(),
|
gh<_i7.AdsManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i27.PackManager>(
|
gh.lazySingleton<_i28.PackManager>(
|
||||||
() => _i27.PackManager(gh<_i26.PackDtoConverter>()));
|
() => _i28.PackManager(gh<_i27.PackDtoConverter>()));
|
||||||
gh.lazySingleton<_i28.TestManager>(
|
gh.lazySingleton<_i29.TestManager>(
|
||||||
() => _i28.TestManager(gh<_i26.PackDtoConverter>()));
|
() => _i29.TestManager(gh<_i27.PackDtoConverter>()));
|
||||||
gh.lazySingleton<_i29.TestsApiV2>(() => _i29.TestsApiV2(
|
gh.lazySingleton<_i30.TestsApiV2>(() => _i30.TestsApiV2(
|
||||||
gh<_i28.TestManager>(),
|
gh<_i29.TestManager>(),
|
||||||
gh<_i21.UserManager>(),
|
gh<_i22.UserManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i30.YooMoneyHandler>(
|
gh.lazySingleton<_i31.YooMoneyHandler>(
|
||||||
() => _i30.YooMoneyHandler(gh<_i27.PackManager>()));
|
() => _i31.YooMoneyHandler(gh<_i28.PackManager>()));
|
||||||
gh.lazySingleton<_i31.AdminPacksApiV2>(() => _i31.AdminPacksApiV2(
|
gh.lazySingleton<_i32.AdminPacksApiV2>(() => _i32.AdminPacksApiV2(
|
||||||
gh<_i27.PackManager>(),
|
gh<_i28.PackManager>(),
|
||||||
gh<_i26.PackDtoConverter>(),
|
gh<_i27.PackDtoConverter>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i32.PacksApiV2>(() => _i32.PacksApiV2(
|
gh.lazySingleton<_i33.PacksApiV2>(() => _i33.PacksApiV2(
|
||||||
gh<_i27.PackManager>(),
|
gh<_i28.PackManager>(),
|
||||||
gh<_i28.TestManager>(),
|
gh<_i29.TestManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i33.PaymentManager>(() => _i33.PaymentManager(
|
gh.lazySingleton<_i34.PaymentManager>(() => _i34.PaymentManager(
|
||||||
gh<_i27.PackManager>(),
|
gh<_i28.PackManager>(),
|
||||||
gh<_i17.SubscriptionManager>(),
|
gh<_i17.SubscriptionManager>(),
|
||||||
gh<_i30.YooMoneyHandler>(),
|
gh<_i31.YooMoneyHandler>(),
|
||||||
gh<_i14.RustorePurchaseHandler>(),
|
gh<_i14.RustorePurchaseHandler>(),
|
||||||
gh<_i13.ProductsPriceResolver>(),
|
gh<_i13.ProductsPriceResolver>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i34.PromoCodesManager>(
|
gh.lazySingleton<_i35.PromoCodesManager>(
|
||||||
() => _i34.PromoCodesManager(gh<_i33.PaymentManager>()));
|
() => _i35.PromoCodesManager(gh<_i34.PaymentManager>()));
|
||||||
gh.lazySingleton<_i35.PromocodesApiV2>(
|
gh.lazySingleton<_i36.PromocodesApiV2>(
|
||||||
() => _i35.PromocodesApiV2(gh<_i34.PromoCodesManager>()));
|
() => _i36.PromocodesApiV2(gh<_i35.PromoCodesManager>()));
|
||||||
gh.lazySingleton<_i36.PurchasesApiV2>(() => _i36.PurchasesApiV2(
|
gh.lazySingleton<_i37.PurchasesApiV2>(() => _i37.PurchasesApiV2(
|
||||||
gh<_i33.PaymentManager>(),
|
gh<_i34.PaymentManager>(),
|
||||||
gh<_i27.PackManager>(),
|
gh<_i28.PackManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i37.UsersApiV2>(() => _i37.UsersApiV2(
|
gh.lazySingleton<_i38.UsersApiV2>(() => _i38.UsersApiV2(
|
||||||
gh<_i21.UserManager>(),
|
gh<_i22.UserManager>(),
|
||||||
gh<_i33.PaymentManager>(),
|
gh<_i34.PaymentManager>(),
|
||||||
gh<_i16.StatisticsCalculator>(),
|
gh<_i16.StatisticsCalculator>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i38.AdminUsersApiV2>(() => _i38.AdminUsersApiV2(
|
gh.lazySingleton<_i39.AdminUsersApiV2>(() => _i39.AdminUsersApiV2(
|
||||||
gh<_i21.UserManager>(),
|
gh<_i22.UserManager>(),
|
||||||
gh<_i33.PaymentManager>(),
|
gh<_i34.PaymentManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i39.AdsApiV2>(() => _i39.AdsApiV2(
|
gh.lazySingleton<_i40.AdsApiV2>(() => _i40.AdsApiV2(
|
||||||
gh<_i7.AdsManager>(),
|
gh<_i7.AdsManager>(),
|
||||||
gh<_i33.PaymentManager>(),
|
gh<_i34.PaymentManager>(),
|
||||||
));
|
));
|
||||||
gh.lazySingleton<_i40.CheckPaymentTask>(
|
gh.lazySingleton<_i41.CheckPaymentTask>(
|
||||||
() => _i40.CheckPaymentTask(gh<_i33.PaymentManager>()));
|
() => _i41.CheckPaymentTask(gh<_i34.PaymentManager>()));
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ import 'v2/subscriptions_api_v2.dart';
|
||||||
import 'v2/tasks_api_v2.dart';
|
import 'v2/tasks_api_v2.dart';
|
||||||
import 'v2/tests_api_v2.dart';
|
import 'v2/tests_api_v2.dart';
|
||||||
import 'v2/users_api_v2.dart';
|
import 'v2/users_api_v2.dart';
|
||||||
|
import 'v2/telegram_bot_api_v2.dart';
|
||||||
import 'v2/authorize_v2.dart';
|
import 'v2/authorize_v2.dart';
|
||||||
|
import 'v2/telegram_bot_auth_middleware.dart';
|
||||||
import 'v2/jwt_service.dart';
|
import 'v2/jwt_service.dart';
|
||||||
import 'authorize/access_middleware.dart';
|
import 'authorize/access_middleware.dart';
|
||||||
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||||
|
|
@ -35,18 +37,15 @@ class MnemoShelf {
|
||||||
|
|
||||||
MnemoShelf(this.userManager);
|
MnemoShelf(this.userManager);
|
||||||
|
|
||||||
Future<void> initV2(List<String> args) async {
|
Future<void> initV2() async {
|
||||||
final addressArg =
|
// Чтение переменных окружения вместо CLI аргументов
|
||||||
args.indexOf('-a') == -1 ? null : args[args.indexOf('-a') + 1];
|
final addressArg = Platform.environment['SERVER_ADDRESS'];
|
||||||
final portArg =
|
final portArg = Platform.environment['PORT'];
|
||||||
args.indexOf('-p') == -1 ? null : args[args.indexOf('-p') + 1];
|
|
||||||
final address = addressArg ?? InternetAddress.anyIPv4.address;
|
final address = addressArg ?? InternetAddress.anyIPv4.address;
|
||||||
final port = int.tryParse(portArg ?? '') ?? 8443;
|
final port = int.tryParse(portArg ?? '') ?? 3000;
|
||||||
final certs = args.contains('--certs')
|
final certs = Platform.environment['CERTS_PATH']; // null = HTTP without SSL
|
||||||
? args[args.indexOf('--certs') + 1]
|
final dualMode = Platform.environment['DUAL_MODE'] == 'true' ||
|
||||||
: null; // null = HTTP without SSL
|
Platform.environment['DUAL_MODE'] == '1'; // Запуск HTTP и HTTPS одновременно
|
||||||
final dualMode =
|
|
||||||
args.contains('--dual'); // Запуск HTTP и HTTPS одновременно
|
|
||||||
|
|
||||||
// V2 APIs (new RESTful API with OAuth2/JWT)
|
// V2 APIs (new RESTful API with OAuth2/JWT)
|
||||||
final v2Routers = [
|
final v2Routers = [
|
||||||
|
|
@ -66,6 +65,7 @@ class MnemoShelf {
|
||||||
getIt.get<AdminUsersApiV2>().router,
|
getIt.get<AdminUsersApiV2>().router,
|
||||||
getIt.get<DiscountsApiV2>().router,
|
getIt.get<DiscountsApiV2>().router,
|
||||||
getIt.get<TasksApiV2>().router,
|
getIt.get<TasksApiV2>().router,
|
||||||
|
getIt.get<TelegramBotApiV2>().router,
|
||||||
];
|
];
|
||||||
|
|
||||||
final v2Router = v2Routers.fold<Router>(
|
final v2Router = v2Routers.fold<Router>(
|
||||||
|
|
@ -104,7 +104,7 @@ class MnemoShelf {
|
||||||
'Access-Control-Allow-Methods':
|
'Access-Control-Allow-Methods':
|
||||||
'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS',
|
'GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS',
|
||||||
'Access-Control-Allow-Headers':
|
'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-Expose-Headers': 'Authorization',
|
||||||
'Access-Control-Max-Age': '86400',
|
'Access-Control-Max-Age': '86400',
|
||||||
};
|
};
|
||||||
|
|
@ -137,6 +137,7 @@ class MnemoShelf {
|
||||||
final v2Handler = Pipeline()
|
final v2Handler = Pipeline()
|
||||||
.addMiddleware(corsMiddleware())
|
.addMiddleware(corsMiddleware())
|
||||||
.addMiddleware(logRequests(logger: logger('v2')))
|
.addMiddleware(logRequests(logger: logger('v2')))
|
||||||
|
.addMiddleware(telegramBotAuthMiddleware())
|
||||||
.addMiddleware(
|
.addMiddleware(
|
||||||
authorizeV2(getIt.get<UserManager>(), getIt.get<JwtService>()))
|
authorizeV2(getIt.get<UserManager>(), getIt.get<JwtService>()))
|
||||||
.addMiddleware(accessMiddleware(
|
.addMiddleware(accessMiddleware(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:mnemo_cards_backend/api/authorize/helpers.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/auth/telegram_auth_code_service.dart';
|
||||||
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
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_backend/user/telegram.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
import 'package:shelf/shelf.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 {
|
Future<List<String>> _getAdminIds() async {
|
||||||
try {
|
return AdminIdsService.getAdminIds();
|
||||||
// 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 [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate JWT token for admin user
|
/// Generate JWT token for admin user
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,11 @@ Middleware authorizeV2(UserManager userManager, JwtService jwtService) {
|
||||||
normalizedPath = '/$normalizedPath';
|
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
|
// Check if this is a public auth endpoint - always allow for all HTTP methods
|
||||||
if (normalizedPath.startsWith('/auth/')) {
|
if (normalizedPath.startsWith('/auth/')) {
|
||||||
if (_publicAuthPaths.contains(normalizedPath)) {
|
if (_publicAuthPaths.contains(normalizedPath)) {
|
||||||
|
|
|
||||||
309
mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart
Normal file
309
mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.dart
Normal 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);
|
||||||
|
}
|
||||||
37
mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.g.dart
Normal file
37
mnemo_cards_backend/lib/api/v2/telegram_bot_api_v2.g.dart
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
import 'package:mnemo_cards_backend/cron/task.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 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
|
|
@ -12,15 +11,15 @@ class CheckAdminsTask with Task {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> task() async {
|
Future<void> task() async {
|
||||||
final file = File('admins');
|
// Получаем список ID админов из переменной окружения или файла
|
||||||
if (!file.existsSync()) {
|
final adminIdsList = await AdminIdsService.getAdminIds();
|
||||||
|
if (adminIdsList.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final lines = file.readAsLinesSync();
|
|
||||||
List<int> adminIds = [];
|
List<int> adminIds = [];
|
||||||
for (final line in lines) {
|
for (final token in adminIdsList) {
|
||||||
final token = line.trim();
|
print('$name processing admin ID: $token');
|
||||||
print('$name lines: ${lines.length}');
|
|
||||||
if (token.isNotEmpty) {
|
if (token.isNotEmpty) {
|
||||||
await isar.writeTxn(() async {
|
await isar.writeTxn(() async {
|
||||||
final tokenModel = await isar.tokenModels
|
final tokenModel = await isar.tokenModels
|
||||||
|
|
|
||||||
|
|
@ -26,18 +26,15 @@ late Isar isar;
|
||||||
|
|
||||||
late final String WORK_DIR;
|
late final String WORK_DIR;
|
||||||
|
|
||||||
void main(List<String> args) async {
|
void main() async {
|
||||||
// WidgetsFlutterBinding.ensureInitialized();
|
// WidgetsFlutterBinding.ensureInitialized();
|
||||||
// final dir = (await getApplicationDocumentsDirectory()).path;
|
// final dir = (await getApplicationDocumentsDirectory()).path;
|
||||||
|
|
||||||
final dir =
|
// Чтение переменных окружения вместо CLI аргументов
|
||||||
args.contains('--isar') ? args[args.indexOf('--isar') + 1] : 'isar';
|
final dir = Platform.environment['ISAR_DIR'] ?? 'isar';
|
||||||
final backupDir = args.contains('--backup')
|
final backupDir = Platform.environment['BACKUP_DIR'] ??
|
||||||
? args[args.indexOf('--backup') + 1]
|
'../mnemo_cards_telegram_bot/backups/';
|
||||||
: '../mnemo_cards_telegram_bot/backups/';
|
WORK_DIR = Platform.environment['WORK_DIR'] ?? '/root/mnemo_cards_backend';
|
||||||
WORK_DIR = args.contains('--workdir')
|
|
||||||
? args[args.indexOf('--workdir') + 1]
|
|
||||||
: '/root/mnemo_cards_backend';
|
|
||||||
|
|
||||||
print(
|
print(
|
||||||
'running server in ${(await Process.run('pwd', [], runInShell: true)).stdout}',
|
'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);
|
// await Isar.initializeIsarCore(download: true);
|
||||||
|
final debugMode = Platform.environment['DEBUG'] == 'true' ||
|
||||||
|
Platform.environment['DEBUG'] == '1';
|
||||||
isar = await IsarConnector().connect(
|
isar = await IsarConnector().connect(
|
||||||
dir: dir,
|
dir: dir,
|
||||||
name: 'db',
|
name: 'db',
|
||||||
inspector: args.contains('--debug'),
|
inspector: debugMode,
|
||||||
);
|
);
|
||||||
getIt.registerSingleton<Isar>(isar);
|
getIt.registerSingleton<Isar>(isar);
|
||||||
configureDependencies();
|
configureDependencies();
|
||||||
await getIt<MnemoShelf>().initV2(args);
|
await getIt<MnemoShelf>().initV2();
|
||||||
|
|
||||||
// ignore: unawaited_futures
|
// ignore: unawaited_futures
|
||||||
CronManager([
|
CronManager([
|
||||||
|
|
|
||||||
21
mnemo_cards_backend/lib/user/admin_ids_service.dart
Normal file
21
mnemo_cards_backend/lib/user/admin_ids_service.dart
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,32 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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:
|
/admin/cards:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -179,32 +205,6 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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:
|
/subscriptions/plans:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -816,6 +816,84 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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:
|
/tasks:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -888,81 +966,53 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
description: "Operation completed!"
|
||||||
/admin/analytics/dashboard:
|
/telegram-bot/random-card:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- AdminAnalyticsApiV2
|
- TelegramBotApiV2
|
||||||
summary: getDashboardAnalytics
|
summary: getRandomCard
|
||||||
description: GET /api/v2/admin/analytics/dashboard\nGet dashboard analytics data
|
description: "GET /api/v2/telegram-bot/random-card\nGet a random card from all available cards"
|
||||||
operationId: getDashboardAnalytics
|
operationId: getRandomCard
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
description: "Operation completed!"
|
||||||
/admin/analytics/users/chart:
|
/telegram-bot/share/check-limit:
|
||||||
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:
|
|
||||||
post:
|
post:
|
||||||
tags:
|
tags:
|
||||||
- TestsApiV2
|
- TelegramBotApiV2
|
||||||
summary: submitTestResults
|
summary: checkShareLimit
|
||||||
description: "POST /api/v2/tests/{testId}/results\nSubmit test results"
|
description: "POST /api/v2/telegram-bot/share/check-limit\nCheck if user can share today (rate limiting)\nBody: { telegramUserId: string, dailyLimit: number }"
|
||||||
operationId: submitTestResults
|
operationId: checkShareLimit
|
||||||
parameters:
|
|
||||||
- name: testId
|
|
||||||
in: path
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
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:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- TestsApiV2
|
- TelegramBotApiV2
|
||||||
summary: getTestHistory
|
summary: getUsersInfo
|
||||||
description: "GET /api/v2/tests/{testId}/history\nGet test attempt history for the authenticated user\nSupports pagination via query params: ?page=1&limit=20"
|
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: getTestHistory
|
operationId: getUsersInfo
|
||||||
parameters:
|
responses:
|
||||||
- name: testId
|
200:
|
||||||
in: path
|
description: "Operation completed!"
|
||||||
required: true
|
/telegram-bot/words:
|
||||||
schema:
|
get:
|
||||||
type: string
|
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:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Operation completed!"
|
description: "Operation completed!"
|
||||||
|
|
@ -972,10 +1022,10 @@ tags:
|
||||||
description: API v2 endpoints for promocode management and activation.
|
description: API v2 endpoints for promocode management and activation.
|
||||||
- name: DiscountsApiV2
|
- name: DiscountsApiV2
|
||||||
description: Admin endpoints for discount campaign management.
|
description: Admin endpoints for discount campaign management.
|
||||||
- name: AdminCardsApiV2
|
|
||||||
description: Admin endpoints for card management in API v2.
|
|
||||||
- name: AdsApiV2
|
- name: AdsApiV2
|
||||||
description: API v2 endpoints for rewarded ads flows.
|
description: API v2 endpoints for rewarded ads flows.
|
||||||
|
- name: AdminCardsApiV2
|
||||||
|
description: Admin endpoints for card management in API v2.
|
||||||
- name: SubscriptionsApiV2
|
- name: SubscriptionsApiV2
|
||||||
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
|
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
|
||||||
- name: UsersApiV2
|
- name: UsersApiV2
|
||||||
|
|
@ -994,9 +1044,11 @@ tags:
|
||||||
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
|
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
|
||||||
- name: AuthApiV2
|
- name: AuthApiV2
|
||||||
description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication
|
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
|
- name: TestsApiV2
|
||||||
description: Tests API v2\n\nRESTful endpoints for managing tests and test results
|
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"
|
||||||
|
|
@ -49,7 +49,9 @@ RUN mkdir -p /app/backups /app/zips /app/apks && \
|
||||||
|
|
||||||
# Environment variables
|
# Environment variables
|
||||||
ENV BACKEND_URL=https://api.mnemo-cards.online \
|
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)
|
# No EXPOSE needed - bot uses outbound connections only (polling)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
}
|
|
||||||
|
|
@ -4,21 +4,15 @@ import 'package:path/path.dart';
|
||||||
import 'package:mnemo_cards_telegram_bot/bot_config.dart';
|
import 'package:mnemo_cards_telegram_bot/bot_config.dart';
|
||||||
import 'package:mnemo_cards_telegram_bot/image_generator.dart';
|
import 'package:mnemo_cards_telegram_bot/image_generator.dart';
|
||||||
|
|
||||||
import 'package:args/args.dart';
|
|
||||||
import 'package:process_run/process_run.dart';
|
import 'package:process_run/process_run.dart';
|
||||||
import 'package:teledart/teledart.dart';
|
import 'package:teledart/teledart.dart';
|
||||||
import 'package:teledart/telegram.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';
|
const String version = '0.0.1';
|
||||||
|
|
||||||
void printUsage(ArgParser argParser) {
|
void main() async {
|
||||||
print('Usage: dart mnemo_cards_telegram_bot.dart <flags> [arguments]');
|
return _run();
|
||||||
print(argParser.usage);
|
|
||||||
}
|
|
||||||
|
|
||||||
void main(List<String> arguments) async {
|
|
||||||
return _run(arguments);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _extractCodeFromText(String? rawText) {
|
String? _extractCodeFromText(String? rawText) {
|
||||||
|
|
@ -63,7 +57,7 @@ String? _extractCodeFromPayload(String payload) {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleCodeClaim({
|
Future<void> _handleCodeClaim({
|
||||||
required DBManager dbManager,
|
required BackendClient backendClient,
|
||||||
required TeleDart teledartInstance,
|
required TeleDart teledartInstance,
|
||||||
required dynamic message,
|
required dynamic message,
|
||||||
required String code,
|
required String code,
|
||||||
|
|
@ -73,7 +67,7 @@ Future<void> _handleCodeClaim({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final claimed = await dbManager.claimWebAuthCode(
|
final claimed = await backendClient.claimWebAuthCode(
|
||||||
code: code,
|
code: code,
|
||||||
telegramUserId: from.id.toString(),
|
telegramUserId: from.id.toString(),
|
||||||
telegramUsername: from.username,
|
telegramUsername: from.username,
|
||||||
|
|
@ -98,26 +92,24 @@ Future<void> _handleCodeClaim({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _run(List<String> arguments) async {
|
Future<void> _run() async {
|
||||||
final ArgParser argParser = buildParser();
|
|
||||||
log('Starting bot version $version');
|
log('Starting bot version $version');
|
||||||
DBManager? dbManager;
|
BackendClient? backendClient;
|
||||||
TeleDart? teledart;
|
TeleDart? teledart;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final ArgResults results = argParser.parse(arguments);
|
final config = BotConfig.fromEnvironment();
|
||||||
final config = BotConfig.fromArgs(results);
|
|
||||||
|
|
||||||
final lines = File('admins').readAsLinesSync();
|
final lines = File('admins').readAsLinesSync();
|
||||||
|
|
||||||
final adminIds = lines.map((e) => e.trim()).toList();
|
final adminIds = lines.map((e) => e.trim()).toList();
|
||||||
if (adminIds.isEmpty) {
|
if (adminIds.isEmpty) {
|
||||||
|
log('No admins found in admins file');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var BOT_TOKEN = '7057032753:AAFP-BCpdry-YuBUOqx1W8dGxiFQdSOJlpI';
|
final username = (await Telegram(config.botToken).getMe()).username;
|
||||||
final username = (await Telegram(BOT_TOKEN).getMe()).username;
|
teledart = TeleDart(config.botToken, Event(username!));
|
||||||
teledart = TeleDart(BOT_TOKEN, Event(username!));
|
|
||||||
|
|
||||||
Future<void> notifyAdmins(String message) async {
|
Future<void> notifyAdmins(String message) async {
|
||||||
for (final admin in adminIds) {
|
for (final admin in adminIds) {
|
||||||
|
|
@ -131,15 +123,14 @@ Future<void> _run(List<String> arguments) async {
|
||||||
|
|
||||||
log('Using backend API at ${config.backendUrl}');
|
log('Using backend API at ${config.backendUrl}');
|
||||||
|
|
||||||
dbManager = DBManager(
|
backendClient = BackendClient(
|
||||||
notifyAdmins,
|
|
||||||
backendUrl: config.backendUrl,
|
backendUrl: config.backendUrl,
|
||||||
|
apiKey: config.apiKey,
|
||||||
);
|
);
|
||||||
await dbManager.init(results);
|
|
||||||
|
|
||||||
// At this point all variables are initialized and non-null
|
// At this point all variables are initialized and non-null
|
||||||
final teledartInstance = teledart;
|
final teledartInstance = teledart;
|
||||||
final dbManagerInstance = dbManager;
|
final backendClientInstance = backendClient;
|
||||||
|
|
||||||
teledartInstance.onCommand('logs').listen(
|
teledartInstance.onCommand('logs').listen(
|
||||||
(message) async {
|
(message) async {
|
||||||
|
|
@ -167,7 +158,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
final payloadCode = _extractCodeFromText(message.text);
|
final payloadCode = _extractCodeFromText(message.text);
|
||||||
if (payloadCode != null) {
|
if (payloadCode != null) {
|
||||||
await _handleCodeClaim(
|
await _handleCodeClaim(
|
||||||
dbManager: dbManagerInstance,
|
backendClient: backendClientInstance,
|
||||||
teledartInstance: teledartInstance,
|
teledartInstance: teledartInstance,
|
||||||
message: message,
|
message: message,
|
||||||
code: payloadCode,
|
code: payloadCode,
|
||||||
|
|
@ -176,7 +167,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate auth code for the user (legacy flow)
|
// Generate auth code for the user (legacy flow)
|
||||||
final code = await dbManagerInstance.generateAuthCode(
|
final code = await backendClientInstance.generateAuthCode(
|
||||||
telegramUserId: from.id.toString(),
|
telegramUserId: from.id.toString(),
|
||||||
telegramUsername: from.username,
|
telegramUsername: from.username,
|
||||||
firstName: from.firstName,
|
firstName: from.firstName,
|
||||||
|
|
@ -205,7 +196,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate auth code for the user
|
// Generate auth code for the user
|
||||||
final code = await dbManagerInstance.generateAuthCode(
|
final code = await backendClientInstance.generateAuthCode(
|
||||||
telegramUserId: from.id.toString(),
|
telegramUserId: from.id.toString(),
|
||||||
telegramUsername: from.username,
|
telegramUsername: from.username,
|
||||||
firstName: from.firstName,
|
firstName: from.firstName,
|
||||||
|
|
@ -235,7 +226,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate auth code for the user
|
// Generate auth code for the user
|
||||||
final code = await dbManagerInstance.generateAuthCode(
|
final code = await backendClientInstance.generateAuthCode(
|
||||||
telegramUserId: from.id.toString(),
|
telegramUserId: from.id.toString(),
|
||||||
telegramUsername: from.username,
|
telegramUsername: from.username,
|
||||||
firstName: from.firstName,
|
firstName: from.firstName,
|
||||||
|
|
@ -274,7 +265,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
|
|
||||||
teledartInstance.onCommand('info').listen((message) async {
|
teledartInstance.onCommand('info').listen((message) async {
|
||||||
if (adminIds.contains(message.from?.id.toString())) {
|
if (adminIds.contains(message.from?.id.toString())) {
|
||||||
final info = await dbManagerInstance.info();
|
final info = await backendClientInstance.info();
|
||||||
message.reply(info);
|
message.reply(info);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -282,7 +273,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
teledartInstance.onCommand('user').listen((message) async {
|
teledartInstance.onCommand('user').listen((message) async {
|
||||||
if (adminIds.contains(message.from?.id.toString())) {
|
if (adminIds.contains(message.from?.id.toString())) {
|
||||||
final userId = message.text?.replaceFirst('/user', '').trim() ?? '';
|
final userId = message.text?.replaceFirst('/user', '').trim() ?? '';
|
||||||
final info = await dbManagerInstance.userInfo(userId);
|
final info = await backendClientInstance.userInfo(userId);
|
||||||
message.reply(info);
|
message.reply(info);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -290,7 +281,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
teledartInstance.onCommand('words').listen((message) async {
|
teledartInstance.onCommand('words').listen((message) async {
|
||||||
if (adminIds.contains(message.from?.id.toString())) {
|
if (adminIds.contains(message.from?.id.toString())) {
|
||||||
final separator = message.text?.replaceFirst('/words', '').trim() ?? '';
|
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));
|
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');
|
print('[SHARE_CMD] User $telegramUserId requested /share command');
|
||||||
|
|
||||||
// Check rate limit
|
// Check rate limit
|
||||||
if (!await dbManagerInstance.canShareToday(
|
if (!await backendClientInstance.canShareToday(
|
||||||
telegramUserId,
|
telegramUserId,
|
||||||
config.shareDailyLimit,
|
config.shareDailyLimit,
|
||||||
)) {
|
)) {
|
||||||
|
|
@ -483,7 +474,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get random card
|
// Get random card
|
||||||
final card = await dbManagerInstance.getRandomCard();
|
final card = await backendClientInstance.getRandomCard();
|
||||||
if (card == null) {
|
if (card == null) {
|
||||||
try {
|
try {
|
||||||
await teledartInstance.editMessageText(
|
await teledartInstance.editMessageText(
|
||||||
|
|
@ -552,7 +543,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
|
|
||||||
|
|
||||||
// Record the share request
|
// Record the share request
|
||||||
await dbManagerInstance.recordShareRequest(
|
await backendClientInstance.recordShareRequest(
|
||||||
telegramUserId: telegramUserId,
|
telegramUserId: telegramUserId,
|
||||||
telegramUsername: from.username,
|
telegramUsername: from.username,
|
||||||
sharedCardId: card.id,
|
sharedCardId: card.id,
|
||||||
|
|
@ -578,7 +569,7 @@ Future<void> _run(List<String> arguments) async {
|
||||||
final code = _extractCodeFromText(text);
|
final code = _extractCodeFromText(text);
|
||||||
if (code != null) {
|
if (code != null) {
|
||||||
await _handleCodeClaim(
|
await _handleCodeClaim(
|
||||||
dbManager: dbManagerInstance,
|
backendClient: backendClientInstance,
|
||||||
teledartInstance: teledartInstance,
|
teledartInstance: teledartInstance,
|
||||||
message: message,
|
message: message,
|
||||||
code: code,
|
code: code,
|
||||||
|
|
@ -598,22 +589,26 @@ Future<void> _run(List<String> arguments) async {
|
||||||
teledartInstance.start();
|
teledartInstance.start();
|
||||||
log('Bot started');
|
log('Bot started');
|
||||||
} on Exception catch (e, s) {
|
} on Exception catch (e, s) {
|
||||||
print('Error: $e');
|
log('Error: $e', error: e, stackTrace: s);
|
||||||
print('');
|
|
||||||
printUsage(argParser);
|
|
||||||
print('Error while starting bot: $e');
|
print('Error while starting bot: $e');
|
||||||
print('Stack trace: $s');
|
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
|
// Cleanup resources before restart
|
||||||
try {
|
try {
|
||||||
await dbManager?.dispose();
|
backendClient?.dispose();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore cleanup errors
|
// Ignore cleanup errors
|
||||||
}
|
}
|
||||||
|
|
||||||
print('Restarting in 5 seconds...');
|
print('Restarting in 5 seconds...');
|
||||||
await Future.delayed(const Duration(seconds: 5));
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
return _run(arguments);
|
return _run();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
266
mnemo_cards_telegram_bot/lib/backend_client.dart
Normal file
266
mnemo_cards_telegram_bot/lib/backend_client.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,42 +1,28 @@
|
||||||
import 'dart:io';
|
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 {
|
class BotConfig {
|
||||||
BotConfig({
|
BotConfig({
|
||||||
required this.backendUrl,
|
required this.backendUrl,
|
||||||
required this.shareDailyLimit,
|
required this.shareDailyLimit,
|
||||||
|
required this.apiKey,
|
||||||
|
required this.botToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String backendUrl;
|
final String backendUrl;
|
||||||
final int shareDailyLimit;
|
final int shareDailyLimit;
|
||||||
|
final String apiKey;
|
||||||
|
final String botToken;
|
||||||
|
|
||||||
static const String defaultBackendUrl = 'https://api.mnemo-cards.online';
|
static const String defaultBackendUrl = 'https://api.mnemo-cards.online';
|
||||||
static const int defaultShareDailyLimit = 1;
|
static const int defaultShareDailyLimit = 1;
|
||||||
|
|
||||||
factory BotConfig.fromArgs(
|
factory BotConfig.fromEnvironment({
|
||||||
ArgResults results, {
|
|
||||||
Map<String, String>? environment,
|
Map<String, String>? environment,
|
||||||
}) {
|
}) {
|
||||||
final env = environment ?? Platform.environment;
|
final env = environment ?? Platform.environment;
|
||||||
final argValue = results.wasParsed('backend-url')
|
|
||||||
? results['backend-url'] as String?
|
|
||||||
: null;
|
|
||||||
|
|
||||||
final resolved = <String?>[
|
// Backend URL: MNEMO_BACKEND_URL or BACKEND_URL or default
|
||||||
argValue,
|
final backendUrl = <String?>[
|
||||||
env['MNEMO_BACKEND_URL'],
|
env['MNEMO_BACKEND_URL'],
|
||||||
env['BACKEND_URL'],
|
env['BACKEND_URL'],
|
||||||
defaultBackendUrl,
|
defaultBackendUrl,
|
||||||
|
|
@ -44,14 +30,30 @@ class BotConfig {
|
||||||
(value) => value != null && value.trim().isNotEmpty,
|
(value) => value != null && value.trim().isNotEmpty,
|
||||||
)!;
|
)!;
|
||||||
|
|
||||||
|
// Share daily limit: BOT_SHARE_DAILY_LIMIT or default
|
||||||
final shareDailyLimitStr = env['BOT_SHARE_DAILY_LIMIT'];
|
final shareDailyLimitStr = env['BOT_SHARE_DAILY_LIMIT'];
|
||||||
final shareDailyLimit = shareDailyLimitStr != null
|
final shareDailyLimit = shareDailyLimitStr != null
|
||||||
? int.tryParse(shareDailyLimitStr) ?? defaultShareDailyLimit
|
? int.tryParse(shareDailyLimitStr) ?? defaultShareDailyLimit
|
||||||
: 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(
|
return BotConfig(
|
||||||
backendUrl: resolved.trim(),
|
backendUrl: backendUrl.trim(),
|
||||||
shareDailyLimit: shareDailyLimit,
|
shareDailyLimit: shareDailyLimit,
|
||||||
|
apiKey: apiKey,
|
||||||
|
botToken: botToken,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,20 @@ description: A sample command-line application with basic argument parsing.
|
||||||
version: 0.0.1+1
|
version: 0.0.1+1
|
||||||
publish_to: none
|
publish_to: none
|
||||||
# repository: https://github.com/my_org/my_repo
|
# repository: https://github.com/my_org/my_repo
|
||||||
isar_version: &isar_version 3.1.0+1 # define the version to be used
|
|
||||||
|
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.0.0 <4.0.0'
|
sdk: '>=3.0.0 <4.0.0'
|
||||||
|
|
||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
args: ^2.4.2
|
|
||||||
|
|
||||||
mnemo_cards_common_backend:
|
mnemo_cards_common_backend:
|
||||||
path: ../mnemo_cards_common_backend
|
path: ../mnemo_cards_common_backend
|
||||||
|
|
||||||
teledart: ^0.6.1
|
teledart: ^0.6.1
|
||||||
archive: ^3.6.0
|
archive: ^3.6.0
|
||||||
process_run: ^0.14.2
|
process_run: ^0.14.2
|
||||||
isar: *isar_version
|
|
||||||
image: ^4.1.0
|
image: ^4.1.0
|
||||||
json_annotation: ^4.9.0
|
json_annotation: ^4.9.0
|
||||||
# isar_flutter_libs: *isar_version # contains Isar Core
|
|
||||||
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue