Spaces:
Running
Running
| import os | |
| import asyncio | |
| from datetime import datetime, timezone, timedelta | |
| from flask import Flask, render_template_string, request, redirect, url_for, flash, session, jsonify | |
| from functools import wraps | |
| from supabase import create_client, Client | |
| from dotenv import load_dotenv | |
| import requests | |
| # Load environment variables | |
| load_dotenv() | |
| # Configuration | |
| SUPABASE_URL = os.getenv("SUPABASE_URL") | |
| SUPABASE_KEY = os.getenv("SUPABASE_KEY") | |
| ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123") | |
| FLASK_SECRET_KEY = os.getenv("FLASK_SECRET_KEY", "icebox-admin-secret-777") | |
| # Initialize Supabase Client | |
| supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) | |
| app = Flask(__name__) | |
| app.secret_key = FLASK_SECRET_KEY | |
| # Authentication Decorator | |
| def login_required(f): | |
| def decorated_function(*args, **kwargs): | |
| if 'logged_in' not in session: | |
| return redirect(url_for('login')) | |
| return f(*args, **kwargs) | |
| return decorated_function | |
| def to_wib(dt_str): | |
| if not dt_str: return "-" | |
| try: | |
| # Supabase strings: '2026-03-03T08:24:15.123+00:00' or similar | |
| t_part = dt_str.split('.')[0].replace('Z', '').replace('T', ' ') | |
| dt = datetime.fromisoformat(t_part).replace(tzinfo=timezone.utc) | |
| wib = dt + timedelta(hours=7) | |
| return wib.strftime("%Y-%m-%d %H:%M:%S") | |
| except: | |
| return dt_str | |
| # Templates | |
| LOGIN_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Admin Login | Icebox AI</title> | |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --primary-bg: #0f172a; | |
| --accent: #6366f1; | |
| --glass: rgba(255, 255, 255, 0.05); | |
| } | |
| body { | |
| background-color: var(--primary-bg); | |
| color: white; | |
| height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-family: 'Inter', sans-serif; | |
| } | |
| .login-card { | |
| background: var(--glass); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 20px; | |
| padding: 40px; | |
| width: 100%; | |
| max-width: 400px; | |
| box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); | |
| } | |
| .form-control { | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| color: white; | |
| border-radius: 10px; | |
| padding: 12px; | |
| } | |
| .form-control:focus { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-color: var(--accent); | |
| color: white; | |
| box-shadow: none; | |
| } | |
| .btn-primary { | |
| background: var(--accent); | |
| border: none; | |
| border-radius: 10px; | |
| padding: 12px; | |
| font-weight: 600; | |
| } | |
| .logo { | |
| text-align: center; | |
| margin-bottom: 30px; | |
| } | |
| .logo h1 { | |
| font-size: 24px; | |
| font-weight: 800; | |
| background: linear-gradient(to right, #818cf8, #c084fc); | |
| -webkit-background-clip: text; | |
| -webkit-text-fill-color: transparent; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="login-card"> | |
| <div class="logo"> | |
| <h1>ICEBOX AI ADMIN</h1> | |
| <p class="text-secondary">Control Center</p> | |
| </div> | |
| {% with messages = get_flashed_messages() %} | |
| {% if messages %} | |
| {% for message in messages %} | |
| <div class="alert alert-danger py-2" role="alert" style="background: rgba(220, 53, 69, 0.2); border: none; color: #ff8787;"> | |
| {{ message }} | |
| </div> | |
| {% endfor %} | |
| {% endif %} | |
| {% endwith %} | |
| <form method="POST"> | |
| <div class="mb-4"> | |
| <label class="form-label text-secondary small">ACCESS TOKEN</label> | |
| <input type="password" name="password" class="form-control" placeholder="••••••••" required> | |
| </div> | |
| <button type="submit" class="btn btn-primary w-100">AUTHENTICATE</button> | |
| </form> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| INDEX_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Dashboard | Icebox AI Admin</title> | |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
| <style> | |
| :root { | |
| --bg: #0b0f19; | |
| --sidebar: #111827; | |
| --card: #1f2937; | |
| --accent: #6366f1; | |
| --text-main: #f8fafc; | |
| --text-secondary: #cbd5e1; | |
| --text-muted: #94a3b8; | |
| } | |
| body { | |
| background-color: var(--bg); | |
| color: var(--text-main); | |
| font-family: 'Inter', system-ui, -apple-system, sans-serif; | |
| overflow-x: hidden; | |
| } | |
| .sidebar { | |
| width: 250px; | |
| background-color: var(--sidebar); | |
| height: 100vh; | |
| position: fixed; | |
| padding: 20px; | |
| border-right: 1px solid rgba(255, 255, 255, 0.05); | |
| z-index: 100; | |
| } | |
| .main-content { | |
| margin-left: 250px; | |
| padding: 30px; | |
| } | |
| .nav-link { | |
| color: var(--text-muted); | |
| padding: 12px 15px; | |
| border-radius: 10px; | |
| margin-bottom: 5px; | |
| transition: 0.3s; | |
| cursor: pointer; | |
| text-decoration: none; | |
| display: block; | |
| } | |
| .nav-link:hover, .nav-link.active { | |
| color: white; | |
| background: rgba(99, 102, 241, 0.1); | |
| } | |
| .nav-link i { | |
| margin-right: 10px; | |
| } | |
| .stat-card { | |
| background: var(--card); | |
| border-radius: 15px; | |
| padding: 24px; | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| height: 100%; | |
| } | |
| .stat-icon { | |
| width: 48px; | |
| height: 48px; | |
| border-radius: 12px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 24px; | |
| margin-bottom: 15px; | |
| } | |
| .table-container { | |
| background: var(--card); | |
| border-radius: 15px; | |
| padding: 20px; | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| margin-top: 25px; | |
| } | |
| .table { | |
| color: var(--text-secondary); | |
| vertical-align: middle; | |
| } | |
| .table thead th { | |
| color: white; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| font-size: 11px; | |
| letter-spacing: 0.05em; | |
| border-bottom: 2px solid rgba(255, 255, 255, 0.1); | |
| background: rgba(0,0,0,0.1); | |
| } | |
| .table td { | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.05); | |
| padding: 15px 10px; | |
| } | |
| .badge-premium { background: rgba(168, 85, 247, 0.3); color: #e9d5ff; border: 1px solid rgba(168, 85, 247, 0.5); } | |
| .badge-free { background: rgba(59, 130, 246, 0.3); color: #dbeafe; border: 1px solid rgba(59, 130, 246, 0.5); } | |
| .badge-success { background: rgba(16, 185, 129, 0.3); color: #dcfce7; border: 1px solid rgba(16, 185, 129, 0.5); } | |
| .search-bar, .limit-selector { | |
| background: rgba(255, 255, 255, 0.05); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| border-radius: 10px; | |
| color: white; | |
| padding: 10px 15px; | |
| outline: none; | |
| transition: 0.2s; | |
| } | |
| .search-bar:focus { | |
| border-color: var(--accent); | |
| background: rgba(255, 255, 255, 0.08); | |
| box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); | |
| } | |
| .limit-selector option { background: var(--card); } | |
| .content-section { display: none; } | |
| .content-section.active { display: block; } | |
| .x-small { font-size: 11px; } | |
| .text-accent { color: var(--accent); } | |
| /* Modal Styles */ | |
| .modal-overlay { | |
| display: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(0, 0, 0, 0.8); | |
| backdrop-filter: blur(8px); | |
| z-index: 1000; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .modal-card { | |
| background: var(--card); | |
| border-radius: 20px; | |
| width: 90%; | |
| max-width: 600px; | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| overflow: hidden; | |
| box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); | |
| } | |
| .modal-header { | |
| padding: 20px; | |
| background: rgba(255, 255, 255, 0.02); | |
| border-bottom: 1px solid rgba(255, 255, 255, 0.05); | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .modal-body { | |
| padding: 25px; | |
| max-height: 70vh; | |
| overflow-y: auto; | |
| } | |
| .info-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, 1fr); | |
| gap: 20px; | |
| } | |
| .info-item { | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| .info-label { | |
| font-size: 11px; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| font-weight: 700; | |
| margin-bottom: 4px; | |
| } | |
| .info-value { | |
| font-size: 14px; | |
| color: var(--text-main); | |
| font-weight: 500; | |
| } | |
| /* Insight Cards */ | |
| .insight-row { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr 1fr; | |
| gap: 15px; | |
| margin-top: 15px; | |
| } | |
| .insight-card { | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| border-radius: 12px; | |
| padding: 15px; | |
| text-align: center; | |
| } | |
| .insight-label { | |
| font-size: 10px; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| margin-bottom: 5px; | |
| } | |
| .insight-value { | |
| font-size: 18px; | |
| font-weight: 700; | |
| color: var(--text-main); | |
| } | |
| /* Chat History Styles */ | |
| .chat-bubble { | |
| max-width: 80%; | |
| margin-bottom: 15px; | |
| padding: 12px 16px; | |
| border-radius: 18px; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| position: relative; | |
| } | |
| .user-bubble { | |
| background: #4f46e5; | |
| color: white; | |
| align-self: flex-end; | |
| margin-left: auto; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .ai-bubble { | |
| background: #374151; | |
| color: #d1d5db; | |
| align-self: flex-start; | |
| margin-right: auto; | |
| border-bottom-left-radius: 4px; | |
| } | |
| .bubble-time { | |
| font-size: 9px; | |
| opacity: 0.6; | |
| margin-top: 5px; | |
| display: block; | |
| } | |
| .chat-history-container { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 5px; | |
| padding: 10px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="sidebar"> | |
| <h4 class="fw-bold mb-4 px-3" style="color: var(--accent);">Icebox Admin</h4> | |
| <nav class="nav flex-column" id="sidebar-nav"> | |
| <a class="nav-link active" href="/#overview" data-section="overview"><i class="bi bi-grid-1x2"></i> Overview</a> | |
| <a class="nav-link" href="/#users" data-section="users"><i class="bi bi-people"></i> Users</a> | |
| <a class="nav-link" href="/#generations" data-section="generations"><i class="bi bi-robot"></i> Generations</a> | |
| <a class="nav-link" href="/#broadcast" data-section="broadcast"><i class="bi bi-megaphone"></i> Broadcast</a> | |
| <a class="nav-link" href="/#cron" data-section="cron"><i class="bi bi-alarm"></i> Cron Settings</a> | |
| <hr class="opacity-10 my-4"> | |
| <a class="nav-link text-danger" href="/logout"><i class="bi bi-box-arrow-left"></i> Logout</a> | |
| </nav> | |
| </div> | |
| <div class="main-content"> | |
| <!-- CRON SETTINGS SECTION --> | |
| <div id="cron" class="content-section"> | |
| <div class="d-flex justify-content-between align-items-center mb-5"> | |
| <div> | |
| <h2 class="fw-bold mb-0">Daily Reset Settings</h2> | |
| <p class="text-secondary small">Configure automatic daily coin reset</p> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <div class="col-md-6"> | |
| <div class="table-container mt-0"> | |
| <form id="cronSettingsForm"> | |
| <div class="mb-4"> | |
| <label class="form-check-label text-secondary small fw-bold d-block mb-2">ENABLE AUTOMATIC RESET</label> | |
| <div class="form-check form-switch"> | |
| <input class="form-check-input" type="checkbox" id="cron_enabled" name="enabled" {% if settings.cron_enabled %}checked{% endif %}> | |
| <label class="form-check-label text-muted" for="cron_enabled">Enable Daily Reset & Broadcast</label> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <label class="form-label text-secondary small fw-bold">BROADCAST MESSAGE</label> | |
| <textarea class="search-bar w-100" name="message" rows="6" placeholder="Enter custom message...">{{ settings.cron_message }}</textarea> | |
| <small class="text-muted">Markdown is supported.</small> | |
| </div> | |
| <button type="submit" class="btn btn-primary w-100 py-3 fw-bold mb-3"> | |
| <i class="bi bi-save me-2"></i> SAVE SETTINGS | |
| </button> | |
| </form> | |
| </div> | |
| <div class="table-container mt-4"> | |
| <h5 class="fw-bold mb-3 text-warning"><i class="bi bi-play-circle me-2"></i> Manual Test / Trigger</h5> | |
| <p class="small text-secondary mb-3">Test the reset and broadcast delivery immediately.</p> | |
| <div class="mb-3"> | |
| <label class="form-label text-secondary small fw-bold">TEST USER ID (OPTIONAL)</label> | |
| <input type="text" id="test_user_id" class="search-bar w-100" placeholder="Enter Chat ID to test broadcast only to ONE user"> | |
| </div> | |
| <button id="triggerResetBtn" class="btn btn-outline-warning w-100 py-2"> | |
| TRIGGER RESET & NOTIFY NOW | |
| </button> | |
| </div> | |
| </div> | |
| <div class="col-md-6"> | |
| <div class="table-container mt-0 h-100"> | |
| <h5 class="fw-bold mb-4">Operation Logs</h5> | |
| <div id="cronStatus" class="p-3 rounded bg-dark border border-secondary" style="height: 480px; overflow-y: auto; font-family: monospace; font-size: 12px; color: #60a5fa;"> | |
| > Settings loaded... | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- BROADCAST SECTION --> | |
| <div id="broadcast" class="content-section"> | |
| <div class="d-flex justify-content-between align-items-center mb-5"> | |
| <div> | |
| <h2 class="fw-bold mb-0">Broadcast Message</h2> | |
| <p class="text-secondary small">Send messages to bot users</p> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <div class="col-md-6"> | |
| <div class="table-container mt-0"> | |
| <form id="broadcastForm"> | |
| <div class="mb-4"> | |
| <label class="form-label text-secondary small fw-bold">SELECT TARGET BOT</label> | |
| <select class="search-bar w-100" name="bot_target" required> | |
| <option value="main">Main Bot (@iceboxai_bot)</option> | |
| <option value="voice">Voice Bot (@iceboxai_voice_bot)</option> | |
| <option value="chat">Chat Bot (@chaticeboxai_bot)</option> | |
| </select> | |
| </div> | |
| <div class="mb-4"> | |
| <label class="form-label text-secondary small fw-bold">TARGET USER (OPTIONAL)</label> | |
| <input type="text" class="search-bar w-100" name="target_user" placeholder="Enter Chat ID for specific user, or leave empty for ALL"> | |
| <small class="text-muted">Leave empty to broadcast to all users who joined the selected bot.</small> | |
| </div> | |
| <div class="mb-4"> | |
| <label class="form-label text-secondary small fw-bold">MESSAGE (MARKDOWN SUPPORTED)</label> | |
| <textarea class="search-bar w-100" name="message" rows="8" placeholder="Enter your message here..." required></textarea> | |
| </div> | |
| <button type="submit" class="btn btn-primary w-100 py-3 fw-bold"> | |
| <i class="bi bi-send me-2"></i> SEND BROADCAST | |
| </button> | |
| </form> | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <div class="table-container mt-0"> | |
| <h5 class="fw-bold mb-3"><i class="bi bi-info-circle me-2"></i>Formatting Guide</h5> | |
| <div class="small text-secondary mb-3">The broadcast supports standard Markdown syntax:</div> | |
| <ul class="list-unstyled small"> | |
| <li class="mb-2"><code class="text-accent">**text**</code> or <code class="text-accent">*text*</code> <br> <span class="text-white-50">→</span> <strong>Bold Text</strong></li> | |
| <li class="mb-2"><code class="text-accent">__text__</code> or <code class="text-accent">_text_</code> <br> <span class="text-white-50">→</span> <em>Italic Text</em></li> | |
| <li class="mb-2"><code class="text-accent">[google](https://google.com)</code> <br> <span class="text-white-50">→</span> <a href="#" class="text-accent" style="pointer-events: none;">Hyperlink</a></li> | |
| <li class="mb-2"><code class="text-accent">`code`</code> <br> <span class="text-white-50">→</span> <code>Monospaced Code</code></li> | |
| </ul> | |
| <hr class="opacity-10"> | |
| <div class="alert alert-info py-2" style="background: rgba(59, 130, 246, 0.1); border: none; font-size: 11px; color: #60a5fa;"> | |
| <i class="bi bi-exclamation-triangle-fill me-1"></i> Ensure all tags are properly closed to avoid delivery failure. | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <div class="table-container mt-0 h-100"> | |
| <h5 class="fw-bold mb-4">Broadcast Results</h5> | |
| <div id="broadcastStatus" class="p-3 rounded bg-dark border border-secondary" style="height: 300px; overflow-y: auto; font-family: monospace; font-size: 12px; color: #10b981;"> | |
| > Ready to broadcast... | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- OVERVIEW SECTION --> | |
| <div id="overview" class="content-section active"> | |
| <div class="d-flex justify-content-between align-items-center mb-5"> | |
| <div> | |
| <h2 class="fw-bold mb-0">Platform Overview</h2> | |
| <p class="text-secondary small">Real-time update as of {{ now }}</p> | |
| </div> | |
| </div> | |
| <div class="row g-4 mb-4"> | |
| <div class="col-md-4"> | |
| <div class="stat-card"> | |
| <div class="stat-icon" style="background: rgba(59, 130, 246, 0.1); color: #3b82f6;"> | |
| <i class="bi bi-people"></i> | |
| </div> | |
| <div class="text-secondary small fw-medium text-uppercase letter-spacing-1">TOTAL USERS</div> | |
| <h2 class="fw-bold mt-1">{{ stats.total_users }}</h2> | |
| <div class="small text-muted mt-2"><i class="bi bi-person-fill-add me-1 text-primary"></i> +{{ stats.u_insights.today }} Today</div> | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <div class="stat-card"> | |
| <div class="stat-icon" style="background: rgba(139, 92, 246, 0.1); color: #8b5cf6;"> | |
| <i class="bi bi-gem"></i> | |
| </div> | |
| <div class="text-secondary small fw-medium text-uppercase letter-spacing-1">PREMIUM USERS</div> | |
| <h2 class="fw-bold mt-1 text-accent">{{ stats.premium_users }}</h2> | |
| <div class="small text-muted mt-2">Active Subscriptions</div> | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <div class="stat-card border-accent" style="border-width: 1px; border-style: solid;"> | |
| <div class="stat-icon" style="background: rgba(16, 185, 129, 0.1); color: #10b981;"> | |
| <i class="bi bi-graph-up-arrow"></i> | |
| </div> | |
| <div class="text-secondary small fw-medium text-uppercase letter-spacing-1">TOTAL GEN ACTIVE</div> | |
| <h2 class="fw-bold mt-1">{{ stats.total_images + stats.total_voices + stats.total_chats }}</h2> | |
| <div class="small text-muted mt-2"><span class="text-accent">{{ stats.g_insights.today }}</span> actions in 24h</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="row g-4"> | |
| <div class="col-md-4"> | |
| <div class="stat-card shadow-sm border-0" style="background: linear-gradient(145deg, #1f2937 0%, #111827 100%);"> | |
| <div class="d-flex justify-content-between align-items-start mb-3"> | |
| <div class="stat-icon m-0" style="background: rgba(16, 185, 129, 0.1); color: #10b981;"> | |
| <i class="bi bi-image"></i> | |
| </div> | |
| <span class="badge bg-dark border border-secondary text-secondary x-small">Visuals</span> | |
| </div> | |
| <div class="text-secondary small fw-medium">TOTAL IMAGES</div> | |
| <h3 class="fw-bold mt-1">{{ "{:,}".format(stats.total_images) }}</h3> | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <div class="stat-card shadow-sm border-0" style="background: linear-gradient(145deg, #1f2937 0%, #111827 100%);"> | |
| <div class="d-flex justify-content-between align-items-start mb-3"> | |
| <div class="stat-icon m-0" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b;"> | |
| <i class="bi bi-mic"></i> | |
| </div> | |
| <span class="badge bg-dark border border-secondary text-secondary x-small">Audio</span> | |
| </div> | |
| <div class="text-secondary small fw-medium">TOTAL VOICES</div> | |
| <h3 class="fw-bold mt-1">{{ "{:,}".format(stats.total_voices) }}</h3> | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <div class="stat-card shadow-sm border-0" style="background: linear-gradient(145deg, #1f2937 0%, #111827 100%);"> | |
| <div class="d-flex justify-content-between align-items-start mb-3"> | |
| <div class="stat-icon m-0" style="background: rgba(6, 182, 212, 0.1); color: #06b6d4;"> | |
| <i class="bi bi-chat-left-text"></i> | |
| </div> | |
| <span class="badge bg-dark border border-secondary text-secondary x-small">Text</span> | |
| </div> | |
| <div class="text-secondary small fw-medium">TOTAL CHATS</div> | |
| <h3 class="fw-bold mt-1">{{ "{:,}".format(stats.total_chats) }}</h3> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="row mt-4 g-4"> | |
| <div class="col-md-8"> | |
| <div class="table-container pt-4"> | |
| <div class="d-flex justify-content-between align-items-center mb-4"> | |
| <h5 class="fw-bold mb-0">30-Day Activity Trend</h5> | |
| <span class="badge bg-dark border border-secondary text-secondary shadow-sm">Traffic & Usage</span> | |
| </div> | |
| <div style="height: 350px;"> | |
| <canvas id="trendChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="col-md-4"> | |
| <div class="table-container pt-4"> | |
| <h5 class="fw-bold mb-4 text-center">Growth Insights</h5> | |
| <div class="mb-4"> | |
| <div class="small fw-bold text-secondary mb-2 px-1 text-center">NEW REGISTRATIONS</div> | |
| <div class="insight-row"> | |
| <div class="insight-card shadow-sm"> | |
| <div class="insight-label">Today</div> | |
| <div class="insight-value">{{ stats.u_insights.today }}</div> | |
| </div> | |
| <div class="insight-card shadow-sm"> | |
| <div class="insight-label">Week</div> | |
| <div class="insight-value">{{ stats.u_insights.week }}</div> | |
| </div> | |
| <div class="insight-card shadow-sm"> | |
| <div class="insight-label">Month</div> | |
| <div class="insight-value">{{ stats.u_insights.month }}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-4"> | |
| <div class="small fw-bold text-secondary mb-2 px-1 text-center">TOTAL GENERATIONS</div> | |
| <div class="insight-row"> | |
| <div class="insight-card shadow-sm"> | |
| <div class="insight-label">Today</div> | |
| <div class="insight-value text-accent">{{ stats.g_insights.today }}</div> | |
| </div> | |
| <div class="insight-card shadow-sm"> | |
| <div class="insight-label">Week</div> | |
| <div class="insight-value text-accent">{{ stats.g_insights.week }}</div> | |
| </div> | |
| <div class="insight-card shadow-sm"> | |
| <div class="insight-label">Month</div> | |
| <div class="insight-value text-accent">{{ stats.g_insights.month }}</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mt-5"> | |
| <h6 class="fw-bold mb-3 text-center text-muted small">ACTIVITY MIX</h6> | |
| <div style="height: 200px;"> | |
| <canvas id="activityChart"></canvas> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- USERS SECTION --> | |
| <div id="users" class="content-section"> | |
| <div class="d-flex justify-content-between align-items-center mb-5"> | |
| <div> | |
| <h2 class="fw-bold mb-0">User Management</h2> | |
| <p class="text-secondary small">Total {{ stats.total_users }} registrants</p> | |
| </div> | |
| <div class="d-flex align-items-center gap-3"> | |
| <select class="limit-selector" onchange="updateBotFilter(this.value)"> | |
| <option value="">All Bots</option> | |
| <option value="image_bot" {% if pages.bot_filter == 'image_bot' %}selected{% endif %}>Main Bot</option> | |
| <option value="voice_bot" {% if pages.bot_filter == 'voice_bot' %}selected{% endif %}>Voice Bot</option> | |
| <option value="chat_bot" {% if pages.bot_filter == 'chat_bot' %}selected{% endif %}>Chat Bot</option> | |
| </select> | |
| <select class="limit-selector" onchange="updateLimit(this.value)"> | |
| <option value="15" {% if pages.per_page == 15 %}selected{% endif %}>15 Rows</option> | |
| <option value="50" {% if pages.per_page == 50 %}selected{% endif %}>50 Rows</option> | |
| <option value="100" {% if pages.per_page == 100 %}selected{% endif %}>100 Rows</option> | |
| <option value="300" {% if pages.per_page == 300 %}selected{% endif %}>300 Rows</option> | |
| </select> | |
| <div class="position-relative"> | |
| <input type="text" id="userQuery" class="search-bar" placeholder="Search Chat ID or username..." value="{{ pages.search }}" style="width: 250px;"> | |
| <button class="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary" id="searchBtn"> | |
| <i class="bi bi-search"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Selection Tools --> | |
| <div id="selectionTools" class="mb-3 d-none"> | |
| <div class="alert alert-primary d-flex justify-content-between align-items-center py-2 px-3" style="background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.2); border-radius: 10px;"> | |
| <div class="small fw-medium text-main"> | |
| <i class="bi bi-check-circle-fill me-2 text-accent"></i> | |
| <span id="selectCount">0</span> users selected | |
| </div> | |
| <div> | |
| <button class="btn btn-sm btn-link text-accent fw-bold text-decoration-none" onclick="clearSelection()">Clear Selection</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="table-container"> | |
| <div class="table-responsive"> | |
| <table class="table"> | |
| <thead> | |
| <tr> | |
| <th style="width: 40px;"> | |
| <input class="form-check-input" type="checkbox" id="selectAllUsers"> | |
| </th> | |
| <th>User</th> | |
| <th>Chat ID</th> | |
| <th>Tier</th> | |
| <th>Activity</th> | |
| <th>Joined At</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% for user in users %} | |
| <tr> | |
| <td> | |
| <input class="form-check-input user-checkbox" type="checkbox" value="{{ user.chat_id }}"> | |
| </td> | |
| <td> | |
| <div class="d-flex align-items-center"> | |
| <div class="rounded-circle bg-secondary d-flex align-items-center justify-content-center me-3" style="width: 35px; height: 35px; font-size: 14px;"> | |
| {{ user.first_name[0] if user.first_name else 'U' }} | |
| </div> | |
| <div> | |
| <div class="fw-medium text-main">{{ user.first_name or 'User' }}</div> | |
| <div class="text-secondary x-small">@{{ user.username or 'unknown' }}</div> | |
| </div> | |
| </div> | |
| </td> | |
| <td class="small font-monospace">{{ user.chat_id }}</td> | |
| <td> | |
| <span class="badge {{ 'badge-premium' if user.tier == 'paid' else 'badge-free' }}"> | |
| {{ user.tier.upper() }} | |
| </span> | |
| </td> | |
| <td> | |
| <div class="text-secondary small">Images: {{ user.total_images_generated }}</div> | |
| <div class="text-secondary small">Tokens: {{ user.token_balance }}</div> | |
| </td> | |
| <td class="small text-secondary">{{ user.created_at[:19].replace('T', ' ') }}</td> | |
| <td> | |
| <button class="btn btn-sm btn-outline-info rounded-pill px-3" onclick="showUserInfo('{{ user.chat_id }}')"> | |
| <i class="bi bi-info-circle me-1"></i> Info | |
| </button> | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| {% if not users %} | |
| <tr> | |
| <td colspan="6" class="text-center py-5 text-secondary">No users found matching your search.</td> | |
| </tr> | |
| {% endif %} | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- User Pagination --> | |
| {% if pages.u_total > 1 %} | |
| <div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top border-secondary border-opacity-10"> | |
| <div class="text-secondary small">Page {{ pages.u_current }} of {{ pages.u_total }}</div> | |
| <div class="btn-group"> | |
| <button class="btn btn-sm btn-outline-secondary" onclick="updatePage('u_page', {{ pages.u_current - 1 }})" {{ 'disabled' if pages.u_current <= 1 }}>Previous</button> | |
| <button class="btn btn-sm btn-outline-secondary" onclick="updatePage('u_page', {{ pages.u_current + 1 }})" {{ 'disabled' if pages.u_current >= pages.u_total }}>Next</button> | |
| </div> | |
| </div> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <!-- GENERATIONS SECTION --> | |
| <div id="generations" class="content-section"> | |
| <div class="d-flex justify-content-between align-items-center mb-5"> | |
| <div> | |
| <h2 class="fw-bold mb-0">Generation Logs</h2> | |
| <p class="text-secondary small">Latest activity across all bots</p> | |
| </div> | |
| <div class="d-flex align-items-center gap-3"> | |
| <div class="nav nav-pills" id="gen-tabs"> | |
| <button class="nav-link active me-2" data-bs-toggle="pill" data-bs-target="#chat-logs">Chat Logs</button> | |
| <button class="nav-link me-2" data-bs-toggle="pill" data-bs-target="#voice-logs">Voice Logs</button> | |
| <button class="nav-link" data-bs-toggle="pill" data-bs-target="#image-logs">Image Logs</button> | |
| </div> | |
| <div class="position-relative"> | |
| <input type="text" id="genSearchQuery" class="search-bar" placeholder="Search User ID..." value="{{ pages.gen_search or '' }}" style="width: 250px;"> | |
| <button class="btn btn-link position-absolute end-0 top-50 translate-middle-y text-secondary" id="genSearchBtn"> | |
| <i class="bi bi-search"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-content"> | |
| <div class="tab-pane fade show active" id="chat-logs"> | |
| <div class="table-container mt-0"> | |
| <table class="table"> | |
| <thead> | |
| <tr> | |
| <th>Latest Activity</th> | |
| <th>User Info</th> | |
| <th>Total Interaction</th> | |
| <th>Model</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% for log in chat_logs %} | |
| <tr> | |
| <td class="x-small text-muted">{{ log.last_chat }}</td> | |
| <td> | |
| <div class="fw-bold text-accent small">{{ log.user_id }}</div> | |
| </td> | |
| <td> | |
| <span class="badge bg-secondary px-3">{{ log.interactions }} chats</span> | |
| </td> | |
| <td><span class="badge bg-dark border border-cyan x-small">{{ log.model_used }}</span></td> | |
| <td> | |
| <div class="d-flex gap-2"> | |
| <button class="btn btn-sm btn-outline-info rounded-pill px-3" onclick="showUserInfo('{{ log.user_id }}')"> | |
| <i class="bi bi-person-badge"></i> Detail | |
| </button> | |
| <button class="btn btn-sm btn-info rounded-pill px-3" onclick="showChatHistory('{{ log.user_id }}')"> | |
| <i class="bi bi-chat-dots"></i> History | |
| </button> | |
| </div> | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| <div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top border-secondary border-opacity-10"> | |
| <span class="small text-muted">Page {{ pages.c_current }} of {{ pages.c_total }}</span> | |
| <div class="btn-group"> | |
| <a href="?c_page={{ pages.c_current-1 }}&u_page={{ pages.u_page }}&i_page={{ pages.i_page }}&v_page={{ pages.v_page }}&gen_search={{ pages.gen_search or '' }}#generations" class="btn btn-sm btn-outline-secondary {% if pages.c_current <= 1 %}disabled{% endif %}">Prev</a> | |
| <a href="?c_page={{ pages.c_current+1 }}&u_page={{ pages.u_page }}&i_page={{ pages.i_page }}&v_page={{ pages.v_page }}&gen_search={{ pages.gen_search or '' }}#generations" class="btn btn-sm btn-outline-secondary {% if pages.c_current >= pages.c_total %}disabled{% endif %}">Next</a> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="tab-pane fade" id="voice-logs"> | |
| <div class="table-container mt-0"> | |
| <table class="table"> | |
| <thead> | |
| <tr> | |
| <th>User ID</th> | |
| <th>Voice</th> | |
| <th>Input Text</th> | |
| <th>Status</th> | |
| <th>Time</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% for log in voice_logs %} | |
| <tr> | |
| <td class="small font-monospace text-truncate" style="max-width: 100px;">{{ log.user_id }}</td> | |
| <td><span class="badge bg-dark">{{ log.voice_used }}</span></td> | |
| <td class="small text-muted text-truncate" style="max-width: 300px;">{{ log.text_input }}</td> | |
| <td><span class="badge badge-success">SUCCESS</span></td> | |
| <td class="small text-secondary">{{ log.created_at[:19].replace('T', ' ') }}</td> | |
| <td> | |
| <button class="btn btn-sm btn-outline-info rounded-pill px-3" onclick="showUserInfo('{{ log.user_id }}')"> | |
| <i class="bi bi-info-circle"></i> Info | |
| </button> | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| <!-- Voice logs Pagination --> | |
| {% if pages.v_total > 1 %} | |
| <div class="d-flex justify-content-between align-items-center mt-3 pt-3 border-top border-secondary border-opacity-10"> | |
| <div class="text-secondary small">Page {{ pages.v_current }} of {{ pages.v_total }}</div> | |
| <div class="btn-group"> | |
| <button class="btn btn-sm btn-outline-secondary" onclick="updatePage('v_page', {{ pages.v_current - 1 }})" {{ 'disabled' if pages.v_current <= 1 }}>Previous</button> | |
| <button class="btn btn-sm btn-outline-secondary" onclick="updatePage('v_page', {{ pages.v_current + 1 }})" {{ 'disabled' if pages.v_current >= pages.v_total }}>Next</button> | |
| </div> | |
| </div> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <div class="tab-pane fade" id="image-logs"> | |
| <div class="table-container mt-0"> | |
| <table class="table"> | |
| <thead> | |
| <tr> | |
| <th>User ID</th> | |
| <th>Model</th> | |
| <th>Prompt</th> | |
| <th>Size</th> | |
| <th>Time</th> | |
| <th>Action</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% for log in image_logs %} | |
| <tr> | |
| <td class="small font-monospace text-truncate" style="max-width: 100px;">{{ log.user_id }}</td> | |
| <td><span class="badge bg-dark">{{ log.model_used }}</span></td> | |
| <td class="small text-muted text-truncate" style="max-width: 300px;">{{ log.prompt }}</td> | |
| <td>{{ log.image_size }}</td> | |
| <td class="small text-secondary">{{ log.created_at[:19].replace('T', ' ') }}</td> | |
| <td> | |
| <button class="btn btn-sm btn-outline-info rounded-pill px-3" onclick="showUserInfo('{{ log.user_id }}')"> | |
| <i class="bi bi-info-circle"></i> Info | |
| </button> | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| <!-- Image logs Pagination --> | |
| {% if pages.i_total > 1 %} | |
| <div class="d-flex justify-content-between align-items-center mt-3 pt-3 border-top border-secondary border-opacity-10"> | |
| <div class="text-secondary small">Page {{ pages.i_current }} of {{ pages.i_total }}</div> | |
| <div class="btn-group"> | |
| <button class="btn btn-sm btn-outline-secondary" onclick="updatePage('i_page', {{ pages.i_current - 1 }})" {{ 'disabled' if pages.i_current <= 1 }}>Previous</button> | |
| <button class="btn btn-sm btn-outline-secondary" onclick="updatePage('i_page', {{ pages.i_current + 1 }})" {{ 'disabled' if pages.i_current >= pages.i_total }}>Next</button> | |
| </div> | |
| </div> | |
| {% endif %} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- USER INFO MODAL --> | |
| <div id="userInfoModal" class="modal-overlay"> | |
| <div class="modal-card"> | |
| <div class="modal-header"> | |
| <h5 class="fw-bold mb-0">Detailed User Info</h5> | |
| <button class="btn btn-link text-secondary p-0" onclick="closeModal()"> | |
| <i class="bi bi-x-lg"></i> | |
| </button> | |
| </div> | |
| <div class="modal-body"> | |
| <div class="info-grid" id="userDetailsContent"> | |
| <div class="text-center w-100 py-5"> | |
| <div class="spinner-border text-primary" role="status"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat History Modal --> | |
| <div id="chatHistoryModal" class="modal-overlay"> | |
| <div class="modal-card" style="max-width: 800px;"> | |
| <div class="modal-header"> | |
| <h5 class="fw-bold mb-0">Conversation History</h5> | |
| <button class="btn btn-link text-secondary" onclick="closeChatHistory()"> | |
| <i class="bi bi-x-lg"></i> | |
| </button> | |
| </div> | |
| <div class="modal-body bg-dark" style="max-height: 600px; overflow-y: auto;"> | |
| <div class="chat-history-container" id="chatHistoryContent"> | |
| <!-- History injected here --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Bootstrap JS for Tabs --> | |
| <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> | |
| <script> | |
| // Helper to log to cron status | |
| function logCron(msg, isError = false) { | |
| const div = document.getElementById('cronStatus'); | |
| if (!div) return; | |
| const time = new Date().toLocaleTimeString(); | |
| const color = isError ? 'text-danger' : ''; | |
| div.innerHTML += `<span class="${color}">[${time}] ${msg}</span><br>`; | |
| div.scrollTop = div.scrollHeight; | |
| } | |
| // Tab switching logic | |
| function switchSection() { | |
| const hash = window.location.hash || '#overview'; | |
| const sectionId = hash.substring(1); | |
| document.querySelectorAll('.content-section').forEach(section => { | |
| section.classList.remove('active'); | |
| }); | |
| const targetSection = document.getElementById(sectionId); | |
| if (targetSection) { | |
| targetSection.classList.add('active'); | |
| } | |
| document.querySelectorAll('.sidebar .nav-link').forEach(link => { | |
| link.classList.remove('active'); | |
| const href = link.getAttribute('href'); | |
| if (href && href.includes(hash)) { | |
| link.classList.add('active'); | |
| } | |
| }); | |
| } | |
| // Pagination & Search Logic | |
| function updatePage(param, value) { | |
| const url = new URL(window.location.href); | |
| url.searchParams.set(param, value); | |
| url.hash = window.location.hash || '#overview'; // Preserve hash | |
| window.location.href = url.toString(); | |
| } | |
| function updateLimit(value) { | |
| const url = new URL(window.location.href); | |
| url.searchParams.set('per_page', value); | |
| url.searchParams.set('u_page', 1); | |
| url.searchParams.set('v_page', 1); | |
| url.searchParams.set('i_page', 1); | |
| url.hash = window.location.hash || '#overview'; // Preserve hash | |
| window.location.href = url.toString(); | |
| } | |
| function updateBotFilter(value) { | |
| const url = new URL(window.location.href); | |
| if (value) url.searchParams.set('bot_filter', value); | |
| else url.searchParams.delete('bot_filter'); | |
| url.searchParams.set('u_page', 1); // Reset to page 1 | |
| url.hash = '#users'; | |
| window.location.href = url.toString(); | |
| } | |
| function doSearch() { | |
| const query = document.getElementById('userQuery').value; | |
| const url = new URL(window.location.href); | |
| if (query) url.searchParams.set('search', query); | |
| else url.searchParams.delete('search'); | |
| url.searchParams.set('u_page', 1); | |
| url.hash = '#users'; | |
| window.location.href = url.toString(); | |
| } | |
| function doGenSearch() { | |
| const query = document.getElementById('genSearchQuery').value; | |
| const url = new URL(window.location.href); | |
| if (query) url.searchParams.set('gen_search', query); | |
| else url.searchParams.delete('gen_search'); | |
| url.searchParams.set('v_page', 1); | |
| url.searchParams.set('i_page', 1); | |
| url.hash = '#generations'; | |
| window.location.href = url.toString(); | |
| } | |
| // Selection Logic | |
| function updateSelectionState() { | |
| const checks = document.querySelectorAll('.user-checkbox:checked'); | |
| const tools = document.getElementById('selectionTools'); | |
| const count = document.getElementById('selectCount'); | |
| if (checks.length > 0) { | |
| tools.classList.remove('d-none'); | |
| count.innerText = checks.length; | |
| } else { | |
| tools.classList.add('d-none'); | |
| } | |
| } | |
| function clearSelection() { | |
| document.querySelectorAll('.user-checkbox').forEach(c => c.checked = false); | |
| document.getElementById('selectAllUsers').checked = false; | |
| updateSelectionState(); | |
| } | |
| document.addEventListener('DOMContentLoaded', () => { | |
| console.log("Dashboard JS Initializing..."); | |
| switchSection(); | |
| window.addEventListener('hashchange', switchSection); | |
| // Selection events | |
| const selectAll = document.getElementById('selectAllUsers'); | |
| if (selectAll) { | |
| selectAll.addEventListener('change', (e) => { | |
| document.querySelectorAll('.user-checkbox').forEach(c => c.checked = e.target.checked); | |
| updateSelectionState(); | |
| }); | |
| } | |
| document.querySelectorAll('.user-checkbox').forEach(c => { | |
| c.addEventListener('change', updateSelectionState); | |
| }); | |
| // Search events | |
| const searchBtn = document.getElementById('searchBtn'); | |
| const searchInput = document.getElementById('userQuery'); | |
| if (searchBtn) searchBtn.addEventListener('click', doSearch); | |
| if (searchInput) { | |
| searchInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') doSearch(); | |
| }); | |
| } | |
| const genSearchBtn = document.getElementById('genSearchBtn'); | |
| const genSearchInput = document.getElementById('genSearchQuery'); | |
| if (genSearchBtn) genSearchBtn.addEventListener('click', doGenSearch); | |
| if (genSearchInput) { | |
| genSearchInput.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') doGenSearch(); | |
| }); | |
| } | |
| // Summary Chart Initialization | |
| const summaryCtx = document.getElementById('activityChart'); | |
| if (summaryCtx) { | |
| new Chart(summaryCtx.getContext('2d'), { | |
| type: 'doughnut', | |
| data: { | |
| labels: ['Images', 'Voices', 'Chat'], | |
| datasets: [{ | |
| data: [{{ stats.total_images }}, {{ stats.total_voices }}, {{ stats.total_chats }}], | |
| backgroundColor: ['#6366f1', '#f59e0b', '#06b6d4'], | |
| borderWidth: 0, | |
| hoverOffset: 4 | |
| }] | |
| }, | |
| options: { | |
| plugins: { | |
| legend: { | |
| position: 'bottom', | |
| labels: { color: '#9ca3af', boxWidth: 12, padding: 15 } | |
| } | |
| }, | |
| cutout: '70%', | |
| responsive: true, | |
| maintainAspectRatio: false | |
| } | |
| }); | |
| } | |
| // Trend Chart Initialization | |
| const trendCtx = document.getElementById('trendChart'); | |
| if (trendCtx) { | |
| new Chart(trendCtx.getContext('2d'), { | |
| type: 'line', | |
| data: { | |
| labels: {{ stats.chart_data.labels | tojson }}, | |
| datasets: [ | |
| { | |
| label: 'New Users', | |
| data: {{ stats.chart_data.users | tojson }}, | |
| borderColor: '#6366f1', | |
| backgroundColor: 'rgba(99, 102, 241, 0.1)', | |
| fill: true, | |
| tension: 0.4, | |
| borderWidth: 2, | |
| pointRadius: 0 | |
| }, | |
| { | |
| label: 'Generations', | |
| data: {{ stats.chart_data.gens | tojson }}, | |
| borderColor: '#10b981', | |
| backgroundColor: 'rgba(16, 185, 129, 0.1)', | |
| fill: true, | |
| tension: 0.4, | |
| borderWidth: 2, | |
| pointRadius: 0 | |
| } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| labels: { color: '#9ca3af', boxWidth: 12 } | |
| } | |
| }, | |
| scales: { | |
| y: { | |
| grid: { color: 'rgba(255, 255, 255, 0.05)' }, | |
| ticks: { color: '#9ca3af' } | |
| }, | |
| x: { | |
| grid: { display: false }, | |
| ticks: { color: '#9ca3af', maxRotation: 0 } | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| // Forms initialization | |
| const bform = document.getElementById('broadcastForm'); | |
| if (bform) { | |
| bform.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(e.target); | |
| const statusDiv = document.getElementById('broadcastStatus'); | |
| const data = { | |
| bot_target: formData.get('bot_target'), | |
| target_user: formData.get('target_user'), | |
| message: formData.get('message') | |
| }; | |
| statusDiv.innerHTML = `> Starting broadcast...<br>`; | |
| try { | |
| const response = await fetch('/api/broadcast', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(data) | |
| }); | |
| const result = await response.json(); | |
| if (result.status === 'success') { | |
| statusDiv.innerHTML += `> Sent: ${result.sent_count}<br>`; | |
| } else { | |
| statusDiv.innerHTML += `<span class="text-danger">> Error: ${result.message}</span><br>`; | |
| } | |
| } catch (err) { | |
| statusDiv.innerHTML += `<span class="text-danger">> Failed: ${err.message}</span><br>`; | |
| } | |
| }); | |
| } | |
| const cform = document.getElementById('cronSettingsForm'); | |
| if (cform) { | |
| cform.addEventListener('submit', async (e) => { | |
| e.preventDefault(); | |
| const formData = new FormData(e.target); | |
| const data = { | |
| cron_enabled: formData.get('enabled') === 'on', | |
| cron_message: formData.get('message') | |
| }; | |
| logCron("> Saving settings..."); | |
| try { | |
| const response = await fetch('/api/admin/save-settings', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ key: 'cron_config', value: data }) | |
| }); | |
| const result = await response.json(); | |
| if (result.status === 'success') logCron("> Settings saved!"); | |
| else logCron("> Error: " + result.message, true); | |
| } catch (err) { | |
| logCron("> Error: " + err.message, true); | |
| } | |
| }); | |
| } | |
| const triggerBtn = document.getElementById('triggerResetBtn'); | |
| if (triggerBtn) { | |
| triggerBtn.addEventListener('click', async () => { | |
| const testId = document.getElementById('test_user_id').value; | |
| logCron(`> Triggering reset... ${testId ? '(Test: '+testId+')' : ''}`); | |
| try { | |
| const response = await fetch(`/api/cron/reset-coins?test_user_id=${testId}`); | |
| const result = await response.json(); | |
| logCron(`> Result: ${result.status}`); | |
| if (result.notifications_sent !== undefined) { | |
| logCron(`> Sent to ${result.notifications_sent} users`); | |
| } | |
| } catch (err) { | |
| logCron("> Trigger failed: " + err.message, true); | |
| } | |
| }); | |
| } | |
| logCron("> System ready."); | |
| }); | |
| async function showUserInfo(chat_id) { | |
| const modal = document.getElementById('userInfoModal'); | |
| const content = document.getElementById('userDetailsContent'); | |
| modal.style.display = 'flex'; | |
| content.innerHTML = '<div class="col-12 text-center py-5"><div class="spinner-border text-primary"></div></div>'; | |
| try { | |
| const response = await fetch(`/api/user-details/${chat_id}`); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| const u = data.user; | |
| const stats = u.computed_stats; | |
| content.innerHTML = ` | |
| <div class="info-item"> | |
| <span class="info-label">Username</span> | |
| <span class="info-value">@${u.username || 'unknown'}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Full Name</span> | |
| <span class="info-value">${u.first_name || '-'} ${u.last_name || ''}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">User ID (Internal)</span> | |
| <span class="info-value font-monospace text-warning">${u.id || 'N/A'}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Chat ID</span> | |
| <span class="info-value font-monospace">${u.chat_id}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Language / Region</span> | |
| <span class="info-value">${u.language_code || 'N/A'}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Daily Chat (Actions)</span> | |
| <span class="info-value">${stats.chat_daily}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Total Chat (Actions)</span> | |
| <span class="info-value">${stats.chat_total}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Daily Image/Voice</span> | |
| <span class="info-value">${stats.image_daily + stats.voice_daily}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Total Image/Voice</span> | |
| <span class="info-value">${stats.image_total + stats.voice_total}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Images (Total / Daily)</span> | |
| <span class="info-value">${stats.image_total} / ${stats.image_daily}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Voices (Total / Daily)</span> | |
| <span class="info-value">${stats.voice_total} / ${stats.voice_daily}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Remaining Coins</span> | |
| <span class="info-value fw-bold text-accent">${stats.remaining_coins}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Account Tier</span> | |
| <span class="info-value">${u.tier ? u.tier.toUpperCase() : 'FREE'}</span> | |
| </div> | |
| <div class="info-item col-12"> | |
| <span class="info-label">Bots Joined</span> | |
| <span class="info-value">${u.bots_joined ? (Array.isArray(u.bots_joined) ? u.bots_joined.join(', ') : u.bots_joined) : 'None'}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Joined Date</span> | |
| <span class="info-value">${u.created_at ? u.created_at.replace('T', ' ').substring(0, 19) : '-'}</span> | |
| </div> | |
| <div class="info-item"> | |
| <span class="info-label">Last Active</span> | |
| <span class="info-value">${u.updated_at ? u.updated_at.replace('T', ' ').substring(0, 19) : '-'}</span> | |
| </div> | |
| `; | |
| } else { | |
| content.innerHTML = `<div class="col-12 text-danger">Error: ${data.message}</div>`; | |
| } | |
| } catch (err) { | |
| content.innerHTML = `<div class="col-12 text-danger">Error fetching data: ${err.message}</div>`; | |
| } | |
| } | |
| function closeModal() { | |
| document.getElementById('userInfoModal').style.display = 'none'; | |
| } | |
| async function showChatHistory(user_id) { | |
| const modal = document.getElementById('chatHistoryModal'); | |
| const container = document.getElementById('chatHistoryContent'); | |
| modal.style.display = 'flex'; | |
| container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-info"></div></div>'; | |
| try { | |
| const response = await fetch(`/api/chat-history/${user_id}`); | |
| const data = await response.json(); | |
| if (data.status === 'success') { | |
| if (data.history.length === 0) { | |
| container.innerHTML = '<div class="text-center py-5 text-muted">No history found for this user.</div>'; | |
| return; | |
| } | |
| container.innerHTML = data.history.map(msg => ` | |
| <div class="chat-bubble ${msg.role === 'user' ? 'user-bubble' : 'ai-bubble'}"> | |
| <div class="bubble-content">${msg.content || (msg.image_url ? '<i>[Generated Image]</i>' : '[Empty]')}</div> | |
| <span class="bubble-time">${new Date(msg.created_at).toLocaleString()}</span> | |
| </div> | |
| `).join(''); | |
| // Scroll to bottom | |
| container.scrollTop = container.scrollHeight; | |
| } else { | |
| container.innerHTML = `<div class="alert alert-danger">Error: ${data.message}</div>`; | |
| } | |
| } catch (err) { | |
| container.innerHTML = `<div class="alert alert-danger">Failed to fetch history: ${err.message}</div>`; | |
| } | |
| } | |
| function closeChatHistory() { | |
| document.getElementById('chatHistoryModal').style.display = 'none'; | |
| } | |
| // Close modals on click outside | |
| window.onclick = function(event) { | |
| const userModal = document.getElementById('userInfoModal'); | |
| const chatModal = document.getElementById('chatHistoryModal'); | |
| if (event.target == userModal) userModal.style.display = 'none'; | |
| if (event.target == chatModal) chatModal.style.display = 'none'; | |
| } | |
| // Close modal on background click | |
| window.onclick = function(event) { | |
| const modal = document.getElementById('userInfoModal'); | |
| if (event.target == modal) closeModal(); | |
| } | |
| </script> | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # Routes | |
| def login(): | |
| if request.method == 'POST': | |
| password = request.form.get('password') | |
| if password == ADMIN_PASSWORD: | |
| session['logged_in'] = True | |
| return redirect(url_for('index')) | |
| else: | |
| flash('Invalid access token') | |
| return render_template_string(LOGIN_TEMPLATE) | |
| def logout(): | |
| session.pop('logged_in', None) | |
| return redirect(url_for('login')) | |
| def api_user_details(chat_id): | |
| try: | |
| import re | |
| # Detect UUID vs ChatID (BigInt) | |
| is_uuid = bool(re.match(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', chat_id.lower())) | |
| if is_uuid: | |
| res = supabase.table("telegram_users").select("*").eq("id", chat_id).execute() | |
| else: | |
| try: | |
| res = supabase.table("telegram_users").select("*").eq("chat_id", int(chat_id)).execute() | |
| except: | |
| return jsonify({"status": "error", "message": "Invalid Chat ID format"}), 400 | |
| if not res.data: | |
| return jsonify({"status": "error", "message": "User not found"}), 404 | |
| u = res.data[0] | |
| user_uuid = u['id'] | |
| u_chat_id = u['chat_id'] | |
| # Fetch Actual Counts from Logs for accuracy | |
| today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat() | |
| # Voice Counts | |
| voice_total_res = supabase.table("voice_generation_logs").select("id", count="exact").eq("user_id", user_uuid).execute() | |
| voice_daily_res = supabase.table("voice_generation_logs").select("id", count="exact").eq("user_id", user_uuid).gte("created_at", today_start).execute() | |
| # Image Counts | |
| image_total_res = supabase.table("image_generation_logs").select("id", count="exact").eq("user_id", user_uuid).execute() | |
| image_daily_res = supabase.table("image_generation_logs").select("id", count="exact").eq("user_id", user_uuid).gte("created_at", today_start).execute() | |
| # Calculate remaining coins (free level is 60 as per robot info) | |
| if u.get('tier') == 'paid': | |
| remaining_coins = u.get('token_balance', 0) | |
| else: | |
| # For free, use daily_images_generated counter (max 60) | |
| remaining_coins = max(0, 60 - u.get('daily_images_generated', 0)) | |
| # Convert times to WIB for frontend | |
| u['created_at'] = to_wib(u.get('created_at')) | |
| u['updated_at'] = to_wib(u.get('updated_at')) | |
| # Chat Counts | |
| chat_total_res = supabase.table("chat_generation_logs").select("id", count="exact").eq("user_id", user_uuid).execute() | |
| chat_daily_res = supabase.table("chat_generation_logs").select("id", count="exact").eq("user_id", user_uuid).gte("created_at", today_start).execute() | |
| # Merge custom stats into user object for frontend | |
| u['computed_stats'] = { | |
| "voice_total": voice_total_res.count or 0, | |
| "voice_daily": voice_daily_res.count or 0, | |
| "image_total": image_total_res.count or 0, | |
| "image_daily": image_daily_res.count or 0, | |
| "chat_total": chat_total_res.count or 0, | |
| "chat_daily": chat_daily_res.count or 0, | |
| "remaining_coins": remaining_coins | |
| } | |
| return jsonify({"status": "success", "user": u}) | |
| except Exception as e: | |
| import traceback | |
| print(traceback.format_exc()) | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def api_chat_history(user_id): | |
| try: | |
| # Fetch all messages for this user ordered by time | |
| res = supabase.table("chat_history").select("*").eq("user_id", user_id).order("created_at", desc=False).execute() | |
| return jsonify({"status": "success", "history": res.data or []}) | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def api_save_settings(): | |
| try: | |
| data = request.json | |
| key = data.get('key') | |
| value = data.get('value') | |
| # Upsert into admin_settings table | |
| res = supabase.table("admin_settings").upsert({"key": key, "value": value}).execute() | |
| return jsonify({"status": "success"}) | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| def index(): | |
| try: | |
| # Load Cron Settings | |
| settings_res = supabase.table("admin_settings").select("*").eq("key", "cron_config").execute() | |
| if settings_res.data: | |
| db_value = settings_res.data[0].get('value', {}) | |
| settings = { | |
| "cron_enabled": db_value.get('cron_enabled', db_value.get('enabled', True)), | |
| "cron_message": db_value.get('cron_message', db_value.get('message', "")) | |
| } | |
| if not settings["cron_message"] and not db_value.get('message'): | |
| settings["cron_message"] = "🚀 **Daily Coins Reset!**\n\nYour daily generation coins have been successfully reset. You now have **60 fresh coins** to create amazing AI art today!\n\n👉 Start generating now: /start" | |
| else: | |
| settings = { | |
| "cron_enabled": True, | |
| "cron_message": "🚀 **Daily Coins Reset!**\n\nYour daily generation coins have been successfully reset. You now have **60 fresh coins** to create amazing AI art today!\n\n👉 Start generating now: /start" | |
| } | |
| try: supabase.table("admin_settings").insert({"key": "cron_config", "value": settings}).execute() | |
| except: pass | |
| # Pagination & Search Params | |
| u_page = int(request.args.get('u_page', 1)) | |
| v_page = int(request.args.get('v_page', 1)) | |
| i_page = int(request.args.get('i_page', 1)) | |
| per_page = int(request.args.get('per_page', 15)) | |
| u_search = request.args.get('search', '').strip() | |
| bot_filter = request.args.get('bot_filter', '').strip() | |
| # 1. Fetch Users with Search & Pagination | |
| user_query = supabase.table("telegram_users").select("*", count="exact").order("created_at", desc=True) | |
| if u_search: | |
| if u_search.isdigit(): | |
| user_query = user_query.eq("chat_id", int(u_search)) | |
| else: | |
| user_query = user_query.ilike("username", f"%{u_search}%") | |
| if bot_filter: | |
| user_query = user_query.contains("bots_joined", [bot_filter]) | |
| u_start = (u_page - 1) * per_page | |
| u_end = u_start + per_page - 1 | |
| users_res = user_query.range(u_start, u_end).execute() | |
| total_users_filtered = users_res.count or 0 | |
| # Insights Calculation | |
| now_dt = datetime.now(timezone.utc) | |
| today_start = now_dt.replace(hour=0, minute=0, second=0, microsecond=0) | |
| week_start = today_start - timedelta(days=7) | |
| month_start = today_start - timedelta(days=30) | |
| # Helper to fetch since date | |
| def get_count_since(table, date_field, since_dt): | |
| try: | |
| res = supabase.table(table).select(date_field).gte(date_field, since_dt.isoformat()).execute() | |
| return res.data or [] | |
| except Exception as e: | |
| print(f"Error fetching {table}: {e}") | |
| return [] | |
| # Fetch data for insights & chart | |
| user_dates = get_count_since("telegram_users", "created_at", month_start) | |
| image_dates = get_count_since("image_generation_logs", "created_at", month_start) | |
| voice_dates = get_count_since("voice_generation_logs", "created_at", month_start) | |
| # Process Insights | |
| def count_periods(data_list, date_key): | |
| periods = {"today": 0, "week": 0, "month": len(data_list)} | |
| for item in data_list: | |
| dt_str = item.get(date_key, "")[:19] | |
| try: | |
| dt = datetime.fromisoformat(dt_str).replace(tzinfo=timezone.utc) | |
| if dt >= today_start: periods["today"] += 1 | |
| if dt >= week_start: periods["week"] += 1 | |
| except: continue | |
| return periods | |
| u_insights = count_periods(user_dates, "created_at") | |
| chat_dates = get_count_since("chat_generation_logs", "created_at", month_start) | |
| combined_gen = image_dates + voice_dates + chat_dates | |
| g_insights = count_periods(combined_gen, "created_at") | |
| # Build 30-day Chart Data | |
| chart_labels = [] | |
| chart_users = [] | |
| chart_gens = [] | |
| for i in range(29, -1, -1): | |
| day = today_start - timedelta(days=i) | |
| day_str = day.strftime("%Y-%m-%d") | |
| chart_labels.append(day.strftime("%d %b")) | |
| u_count = sum(1 for x in user_dates if x.get("created_at", "").startswith(day_str)) | |
| g_count = sum(1 for x in combined_gen if x.get("created_at", "").startswith(day_str)) | |
| chart_users.append(u_count) | |
| chart_gens.append(g_count) | |
| # Pagination for Generations | |
| gen_search = request.args.get('gen_search', '').strip() | |
| # 2. Fetch Voice Logs with Pagination | |
| v_query = supabase.table("voice_generation_logs").select("*", count="exact").order("created_at", desc=True) | |
| if gen_search: | |
| v_query = v_query.eq("user_id", gen_search) | |
| v_start = (v_page - 1) * per_page | |
| v_end = v_start + per_page - 1 | |
| voices_logs_res = v_query.range(v_start, v_end).execute() | |
| total_voices_count = voices_logs_res.count or 0 | |
| # 3. Fetch Image Logs with Pagination | |
| i_query = supabase.table("image_generation_logs").select("*", count="exact").order("created_at", desc=True) | |
| if gen_search: | |
| i_query = i_query.eq("user_id", gen_search) | |
| i_start = (i_page - 1) * per_page | |
| i_end = i_start + per_page - 1 | |
| try: | |
| image_logs_res = i_query.range(i_start, i_end).execute() | |
| image_logs_data = image_logs_res.data | |
| total_images_logs_count = image_logs_res.count or 0 | |
| except Exception as e: | |
| print(f"Image logs fetch error: {e}") | |
| image_logs_data = [] | |
| total_images_logs_count = 0 | |
| # 4. Fetch Chat Logs (Grouped per user) | |
| c_page = int(request.args.get('c_page', 1)) | |
| # Fetch a larger batch to group by user in memory | |
| c_raw = supabase.table("chat_generation_logs").select("*").order("created_at", desc=True).limit(1000).execute() | |
| chat_user_map = {} | |
| for log in (c_raw.data or []): | |
| uid = log['user_id'] | |
| if uid not in chat_user_map: | |
| chat_user_map[uid] = { | |
| "user_id": uid, | |
| "last_chat": log['created_at'], | |
| "model_used": log['model_used'], | |
| "interactions": 1 | |
| } | |
| else: | |
| chat_user_map[uid]["interactions"] += 1 | |
| chat_users_list = sorted(chat_user_map.values(), key=lambda x: x['last_chat'], reverse=True) | |
| total_unique_chatters = len(chat_users_list) | |
| # Paginate the unique users | |
| c_start = (c_page - 1) * per_page | |
| chat_logs_display = chat_users_list[c_start : c_start + per_page] | |
| # Get absolute total chat count for the card stats | |
| total_chats_count_res = supabase.table("chat_generation_logs").select("id", count="exact").execute() | |
| total_chats_logs_count = total_chats_count_res.count or 0 | |
| stats_query = supabase.table("telegram_users").select("total_images_generated, tier").execute() | |
| total_images_gen = sum(u.get('total_images_generated', 0) for u in stats_query.data) | |
| premium_count = sum(1 for u in stats_query.data if u.get('tier') == 'paid') | |
| total_users_count = len(stats_query.data) | |
| stats = { | |
| "total_users": total_users_count, | |
| "total_images": total_images_gen, | |
| "total_voices": total_voices_count, | |
| "total_chats": total_chats_logs_count, | |
| "premium_users": premium_count, | |
| "u_insights": u_insights, | |
| "g_insights": g_insights, | |
| "chart_data": { | |
| "labels": chart_labels, | |
| "users": chart_users, | |
| "gens": chart_gens | |
| } | |
| } | |
| pages = { | |
| "u_current": u_page, | |
| "u_total": (total_users_filtered + per_page - 1) // per_page, | |
| "v_current": v_page, | |
| "v_total": (total_voices_count + per_page - 1) // per_page, | |
| "i_current": i_page, | |
| "i_total": (total_images_logs_count + per_page - 1) // per_page, | |
| "c_current": c_page, | |
| "c_total": (total_unique_chatters + per_page - 1) // per_page, | |
| "search": u_search, | |
| "gen_search": gen_search, | |
| "bot_filter": bot_filter, | |
| "per_page": per_page | |
| } | |
| for user in users_res.data: | |
| user['created_at'] = to_wib(user.get('created_at')) | |
| user['last_active'] = to_wib(user.get('last_active')) | |
| for log in voices_logs_res.data: | |
| log['created_at'] = to_wib(log.get('created_at')) | |
| for log in image_logs_data: | |
| log['created_at'] = to_wib(log.get('created_at')) | |
| for log in chat_logs_display: | |
| log['last_chat'] = to_wib(log.get('last_chat')) | |
| return render_template_string( | |
| INDEX_TEMPLATE, | |
| stats=stats, | |
| users=users_res.data, | |
| voice_logs=voices_logs_res.data, | |
| image_logs=image_logs_data, | |
| chat_logs=chat_logs_display, | |
| settings=settings, | |
| pages=pages, | |
| now=(datetime.now(timezone.utc) + timedelta(hours=7)).strftime("%H:%M:%S WIB") | |
| ) | |
| except Exception as e: | |
| import traceback | |
| return f"Database Error: {str(e)}<pre>{traceback.format_exc()}</pre>" | |
| def format_message_to_html(text): | |
| import re | |
| # 1. Escape basic HTML entities to prevent malformed tags | |
| text = text.replace("&", "&").replace("<", "<").replace(">", ">") | |
| # 2. Protect Code Blocks (Triple backticks) | |
| code_blocks = {} | |
| def save_code_block(m): | |
| ph = f"CODEBLOCKPH{len(code_blocks)}" | |
| code_blocks[ph] = f"<pre>{m.group(1)}</pre>" | |
| return ph | |
| text = re.sub(r'```(?:[\w+\-]+)?\n?([\s\S]+?)\n?```', save_code_block, text) | |
| # 3. Protect Inline Code (Single backticks) | |
| inline_codes = {} | |
| def save_inline_code(m): | |
| ph = f"INLINECODEPH{len(inline_codes)}" | |
| inline_codes[ph] = f"<code>{m.group(1)}</code>" | |
| return ph | |
| text = re.sub(r'`([^`]+)`', save_inline_code, text) | |
| # 4. Protect URLs in Links | |
| links = {} | |
| def save_link(m): | |
| ph = f"LINKURLPH{len(links)}" | |
| links[ph] = m.group(2) | |
| return f"[{m.group(1)}]({ph})" | |
| text = re.sub(r'\[(.*?)\]\((.*?)\)', save_link, text) | |
| # 5. Apply Formatting to the remaining text | |
| # Bold **...** | |
| text = re.sub(r'\*\*(.*?)\*\*', r'<b>\1</b>', text) | |
| # Bold *...* | |
| text = re.sub(r'(?<!\*)\*(?!\*)(.*?)(?<!\*)\*(?!\*)', r'<b>\1</b>', text) | |
| # Italic __...__ | |
| text = re.sub(r'__(.*?)__', r'<i>\1</i>', text) | |
| # Italic _..._ | |
| text = re.sub(r'(?<!_)_(?!_)(.*?)(?<!_)_(?!_)', r'<i>\1</i>', text) | |
| # 6. Reassemble Links | |
| for ph, url in links.items(): | |
| text = text.replace(f"({ph})", f' href="{url}"') | |
| text = re.sub(r'\[(.*?)\] href="(.*?)"', r'<a href="\2">\1</a>', text) | |
| # 7. Restore Code Blocks | |
| for ph, html in inline_codes.items(): | |
| text = text.replace(ph, html) | |
| for ph, html in code_blocks.items(): | |
| text = text.replace(ph, html) | |
| return text | |
| # Define Bot Menus for post-broadcast restoration | |
| BOT_MENUS = { | |
| "main": { | |
| "text": "Welcome to the @iceboxai_bot.\n\n✨ <b>What you can create:</b>\n• Realistic photos\n• Anime & illustration\n• Cinematic portraits\n• Fantasy & concept art\n• Logos & product visuals\n\nChoose an option below to get started 👇", | |
| "keyboard": [ | |
| [{"text": "Generate Image", "callback_data": "generate_mode"}], | |
| [{"text": "Generate Voice", "url": "https://t.me/iceboxai_voice_bot"}], | |
| [{"text": "GPT-5.1", "url": "https://t.me/chaticeboxai_bot"}], | |
| [{"text": "My Profile", "callback_data": "profile"}], | |
| [{"text": "Help & Support", "callback_data": "help"}] | |
| ] | |
| }, | |
| "voice": { | |
| "text": "<b>Welcome to IceboxAI Voice</b>\n\nGenerate natural text-to-speech in seconds.\n\nSelect an option below to begin.\nEnter your text and choose your voice.", | |
| "keyboard": [ | |
| [{"text": "Generate Voice", "callback_data": "generate_voice"}], | |
| [{"text": "Generate Image", "url": "https://t.me/iceboxai_bot"}], | |
| [{"text": "GPT-5.1", "url": "https://t.me/chaticeboxai_bot"}], | |
| [{"text": "My Profile", "callback_data": "profile"}], | |
| [{"text": "Help & Support", "callback_data": "help"}] | |
| ] | |
| }, | |
| "chat": { | |
| "text": "<b>Welcome to Icebox AI Chat!</b>\n\nI'm an AI assistant that can help you:\n• Answer questions\n• Write and summarize text\n• Search the web\n\n<pre>\n📟 How to start:\nJust send any message.\n\nExamples:\n> Give me business ideas\n> Summarize this article\n> Find the latest AI news\n</pre>\n\n<b>⚙️ Commands:</b>\n• /help — show help\n• /model — model info\n• /clear — delete chat history", | |
| "keyboard": [ | |
| [{"text": "⚙️ Model Settings", "callback_data": "select_model"}], | |
| [{"text": "🗑️ Clear Chat History", "callback_data": "clear_history"}], | |
| [{"text": "🏞️ Create Image", "url": "https://t.me/iceboxai_bot"}] | |
| ] | |
| } | |
| } | |
| def api_broadcast(): | |
| data = request.json | |
| bot_target = data.get('bot_target') | |
| target_user = data.get('target_user') | |
| message = data.get('message') | |
| # Format message to HTML for more reliable distribution | |
| formatted_message = format_message_to_html(message) | |
| # Get token for selected bot | |
| tokens = { | |
| "main": os.getenv("TELEGRAM_BOT_TOKEN"), | |
| "voice": os.getenv("TELEGRAM_VOICE_BOT_TOKEN"), | |
| "chat": os.getenv("TELEGRAM_CHAT_BOT_TOKEN") | |
| } | |
| token = tokens.get(bot_target) | |
| if not token: | |
| return jsonify({"status": "error", "message": f"Token for {bot_target} not found"}), 400 | |
| # Get target user(s) | |
| try: | |
| if target_user: | |
| # Single user broadcast | |
| users_to_message = [{"chat_id": target_user}] | |
| else: | |
| # Multi user broadcast based on bot selection | |
| bot_tag = "image_bot" if bot_target == "main" else f"{bot_target}_bot" | |
| query = supabase.table("telegram_users").select("chat_id") | |
| # If not main, filter by bots_joined | |
| if bot_target != "main": | |
| query = query.contains("bots_joined", [bot_tag]) | |
| res = query.execute() | |
| users_to_message = res.data | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": f"DB Fetch Error: {str(e)}"}), 500 | |
| sent_count = 0 | |
| failed_count = 0 | |
| # Support for custom API URL (Proxy) | |
| base_url = os.getenv("TELEGRAM_API_BASE_URL", "https://api.telegram.org").rstrip('/') | |
| # Send messages | |
| for user in users_to_message: | |
| chat_id = user.get('chat_id') | |
| if not chat_id: continue | |
| try: | |
| # Construct URL based on whether base_url already contains 'bot' | |
| if "/bot" in base_url.lower(): | |
| url = f"{base_url}{token}/sendMessage" | |
| else: | |
| url = f"{base_url}/bot{token}/sendMessage" | |
| payload = { | |
| "chat_id": chat_id, | |
| "text": formatted_message, | |
| "parse_mode": "HTML" | |
| } | |
| resp = requests.post(url, json=payload, timeout=10) | |
| if resp.status_code == 200: | |
| sent_count += 1 | |
| # Restore Home Menu so user has keyboard back | |
| menu = BOT_MENUS.get(bot_target) | |
| if menu: | |
| import json | |
| menu_payload = { | |
| "chat_id": chat_id, | |
| "text": menu["text"], | |
| "parse_mode": "HTML", | |
| "reply_markup": json.dumps({"inline_keyboard": menu["keyboard"]}) | |
| } | |
| requests.post(url, json=menu_payload, timeout=5) | |
| else: | |
| error_detail = resp.json().get('description', 'Unknown error') | |
| print(f"Broadcast Failed for {chat_id}: {error_detail}") | |
| failed_count += 1 | |
| if target_user: # If single user, return specific error | |
| return jsonify({"status": "error", "message": f"Telegram Error: {error_detail}"}), 400 | |
| except Exception as e: | |
| print(f"Broadcast Exception: {str(e)}") | |
| failed_count += 1 | |
| if target_user: | |
| return jsonify({"status": "error", "message": f"System Error: {str(e)}"}), 500 | |
| return jsonify({ | |
| "status": "success", | |
| "sent_count": sent_count, | |
| "failed_count": failed_count | |
| }) | |
| def cron_reset_coins(): | |
| # Optional security check: You can add a CRON_SECRET if needed | |
| # secret = request.args.get('secret') | |
| # if secret != os.getenv("CRON_SECRET"): return "Unauthorized", 401 | |
| # Check for manual test ID | |
| test_user_id = request.args.get('test_user_id') | |
| try: | |
| # Load Config | |
| settings_res = supabase.table("admin_settings").select("*").eq("key", "cron_config").execute() | |
| db_raw = settings_res.data[0].get('value', {}) if settings_res.data else {} | |
| # Consistent config object | |
| config = { | |
| "cron_enabled": db_raw.get('cron_enabled', db_raw.get('enabled', True)), | |
| "cron_message": db_raw.get('cron_message', db_raw.get('message', "")) | |
| } | |
| if not config["cron_enabled"] and not test_user_id: | |
| return jsonify({"status": "disabled", "message": "Cron reset is currently disabled in settings"}), 200 | |
| # 1. Reset database counter (Only if NOT a single user test) | |
| updated_count = 0 | |
| if not test_user_id: | |
| res = supabase.table("telegram_users").update({"daily_images_generated": 0}).eq("tier", "free").execute() | |
| updated_count = len(res.data) if res.data else 0 | |
| # 2. Notification Message from settings | |
| message = config.get('cron_message') or "🚀 **Daily Coins Reset!**\n\nYour daily generation coins have been successfully reset. You now have **60 fresh coins** to create amazing AI art today!\n\n👉 Start generating now: /start" | |
| formatted_message = format_message_to_html(message) | |
| # 3. Get token | |
| token = os.getenv("TELEGRAM_BOT_TOKEN") | |
| if not token: | |
| return jsonify({"status": "partial_success", "message": "DB Reset OK, but BOT_TOKEN missing", "reset_count": updated_count}), 200 | |
| # 4. Determine recipient(s) | |
| if test_user_id: | |
| users = [{"chat_id": test_user_id}] | |
| else: | |
| users_res = supabase.table("telegram_users").select("chat_id").eq("tier", "free").execute() | |
| users = users_res.data or [] | |
| # 5. Send notifications | |
| sent_count = 0 | |
| base_url = os.getenv("TELEGRAM_API_BASE_URL", "https://api.telegram.org").rstrip('/') | |
| for user in users: | |
| chat_id = user.get('chat_id') | |
| if not chat_id: continue | |
| try: | |
| # Construct URL | |
| if "/bot" in base_url.lower(): url = f"{base_url}{token}/sendMessage" | |
| else: url = f"{base_url}/bot{token}/sendMessage" | |
| res_notify = requests.post(url, json={ | |
| "chat_id": chat_id, | |
| "text": formatted_message, | |
| "parse_mode": "HTML" | |
| }, timeout=5) | |
| if res_notify.status_code == 200: | |
| sent_count += 1 | |
| # Restore Home Menu | |
| menu = BOT_MENUS.get('main') | |
| if menu: | |
| import json | |
| menu_payload = { | |
| "chat_id": chat_id, | |
| "text": menu["text"], | |
| "parse_mode": "HTML", | |
| "reply_markup": json.dumps({"inline_keyboard": menu["keyboard"]}) | |
| } | |
| requests.post(url, json=menu_payload, timeout=5) | |
| except: | |
| continue | |
| return jsonify({ | |
| "status": "success", | |
| "db_reset_count": updated_count, | |
| "notifications_sent": sent_count, | |
| "test_mode": bool(test_user_id) | |
| }), 200 | |
| except Exception as e: | |
| return jsonify({"status": "error", "message": str(e)}), 500 | |
| # Health check endpoint for Space | |
| def health(): | |
| return jsonify({"status": "healthy", "time": datetime.now().isoformat()}), 200 | |
| if __name__ == "__main__": | |
| port = int(os.getenv("PORT", 7860)) | |
| app.run(host='0.0.0.0', port=port, debug=True) | |