Kgshop commited on
Commit
c712630
·
verified ·
1 Parent(s): c54f89a

Upload Soola.txt

Browse files
Files changed (1) hide show
  1. Soola.txt +874 -0
Soola.txt ADDED
@@ -0,0 +1,874 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ import os
5
+ from flask import Flask, request, Response, render_template_string, jsonify
6
+ import hmac
7
+ import hashlib
8
+ import json
9
+ from urllib.parse import unquote, parse_qs
10
+ import time
11
+ from datetime import datetime
12
+ import logging
13
+ import threading
14
+ from huggingface_hub import HfApi, hf_hub_download
15
+ from huggingface_hub.utils import RepositoryNotFoundError
16
+ import uuid
17
+
18
+ BOT_TOKEN = os.getenv("BOT_TOKEN", "7531615056:AAFL7Lp1kc-sAshoiM0tfez5-7wea26GXYU")
19
+ HOST = '0.0.0.0'
20
+ PORT = 7860
21
+ DATA_FILE = 'druzhba_data.json'
22
+
23
+ REPO_ID = "flpolprojects/druzhbabase"
24
+ HF_DATA_FILE_PATH = "druzhba_data.json"
25
+ HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
26
+ HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
27
+
28
+ app = Flask(__name__)
29
+ logging.basicConfig(level=logging.INFO)
30
+ app.secret_key = os.urandom(24)
31
+
32
+ _data_lock = threading.Lock()
33
+ visitor_data_cache = {}
34
+
35
+ def download_data_from_hf():
36
+ global visitor_data_cache
37
+ if not HF_TOKEN_READ:
38
+ logging.warning("HF_TOKEN_READ not set. Skipping Hugging Face download.")
39
+ return False
40
+ try:
41
+ logging.info(f"Attempting to download {HF_DATA_FILE_PATH} from {REPO_ID}...")
42
+ hf_hub_download(
43
+ repo_id=REPO_ID,
44
+ filename=HF_DATA_FILE_PATH,
45
+ repo_type="dataset",
46
+ token=HF_TOKEN_READ,
47
+ local_dir=".",
48
+ local_dir_use_symlinks=False,
49
+ force_download=True,
50
+ etag_timeout=10
51
+ )
52
+ logging.info("Data file successfully downloaded from Hugging Face.")
53
+ with _data_lock:
54
+ try:
55
+ with open(DATA_FILE, 'r', encoding='utf-8') as f:
56
+ visitor_data_cache = json.load(f)
57
+ logging.info("Successfully loaded downloaded data into cache.")
58
+ except (FileNotFoundError, json.JSONDecodeError) as e:
59
+ logging.error(f"Error reading downloaded data file: {e}. Starting with empty cache.")
60
+ visitor_data_cache = {}
61
+ return True
62
+ except RepositoryNotFoundError:
63
+ logging.error(f"Hugging Face repository '{REPO_ID}' not found. Cannot download data.")
64
+ except Exception as e:
65
+ logging.error(f"Error downloading data from Hugging Face: {e}")
66
+ return False
67
+
68
+ def load_visitor_data():
69
+ global visitor_data_cache
70
+ with _data_lock:
71
+ if not visitor_data_cache:
72
+ try:
73
+ with open(DATA_FILE, 'r', encoding='utf-8') as f:
74
+ visitor_data_cache = json.load(f)
75
+ logging.info("Visitor data loaded from local JSON.")
76
+ except FileNotFoundError:
77
+ logging.warning(f"{DATA_FILE} not found locally. Starting with empty data.")
78
+ visitor_data_cache = {}
79
+ except json.JSONDecodeError:
80
+ logging.error(f"Error decoding {DATA_FILE}. Starting with empty data.")
81
+ visitor_data_cache = {}
82
+ except Exception as e:
83
+ logging.error(f"Unexpected error loading visitor data: {e}")
84
+ visitor_data_cache = {}
85
+ return visitor_data_cache
86
+
87
+ def save_visitor_data(data):
88
+ with _data_lock:
89
+ try:
90
+ visitor_data_cache.update(data)
91
+ with open(DATA_FILE, 'w', encoding='utf-8') as f:
92
+ json.dump(visitor_data_cache, f, ensure_ascii=False, indent=4)
93
+ logging.info(f"Visitor data successfully saved to {DATA_FILE}.")
94
+ upload_data_to_hf_async()
95
+ except Exception as e:
96
+ logging.error(f"Error saving visitor data: {e}")
97
+
98
+ def upload_data_to_hf():
99
+ if not HF_TOKEN_WRITE:
100
+ logging.warning("HF_TOKEN_WRITE not set. Skipping Hugging Face upload.")
101
+ return
102
+ if not os.path.exists(DATA_FILE):
103
+ logging.warning(f"{DATA_FILE} does not exist. Skipping upload.")
104
+ return
105
+
106
+ try:
107
+ api = HfApi()
108
+ with _data_lock:
109
+ if not os.path.getsize(DATA_FILE) > 0:
110
+ logging.warning(f"{DATA_FILE} is empty. Skipping upload.")
111
+ return
112
+ logging.info(f"Attempting to upload {DATA_FILE} to {REPO_ID}/{HF_DATA_FILE_PATH}...")
113
+ api.upload_file(
114
+ path_or_fileobj=DATA_FILE,
115
+ path_in_repo=HF_DATA_FILE_PATH,
116
+ repo_id=REPO_ID,
117
+ repo_type="dataset",
118
+ token=HF_TOKEN_WRITE,
119
+ commit_message=f"Update bonus data {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
120
+ )
121
+ logging.info("Bonus data successfully uploaded to Hugging Face.")
122
+ except Exception as e:
123
+ logging.error(f"Error uploading data to Hugging Face: {e}")
124
+
125
+ def upload_data_to_hf_async():
126
+ upload_thread = threading.Thread(target=upload_data_to_hf, daemon=True)
127
+ upload_thread.start()
128
+
129
+ def periodic_backup():
130
+ if not HF_TOKEN_WRITE:
131
+ logging.info("Periodic backup disabled: HF_TOKEN_WRITE not set.")
132
+ return
133
+ while True:
134
+ time.sleep(3600)
135
+ logging.info("Initiating periodic backup...")
136
+ upload_data_to_hf()
137
+
138
+ def verify_telegram_data(init_data_str):
139
+ try:
140
+ parsed_data = parse_qs(init_data_str)
141
+ received_hash = parsed_data.pop('hash', [None])[0]
142
+
143
+ if not received_hash:
144
+ return None, False
145
+
146
+ data_check_list = []
147
+ for key, value in sorted(parsed_data.items()):
148
+ data_check_list.append(f"{key}={value[0]}")
149
+ data_check_string = "\n".join(data_check_list)
150
+
151
+ secret_key = hmac.new("WebAppData".encode(), BOT_TOKEN.encode(), hashlib.sha256).digest()
152
+ calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
153
+
154
+ if calculated_hash == received_hash:
155
+ return parsed_data, True
156
+ else:
157
+ logging.warning(f"Data verification failed. Calculated: {calculated_hash}, Received: {received_hash}")
158
+ return parsed_data, False
159
+ except Exception as e:
160
+ logging.error(f"Error verifying Telegram data: {e}")
161
+ return None, False
162
+
163
+ USER_TEMPLATE = """
164
+ <!DOCTYPE html>
165
+ <html lang="ru">
166
+ <head>
167
+ <meta charset="UTF-8">
168
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no, user-scalable=no, viewport-fit=cover">
169
+ <title>Druzhba Bonus</title>
170
+ <script src="https://telegram.org/js/telegram-web-app.js"></script>
171
+ <link rel="preconnect" href="https://fonts.googleapis.com">
172
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
173
+ <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet">
174
+ <style>
175
+ :root {
176
+ --druzhba-yellow: {{ theme.button_color | default('#ffc700') }};
177
+ --druzhba-black: {{ theme.bg_color | default('#121212') }};
178
+ --druzhba-text: {{ theme.text_color | default('#ffffff') }};
179
+ --druzhba-text-secondary: {{ theme.hint_color | default('#8a8a8e') }};
180
+ --druzhba-card-bg: {{ theme.secondary_bg_color | default('#1c1c1e') }};
181
+
182
+ --border-radius: 16px;
183
+ --padding-m: 16px;
184
+ --padding-l: 24px;
185
+ }
186
+ * { box-sizing: border-box; margin: 0; padding: 0; }
187
+
188
+ body {
189
+ font-family: 'Manrope', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
190
+ background-color: var(--druzhba-black);
191
+ color: var(--druzhba-text);
192
+ padding: var(--padding-m);
193
+ -webkit-font-smoothing: antialiased;
194
+ -moz-osx-font-smoothing: grayscale;
195
+ visibility: hidden;
196
+ min-height: 100vh;
197
+ }
198
+ .container {
199
+ max-width: 600px;
200
+ margin: 0 auto;
201
+ display: flex;
202
+ flex-direction: column;
203
+ gap: var(--padding-l);
204
+ }
205
+ .header {
206
+ text-align: center;
207
+ padding: var(--padding-m) 0;
208
+ }
209
+ .logo {
210
+ font-size: 2em;
211
+ font-weight: 800;
212
+ color: var(--druzhba-yellow);
213
+ }
214
+ .user-greeting {
215
+ margin-top: 8px;
216
+ font-size: 1.1em;
217
+ font-weight: 500;
218
+ color: var(--druzhba-text-secondary);
219
+ }
220
+ .balance-card {
221
+ background: linear-gradient(135deg, var(--druzhba-card-bg), #2c2c2e);
222
+ border-radius: var(--border-radius);
223
+ padding: var(--padding-l);
224
+ text-align: center;
225
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
226
+ border: 1px solid rgba(255, 255, 255, 0.1);
227
+ }
228
+ .balance-title {
229
+ font-size: 1.1em;
230
+ font-weight: 500;
231
+ color: var(--druzhba-text-secondary);
232
+ margin-bottom: 8px;
233
+ }
234
+ .balance-amount {
235
+ font-size: 3em;
236
+ font-weight: 800;
237
+ color: var(--druzhba-yellow);
238
+ line-height: 1.2;
239
+ }
240
+ .loader {
241
+ font-size: 1.5em;
242
+ font-weight: 600;
243
+ color: var(--druzhba-text-secondary);
244
+ animation: pulse 1.5s infinite ease-in-out;
245
+ }
246
+ @keyframes pulse {
247
+ 0% { opacity: 1; }
248
+ 50% { opacity: 0.5; }
249
+ 100% { opacity: 1; }
250
+ }
251
+ .history-section {
252
+ background-color: var(--druzhba-card-bg);
253
+ border-radius: var(--border-radius);
254
+ padding: var(--padding-l);
255
+ }
256
+ .history-title {
257
+ font-size: 1.5em;
258
+ font-weight: 700;
259
+ margin-bottom: var(--padding-m);
260
+ }
261
+ .transaction-list {
262
+ list-style: none;
263
+ padding: 0;
264
+ display: flex;
265
+ flex-direction: column;
266
+ gap: var(--padding-m);
267
+ }
268
+ .transaction-item {
269
+ display: flex;
270
+ justify-content: space-between;
271
+ align-items: center;
272
+ padding-bottom: var(--padding-m);
273
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
274
+ }
275
+ .transaction-item:last-child {
276
+ border-bottom: none;
277
+ padding-bottom: 0;
278
+ }
279
+ .transaction-details {
280
+ display: flex;
281
+ flex-direction: column;
282
+ gap: 4px;
283
+ }
284
+ .transaction-description {
285
+ font-weight: 600;
286
+ font-size: 1em;
287
+ }
288
+ .transaction-date {
289
+ font-size: 0.85em;
290
+ color: var(--druzhba-text-secondary);
291
+ }
292
+ .transaction-amount {
293
+ font-weight: 700;
294
+ font-size: 1.2em;
295
+ }
296
+ .transaction-amount.accrual { color: #34c759; }
297
+ .transaction-amount.deduction { color: #ff3b30; }
298
+ .no-history {
299
+ text-align: center;
300
+ padding: 32px 0;
301
+ color: var(--druzhba-text-secondary);
302
+ }
303
+ </style>
304
+ </head>
305
+ <body>
306
+ <div class="container">
307
+ <header class="header">
308
+ <div class="logo">DRUZHBA</div>
309
+ <div id="user-greeting" class="user-greeting">Загрузка...</div>
310
+ </header>
311
+
312
+ <section class="balance-card">
313
+ <div class="balance-title">Ваш бонусный баланс</div>
314
+ <div id="balance-amount" class="balance-amount loader">...</div>
315
+ </section>
316
+
317
+ <section class="history-section">
318
+ <h2 class="history-title">История операций</h2>
319
+ <ul id="transaction-list" class="transaction-list">
320
+ <li class="no-history">История операций пуста</li>
321
+ </ul>
322
+ </section>
323
+ </div>
324
+
325
+ <script>
326
+ const tg = window.Telegram.WebApp;
327
+
328
+ function applyTheme(themeParams) {
329
+ const root = document.documentElement;
330
+ root.style.setProperty('--druzhba-yellow', themeParams.button_color || '#ffc700');
331
+ root.style.setProperty('--druzhba-black', themeParams.bg_color || '#121212');
332
+ root.style.setProperty('--druzhba-text', themeParams.text_color || '#ffffff');
333
+ root.style.setProperty('--druzhba-text-secondary', themeParams.hint_color || '#8a8a8e');
334
+ root.style.setProperty('--druzhba-card-bg', themeParams.secondary_bg_color || '#1c1c1e');
335
+ }
336
+
337
+ function formatBonus(amount) {
338
+ return parseFloat(amount).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
339
+ }
340
+
341
+ function updateUI(userData) {
342
+ const greetingEl = document.getElementById('user-greeting');
343
+ const balanceEl = document.getElementById('balance-amount');
344
+ const transactionListEl = document.getElementById('transaction-list');
345
+
346
+ const name = userData.first_name || userData.username || 'Гость';
347
+ greetingEl.textContent = `Добро пожаловать, ${name}!`;
348
+
349
+ balanceEl.classList.remove('loader');
350
+ balanceEl.textContent = formatBonus(userData.balance || 0);
351
+
352
+ transactionListEl.innerHTML = '';
353
+
354
+ if (userData.transactions && userData.transactions.length > 0) {
355
+ const sortedTransactions = userData.transactions.sort((a, b) => b.timestamp - a.timestamp);
356
+ sortedTransactions.forEach(tx => {
357
+ const item = document.createElement('li');
358
+ item.className = 'transaction-item';
359
+
360
+ const date = new Date(tx.timestamp * 1000);
361
+ const formattedDate = date.toLocaleString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' });
362
+
363
+ const sign = tx.type === 'accrual' ? '+' : '-';
364
+ const amountClass = tx.type === 'accrual' ? 'accrual' : 'deduction';
365
+
366
+ item.innerHTML = `
367
+ <div class="transaction-details">
368
+ <span class="transaction-description">${tx.description}</span>
369
+ <span class="transaction-date">${formattedDate}</span>
370
+ </div>
371
+ <span class="transaction-amount ${amountClass}">
372
+ ${sign} ${formatBonus(tx.amount)}
373
+ </span>
374
+ `;
375
+ transactionListEl.appendChild(item);
376
+ });
377
+ } else {
378
+ transactionListEl.innerHTML = '<li class="no-history">История операций пуста</li>';
379
+ }
380
+ }
381
+
382
+ function setupTelegram() {
383
+ if (!tg || !tg.initData) {
384
+ console.error("Telegram WebApp script not loaded or initData is missing.");
385
+ document.getElementById('user-greeting').textContent = 'Ошибка загрузки.';
386
+ document.body.style.visibility = 'visible';
387
+ return;
388
+ }
389
+
390
+ tg.ready();
391
+ tg.expand();
392
+
393
+ applyTheme(tg.themeParams);
394
+ tg.onEvent('themeChanged', () => applyTheme(tg.themeParams));
395
+
396
+ fetch('/verify', {
397
+ method: 'POST',
398
+ headers: { 'Content-Type': 'application/json' },
399
+ body: JSON.stringify({ initData: tg.initData }),
400
+ })
401
+ .then(response => response.json())
402
+ .then(data => {
403
+ if (data.status === 'ok' && data.verified) {
404
+ updateUI(data.user);
405
+ } else {
406
+ throw new Error(data.message || 'Verification failed');
407
+ }
408
+ })
409
+ .catch(error => {
410
+ console.error('Error fetching user data:', error);
411
+ document.getElementById('user-greeting').textContent = 'Не удалось загрузить данные.';
412
+ });
413
+
414
+ document.body.style.visibility = 'visible';
415
+ }
416
+
417
+ window.addEventListener('load', setupTelegram);
418
+ </script>
419
+ </body>
420
+ </html>
421
+ """
422
+
423
+ ADMIN_TEMPLATE = """
424
+ <!DOCTYPE html>
425
+ <html lang="ru">
426
+ <head>
427
+ <meta charset="UTF-8">
428
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
429
+ <title>Druzhba Admin</title>
430
+ <link rel="preconnect" href="https://fonts.googleapis.com">
431
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
432
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
433
+ <style>
434
+ :root {
435
+ --admin-bg: #f0f2f5; --admin-text: #333; --admin-card-bg: #fff; --admin-border: #e0e0e0;
436
+ --admin-shadow: rgba(0, 0, 0, 0.08); --admin-primary: #1877f2; --admin-secondary: #65676b;
437
+ --admin-success: #31a24c; --admin-danger: #e02c4b; --admin-yellow: #ffc700;
438
+ --border-radius: 12px; --padding: 1.5rem; --font-family: 'Inter', sans-serif;
439
+ }
440
+ body { font-family: var(--font-family); background-color: var(--admin-bg); margin: 0; padding: var(--padding); }
441
+ .container { max-width: 1200px; margin: 0 auto; }
442
+ h1 { text-align: center; color: var(--admin-secondary); margin-bottom: 1rem; }
443
+ .controls-container {
444
+ display: flex; flex-wrap: wrap; gap: 1rem; justify-content: space-between; align-items: center;
445
+ background: var(--admin-card-bg); padding: 1rem; border-radius: var(--border-radius);
446
+ box-shadow: 0 2px 8px var(--admin-shadow); margin-bottom: 2rem;
447
+ }
448
+ .search-box { flex-grow: 1; max-width: 400px; }
449
+ .search-box input {
450
+ width: 100%; padding: 12px 16px; font-size: 1em; border-radius: 8px;
451
+ border: 1px solid var(--admin-border); transition: border-color 0.2s;
452
+ }
453
+ .search-box input:focus { border-color: var(--admin-primary); outline: none; }
454
+ .user-grid {
455
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem;
456
+ }
457
+ .user-card {
458
+ background-color: var(--admin-card-bg); border-radius: var(--border-radius); padding: 1.5rem;
459
+ box-shadow: 0 4px 12px var(--admin-shadow); border: 1px solid var(--admin-border);
460
+ display: flex; flex-direction: column; transition: transform 0.2s, box-shadow 0.2s;
461
+ }
462
+ .user-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); }
463
+ .user-info { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
464
+ .user-info img { width: 60px; height: 60px; border-radius: 50%; object-fit: cover; }
465
+ .user-name-details { flex-grow: 1; }
466
+ .user-name { font-weight: 600; font-size: 1.15em; margin-bottom: 0.2rem; }
467
+ .user-username { color: var(--admin-secondary); font-size: 0.95em; }
468
+ .user-balance {
469
+ margin-top: 1rem; padding: 0.8rem; background-color: #f7f7f7; border-radius: 8px;
470
+ text-align: center; border: 1px solid #eee;
471
+ }
472
+ .user-balance-label { font-size: 0.8em; color: var(--admin-secondary); text-transform: uppercase; letter-spacing: 0.5px; }
473
+ .user-balance-amount { font-size: 1.5em; font-weight: 700; color: var(--admin-primary); }
474
+ .user-card-actions { margin-top: 1.5rem; }
475
+ .btn {
476
+ display: inline-block; width: 100%; text-align: center; padding: 12px; font-size: 1em; font-weight: 600;
477
+ border: none; border-radius: 8px; cursor: pointer; transition: background-color 0.2s;
478
+ }
479
+ .btn-manage { background-color: var(--admin-primary); color: #fff; }
480
+ .btn-manage:hover { background-color: #166fe5; }
481
+
482
+ .modal {
483
+ display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%;
484
+ overflow: auto; background-color: rgba(0,0,0,0.6); backdrop-filter: blur(5px);
485
+ }
486
+ .modal-content {
487
+ background-color: var(--admin-card-bg); margin: 5% auto; padding: 2rem;
488
+ border-radius: var(--border-radius); width: 90%; max-width: 600px;
489
+ box-shadow: 0 10px 40px rgba(0,0,0,0.2); position: relative;
490
+ }
491
+ .modal-close {
492
+ color: #aaa; position: absolute; top: 1rem; right: 1.5rem; font-size: 32px;
493
+ font-weight: bold; cursor: pointer; line-height: 1;
494
+ }
495
+ .modal-header { margin-bottom: 1.5rem; }
496
+ .modal-title { font-size: 1.5em; font-weight: 700; }
497
+ .modal-subtitle { color: var(--admin-secondary); }
498
+ .modal-body { display: flex; flex-direction: column; gap: 2rem; }
499
+ .form-section { padding: 1.5rem; border: 1px solid var(--admin-border); border-radius: 8px; }
500
+ .form-section h3 { margin-top: 0; margin-bottom: 1rem; font-weight: 600; }
501
+ .form-group { margin-bottom: 1rem; }
502
+ .form-group label { display: block; font-weight: 500; margin-bottom: 0.5rem; }
503
+ .form-group input { width: 100%; padding: 12px; font-size: 1em; border: 1px solid #ccc; border-radius: 8px; }
504
+ .form-actions { text-align: right; }
505
+ .btn-submit { background-color: var(--admin-success); color: #fff; padding: 12px 24px; }
506
+ .history-list { list-style: none; padding: 0; max-height: 300px; overflow-y: auto; }
507
+ .history-item { display: flex; justify-content: space-between; padding: 0.8rem 0; border-bottom: 1px solid #f0f0f0; }
508
+ .history-item:last-child { border: none; }
509
+ .history-desc { font-size: 0.95em; }
510
+ .history-date { font-size: 0.8em; color: #888; }
511
+ .history-amount { font-weight: 600; }
512
+ .history-amount.accrual { color: var(--admin-success); }
513
+ .history-amount.deduction { color: var(--admin-danger); }
514
+ .status-message { margin-top: 1rem; padding: 1rem; border-radius: 8px; text-align: center; display: none; }
515
+ .status-message.success { background-color: #eaf6ec; color: var(--admin-success); }
516
+ .status-message.error { background-color: #fcebec; color: var(--admin-danger); }
517
+ </style>
518
+ </head>
519
+ <body>
520
+ <div class="container">
521
+ <h1>Панель администратора Druzhba</h1>
522
+
523
+ <div class="controls-container">
524
+ <div class="search-box">
525
+ <input type="text" id="searchInput" placeholder="Поиск по имени, username или ID...">
526
+ </div>
527
+ </div>
528
+
529
+ <div class="user-grid" id="userGrid">
530
+ {% for user in users|sort(attribute='visited_at', reverse=true) %}
531
+ <div class="user-card" data-search-term="{{ user.first_name or '' }} {{ user.last_name or '' }} @{{ user.username or '' }} {{ user.id }}">
532
+ <div class="user-info">
533
+ <img src="{{ user.photo_url or 'data:image/svg+xml;charset=UTF-8,%3csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 100 100%27%3e%3crect width=%27100%27 height=%27100%27 fill=%27%23e9ecef%27/%3e%3ctext x=%2750%25%27 y=%2755%25%27 dominant-baseline=%27middle%27 text-anchor=%27middle%27 font-size=%2745%27 font-family=%27sans-serif%27 fill=%27%23adb5bd%27%3e?%3c/text%3e%3c/svg%3e' }}" alt="Avatar">
534
+ <div class="user-name-details">
535
+ <div class="user-name">{{ user.first_name or '' }} {{ user.last_name or '' }}</div>
536
+ {% if user.username %}<div class="user-username">@{{ user.username }}</div>{% endif %}
537
+ </div>
538
+ </div>
539
+ <div class="user-balance">
540
+ <div class="user-balance-label">Бонусный баланс</div>
541
+ <div class="user-balance-amount" id="balance-{{ user.id }}">{{ "%.2f"|format(user.balance|float) }}</div>
542
+ </div>
543
+ <div class="user-card-actions">
544
+ <button class="btn btn-manage"
545
+ data-user-id="{{ user.id }}"
546
+ data-user-name="{{ user.first_name or '' }} {{ user.last_name or '' }}"
547
+ data-user-balance="{{ user.balance|float }}"
548
+ data-user-transactions='{{ user.transactions|tojson|safe }}'>
549
+ Управление бонусами
550
+ </button>
551
+ </div>
552
+ </div>
553
+ {% endfor %}
554
+ </div>
555
+ </div>
556
+
557
+ <div id="manageModal" class="modal">
558
+ <div class="modal-content">
559
+ <span class="modal-close" id="modal-close-btn">×</span>
560
+ <div class="modal-header">
561
+ <h2 id="modal-title" class="modal-title">Управление бонусами</h2>
562
+ <div id="modal-subtitle" class="modal-subtitle"></div>
563
+ </div>
564
+ <div class="modal-body">
565
+ <section class="form-section">
566
+ <h3>Новая операция</h3>
567
+ <form id="transactionForm">
568
+ <input type="hidden" id="modal-user-id">
569
+ <div class="form-group">
570
+ <label for="purchase_amount">Сумма покупки (для начисления 2%)</label>
571
+ <input type="number" id="purchase_amount" name="purchase_amount" placeholder="Например: 5000" min="0" step="any">
572
+ </div>
573
+ <div class="form-group">
574
+ <label for="spend_amount">Списать бонусов (текущий баланс: <span id="modal-current-balance">0.00</span>)</label>
575
+ <input type="number" id="spend_amount" name="spend_amount" placeholder="Например: 150.50" min="0" step="any">
576
+ </div>
577
+ <div class="form-actions">
578
+ <button type="submit" class="btn btn-submit">Провести операцию</button>
579
+ </div>
580
+ </form>
581
+ <div id="statusMessage" class="status-message"></div>
582
+ </section>
583
+ <section>
584
+ <h3>История операций</h3>
585
+ <ul id="modal-history-list" class="history-list">
586
+ <li>Нет данных</li>
587
+ </ul>
588
+ </section>
589
+ </div>
590
+ </div>
591
+ </div>
592
+
593
+ <script>
594
+ document.addEventListener('DOMContentLoaded', () => {
595
+ const searchInput = document.getElementById('searchInput');
596
+ const userGrid = document.getElementById('userGrid');
597
+ const userCards = userGrid.querySelectorAll('.user-card');
598
+
599
+ searchInput.addEventListener('input', (e) => {
600
+ const searchTerm = e.target.value.toLowerCase();
601
+ userCards.forEach(card => {
602
+ const cardSearchTerm = card.dataset.searchTerm.toLowerCase();
603
+ if (cardSearchTerm.includes(searchTerm)) {
604
+ card.style.display = '';
605
+ } else {
606
+ card.style.display = 'none';
607
+ }
608
+ });
609
+ });
610
+
611
+ const modal = document.getElementById('manageModal');
612
+ const closeBtn = document.getElementById('modal-close-btn');
613
+ const transactionForm = document.getElementById('transactionForm');
614
+ const statusMessage = document.getElementById('statusMessage');
615
+
616
+ document.querySelectorAll('.btn-manage').forEach(button => {
617
+ button.addEventListener('click', (e) => {
618
+ const userData = e.currentTarget.dataset;
619
+ openModal(userData);
620
+ });
621
+ });
622
+
623
+ function openModal(userData) {
624
+ document.getElementById('modal-title').textContent = `Бонусы: ${userData.userName}`;
625
+ document.getElementById('modal-subtitle').textContent = `ID: ${userData.userId}`;
626
+ document.getElementById('modal-user-id').value = userData.userId;
627
+ document.getElementById('modal-current-balance').textContent = parseFloat(userData.userBalance).toFixed(2);
628
+ transactionForm.reset();
629
+ statusMessage.style.display = 'none';
630
+
631
+ const transactions = JSON.parse(userData.userTransactions);
632
+ updateHistoryList(transactions);
633
+
634
+ modal.style.display = 'block';
635
+ }
636
+
637
+ function updateHistoryList(transactions) {
638
+ const historyList = document.getElementById('modal-history-list');
639
+ historyList.innerHTML = '';
640
+ if (transactions && transactions.length > 0) {
641
+ transactions.sort((a,b) => b.timestamp - a.timestamp).forEach(tx => {
642
+ const item = document.createElement('li');
643
+ item.className = 'history-item';
644
+ const date = new Date(tx.timestamp * 1000).toLocaleString('ru-RU');
645
+ const sign = tx.type === 'accrual' ? '+' : '-';
646
+ item.innerHTML = `
647
+ <div>
648
+ <div class="history-desc">${tx.description}</div>
649
+ <div class="history-date">${date}</div>
650
+ </div>
651
+ <div class="history-amount ${tx.type}">${sign} ${parseFloat(tx.amount).toFixed(2)}</div>
652
+ `;
653
+ historyList.appendChild(item);
654
+ });
655
+ } else {
656
+ historyList.innerHTML = '<li>История пуста</li>';
657
+ }
658
+ }
659
+
660
+ closeBtn.onclick = () => { modal.style.display = 'none'; };
661
+ window.onclick = (event) => { if (event.target == modal) { modal.style.display = 'none'; } };
662
+
663
+ transactionForm.addEventListener('submit', async (e) => {
664
+ e.preventDefault();
665
+ statusMessage.style.display = 'none';
666
+
667
+ const userId = document.getElementById('modal-user-id').value;
668
+ const purchaseAmount = document.getElementById('purchase_amount').value;
669
+ const spendAmount = document.getElementById('spend_amount').value;
670
+
671
+ try {
672
+ const response = await fetch('/admin/transaction', {
673
+ method: 'POST',
674
+ headers: { 'Content-Type': 'application/json' },
675
+ body: JSON.stringify({
676
+ user_id: userId,
677
+ purchase_amount: purchaseAmount || 0,
678
+ spend_amount: spendAmount || 0
679
+ })
680
+ });
681
+ const data = await response.json();
682
+
683
+ if (response.ok) {
684
+ statusMessage.textContent = data.message || 'Успешно!';
685
+ statusMessage.className = 'status-message success';
686
+
687
+ const updatedUser = data.user;
688
+ document.getElementById(`balance-${userId}`).textContent = parseFloat(updatedUser.balance).toFixed(2);
689
+ document.getElementById('modal-current-balance').textContent = parseFloat(updatedUser.balance).toFixed(2);
690
+ const manageButton = document.querySelector(`.btn-manage[data-user-id='${userId}']`);
691
+ manageButton.dataset.userBalance = updatedUser.balance;
692
+ manageButton.dataset.userTransactions = JSON.stringify(updatedUser.transactions);
693
+
694
+ updateHistoryList(updatedUser.transactions);
695
+ transactionForm.reset();
696
+ } else {
697
+ throw new Error(data.message || 'Произошла ошибка');
698
+ }
699
+ } catch (error) {
700
+ statusMessage.textContent = error.message;
701
+ statusMessage.className = 'status-message error';
702
+ }
703
+ statusMessage.style.display = 'block';
704
+ });
705
+ });
706
+ </script>
707
+ </body>
708
+ </html>
709
+ """
710
+
711
+ @app.route('/')
712
+ def index():
713
+ theme_params = {}
714
+ return render_template_string(USER_TEMPLATE, theme=theme_params)
715
+
716
+ @app.route('/verify', methods=['POST'])
717
+ def verify_data():
718
+ try:
719
+ req_data = request.get_json()
720
+ init_data_str = req_data.get('initData')
721
+ if not init_data_str:
722
+ return jsonify({"status": "error", "message": "Missing initData"}), 400
723
+
724
+ user_data_parsed, is_valid = verify_telegram_data(init_data_str)
725
+ user_info_dict = {}
726
+
727
+ if user_data_parsed and 'user' in user_data_parsed:
728
+ try:
729
+ user_json_str = unquote(user_data_parsed['user'][0])
730
+ user_info_dict = json.loads(user_json_str)
731
+ except Exception as e:
732
+ logging.error(f"Could not parse user JSON: {e}")
733
+ user_info_dict = {}
734
+
735
+ if is_valid:
736
+ user_id = str(user_info_dict.get('id'))
737
+ if user_id:
738
+ with _data_lock:
739
+ all_data = load_visitor_data()
740
+ now = time.time()
741
+
742
+ if user_id not in all_data:
743
+ user_entry = {
744
+ 'id': user_info_dict.get('id'),
745
+ 'first_name': user_info_dict.get('first_name'),
746
+ 'last_name': user_info_dict.get('last_name'),
747
+ 'username': user_info_dict.get('username'),
748
+ 'photo_url': user_info_dict.get('photo_url'),
749
+ 'language_code': user_info_dict.get('language_code'),
750
+ 'is_premium': user_info_dict.get('is_premium', False),
751
+ 'visited_at': now,
752
+ 'visited_at_str': datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S'),
753
+ 'balance': 0.0,
754
+ 'transactions': []
755
+ }
756
+ all_data[user_id] = user_entry
757
+ else:
758
+ all_data[user_id]['visited_at'] = now
759
+ all_data[user_id]['visited_at_str'] = datetime.fromtimestamp(now).strftime('%Y-%m-%d %H:%M:%S')
760
+ all_data[user_id]['first_name'] = user_info_dict.get('first_name')
761
+ all_data[user_id]['last_name'] = user_info_dict.get('last_name')
762
+ all_data[user_id]['username'] = user_info_dict.get('username')
763
+ all_data[user_id]['photo_url'] = user_info_dict.get('photo_url')
764
+
765
+ save_visitor_data({user_id: all_data[user_id]})
766
+ current_user_data = all_data[user_id]
767
+
768
+ return jsonify({"status": "ok", "verified": True, "user": current_user_data}), 200
769
+ else:
770
+ logging.warning(f"Verification failed for user: {user_info_dict.get('id')}")
771
+ return jsonify({"status": "error", "verified": False, "message": "Invalid data"}), 403
772
+
773
+ except Exception as e:
774
+ logging.exception("Error in /verify endpoint")
775
+ return jsonify({"status": "error", "message": "Internal server error"}), 500
776
+
777
+ @app.route('/admin')
778
+ def admin_panel():
779
+ current_data = load_visitor_data()
780
+ users_list = list(current_data.values())
781
+ return render_template_string(ADMIN_TEMPLATE, users=users_list)
782
+
783
+ @app.route('/admin/transaction', methods=['POST'])
784
+ def admin_transaction():
785
+ data = request.get_json()
786
+ user_id = str(data.get('user_id'))
787
+ try:
788
+ purchase_amount = float(data.get('purchase_amount', 0)) if data.get('purchase_amount') else 0
789
+ spend_amount = float(data.get('spend_amount', 0)) if data.get('spend_amount') else 0
790
+ except (ValueError, TypeError):
791
+ return jsonify({"status": "error", "message": "Неверный формат суммы."}), 400
792
+
793
+ if not user_id or (purchase_amount <= 0 and spend_amount <= 0):
794
+ return jsonify({"status": "error", "message": "Необходимо указать сумму покупки или сумму списания."}), 400
795
+
796
+ with _data_lock:
797
+ all_data = load_visitor_data()
798
+ user = all_data.get(user_id)
799
+
800
+ if not user:
801
+ return jsonify({"status": "error", "message": "Пользователь не найден."}), 404
802
+
803
+ if spend_amount > 0:
804
+ if user.get('balance', 0) < spend_amount:
805
+ return jsonify({"status": "error", "message": f"Недостаточно бонусов. Доступно: {user.get('balance', 0):.2f}"}), 400
806
+
807
+ user['balance'] -= spend_amount
808
+ spend_transaction = {
809
+ "id": str(uuid.uuid4()),
810
+ "timestamp": time.time(),
811
+ "type": "deduction",
812
+ "amount": spend_amount,
813
+ "description": "Списание бонусов"
814
+ }
815
+ user.setdefault('transactions', []).append(spend_transaction)
816
+
817
+ if purchase_amount > 0:
818
+ accrual_amount = round(purchase_amount * 0.02, 2)
819
+ user.setdefault('balance', 0.0)
820
+ user['balance'] += accrual_amount
821
+ accrual_transaction = {
822
+ "id": str(uuid.uuid4()),
823
+ "timestamp": time.time(),
824
+ "type": "accrual",
825
+ "amount": accrual_amount,
826
+ "description": f"Бонусы за покупку на {purchase_amount:.2f}"
827
+ }
828
+ user.setdefault('transactions', []).append(accrual_transaction)
829
+
830
+ save_visitor_data({user_id: user})
831
+ updated_user = all_data[user_id]
832
+
833
+ return jsonify({"status": "ok", "message": "Операция успешно проведена.", "user": updated_user})
834
+
835
+ @app.route('/admin/download_data', methods=['POST'])
836
+ def admin_trigger_download():
837
+ success = download_data_from_hf()
838
+ if success:
839
+ return jsonify({"status": "ok", "message": "Скачивание данных с Hugging Face завершено."})
840
+ else:
841
+ return jsonify({"status": "error", "message": "Ошибка скачивания данных с Hugging Face."}), 500
842
+
843
+ @app.route('/admin/upload_data', methods=['POST'])
844
+ def admin_trigger_upload():
845
+ if not HF_TOKEN_WRITE:
846
+ return jsonify({"status": "error", "message": "HF_TOKEN_WRITE не настроен на сервере."}), 400
847
+ upload_data_to_hf_async()
848
+ return jsonify({"status": "ok", "message": "Загрузка данных на Hugging Face запущена."})
849
+
850
+ if __name__ == '__main__':
851
+ print("--- DRUZHBA BONUS APP SERVER ---")
852
+ print(f"Server starting on http://{HOST}:{PORT}")
853
+ print(f"Data file: {DATA_FILE}")
854
+ print(f"Hugging Face Repo: {REPO_ID}")
855
+
856
+ if not HF_TOKEN_READ or not HF_TOKEN_WRITE:
857
+ print("WARNING: Hugging Face token(s) not set. Backup/restore functionality will be limited.")
858
+ else:
859
+ print("Hugging Face tokens found. Attempting initial data download...")
860
+ download_data_from_hf()
861
+
862
+ load_visitor_data()
863
+
864
+ print("WARNING: The /admin route is NOT protected by authentication.")
865
+
866
+ if HF_TOKEN_WRITE:
867
+ backup_thread = threading.Thread(target=periodic_backup, daemon=True)
868
+ backup_thread.start()
869
+ print("Periodic backup thread started (every hour).")
870
+ else:
871
+ print("Periodic backup disabled (HF_TOKEN_WRITE missing).")
872
+
873
+ print("--- Server Ready ---")
874
+ app.run(host=HOST, port=PORT, debug=False)