456 lines
17 KiB
Python
456 lines
17 KiB
Python
|
|
import asyncio
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import sqlite3
|
||
|
|
import tempfile
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime
|
||
|
|
from PIL import Image
|
||
|
|
import subprocess
|
||
|
|
import requests
|
||
|
|
import shutil
|
||
|
|
import glob
|
||
|
|
from telegram import Update, InlineKeyboardMarkup, InlineKeyboardButton
|
||
|
|
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes, ConversationHandler, CallbackQueryHandler
|
||
|
|
|
||
|
|
RULES_CALLBACK = "show_rules"
|
||
|
|
BACK_CALLBACK = "back_to_main"
|
||
|
|
|
||
|
|
COMFYUI_WORKFLOW = "video_generate.json"
|
||
|
|
TELEGRAM_TOKEN = "TELEGRAM_TOKEN"
|
||
|
|
COMFYUI_URL = "http://127.0.0.1:8188"
|
||
|
|
COMFYUI_OUTPUT_DIR = "output/"
|
||
|
|
COMFYUI_INPUT_DIR = "input/"
|
||
|
|
COMFYUI_COMMON_PROMT = ["woman take off her clothes, full nudity, full naked, pyssy, boobs, nipples. camera zooms out",""]
|
||
|
|
COMFYUI_MAX_SIZE = 580
|
||
|
|
COMFYUI_COST = 20
|
||
|
|
ADMIN_IDS = [1145483994, 498845556]
|
||
|
|
|
||
|
|
WAITING_CONFIRM = 0
|
||
|
|
|
||
|
|
def init_db():
|
||
|
|
conn = sqlite3.connect('bot.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute('''CREATE TABLE IF NOT EXISTS users (
|
||
|
|
user_id INTEGER PRIMARY KEY,
|
||
|
|
username TEXT,
|
||
|
|
balance INTEGER DEFAULT 0,
|
||
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
|
|
)''')
|
||
|
|
c.execute('''CREATE TABLE IF NOT EXISTS admin_logs (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
admin_id INTEGER NOT NULL,
|
||
|
|
admin_username TEXT,
|
||
|
|
target_user_id INTEGER NOT NULL,
|
||
|
|
tokens_added INTEGER NOT NULL,
|
||
|
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
|
|
)''')
|
||
|
|
conn.commit()
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
def log_admin_action(admin_id, admin_username, target_user_id, tokens):
|
||
|
|
"""Записывает действие админа в файл"""
|
||
|
|
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
|
|
log_entry = (
|
||
|
|
f"[{timestamp}] Admin {admin_id} (@{admin_username}) "
|
||
|
|
f"added {tokens} tokens to user {target_user_id}\n"
|
||
|
|
)
|
||
|
|
with open('admin_actions.log', 'a', encoding='utf-8') as f:
|
||
|
|
f.write(log_entry)
|
||
|
|
|
||
|
|
def get_user_balance(user_id):
|
||
|
|
conn = sqlite3.connect('bot.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute('SELECT balance FROM users WHERE user_id = ?', (user_id,))
|
||
|
|
result = c.fetchone()
|
||
|
|
conn.close()
|
||
|
|
return result[0] if result else 0
|
||
|
|
|
||
|
|
def add_user(user_id, username):
|
||
|
|
conn = sqlite3.connect('bot.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute('INSERT OR IGNORE INTO users (user_id, username) VALUES (?, ?)', (user_id, username))
|
||
|
|
conn.commit()
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
def update_balance(user_id, amount):
|
||
|
|
conn = sqlite3.connect('bot.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute('UPDATE users SET balance = balance + ? WHERE user_id = ?', (amount, user_id))
|
||
|
|
conn.commit()
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
def deduct_balance(user_id, amount):
|
||
|
|
conn = sqlite3.connect('bot.db')
|
||
|
|
c = conn.cursor()
|
||
|
|
c.execute('UPDATE users SET balance = balance - ? WHERE user_id = ?', (amount, user_id))
|
||
|
|
conn.commit()
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
def resize_image(image_path, max_size=COMFYUI_MAX_SIZE):
|
||
|
|
with Image.open(image_path) as img:
|
||
|
|
img = img.convert('RGBA')
|
||
|
|
width, height = img.size
|
||
|
|
if width > height:
|
||
|
|
new_width = min(width, max_size)
|
||
|
|
new_height = int(height * new_width / width)
|
||
|
|
else:
|
||
|
|
new_height = min(height, max_size)
|
||
|
|
new_width = int(width * new_height / height)
|
||
|
|
resized = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
|
return new_width, new_height, resized
|
||
|
|
|
||
|
|
def find_video_output_node(workflow_dict):
|
||
|
|
for node_id, node in workflow_dict.items():
|
||
|
|
inputs = node.get("inputs", {})
|
||
|
|
if isinstance(inputs, dict) and "filename_prefix" in inputs:
|
||
|
|
return node_id
|
||
|
|
raise ValueError("No video output node with 'filename_prefix' found in workflow!")
|
||
|
|
|
||
|
|
def find_latest_video(output_dir, extensions=('.mp4', '.webm', '.mkv')):
|
||
|
|
"""Ищет самый свежий видеофайл в output_dir"""
|
||
|
|
all_videos = []
|
||
|
|
for root, _, files in os.walk(output_dir):
|
||
|
|
for f in files:
|
||
|
|
if f.lower().endswith(extensions):
|
||
|
|
full_path = os.path.join(root, f)
|
||
|
|
all_videos.append(full_path)
|
||
|
|
if not all_videos:
|
||
|
|
return None
|
||
|
|
return max(all_videos, key=os.path.getmtime)
|
||
|
|
|
||
|
|
def modify_workflow(workflow_str, prompt, image_filename, width, height, video_prefix):
|
||
|
|
wf_raw = json.loads(workflow_str)
|
||
|
|
wf = {}
|
||
|
|
for key, value in wf_raw.items():
|
||
|
|
clean_key = key.strip()
|
||
|
|
if isinstance(value, dict) and 'inputs' in value:
|
||
|
|
inputs_raw = value['inputs']
|
||
|
|
if isinstance(inputs_raw, dict):
|
||
|
|
clean_inputs = {}
|
||
|
|
for in_key, in_val in inputs_raw.items():
|
||
|
|
clean_inputs[in_key.strip()] = in_val
|
||
|
|
value = {k: v for k, v in value.items() if k != 'inputs'}
|
||
|
|
value['inputs'] = clean_inputs
|
||
|
|
wf[clean_key] = value
|
||
|
|
|
||
|
|
if '93' in wf and 'inputs' in wf['93']:
|
||
|
|
wf['93']['inputs']['text'] = f"{COMFYUI_COMMON_PROMT}"
|
||
|
|
|
||
|
|
height = (height // 16) * 16
|
||
|
|
width = (width // 16) * 16
|
||
|
|
|
||
|
|
if '183' in wf and 'inputs' in wf['183']:
|
||
|
|
wf['183']['inputs']['value'] = height
|
||
|
|
if '184' in wf and 'inputs' in wf['184']:
|
||
|
|
wf['184']['inputs']['value'] = width
|
||
|
|
|
||
|
|
if '193' in wf and 'inputs' in wf['193']:
|
||
|
|
wf['193']['inputs']['image'] = image_filename
|
||
|
|
|
||
|
|
if '214' in wf and 'inputs' in wf['214']:
|
||
|
|
wf['214']['inputs']['filename_prefix'] = video_prefix
|
||
|
|
|
||
|
|
return json.dumps(wf)
|
||
|
|
|
||
|
|
def find_video_by_prefix(output_dir, prefix, extensions=('.mp4', '.webm', '.mkv')):
|
||
|
|
for ext in extensions:
|
||
|
|
pattern = os.path.join(output_dir, '**', f"{prefix}*{ext}")
|
||
|
|
matches = glob.glob(pattern, recursive=True)
|
||
|
|
if matches:
|
||
|
|
return max(matches, key=os.path.getmtime)
|
||
|
|
return None
|
||
|
|
|
||
|
|
async def run_comfyui_workflow(workflow_json_str, input_image_path, video_prefix, update: Update):
|
||
|
|
workflow_dict = json.loads(workflow_json_str)
|
||
|
|
client_id = str(uuid.uuid4())
|
||
|
|
|
||
|
|
async with aiohttp.ClientSession() as session:
|
||
|
|
async with session.post(f"{COMFYUI_URL}/prompt", json={
|
||
|
|
"prompt": workflow_dict,
|
||
|
|
"client_id": client_id
|
||
|
|
}) as resp:
|
||
|
|
if resp.status != 200:
|
||
|
|
raise Exception(f"ComfyUI API error {resp.status}")
|
||
|
|
prompt_id = (await resp.json())["prompt_id"]
|
||
|
|
|
||
|
|
progress_msg = await update.message.reply_text("🚀 Запуск генерации...")
|
||
|
|
uri = f"ws://127.0.0.1:8188/ws?clientId={client_id}"
|
||
|
|
|
||
|
|
try:
|
||
|
|
async with websockets.connect(uri) as websocket:
|
||
|
|
while True:
|
||
|
|
try:
|
||
|
|
msg = await asyncio.wait_for(websocket.recv(), timeout=300.0)
|
||
|
|
data = json.loads(msg)
|
||
|
|
|
||
|
|
if data["type"] == "executing":
|
||
|
|
node = data["data"].get("node")
|
||
|
|
if node is None:
|
||
|
|
break
|
||
|
|
try:
|
||
|
|
await progress_msg.edit_text("🔄 Обрабатываю...")
|
||
|
|
except BadRequest as e:
|
||
|
|
if "Message is not modified" not in str(e):
|
||
|
|
raise
|
||
|
|
|
||
|
|
elif data["type"] == "progress":
|
||
|
|
value = data["data"].get("value", 0)
|
||
|
|
max_val = data["data"].get("max", 1)
|
||
|
|
pct = int(100 * value / max_val) if max_val else 0
|
||
|
|
await progress_msg.edit_text(f"⏳ Прогресс: {pct}%")
|
||
|
|
|
||
|
|
elif data["type"] == "execution_error":
|
||
|
|
err = data["data"].get("exception_message", "Ошибка")
|
||
|
|
raise Exception(err)
|
||
|
|
|
||
|
|
except asyncio.TimeoutError:
|
||
|
|
break
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
print(f"WebSocket error: {e}")
|
||
|
|
|
||
|
|
await progress_msg.edit_text("🎥 Собираю видео...")
|
||
|
|
for _ in range(60):
|
||
|
|
await asyncio.sleep(2)
|
||
|
|
video_path = find_latest_video(COMFYUI_OUTPUT_DIR)
|
||
|
|
if video_path and os.path.getsize(video_path) > 1024:
|
||
|
|
await progress_msg.edit_text("✅ Готово!")
|
||
|
|
return video_path
|
||
|
|
|
||
|
|
raise Exception("Видео не найдено")
|
||
|
|
|
||
|
|
def clean_metadata(video_path):
|
||
|
|
temp_dir = os.path.dirname(video_path)
|
||
|
|
with tempfile.NamedTemporaryFile(suffix='.mp4', dir=temp_dir, delete=False) as tmp:
|
||
|
|
temp_path = tmp.name
|
||
|
|
|
||
|
|
try:
|
||
|
|
subprocess.run([
|
||
|
|
'ffmpeg', '-y', '-i', video_path,
|
||
|
|
'-map_metadata', '-1',
|
||
|
|
'-c:v', 'libx264',
|
||
|
|
'-c:a', 'aac',
|
||
|
|
'-pix_fmt', 'yuv420p',
|
||
|
|
temp_path
|
||
|
|
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
|
|
|
||
|
|
shutil.copy2(temp_path, video_path)
|
||
|
|
os.unlink(temp_path)
|
||
|
|
return video_path
|
||
|
|
except Exception as e:
|
||
|
|
if os.path.exists(temp_path):
|
||
|
|
os.unlink(temp_path)
|
||
|
|
raise Exception(f"Metadata cleanup failed: {str(e)}")
|
||
|
|
|
||
|
|
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
|
user = update.effective_user
|
||
|
|
add_user(user.id, user.username)
|
||
|
|
balance = get_user_balance(user.id)
|
||
|
|
keyboard = [[InlineKeyboardButton("ПРАВИЛА", callback_data=RULES_CALLBACK)]]
|
||
|
|
await update.message.reply_text(
|
||
|
|
f"👋🏻 Привет, Творец!\n\n"
|
||
|
|
"Рада видеть тебя в SecretRoom\n\n"
|
||
|
|
"✅ Любая фантазия о которой ты мечтал оживёт в этом боте\n\n"
|
||
|
|
"Жми «🔮 Оживить фото» и наслаждайся\n\n"
|
||
|
|
"Стоимость: 20 токен за запрос.\n\n"
|
||
|
|
f"Ваш баланс: {balance} токенов.",
|
||
|
|
reply_markup=InlineKeyboardMarkup(keyboard),
|
||
|
|
)
|
||
|
|
return ConversationHandler.END
|
||
|
|
|
||
|
|
async def handle_admin_add(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
|
if update.effective_user.id not in ADMIN_IDS:
|
||
|
|
await update.message.reply_text("У вас нет прав администратора.")
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
tokens = int(context.args[0])
|
||
|
|
target_id = int(context.args[1])
|
||
|
|
update_balance(target_id, tokens)
|
||
|
|
log_admin_action(
|
||
|
|
update.effective_user.id,
|
||
|
|
update.effective_user.username or 'unknown',
|
||
|
|
target_id,
|
||
|
|
tokens
|
||
|
|
)
|
||
|
|
await update.message.reply_text(f"Добавлено {tokens} токенов пользователю {target_id}.")
|
||
|
|
except (IndexError, ValueError):
|
||
|
|
await update.message.reply_text("Использование: /add <токены> <user_id>")
|
||
|
|
|
||
|
|
async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
|
photo_file = await update.message.photo[-1].get_file()
|
||
|
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||
|
|
temp_image = tmp.name
|
||
|
|
await photo_file.download_to_drive(temp_image)
|
||
|
|
|
||
|
|
width, height, resized_img = resize_image(temp_image)
|
||
|
|
resized_path = temp_image.replace('.png', '_resized.png')
|
||
|
|
resized_img.save(resized_path, format='PNG')
|
||
|
|
os.unlink(temp_image)
|
||
|
|
|
||
|
|
context.user_data['image_path'] = resized_path
|
||
|
|
context.user_data['width'] = width
|
||
|
|
context.user_data['height'] = height
|
||
|
|
|
||
|
|
video_prefix = f"video_{uuid.uuid4().hex[:12]}"
|
||
|
|
context.user_data['video_prefix'] = video_prefix
|
||
|
|
|
||
|
|
cost = COMFYUI_COST
|
||
|
|
balance = get_user_balance(update.effective_user.id)
|
||
|
|
|
||
|
|
keyboard = [
|
||
|
|
[InlineKeyboardButton("✅ Начать (Пресет 1)", callback_data="preset_1")],
|
||
|
|
[InlineKeyboardButton("🎭 Начать (Пресет 2)", callback_data="preset_2")],
|
||
|
|
[InlineKeyboardButton("❌ Отмена", callback_data="cancel_gen")]
|
||
|
|
]
|
||
|
|
reply_markup = InlineKeyboardMarkup(keyboard)
|
||
|
|
|
||
|
|
await update.message.reply_text(
|
||
|
|
f"Стоимость генерации: {cost} токенов.\n"
|
||
|
|
f"Ваш баланс: {balance}\n"
|
||
|
|
"Выберите пресет для генерации:",
|
||
|
|
reply_markup=reply_markup
|
||
|
|
)
|
||
|
|
|
||
|
|
return ConversationHandler.END
|
||
|
|
|
||
|
|
async def handle_photo_anywhere(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
|
return await handle_photo(update, context)
|
||
|
|
|
||
|
|
async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
|
query = update.callback_query
|
||
|
|
await query.answer()
|
||
|
|
if query.data == "preset_1":COMFYUI_COMMON_PROMT[0]
|
||
|
|
if query.data == "preset_2":COMFYUI_COMMON_PROMT[1]
|
||
|
|
if query.data == RULES_CALLBACK:
|
||
|
|
keyboard = [[InlineKeyboardButton("← Назад", callback_data=BACK_CALLBACK)]]
|
||
|
|
await query.message.reply_text(
|
||
|
|
"🖼 Творец, отправь фото ниже!:\n\n"
|
||
|
|
"Выбери фото человека или из аниме🙈:\n\n"
|
||
|
|
"Для лучшего результата следуй этим правилам:\n\n"
|
||
|
|
"✅ Фотография в полный рост\n"
|
||
|
|
"✅ Девушка смотрит прямо в камеру\n"
|
||
|
|
"✅ Хорошее освещение\n"
|
||
|
|
"❌ Нет солнцезащитных очков\n"
|
||
|
|
"❌ Не закрывать лицо волосами",
|
||
|
|
reply_markup=InlineKeyboardMarkup(keyboard)
|
||
|
|
)
|
||
|
|
await query.message.delete()
|
||
|
|
elif query.data == "preset_1":
|
||
|
|
await query.answer()
|
||
|
|
await start_generation(update, context, preset_index=0)
|
||
|
|
|
||
|
|
elif query.data == "preset_2":
|
||
|
|
await query.answer()
|
||
|
|
await start_generation(update, context, preset_index=1)
|
||
|
|
|
||
|
|
elif query.data == "cancel_gen":
|
||
|
|
await query.answer()
|
||
|
|
image_path = context.user_data.get('image_path')
|
||
|
|
if image_path and os.path.exists(image_path):
|
||
|
|
os.unlink(image_path)
|
||
|
|
context.user_data.clear()
|
||
|
|
await query.message.edit_text("Операция отменена.")
|
||
|
|
|
||
|
|
elif query.data == BACK_CALLBACK:
|
||
|
|
context.user_data.clear()
|
||
|
|
|
||
|
|
user = update.effective_user
|
||
|
|
add_user(user.id, user.username)
|
||
|
|
balance = get_user_balance(user.id)
|
||
|
|
keyboard = [[InlineKeyboardButton("ПРАВИЛА", callback_data=RULES_CALLBACK)]]
|
||
|
|
await query.message.reply_text(
|
||
|
|
f"👋🏻 Привет, Творец!\n\n"
|
||
|
|
"Рада видеть тебя в SecretRoom\n\n"
|
||
|
|
"✅ Любая фантазия о которой ты мечтал оживёт в этом боте\n\n"
|
||
|
|
"Жми «🔮 Оживить фото» и наслаждайся\n\n"
|
||
|
|
"Стоимость: 20 токен за запрос.\n\n"
|
||
|
|
f"Ваш баланс: {balance} токенов.",
|
||
|
|
reply_markup=InlineKeyboardMarkup(keyboard)
|
||
|
|
)
|
||
|
|
await query.message.delete()
|
||
|
|
|
||
|
|
async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||
|
|
await update.message.reply_text("Отменено.")
|
||
|
|
image_path = context.user_data.get('image_path')
|
||
|
|
if image_path and os.path.exists(image_path):
|
||
|
|
os.unlink(image_path)
|
||
|
|
context.user_data.clear()
|
||
|
|
return ConversationHandler.END
|
||
|
|
|
||
|
|
async def start_generation(update: Update, context: ContextTypes.DEFAULT_TYPE, preset_index: int):
|
||
|
|
query = update.callback_query
|
||
|
|
user_id = update.effective_user.id
|
||
|
|
|
||
|
|
cost = COMFYUI_COST
|
||
|
|
balance = get_user_balance(user_id)
|
||
|
|
if balance < cost:
|
||
|
|
await query.edit_message_text("❌ Недостаточно токенов.")
|
||
|
|
context.user_data.clear()
|
||
|
|
return
|
||
|
|
|
||
|
|
deduct_balance(user_id, cost)
|
||
|
|
await query.edit_message_text("🚀 Начинаю генерацию...")
|
||
|
|
|
||
|
|
try:
|
||
|
|
image_path = context.user_data['image_path']
|
||
|
|
video_prefix = context.user_data['video_prefix']
|
||
|
|
selected_prompt = COMFYUI_COMMON_PROMT[preset_index]
|
||
|
|
|
||
|
|
os.makedirs(COMFYUI_INPUT_DIR, exist_ok=True)
|
||
|
|
image_filename = f"user_image_{uuid.uuid4().hex[:12]}.png"
|
||
|
|
input_image_path = os.path.join(COMFYUI_INPUT_DIR, image_filename)
|
||
|
|
shutil.copy(image_path, input_image_path)
|
||
|
|
|
||
|
|
with open(COMFYUI_WORKFLOW, 'r', encoding='utf-8') as f:
|
||
|
|
workflow_base = f.read()
|
||
|
|
|
||
|
|
modified_workflow = modify_workflow(
|
||
|
|
workflow_base,
|
||
|
|
selected_prompt,
|
||
|
|
image_filename,
|
||
|
|
context.user_data['width'],
|
||
|
|
context.user_data['height'],
|
||
|
|
video_prefix
|
||
|
|
)
|
||
|
|
|
||
|
|
video_path = await run_comfyui_workflow(modified_workflow, input_image_path, video_prefix)
|
||
|
|
video_path = clean_metadata(video_path)
|
||
|
|
|
||
|
|
with open(video_path, 'rb') as video_file:
|
||
|
|
await query.message.reply_video(video_file, supports_streaming=True)
|
||
|
|
|
||
|
|
await query.message.reply_text("✅ Генерация завершена!")
|
||
|
|
|
||
|
|
for p in [image_path, input_image_path, video_path]:
|
||
|
|
if os.path.exists(p):
|
||
|
|
os.unlink(p)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
await query.message.reply_text(f"❌ Ошибка генерации: {str(e)}")
|
||
|
|
update_balance(user_id, cost)
|
||
|
|
|
||
|
|
new_balance = get_user_balance(user_id)
|
||
|
|
await query.message.reply_text(f"Ваш баланс: {new_balance} токенов.")
|
||
|
|
context.user_data.clear()
|
||
|
|
|
||
|
|
def main():
|
||
|
|
init_db()
|
||
|
|
app = Application.builder().token(TELEGRAM_TOKEN).build()
|
||
|
|
|
||
|
|
|
||
|
|
conv_handler = ConversationHandler(
|
||
|
|
entry_points=[MessageHandler(filters.PHOTO & ~filters.COMMAND, handle_photo)],
|
||
|
|
states={},
|
||
|
|
fallbacks=[],
|
||
|
|
)
|
||
|
|
|
||
|
|
app.add_handler(CommandHandler("start", start))
|
||
|
|
app.add_handler(CommandHandler("add", handle_admin_add))
|
||
|
|
app.add_handler(conv_handler)
|
||
|
|
app.add_handler(CallbackQueryHandler(handle_callback))
|
||
|
|
print(f"Бот запущен. Output dir: {COMFYUI_OUTPUT_DIR}")
|
||
|
|
app.run_polling()
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
main()
|