iceboxai / admin.py
alphabagibagi's picture
Update admin.py
a182b7d verified
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> &nbsp;|&nbsp;
<code>_italic_</code> → <i>italic</i> &nbsp;|&nbsp;
<code>`code`</code> → <code>code</code><br>
<code>[text](url)</code> → link &nbsp;|&nbsp;
<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):
@wraps(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 ---
@app.route('/login', methods=['GET', 'POST'])
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)
@app.route('/logout')
def logout():
session.pop('logged_in', None)
return redirect(url_for('login'))
@app.route('/', methods=['GET'])
@login_required
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
)
@app.route('/broadcast', methods=['POST'])
@login_required
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'))
@app.route('/message/<id>/resend', methods=['POST'])
@login_required
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'))
@app.route('/message/<id>/delete', methods=['POST'])
@login_required
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'))
@app.route('/message/<id>/update', methods=['POST'])
@login_required
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'))
@app.route('/schedule/new', methods=['POST'])
@login_required
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'))
@app.route('/schedule/<id>/update', methods=['POST'])
@login_required
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'))
@app.route('/schedule/<id>/toggle', methods=['POST'])
@login_required
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'))
@app.route('/schedule/<id>/delete', methods=['POST'])
@login_required
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)