iceboxai-admin / admin.py
alphabagibagi's picture
Update admin.py
61427c4 verified
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):
@wraps(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
@app.route('/login', methods=['GET', 'POST'])
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)
@app.route('/logout')
def logout():
session.pop('logged_in', None)
return redirect(url_for('login'))
@app.route('/api/user-details/<chat_id>')
@login_required
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
@app.route('/api/chat-history/<user_id>')
@login_required
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
@app.route('/api/admin/save-settings', methods=['POST'])
@login_required
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
@app.route('/')
@login_required
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 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"}]
]
}
}
@app.route('/api/broadcast', methods=['POST'])
@login_required
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
})
@app.route('/api/cron/reset-coins', methods=['GET', 'POST'])
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
@app.route('/health')
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)