Shveiauto commited on
Commit
577baed
·
verified ·
1 Parent(s): 15bf1f8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +1060 -1003
app.py CHANGED
@@ -1,4 +1,6 @@
1
- from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash
 
 
2
  import json
3
  import os
4
  import logging
@@ -17,15 +19,30 @@ import requests
17
 
18
  load_dotenv()
19
 
20
- app = Flask(__name__)
21
- app.secret_key = os.getenv("FLASK_SECRET_KEY", 'tontalent_secret_key_for_flash_messages_only')
 
 
 
22
  DATA_FILE = 'wall_data.json'
23
- SYNC_FILES = [DATA_FILE]
 
 
 
24
 
25
- REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/tontalent2")
 
 
 
 
 
 
26
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
27
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
28
 
 
 
 
29
  TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "7549355625:AAGYWatM-nUVQirgBiBwoAtWZgzfp3QnQjY")
30
 
31
  DOWNLOAD_RETRIES = 3
@@ -33,1082 +50,1122 @@ DOWNLOAD_DELAY = 5
33
 
34
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
35
 
 
 
 
 
 
 
36
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
37
- if not HF_TOKEN_READ and not HF_TOKEN_WRITE:
38
- logging.warning("HF_TOKEN_READ/HF_TOKEN_WRITE not set. Download might fail for private repos.")
39
- token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
40
- files_to_download = [specific_file] if specific_file else SYNC_FILES
41
- logging.info(f"Attempting download for {files_to_download} from {REPO_ID}...")
42
- all_successful = True
43
- for file_name in files_to_download:
44
- success = False
45
- for attempt in range(retries + 1):
46
- try:
47
- logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...")
48
- local_path = hf_hub_download(
49
- repo_id=REPO_ID, filename=file_name, repo_type="dataset",
50
- token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
51
- force_download=True, resume_download=False
52
- )
53
- logging.info(f"Successfully downloaded {file_name} to {local_path}.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  success = True
55
  break
56
- except RepositoryNotFoundError:
57
- logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.")
58
- return False
59
- except HfHubHTTPError as e:
60
- if e.response.status_code == 404:
61
- logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
62
- if attempt == 0 and not os.path.exists(file_name):
63
- try:
64
- if file_name == DATA_FILE:
65
- with open(file_name, 'w', encoding='utf-8') as f:
66
- json.dump({'posts': [], 'users': {}}, f)
67
- logging.info(f"Created empty local file {file_name} because it was not found on HF.")
68
- except Exception as create_e:
69
- logging.error(f"Failed to create empty local file {file_name}: {create_e}")
70
- success = True
71
- break
72
- else:
73
- logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
74
- except Exception as e:
75
- logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
76
- if attempt < retries: time.sleep(delay)
77
- if not success:
78
- logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
79
- all_successful = False
80
- logging.info(f"Download process finished. Overall success: {all_successful}")
81
- return all_successful
82
 
83
  def upload_db_to_hf(specific_file=None):
84
- if not HF_TOKEN_WRITE:
85
- logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
86
- return
87
- try:
88
- api = HfApi()
89
- files_to_upload = [specific_file] if specific_file else SYNC_FILES
90
- logging.info(f"Starting upload of {files_to_upload} to HF repo {REPO_ID}...")
91
- for file_name in files_to_upload:
92
- if os.path.exists(file_name):
93
- try:
94
- api.upload_file(
95
- path_or_fileobj=file_name, path_in_repo=file_name, repo_id=REPO_ID,
96
- repo_type="dataset", token=HF_TOKEN_WRITE,
97
- commit_message=f"Sync {file_name} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
98
- )
99
- logging.info(f"File {file_name} successfully uploaded to Hugging Face.")
100
- except Exception as e:
101
- logging.error(f"Error uploading file {file_name} to Hugging Face: {e}")
102
- else:
103
- logging.warning(f"File {file_name} not found locally, skipping upload.")
104
- logging.info("Finished uploading files to HF.")
105
- except Exception as e:
106
- logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
  def periodic_backup():
109
- backup_interval = 1800
110
- logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
111
- while True:
112
- time.sleep(backup_interval)
113
- logging.info("Starting periodic backup...")
114
- upload_db_to_hf()
115
- logging.info("Periodic backup finished.")
116
 
117
  def load_data():
118
- default_data = {'posts': [], 'users': {}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  try:
120
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
121
  data = json.load(file)
122
- logging.info(f"Local data loaded successfully from {DATA_FILE}")
123
- if not isinstance(data, dict):
124
- logging.warning(f"Local {DATA_FILE} is not a dictionary. Attempting download.")
125
- raise FileNotFoundError
126
  for key in default_data:
127
  if key not in data: data[key] = default_data[key]
128
  return data
129
- except (FileNotFoundError, json.JSONDecodeError) as e:
130
- logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
131
-
132
- if download_db_from_hf(specific_file=DATA_FILE):
133
- try:
134
- with open(DATA_FILE, 'r', encoding='utf-8') as file:
135
- data = json.load(file)
136
- logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
137
- if not isinstance(data, dict):
138
- logging.error(f"Downloaded {DATA_FILE} is not a dictionary. Using default.")
139
- return default_data
140
- for key in default_data:
141
- if key not in data: data[key] = default_data[key]
142
- return data
143
- except Exception as load_e:
144
- logging.error(f"Error loading downloaded {DATA_FILE}: {load_e}. Using default.", exc_info=True)
145
- return default_data
146
- else:
147
- logging.error(f"Failed to download {DATA_FILE} from HF. Using empty default data structure.")
148
- if not os.path.exists(DATA_FILE):
149
- try:
150
- with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump(default_data, f)
151
- logging.info(f"Created empty local file {DATA_FILE} after failed download.")
152
- except Exception as create_e:
153
- logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
154
  return default_data
 
 
 
 
 
 
 
 
 
155
 
156
  def save_data(data):
157
- try:
158
- if not isinstance(data, dict):
159
- logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
160
- return
161
- default_keys = {'posts': [], 'users': {}}
162
- for key in default_keys:
163
- if key not in data: data[key] = default_keys[key]
164
-
165
- with open(DATA_FILE, 'w', encoding='utf-8') as file:
166
- json.dump(data, file, ensure_ascii=False, indent=4)
167
- logging.info(f"Data successfully saved to {DATA_FILE}")
168
- upload_db_to_hf(specific_file=DATA_FILE)
169
- except Exception as e:
170
- logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
 
 
 
 
 
171
 
172
  def verify_telegram_auth_data(auth_data_str, bot_token):
173
- if not auth_data_str:
174
- return False, None
175
-
176
- params = dict(urllib.parse.parse_qsl(auth_data_str))
177
- if 'hash' not in params:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  return False, None
 
179
 
180
- received_hash = params.pop('hash')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
- sorted_params = sorted(params.items())
183
- data_check_string_parts = []
184
- for key, value in sorted_params:
185
- data_check_string_parts.append(f"{key}={value}")
 
 
186
 
187
- data_check_string = "\n".join(data_check_string_parts)
188
 
189
- secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest()
190
- calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
- if calculated_hash == received_hash:
193
- try:
194
- user_data = json.loads(params.get('user', '{}'))
195
- return True, user_data
196
- except json.JSONDecodeError:
197
- return False, None
198
- return False, None
 
 
 
 
 
 
 
199
 
200
- def get_authenticated_user_details(request_headers):
201
- auth_data_str = request_headers.get('X-Telegram-Auth')
202
- if not auth_data_str:
203
- return None
204
- is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
205
- if is_valid and user_data_from_auth:
206
- data = load_data()
207
- user_id_str = str(user_data_from_auth.get('id'))
208
- return data.get('users', {}).get(user_id_str)
209
- return None
210
 
211
- def notify_user(chat_id, message):
212
- if not TELEGRAM_BOT_TOKEN:
213
- logging.warning("TELEGRAM_BOT_TOKEN not set. Skipping notification.")
214
- return False
 
 
 
 
215
 
216
- url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
217
- payload = {
218
- 'chat_id': chat_id,
219
- 'text': message,
220
- 'parse_mode': 'HTML'
221
- }
222
- try:
223
- response = requests.post(url, json=payload, timeout=5)
224
- response.raise_for_status()
225
- logging.info(f"Notification sent to user {chat_id}")
226
- return True
227
- except requests.exceptions.HTTPError as e:
228
- logging.error(f"Telegram Bot API HTTP Error for user {chat_id}: {e.response.text}")
229
- return False
230
- except requests.exceptions.RequestException as e:
231
- logging.error(f"Error sending notification to user {chat_id}: {e}")
232
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
 
 
 
 
 
234
 
235
  MAIN_APP_TEMPLATE = '''
 
236
  <!DOCTYPE html>
 
237
  <html lang="en">
238
  <head>
239
- <meta charset="UTF-8">
240
- <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
241
- <title>TonTalent Wall</title>
242
- <script src="https://telegram.org/js/telegram-web-app.js"></script>
243
- <style>
244
- :root {
245
- --tg-theme-bg-color: #ffffff;
246
- --tg-theme-text-color: #000000;
247
- --tg-theme-hint-color: #999999;
248
- --tg-theme-link-color: #007aff;
249
- --tg-theme-button-color: #007aff;
250
- --tg-theme-button-text-color: #ffffff;
251
- --tg-theme-secondary-bg-color: #f0f0f0;
252
- --tg-theme-header-bg-color: #efeff4;
253
- --tg-theme-section-bg-color: #ffffff;
254
- --tg-theme-section-header-text-color: #8e8e93;
255
- --tg-theme-destructive-text-color: #ff3b30;
256
- --tg-theme-accent-text-color: #007aff;
257
- }
258
- body {
259
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
260
- margin: 0;
261
- padding: 0;
262
- background-color: var(--tg-theme-bg-color);
263
- color: var(--tg-theme-text-color);
264
- overscroll-behavior-y: none;
265
- -webkit-font-smoothing: antialiased;
266
- -moz-osx-font-smoothing: grayscale;
267
- line-height: 1.4;
268
- }
269
- .app-container { display: flex; flex-direction: column; min-height: 100vh; min-height: -webkit-fill-available; }
270
- .header {
271
- background-color: var(--tg-theme-header-bg-color);
272
- padding: 12px 15px;
273
- text-align: center;
274
- font-weight: 600;
275
- font-size: 17px;
276
- border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
277
- position: sticky;
278
- top: 0;
279
- z-index: 100;
280
- }
281
- .content { flex-grow: 1; padding: 0; overflow-x: hidden; transition: opacity 0.2s ease-out; }
282
-
283
- .footer-nav {
284
- display: flex;
285
- justify-content: space-around;
286
- background-color: var(--tg-theme-secondary-bg-color);
287
- border-top: 0.5px solid var(--tg-theme-secondary-bg-color);
288
- padding: 5px 0 calc(5px + env(safe-area-inset-bottom));
289
- position: sticky;
290
- bottom: 0;
291
- z-index: 200;
292
- }
293
- .nav-button {
294
- flex: 1;
295
- display: flex;
296
- flex-direction: column;
297
- align-items: center;
298
- justify-content: center;
299
- padding: 5px 0;
300
- cursor: pointer;
301
- background: none;
302
- border: none;
303
- color: var(--tg-theme-hint-color);
304
- font-size: 12px;
305
- font-weight: 500;
306
- transition: color 0.2s ease;
307
- -webkit-tap-highlight-color: transparent;
308
- }
309
- .nav-button.active { color: var(--tg-theme-link-color); }
310
- .nav-button svg { width: 24px; height: 24px; margin-bottom: 2px; }
311
-
312
- .list-container { padding: 10px 15px; }
313
- .list-item {
314
- background-color: var(--tg-theme-section-bg-color);
315
- padding: 12px 15px;
316
- margin-bottom: 10px;
317
- border-radius: 10px;
318
- box-shadow: 0 2px 8px rgba(0,0,0,0.06);
319
- cursor: pointer;
320
- transition: transform 0.1s ease-out, background-color 0.1s ease;
321
- }
322
- .list-item:active { transform: scale(0.99); background-color: var(--tg-theme-secondary-bg-color); }
323
- .list-item h3 { margin: 0 0 4px 0; font-size: 16px; font-weight: 600; color: var(--tg-theme-text-color); }
324
- .list-item p { margin: 0 0 4px 0; font-size: 14px; color: var(--tg-theme-text-color); }
325
- .list-item .meta { font-size: 12px; color: var(--tg-theme-hint-color); margin-top: 8px; }
326
-
327
- .user-list-item {
328
- display: flex;
329
- align-items: center;
330
- padding: 12px 15px;
331
- background-color: var(--tg-theme-section-bg-color);
332
- border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
333
- cursor: pointer;
334
- }
335
- .user-list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
336
- .user-list-item img {
337
- width: 40px;
338
- height: 40px;
339
- border-radius: 50%;
340
- margin-right: 12px;
341
- object-fit: cover;
342
- background-color: var(--tg-theme-secondary-bg-color);
343
- }
344
- .user-list-item span {
345
- font-size: 15px;
346
- font-weight: 500;
347
- color: var(--tg-theme-text-color);
348
- }
349
-
350
- .post-content { margin-top: 8px; white-space: pre-wrap; word-break: break-word; }
351
- .post-media { max-width: 100%; height: auto; display: block; margin-top: 10px; border-radius: 6px; }
352
-
353
- .loading, .empty-state { text-align: center; padding: 50px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
354
- .form-container { padding: 20px 15px; background-color: var(--tg-theme-section-bg-color); }
355
- .form-group { margin-bottom: 18px; }
356
- .form-group label { display: block; font-size: 14px; color: var(--tg-theme-section-header-text-color); margin-bottom: 6px; font-weight: 500; }
357
- .form-group input, .form-group textarea, .form-group select {
358
- width: 100%;
359
- padding: 12px;
360
- border: 1px solid var(--tg-theme-secondary-bg-color);
361
- border-radius: 8px;
362
- font-size: 16px;
363
- background-color: var(--tg-theme-bg-color);
364
- color: var(--tg-theme-text-color);
365
- box-sizing: border-box;
366
- transition: border-color 0.2s ease;
367
- }
368
- .form-group input:focus, .form-group textarea:focus, .form-group select:focus { border-color: var(--tg-theme-link-color); outline: none; }
369
- .form-group textarea { min-height: 100px; resize: vertical; }
370
- .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 10px; text-align: center; }
371
-
372
- .delete-button {
373
- display: block;
374
- width: 100%;
375
- padding: 12px 15px;
376
- margin-top: 15px;
377
- border: none;
378
- border-radius: 8px;
379
- font-size: 16px;
380
- font-weight: 500;
381
- cursor: pointer;
382
- text-align: center;
383
- transition: background-color 0.2s ease;
384
- background-color: var(--tg-theme-destructive-text-color);
385
- color: #ffffff;
386
- }
387
- </style>
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  </head>
389
  <body>
390
- <div class="app-container">
391
- <div class="header" id="appHeader">TonTalent Wall</div>
392
- <div class="content" id="mainContent">
393
- <div class="loading">Loading content...</div>
394
- </div>
395
-
396
- <div class="footer-nav">
397
- <button class="nav-button active" data-nav="my_wall">
398
- <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm0 3c1.38 0 2.5 1.12 2.5 2.5S13.38 10 12 10 9.5 8.88 9.5 7.5 10.62 5 12 5zm0 14.2c-2.45 0-4.66-1.23-5.78-3.08l.05-.08a7 7 0 0 1 11.46 0l.05.08c-1.12 1.85-3.33 3.08-5.78 3.08z"/></svg>
399
- <span id="navMyWallText">My Wall</span>
400
- </button>
401
- <button class="nav-button" data-nav="users">
402
- <svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-6.08 1.46-6.08 3.5v3.5h12.16v-3.5c0-2.04-3.75-3.5-6.08-3.5zm8 0c-.29 0-.62.05-.97.12 1.25.96 1.97 2.22 1.97 3.38v3.5h6v-3.5c0-2.04-3.75-3.5-6.91-3.5z"/></svg>
403
- <span id="navUsersText">Users</span>
404
- </button>
405
- </div>
406
- </div>
407
-
408
- <script>
409
- const tg = window.Telegram.WebApp;
410
- let currentUser = null;
411
- let currentView = 'my_wall';
412
- let currentTargetUser = null;
413
- const mainContent = document.getElementById('mainContent');
414
-
415
- const L = {
416
- 'ru': {
417
- 'title': 'Стена TonTalent',
418
- 'myWall': 'Моя Стена',
419
- 'users': 'Пользователи',
420
- 'newPost': 'Новая Запись',
421
- 'loading': 'Загрузка...',
422
- 'welcome': 'Добро пожаловать, ',
423
- 'noPosts': 'На этой стене пока нет записей.',
424
- 'noUsers': 'Нет доступных пользователей.',
425
- 'postButton': 'Опубликовать',
426
- 'deleteConfirm': 'Вы уверены, что хотите удалить эту запись?',
427
- 'postDeleted': 'Запись успешно удалена.',
428
- 'failedSubmit': 'Не удалось опубликовать запись.',
429
- 'failedDelete': 'Не удалось удалить запись.',
430
- 'postText': 'Текст записи',
431
- 'postType': 'Тип контента',
432
- 'text': 'Текст',
433
- 'photo': 'Фото',
434
- 'video': 'Видео',
435
- 'document': 'Документ',
436
- 'contentUrl': 'Ссылка на контент (URL/ID)',
437
- 'requiredField': 'Пожалуйста, заполните поле',
438
- 'postTo': 'Оставить запись на стене ',
439
- 'deletePost': 'Удалить Запись',
440
- 'wallOf': 'Стена пользователя',
441
- 'from': 'от',
442
- },
443
- 'en': {
444
- 'title': 'TonTalent Wall',
445
- 'myWall': 'My Wall',
446
- 'users': 'Users',
447
- 'newPost': 'New Post',
448
- 'loading': 'Loading...',
449
- 'welcome': 'Welcome, ',
450
- 'noPosts': 'No posts found on this wall yet.',
451
- 'noUsers': 'No users available.',
452
- 'postButton': 'Post',
453
- 'deleteConfirm': 'Are you sure you want to delete this post?',
454
- 'postDeleted': 'Post deleted successfully.',
455
- 'failedSubmit': 'Failed to submit post.',
456
- 'failedDelete': 'Failed to delete post.',
457
- 'postText': 'Post Text',
458
- 'postType': 'Content Type',
459
- 'text': 'Text',
460
- 'photo': 'Photo',
461
- 'video': 'Video',
462
- 'document': 'Document',
463
- 'contentUrl': 'Content Link (URL/ID)',
464
- 'requiredField': 'Please fill in the required field',
465
- 'postTo': 'Leave a post on the wall of ',
466
- 'deletePost': 'Delete Post',
467
- 'wallOf': "'s Wall",
468
- 'from': 'from',
469
- }
470
- };
471
-
472
- let lang = (tg.initDataUnsafe.user?.language_code === 'ru') ? 'ru' : 'en';
473
- const texts = L[lang];
474
-
475
- function applyThemeParams() {
476
- const rootStyle = document.documentElement.style;
477
- rootStyle.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
478
- rootStyle.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
479
- rootStyle.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
480
- rootStyle.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
481
- rootStyle.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
482
- rootStyle.setProperty('--tg-theme-button-text-color', tg.themeParams.button_text_color || '#ffffff');
483
- rootStyle.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
484
- rootStyle.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
485
- rootStyle.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
486
- rootStyle.setProperty('--tg-theme-section-header-text-color', tg.themeParams.section_header_text_color || tg.themeParams.hint_color || '#8e8e93');
487
- rootStyle.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
488
- rootStyle.setProperty('--tg-theme-accent-text-color', tg.themeParams.accent_text_color || tg.themeParams.link_color || '#007aff');
489
- document.getElementById('appHeader').textContent = texts.title;
490
- document.getElementById('navMyWallText').textContent = texts.myWall;
491
- document.getElementById('navUsersText').textContent = texts.users;
492
  }
493
-
494
- async function apiCall(endpoint, method = 'GET', body = null) {
495
- const headers = { 'Content-Type': 'application/json' };
496
- if (tg.initData) {
497
- headers['X-Telegram-Auth'] = tg.initData;
 
 
 
 
 
 
 
 
 
 
 
 
498
  }
499
- const options = { method, headers };
500
- if (body) options.body = JSON.stringify(body);
501
- try {
502
- const response = await fetch(endpoint, options);
503
- if (!response.ok) {
504
- const errorData = await response.json().catch(() => ({ error: 'Request failed without JSON body' }));
505
- throw new Error(errorData.error || `HTTP error ${response.status}`);
506
- }
507
- return response.json();
508
- } catch (error) {
509
- console.error('API Call Error:', error);
510
- tg.showAlert(error.message || 'An API error occurred.');
511
- throw error;
512
  }
 
 
 
 
 
 
513
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
 
515
- function getAuthorDisplay(post) {
516
- const name = post.author_first_name || 'Anonymous';
517
- const username = post.author_username;
518
- const userLink = username ? `<a href="tg://resolve?domain=${username}" target="_blank" rel="noopener noreferrer">@${username}</a>` : name;
519
- return userLink;
 
 
 
 
 
 
 
 
520
  }
 
 
521
 
522
- function renderWallPosts(posts, targetUser) {
523
- mainContent.style.opacity = 0;
524
- const targetName = targetUser.first_name || targetUser.username || `User ${targetUser.id}`;
525
- const headerText = targetUser.id === currentUser.id ? texts.myWall : `${targetName}${texts.wallOf}`;
526
- document.getElementById('appHeader').textContent = headerText;
527
-
528
- tg.BackButton.hide();
529
- tg.MainButton.hide();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
 
531
- let html = `<div class="list-container">`;
532
 
533
- if (!posts || posts.length === 0) {
534
- html += `<div class="empty-state">${texts.noPosts}</div>`;
535
- } else {
536
- html += posts.map(post => {
537
- let mediaHtml = '';
538
- if (post.type !== 'text' && post.content) {
539
- if (post.type === 'photo') {
540
- mediaHtml = `<img src="${post.content}" alt="Photo" class="post-media" loading="lazy">`;
541
- } else {
542
- mediaHtml = `<p><strong>${texts[post.type]}:</strong> <a href="${post.content}" target="_blank">${post.content}</a></p>`;
543
- }
544
- }
545
-
546
- let deleteButton = '';
547
- if (String(post.user_id) === String(currentUser.id)) {
548
- deleteButton = `<button class="delete-button" onclick="handleDeletePost('${post.id}')">${texts.deletePost}</button>`;
549
- }
550
-
551
- return `
552
- <div class="list-item">
553
- <h3>${texts.from} ${getAuthorDisplay(post)}</h3>
554
- ${mediaHtml}
555
- <p class="post-content">${post.text}</p>
556
- <p class="meta">Posted on ${new Date(post.timestamp).toLocaleDateString()}</p>
557
- ${deleteButton}
558
  </div>
559
- `;
560
- }).join('');
561
- }
562
- html += `</div>`;
563
- mainContent.innerHTML = html;
564
-
565
- tg.MainButton.setText(texts.newPost);
566
- tg.MainButton.show();
567
- tg.MainButton.onClick(() => showPostForm(targetUser));
568
-
569
- if (targetUser.id !== currentUser.id) {
570
- tg.BackButton.show();
571
- tg.BackButton.onClick(() => loadView('users'));
572
- }
573
 
574
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
575
  }
 
 
576
 
577
- function renderUsersList(users) {
578
- mainContent.style.opacity = 0;
579
- document.getElementById('appHeader').textContent = texts.users;
580
-
581
- tg.BackButton.hide();
582
- tg.MainButton.hide();
 
 
583
 
584
- if (!users || users.length === 0) {
585
- mainContent.innerHTML = `<div class="empty-state">${texts.noUsers}</div>`;
586
- } else {
587
- mainContent.innerHTML = users.map(user => {
588
- if (String(user.id) === String(currentUser.id)) return ''; // Hide self from user list
589
- const userName = user.first_name || user.username || `User ${user.id}`;
590
- return `
591
- <div class="user-list-item" onclick="loadUserWall('${user.id}')">
592
- <img src="${user.photo_url || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="Avatar">
593
- <span>${userName} ${user.username ? `(@${user.username})` : ''}</span>
594
- </div>
595
- `;
596
- }).join('');
597
- }
598
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
599
  }
600
 
601
- function loadUserWall(userId) {
602
- tg.HapticFeedback.impactOccurred('light');
603
- currentView = 'user_wall';
604
- mainContent.style.opacity = 0;
605
- mainContent.innerHTML = `<div class="loading">${texts.loading}</div>`;
606
-
607
- const usersEndpoint = '/api/users';
608
- const postsEndpoint = `/api/users/${userId}/wall`;
609
-
610
- Promise.all([apiCall(usersEndpoint), apiCall(postsEndpoint)])
611
- .then(([allUsers, wallPosts]) => {
612
- currentTargetUser = allUsers.find(u => String(u.id) === String(userId));
613
- if (currentTargetUser) {
614
- renderWallPosts(wallPosts, currentTargetUser);
615
- } else {
616
- mainContent.innerHTML = `<div class="empty-state">User not found.</div>`;
617
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
618
- }
619
- })
620
- .catch(err => {
621
- mainContent.innerHTML = `<div class="empty-state">Error loading wall.</div>`;
622
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
623
- });
624
  }
625
 
626
- function loadView(viewName) {
627
- if (currentView !== viewName) tg.HapticFeedback.impactOccurred('light');
628
- currentView = viewName;
629
-
630
- document.querySelectorAll('.nav-button').forEach(btn => btn.classList.remove('active'));
631
- document.querySelector(`.nav-button[data-nav="${viewName}"]`)?.classList.add('active');
632
-
633
- mainContent.style.opacity = 0;
634
- mainContent.innerHTML = `<div class="loading">${texts.loading}</div>`;
635
-
636
- tg.BackButton.hide();
637
- tg.MainButton.hide();
638
-
639
- if (viewName === 'my_wall') {
640
- if (currentUser) {
641
- loadUserWall(currentUser.id);
642
  } else {
643
- mainContent.innerHTML = `<div class="empty-state">Authentication failed. Cannot load wall.</div>`;
644
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
645
  }
646
- } else if (viewName === 'users') {
647
- apiCall('/api/users')
648
- .then(renderUsersList)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
649
  .catch(err => {
650
- mainContent.innerHTML = `<div class="empty-state">Error loading users.</div>`;
651
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
652
  });
653
- }
654
- }
655
-
656
- function showPostForm(targetUser) {
657
- mainContent.style.opacity = 0;
658
- tg.BackButton.show();
659
- tg.BackButton.onClick(() => {
660
  tg.HapticFeedback.impactOccurred('light');
661
- loadUserWall(targetUser.id);
662
- });
663
- tg.MainButton.hide();
664
-
665
- const targetName = targetUser.first_name || targetUser.username || `User ${targetUser.id}`;
666
- let formHtml = `<div class="form-container"><h2>${texts.postTo} ${targetName}</h2>`;
667
-
668
- formHtml += `
669
- <div class="form-group">
670
- <label for="postText">${texts.postText} *</label>
671
- <textarea id="postText" required></textarea>
672
- </div>
673
- <div class="form-group">
674
- <label for="postType">${texts.postType}</label>
675
- <select id="postType">
676
- <option value="text">${texts.text}</option>
677
- <option value="photo">${texts.photo}</option>
678
- <option value="video">${texts.video}</option>
679
- <option value="document">${texts.document}</option>
680
- </select>
681
- </div>
682
- <div class="form-group" id="contentGroup" style="display:none;">
683
- <label for="contentUrl">${texts.contentUrl}</label>
684
- <input type="text" id="contentUrl">
685
- </div>
686
- `;
687
- formHtml += `<div id="formError" class="error-message"></div></div>`;
688
- mainContent.innerHTML = formHtml;
689
- setTimeout(() => { mainContent.style.opacity = 1; }, 50);
690
 
691
- document.getElementById('postType').addEventListener('change', (e) => {
692
- document.getElementById('contentGroup').style.display = e.target.value === 'text' ? 'none' : 'block';
693
- });
694
 
695
- tg.MainButton.setText(texts.postButton);
696
- tg.MainButton.show();
697
- tg.MainButton.onClick(() => handleSubmitPost(targetUser.id));
 
 
 
 
698
  }
699
 
700
- function handleSubmitPost(targetUserId) {
701
- const postText = document.getElementById('postText').value.trim();
702
- const postType = document.getElementById('postType').value;
703
- const contentUrl = document.getElementById('contentUrl').value.trim();
704
-
705
- document.getElementById('formError').textContent = '';
706
-
707
- if (!postText) {
708
- document.getElementById('formError').textContent = texts.requiredField;
709
- tg.HapticFeedback.notificationOccurred('error');
710
- return;
711
  }
712
-
713
- const payload = {
714
- target_user_id: targetUserId,
715
- text: postText,
716
- type: postType,
717
- content: postType === 'text' ? '' : contentUrl
718
- };
719
-
720
- if (postType !== 'text' && !contentUrl) {
721
- document.getElementById('formError').textContent = texts.requiredField + ' (' + texts.contentUrl + ')';
722
- tg.HapticFeedback.notificationOccurred('error');
723
- return;
724
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
 
726
- tg.MainButton.showProgress();
727
- tg.HapticFeedback.impactOccurred('light');
728
-
729
- apiCall('/api/posts', 'POST', payload)
730
- .then(response => {
731
- tg.HapticFeedback.notificationOccurred('success');
732
- tg.MainButton.hideProgress();
733
- loadUserWall(targetUserId);
734
- })
735
- .catch(err => {
736
- tg.HapticFeedback.notificationOccurred('error');
737
- tg.MainButton.hideProgress();
738
- document.getElementById('formError').textContent = texts.failedSubmit;
739
- });
740
  }
741
 
742
- function handleDeletePost(postId) {
743
- tg.showConfirm(texts.deleteConfirm, (confirmed) => {
744
- if (confirmed) {
745
- tg.HapticFeedback.impactOccurred('medium');
746
- apiCall(`/api/posts/${postId}`, 'DELETE')
747
- .then(() => {
748
- tg.HapticFeedback.notificationOccurred('success');
749
- tg.showAlert(texts.postDeleted);
750
- loadUserWall(currentTargetUser ? currentTargetUser.id : currentUser.id);
751
- })
752
- .catch(err => {
753
- tg.HapticFeedback.notificationOccurred('error');
754
- tg.showAlert(texts.failedDelete);
755
- });
756
  } else {
757
- tg.HapticFeedback.impactOccurred('light');
758
  }
759
  });
760
- }
761
-
762
- async function init() {
763
- tg.ready();
764
- applyThemeParams();
765
- tg.expand();
766
- tg.enableClosingConfirmation();
767
-
768
- tg.onEvent('themeChanged', applyThemeParams);
769
-
770
- try {
771
- const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
772
- currentUser = authResponse.user;
773
- } catch (error) {
774
- console.error("Auth error:", error);
775
- // Handle auth failure (e.g., show error message)
776
- }
777
-
778
- document.querySelectorAll('.nav-button').forEach(button => {
779
- button.addEventListener('click', () => loadView(button.dataset.nav));
780
- });
781
-
782
- loadView('my_wall');
783
- }
784
 
785
- init();
786
- </script>
787
  </body>
788
  </html>
789
  '''
790
 
791
- ADMIN_TEMPLATE = '''
792
- <!DOCTYPE html>
793
- <html lang="en">
794
- <head>
795
- <meta charset="UTF-8">
796
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
797
- <title>TonTalent Admin</title>
798
- <style>
799
- body { font-family: sans-serif; margin: 20px; background-color: #f4f4f4; color: #333; }
800
- .container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }
801
- h1, h2 { color: #333; }
802
- .section { margin-bottom: 30px; padding: 15px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9;}
803
- .item { border-bottom: 1px solid #eee; padding: 10px 0; }
804
- .item:last-child { border-bottom: none; }
805
- .item h3 { margin: 0 0 5px 0; }
806
- .item p { margin: 3px 0; font-size: 0.9em; color: #555; }
807
- .button { padding: 8px 15px; border: none; border-radius: 4px; cursor: pointer; font-size: 0.9em; margin-right: 5px; }
808
- .button-primary { background-color: #007bff; color: white; }
809
- .button-danger { background-color: #dc3545; color: white; }
810
- .button-secondary { background-color: #6c757d; color: white; }
811
- .message { padding: 10px; margin-bottom: 15px; border-radius: 4px; }
812
- .message.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
813
- .message.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
814
- .message.warning { background-color: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
815
- .sync-buttons form { display: inline-block; margin-right: 10px; }
816
- </style>
817
- </head>
818
- <body>
819
- <div class="container">
820
- <h1>TonTalent Wall Admin Panel</h1>
821
-
822
- {% with messages = get_flashed_messages(with_categories=true) %}
823
- {% if messages %}
824
- {% for category, message in messages %}
825
- <div class="message {{ category }}">{{ message }}</div>
826
- {% endfor %}
827
- {% endif %}
828
- {% endwith %}
829
-
830
- <div class="section">
831
- <h2>Data Synchronization with Hugging Face</h2>
832
- <div class="sync-buttons">
833
- <form method="POST" action="{{ url_for('force_upload_admin') }}" onsubmit="return confirm('Upload local data to Hugging Face? This will overwrite server data.');">
834
- <button type="submit" class="button button-primary">Upload DB to HF</button>
835
- </form>
836
- <form method="POST" action="{{ url_for('force_download_admin') }}" onsubmit="return confirm('Download data from Hugging Face? This will overwrite local data.');">
837
- <button type="submit" class="button button-secondary">Download DB from HF</button>
838
- </form>
839
- </div>
840
- <p style="font-size: 0.8em; color: #666;">Automatic backup runs every 30 minutes if HF_TOKEN_WRITE is set.</p>
841
- </div>
842
-
843
- <div class="section">
844
- <h2>Users ({{ users|length }})</h2>
845
- {% for user_id, user in users.items() %}
846
- <div class="item">
847
- <h3>{{ user.first_name }} {% if user.last_name %}{{ user.last_name }}{% endif %} (@{{ user.username }})</h3>
848
- <p>ID: {{ user.id }} | Last Seen: {{ user.last_seen.split('T')[0] }}</p>
849
- <p>Chat ID (for notifications): {{ user.chat_id }}</p>
850
- <form method="POST" action="{{ url_for('admin_delete_user') }}" style="display:inline;" onsubmit="return confirm('Delete user and all their posts/comments?');">
851
- <input type="hidden" name="user_id" value="{{ user.id }}">
852
- <button type="submit" class="button button-danger">Delete User</button>
853
- </form>
854
- </div>
855
- {% else %}
856
- <p>No users found.</p>
857
- {% endfor %}
858
- </div>
859
-
860
- <div class="section">
861
- <h2>Posts ({{ posts|length }})</h2>
862
- {% for post in posts %}
863
- <div class="item">
864
- <h3>Post {{ post.id[:8] }}... to User {{ post.target_user_id }}</h3>
865
- <p>Author ID: {{ post.user_id }} | Type: {{ post.type }} | Posted: {{ post.timestamp.split('T')[0] }}</p>
866
- <p>Text: {{ post.text[:100] }}...</p>
867
- {% if post.type != 'text' %}
868
- <p>Content: {{ post.content[:100] }}...</p>
869
- {% endif %}
870
- <form method="POST" action="{{ url_for('admin_delete_post') }}" style="display:inline;" onsubmit="return confirm('Delete this post?');">
871
- <input type="hidden" name="post_id" value="{{ post.id }}">
872
- <button type="submit" class="button button-danger">Delete Post</button>
873
- </form>
874
- </div>
875
- {% else %}
876
- <p>No posts found.</p>
877
- {% endfor %}
878
- </div>
879
- </div>
880
- </body>
881
- </html>
882
- '''
883
 
884
  @app.route('/')
885
  def main_app_view():
886
- return render_template_string(MAIN_APP_TEMPLATE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
887
 
888
- @app.route('/api/auth_user', methods=['POST'])
889
- def auth_user():
890
- auth_data_str = request.headers.get('X-Telegram-Auth')
891
- if not auth_data_str:
892
- init_data_payload = request.json.get('init_data')
893
- if init_data_payload:
894
- auth_data_str = init_data_payload
895
- else:
896
- return jsonify({"error": "Authentication data not provided"}), 401
897
-
898
- is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
899
-
900
- if not is_valid or not user_data_from_auth:
901
- return jsonify({"error": "Invalid authentication data"}), 403
902
-
903
- data = load_data()
904
- users = data.get('users', {})
905
- user_id_str = str(user_data_from_auth.get('id'))
906
-
907
- if user_id_str not in users:
908
- users[user_id_str] = {
909
- 'id': user_data_from_auth.get('id'),
910
- 'first_name': user_data_from_auth.get('first_name'),
911
- 'last_name': user_data_from_auth.get('last_name'),
912
- 'username': user_data_from_auth.get('username'),
913
- 'language_code': user_data_from_auth.get('language_code'),
914
- 'photo_url': user_data_from_auth.get('photo_url'),
915
- 'chat_id': user_id_str,
916
- 'first_seen': datetime.now().isoformat()
917
- }
918
-
919
- users[user_id_str]['last_seen'] = datetime.now().isoformat()
920
- if user_data_from_auth.get('photo_url'):
921
- users[user_id_str]['photo_url'] = user_data_from_auth.get('photo_url')
922
- if user_data_from_auth.get('username'):
923
- users[user_id_str]['username'] = user_data_from_auth.get('username')
924
-
925
- data['users'] = users
926
- save_data(data)
927
-
928
- return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
929
-
930
- @app.route('/api/users', methods=['GET'])
931
- def get_all_users():
932
- data = load_data()
933
- users = list(data.get('users', {}).values())
934
- users.sort(key=lambda u: u.get('first_name', '').lower())
935
- for user in users:
936
- user.pop('chat_id', None)
937
- return jsonify(users), 200
938
-
939
- @app.route('/api/users/<user_id>/wall', methods=['GET'])
940
- def get_user_wall(user_id):
941
- data = load_data()
942
- all_posts = data.get('posts', [])
943
- wall_posts = [p for p in all_posts if str(p.get('target_user_id')) == str(user_id)]
944
-
945
- wall_posts.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
946
-
947
- users = data.get('users', {})
948
- for post in wall_posts:
949
- author = users.get(str(post.get('user_id')))
950
- if author:
951
- post['author_username'] = author.get('username', 'anonymous')
952
- post['author_first_name'] = author.get('first_name', 'Anonymous')
953
- post['author_photo_url'] = author.get('photo_url')
954
-
955
- return jsonify(wall_posts), 200
956
-
957
- @app.route('/api/posts', methods=['POST'])
958
- def create_post():
959
- author = get_authenticated_user_details(request.headers)
960
- if not author:
961
- return jsonify({"error": "Authentication required or user not found in DB"}), 401
962
-
963
- req_data = request.json
964
- target_user_id = str(req_data.get('target_user_id'))
965
- post_text = req_data.get('text', '').strip()
966
- post_type = req_data.get('type', 'text')
967
- post_content = req_data.get('content', '').strip()
968
-
969
- if not target_user_id or (not post_text and not post_content):
970
- return jsonify({"error": "Missing target user ID or post content"}), 400
971
-
972
- if post_type not in ['text', 'photo', 'video', 'document']:
973
- return jsonify({"error": "Invalid post type"}), 400
974
-
975
- data = load_data()
976
- target_user = data.get('users', {}).get(target_user_id)
977
- if not target_user:
978
- return jsonify({"error": "Target user not found"}), 404
979
-
980
- if post_type != 'text' and not post_content:
981
- return jsonify({"error": f"Content is required for post type {post_type}"}), 400
982
-
983
- new_post = {
984
- "id": str(uuid.uuid4()),
985
- "user_id": str(author.get('id')),
986
- "target_user_id": target_user_id,
987
- "text": post_text,
988
- "type": post_type,
989
- "content": post_content,
990
- "timestamp": datetime.now().isoformat(),
991
- }
992
-
993
- data['posts'].append(new_post)
994
- save_data(data)
995
-
996
- if str(author.get('id')) != target_user_id:
997
- author_name = author.get('first_name', 'Someone')
998
- author_username = author.get('username')
999
- notification_message = (
1000
- f"<b>New post on your wall!</b>\n\n"
1001
- f"{author_name} ({f'@{author_username}' if author_username else 'ID: ' + str(author['id'])}) "
1002
- f"left a message: <i>{post_text[:50]}...</i>"
1003
- )
1004
- notify_user(target_user.get('chat_id'), notification_message)
1005
-
1006
- return jsonify(new_post), 201
1007
-
1008
- @app.route('/api/posts/<post_id>', methods=['DELETE'])
1009
- def delete_post(post_id):
1010
- user = get_authenticated_user_details(request.headers)
1011
- if not user: return jsonify({"error": "Authentication required or user not found in DB"}), 401
1012
-
1013
- data = load_data()
1014
- posts_list = data.get('posts', [])
1015
- original_length = len(posts_list)
1016
-
1017
- post_to_delete = next((p for p in posts_list if p['id'] == post_id), None)
1018
- if not post_to_delete: return jsonify({"error": "Post not found"}), 404
1019
-
1020
- if str(post_to_delete.get('user_id')) != str(user.get('id')):
1021
- return jsonify({"error": "Forbidden: You can only delete your own posts"}), 403
1022
-
1023
- data['posts'] = [p for p in posts_list if p['id'] != post_id]
1024
-
1025
- if len(data['posts']) < original_length:
1026
- save_data(data)
1027
- return jsonify({"message": "Post deleted successfully"}), 200
1028
- return jsonify({"error": "Post not found or deletion failed"}), 404
1029
-
1030
- @app.route('/admin', methods=['GET'])
1031
- def admin_panel():
1032
- data = load_data()
1033
- sorted_users = dict(sorted(data.get('users', {}).items(), key=lambda item: item[1].get('last_seen', ''), reverse=True))
1034
- sorted_posts = sorted(data.get('posts', []), key=lambda x: x.get('timestamp', ''), reverse=True)
1035
-
1036
- return render_template_string(ADMIN_TEMPLATE,
1037
- users=sorted_users,
1038
- posts=sorted_posts)
1039
-
1040
- @app.route('/admin/delete_user', methods=['POST'])
1041
- def admin_delete_user():
1042
- user_id = request.form.get('user_id')
1043
-
1044
- data = load_data()
1045
- if user_id in data.get('users', {}):
1046
- del data['users'][user_id]
1047
-
1048
- data['posts'] = [p for p in data.get('posts', []) if str(p.get('user_id')) != user_id and str(p.get('target_user_id')) != user_id]
1049
-
1050
- save_data(data)
1051
- flash(f"User {user_id} and all related posts deleted successfully.", 'success')
1052
- else:
1053
- flash("User not found.", 'warning')
1054
- return redirect(url_for('admin_panel'))
1055
-
1056
- @app.route('/admin/delete_post', methods=['POST'])
1057
- def admin_delete_post():
1058
- post_id = request.form.get('post_id')
1059
-
1060
- data = load_data()
1061
- posts_list = data.get('posts', [])
1062
- original_length = len(posts_list)
1063
-
1064
- data['posts'] = [p for p in posts_list if p['id'] != post_id]
1065
-
1066
- if len(data['posts']) < original_length:
1067
- save_data(data)
1068
- flash('Post deleted successfully.', 'success')
1069
- else:
1070
- flash('Post not found or already deleted.', 'warning')
1071
- return redirect(url_for('admin_panel'))
1072
-
1073
- @app.route('/admin/force_upload', methods=['POST'])
1074
- def force_upload_admin():
1075
- logging.info("Admin forcing upload to Hugging Face...")
1076
- try:
1077
- upload_db_to_hf()
1078
- flash("Data successfully uploaded to Hugging Face.", 'success')
1079
- except Exception as e:
1080
- logging.error(f"Error during forced upload: {e}", exc_info=True)
1081
- flash(f"Error uploading to Hugging Face: {e}", 'error')
1082
- return redirect(url_for('admin_panel'))
1083
-
1084
- @app.route('/admin/force_download', methods=['POST'])
1085
- def force_download_admin():
1086
- logging.info("Admin forcing download from Hugging Face...")
1087
- try:
1088
- if download_db_from_hf():
1089
- flash("Data successfully downloaded from Hugging Face. Local files updated.", 'success')
1090
- load_data()
1091
- else:
1092
- flash("Failed to download data from Hugging Face. Check logs.", 'error')
1093
- except Exception as e:
1094
- logging.error(f"Error during forced download: {e}", exc_info=True)
1095
- flash(f"Error downloading from Hugging Face: {e}", 'error')
1096
- return redirect(url_for('admin_panel'))
1097
-
1098
-
1099
- if __name__ == '__main__':
1100
- logging.info("Application starting up. Performing initial data load/download...")
1101
- download_db_from_hf()
1102
- load_data()
1103
- logging.info("Initial data load complete.")
1104
-
1105
- if HF_TOKEN_WRITE:
1106
- backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1107
- backup_thread.start()
1108
- logging.info("Periodic backup thread started.")
1109
- else:
1110
- logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
1111
-
1112
- port = int(os.environ.get('PORT', 7860))
1113
- logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
1114
- app.run(debug=False, host='0.0.0.0', port=port)
 
1
+
2
+
3
+ from flask import Flask, render_template_string, request, redirect, url_for, jsonify, flash, send_from_directory
4
  import json
5
  import os
6
  import logging
 
19
 
20
  load_dotenv()
21
 
22
+ app = Flask(name)
23
+ app.secret_key = os.getenv("FLASK_SECRET_KEY", 'telegram_wall_secret_key_for_flash_messages_only')
24
+
25
+ --- CONFIGURATION ---
26
+
27
  DATA_FILE = 'wall_data.json'
28
+ UPLOAD_FOLDER = 'uploads'
29
+ SYNC_FILES = [DATA_FILE, UPLOAD_FOLDER] # Will be treated as a list of files/folders
30
+ MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB limit for file uploads
31
+ ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'mp4', 'mov', 'avi', 'pdf', 'doc', 'docx', 'txt'}
32
 
33
+ if not os.path.exists(UPLOAD_FOLDER):
34
+ os.makedirs(UPLOAD_FOLDER)
35
+
36
+ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
37
+ app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
38
+
39
+ REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/telegram-wall-app")
40
  HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE")
41
  HF_TOKEN_READ = os.getenv("HF_TOKEN_READ")
42
 
43
+ NOTE: The provided token is not a real Telegram Bot Token format.
44
+ A real token looks like: <BOT_ID>:<RANDOM_STRING>
45
+
46
  TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "7549355625:AAGYWatM-nUVQirgBiBwoAtWZgzfp3QnQjY")
47
 
48
  DOWNLOAD_RETRIES = 3
 
50
 
51
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
52
 
53
+ --- UTILITIES ---
54
+
55
+ def allowed_file(filename):
56
+ return '.' in filename and
57
+ filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
58
+
59
  def download_db_from_hf(specific_file=None, retries=DOWNLOAD_RETRIES, delay=DOWNLOAD_DELAY):
60
+ token_to_use = HF_TOKEN_READ if HF_TOKEN_READ else HF_TOKEN_WRITE
61
+ files_to_download = [specific_file] if specific_file else SYNC_FILES
62
+ all_successful = True
63
+ for file_name in files_to_download:
64
+ if file_name == UPLOAD_FOLDER:
65
+ logging.info("Skipping UPLOAD_FOLDER direct download as it's typically large. Requires git-lfs/separate process.")
66
+ continue
67
+
68
+ code
69
+ Code
70
+ download
71
+ content_copy
72
+ expand_less
73
+ success = False
74
+ for attempt in range(retries + 1):
75
+ try:
76
+ logging.info(f"Downloading {file_name} (Attempt {attempt + 1}/{retries + 1})...")
77
+ local_path = hf_hub_download(
78
+ repo_id=REPO_ID, filename=file_name, repo_type="dataset",
79
+ token=token_to_use, local_dir=".", local_dir_use_symlinks=False,
80
+ force_download=True, resume_download=False
81
+ )
82
+ logging.info(f"Successfully downloaded {file_name} to {local_path}.")
83
+ success = True
84
+ break
85
+ except RepositoryNotFoundError:
86
+ logging.error(f"Repository {REPO_ID} not found. Download cancelled for all files.")
87
+ return False
88
+ except HfHubHTTPError as e:
89
+ if e.response.status_code == 404:
90
+ logging.warning(f"File {file_name} not found in repo {REPO_ID} (404). Skipping this file.")
91
+ if attempt == 0 and not os.path.exists(file_name):
92
+ try:
93
+ if file_name == DATA_FILE:
94
+ default_data = {'posts': [], 'users': {}}
95
+ with open(file_name, 'w', encoding='utf-8') as f:
96
+ json.dump(default_data, f)
97
+ logging.info(f"Created empty local file {file_name} because it was not found on HF.")
98
+ except Exception as create_e:
99
+ logging.error(f"Failed to create empty local file {file_name}: {create_e}")
100
  success = True
101
  break
102
+ else:
103
+ logging.error(f"HTTP error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...")
104
+ except Exception as e:
105
+ logging.error(f"Unexpected error downloading {file_name} (Attempt {attempt + 1}): {e}. Retrying in {delay}s...", exc_info=True)
106
+ if attempt < retries: time.sleep(delay)
107
+ if not success:
108
+ logging.error(f"Failed to download {file_name} after {retries + 1} attempts.")
109
+ all_successful = False
110
+ return all_successful
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
  def upload_db_to_hf(specific_file=None):
113
+ if not HF_TOKEN_WRITE:
114
+ logging.warning("HF_TOKEN_WRITE not set. Skipping upload to Hugging Face.")
115
+ return
116
+ try:
117
+ api = HfApi()
118
+ files_to_upload = [specific_file] if specific_file and specific_file != UPLOAD_FOLDER else []
119
+
120
+ code
121
+ Code
122
+ download
123
+ content_copy
124
+ expand_less
125
+ if not specific_file:
126
+ files_to_upload = [DATA_FILE]
127
+ # Add files from UPLOAD_FOLDER
128
+ for root, _, files in os.walk(UPLOAD_FOLDER):
129
+ for file_name in files:
130
+ local_path = os.path.join(root, file_name)
131
+ path_in_repo = local_path
132
+ files_to_upload.append((local_path, path_in_repo))
133
+
134
+ logging.info(f"Starting upload of {len(files_to_upload)} files/paths to HF repo {REPO_ID}...")
135
+
136
+ for item in files_to_upload:
137
+ local_path = item if isinstance(item, tuple) else item
138
+ path_in_repo = item if isinstance(item, tuple) else item
139
+
140
+ if os.path.exists(local_path):
141
+ try:
142
+ api.upload_file(
143
+ path_or_fileobj=local_path, path_in_repo=path_in_repo, repo_id=REPO_ID,
144
+ repo_type="dataset", token=HF_TOKEN_WRITE,
145
+ commit_message=f"Sync {path_in_repo} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
146
+ )
147
+ logging.info(f"File {path_in_repo} successfully uploaded to Hugging Face.")
148
+ except Exception as e:
149
+ logging.error(f"Error uploading file {path_in_repo} to Hugging Face: {e}")
150
+ else:
151
+ logging.warning(f"File {local_path} not found locally, skipping upload.")
152
+ logging.info("Finished uploading files to HF.")
153
+ except Exception as e:
154
+ logging.error(f"General error during Hugging Face upload initialization or process: {e}", exc_info=True)
155
 
156
  def periodic_backup():
157
+ backup_interval = 1800
158
+ logging.info(f"Setting up periodic backup every {backup_interval} seconds.")
159
+ while True:
160
+ time.sleep(backup_interval)
161
+ logging.info("Starting periodic backup...")
162
+ upload_db_to_hf()
163
+ logging.info("Periodic backup finished.")
164
 
165
  def load_data():
166
+ default_data = {'posts': [], 'users': {}}
167
+ try:
168
+ with open(DATA_FILE, 'r', encoding='utf-8') as file:
169
+ data = json.load(file)
170
+ logging.info(f"Local data loaded successfully from {DATA_FILE}")
171
+ if not isinstance(data, dict): raise FileNotFoundError
172
+ for key in default_data:
173
+ if key not in data: data[key] = default_data[key]
174
+ return data
175
+ except (FileNotFoundError, json.JSONDecodeError) as e:
176
+ logging.warning(f"Error loading local data ({e}). Attempting download from HF.")
177
+
178
+ code
179
+ Code
180
+ download
181
+ content_copy
182
+ expand_less
183
+ if download_db_from_hf(specific_file=DATA_FILE):
184
  try:
185
  with open(DATA_FILE, 'r', encoding='utf-8') as file:
186
  data = json.load(file)
187
+ logging.info(f"Data loaded successfully from {DATA_FILE} after download.")
188
+ if not isinstance(data, dict): return default_data
 
 
189
  for key in default_data:
190
  if key not in data: data[key] = default_data[key]
191
  return data
192
+ except Exception as load_e:
193
+ logging.error(f"Error loading downloaded {DATA_FILE}: {load_e}. Using default.", exc_info=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  return default_data
195
+ else:
196
+ logging.error(f"Failed to download {DATA_FILE} from HF. Using empty default data structure.")
197
+ if not os.path.exists(DATA_FILE):
198
+ try:
199
+ with open(DATA_FILE, 'w', encoding='utf-8') as f: json.dump(default_data, f)
200
+ logging.info(f"Created empty local file {DATA_FILE} after failed download.")
201
+ except Exception as create_e:
202
+ logging.error(f"Failed to create empty local file {DATA_FILE}: {create_e}")
203
+ return default_data
204
 
205
  def save_data(data):
206
+ try:
207
+ if not isinstance(data, dict):
208
+ logging.error("Attempted to save invalid data structure (not a dict). Aborting save.")
209
+ return
210
+ default_keys = {'posts': [], 'users': {}}
211
+ for key in default_keys:
212
+ if key not in data: data[key] = default_keys[key]
213
+
214
+ code
215
+ Code
216
+ download
217
+ content_copy
218
+ expand_less
219
+ with open(DATA_FILE, 'w', encoding='utf-8') as file:
220
+ json.dump(data, file, ensure_ascii=False, indent=4)
221
+ logging.info(f"Data successfully saved to {DATA_FILE}")
222
+ threading.Thread(target=upload_db_to_hf, args=(DATA_FILE,), daemon=True).start()
223
+ except Exception as e:
224
+ logging.error(f"Error saving data to {DATA_FILE}: {e}", exc_info=True)
225
 
226
  def verify_telegram_auth_data(auth_data_str, bot_token):
227
+ if not auth_data_str:
228
+ return False, None
229
+
230
+ code
231
+ Code
232
+ download
233
+ content_copy
234
+ expand_less
235
+ params = dict(urllib.parse.parse_qsl(auth_data_str))
236
+ if 'hash' not in params:
237
+ return False, None
238
+
239
+ received_hash = params.pop('hash')
240
+
241
+ sorted_params = sorted(params.items())
242
+ data_check_string_parts = []
243
+ for key, value in sorted_params:
244
+ data_check_string_parts.append(f"{key}={value}")
245
+
246
+ data_check_string = "\n".join(data_check_string_parts)
247
+
248
+ secret_key = hmac.new("WebAppData".encode(), bot_token.encode(), hashlib.sha256).digest()
249
+ calculated_hash = hmac.new(secret_key, data_check_string.encode(), hashlib.sha256).hexdigest()
250
+
251
+ if calculated_hash == received_hash:
252
+ try:
253
+ user_data = json.loads(params.get('user', '{}'))
254
+ # Get chat_id if available (often same as user_id in private chats)
255
+ chat_id = params.get('chat_id', user_data.get('id'))
256
+ user_data['chat_id'] = chat_id
257
+ return True, user_data
258
+ except json.JSONDecodeError:
259
  return False, None
260
+ return False, None
261
 
262
+ def get_authenticated_user_details(request_headers):
263
+ auth_data_str = request_headers.get('X-Telegram-Auth')
264
+ if not auth_data_str:
265
+ return None
266
+ is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
267
+ if is_valid and user_data_from_auth:
268
+ data = load_data()
269
+ user_id_str = str(user_data_from_auth.get('id'))
270
+ return data.get('users', {}).get(user_id_str)
271
+ return None
272
+
273
+ def send_telegram_notification(chat_id, message):
274
+ if not TELEGRAM_BOT_TOKEN:
275
+ logging.warning("TELEGRAM_BOT_TOKEN not set. Cannot send notification.")
276
+ return
277
+
278
+ code
279
+ Code
280
+ download
281
+ content_copy
282
+ expand_less
283
+ url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
284
+ payload = {
285
+ 'chat_id': chat_id,
286
+ 'text': message,
287
+ 'parse_mode': 'HTML'
288
+ }
289
+
290
+ try:
291
+ response = requests.post(url, data=payload, timeout=5)
292
+ response.raise_for_status()
293
+ logging.info(f"Notification sent to {chat_id}.")
294
+ return response.json()
295
+ except requests.exceptions.RequestException as e:
296
+ logging.error(f"Error sending Telegram notification to {chat_id}: {e}")
297
+ return None
298
+ --- API ROUTES ---
299
+
300
+ @app.route('/api/auth_user', methods=['POST'])
301
+ def auth_user():
302
+ auth_data_str = request.headers.get('X-Telegram-Auth')
303
+ if not auth_data_str:
304
+ init_data_payload = request.json.get('init_data')
305
+ if init_data_payload:
306
+ auth_data_str = init_data_payload
307
+ else:
308
+ return jsonify({"error": "Authentication data not provided"}), 401
309
+
310
+ code
311
+ Code
312
+ download
313
+ content_copy
314
+ expand_less
315
+ is_valid, user_data_from_auth = verify_telegram_auth_data(auth_data_str, TELEGRAM_BOT_TOKEN)
316
+
317
+ if not is_valid or not user_data_from_auth:
318
+ return jsonify({"error": "Invalid authentication data"}), 403
319
+
320
+ data = load_data()
321
+ users = data.get('users', {})
322
+ user_id_str = str(user_data_from_auth.get('id'))
323
+
324
+ # Update/Create user record
325
+ user_record = users.get(user_id_str, {})
326
+
327
+ # Store chat_id (assuming it is available or fallback to user_id)
328
+ # Note: In a real-world bot, the chat_id should be captured during the /start command.
329
+ user_record.update({
330
+ 'id': user_data_from_auth.get('id'),
331
+ 'chat_id': user_data_from_auth.get('chat_id') or user_data_from_auth.get('id'), # Critical for notifications
332
+ 'first_name': user_data_from_auth.get('first_name'),
333
+ 'last_name': user_data_from_auth.get('last_name'),
334
+ 'username': user_data_from_auth.get('username'),
335
+ 'language_code': user_data_from_auth.get('language_code'),
336
+ 'photo_url': user_data_from_auth.get('photo_url'),
337
+ 'last_seen': datetime.now().isoformat()
338
+ })
339
+
340
+ if 'first_seen' not in user_record:
341
+ user_record['first_seen'] = datetime.now().isoformat()
342
+
343
+ users[user_id_str] = user_record
344
+ data['users'] = users
345
+ save_data(data)
346
+
347
+ return jsonify({"message": "User authenticated", "user": users[user_id_str]}), 200
348
+
349
+ @app.route('/api/users', methods=['GET'])
350
+ def get_users():
351
+ # Only authenticated users can view the list
352
+ if not get_authenticated_user_details(request.headers):
353
+ return jsonify({"error": "Authentication required"}), 401
354
+
355
+ code
356
+ Code
357
+ download
358
+ content_copy
359
+ expand_less
360
+ data = load_data()
361
+ user_list = list(data.get('users', {}).values())
362
+ # Return limited user info for the list
363
+ safe_user_list = [{
364
+ 'id': user['id'],
365
+ 'first_name': user['first_name'],
366
+ 'last_name': user['last_name'],
367
+ 'username': user.get('username'),
368
+ 'photo_url': user.get('photo_url')
369
+ } for user in user_list]
370
+
371
+ return jsonify(sorted(safe_user_list, key=lambda x: x.get('first_name', 'z'))), 200
372
+
373
+ @app.route('/api/wall/<user_id>', methods=['GET'])
374
+ def get_user_wall(user_id):
375
+ if not get_authenticated_user_details(request.headers):
376
+ return jsonify({"error": "Authentication required"}), 401
377
+
378
+ code
379
+ Code
380
+ download
381
+ content_copy
382
+ expand_less
383
+ data = load_data()
384
+ all_posts = data.get('posts', [])
385
+
386
+ # Filter posts:
387
+ # 1. Posts made by the user_id on their own wall (target_user_id is None or same as user_id)
388
+ # 2. Posts made by *others* targeting this user_id's wall
389
+ wall_posts = [
390
+ post for post in all_posts
391
+ if (str(post.get('user_id')) == str(user_id) and not post.get('target_user_id')) or
392
+ (str(post.get('target_user_id')) == str(user_id))
393
+ ]
394
+
395
+ # Enrich posts with user info (poster and target)
396
+ users = data.get('users', {})
397
+ enriched_posts = []
398
+ for post in sorted(wall_posts, key=lambda x: x.get('timestamp', ''), reverse=True):
399
+ poster = users.get(str(post['user_id']), {})
400
+ target = users.get(str(post.get('target_user_id')), {}) if post.get('target_user_id') else None
401
 
402
+ enriched_post = post.copy()
403
+ enriched_post['poster_name'] = f"{poster.get('first_name', 'Unknown')} {poster.get('last_name', '')}".strip()
404
+ enriched_post['poster_username'] = poster.get('username', None)
405
+ if target:
406
+ enriched_post['target_name'] = f"{target.get('first_name', 'Unknown')} {target.get('last_name', '')}".strip()
407
+ enriched_post['target_username'] = target.get('username', None)
408
 
409
+ enriched_posts.append(enriched_post)
410
 
411
+ return jsonify(enriched_posts), 200
412
+
413
+ @app.route('/api/post/<target_user_id>', methods=['POST'])
414
+ def create_post(target_user_id):
415
+ user = get_authenticated_user_details(request.headers)
416
+ if not user:
417
+ return jsonify({"error": "Authentication required or user not found in DB"}), 401
418
+
419
+ code
420
+ Code
421
+ download
422
+ content_copy
423
+ expand_less
424
+ if str(target_user_id) == 'me':
425
+ target_user_id = str(user['id'])
426
+
427
+ data = load_data()
428
+ users = data.get('users', {})
429
+ target_user = users.get(str(target_user_id))
430
+
431
+ if not target_user:
432
+ return jsonify({"error": "Target user not found"}), 404
433
+
434
+ text_content = request.form.get('content', '').strip()
435
+ file = request.files.get('file')
436
+
437
+ if not text_content and not file:
438
+ return jsonify({"error": "Post must contain text or a file"}), 400
439
+
440
+ new_post = {
441
+ "id": str(uuid.uuid4()),
442
+ "user_id": str(user['id']),
443
+ "target_user_id": str(target_user_id) if str(target_user_id) != str(user['id']) else None, # Null for own wall post
444
+ "timestamp": datetime.now().isoformat(),
445
+ "content": text_content,
446
+ "type": "text",
447
+ "file_path": None
448
+ }
449
+
450
+ # Handle file upload
451
+ if file and file.filename:
452
+ if not allowed_file(file.filename):
453
+ return jsonify({"error": "File type not allowed"}), 400
454
 
455
+ extension = file.filename.rsplit('.', 1)[[1](https://www.google.com/url?sa=E&q=https%3A%2F%2Fvertexaisearch.cloud.google.com%2Fgrounding-api-redirect%2FAUZIYQF6S04rq1C0Z_rbzF1NSAxsil9bBG4L3nuHOSOAbHHtJiwnE2LxVsPOVpiPhBXRK6XaybxBsn0UZ9Mn1KLhTGONkEjmCPX1AD7mT0SoQ15oTUhmR7n6PGa73aBGIEQ67iFmSMDvPPA3aXv6RLq5SesfTK1HYQ3z5Q%3D%3D)].lower()
456
+ filename = secure_filename(f"{new_post['id']}.{extension}")
457
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
458
+
459
+ try:
460
+ file.save(file_path)
461
+ new_post['file_path'] = filename
462
+
463
+ if extension in ['png', 'jpg', 'jpeg', 'gif']: new_post['type'] = 'photo'
464
+ elif extension in ['mp4', 'mov', 'avi']: new_post['type'] = 'video'
465
+ else: new_post['type'] = 'document'
466
+
467
+ # Asynchronously upload file to HF
468
+ threading.Thread(target=upload_db_to_hf, args=((file_path, os.path.join(UPLOAD_FOLDER, filename)),), daemon=True).start()
469
 
470
+ except Exception as e:
471
+ logging.error(f"File save/upload failed: {e}")
472
+ return jsonify({"error": f"Failed to save file: {e}"}), 500
 
 
 
 
 
 
 
473
 
474
+ data['posts'].append(new_post)
475
+ save_data(data)
476
+
477
+ # --- Notification Logic ---
478
+ if str(target_user_id) != str(user['id']):
479
+ # Post to another user's wall, send notification to the target user
480
+ poster_name = user.get('first_name', 'Someone')
481
+ target_chat_id = target_user.get('chat_id')
482
 
483
+ if target_chat_id:
484
+ message = f"📢 New post on your wall!\n\n<b>{poster_name}</b> posted: {text_content[:100]}..."
485
+ threading.Thread(target=send_telegram_notification, args=(target_chat_id, message), daemon=True).start()
486
+
487
+ return jsonify(new_post), 201
488
+
489
+ @app.route('/api/post/<post_id>', methods=['DELETE'])
490
+ def delete_post(post_id):
491
+ user = get_authenticated_user_details(request.headers)
492
+ if not user: return jsonify({"error": "Authentication required or user not found in DB"}), 401
493
+
494
+ code
495
+ Code
496
+ download
497
+ content_copy
498
+ expand_less
499
+ data = load_data()
500
+ posts_list = data.get('posts', [])
501
+ original_length = len(posts_list)
502
+
503
+ item_to_delete = next((i for i in posts_list if i['id'] == post_id), None)
504
+ if not item_to_delete: return jsonify({"error": "Post not found"}), 404
505
+
506
+ # Allow deleting if the user is the poster OR the user is the wall owner (if target_user_id is set)
507
+ is_poster = str(item_to_delete.get('user_id')) == str(user.get('id'))
508
+ is_wall_owner = str(item_to_delete.get('target_user_id')) == str(user.get('id'))
509
+
510
+ if not is_poster and not is_wall_owner:
511
+ return jsonify({"error": "Forbidden: You can only delete your own posts or posts on your wall"}), 403
512
+
513
+ # Delete the associated file if it exists
514
+ file_path = item_to_delete.get('file_path')
515
+ if file_path:
516
+ full_path = os.path.join(app.config['UPLOAD_FOLDER'], file_path)
517
+ if os.path.exists(full_path):
518
+ os.remove(full_path)
519
+ logging.info(f"Deleted file: {full_path}")
520
+ # Note: File deletion on HF requires complex logic (git delete commit) - skipping for this example.
521
+
522
+ data['posts'] = [i for i in posts_list if i['id'] != post_id]
523
+
524
+ if len(data['posts']) < original_length:
525
+ save_data(data)
526
+ return jsonify({"message": "Post deleted successfully"}), 200
527
+ return jsonify({"error": "Post not found or deletion failed"}), 404
528
 
529
+ @app.route('/uploads/<filename>', methods=['GET'])
530
+ def uploaded_file(filename):
531
+ return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
532
+
533
+ --- WEBAPP TEMPLATES & MAIN VIEW ---
534
 
535
  MAIN_APP_TEMPLATE = '''
536
+
537
  <!DOCTYPE html>
538
+
539
  <html lang="en">
540
  <head>
541
+ <meta charset="UTF-8">
542
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
543
+ <title>TonWall</title>
544
+ <script src="https://telegram.org/js/telegram-web-app.js"></script>
545
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
546
+ <style>
547
+ :root {
548
+ --tg-theme-bg-color: #ffffff;
549
+ --tg-theme-text-color: #000000;
550
+ --tg-theme-hint-color: #999999;
551
+ --tg-theme-link-color: #007aff;
552
+ --tg-theme-button-color: #007aff;
553
+ --tg-theme-button-text-color: #ffffff;
554
+ --tg-theme-secondary-bg-color: #f0f0f0;
555
+ --tg-theme-header-bg-color: #efeff4;
556
+ --tg-theme-section-bg-color: #ffffff;
557
+ --tg-theme-destructive-text-color: #ff3b30;
558
+ }
559
+ body {
560
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", Arial, sans-serif;
561
+ margin: 0;
562
+ padding: 0;
563
+ background-color: var(--tg-theme-bg-color);
564
+ color: var(--tg-theme-text-color);
565
+ overscroll-behavior-y: none;
566
+ -webkit-font-smoothing: antialiased;
567
+ min-height: 100vh;
568
+ display: flex;
569
+ flex-direction: column;
570
+ }
571
+ .header {
572
+ background-color: var(--tg-theme-header-bg-color);
573
+ padding: 12px 15px;
574
+ text-align: center;
575
+ font-weight: 600;
576
+ font-size: 17px;
577
+ border-bottom: 0.5px solid var(--tg-theme-secondary-bg-color);
578
+ position: sticky;
579
+ top: 0;
580
+ z-index: 100;
581
+ }
582
+ .content { flex-grow: 1; padding: 10px 0; overflow-x: hidden; transition: opacity 0.2s ease-out; }
583
+ .footer-nav {
584
+ display: flex;
585
+ justify-content: space-around;
586
+ background-color: var(--tg-theme-header-bg-color);
587
+ border-top: 0.5px solid var(--tg-theme-hint-color);
588
+ position: sticky;
589
+ bottom: 0;
590
+ z-index: 100;
591
+ padding-top: 5px;
592
+ padding-bottom: env(safe-area-inset-bottom);
593
+ }
594
+ .nav-button {
595
+ display: flex;
596
+ flex-direction: column;
597
+ align-items: center;
598
+ padding: 5px 0 8px 0;
599
+ flex-grow: 1;
600
+ cursor: pointer;
601
+ background: none;
602
+ border: none;
603
+ color: var(--tg-theme-hint-color);
604
+ font-size: 11px;
605
+ font-weight: 500;
606
+ -webkit-tap-highlight-color: transparent;
607
+ transition: color 0.2s ease;
608
+ }
609
+ .nav-button.active { color: var(--tg-theme-link-color); }
610
+ .nav-button i { font-size: 20px; margin-bottom: 2px; }
611
+
612
+ code
613
+ Code
614
+ download
615
+ content_copy
616
+ expand_less
617
+ /* Wall Styles */
618
+ .post-list { display: flex; flex-direction: column; gap: 10px; padding: 0 15px; }
619
+ .post-card {
620
+ background-color: var(--tg-theme-section-bg-color);
621
+ padding: 15px;
622
+ border-radius: 10px;
623
+ box-shadow: 0 2px 8px rgba(0,0,0,0.06);
624
+ word-wrap: break-word;
625
+ }
626
+ .post-header { display: flex; align-items: center; margin-bottom: 10px; }
627
+ .post-header img { width: 40px; height: 40px; border-radius: 50%; margin-right: 10px; object-fit: cover; background-color: var(--tg-theme-secondary-bg-color); }
628
+ .post-info { flex-grow: 1; }
629
+ .post-info h4 { margin: 0; font-size: 15px; font-weight: 600; color: var(--tg-theme-text-color); }
630
+ .post-info span { font-size: 12px; color: var(--tg-theme-hint-color); }
631
+ .post-content p { margin: 5px 0; font-size: 15px; color: var(--tg-theme-text-color); white-space: pre-wrap; }
632
+ .post-content a { color: var(--tg-theme-link-color); text-decoration: none; }
633
+ .post-content a:hover { text-decoration: underline; }
634
+ .post-content .media-container { max-width: 100%; border-radius: 8px; overflow: hidden; margin-top: 10px; }
635
+ .post-content img, .post-content video { width: 100%; height: auto; display: block; }
636
+
637
+ /* User List Styles */
638
+ .user-list { padding: 10px 15px; display: flex; flex-direction: column; gap: 1px; }
639
+ .user-list-item {
640
+ display: flex;
641
+ align-items: center;
642
+ padding: 12px 15px;
643
+ background-color: var(--tg-theme-section-bg-color);
644
+ border-radius: 8px;
645
+ margin-bottom: 8px;
646
+ box-shadow: 0 1px 4px rgba(0,0,0,0.05);
647
+ cursor: pointer;
648
+ transition: background-color 0.1s ease;
649
+ }
650
+ .user-list-item:active { background-color: var(--tg-theme-secondary-bg-color); }
651
+ .user-list-item img { width: 45px; height: 45px; border-radius: 50%; margin-right: 15px; object-fit: cover; background-color: var(--tg-theme-secondary-bg-color); }
652
+ .user-list-item span { font-size: 16px; font-weight: 500; color: var(--tg-theme-text-color); }
653
+
654
+ /* Form Styles */
655
+ .form-container { padding: 20px 15px; }
656
+ .form-group { margin-bottom: 18px; }
657
+ .form-group label { display: block; font-size: 14px; color: var(--tg-theme-hint-color); margin-bottom: 6px; font-weight: 500; }
658
+ .form-group textarea {
659
+ width: 100%;
660
+ padding: 12px;
661
+ border: 1px solid var(--tg-theme-secondary-bg-color);
662
+ border-radius: 8px;
663
+ font-size: 16px;
664
+ background-color: var(--tg-theme-bg-color);
665
+ color: var(--tg-theme-text-color);
666
+ box-sizing: border-box;
667
+ transition: border-color 0.2s ease;
668
+ min-height: 100px;
669
+ resize: vertical;
670
+ }
671
+ .form-group input[type="file"] { border: none; padding: 5px 0; }
672
+ .form-group textarea:focus { border-color: var(--tg-theme-link-color); outline: none; }
673
+ .error-message { color: var(--tg-theme-destructive-text-color); font-size: 14px; margin-top: 10px; text-align: center; }
674
+ .loading, .empty-state { text-align: center; padding: 50px 15px; color: var(--tg-theme-hint-color); font-size: 16px; }
675
+
676
+ .delete-button {
677
+ margin-top: 10px;
678
+ padding: 6px 12px;
679
+ background-color: var(--tg-theme-destructive-text-color);
680
+ color: var(--tg-theme-button-text-color);
681
+ border: none;
682
+ border-radius: 6px;
683
+ cursor: pointer;
684
+ font-size: 13px;
685
+ transition: opacity 0.1s;
686
+ }
687
+ .delete-button:active { opacity: 0.8; }
688
+
689
+ /* Media utility classes */
690
+ .media-image { max-height: 400px; object-fit: cover; }
691
+ .media-video { max-height: 400px; }
692
+ .media-document {
693
+ background-color: var(--tg-theme-secondary-bg-color);
694
+ padding: 10px;
695
+ border-radius: 6px;
696
+ font-size: 14px;
697
+ display: flex;
698
+ align-items: center;
699
+ }
700
+ .media-document i { margin-right: 8px; font-size: 18px; color: var(--tg-theme-link-color); }
701
+ .media-document span { font-weight: 500; }
702
+ </style>
703
  </head>
704
  <body>
705
+ <div class="app-container">
706
+ <div class="header" id="appHeader"></div>
707
+ <div class="content" id="mainContent">
708
+ <div class="loading">Loading...</div>
709
+ </div>
710
+ <div class="footer-nav" id="footerNav">
711
+ <button class="nav-button active" data-view="my_wall" id="navMyWall">
712
+ <i class="fas fa-home"></i>
713
+ <span id="labelMyWall">My Wall</span>
714
+ </button>
715
+ <button class="nav-button" data-view="users" id="navUsers">
716
+ <i class="fas fa-users"></i>
717
+ <span id="labelUsers">Users</span>
718
+ </button>
719
+ <button class="nav-button" data-view="new_post" id="navNewPost">
720
+ <i class="fas fa-plus-circle"></i>
721
+ <span id="labelNewPost">New Post</span>
722
+ </button>
723
+ </div>
724
+ </div>
725
+
726
+ code
727
+ Code
728
+ download
729
+ content_copy
730
+ expand_less
731
+ <script>
732
+ const tg = window.Telegram.WebApp;
733
+ let currentUser = null;
734
+ let currentView = 'my_wall';
735
+ let currentTargetUserId = null;
736
+ const mainContent = document.getElementById('mainContent');
737
+ const footerNav = document.getElementById('footerNav');
738
+ const appHeader = document.getElementById('appHeader');
739
+
740
+ const langCode = tg.initDataUnsafe.user?.language_code;
741
+ const isRussian = langCode && (langCode.startsWith('ru') || langCode.startsWith('uk') || langCode.startsWith('be'));
742
+
743
+ const T = {
744
+ 'My Wall': isRussian ? 'Моя Стена' : 'My Wall',
745
+ 'Users': isRussian ? 'Пользователи' : 'Users',
746
+ 'New Post': isRussian ? 'Новая Запись' : 'New Post',
747
+ 'Post on Wall': isRussian ? 'Опубликовать на Стену' : 'Post on Wall',
748
+ 'Users List': isRussian ? 'Список Пользователей' : 'Users List',
749
+ 'Wall of': isRussian ? 'Стена' : 'Wall of',
750
+ 'Post Text': isRussian ? 'Текст записи...' : 'Post Text...',
751
+ 'Attachment (optional)': isRussian ? 'Вложение (необязательно)' : 'Attachment (optional)',
752
+ 'Post': isRussian ? 'Опубликовать' : 'Post',
753
+ 'No posts found.': isRussian ? 'Записей не найдено.' : 'No posts found.',
754
+ 'No users found.': isRussian ? 'Пользователей не найдено.' : 'No users found.',
755
+ 'Loading...': isRussian ? 'Загрузка...' : 'Loading...',
756
+ 'Posted by': isRussian ? 'Опубликовано' : 'Posted by',
757
+ 'on': isRussian ? 'на' : 'on',
758
+ 'View Wall': isRussian ? 'Посмотреть Стену' : 'View Wall',
759
+ 'Delete Post?': isRussian ? 'Удалить эту запись?' : 'Delete this post?',
760
+ 'Post deleted.': isRussian ? 'Запись удалена.' : 'Post deleted.',
761
+ 'Post failed.': isRussian ? 'Ошибка публикации.' : 'Post failed.',
762
+ 'Error loading.': isRussian ? 'Ошибка загрузки.' : 'Error loading.',
763
+ 'File': isRussian ? 'Файл' : 'File',
764
+ 'Document': isRussian ? 'Документ' : 'Document',
765
+ 'Video': isRussian ? 'Видео' : 'Video',
766
+ 'Photo': isRussian ? 'Фото' : 'Photo',
767
+ 'Post must contain text or a file': isRussian ? 'Запись должна содержать текст или файл' : 'Post must contain text or a file',
768
+ 'Delete': isRussian ? 'Удалить' : 'Delete',
769
+ 'to your wall': isRussian ? 'на вашу стену' : 'to your wall',
770
+ 'on the wall of': isRussian ? 'на стене' : 'on the wall of'
771
+ };
772
+
773
+ function translateUI() {
774
+ document.getElementById('appHeader').textContent = 'TonWall';
775
+ document.getElementById('labelMyWall').textContent = T['My Wall'];
776
+ document.getElementById('labelUsers').textContent = T['Users'];
777
+ document.getElementById('labelNewPost').textContent = T['New Post'];
778
+ }
779
+
780
+ function applyThemeParams() {
781
+ const rootStyle = document.documentElement.style;
782
+ // Use fallback colors based on Telegram WebApp documentation
783
+ rootStyle.setProperty('--tg-theme-bg-color', tg.themeParams.bg_color || '#ffffff');
784
+ rootStyle.setProperty('--tg-theme-text-color', tg.themeParams.text_color || '#000000');
785
+ rootStyle.setProperty('--tg-theme-hint-color', tg.themeParams.hint_color || '#999999');
786
+ rootStyle.setProperty('--tg-theme-link-color', tg.themeParams.link_color || '#007aff');
787
+ rootStyle.setProperty('--tg-theme-button-color', tg.themeParams.button_color || '#007aff');
788
+ rootStyle.setProperty('--tg-theme-button-text-color', tg.themeParams.button_button_text_color || '#ffffff');
789
+ rootStyle.setProperty('--tg-theme-secondary-bg-color', tg.themeParams.secondary_bg_color || '#f0f0f0');
790
+ rootStyle.setProperty('--tg-theme-header-bg-color', tg.themeParams.header_bg_color || tg.themeParams.secondary_bg_color || '#efeff4');
791
+ rootStyle.setProperty('--tg-theme-section-bg-color', tg.themeParams.section_bg_color || tg.themeParams.bg_color || '#ffffff');
792
+ rootStyle.setProperty('--tg-theme-destructive-text-color', tg.themeParams.destructive_text_color || '#ff3b30');
793
+ tg.setBackgroundColor(tg.themeParams.bg_color || '#ffffff');
794
+ }
795
+
796
+ async function apiCall(endpoint, method = 'GET', body = null, isFormData = false) {
797
+ const headers = {};
798
+ if (tg.initData) {
799
+ headers['X-Telegram-Auth'] = tg.initData;
 
 
 
 
 
 
 
800
  }
801
+
802
+ const options = { method, headers };
803
+
804
+ if (isFormData) {
805
+ // Flask handles multipart/form-data without setting Content-Type in JS headers
806
+ delete options.headers['Content-Type'];
807
+ options.body = body;
808
+ } else if (body) {
809
+ headers['Content-Type'] = 'application/json';
810
+ options.body = JSON.stringify(body);
811
+ }
812
+
813
+ try {
814
+ const response = await fetch(endpoint, options);
815
+ if (response.status === 401 || response.status === 403) {
816
+ tg.showAlert("Authentication failed. Please restart the Mini App from the bot.");
817
+ throw new Error("Authentication failed");
818
  }
819
+ if (!response.ok) {
820
+ const errorData = await response.json().catch(() => ({ error: `HTTP error ${response.status}` }));
821
+ throw new Error(errorData.error || `HTTP error ${response.status}`);
 
 
 
 
 
 
 
 
 
 
822
  }
823
+ if (response.status === 204 || response.headers.get('content-length') === '0') return {};
824
+ return response.json();
825
+ } catch (error) {
826
+ console.error('API Call Error:', error);
827
+ tg.showAlert(error.message || T['Error loading.']);
828
+ throw error;
829
  }
830
+ }
831
+
832
+ function getUserAvatarUrl(user) {
833
+ return user.photo_url || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
834
+ }
835
+
836
+ function getFullUserName(user) {
837
+ let name = `${user.first_name || ''} ${user.last_name || ''}`.trim();
838
+ if (!name) name = user.username || `User ${user.id}`;
839
+ return name;
840
+ }
841
+
842
+ function renderPostMedia(post) {
843
+ if (!post.file_path) return '';
844
 
845
+ const fileUrl = `/uploads/${post.file_path}`;
846
+
847
+ if (post.type === 'photo') {
848
+ return `<div class="media-container"><img src="${fileUrl}" class="media-image" alt="${T['Photo']}"></div>`;
849
+ } else if (post.type === 'video') {
850
+ return `<div class="media-container"><video controls src="${fileUrl}" class="media-video"></video></div>`;
851
+ } else if (post.type === 'document') {
852
+ return `
853
+ <a href="${fileUrl}" target="_blank" rel="noopener noreferrer" class="media-document">
854
+ <i class="fas fa-file"></i>
855
+ <span>${T['Document']} (${post.file_path})</span>
856
+ </a>
857
+ `;
858
  }
859
+ return '';
860
+ }
861
 
862
+ function renderPostList(posts, wallOwnerId) {
863
+ mainContent.style.opacity = 0;
864
+ tg.BackButton.hide();
865
+ tg.MainButton.hide();
866
+
867
+ if (!posts || posts.length === 0) {
868
+ mainContent.innerHTML = `<div class="empty-state">${T['No posts found.']}</div>`;
869
+ } else {
870
+ mainContent.innerHTML = `<div class="post-list">${posts.map(post => {
871
+ const poster = { id: post.user_id, first_name: post.poster_name, username: post.poster_username, photo_url: post.photo_url };
872
+ const isCurrentUserWall = String(wallOwnerId) === String(currentUser.id);
873
+ const isPoster = String(post.user_id) === String(currentUser.id);
874
+ const isWallOwner = isCurrentUserWall || (post.target_user_id && String(post.target_user_id) === String(currentUser.id));
875
+
876
+ const postTarget = post.target_user_id
877
+ ? `${T['on the wall of']} <a href="#" onclick="loadView('user_wall', '${post.target_user_id}')">${post.target_name}</a>`
878
+ : `${T['to your wall']}`;
879
+
880
+ let infoLine;
881
+ if (isCurrentUserWall && !post.target_user_id) { // My Wall main view
882
+ infoLine = `<span>${new Date(post.timestamp).toLocaleDateString()} ${new Date(post.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>`;
883
+ } else {
884
+ infoLine = `<span>${T['Posted by']} <a href="#" onclick="loadView('user_wall', '${post.user_id}')">@${post.poster_username || post.poster_name}</a> ${postTarget}</span>`;
885
+ }
886
 
 
887
 
888
+ return `
889
+ <div class="post-card" id="post-${post.id}">
890
+ <div class="post-header">
891
+ <img src="${getUserAvatarUrl(poster)}" alt="Avatar">
892
+ <div class="post-info">
893
+ <h4>${post.poster_name}</h4>
894
+ <span>@${post.poster_username || 'anonymous'}</span>
895
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
896
  </div>
897
+ <div class="post-content">
898
+ ${post.content ? `<p>${post.content.replace(/\\n/g, '<br>')}</p>` : ''}
899
+ ${renderPostMedia(post)}
900
+ </div>
901
+ <div style="font-size: 12px; color: var(--tg-theme-hint-color); margin-top: 10px;">
902
+ ${infoLine}
903
+ </div>
904
+ ${(isPoster || isWallOwner) ? `<button class="delete-button" onclick="handleDeletePost('${post.id}')">${T['Delete']}</button>` : ''}
905
+ </div>
906
+ `;
907
+ }).join('')}</div>`;
908
+ }
909
+ setTimeout(() => { mainContent.style.opacity = 1; }, 50);
910
+ }
911
 
912
+ function renderUsersList(users) {
913
+ mainContent.style.opacity = 0;
914
+ tg.BackButton.hide();
915
+ tg.MainButton.hide();
916
+
917
+ if (!users || users.length === 0) {
918
+ mainContent.innerHTML = `<div class="empty-state">${T['No users found.']}</div>`;
919
+ } else {
920
+ mainContent.innerHTML = `<div class="user-list">${users.map(user => {
921
+ const fullName = getFullUserName(user);
922
+ return `
923
+ <div class="user-list-item" onclick="loadView('user_wall', '${user.id}')">
924
+ <img src="${getUserAvatarUrl(user)}" alt="Avatar">
925
+ <span>${fullName} ${user.username ? `(@${user.username})` : ''}</span>
926
+ <div style="margin-left: auto; color: var(--tg-theme-link-color); font-size: 14px; font-weight: 500;">${T['View Wall']}</div>
927
+ </div>
928
+ `;
929
+ }).join('')}</div>`;
930
  }
931
+ setTimeout(() => { mainContent.style.opacity = 1; }, 50);
932
+ }
933
 
934
+ function renderNewPostForm(targetUser) {
935
+ mainContent.style.opacity = 0;
936
+ tg.MainButton.hide();
937
+ const targetName = targetUser ? getFullUserName(targetUser) : getFullUserName(currentUser);
938
+ const isTargetingSelf = !targetUser || String(targetUser.id) === String(currentUser.id);
939
+ const targetHeader = isTargetingSelf
940
+ ? `${T['New Post']} (${T['My Wall']})`
941
+ : `${T['Post on Wall']} ${targetName}`;
942
 
943
+ appHeader.textContent = targetHeader;
944
+
945
+ let formHtml = `<div class="form-container">
946
+ <form id="postForm" enctype="multipart/form-data">
947
+ <div class="form-group">
948
+ <label for="postContent">${T['Post Text']}</label>
949
+ <textarea id="postContent" name="content" placeholder="${T['Post Text']}..."></textarea>
950
+ </div>
951
+ <div class="form-group">
952
+ <label for="postFile">${T['Attachment (optional)']}</label>
953
+ <input type="file" id="postFile" name="file" accept="image/*,video/*,.pdf,.doc,.docx,.txt">
954
+ </div>
955
+ <div id="formError" class="error-message"></div>
956
+ </form>
957
+ </div>`;
958
+ mainContent.innerHTML = formHtml;
959
+ setTimeout(() => { mainContent.style.opacity = 1; }, 50);
960
+
961
+ tg.MainButton.setText(T['Post']);
962
+ tg.MainButton.show();
963
+ tg.MainButton.onClick(() => handleSubmitPost(targetUser ? targetUser.id : currentUser.id));
964
+ }
965
+
966
+ function handleSubmitPost(targetUserId) {
967
+ const content = document.getElementById('postContent').value.trim();
968
+ const fileInput = document.getElementById('postFile');
969
+ const file = fileInput.files;
970
+ const formError = document.getElementById('formError');
971
+ formError.textContent = '';
972
+
973
+ if (!content && !file) {
974
+ formError.textContent = T['Post must contain text or a file'];
975
+ tg.HapticFeedback.notificationOccurred('error');
976
+ return;
977
  }
978
 
979
+ const formData = new FormData();
980
+ formData.append('content', content);
981
+ if (file) {
982
+ formData.append('file', file);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  }
984
 
985
+ tg.MainButton.showProgress();
986
+ tg.HapticFeedback.impactOccurred('light');
987
+
988
+ apiCall(`/api/post/${targetUserId}`, 'POST', formData, true)
989
+ .then(response => {
990
+ tg.HapticFeedback.notificationOccurred('success');
991
+ tg.MainButton.hideProgress();
992
+ tg.MainButton.hide();
993
+ if (String(targetUserId) === String(currentUser.id)) {
994
+ loadView('my_wall');
 
 
 
 
 
 
995
  } else {
996
+ loadView('user_wall', targetUserId);
 
997
  }
998
+
999
+ })
1000
+ .catch(err => {
1001
+ tg.HapticFeedback.notificationOccurred('error');
1002
+ tg.MainButton.hideProgress();
1003
+ formError.textContent = err.message || T['Post failed.'];
1004
+ });
1005
+ }
1006
+
1007
+ function handleDeletePost(postId) {
1008
+ tg.showConfirm(T['Delete Post?'], (confirmed) => {
1009
+ if (confirmed) {
1010
+ tg.HapticFeedback.impactOccurred('medium');
1011
+ apiCall(`/api/post/${postId}`, 'DELETE')
1012
+ .then(() => {
1013
+ tg.HapticFeedback.notificationOccurred('success');
1014
+ tg.showAlert(T['Post deleted.']);
1015
+ // Reload current view
1016
+ if (currentView === 'my_wall') loadView('my_wall');
1017
+ else if (currentView === 'user_wall') loadView('user_wall', currentTargetUserId);
1018
+ })
1019
  .catch(err => {
1020
+ tg.HapticFeedback.notificationOccurred('error');
1021
+ tg.showAlert(err.message || T['Error loading.']);
1022
  });
1023
+ } else {
 
 
 
 
 
 
1024
  tg.HapticFeedback.impactOccurred('light');
1025
+ }
1026
+ });
1027
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1028
 
1029
+ async function loadView(viewName, userId = null) {
1030
+ if (currentView === viewName && viewName !== 'new_post' && viewName !== 'user_wall') return;
 
1031
 
1032
+ tg.HapticFeedback.impactOccurred('light');
1033
+ currentView = viewName;
1034
+ currentTargetUserId = userId;
1035
+
1036
+ document.querySelectorAll('.nav-button').forEach(btn => btn.classList.remove('active'));
1037
+ if (viewName !== 'user_wall' && viewName !== 'new_post') {
1038
+ document.querySelector(`.nav-button[data-view="${viewName}"]`).classList.add('active');
1039
  }
1040
 
1041
+ mainContent.innerHTML = `<div class="loading">${T['Loading...']}</div>`;
1042
+ tg.BackButton.hide();
1043
+ tg.MainButton.hide();
1044
+
1045
+ if (viewName === 'my_wall') {
1046
+ appHeader.textContent = T['My Wall'];
1047
+ try {
1048
+ const posts = await apiCall(`/api/wall/${currentUser.id}`);
1049
+ renderPostList(posts, currentUser.id);
1050
+ } catch (e) {
1051
+ mainContent.innerHTML = `<div class="empty-state">${T['Error loading.']}</div>`;
1052
  }
1053
+ } else if (viewName === 'users') {
1054
+ appHeader.textContent = T['Users List'];
1055
+ try {
1056
+ const users = await apiCall('/api/users');
1057
+ const filteredUsers = users.filter(u => String(u.id) !== String(currentUser.id));
1058
+ renderUsersList(filteredUsers);
1059
+ } catch (e) {
1060
+ mainContent.innerHTML = `<div class="empty-state">${T['Error loading.']}</div>`;
 
 
 
 
1061
  }
1062
+ } else if (viewName === 'user_wall' && userId) {
1063
+ tg.BackButton.show();
1064
+ tg.BackButton.onClick(() => loadView('users'));
1065
+ try {
1066
+ const users = await apiCall('/api/users');
1067
+ const targetUser = users.find(u => String(u.id) === String(userId));
1068
+ if (!targetUser) throw new Error("User not found");
1069
+ appHeader.textContent = `${T['Wall of']} ${getFullUserName(targetUser)}`;
1070
+
1071
+ const posts = await apiCall(`/api/wall/${userId}`);
1072
+ renderPostList(posts, userId);
1073
+
1074
+ // Add a button to post to this user's wall
1075
+ tg.MainButton.setText(T['Post on Wall']);
1076
+ tg.MainButton.show();
1077
+ tg.MainButton.onClick(() => loadView('new_post', userId));
1078
+
1079
+ } catch (e) {
1080
+ mainContent.innerHTML = `<div class="empty-state">${T['Error loading.']}</div>`;
1081
+ }
1082
+ } else if (viewName === 'new_post' && userId) {
1083
+ tg.BackButton.show();
1084
+ tg.BackButton.onClick(() => {
1085
+ if (String(userId) === String(currentUser.id)) loadView('my_wall');
1086
+ else loadView('user_wall', userId);
1087
+ });
1088
+ try {
1089
+ const users = await apiCall('/api/users');
1090
+ const targetUser = users.find(u => String(u.id) === String(userId));
1091
+ if (!targetUser) throw new Error("User not found");
1092
+ renderNewPostForm(targetUser);
1093
+ } catch (e) {
1094
+ mainContent.innerHTML = `<div class="empty-state">${T['Error loading.']}</div>`;
1095
+ }
1096
+ } else if (viewName === 'new_post') {
1097
+ tg.BackButton.show();
1098
+ tg.BackButton.onClick(() => loadView('my_wall'));
1099
+ renderNewPostForm(currentUser);
1100
+ }
1101
+ }
1102
+
1103
+ async function init() {
1104
+ tg.ready();
1105
+ applyThemeParams();
1106
+ tg.expand();
1107
+ tg.enableClosingConfirmation();
1108
+ translateUI();
1109
+
1110
+ tg.onEvent('themeChanged', applyThemeParams);
1111
+
1112
+ try {
1113
+ const authResponse = await apiCall('/api/auth_user', 'POST', { init_data: tg.initData });
1114
+ currentUser = authResponse.user;
1115
+ if (!currentUser) throw new Error("Auth failed: No user object");
1116
 
1117
+ // Set initial view to My Wall for the authenticated user
1118
+ loadView('my_wall');
1119
+
1120
+ } catch (error) {
1121
+ console.error("Auth error:", error);
1122
+ mainContent.innerHTML = `<div class="empty-state">Authentication failed. Please launch from the Telegram bot.</div>`;
 
 
 
 
 
 
 
 
1123
  }
1124
 
1125
+ document.querySelectorAll('.nav-button').forEach(button => {
1126
+ button.addEventListener('click', () => {
1127
+ const view = button.dataset.view;
1128
+ if (view === 'new_post') {
1129
+ loadView('new_post', currentUser.id); // Default to posting on own wall
 
 
 
 
 
 
 
 
 
1130
  } else {
1131
+ loadView(view);
1132
  }
1133
  });
1134
+ });
1135
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1136
 
1137
+ init();
1138
+ </script>
1139
  </body>
1140
  </html>
1141
  '''
1142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1143
 
1144
  @app.route('/')
1145
  def main_app_view():
1146
+ return render_template_string(MAIN_APP_TEMPLATE)
1147
+
1148
+ --- BOOTSTRAP ---
1149
+
1150
+ if name == 'main':
1151
+ logging.info("Application starting up. Performing initial data load/download...")
1152
+ download_db_from_hf()
1153
+ load_data()
1154
+ logging.info("Initial data load complete.")
1155
+
1156
+ code
1157
+ Code
1158
+ download
1159
+ content_copy
1160
+ expand_less
1161
+ if HF_TOKEN_WRITE:
1162
+ backup_thread = threading.Thread(target=periodic_backup, daemon=True)
1163
+ backup_thread.start()
1164
+ logging.info("Periodic backup thread started.")
1165
+ else:
1166
+ logging.warning("Periodic backup will NOT run (HF_TOKEN_WRITE not set).")
1167
+
1168
+ port = int(os.environ.get('PORT', 7860))
1169
+ logging.info(f"Starting Flask app on host 0.0.0.0 and port {port}")
1170
+ app.run(debug=False, host='0.0.0.0', port=port)
1171