2025-12-11 17:49:15 +00:00
|
|
|
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,
|
2025-12-13 21:00:13 +00:00
|
|
|
String? sharedCardId,
|
2025-12-11 17:49:15 +00:00
|
|
|
}) 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(
|
2025-12-13 20:55:50 +00:00
|
|
|
id: response['id'] as String,
|
2025-12-11 17:49:15 +00:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|