Spaces:
Running
Running
| import os | |
| import asyncio | |
| from flask import Flask, render_template_string, request, redirect, url_for, flash, session | |
| from functools import wraps | |
| from supabase import create_client, Client | |
| from dotenv import load_dotenv | |
| import requests | |
| from datetime import datetime, timedelta, timezone | |
| import json | |
| from apscheduler.schedulers.background import BackgroundScheduler | |
| # Load environment variables | |
| load_dotenv() | |
| # Configuration | |
| TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN") | |
| SUPABASE_URL = os.getenv("SUPABASE_URL") | |
| SUPABASE_KEY = os.getenv("SUPABASE_KEY") | |
| ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "1sampai8") | |
| TELEGRAM_API_BASE = os.getenv("TELEGRAM_API_BASE_URL", "https://api.telegram.org").rstrip('/') | |
| if TELEGRAM_API_BASE.endswith('/bot'): | |
| TELEGRAM_API_BASE = TELEGRAM_API_BASE[:-4] | |
| # Supabase Client | |
| supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) | |
| app = Flask(__name__) | |
| app.secret_key = os.getenv("FLASK_SECRET_KEY", "your-secret-key-change-this") | |
| # Initialize Scheduler | |
| scheduler = BackgroundScheduler(timezone='UTC') | |
| # HTML Templates | |
| LOGIN_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Admin Login - Icebox AI</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 20px; | |
| } | |
| .login-box { | |
| background: rgba(255,255,255,0.1); | |
| backdrop-filter: blur(10px); | |
| border-radius: 20px; | |
| padding: 40px; | |
| width: 100%; | |
| max-width: 400px; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.3); | |
| } | |
| h1 { color: #fff; text-align: center; margin-bottom: 30px; } | |
| .form-group { margin-bottom: 20px; } | |
| label { color: #aaa; display: block; margin-bottom: 8px; } | |
| input[type="password"] { | |
| width: 100%; | |
| padding: 15px; | |
| border: none; | |
| border-radius: 10px; | |
| background: rgba(255,255,255,0.1); | |
| color: #fff; | |
| font-size: 16px; | |
| } | |
| input[type="password"]:focus { outline: 2px solid #667eea; } | |
| button { | |
| width: 100%; | |
| padding: 15px; | |
| border: none; | |
| border-radius: 10px; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: #fff; | |
| font-size: 16px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| transition: transform 0.2s; | |
| } | |
| button:hover { transform: scale(1.02); } | |
| .error { color: #ff6b6b; text-align: center; margin-bottom: 20px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="login-box"> | |
| <h1>🔐 Admin Login</h1> | |
| {% if error %}<p class="error">{{ error }}</p>{% endif %} | |
| <form method="POST"> | |
| <div class="form-group"> | |
| <label>Password</label> | |
| <input type="password" name="password" required autofocus> | |
| </div> | |
| <button type="submit">Login</button> | |
| </form> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| DASHBOARD_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Admin Console - Icebox AI</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| min-height: 100vh; | |
| color: #fff; | |
| } | |
| .header { | |
| background: rgba(0,0,0,0.3); | |
| padding: 20px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid rgba(255,255,255,0.1); | |
| } | |
| .header h1 { font-size: 24px; } | |
| .logout-btn { | |
| background: rgba(255,255,255,0.1); | |
| color: #fff; | |
| border: none; | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| cursor: pointer; | |
| text-decoration: none; | |
| transition: background 0.2s; | |
| } | |
| .logout-btn:hover { background: rgba(255,255,255,0.2); } | |
| .container { max-width: 1200px; margin: 0 auto; padding: 30px 20px; } | |
| .nav-tabs { display: flex; gap: 10px; margin-bottom: 30px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 2px; } | |
| .nav-tab { | |
| padding: 12px 24px; | |
| background: transparent; | |
| color: #aaa; | |
| border: none; | |
| cursor: pointer; | |
| font-size: 16px; | |
| font-weight: 500; | |
| border-bottom: 3px solid transparent; | |
| transition: all 0.2s; | |
| } | |
| .nav-tab:hover { color: #fff; } | |
| .nav-tab.active { | |
| color: #fff; | |
| border-bottom: 3px solid #667eea; | |
| } | |
| .tab-content { display: none; } | |
| .tab-content.active { display: block; } | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 30px; | |
| } | |
| .stat-card { | |
| background: rgba(255,255,255,0.05); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 12px; | |
| padding: 20px; | |
| } | |
| .stat-card h3 { font-size: 32px; margin-bottom: 5px; color: #fff; } | |
| .stat-card p { color: #aaa; font-size: 14px; margin: 0; } | |
| .card { | |
| background: rgba(255,255,255,0.05); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 12px; | |
| padding: 25px; | |
| margin-bottom: 25px; | |
| } | |
| .card h2 { margin-bottom: 20px; font-size: 20px; display: flex; align-items: center; gap: 10px; } | |
| .form-group { margin-bottom: 20px; } | |
| label { display: block; margin-bottom: 8px; color: #aaa; font-size: 14px; } | |
| select, textarea, input[type="text"], input[type="time"] { | |
| width: 100%; | |
| padding: 12px; | |
| border: 1px solid rgba(255,255,255,0.1); | |
| border-radius: 8px; | |
| background: rgba(0,0,0,0.2); | |
| color: #fff; | |
| font-size: 14px; | |
| font-family: inherit; | |
| } | |
| textarea { min-height: 120px; resize: vertical; } | |
| select option { background: #1a1a2e; } | |
| .btn { | |
| padding: 12px 24px; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| text-decoration: none; | |
| display: inline-block; | |
| text-align: center; | |
| } | |
| .btn-primary { background: #667eea; color: #fff; } | |
| .btn-primary:hover { background: #5a6fd6; } | |
| .btn-danger { background: rgba(255, 107, 107, 0.2); color: #ff6b6b; } | |
| .btn-danger:hover { background: rgba(255, 107, 107, 0.3); } | |
| .btn-success { background: rgba(56, 239, 125, 0.2); color: #38ef7d; } | |
| .btn-sm { padding: 6px 12px; font-size: 12px; } | |
| .alert { padding: 15px; border-radius: 8px; margin-bottom: 20px; } | |
| .alert-success { background: rgba(56, 239, 125, 0.1); border: 1px solid rgba(56, 239, 125, 0.2); color: #38ef7d; } | |
| .alert-error { background: rgba(255, 107, 107, 0.1); border: 1px solid rgba(255, 107, 107, 0.2); color: #ff6b6b; } | |
| .list-item { | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 8px; | |
| padding: 15px; | |
| margin-bottom: 10px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .list-item:last-child { margin-bottom: 0; } | |
| .list-info h4 { margin-bottom: 5px; color: #fff; } | |
| .list-info p { color: #aaa; font-size: 13px; } | |
| .list-meta { text-align: right; font-size: 12px; color: #888; } | |
| .list-actions { display: flex; gap: 10px; margin-left: 15px; } | |
| .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: bold; } | |
| .badge-active { background: rgba(56, 239, 125, 0.2); color: #38ef7d; } | |
| .badge-inactive { background: rgba(255, 255, 255, 0.1); color: #aaa; } | |
| .checkbox-wrapper { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; } | |
| .checkbox-wrapper input { width: auto; } | |
| /* Modal Styles */ | |
| .modal { | |
| position: fixed; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(0,0,0,0.85); | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| z-index: 1000; | |
| padding: 20px; | |
| } | |
| .modal.active { display: flex; } | |
| .modal-content { | |
| background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); | |
| border-radius: 16px; | |
| padding: 30px; | |
| width: 100%; | |
| max-width: 650px; | |
| border: 1px solid rgba(255,255,255,0.15); | |
| box-shadow: 0 20px 60px rgba(0,0,0,0.5); | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| } | |
| .modal-content h3 { | |
| margin-bottom: 20px; | |
| font-size: 22px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .markdown-help { | |
| background: rgba(102,126,234,0.1); | |
| border: 1px solid rgba(102,126,234,0.2); | |
| padding: 12px 15px; | |
| border-radius: 10px; | |
| margin: 15px 0; | |
| font-size: 13px; | |
| } | |
| .markdown-help strong { color: #667eea; } | |
| .markdown-help code { | |
| background: rgba(102,126,234,0.2); | |
| padding: 2px 8px; | |
| border-radius: 4px; | |
| margin: 0 3px; | |
| font-family: 'Consolas', monospace; | |
| } | |
| .modal-actions { | |
| display: flex; | |
| gap: 12px; | |
| justify-content: flex-end; | |
| margin-top: 25px; | |
| } | |
| .modal-actions .btn { min-width: 100px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="header"> | |
| <h1>🤖 Icebox AI Admin</h1> | |
| <a href="/logout" class="logout-btn"><i class="fas fa-sign-out-alt"></i> Logout</a> | |
| </div> | |
| <div class="container"> | |
| {% with messages = get_flashed_messages(with_categories=true) %} | |
| {% for category, message in messages %} | |
| <div class="alert alert-{{ category }}">{{ message }}</div> | |
| {% endfor %} | |
| {% endwith %} | |
| <div class="nav-tabs"> | |
| <button class="nav-tab active" onclick="openTab(event, 'dashboard')">Dashboard</button> | |
| <button class="nav-tab" onclick="openTab(event, 'broadcast')">Broadcast & Messages</button> | |
| <button class="nav-tab" onclick="openTab(event, 'schedules')">Schedules</button> | |
| <button class="nav-tab" onclick="openTab(event, 'users')">Users</button> | |
| </div> | |
| <!-- DASHBOARD TAB --> | |
| <div id="dashboard" class="tab-content active"> | |
| <div class="stats-grid"> | |
| <div class="stat-card"> | |
| <h3>{{ stats.total_users }}</h3> | |
| <p>Total Users</p> | |
| </div> | |
| <div class="stat-card"> | |
| <h3>{{ stats.active_users_7d }}</h3> | |
| <p>Active Users (7d)</p> | |
| </div> | |
| <div class="stat-card"> | |
| <h3>{{ stats.paid_users or 0 }}</h3> | |
| <p>Premium Users</p> | |
| </div> | |
| <div class="stat-card"> | |
| <h3>{{ stats.total_generations }}</h3> | |
| <p>Total Generations</p> | |
| </div> | |
| <div class="stat-card"> | |
| <h3>{{ stats.gen_today or 0 }}</h3> | |
| <p>Generations Today</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- BROADCAST TAB --> | |
| <div id="broadcast" class="tab-content"> | |
| <div class="card"> | |
| <h2>📢 Send Broadcast</h2> | |
| <form method="POST" action="/broadcast"> | |
| <div class="form-group"> | |
| <label>Target Audience</label> | |
| <select name="target" id="targetSelect"> | |
| <option value="all">All Users</option> | |
| <option value="active">Active Users (last 7 days)</option> | |
| <option value="specific">Specific User (by Chat ID)</option> | |
| </select> | |
| </div> | |
| <div class="form-group" id="chatIdGroup" style="display:none;"> | |
| <label>Chat ID (comma separated)</label> | |
| <input type="text" name="chat_ids" placeholder="123456789, 987654321"> | |
| </div> | |
| <div class="form-group"> | |
| <label>Message (Markdown supported)</label> | |
| <textarea name="message" required placeholder="Enter your message here..."></textarea> | |
| </div> | |
| <div class="checkbox-wrapper"> | |
| <input type="checkbox" name="send_menu" id="send_menu" checked> | |
| <label for="send_menu" style="margin-bottom:0">Send Main Menu after message (Recommended)</label> | |
| </div> | |
| <button type="submit" class="btn btn-primary">Send Broadcast</button> | |
| </form> | |
| </div> | |
| <div class="card"> | |
| <h2>📝 Message History</h2> | |
| {% if message_logs %} | |
| {% for msg in message_logs %} | |
| <div class="list-item"> | |
| <div class="list-info"> | |
| <h4>{{ msg.message_content[:60] }}{% if msg.message_content|length > 60 %}...{% endif %}</h4> | |
| <p> | |
| <span class="badge badge-inactive">{{ msg.target_type }}</span> | |
| {{ msg.success_count }} sent, {{ msg.fail_count }} failed | |
| {% if msg.sent_message_ids %} | |
| <span class="badge badge-active" title="Can edit/delete">✓ Editable</span> | |
| {% endif %} | |
| </p> | |
| </div> | |
| <div class="list-meta"> | |
| {{ msg.sent_at }} | |
| </div> | |
| <div class="list-actions"> | |
| <button class="btn btn-primary btn-sm" | |
| data-id="{{ msg.id }}" | |
| data-message="{{ msg.message_content|e }}" | |
| onclick="openEditModal(this)">✏️ Edit</button> | |
| <form action="/message/{{ msg.id }}/delete" method="POST" style="display:inline;"> | |
| <button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('⚠️ This will DELETE the message from Telegram chats and remove the log. Continue?')">🗑️ Delete</button> | |
| </form> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| {% else %} | |
| <p style="color:#aaa; text-align:center;">No broadcast history yet.</p> | |
| {% endif %} | |
| </div> | |
| <!-- Edit Modal --> | |
| <div id="editModal" class="modal"> | |
| <div class="modal-content"> | |
| <h3>✏️ Edit & Resend Message</h3> | |
| <form id="editForm" method="POST"> | |
| <div class="form-group"> | |
| <label>Message Content</label> | |
| <textarea name="message" id="editMessage" rows="10" required placeholder="Enter your message..."></textarea> | |
| </div> | |
| <div class="markdown-help"> | |
| <strong>📝 Telegram Markdown Syntax:</strong><br> | |
| <code>*bold*</code> → <b>bold</b> | | |
| <code>_italic_</code> → <i>italic</i> | | |
| <code>`code`</code> → <code>code</code><br> | |
| <code>[text](url)</code> → link | | |
| <span style="color:#ff6b6b">⚠️ Note: <code>**text**</code> does NOT work in Telegram!</span> | |
| </div> | |
| <div class="modal-actions"> | |
| <button type="button" class="btn" onclick="closeEditModal()">Cancel</button> | |
| <button type="submit" class="btn btn-primary">💾 Save & Resend to Telegram</button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- SCHEDULES TAB --> | |
| <div id="schedules" class="tab-content"> | |
| <div class="card"> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;"> | |
| <h2 style="margin:0">⏰ Scheduled Messages</h2> | |
| <button onclick="document.getElementById('newSchedule').style.display='block'" class="btn btn-primary">+ New Schedule</button> | |
| </div> | |
| <div id="newSchedule" style="display:none; background:rgba(0,0,0,0.2); padding:20px; border-radius:10px; margin-bottom:20px;"> | |
| <h3 id="scheduleFormTitle">New Schedule</h3> | |
| <form method="POST" action="/schedule/new" id="scheduleForm"> | |
| <div class="form-group"> | |
| <label>Schedule Name</label> | |
| <input type="text" name="name" placeholder="e.g. Daily Limit Reset" required> | |
| </div> | |
| <div class="form-group"> | |
| <label>Message Content</label> | |
| <textarea name="message" required placeholder="Your scheduled message..."></textarea> | |
| </div> | |
| <div class="form-group"> | |
| <label>Presets</label> | |
| <select onchange="applyPreset(this)"> | |
| <option value="">-- Select a Preset --</option> | |
| <option value="reset">Daily Limit Reset Info</option> | |
| <option value="promo">Weekly Promotion</option> | |
| </select> | |
| </div> | |
| <div class="form-group"> | |
| <label>Time (UTC)</label> | |
| <input type="time" name="schedule_time" required> | |
| </div> | |
| <div class="checkbox-wrapper"> | |
| <input type="checkbox" name="is_enabled" value="true" checked> | |
| <label style="margin-bottom:0">Enable Immediately</label> | |
| </div> | |
| <div style="text-align:right;"> | |
| <button type="button" class="btn btn-sm" onclick="resetScheduleForm()">Cancel</button> | |
| <button type="submit" class="btn btn-primary" id="scheduleSubmitBtn">Create Schedule</button> | |
| </div> | |
| </form> | |
| </div> | |
| {% if schedules %} | |
| {% for sched in schedules %} | |
| <div class="list-item" style="opacity: {{ '1' if sched.is_enabled else '0.6' }}"> | |
| <div class="list-info"> | |
| <h4>{{ sched.name }}</h4> | |
| <p>{{ sched.message_content[:50] }}...</p> | |
| <div style="margin-top:5px;"> | |
| <span class="badge {{ 'badge-active' if sched.is_enabled else 'badge-inactive' }}"> | |
| {{ 'Active' if sched.is_enabled else 'Disabled' }} | |
| </span> | |
| <span class="badge badge-inactive">{{ sched.schedule_time }} UTC</span> | |
| </div> | |
| </div> | |
| <div class="list-actions"> | |
| <button class="btn btn-sm btn-primary" | |
| onclick="editSchedule('{{ sched.id }}', '{{ sched.name }}', `{{ sched.message_content }}`, '{{ sched.schedule_time }}', '{{ sched.is_enabled }}')"> | |
| Edit | |
| </button> | |
| <form action="/schedule/{{ sched.id }}/toggle" method="POST"> | |
| <button type="submit" class="btn btn-sm {{ 'btn-danger' if sched.is_enabled else 'btn-success' }}"> | |
| {{ 'Disable' if sched.is_enabled else 'Enable' }} | |
| </button> | |
| </form> | |
| <form action="/schedule/{{ sched.id }}/delete" method="POST"> | |
| <button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Delete this schedule?')">Delete</button> | |
| </form> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| {% else %} | |
| <p style="color:#aaa; text-align:center;">No scheduled messages.</p> | |
| {% endif %} | |
| </div> | |
| </div> | |
| <!-- USERS TAB --> | |
| <div id="users" class="tab-content"> | |
| <div class="card"> | |
| <h2>👥 Recent Active Users</h2> | |
| <div style="overflow-x:auto;"> | |
| <table style="width:100%; border-collapse:collapse; text-align:left;"> | |
| <thead> | |
| <tr style="border-bottom:1px solid rgba(255,255,255,0.1); color:#aaa;"> | |
| <th style="padding:10px;">User</th> | |
| <th style="padding:10px;">Chat ID</th> | |
| <th style="padding:10px;">Tier</th> | |
| <th style="padding:10px;">Generated</th> | |
| <th style="padding:10px;">Last Active</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% for user in users_list %} | |
| <tr style="border-bottom:1px solid rgba(255,255,255,0.05);"> | |
| <td style="padding:10px;"> | |
| <strong>{{ user.first_name }}</strong> | |
| {% if user.username %}<br><small style="color:#aaa">@{{ user.username }}</small>{% endif %} | |
| </td> | |
| <td style="padding:10px; color:#aaa;">{{ user.chat_id }}</td> | |
| <td style="padding:10px;"><span class="badge">{{ user.tier }}</span></td> | |
| <td style="padding:10px;">{{ user.total_images_generated }}</td> | |
| <td style="padding:10px; font-size:12px; color:#aaa;">{{ user.last_active }}</td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Define global functions | |
| function openTab(evt, tabName) { | |
| var i; | |
| var x = document.getElementsByClassName("tab-content"); | |
| for (i = 0; i < x.length; i++) { | |
| x[i].style.display = "none"; | |
| x[i].classList.remove("active"); | |
| } | |
| var tabs = document.getElementsByClassName("nav-tab"); | |
| for (i = 0; i < tabs.length; i++) { | |
| tabs[i].className = tabs[i].className.replace(" active", ""); | |
| } | |
| var tab = document.getElementById(tabName); | |
| if (tab) { | |
| tab.style.display = "block"; | |
| tab.classList.add("active"); | |
| } | |
| if (evt && evt.currentTarget) { | |
| evt.currentTarget.className += " active"; | |
| } | |
| } | |
| function applyPreset(select) { | |
| var val = select.value; | |
| var ta = document.querySelector('textarea[name="message"]'); | |
| var nameIn = document.querySelector('input[name="name"]'); | |
| if (val === 'reset') { | |
| ta.value = "*🔄 Daily Limit Reset!*\n\nYour free generation limit has been reset. You have 30 coins available to create amazing images today! 🎨\n\nUse /start to generate now."; | |
| nameIn.value = "Daily Limit Notification"; | |
| } else if (val === 'promo') { | |
| ta.value = "*🎉 Special Offer!*\n\nGet 500 coins for just $5. Limited time offer! 💎\n\nType /upgrade to claim."; | |
| nameIn.value = "Weekly Promo"; | |
| } | |
| } | |
| function editSchedule(id, name, message, time, is_enabled) { | |
| var newSchedule = document.getElementById('newSchedule'); | |
| if (newSchedule) newSchedule.style.display = 'block'; | |
| var form = document.getElementById('scheduleForm'); | |
| if (form) { | |
| form.action = '/schedule/' + id + '/update'; | |
| document.querySelector('input[name="name"]').value = name; | |
| document.querySelector('textarea[name="message"]').value = message; | |
| document.querySelector('input[name="schedule_time"]').value = time; | |
| document.querySelector('input[name="is_enabled"]').checked = is_enabled === 'True'; | |
| document.getElementById('scheduleFormTitle').innerText = 'Edit Schedule'; | |
| document.getElementById('scheduleSubmitBtn').innerText = 'Update Schedule'; | |
| } | |
| } | |
| function resetScheduleForm() { | |
| document.getElementById('newSchedule').style.display = 'none'; | |
| var form = document.getElementById('scheduleForm'); | |
| form.action = '/schedule/new'; | |
| form.reset(); | |
| document.getElementById('scheduleFormTitle').innerText = 'New Schedule'; | |
| document.getElementById('scheduleSubmitBtn').innerText = 'Create Schedule'; | |
| } | |
| function openEditModal(btn) { | |
| var id = btn.getAttribute('data-id'); | |
| var message = btn.getAttribute('data-message'); | |
| document.getElementById('editForm').action = '/message/' + id + '/resend'; | |
| document.getElementById('editMessage').value = message; | |
| document.getElementById('editModal').classList.add('active'); | |
| } | |
| function closeEditModal() { | |
| var modal = document.getElementById('editModal'); | |
| if (modal) modal.classList.remove('active'); | |
| var form = document.getElementById('editForm'); | |
| if (form) form.reset(); | |
| } | |
| // Initialize listeners when DOM is ready | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // Target chat id input toggle | |
| var targetSelect = document.getElementById('targetSelect'); | |
| if (targetSelect) { | |
| targetSelect.addEventListener('change', function() { | |
| var group = document.getElementById('chatIdGroup'); | |
| if (group) group.style.display = this.value === 'specific' ? 'block' : 'none'; | |
| }); | |
| } | |
| // Edit modal outside click | |
| var editModal = document.getElementById('editModal'); | |
| if (editModal) { | |
| editModal.addEventListener('click', function(e) { | |
| if (e.target === this) closeEditModal(); | |
| }); | |
| } | |
| // Escape key | |
| document.addEventListener('keydown', function(e) { | |
| if (e.key === 'Escape') closeEditModal(); | |
| }); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| def login_required(f): | |
| def decorated_function(*args, **kwargs): | |
| if not session.get('logged_in'): | |
| return redirect(url_for('login')) | |
| return f(*args, **kwargs) | |
| return decorated_function | |
| # --- Helper Functions --- | |
| def normalize_chat_ids(target, chat_ids_input): | |
| """Return a list of chat IDs based on target selection.""" | |
| if target == 'specific': | |
| return [int(cid.strip()) for cid in chat_ids_input.split(',') if cid.strip()] | |
| elif target == 'active': | |
| # Active in last 7 days | |
| res = supabase.table("telegram_users").select("chat_id").gte( | |
| "last_active", (datetime.now() - timedelta(days=7)).isoformat() | |
| ).execute() | |
| return [u['chat_id'] for u in res.data] | |
| else: | |
| # All users | |
| res = supabase.table("telegram_users").select("chat_id").execute() | |
| return [u['chat_id'] for u in res.data] | |
| def send_telegram_message_with_menu(chat_id: int, message: str, send_menu: bool = True) -> tuple[bool, str, int]: | |
| """Send message and optionally the main menu. | |
| Returns: (success, error_message, message_id) | |
| Telegram Markdown syntax: | |
| - *bold* (not **bold**) | |
| - _italic_ | |
| - `code` | |
| - [link text](url) | |
| """ | |
| try: | |
| # 1. Send Main Message | |
| url = f"{TELEGRAM_API_BASE}/bot{TELEGRAM_TOKEN}/sendMessage" | |
| data = { | |
| "chat_id": chat_id, | |
| "text": message, | |
| "parse_mode": "Markdown" | |
| } | |
| response = requests.post(url, json=data, timeout=10) | |
| if response.status_code != 200: | |
| if response.status_code == 404: | |
| return False, f"404 Not Found at {url.replace(TELEGRAM_TOKEN, '***')}. Check Base URL or Token.", 0 | |
| return False, f"Main msg failed: {response.text}", 0 | |
| # Extract message_id from response | |
| result = response.json() | |
| message_id = result.get('result', {}).get('message_id', 0) | |
| if send_menu: | |
| # 2. Add Main Menu | |
| menu_url = f"{TELEGRAM_API_BASE}/bot{TELEGRAM_TOKEN}/sendMessage" | |
| menu_keyboard = { | |
| "inline_keyboard": [ | |
| [{"text": "Generate Image", "callback_data": "generate_mode"}], | |
| [{"text": "My Profile", "callback_data": "profile"}], | |
| [{"text": "Help & Support", "callback_data": "help"}] | |
| ] | |
| } | |
| menu_data = { | |
| "chat_id": chat_id, | |
| "text": "👇 *Quick Actions:*", | |
| "parse_mode": "Markdown", | |
| "reply_markup": menu_keyboard | |
| } | |
| menu_res = requests.post(menu_url, json=menu_data, timeout=5) | |
| if menu_res.status_code != 200: | |
| return True, f"Msg sent, but menu failed: {menu_res.text}", message_id | |
| return True, "", message_id | |
| except Exception as e: | |
| return False, str(e), 0 | |
| def edit_telegram_message(chat_id: int, message_id: int, new_text: str) -> tuple[bool, str]: | |
| """Edit existing Telegram message using editMessageText API. | |
| Note: Can only edit messages sent by the bot within 48 hours. | |
| """ | |
| try: | |
| url = f"{TELEGRAM_API_BASE}/bot{TELEGRAM_TOKEN}/editMessageText" | |
| data = { | |
| "chat_id": chat_id, | |
| "message_id": message_id, | |
| "text": new_text, | |
| "parse_mode": "Markdown" | |
| } | |
| response = requests.post(url, json=data, timeout=10) | |
| if response.status_code == 200: | |
| return True, "" | |
| return False, response.text | |
| except Exception as e: | |
| return False, str(e) | |
| def delete_telegram_message(chat_id: int, message_id: int) -> tuple[bool, str]: | |
| """Delete Telegram message using deleteMessage API. | |
| Note: Can only delete messages sent by the bot within 48 hours. | |
| """ | |
| try: | |
| url = f"{TELEGRAM_API_BASE}/bot{TELEGRAM_TOKEN}/deleteMessage" | |
| data = { | |
| "chat_id": chat_id, | |
| "message_id": message_id | |
| } | |
| response = requests.post(url, json=data, timeout=10) | |
| if response.status_code == 200: | |
| return True, "" | |
| return False, response.text | |
| except Exception as e: | |
| return False, str(e) | |
| def check_and_send_scheduled_messages(): | |
| """Background task to check and send scheduled messages.""" | |
| with app.app_context(): | |
| now = datetime.now(timezone.utc) | |
| current_time_str = now.strftime('%H:%M') | |
| try: | |
| # Fetch active schedules | |
| res = supabase.table("scheduled_messages").select("*").eq("is_enabled", True).execute() | |
| schedules = res.data | |
| for sched in schedules: | |
| if sched['schedule_time'].startswith(current_time_str): | |
| # Check if already sent today (simple debounce) | |
| last_sent = sched.get('last_sent_at') | |
| if last_sent: | |
| last_sent_date = datetime.fromisoformat(last_sent).date() | |
| if last_sent_date == now.date(): | |
| continue # Already sent today | |
| # Execute Broadcast logic | |
| chat_ids = normalize_chat_ids(sched.get('target_type', 'all'), "") | |
| msg_content = sched['message_content'] | |
| success = 0 | |
| fail = 0 | |
| last_error = "" | |
| sent_message_ids = {} | |
| for cid in chat_ids: | |
| is_ok, err_msg, msg_id = send_telegram_message_with_menu(cid, msg_content, send_menu=True) | |
| if is_ok: | |
| success += 1 | |
| if msg_id: | |
| sent_message_ids[str(cid)] = msg_id | |
| else: | |
| fail += 1 | |
| last_error = err_msg | |
| # Update Schedule | |
| supabase.table("scheduled_messages").update({ | |
| "last_sent_at": now.isoformat() | |
| }).eq("id", sched['id']).execute() | |
| # Log it | |
| try: | |
| supabase.table("admin_message_logs").insert({ | |
| "target_type": sched.get('target_type'), | |
| "message_content": msg_content, | |
| "total_recipients": len(chat_ids), | |
| "success_count": success, | |
| "fail_count": fail, | |
| "created_by": "scheduler", | |
| "sent_message_ids": sent_message_ids | |
| }).execute() | |
| except: | |
| pass | |
| print(f"Executed schedule {sched['name']}: {success} sent. Last Error: {last_error}") | |
| except Exception as e: | |
| print(f"Scheduler Error: {e}") | |
| # Start Scheduler | |
| if not scheduler.running: | |
| scheduler.add_job(check_and_send_scheduled_messages, 'interval', minutes=1) | |
| scheduler.start() | |
| # --- Routes --- | |
| def login(): | |
| error = None | |
| if request.method == 'POST': | |
| if request.form['password'] == ADMIN_PASSWORD: | |
| session['logged_in'] = True | |
| return redirect(url_for('dashboard')) | |
| else: | |
| error = 'Invalid password' | |
| return render_template_string(LOGIN_TEMPLATE, error=error) | |
| def logout(): | |
| session.pop('logged_in', None) | |
| return redirect(url_for('login')) | |
| def dashboard(): | |
| stats = {} | |
| message_logs = [] | |
| schedules = [] | |
| users_list = [] | |
| try: | |
| # 1. Stats | |
| total_users = supabase.table("telegram_users").select("id", count="exact").execute().count | |
| active_7d = supabase.table("telegram_users").select("id", count="exact").gte( | |
| "last_active", (datetime.now() - timedelta(days=7)).isoformat() | |
| ).execute().count | |
| paid_users = supabase.table("telegram_users").select("id", count="exact").eq("tier", "paid").execute().count | |
| total_gen = supabase.table("image_generation_logs").select("id", count="exact").execute().count | |
| gen_today = supabase.table("image_generation_logs").select("id", count="exact").gte( | |
| "created_at", datetime.now().strftime('%Y-%m-%d') | |
| ).execute().count | |
| stats = { | |
| "total_users": total_users, | |
| "active_users_7d": active_7d, | |
| "paid_users": paid_users, | |
| "total_generations": total_gen, | |
| "gen_today": gen_today | |
| } | |
| # 2. Message Logs | |
| try: | |
| res_logs = supabase.table("admin_message_logs").select("*").order("sent_at", desc=True).limit(10).execute() | |
| message_logs = res_logs.data | |
| except: | |
| message_logs = [] # Table might not exist yet | |
| # 3. Schedules | |
| try: | |
| res_sched = supabase.table("scheduled_messages").select("*").order("created_at", desc=True).execute() | |
| schedules = res_sched.data | |
| except: | |
| schedules = [] | |
| # 4. Users List | |
| users_list = supabase.table("telegram_users").select("*").order("last_active", desc=True).limit(50).execute().data | |
| except Exception as e: | |
| flash(f"Error loading dashboard: {e}", "error") | |
| return render_template_string( | |
| DASHBOARD_TEMPLATE, | |
| stats=stats, | |
| message_logs=message_logs, | |
| schedules=schedules, | |
| users_list=users_list | |
| ) | |
| def broadcast(): | |
| target = request.form.get('target', 'all') | |
| message = request.form.get('message', '').strip() | |
| chat_ids_input = request.form.get('chat_ids', '') | |
| send_menu = 'send_menu' in request.form | |
| if not message: | |
| flash('Message cannot be empty', 'error') | |
| return redirect(url_for('dashboard')) | |
| try: | |
| # Get recipients | |
| chat_ids = normalize_chat_ids(target, chat_ids_input) | |
| if not chat_ids: | |
| flash('No users found for target audience', 'error') | |
| return redirect(url_for('dashboard')) | |
| success_count = 0 | |
| fail_count = 0 | |
| last_error = "" | |
| sent_message_ids = {} | |
| for chat_id in chat_ids: | |
| is_ok, err_msg, msg_id = send_telegram_message_with_menu(chat_id, message, send_menu) | |
| if is_ok: | |
| success_count += 1 | |
| if msg_id: | |
| sent_message_ids[str(chat_id)] = msg_id | |
| else: | |
| fail_count += 1 | |
| last_error = err_msg | |
| # Log to DB with message_ids | |
| try: | |
| supabase.table("admin_message_logs").insert({ | |
| "target_type": target, | |
| "target_chat_ids": chat_ids_input if target == 'specific' else None, | |
| "message_content": message, | |
| "total_recipients": len(chat_ids), | |
| "success_count": success_count, | |
| "fail_count": fail_count, | |
| "created_by": "admin", | |
| "sent_message_ids": sent_message_ids | |
| }).execute() | |
| except Exception as log_err: | |
| print(f"Logging error: {log_err}") | |
| msg_text = f'Broadcast complete! Sent: {success_count}, Failed: {fail_count}' | |
| if fail_count > 0: | |
| msg_text += f" (Last Error: {last_error})" | |
| flash(msg_text, 'success' if fail_count == 0 else 'warning') | |
| except Exception as e: | |
| flash(f'Error: {str(e)}', 'error') | |
| return redirect(url_for('dashboard')) | |
| def resend_message(id): | |
| """Edit already sent Telegram messages with new content.""" | |
| try: | |
| # Get new content from form | |
| new_content = request.form.get('message', '').strip() | |
| # Fetch message log | |
| res = supabase.table("admin_message_logs").select("*").eq("id", id).execute() | |
| if not res.data: | |
| flash("Message log not found", "error") | |
| return redirect(url_for('dashboard')) | |
| msg = res.data[0] | |
| sent_ids = msg.get('sent_message_ids', {}) | |
| if not new_content: | |
| # If no new content provided, use existing content | |
| new_content = msg['message_content'] | |
| if not sent_ids: | |
| flash("No message IDs stored - cannot edit (message may be too old)", "warning") | |
| return redirect(url_for('dashboard')) | |
| # Edit each sent message | |
| success = 0 | |
| fail = 0 | |
| last_error = "" | |
| for chat_id_str, message_id in sent_ids.items(): | |
| is_ok, err_msg = edit_telegram_message(int(chat_id_str), message_id, new_content) | |
| if is_ok: | |
| success += 1 | |
| else: | |
| fail += 1 | |
| last_error = err_msg | |
| # Update message content in DB | |
| supabase.table("admin_message_logs").update({ | |
| "message_content": new_content | |
| }).eq("id", id).execute() | |
| msg_text = f"Edited {success}/{len(sent_ids)} messages" | |
| if fail > 0: | |
| msg_text += f" ({fail} failed: {last_error})" | |
| flash(msg_text, "success" if fail == 0 else "warning") | |
| except Exception as e: | |
| flash(f"Error editing: {e}", "error") | |
| return redirect(url_for('dashboard')) | |
| def delete_message(id): | |
| """Delete messages from Telegram and then from database.""" | |
| try: | |
| # Fetch message first to get message_ids | |
| res = supabase.table("admin_message_logs").select("*").eq("id", id).execute() | |
| if res.data: | |
| msg = res.data[0] | |
| sent_ids = msg.get('sent_message_ids', {}) | |
| # Delete from Telegram | |
| deleted = 0 | |
| failed = 0 | |
| for chat_id_str, message_id in sent_ids.items(): | |
| is_ok, _ = delete_telegram_message(int(chat_id_str), message_id) | |
| if is_ok: | |
| deleted += 1 | |
| else: | |
| failed += 1 | |
| # Delete from database | |
| supabase.table("admin_message_logs").delete().eq("id", id).execute() | |
| if sent_ids: | |
| flash(f"Deleted from Telegram: {deleted}/{len(sent_ids)}, DB log removed", "success") | |
| else: | |
| flash("DB log removed (no Telegram messages to delete)", "success") | |
| else: | |
| flash("Message log not found", "error") | |
| except Exception as e: | |
| flash(f"Error deleting: {e}", "error") | |
| return redirect(url_for('dashboard')) | |
| def update_message(id): | |
| """Update message content in DB only (for scheduled editing before resend).""" | |
| try: | |
| new_content = request.form.get('message') | |
| supabase.table("admin_message_logs").update({"message_content": new_content}).eq("id", id).execute() | |
| flash("Message log updated (not yet sent to Telegram)", "success") | |
| except Exception as e: | |
| flash(f"Error updating message: {e}", "error") | |
| return redirect(url_for('dashboard')) | |
| def new_schedule(): | |
| try: | |
| data = { | |
| "name": request.form['name'], | |
| "message_content": request.form['message'], | |
| "schedule_time": request.form['schedule_time'], # Format HH:MM | |
| "is_enabled": 'is_enabled' in request.form, | |
| "target_type": "all" # Default to all for now | |
| } | |
| supabase.table("scheduled_messages").insert(data).execute() | |
| flash("Schedule created!", "success") | |
| except Exception as e: | |
| flash(f"Error creating schedule: {e}", "error") | |
| return redirect(url_for('dashboard')) | |
| def update_schedule(id): | |
| try: | |
| data = { | |
| "name": request.form['name'], | |
| "message_content": request.form['message'], | |
| "schedule_time": request.form['schedule_time'], | |
| "is_enabled": 'is_enabled' in request.form | |
| } | |
| supabase.table("scheduled_messages").update(data).eq("id", id).execute() | |
| flash("Schedule updated!", "success") | |
| except Exception as e: | |
| flash(f"Error updating schedule: {e}", "error") | |
| return redirect(url_for('dashboard')) | |
| def toggle_schedule(id): | |
| try: | |
| # Get current status | |
| curr = supabase.table("scheduled_messages").select("is_enabled").eq("id", id).execute().data[0] | |
| new_status = not curr['is_enabled'] | |
| supabase.table("scheduled_messages").update({"is_enabled": new_status}).eq("id", id).execute() | |
| except Exception as e: | |
| flash(f"Error updating schedule: {e}", "error") | |
| return redirect(url_for('dashboard')) | |
| def delete_schedule(id): | |
| try: | |
| supabase.table("scheduled_messages").delete().eq("id", id).execute() | |
| flash("Schedule deleted", "success") | |
| except Exception as e: | |
| flash(f"Error deleting schedule: {e}", "error") | |
| return redirect(url_for('dashboard')) | |
| if __name__ == "__main__": | |
| app.run(host="0.0.0.0", port=7860, debug=False) | |