Исправлен установщик и добавлены тарифы BotFactory v2.1

Этот коммит содержится в:
2026-05-25 02:44:22 +09:00
родитель de05487d83
Коммит e68ac0dae9
18 изменённых файлов: 3977 добавлений и 0 удалений
+12
Просмотреть файл
@@ -0,0 +1,12 @@
.env
*.log
__pycache__/
*.pyc
.venv/
venv/
node_modules/
dist/
.DS_Store
uploads/
backups/
install-info.txt
+1
Просмотреть файл
@@ -0,0 +1 @@
2.1.0
+103
Просмотреть файл
@@ -0,0 +1,103 @@
from __future__ import annotations
from datetime import datetime, timedelta
from calendar import monthrange
from sqlalchemy import func, select
from config import settings
from models import Tenant, PlanEnum, Order, OrderStatus, Shop
PLAN_TITLES = {
"trial_week": "Тест 7 дней",
"trial": "Trial",
"basic": "Basic",
"pro": "Pro",
"enterprise": "Enterprise",
"postpaid_custom": "Индивидуальный постоплатный",
}
def plan_value(plan: PlanEnum | str | None) -> str:
if plan is None:
return "basic"
return getattr(plan, "value", str(plan))
def plan_title(plan: PlanEnum | str | None) -> str:
return PLAN_TITLES.get(plan_value(plan), plan_value(plan))
def is_postpaid(tenant: Tenant | None) -> bool:
return bool(tenant and plan_value(tenant.plan) == PlanEnum.postpaid_custom.value)
def is_trial_week_expired(tenant: Tenant | None) -> bool:
return bool(
tenant
and plan_value(tenant.plan) == PlanEnum.trial_week.value
and tenant.trial_ends_at
and tenant.trial_ends_at < datetime.utcnow()
)
def tenant_can_sell(tenant: Tenant | None) -> tuple[bool, str]:
if not tenant:
return False, "Владелец магазина не найден."
if not tenant.is_active or tenant.is_blocked:
return False, "Магазин временно приостановлен."
if is_trial_week_expired(tenant):
return False, "Тестовый период закончился. Обратитесь к администрации."
if not is_postpaid(tenant) and tenant.balance <= 0:
return False, "Баланс BotFactory исчерпан. Магазин временно приостановлен."
return True, ""
def commission_rate(tenant: Tenant | None) -> float:
if not tenant:
return float(settings.COMMISSION_BASIC)
if is_postpaid(tenant):
return float(tenant.postpaid_commission_percent or settings.COMMISSION_POSTPAID_DEFAULT)
return float(settings.commission.get(plan_value(tenant.plan), settings.COMMISSION_BASIC))
def previous_month_range(now: datetime | None = None) -> tuple[datetime, datetime]:
now = now or datetime.utcnow()
first_this_month = datetime(now.year, now.month, 1)
last_prev_day = first_this_month - timedelta(days=1)
start = datetime(last_prev_day.year, last_prev_day.month, 1)
end = first_this_month
return start, end
def current_due_date(tenant: Tenant, now: datetime | None = None) -> datetime:
now = now or datetime.utcnow()
day = int(tenant.postpaid_due_day or settings.POSTPAID_DEFAULT_DUE_DAY or 5)
day = max(1, min(28, day))
last_day = monthrange(now.year, now.month)[1]
return datetime(now.year, now.month, min(day, last_day), 23, 59, 59)
async def postpaid_previous_month_totals(db, tenant_id: int) -> tuple[float, float]:
start, end = previous_month_range()
revenue = (await db.execute(
select(func.sum(Order.amount))
.join(Shop, Order.shop_id == Shop.id)
.where(
Shop.tenant_id == tenant_id,
Order.status == OrderStatus.completed,
Order.created_at >= start,
Order.created_at < end,
)
)).scalar() or 0
commission = (await db.execute(
select(func.sum(Order.commission))
.join(Shop, Order.shop_id == Shop.id)
.where(
Shop.tenant_id == tenant_id,
Order.status == OrderStatus.completed,
Order.created_at >= start,
Order.created_at < end,
)
)).scalar() or 0
return float(revenue), float(commission)
+48
Просмотреть файл
@@ -0,0 +1,48 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
DEBUG: bool = False
SECRET_KEY: str = "change-me"
DATABASE_URL: str = ""
DB_POOL_SIZE: int = 20
REDIS_URL: str = "redis://127.0.0.1:6379/0"
PLATFORM_BOT_TOKEN: str = ""
PLATFORM_ADMIN_IDS: str = ""
API_HOST: str = "127.0.0.1"
API_PORT: int = 8000
DOMAIN: str = ""
ALLOWED_ORIGINS: str = "http://localhost:3000"
UPLOAD_DIR: str = "/opt/botfactory/uploads"
MAX_UPLOAD_MB: int = 10
COMMISSION_TRIAL_WEEK: int = 10
COMMISSION_TRIAL: int = 10
COMMISSION_BASIC: int = 7
COMMISSION_PRO: int = 5
COMMISSION_ENTERPRISE: int = 3
COMMISSION_POSTPAID_DEFAULT: int = 5
POSTPAID_DEFAULT_DUE_DAY: int = 5
@property
def admin_ids(self) -> List[int]:
return [int(i.strip()) for i in self.PLATFORM_ADMIN_IDS.split(",")
if i.strip().isdigit()]
@property
def commission(self) -> dict:
return {
"trial_week": self.COMMISSION_TRIAL_WEEK,
"trial": self.COMMISSION_TRIAL,
"basic": self.COMMISSION_BASIC,
"pro": self.COMMISSION_PRO,
"enterprise": self.COMMISSION_ENTERPRISE,
"postpaid_custom": self.COMMISSION_POSTPAID_DEFAULT,
}
class Config:
env_file = "/opt/botfactory/.env"
extra = "ignore"
settings = Settings()
+613
Просмотреть файл
@@ -0,0 +1,613 @@
"""BotFactory — Shop Control Bot (бот управления магазином)"""
import asyncio, logging, random
from datetime import datetime
from typing import Optional
from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.types import (Message, CallbackQuery, InlineKeyboardButton,
KeyboardButton, ReplyKeyboardMarkup, ReplyKeyboardRemove)
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
from sqlalchemy import select, func, desc
from config import settings
from database import AsyncSessionLocal
from models import Shop, ShopMember, ShopToken, Product, PaymentCard, Order, OrderStatus, BalanceTransaction, Tenant
from billing import is_postpaid, tenant_can_sell
logger = logging.getLogger("ctrl_bot")
def fmt(v: float) -> str:
return f"{v:,.0f}".replace(",", " ") + ""
def dt(d: datetime) -> str:
return d.strftime("%d.%m.%Y %H:%M")
class CS(StatesGroup):
prod_name = State(); prod_price = State(); prod_cat = State()
prod_desc = State(); prod_content = State()
card_bank = State(); card_num = State(); card_holder = State(); card_phone = State()
mbr_id = State(); mbr_name = State()
tok_val = State(); tok_user = State(); tok_note = State()
async def get_role(shop_id: int, tg_id: int) -> Optional[str]:
async with AsyncSessionLocal() as db:
shop = await db.get(Shop, shop_id)
if not shop: return None
tenant = await db.get(Tenant, shop.tenant_id)
if tenant and tenant.telegram_id == tg_id:
return "admin"
m = (await db.execute(
select(ShopMember).where(ShopMember.shop_id==shop_id, ShopMember.telegram_id==tg_id)
)).scalar_one_or_none()
return m.role if m else None
def admin_kb():
b = ReplyKeyboardBuilder()
b.row(KeyboardButton(text="📊 Статистика"), KeyboardButton(text="📦 Заказы"))
b.row(KeyboardButton(text="🛍 Товары"), KeyboardButton(text="💳 Карты"))
b.row(KeyboardButton(text="👥 Команда"), KeyboardButton(text="⚙️ Токены"))
b.row(KeyboardButton(text="💰 Баланс"))
return b.as_markup(resize_keyboard=True)
def cashier_kb():
b = ReplyKeyboardBuilder()
b.row(KeyboardButton(text="📦 Заказы на проверке"))
b.row(KeyboardButton(text="📋 История платежей"))
return b.as_markup(resize_keyboard=True)
def ib(*rows):
bld = InlineKeyboardBuilder()
for row in rows:
if isinstance(row, list):
bld.row(*[InlineKeyboardButton(text=t, callback_data=d) for t, d in row])
else:
bld.add(InlineKeyboardButton(text=row[0], callback_data=row[1]))
return bld.as_markup()
class ShopControlBot:
def __init__(self, shop_id: int, token: str, shop_name: str):
self.shop_id = shop_id
self.token = token
self.name = shop_name
self.router = Router()
self._reg()
def _reg(self):
r = self.router; sid = self.shop_id
async def role(event) -> Optional[str]:
uid = event.from_user.id
r = await get_role(sid, uid)
if not r:
deny = "⛔ Вы не в команде магазина. Обратитесь к владельцу."
if isinstance(event, Message): await event.answer(deny)
else: await event.answer(deny, show_alert=True)
return r
# /start
@r.message(Command("start"))
async def start(msg: Message, state: FSMContext):
await state.clear()
rl = await role(msg)
if not rl: return
await msg.answer(
f"🏪 <b>{self.name}</b>\n{'Администратор' if rl=='admin' else 'Кассир'}",
parse_mode="HTML",
reply_markup=admin_kb() if rl=="admin" else cashier_kb())
# ── СТАТИСТИКА ────────────────────────────────────────────────────────
@r.message(F.text=="📊 Статистика")
async def stats(msg: Message):
if await role(msg) != "admin": return
async with AsyncSessionLocal() as db:
rev = (await db.execute(select(func.sum(Order.amount)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar() or 0
comm = (await db.execute(select(func.sum(Order.commission)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar() or 0
ordn = (await db.execute(select(func.count(Order.id)).where(Order.shop_id==sid, Order.status==OrderStatus.completed))).scalar()
pend = (await db.execute(select(func.count(Order.id)).where(Order.shop_id==sid, Order.status.in_([OrderStatus.pending,OrderStatus.confirming])))).scalar()
prods= (await db.execute(select(func.count(Product.id)).where(Product.shop_id==sid, Product.is_active==True))).scalar()
cards= (await db.execute(select(func.count(PaymentCard.id)).where(PaymentCard.shop_id==sid, PaymentCard.is_active==True))).scalar()
shop = await db.get(Shop, sid)
tenant = await db.get(Tenant, shop.tenant_id)
be = "🔴" if tenant.balance<=0 else ("🟡" if tenant.balance<=tenant.alert_threshold else "🟢")
await msg.answer(
f"📊 <b>{self.name}</b> · {datetime.now():%d.%m.%Y %H:%M}\n{''*26}\n\n"
f"📦 Заказов: <b>{ordn}</b> | ⏳ Ждут: <b>{pend}</b>\n"
f"💰 Оборот: <b>{fmt(rev)}</b>\n"
f"📊 Комиссия: <b>{fmt(comm)}</b>\n"
f"💵 Мой доход: <b>{fmt(rev-comm)}</b>\n\n"
f"🛍 Товаров: {prods} | 💳 Карт: {cards}\n\n"
f"{be} Баланс BotFactory: <b>{fmt(tenant.balance)}</b>",
parse_mode="HTML")
# ── ЗАКАЗЫ ────────────────────────────────────────────────────────────
@r.message(F.text.in_(["📦 Заказы","📦 Заказы на проверке"]))
async def orders(msg: Message):
if not await role(msg): return
async with AsyncSessionLocal() as db:
ords = (await db.execute(
select(Order).where(Order.shop_id==sid,
Order.status.in_([OrderStatus.pending,OrderStatus.confirming]))
.order_by(Order.created_at.asc()).limit(20)
)).scalars().all()
if not ords:
await msg.answer("✅ Ожидающих заказов нет."); return
txt = f"📦 <b>Заказы на проверке ({len(ords)}):</b>\n\n"
bld = InlineKeyboardBuilder()
for o in ords:
em = "🟡" if o.status==OrderStatus.pending else "🟠"
txt += f"{em} #{o.id} · {fmt(o.amount)} · {o.buyer_username or o.buyer_telegram_id}\n"
bld.add(InlineKeyboardButton(text=f"{em}#{o.id}·{o.amount:.0f}", callback_data=f"co_{o.id}"))
bld.adjust(2)
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@r.callback_query(F.data.startswith("co_"))
async def order_detail(cb: CallbackQuery):
if not await role(cb): return
oid = int(cb.data[3:])
async with AsyncSessionLocal() as db:
o = await db.get(Order, oid)
if not o or o.shop_id!=sid: await cb.answer("Не найден",show_alert=True); return
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
txt = (f"📦 <b>Заказ #{o.id}</b>\n{''*22}\n"
f"👤 {o.buyer_username or o.buyer_telegram_id}\n"
f"💰 {fmt(o.amount)} | Комиссия: {fmt(o.commission)}\n"
+ (f"💳 {card.bank} · {card.number}\n" if card else "")
+ f"📅 {dt(o.created_at)}")
bld = InlineKeyboardBuilder()
if o.status in [OrderStatus.pending, OrderStatus.confirming]:
bld.row(InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"cc_{oid}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"cr_{oid}"))
bld.row(InlineKeyboardButton(text="← Назад", callback_data="cord_back"))
if o.proof_file_id:
await cb.message.answer_photo(photo=o.proof_file_id, caption=txt,
parse_mode="HTML", reply_markup=bld.as_markup())
else:
await cb.message.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
await cb.answer()
@r.callback_query(F.data.startswith("cc_"))
async def confirm_order(cb: CallbackQuery):
if not await role(cb): return
oid = int(cb.data[3:])
async with AsyncSessionLocal() as db:
o = await db.get(Order, oid)
if not o or o.shop_id!=sid or o.status not in [OrderStatus.pending,OrderStatus.confirming]:
await cb.answer("Нельзя подтвердить", show_alert=True); return
shop = await db.get(Shop, sid)
tenant = await db.get(Tenant, shop.tenant_id) if shop else None
ok, reason = tenant_can_sell(tenant)
if not ok:
await cb.answer(f"{reason}", show_alert=True); return
product = await db.get(Product, o.product_id)
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
o.status = OrderStatus.completed
o.confirmed_by = cb.from_user.id
if product:
product.sold += 1
if product.stock > 0:
product.stock -= 1
if product.stock <= 0:
product.is_active = False
if card:
card.received_total += o.amount
charged_now = False
if tenant and not is_postpaid(tenant):
old = tenant.balance
tenant.balance -= o.commission
charged_now = True
db.add(BalanceTransaction(tenant_id=tenant.id, type="commission",
amount=-o.commission, balance_after=tenant.balance,
note=f"Заказ #{oid}", order_id=oid))
if old > 0 and tenant.balance <= 0:
tenant.is_blocked = True
elif tenant:
db.add(BalanceTransaction(tenant_id=tenant.id, type="postpaid_accrual",
amount=0, balance_after=tenant.balance,
note=f"Начислена постоплатная комиссия {fmt(o.commission)} за заказ #{oid}", order_id=oid))
content = o.product_content; buyer_id = o.buyer_telegram_id
await db.commit()
await self._deliver(buyer_id, content, oid)
suffix = "комиссия списана" if charged_now else "комиссия начислена в постоплату"
txt = f"✅ <b>Заказ #{oid} подтверждён.</b> Товар выдан, {suffix}."
if cb.message.photo:
await cb.message.edit_caption(caption=txt, parse_mode="HTML")
else:
await cb.message.edit_text(txt, parse_mode="HTML")
await cb.answer("")
@r.callback_query(F.data.startswith("cr_"))
async def reject_order(cb: CallbackQuery):
if not await role(cb): return
oid = int(cb.data[3:])
async with AsyncSessionLocal() as db:
o = await db.get(Order, oid); o.status = OrderStatus.rejected; await db.commit()
await cb.message.edit_text(f"❌ Заказ #{oid} отклонён."); await cb.answer()
@r.callback_query(F.data=="cord_back")
async def cord_back(cb: CallbackQuery):
await orders(cb.message); await cb.answer()
@r.message(F.text=="📋 История платежей")
async def history(msg: Message):
if not await role(msg): return
async with AsyncSessionLocal() as db:
ords = (await db.execute(
select(Order).where(Order.shop_id==sid, Order.status==OrderStatus.completed)
.order_by(desc(Order.created_at)).limit(15)
)).scalars().all()
if not ords: await msg.answer("Нет выполненных заказов."); return
txt = "📋 <b>Последние выполненные:</b>\n\n"
for o in ords:
txt += f"✅ #{o.id} · {fmt(o.amount)} · {dt(o.created_at)}\n"
await msg.answer(txt, parse_mode="HTML")
# ── ТОВАРЫ (admin) ────────────────────────────────────────────────────
@r.message(F.text=="🛍 Товары")
async def products_menu(msg: Message):
if await role(msg) != "admin": return
async with AsyncSessionLocal() as db:
prods = (await db.execute(
select(Product).where(Product.shop_id==sid).order_by(desc(Product.created_at)).limit(20)
)).scalars().all()
if not prods:
await msg.answer("Товаров нет.", reply_markup=ib((" Добавить","cp_add"))); return
bld = InlineKeyboardBuilder(); txt = f"🛍 <b>Товары ({len(prods)}):</b>\n\n"
for p in prods:
em = "" if p.is_active else "🔴"
txt += f"{em} {p.name} · {fmt(p.price)}\n"
bld.add(InlineKeyboardButton(text=f"{em}{p.name[:22]}", callback_data=f"cp_{p.id}"))
bld.adjust(1); bld.row(InlineKeyboardButton(text=" Добавить", callback_data="cp_add"))
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@r.callback_query(F.data=="cp_add")
async def add_prod(cb: CallbackQuery, state: FSMContext):
if await role(cb) != "admin": return
await state.set_state(CS.prod_name)
await cb.message.answer("🛍 <b>Название товара:</b>", parse_mode="HTML", reply_markup=ReplyKeyboardRemove())
await cb.answer()
@r.message(CS.prod_name)
async def p_name(msg: Message, state: FSMContext):
await state.update_data(name=msg.text); await state.set_state(CS.prod_price)
await msg.answer("Цена (₽):")
@r.message(CS.prod_price)
async def p_price(msg: Message, state: FSMContext):
try: price=float(msg.text.replace(",",".")); assert price>0
except: await msg.answer("❌ Введите цену, например 599"); return
await state.update_data(price=price); await state.set_state(CS.prod_cat)
await msg.answer("Категория (или '-'):")
@r.message(CS.prod_cat)
async def p_cat(msg: Message, state: FSMContext):
await state.update_data(category=msg.text if msg.text!="-" else "Общее")
await state.set_state(CS.prod_desc)
await msg.answer("Описание для покупателя:")
@r.message(CS.prod_desc)
async def p_desc(msg: Message, state: FSMContext):
await state.update_data(description=msg.text); await state.set_state(CS.prod_content)
await msg.answer("Содержимое товара (выдаётся после оплаты):")
@r.message(CS.prod_content)
async def p_content(msg: Message, state: FSMContext):
data = await state.get_data(); await state.clear()
async with AsyncSessionLocal() as db:
db.add(Product(shop_id=sid, name=data["name"], price=data["price"],
category=data.get("category","Общее"), description=data.get("description",""),
content=msg.text))
await db.commit()
await msg.answer(f"✅ Товар <b>{data['name']}</b> добавлен!", parse_mode="HTML", reply_markup=admin_kb())
@r.callback_query(F.data.startswith("cp_") & ~F.data.in_({"cp_add"}))
async def prod_detail(cb: CallbackQuery):
if await role(cb) != "admin": return
pid = int(cb.data[3:])
async with AsyncSessionLocal() as db:
p = await db.get(Product, pid)
if not p or p.shop_id!=sid: await cb.answer("Не найден"); return
bld = InlineKeyboardBuilder()
bld.row(InlineKeyboardButton(text="🔄 Скрыть/Показать", callback_data=f"cpt_{pid}"),
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"cpd_{pid}"))
bld.row(InlineKeyboardButton(text="← Назад", callback_data="cpback"))
await cb.message.edit_text(
f"🛍 <b>{p.name}</b>\n💰 {fmt(p.price)}\n📂 {p.category}\n"
f"Продано: {p.sold}\n{'' if p.is_active else '🔴'} {'Активен' if p.is_active else 'Скрыт'}",
parse_mode="HTML", reply_markup=bld.as_markup()); await cb.answer()
@r.callback_query(F.data.startswith("cpt_"))
async def prod_toggle(cb: CallbackQuery):
pid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
p=await db.get(Product,pid)
if p and p.shop_id==sid: p.is_active=not p.is_active; await db.commit()
await prod_detail(cb)
@r.callback_query(F.data.startswith("cpd_"))
async def prod_del(cb: CallbackQuery):
pid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
p=await db.get(Product,pid)
if p and p.shop_id==sid: await db.delete(p); await db.commit()
await cb.message.edit_text("🗑 Товар удалён."); await cb.answer()
@r.callback_query(F.data=="cpback")
async def cpback(cb: CallbackQuery):
await products_menu(cb.message); await cb.answer()
# ── КАРТЫ (admin) ─────────────────────────────────────────────────────
@r.message(F.text=="💳 Карты")
async def cards_menu(msg: Message):
if await role(msg) != "admin": return
async with AsyncSessionLocal() as db:
cards = (await db.execute(select(PaymentCard).where(PaymentCard.shop_id==sid))).scalars().all()
if not cards:
await msg.answer("Карт нет.", reply_markup=ib((" Добавить карту","ccard_add"))); return
bld=InlineKeyboardBuilder(); txt="💳 <b>Пул карт:</b>\n\n"
for c in cards:
em="🟢" if c.is_active else "🔴"
txt+=f"{em} {c.bank} · {c.number}\n Получено: {fmt(c.received_total)}\n\n"
bld.add(InlineKeyboardButton(text=f"{em}{c.bank}···{c.number[-4:]}", callback_data=f"ccd_{c.id}"))
bld.adjust(2); bld.row(InlineKeyboardButton(text=" Добавить карту", callback_data="ccard_add"))
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@r.callback_query(F.data=="ccard_add")
async def add_card(cb: CallbackQuery, state: FSMContext):
if await role(cb) != "admin": return
await state.set_state(CS.card_bank)
await cb.message.answer("🏦 Введите название банка:", reply_markup=ReplyKeyboardRemove())
await cb.answer()
@r.message(CS.card_bank)
async def cb_bank(msg: Message, state: FSMContext):
await state.update_data(bank=msg.text); await state.set_state(CS.card_num)
await msg.answer("Номер карты или номер для СБП:")
@r.message(CS.card_num)
async def cb_num(msg: Message, state: FSMContext):
await state.update_data(number=msg.text); await state.set_state(CS.card_holder)
await msg.answer("Имя владельца (ЛАТИНИЦА, CAPS):")
@r.message(CS.card_holder)
async def cb_holder(msg: Message, state: FSMContext):
await state.update_data(holder=msg.text.upper()); await state.set_state(CS.card_phone)
await msg.answer("Телефон для СБП (или '-'):")
@r.message(CS.card_phone)
async def cb_phone(msg: Message, state: FSMContext):
data=await state.get_data(); await state.clear()
async with AsyncSessionLocal() as db:
db.add(PaymentCard(shop_id=sid, bank=data["bank"], number=data["number"],
holder=data["holder"], phone=msg.text if msg.text!="-" else "", is_active=True))
await db.commit()
await msg.answer(f"✅ Карта {data['bank']} добавлена в пул!", reply_markup=admin_kb())
@r.callback_query(F.data.startswith("ccd_"))
async def card_detail(cb: CallbackQuery):
if await role(cb) != "admin": return
cid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
c=await db.get(PaymentCard,cid)
if not c or c.shop_id!=sid: await cb.answer("Не найдена"); return
bld=InlineKeyboardBuilder()
bld.row(InlineKeyboardButton(text="🔄 Вкл/Откл", callback_data=f"cct_{cid}"),
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"ccr_{cid}"))
bld.row(InlineKeyboardButton(text="← Назад", callback_data="ccback"))
await cb.message.edit_text(
f"💳 <b>{c.bank}</b>\nНомер: <code>{c.number}</code>\n{c.holder}\n"
f"{'СБП: '+c.phone+chr(10) if c.phone else ''}"
f"Заказов: {c.orders_count} | Получено: {fmt(c.received_total)}\n"
f"{'🟢 В пуле' if c.is_active else '🔴 Отключена'}",
parse_mode="HTML", reply_markup=bld.as_markup()); await cb.answer()
@r.callback_query(F.data.startswith("cct_"))
async def card_toggle(cb: CallbackQuery):
cid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
c=await db.get(PaymentCard,cid)
if c and c.shop_id==sid: c.is_active=not c.is_active; await db.commit()
await card_detail(cb)
@r.callback_query(F.data.startswith("ccr_"))
async def card_del(cb: CallbackQuery):
cid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
c=await db.get(PaymentCard,cid)
if c and c.shop_id==sid: await db.delete(c); await db.commit()
await cb.message.edit_text("🗑 Карта удалена."); await cb.answer()
@r.callback_query(F.data=="ccback")
async def ccback(cb: CallbackQuery):
await cards_menu(cb.message); await cb.answer()
# ── КОМАНДА (admin) ───────────────────────────────────────────────────
@r.message(F.text=="👥 Команда")
async def team_menu(msg: Message):
if await role(msg) != "admin": return
async with AsyncSessionLocal() as db:
members=(await db.execute(select(ShopMember).where(ShopMember.shop_id==sid))).scalars().all()
txt="👥 <b>Кассиры:</b>\n\n"
bld=InlineKeyboardBuilder()
for m in members:
txt+=f"💼 {m.name} · {m.username}\nTG: <code>{m.telegram_id}</code>\n\n"
bld.add(InlineKeyboardButton(text=f"🗑{m.name[:20]}", callback_data=f"cdm_{m.id}"))
bld.adjust(2); bld.row(InlineKeyboardButton(text=" Добавить кассира", callback_data="cadd_mbr"))
if not members: txt+="Кассиров нет."
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@r.callback_query(F.data=="cadd_mbr")
async def add_mbr(cb: CallbackQuery, state: FSMContext):
if await role(cb) != "admin": return
await state.set_state(CS.mbr_id)
await cb.message.answer("Введите Telegram ID кассира\n(узнать у @userinfobot):", reply_markup=ReplyKeyboardRemove())
await cb.answer()
@r.message(CS.mbr_id)
async def mbr_id(msg: Message, state: FSMContext):
try: tid=int(msg.text.strip())
except: await msg.answer("❌ Только число"); return
await state.update_data(tid=tid); await state.set_state(CS.mbr_name)
await msg.answer("Имя кассира:")
@r.message(CS.mbr_name)
async def mbr_name(msg: Message, state: FSMContext):
data=await state.get_data(); await state.clear()
async with AsyncSessionLocal() as db:
db.add(ShopMember(shop_id=sid, telegram_id=data["tid"], name=msg.text, role="moderator"))
await db.commit()
await msg.answer(f"✅ Кассир {msg.text} добавлен!\nПусть напишет /start боту управления.", reply_markup=admin_kb())
@r.callback_query(F.data.startswith("cdm_"))
async def del_mbr(cb: CallbackQuery):
mid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
m=await db.get(ShopMember,mid)
if m and m.shop_id==sid: await db.delete(m); await db.commit()
await cb.message.edit_text("🗑 Кассир удалён."); await cb.answer()
# ── ТОКЕНЫ (admin) ────────────────────────────────────────────────────
@r.message(F.text=="⚙️ Токены")
async def tokens_menu(msg: Message):
if await role(msg) != "admin": return
async with AsyncSessionLocal() as db:
toks=(await db.execute(select(ShopToken).where(ShopToken.shop_id==sid))).scalars().all()
txt="⚙️ <b>Токены бота магазина:</b>\n\n"
bld=InlineKeyboardBuilder()
for t in toks:
em="🟢" if t.is_active else ""
txt+=f"{em} <b>{t.username}</b> [{t.note}]\n{t.token[:20]}\n\n"
if not t.is_active:
bld.add(InlineKeyboardButton(text=f"🔀 Переключить на {t.username[:16]}", callback_data=f"tsw_{t.id}"))
bld.adjust(1); bld.row(InlineKeyboardButton(text=" Добавить токен", callback_data="tadd"))
txt+="⚠️ При блокировке бота — добавьте новый токен и переключитесь."
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@r.callback_query(F.data.startswith("tsw_"))
async def token_switch(cb: CallbackQuery):
if await role(cb) != "admin": return
tid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
toks=(await db.execute(select(ShopToken).where(ShopToken.shop_id==sid))).scalars().all()
for t in toks: t.is_active=(t.id==tid)
await db.commit()
await cb.answer("✅ Токен переключён! Бот магазина перезапустится.", show_alert=True)
await tokens_menu(cb.message)
@r.callback_query(F.data=="tadd")
async def token_add(cb: CallbackQuery, state: FSMContext):
if await role(cb) != "admin": return
await state.set_state(CS.tok_val)
await cb.message.answer("Вставьте токен нового бота (@BotFather):", reply_markup=ReplyKeyboardRemove())
await cb.answer()
@r.message(CS.tok_val)
async def tok_val(msg: Message, state: FSMContext):
await state.update_data(token=msg.text.strip()); await state.set_state(CS.tok_user)
await msg.answer("Username бота (например @myshop2_bot):")
@r.message(CS.tok_user)
async def tok_user(msg: Message, state: FSMContext):
await state.update_data(username=msg.text.strip()); await state.set_state(CS.tok_note)
await msg.answer("Метка токена (например 'Резервный #2'):")
@r.message(CS.tok_note)
async def tok_note(msg: Message, state: FSMContext):
data=await state.get_data(); await state.clear()
async with AsyncSessionLocal() as db:
db.add(ShopToken(shop_id=sid, token=data["token"], username=data["username"], note=msg.text, is_active=False))
await db.commit()
await msg.answer(f"✅ Токен {data['username']} добавлен!\nДля переключения — раздел «⚙️ Токены»", reply_markup=admin_kb())
# ── БАЛАНС (admin) ────────────────────────────────────────────────────
@r.message(F.text=="💰 Баланс")
async def balance(msg: Message):
if await role(msg) != "admin": return
async with AsyncSessionLocal() as db:
shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id)
txs=(await db.execute(select(BalanceTransaction)
.where(BalanceTransaction.tenant_id==tenant.id)
.order_by(desc(BalanceTransaction.created_at)).limit(5))).scalars().all()
be="🔴" if tenant.balance<=0 else ("🟡" if tenant.balance<=tenant.alert_threshold else "🟢")
txt=(f"💰 <b>Баланс BotFactory</b>\n{be} <b>{fmt(tenant.balance)}</b>\n"
f"⚠️ Порог: {fmt(tenant.alert_threshold)}\n\n")
if tenant.balance<=0: txt+="🔴 Боты приостановлены!\n\n"
if txs:
txt+="<b>Последние транзакции:</b>\n"
for tx in txs:
s="+" if tx.amount>0 else ""; em="💚" if tx.amount>0 else "📊"
txt+=f"{em} {s}{fmt(tx.amount)}{tx.note[:40]}\n"
await msg.answer(txt, parse_mode="HTML",
reply_markup=ib(("💳 Реквизиты для пополнения","topup_req")))
@r.callback_query(F.data=="topup_req")
async def topup_req(cb: CallbackQuery):
await cb.message.answer(
"💳 <b>Реквизиты для пополнения BotFactory</b>\n\n"
"🏦 Тинькофф: <code>5536 **** **** 0001</code>\n"
"👤 ООО БОТФАКТОРИ\n\n"
"📝 Назначение: <code>Пополнение BotFactory</code>\n"
"⚠️ Обязательно укажите назначение!", parse_mode="HTML")
await cb.answer()
# ── Deliver product ────────────────────────────────────────────────────────
async def _deliver(self, buyer_tg_id: int, content: str, order_id: int):
try:
async with AsyncSessionLocal() as db:
tok=(await db.execute(select(ShopToken).where(
ShopToken.shop_id==self.shop_id, ShopToken.is_active==True)
)).scalar_one_or_none()
if not tok: return
bot=Bot(token=tok.token)
await bot.send_message(buyer_tg_id,
f"✅ <b>Оплата подтверждена!</b>\n\nЗаказ #{order_id}\n\n"
f"<code>{content}</code>", parse_mode="HTML")
await bot.session.close()
except Exception as e:
logger.error(f"_deliver: {e}")
# ── Notify cashiers (вызывается из shop_bot) ───────────────────────────────
async def notify_cashiers(self, order_id: int, amount: float, buyer: str, file_id: str):
try:
async with AsyncSessionLocal() as db:
members=(await db.execute(select(ShopMember).where(ShopMember.shop_id==self.shop_id))).scalars().all()
shop=await db.get(Shop,self.shop_id); tenant=await db.get(Tenant,shop.tenant_id)
recipients=[m.telegram_id for m in members]
if tenant and tenant.telegram_id: recipients.append(tenant.telegram_id)
if not recipients: return
bot=Bot(token=self.token)
bld=InlineKeyboardBuilder()
bld.row(InlineKeyboardButton(text=f"✅ Подтвердить #{order_id}", callback_data=f"cc_{order_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"cr_{order_id}"))
caption=(f"🔔 <b>Новый платёж!</b>\n#{order_id} · {fmt(amount)}\n👤 @{buyer}")
for uid in set(recipients):
try:
await bot.send_photo(uid, photo=file_id, caption=caption,
parse_mode="HTML", reply_markup=bld.as_markup())
except Exception as e:
logger.warning(f"notify uid={uid}: {e}")
await bot.session.close()
except Exception as e:
logger.error(f"notify_cashiers: {e}")
async def run(self):
storage=RedisStorage.from_url(settings.REDIS_URL+f"?db={self.shop_id%13+1}")
bot=Bot(token=self.token); dp=Dispatcher(storage=storage)
dp.include_router(self.router)
logger.info(f"Ctrl bot: {self.name} (shop={self.shop_id})")
try:
await dp.start_polling(bot, allowed_updates=["message","callback_query"])
finally:
await bot.session.close()
+53
Просмотреть файл
@@ -0,0 +1,53 @@
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from config import settings
engine = create_async_engine(
settings.DATABASE_URL,
pool_size=settings.DB_POOL_SIZE,
max_overflow=10,
echo=settings.DEBUG,
)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
yield session
async def _run_lightweight_migrations(conn):
"""Small safe migrations for the installer/update path.
The project still uses SQLAlchemy create_all for first deployment. These
ALTER statements keep existing installs alive when new billing fields are
added before a full Alembic workflow appears.
"""
dialect = conn.dialect.name
if dialect != "postgresql":
return
# PostgreSQL enum values must be added before columns can use them.
await conn.execute(text("ALTER TYPE planenum ADD VALUE IF NOT EXISTS 'trial_week'"))
await conn.execute(text("ALTER TYPE planenum ADD VALUE IF NOT EXISTS 'postpaid_custom'"))
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP NULL"))
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_commission_percent DOUBLE PRECISION DEFAULT 5"))
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_due_day INTEGER DEFAULT 5"))
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_note VARCHAR(512) DEFAULT ''"))
await conn.execute(text("ALTER TABLE tenants ADD COLUMN IF NOT EXISTS postpaid_enabled_at TIMESTAMP NULL"))
async def init_db():
# Import models here so Base.metadata is populated even when init_db is
# called from worker entrypoints that did not import models yet.
import models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _run_lightweight_migrations(conn)
+71
Просмотреть файл
@@ -0,0 +1,71 @@
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from config import settings
from database import init_db
logging.basicConfig(
level=logging.DEBUG if settings.DEBUG else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("botfactory")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Starting BotFactory API...")
await init_db()
logger.info("Database initialized")
yield
logger.info("Shutting down BotFactory API")
app = FastAPI(
title="BotFactory API",
version="2.1.0",
docs_url="/api/docs" if settings.DEBUG else None,
redoc_url=None,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.ALLOWED_ORIGINS.split(",") if o.strip()],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
upload_path = Path(settings.UPLOAD_DIR)
upload_path.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(upload_path)), name="uploads")
@app.get("/api/health", tags=["System"])
async def health():
return {"status": "ok", "version": "2.1.0"}
@app.get("/api/version", tags=["System"])
async def version():
return {
"version": "2.1.0",
"debug": settings.DEBUG,
"domain": settings.DOMAIN or None,
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host=settings.API_HOST,
port=settings.API_PORT,
reload=settings.DEBUG,
loop="uvloop",
)
+160
Просмотреть файл
@@ -0,0 +1,160 @@
from datetime import datetime
from sqlalchemy import (
String, Integer, Float, Boolean, DateTime,
ForeignKey, Text, BigInteger, Enum,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from database import Base
import enum
class PlanEnum(str, enum.Enum):
trial_week = "trial_week"
trial = "trial"
basic = "basic"
pro = "pro"
enterprise = "enterprise"
postpaid_custom = "postpaid_custom"
class OrderStatus(str, enum.Enum):
pending = "pending"
confirming = "confirming"
completed = "completed"
rejected = "rejected"
cancelled = "cancelled"
class Tenant(Base):
__tablename__ = "tenants"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128))
email: Mapped[str] = mapped_column(String(256), unique=True)
password_hash: Mapped[str] = mapped_column(String(256))
telegram_id: Mapped[int|None] = mapped_column(BigInteger, nullable=True, index=True)
plan: Mapped[PlanEnum] = mapped_column(Enum(PlanEnum), default=PlanEnum.trial_week)
trial_ends_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
postpaid_commission_percent: Mapped[float] = mapped_column(Float, default=5.0)
postpaid_due_day: Mapped[int] = mapped_column(Integer, default=5)
postpaid_note: Mapped[str] = mapped_column(String(512), default="")
postpaid_enabled_at: Mapped[datetime|None] = mapped_column(DateTime, nullable=True)
balance: Mapped[float] = mapped_column(Float, default=0.0)
alert_threshold: Mapped[float] = mapped_column(Float, default=200.0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_blocked: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
shops: Mapped[list["Shop"]] = relationship(back_populates="tenant")
transactions: Mapped[list["BalanceTransaction"]] = relationship(back_populates="tenant")
class Shop(Base):
__tablename__ = "shops"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
name: Mapped[str] = mapped_column(String(128))
ctrl_bot_token: Mapped[str] = mapped_column(String(128))
ctrl_bot_username:Mapped[str] = mapped_column(String(64), default="")
welcome_msg: Mapped[str] = mapped_column(Text, default="Добро пожаловать!")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
tenant: Mapped["Tenant"] = relationship(back_populates="shops")
tokens: Mapped[list["ShopToken"]] = relationship(back_populates="shop")
products: Mapped[list["Product"]] = relationship(back_populates="shop")
cards: Mapped[list["PaymentCard"]] = relationship(back_populates="shop")
orders: Mapped[list["Order"]] = relationship(back_populates="shop")
team: Mapped[list["ShopMember"]] = relationship(back_populates="shop")
class ShopToken(Base):
__tablename__ = "shop_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
token: Mapped[str] = mapped_column(String(128))
username: Mapped[str] = mapped_column(String(64), default="")
note: Mapped[str] = mapped_column(String(64), default="Основной")
is_active: Mapped[bool] = mapped_column(Boolean, default=False)
added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
shop: Mapped["Shop"] = relationship(back_populates="tokens")
class ShopMember(Base):
__tablename__ = "shop_members"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
telegram_id: Mapped[int] = mapped_column(BigInteger, index=True)
username: Mapped[str] = mapped_column(String(64), default="")
name: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(32), default="moderator")
added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
shop: Mapped["Shop"] = relationship(back_populates="team")
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
name: Mapped[str] = mapped_column(String(256))
category: Mapped[str] = mapped_column(String(64), default="Общее")
description: Mapped[str] = mapped_column(Text, default="")
content: Mapped[str] = mapped_column(Text, default="")
photo_url: Mapped[str|None] = mapped_column(String(512), nullable=True)
price: Mapped[float] = mapped_column(Float)
stock: Mapped[int] = mapped_column(Integer, default=999)
sold: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
shop: Mapped["Shop"] = relationship(back_populates="products")
class PaymentCard(Base):
__tablename__ = "payment_cards"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
bank: Mapped[str] = mapped_column(String(64))
number: Mapped[str] = mapped_column(String(32))
holder: Mapped[str] = mapped_column(String(128))
phone: Mapped[str] = mapped_column(String(32), default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
orders_count: Mapped[int] = mapped_column(Integer, default=0)
received_total: Mapped[float] = mapped_column(Float, default=0.0)
shop: Mapped["Shop"] = relationship(back_populates="cards")
class Order(Base):
__tablename__ = "orders"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
shop_id: Mapped[int] = mapped_column(ForeignKey("shops.id"), index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"))
card_id: Mapped[int|None] = mapped_column(ForeignKey("payment_cards.id"), nullable=True)
buyer_telegram_id: Mapped[int] = mapped_column(BigInteger, index=True)
buyer_username: Mapped[str] = mapped_column(String(64), default="")
amount: Mapped[float] = mapped_column(Float)
commission: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[OrderStatus] = mapped_column(Enum(OrderStatus), default=OrderStatus.pending, index=True)
proof_file_id: Mapped[str|None] = mapped_column(String(256), nullable=True)
confirmed_by: Mapped[int|None] = mapped_column(BigInteger, nullable=True)
product_content: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
shop: Mapped["Shop"] = relationship(back_populates="orders")
class BalanceTransaction(Base):
__tablename__ = "balance_transactions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
type: Mapped[str] = mapped_column(String(32)) # deposit / commission / refund
amount: Mapped[float] = mapped_column(Float)
balance_after: Mapped[float] = mapped_column(Float)
note: Mapped[str] = mapped_column(String(512), default="")
order_id: Mapped[int|None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
tenant: Mapped["Tenant"] = relationship(back_populates="transactions")
+698
Просмотреть файл
@@ -0,0 +1,698 @@
"""BotFactory — Platform Admin Bot (системный бот платформы)"""
import asyncio, logging
from datetime import datetime, timedelta
from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.types import (Message, CallbackQuery,
InlineKeyboardButton, ReplyKeyboardMarkup, KeyboardButton, ReplyKeyboardRemove)
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
from sqlalchemy import select, func, desc
from config import settings
from database import AsyncSessionLocal
from models import Tenant, Shop, ShopToken, Product, PaymentCard, Order, BalanceTransaction, OrderStatus, PlanEnum
from billing import (current_due_date, is_postpaid, plan_title,
postpaid_previous_month_totals, tenant_can_sell)
logger = logging.getLogger("platform_bot")
router = Router()
# ── Guards ────────────────────────────────────────────────────────────────────
def is_admin(uid: int) -> bool:
return uid in settings.admin_ids
def guard(fn):
async def w(event, *a, **kw):
uid = event.from_user.id
if not is_admin(uid):
t = event.answer if isinstance(event, Message) else event.answer
await t("⛔ Доступ запрещён.", show_alert=True) if isinstance(event, CallbackQuery) else await t("⛔ Доступ запрещён.")
return
return await fn(event, *a, **kw)
return w
# ── States ────────────────────────────────────────────────────────────────────
class S(StatesGroup):
topup_amount = State()
topup_note = State()
broadcast = State()
bcast_ok = State()
plan_percent = State()
plan_due_day = State()
# ── Keyboards ─────────────────────────────────────────────────────────────────
def main_kb():
b = ReplyKeyboardBuilder()
b.row(KeyboardButton(text="📊 Статистика"), KeyboardButton(text="👥 Пользователи"))
b.row(KeyboardButton(text="💰 Балансы"), KeyboardButton(text="📦 Заказы"))
b.row(KeyboardButton(text="📢 Рассылка"), KeyboardButton(text="⚙️ Настройки"))
b.row(KeyboardButton(text="🧾 Постоплата"))
return b.as_markup(resize_keyboard=True)
def ib(*rows):
bld = InlineKeyboardBuilder()
for row in rows:
if isinstance(row, list):
bld.row(*[InlineKeyboardButton(text=t, callback_data=d) for t, d in row])
else:
bld.add(InlineKeyboardButton(text=row[0], callback_data=row[1]))
return bld.as_markup()
def fmt(v: float) -> str:
return f"{v:,.0f}".replace(",", " ") + ""
def bal_em(b: float, t: float) -> str:
return "🔴" if b <= 0 else ("🟡" if b <= t else "🟢")
# ── /start ────────────────────────────────────────────────────────────────────
@router.message(Command("start"))
@guard
async def start(msg: Message, state: FSMContext):
await state.clear()
await msg.answer(
f"👋 <b>BotFactory Admin</b>\n{msg.from_user.full_name}\n"
f"ID: <code>{msg.from_user.id}</code>",
reply_markup=main_kb(), parse_mode="HTML")
# ── Статистика ────────────────────────────────────────────────────────────────
@router.message(F.text == "📊 Статистика")
@guard
async def stats(msg: Message):
async with AsyncSessionLocal() as db:
n_all = (await db.execute(select(func.count(Tenant.id)))).scalar()
n_act = (await db.execute(select(func.count(Tenant.id)).where(Tenant.is_active==True))).scalar()
n_blk = (await db.execute(select(func.count(Tenant.id)).where(Tenant.is_blocked==True))).scalar()
n_low = (await db.execute(select(func.count(Tenant.id)).where(Tenant.balance>0, Tenant.balance<=Tenant.alert_threshold))).scalar()
n_zero = (await db.execute(select(func.count(Tenant.id)).where(Tenant.balance<=0, Tenant.is_active==True))).scalar()
turn = (await db.execute(select(func.sum(Order.amount)).where(Order.status==OrderStatus.completed))).scalar() or 0
comm = (await db.execute(select(func.sum(Order.commission)).where(Order.status==OrderStatus.completed))).scalar() or 0
pending = (await db.execute(select(func.count(Order.id)).where(Order.status.in_([OrderStatus.pending,OrderStatus.confirming])))).scalar()
today = datetime.utcnow().replace(hour=0,minute=0,second=0,microsecond=0)
orders_td = (await db.execute(select(func.count(Order.id)).where(Order.status==OrderStatus.completed, Order.created_at>=today))).scalar()
txt = (
f"📊 <b>Статистика платформы</b>\n<i>{datetime.now():%d.%m.%Y %H:%M}</i>\n{''*28}\n\n"
f"👥 Пользователей: <b>{n_all}</b> (активных {n_act})\n"
f"🔴 Заблокировано: <b>{n_blk}</b>\n🟡 Низкий баланс: <b>{n_low}</b>\n"
f"🔴 Баланс=0: <b>{n_zero}</b>\n\n"
f"💰 Оборот: <b>{fmt(turn)}</b>\n📊 Комиссия: <b>{fmt(comm)}</b>\n\n"
f"📦 Заказов сегодня: <b>{orders_td}</b>\n⏳ На подтверждении: <b>{pending}</b>"
)
await msg.answer(txt, parse_mode="HTML", reply_markup=ib(("🔄 Обновить","stats_ref")))
@router.callback_query(F.data=="stats_ref")
@guard
async def stats_ref(cb: CallbackQuery):
await stats(cb.message); await cb.answer("Обновлено")
# ── Пользователи ──────────────────────────────────────────────────────────────
@router.message(F.text == "👥 Пользователи")
@guard
async def users(msg: Message):
async with AsyncSessionLocal() as db:
tenants = (await db.execute(
select(Tenant).order_by(desc(Tenant.created_at)).limit(20)
)).scalars().all()
if not tenants:
await msg.answer("Нет пользователей."); return
bld = InlineKeyboardBuilder()
txt = "👥 <b>Пользователи</b>\n\n"
for t in tenants:
em = "🔒" if t.is_blocked else ("🔴" if t.balance<=0 else "")
txt += f"{em} <b>{t.name}</b> · {t.email}\n 📦 {plan_title(t.plan)} · {bal_em(t.balance,t.alert_threshold)} {fmt(t.balance)}\n\n"
bld.add(InlineKeyboardButton(text=f"{em} {t.name[:22]}", callback_data=f"t_{t.id}"))
bld.adjust(2)
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@router.callback_query(F.data.startswith("t_"))
@guard
async def tenant_detail(cb: CallbackQuery, state: FSMContext):
data = cb.data or ""
if data.startswith("t_"):
tid = int(data[2:])
elif data.startswith("blk_"):
tid = int(data[4:])
elif data.startswith("setplan_"):
tid = int(data[len("setplan_"):].split("_", 1)[0])
else:
await cb.answer("Не удалось определить пользователя", show_alert=True)
return
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, tid)
if not t:
await cb.answer("Не найден", show_alert=True)
return
shops_n = (await db.execute(select(func.count(Shop.id)).where(Shop.tenant_id == tid))).scalar()
trial_txt = f"\n🧪 Тест до: <b>{t.trial_ends_at:%d.%m.%Y}</b>" if t.trial_ends_at else ""
postpaid_txt = ""
if is_postpaid(t):
revenue, due = await postpaid_previous_month_totals(db, tid)
due_date = current_due_date(t)
postpaid_txt = (
f"\n🧾 Постоплата: <b>{t.postpaid_commission_percent:g}%</b> от выручки прошлого месяца"
f"\n📅 Оплата до: <b>{due_date:%d.%m.%Y}</b>"
f"\n📈 Прошлый месяц: {fmt(revenue)} · к оплате {fmt(due)}"
)
txt = (
f"👤 <b>{t.name}</b>\n{''*24}\n"
f"📧 {t.email}\n🆔 TG: <code>{t.telegram_id or ''}</code>\n"
f"📦 Тариф: <b>{plan_title(t.plan)}</b>{trial_txt}{postpaid_txt}\n🏪 Магазинов: {shops_n}\n\n"
f"{bal_em(t.balance,t.alert_threshold)} Баланс: <b>{fmt(t.balance)}</b>\n"
f"⚠️ Порог: {fmt(t.alert_threshold)}\n"
f"Статус: {'🔒 Заблокирован' if t.is_blocked else '✅ Активен'}"
)
bld = InlineKeyboardBuilder()
bld.row(
InlineKeyboardButton(text="💳 Пополнить", callback_data=f"topup_{tid}"),
InlineKeyboardButton(text="🧾 Тариф", callback_data=f"plan_{tid}"),
)
bld.row(InlineKeyboardButton(
text="🔒 Блок" if not t.is_blocked else "🔓 Разблок",
callback_data=f"blk_{tid}",
))
bld.row(InlineKeyboardButton(text="← Назад", callback_data="users_back"))
await cb.message.edit_text(txt, parse_mode="HTML", reply_markup=bld.as_markup())
await cb.answer()
@router.callback_query(F.data=="users_back")
@guard
async def users_back(cb: CallbackQuery):
await users(cb.message); await cb.answer()
@router.callback_query(F.data.startswith("blk_"))
@guard
async def toggle_block(cb: CallbackQuery):
tid = int(cb.data[4:])
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, tid)
if not t: await cb.answer("Не найден"); return
t.is_blocked = not t.is_blocked
await db.commit()
await cb.answer("🔒 Заблокирован" if t.is_blocked else "🔓 Разблокирован", show_alert=True)
await tenant_detail(cb, None)
# ── Тарифы пользователей ─────────────────────────────────────────────────────
def plan_keyboard(tid: int):
bld = InlineKeyboardBuilder()
bld.row(InlineKeyboardButton(text="🧪 Тест 7 дней", callback_data=f"setplan_{tid}_trial_week"))
bld.row(InlineKeyboardButton(text="Trial", callback_data=f"setplan_{tid}_trial"),
InlineKeyboardButton(text="Basic", callback_data=f"setplan_{tid}_basic"))
bld.row(InlineKeyboardButton(text="Pro", callback_data=f"setplan_{tid}_pro"),
InlineKeyboardButton(text="Enterprise", callback_data=f"setplan_{tid}_enterprise"))
bld.row(InlineKeyboardButton(text="🧾 Индивидуальный постоплатный", callback_data=f"postpaid_{tid}"))
bld.row(InlineKeyboardButton(text="← Назад", callback_data=f"t_{tid}"))
return bld.as_markup()
@router.callback_query(F.data.startswith("plan_"))
@guard
async def plan_menu(cb: CallbackQuery):
tid = int(cb.data[5:])
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, tid)
if not t:
await cb.answer("Не найден", show_alert=True)
return
await cb.message.edit_text(
f"🧾 <b>Тариф пользователя</b>\n\n👤 {t.name}\nТекущий: <b>{plan_title(t.plan)}</b>\n\n"
"Постоплатный тариф применяется только вручную администратором.",
parse_mode="HTML",
reply_markup=plan_keyboard(tid),
)
await cb.answer()
@router.callback_query(F.data.startswith("setplan_"))
@guard
async def set_regular_plan(cb: CallbackQuery):
raw = cb.data[len("setplan_"):]
tid_raw, plan_raw = raw.split("_", 1)
tid = int(tid_raw)
if plan_raw not in {p.value for p in PlanEnum} or plan_raw == PlanEnum.postpaid_custom.value:
await cb.answer("Некорректный тариф", show_alert=True)
return
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, tid)
if not t:
await cb.answer("Не найден", show_alert=True)
return
t.plan = PlanEnum(plan_raw)
t.trial_ends_at = datetime.utcnow() + timedelta(days=7) if plan_raw == PlanEnum.trial_week.value else None
t.postpaid_enabled_at = None
t.postpaid_note = ""
if t.is_blocked and t.balance > 0:
t.is_blocked = False
await db.commit()
await cb.answer("Тариф применён", show_alert=True)
await tenant_detail(cb, None)
@router.callback_query(F.data.startswith("postpaid_"))
@guard
async def postpaid_start(cb: CallbackQuery, state: FSMContext):
tid = int(cb.data[len("postpaid_"):])
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, tid)
if not t:
await cb.answer("Не найден", show_alert=True)
return
await state.update_data(tid=tid, tname=t.name)
await state.set_state(S.plan_percent)
await cb.message.answer(
f"🧾 Индивидуальная постоплата для <b>{t.name}</b>\n\n"
"Введите процент комиссии от выручки предыдущего месяца, например: 5",
parse_mode="HTML",
reply_markup=ReplyKeyboardRemove(),
)
await cb.answer()
@router.message(S.plan_percent)
@guard
async def postpaid_percent(msg: Message, state: FSMContext):
try:
percent = float(msg.text.replace(",", ".").replace(" ", ""))
assert 0 < percent <= 100
except Exception:
await msg.answer("❌ Введите процент числом от 0.1 до 100, например: 5")
return
await state.update_data(percent=percent)
await state.set_state(S.plan_due_day)
await msg.answer("📅 Введите день месяца для оплаты комиссии: 1–28")
@router.message(S.plan_due_day)
@guard
async def postpaid_due_day(msg: Message, state: FSMContext):
try:
day = int(msg.text.strip())
assert 1 <= day <= 28
except Exception:
await msg.answer("❌ Введите день месяца числом от 1 до 28")
return
data = await state.get_data()
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, data["tid"])
if not t:
await state.clear()
await msg.answer("Пользователь не найден.", reply_markup=main_kb())
return
t.plan = PlanEnum.postpaid_custom
t.trial_ends_at = None
t.postpaid_commission_percent = float(data["percent"])
t.postpaid_due_day = day
t.postpaid_enabled_at = datetime.utcnow()
t.postpaid_note = f"Назначено администратором {msg.from_user.id}"
t.is_blocked = False
await db.commit()
await state.clear()
await msg.answer(
f"✅ Постоплатный тариф применён\n\n👤 {data['tname']}\n"
f"Комиссия: <b>{data['percent']:g}%</b> от выручки прошлого месяца\n"
f"Оплата: до <b>{day}</b> числа каждого месяца",
parse_mode="HTML",
reply_markup=main_kb(),
)
@router.message(F.text == "🧾 Постоплата")
@guard
async def postpaid_report(msg: Message):
async with AsyncSessionLocal() as db:
tenants = (await db.execute(
select(Tenant).where(Tenant.plan == PlanEnum.postpaid_custom).order_by(Tenant.name.asc())
)).scalars().all()
if not tenants:
await msg.answer("🧾 Постоплатных пользователей пока нет.")
return
lines = ["🧾 <b>Индивидуальная постоплата</b>\n"]
for t in tenants:
revenue, due = await postpaid_previous_month_totals(db, t.id)
due_date = current_due_date(t)
lines.append(
f"👤 <b>{t.name}</b> · {t.postpaid_commission_percent:g}%\n"
f" Выручка прошлого месяца: {fmt(revenue)}\n"
f" К оплате: <b>{fmt(due)}</b> до {due_date:%d.%m.%Y}\n"
)
await msg.answer("\n".join(lines), parse_mode="HTML")
# ── Балансы ───────────────────────────────────────────────────────────────────
@router.message(F.text == "💰 Балансы")
@guard
async def balances(msg: Message):
async with AsyncSessionLocal() as db:
tenants = (await db.execute(
select(Tenant).where(Tenant.is_active==True).order_by(Tenant.balance.asc())
)).scalars().all()
crit, low, ok_list = [], [], []
for t in tenants:
line = f"{bal_em(t.balance,t.alert_threshold)} <b>{t.name[:20]}</b>: {fmt(t.balance)}"
if t.balance <= 0: crit.append(line + " 🔴 СТОП")
elif t.balance <= t.alert_threshold: low.append(line)
else: ok_list.append(line)
txt = "💰 <b>Балансы</b>\n\n"
if crit: txt += "🔴 <b>Критично:</b>\n" + "\n".join(crit) + "\n\n"
if low: txt += "🟡 <b>Низкий:</b>\n" + "\n".join(low) + "\n\n"
txt += f"🟢 В норме: {len(ok_list)}"
await msg.answer(txt, parse_mode="HTML",
reply_markup=ib(("🔄 Обновить","bal_ref")))
@router.callback_query(F.data=="bal_ref")
@guard
async def bal_ref(cb: CallbackQuery):
await balances(cb.message); await cb.answer("Обновлено")
# ── Пополнение баланса ────────────────────────────────────────────────────────
@router.callback_query(F.data.startswith("topup_"))
@guard
async def topup_start(cb: CallbackQuery, state: FSMContext):
tid = int(cb.data[6:])
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, tid)
await state.update_data(tid=tid, tname=t.name, tbal=t.balance)
await state.set_state(S.topup_amount)
await cb.message.answer(
f"💳 Пополнение: <b>{t.name}</b>\nБаланс: {fmt(t.balance)}\n\nВведите сумму (₽):",
parse_mode="HTML", reply_markup=ReplyKeyboardRemove())
await cb.answer()
@router.message(S.topup_amount)
@guard
async def topup_amount(msg: Message, state: FSMContext):
try:
amt = float(msg.text.replace(",",".").replace(" ",""))
assert amt > 0
except:
await msg.answer("❌ Введите сумму, например: 1000"); return
await state.update_data(amt=amt)
await state.set_state(S.topup_note)
await msg.answer(f"Сумма: <b>{fmt(amt)}</b>\nКомментарий (или '-'):", parse_mode="HTML")
@router.message(S.topup_note)
@guard
async def topup_note(msg: Message, state: FSMContext):
data = await state.get_data()
note = msg.text if msg.text != "-" else "Пополнение администратором"
amt = data["amt"]
async with AsyncSessionLocal() as db:
t = await db.get(Tenant, data["tid"])
old = t.balance
t.balance += amt
if old <= 0 and t.balance > 0:
t.is_blocked = False
db.add(BalanceTransaction(
tenant_id=t.id, type="deposit", amount=amt,
balance_after=t.balance, note=f"[Admin] {note}"))
await db.commit()
await state.clear()
await msg.answer(
f"✅ <b>Пополнено</b>\n{data['tname']}\n+{fmt(amt)}\nБаланс: {fmt(t.balance)}\n{note}",
parse_mode="HTML", reply_markup=main_kb())
# Уведомить владельца
if t.telegram_id:
try:
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
await bot.send_message(t.telegram_id,
f"💰 <b>Баланс пополнен!</b>\n+{fmt(amt)}\nВаш баланс: {fmt(t.balance)}\n{note}",
parse_mode="HTML")
await bot.session.close()
except Exception: pass
async def deliver_order(shop_id: int, buyer_tg_id: int, content: str, order_id: int) -> bool:
try:
async with AsyncSessionLocal() as db:
tok = (await db.execute(select(ShopToken).where(
ShopToken.shop_id == shop_id,
ShopToken.is_active == True,
))).scalar_one_or_none()
if not tok:
logger.warning("order %s: active shop token not found", order_id)
return False
bot = Bot(token=tok.token)
try:
await bot.send_message(
buyer_tg_id,
f"✅ <b>Оплата подтверждена!</b>\n\nЗаказ #{order_id}\n\n<code>{content}</code>",
parse_mode="HTML",
)
return True
finally:
await bot.session.close()
except Exception as e:
logger.error("deliver_order #%s failed: %s", order_id, e)
return False
# ── Заказы ────────────────────────────────────────────────────────────────────
@router.message(F.text == "📦 Заказы")
@guard
async def orders_menu(msg: Message):
async with AsyncSessionLocal() as db:
pending = (await db.execute(
select(Order)
.where(Order.status.in_([OrderStatus.pending, OrderStatus.confirming]))
.order_by(Order.created_at.asc()).limit(10)
)).scalars().all()
if not pending:
await msg.answer("✅ Ожидающих заказов нет."); return
txt = f"📦 <b>На подтверждении: {len(pending)}</b>\n\n"
bld = InlineKeyboardBuilder()
for o in pending:
em = "🟡" if o.status==OrderStatus.pending else "🟠"
txt += f"{em} #{o.id} · {fmt(o.amount)} · {o.created_at:%d.%m %H:%M}\n"
bld.add(InlineKeyboardButton(text=f"{em}#{o.id}·{o.amount:.0f}", callback_data=f"ord_{o.id}"))
bld.adjust(3)
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@router.callback_query(F.data.startswith("ord_"))
@guard
async def order_detail(cb: CallbackQuery):
oid = int(cb.data[4:])
async with AsyncSessionLocal() as db:
o = await db.get(Order, oid)
if not o: await cb.answer("Не найден", show_alert=True); return
shop = await db.get(Shop, o.shop_id)
txt = (
f"📦 <b>Заказ #{o.id}</b>\n{''*24}\n"
f"🏪 {shop.name if shop else ''}\n"
f"👤 {o.buyer_username or o.buyer_telegram_id}\n"
f"💰 {fmt(o.amount)} | Комиссия: {fmt(o.commission)}\n"
f"📅 {o.created_at:%d.%m.%Y %H:%M}"
)
bld = InlineKeyboardBuilder()
if o.status in [OrderStatus.pending, OrderStatus.confirming]:
bld.row(InlineKeyboardButton(text="✅ Подтвердить", callback_data=f"oc_{oid}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"or_{oid}"))
bld.row(InlineKeyboardButton(text="← Назад", callback_data="orders_back"))
if o.proof_file_id:
await cb.message.answer_photo(photo=o.proof_file_id, caption=txt,
parse_mode="HTML", reply_markup=bld.as_markup())
else:
await cb.message.edit_text(txt, parse_mode="HTML", reply_markup=bld.as_markup())
await cb.answer()
@router.callback_query(F.data.startswith("oc_"))
@guard
async def order_confirm(cb: CallbackQuery):
oid = int(cb.data[3:])
charged_now = False
async with AsyncSessionLocal() as db:
o = await db.get(Order, oid)
if not o or o.status not in [OrderStatus.pending, OrderStatus.confirming]:
await cb.answer("Нельзя подтвердить", show_alert=True)
return
shop = await db.get(Shop, o.shop_id)
tenant = await db.get(Tenant, shop.tenant_id) if shop else None
ok, reason = tenant_can_sell(tenant)
if not ok:
await cb.answer(f"{reason}", show_alert=True)
return
product = await db.get(Product, o.product_id)
card = await db.get(PaymentCard, o.card_id) if o.card_id else None
o.status = OrderStatus.completed
o.confirmed_by = cb.from_user.id
if product:
product.sold += 1
if product.stock > 0:
product.stock -= 1
if product.stock <= 0:
product.is_active = False
if card:
card.received_total += o.amount
if tenant and not is_postpaid(tenant):
old_balance = tenant.balance
tenant.balance -= o.commission
charged_now = True
db.add(BalanceTransaction(
tenant_id=tenant.id,
type="commission",
amount=-o.commission,
balance_after=tenant.balance,
note=f"Комиссия заказ #{oid}",
order_id=oid,
))
if old_balance > 0 and tenant.balance <= 0:
tenant.is_blocked = True
elif tenant:
db.add(BalanceTransaction(
tenant_id=tenant.id,
type="postpaid_accrual",
amount=0,
balance_after=tenant.balance,
note=f"Начислена постоплатная комиссия {fmt(o.commission)} за заказ #{oid}",
order_id=oid,
))
shop_id = o.shop_id
buyer_id = o.buyer_telegram_id
content = o.product_content
await db.commit()
delivered = await deliver_order(shop_id, buyer_id, content, oid)
suffix = "комиссия списана" if charged_now else "комиссия начислена в постоплату"
delivery = "товар выдан" if delivered else "товар НЕ отправлен — нет активного токена магазина"
text = f"✅ Заказ #{oid} подтверждён: {delivery}, {suffix}."
if getattr(cb.message, "photo", None):
await cb.message.edit_caption(caption=text, parse_mode="HTML")
else:
await cb.message.edit_text(text, parse_mode="HTML")
await cb.answer("✅ Подтверждено!")
@router.callback_query(F.data.startswith("or_"))
@guard
async def order_reject(cb: CallbackQuery):
oid = int(cb.data[3:])
async with AsyncSessionLocal() as db:
o = await db.get(Order, oid)
if o: o.status = OrderStatus.rejected; await db.commit()
await cb.message.edit_text(f"❌ Заказ #{oid} отклонён.")
await cb.answer("Отклонено")
@router.callback_query(F.data=="orders_back")
@guard
async def orders_back(cb: CallbackQuery):
await orders_menu(cb.message); await cb.answer()
# ── Рассылка ──────────────────────────────────────────────────────────────────
@router.message(F.text == "📢 Рассылка")
@guard
async def broadcast_start(msg: Message, state: FSMContext):
await state.set_state(S.broadcast)
b = ReplyKeyboardBuilder(); b.add(KeyboardButton(text="❌ Отмена"))
await msg.answer("📢 Введите текст рассылки (HTML):", reply_markup=b.as_markup(resize_keyboard=True))
@router.message(S.broadcast)
@guard
async def broadcast_text(msg: Message, state: FSMContext):
if msg.text == "❌ Отмена":
await state.clear(); await msg.answer("Отменено.", reply_markup=main_kb()); return
await state.update_data(text=msg.text)
await state.set_state(S.bcast_ok)
async with AsyncSessionLocal() as db:
n = (await db.execute(select(func.count(Tenant.id))
.where(Tenant.telegram_id != None, Tenant.is_active==True))).scalar()
bld = InlineKeyboardBuilder()
bld.row(InlineKeyboardButton(text=f"✅ Отправить {n} польз.", callback_data="bcast_go"),
InlineKeyboardButton(text="❌ Отмена", callback_data="bcast_no"))
await msg.answer(f"Предпросмотр:\n\n{msg.text}\n\n👥 Получателей: {n}",
parse_mode="HTML", reply_markup=bld.as_markup())
@router.callback_query(F.data=="bcast_go", S.bcast_ok)
@guard
async def broadcast_go(cb: CallbackQuery, state: FSMContext):
data = await state.get_data(); await state.clear()
async with AsyncSessionLocal() as db:
tenants = (await db.execute(
select(Tenant).where(Tenant.telegram_id!=None, Tenant.is_active==True)
)).scalars().all()
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
sent = fail = 0
for t in tenants:
try:
await bot.send_message(t.telegram_id, data["text"], parse_mode="HTML")
sent += 1; await asyncio.sleep(0.05)
except: fail += 1
await bot.session.close()
await cb.message.edit_text(f"📢 Рассылка завершена\n{sent} · ❌ {fail}")
await cb.answer()
@router.callback_query(F.data=="bcast_no")
@guard
async def broadcast_no(cb: CallbackQuery, state: FSMContext):
await state.clear(); await cb.message.edit_text("Отменено."); await cb.answer()
# ── Настройки ─────────────────────────────────────────────────────────────────
@router.message(F.text == "⚙️ Настройки")
@guard
async def platform_settings(msg: Message):
c = settings.commission
txt = (
f"⚙️ <b>Настройки платформы</b>\n\n"
f"Комиссии:\n Тест 7 дней: {c['trial_week']}% | Trial: {c['trial']}% | Basic: {c['basic']}%\n"
f" Pro: {c['pro']}% | Enterprise: {c['enterprise']}% | Постоплата default: {c['postpaid_custom']}%\n\n"
f"API: {settings.API_HOST}:{settings.API_PORT}\n"
f"Domain: {settings.DOMAIN or ''}"
)
await msg.answer(txt, parse_mode="HTML")
# ── Уведомления (вызываются из кода) ─────────────────────────────────────────
async def notify_admins(bot: Bot, text: str):
for aid in settings.admin_ids:
try: await bot.send_message(aid, text, parse_mode="HTML")
except Exception as e: logger.warning(f"notify {aid}: {e}")
async def notify_zero_balance(bot: Bot, tenant: Tenant):
await notify_admins(bot,
f"🔴 <b>Баланс исчерпан — боты остановлены!</b>\n\n"
f"👤 {tenant.name} ({tenant.email})\n"
f"💰 Баланс: {fmt(tenant.balance)}\n\n/topup_{tenant.id}")
async def notify_low_balance(bot: Bot, tenant: Tenant):
await notify_admins(bot,
f"🟡 <b>Низкий баланс</b>\n\n"
f"👤 {tenant.name}\n"
f"💰 Баланс: {fmt(tenant.balance)} (порог {fmt(tenant.alert_threshold)})")
# ── Запуск ────────────────────────────────────────────────────────────────────
async def start_platform_bot():
if not settings.PLATFORM_BOT_TOKEN:
logger.warning("PLATFORM_BOT_TOKEN не задан — платформенный бот отключён")
return
storage = RedisStorage.from_url(settings.REDIS_URL)
bot = Bot(token=settings.PLATFORM_BOT_TOKEN)
dp = Dispatcher(storage=storage)
dp.include_router(router)
logger.info("Platform admin bot started")
try:
await dp.start_polling(bot, allowed_updates=["message","callback_query"])
finally:
await bot.session.close()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(start_platform_bot())
+18
Просмотреть файл
@@ -0,0 +1,18 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
aiogram==3.7.0
sqlalchemy[asyncio]==2.0.30
asyncpg==0.29.0
alembic==1.13.1
redis==5.0.4
aioredis==2.0.1
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
python-multipart==0.0.9
httpx==0.27.0
pydantic==2.7.1
pydantic-settings==2.3.0
python-dotenv==1.0.1
aiofiles==23.2.1
pillow==10.3.0
uvloop==0.19.0
+216
Просмотреть файл
@@ -0,0 +1,216 @@
"""BotFactory — Shop Customer Bot (бот магазина для покупателей)"""
import asyncio, logging, random
from datetime import datetime
from aiogram import Bot, Dispatcher, F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.redis import RedisStorage
from aiogram.types import (Message, CallbackQuery, InlineKeyboardButton,
KeyboardButton, ReplyKeyboardRemove)
from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder
from sqlalchemy import select, desc
from config import settings
from database import AsyncSessionLocal
from models import Shop, ShopToken, Product, PaymentCard, Order, OrderStatus, Tenant
from billing import commission_rate, tenant_can_sell
logger = logging.getLogger("shop_bot")
def fmt(v: float) -> str:
return f"{v:,.0f}".replace(",", " ") + ""
class SS(StatesGroup):
waiting_proof = State()
class ShopCustomerBot:
def __init__(self, shop_id: int, shop_name: str):
self.shop_id = shop_id
self.name = shop_name
self.router = Router()
self._reg()
def _reg(self):
r=self.router; sid=self.shop_id
def main_kb():
b=ReplyKeyboardBuilder()
b.row(KeyboardButton(text="🛍 Каталог"))
b.row(KeyboardButton(text="📦 Мои заказы"), KeyboardButton(text="💬 Поддержка"))
return b.as_markup(resize_keyboard=True)
@r.message(Command("start"))
async def start(msg: Message, state: FSMContext):
await state.clear()
async with AsyncSessionLocal() as db:
shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id) if shop else None
ok, reason = tenant_can_sell(tenant)
if not shop or not ok:
await msg.answer(f"⚠️ {reason or 'Магазин временно приостановлен.'}"); return
await msg.answer(f"{shop.welcome_msg}\n\n<b>{shop.name}</b>",
parse_mode="HTML", reply_markup=main_kb())
@r.message(F.text=="🛍 Каталог")
async def catalog(msg: Message):
async with AsyncSessionLocal() as db:
prods=(await db.execute(
select(Product).where(Product.shop_id==sid, Product.is_active==True, Product.stock>0)
.order_by(Product.category, Product.name)
)).scalars().all()
if not prods:
await msg.answer("🛍 Каталог пуст."); return
cats: dict = {}
for p in prods: cats.setdefault(p.category,[]).append(p)
bld=InlineKeyboardBuilder(); txt="🛍 <b>Каталог</b>\n\n"
for cat,ps in cats.items():
txt+=f"📂 <b>{cat}</b>\n"
for p in ps:
txt+=f"{p.name} — <b>{fmt(p.price)}</b>\n"
bld.add(InlineKeyboardButton(text=f"🛒 {p.name[:28]}", callback_data=f"buy_{p.id}"))
txt+="\n"
bld.adjust(1)
await msg.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
@r.callback_query(F.data.startswith("buy_"))
async def product_card(cb: CallbackQuery):
pid=int(cb.data[4:])
async with AsyncSessionLocal() as db:
p=await db.get(Product,pid)
if not p or not p.is_active or p.shop_id!=sid or p.stock <= 0:
await cb.answer("Товар недоступен",show_alert=True); return
bld=InlineKeyboardBuilder()
bld.row(InlineKeyboardButton(text=f"✅ Купить за {fmt(p.price)}", callback_data=f"order_{p.id}"),
InlineKeyboardButton(text="← Назад", callback_data="back_cat"))
txt=f"🛍 <b>{p.name}</b>\n{''*22}\n💰 <b>{fmt(p.price)}</b>\n📂 {p.category}\n\n{p.description}"
if p.photo_url:
await cb.message.answer_photo(photo=p.photo_url, caption=txt,
parse_mode="HTML", reply_markup=bld.as_markup())
else:
await cb.message.answer(txt, parse_mode="HTML", reply_markup=bld.as_markup())
await cb.answer()
@r.callback_query(F.data=="back_cat")
async def back_cat(cb: CallbackQuery):
await catalog(cb.message); await cb.answer()
@r.callback_query(F.data.startswith("order_"))
async def create_order(cb: CallbackQuery, state: FSMContext):
pid=int(cb.data[6:])
async with AsyncSessionLocal() as db:
p=await db.get(Product,pid)
if not p or not p.is_active or p.shop_id!=sid or p.stock <= 0:
await cb.answer("Недоступен",show_alert=True); return
cards=(await db.execute(
select(PaymentCard).where(PaymentCard.shop_id==sid, PaymentCard.is_active==True)
)).scalars().all()
if not cards:
await cb.answer("Оплата временно недоступна.",show_alert=True); return
card=random.choice(cards)
shop=await db.get(Shop,sid); tenant=await db.get(Tenant,shop.tenant_id)
ok, reason = tenant_can_sell(tenant)
if not ok:
await cb.answer(reason, show_alert=True); return
rate=commission_rate(tenant)
commission=round(p.price*rate/100, 2)
order=Order(shop_id=sid, product_id=p.id, card_id=card.id,
buyer_telegram_id=cb.from_user.id,
buyer_username=cb.from_user.username or "",
amount=p.price, commission=commission,
status=OrderStatus.pending, product_content=p.content)
db.add(order); card.orders_count+=1; await db.flush()
oid=order.id; await db.commit()
await state.update_data(order_id=oid); await state.set_state(SS.waiting_proof)
pay_txt=(
f"💳 <b>Заказ #{oid}</b>\n{''*24}\n"
f"🛍 {p.name}\n💰 <b>{fmt(p.price)}</b>\n\n"
f"<b>Переводите на карту:</b>\n"
f"🏦 {card.bank}\n"
f"💳 <code>{card.number}</code>\n"
f"👤 {card.holder}\n"
+(f"📱 СБП: <code>{card.phone}</code>\n" if card.phone else "")
+f"\n📝 Назначение: <code>Заказ #{oid}</code>\n\n"
f"📸 После оплаты пришлите <b>скриншот перевода</b>.\n"
f"⏱ Товар выдаётся после проверки кассиром."
)
bld=InlineKeyboardBuilder()
bld.add(InlineKeyboardButton(text="❌ Отменить заказ", callback_data=f"cancel_{oid}"))
await cb.message.answer(pay_txt, parse_mode="HTML", reply_markup=bld.as_markup())
await cb.answer()
@r.message(SS.waiting_proof, F.photo)
async def receive_proof(msg: Message, state: FSMContext):
data=await state.get_data(); oid=data.get("order_id")
if not oid: await state.clear(); return
file_id=msg.photo[-1].file_id
async with AsyncSessionLocal() as db:
o=await db.get(Order,oid)
if not o or o.buyer_telegram_id!=msg.from_user.id:
await msg.answer("❌ Заказ не найден."); await state.clear(); return
o.status=OrderStatus.confirming; o.proof_file_id=file_id
shop=await db.get(Shop,sid); await db.commit()
await state.clear()
await msg.answer(
f"✅ Скриншот получен!\nЗаказ #{oid} на проверке.\n⏱ До 15 минут.",
reply_markup=ReplyKeyboardRemove())
# Уведомить кассиров
await self._notify(shop, oid, o.amount, msg.from_user.username or str(msg.from_user.id), file_id)
@r.message(SS.waiting_proof)
async def proof_not_photo(msg: Message):
await msg.answer("📸 Пришлите скриншот перевода (фото).")
@r.callback_query(F.data.startswith("cancel_"))
async def cancel(cb: CallbackQuery, state: FSMContext):
oid=int(cb.data[7:])
async with AsyncSessionLocal() as db:
o=await db.get(Order,oid)
if o and o.buyer_telegram_id==cb.from_user.id and o.status==OrderStatus.pending:
o.status=OrderStatus.cancelled; await db.commit()
await state.clear(); await cb.message.edit_text("❌ Заказ отменён."); await cb.answer()
@r.message(F.text=="📦 Мои заказы")
async def my_orders(msg: Message):
async with AsyncSessionLocal() as db:
ords=(await db.execute(
select(Order).where(Order.shop_id==sid, Order.buyer_telegram_id==msg.from_user.id)
.order_by(desc(Order.created_at)).limit(10)
)).scalars().all()
if not ords: await msg.answer("📦 Заказов нет."); return
em_map={OrderStatus.pending:"🟡",OrderStatus.confirming:"🟠",
OrderStatus.completed:"",OrderStatus.rejected:"",OrderStatus.cancelled:"🚫"}
txt="📦 <b>Ваши заказы:</b>\n\n"
for o in ords:
txt+=f"{em_map.get(o.status,'')} #{o.id} · {fmt(o.amount)} · {o.created_at:%d.%m %H:%M}\n"
await msg.answer(txt, parse_mode="HTML")
@r.message(F.text=="💬 Поддержка")
async def support(msg: Message):
await msg.answer(f"💬 По вопросам — обращайтесь к администрации {self.name}.")
async def _notify(self, shop, oid: int, amount: float, buyer: str, file_id: str):
if not shop or not shop.ctrl_bot_token: return
try:
from ctrl_bot import ShopControlBot
ctrl=ShopControlBot(self.shop_id, shop.ctrl_bot_token, self.name)
await ctrl.notify_cashiers(oid, amount, buyer, file_id)
except Exception as e:
logger.error(f"_notify: {e}")
async def run(self):
async with AsyncSessionLocal() as db:
tok=(await db.execute(
select(ShopToken).where(ShopToken.shop_id==self.shop_id, ShopToken.is_active==True)
)).scalar_one_or_none()
if not tok:
logger.warning(f"shop_id={self.shop_id}: нет активного токена"); await asyncio.sleep(30); return
storage=RedisStorage.from_url(settings.REDIS_URL+f"?db={10+self.shop_id%4}")
bot=Bot(token=tok.token); dp=Dispatcher(storage=storage)
dp.include_router(self.router)
logger.info(f"Shop bot: {self.name} ({tok.username})")
try:
await dp.start_polling(bot, allowed_updates=["message","callback_query"])
finally:
await bot.session.close()
+129
Просмотреть файл
@@ -0,0 +1,129 @@
"""
BotFactory — Bots Runner
Запускает платформенный бот, боты управления магазинами и клиентские боты.
Следит за сменой токенов и перезапускает конкретный бот без рестарта сервера.
"""
import asyncio
import logging
from sqlalchemy import select
from database import AsyncSessionLocal, init_db
from models import Shop, ShopToken
logger = logging.getLogger("runner")
class BotManager:
def __init__(self):
self._tasks: dict[str, asyncio.Task] = {}
self._sigs: dict[str, str] = {}
async def _safe_run(self, fn, key: str):
delay = 5
while True:
try:
await fn()
except asyncio.CancelledError:
logger.info("[%s] stopped", key)
return
except Exception as e:
logger.error("[%s] crashed: %s — retry in %ss", key, e, delay)
await asyncio.sleep(delay)
delay = min(delay * 2, 120)
else:
await asyncio.sleep(5)
async def _replace_task(self, key: str, signature: str, factory):
old_sig = self._sigs.get(key)
old_task = self._tasks.get(key)
if old_task and not old_task.done() and old_sig == signature:
return
if old_task and not old_task.done():
logger.info("[%s] signature changed, restarting", key)
old_task.cancel()
try:
await old_task
except asyncio.CancelledError:
pass
bot_obj = factory()
self._sigs[key] = signature
self._tasks[key] = asyncio.create_task(
self._safe_run(bot_obj.run, key), name=key
)
logger.info("[%s] started", key)
async def start_platform(self):
key = "platform"
if key in self._tasks and not self._tasks[key].done():
return
from platform_bot import start_platform_bot
self._sigs[key] = "platform"
self._tasks[key] = asyncio.create_task(
self._safe_run(start_platform_bot, key), name=key
)
logger.info("[platform] started")
async def start_shop(self, shop: Shop, active_token: ShopToken | None):
from ctrl_bot import ShopControlBot
from shop_bot import ShopCustomerBot
await self._replace_task(
key=f"ctrl_{shop.id}",
signature=f"ctrl:{shop.id}:{shop.ctrl_bot_token}",
factory=lambda: ShopControlBot(shop.id, shop.ctrl_bot_token, shop.name),
)
if active_token:
await self._replace_task(
key=f"shop_{shop.id}",
signature=f"shop:{shop.id}:{active_token.token}",
factory=lambda: ShopCustomerBot(shop.id, shop.name),
)
else:
key = f"shop_{shop.id}"
task = self._tasks.get(key)
if task and not task.done():
task.cancel()
logger.info("[%s] stopped: active token missing", key)
async def sync(self):
async with AsyncSessionLocal() as db:
shops = (await db.execute(
select(Shop).where(Shop.is_active == True)
)).scalars().all()
active_tokens = {}
for shop in shops:
active_tokens[shop.id] = (await db.execute(
select(ShopToken).where(
ShopToken.shop_id == shop.id,
ShopToken.is_active == True,
)
)).scalar_one_or_none()
active_keys = {"platform"}
for shop in shops:
active_keys.add(f"ctrl_{shop.id}")
if active_tokens.get(shop.id):
active_keys.add(f"shop_{shop.id}")
await self.start_shop(shop, active_tokens.get(shop.id))
for key, task in list(self._tasks.items()):
if key not in active_keys and not task.done():
task.cancel()
logger.info("[%s] stopped: no longer active", key)
async def run(self):
await init_db()
logger.info("BotManager starting...")
await self.start_platform()
await self.sync()
while True:
await asyncio.sleep(30)
await self.sync()
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
asyncio.run(BotManager().run())
+14
Просмотреть файл
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#07080a" />
<title>BotFactory</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🤖</text></svg>" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+21
Просмотреть файл
@@ -0,0 +1,21 @@
{
"name": "botfactory-frontend",
"version": "2.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1",
"axios": "^1.7.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.2.12"
}
}
Разница между файлами не показана из-за своего большого размера Загрузить разницу
+9
Просмотреть файл
@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)
+8
Просмотреть файл
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 3000 },
build: { outDir: 'dist', sourcemap: false },
})
+549
Просмотреть файл
@@ -0,0 +1,549 @@
#!/usr/bin/env bash
# =============================================================================
# BotFactory — SaaS Platform Interactive Installer v2.1
# Ubuntu 22.04 / 24.04 clean server installer
# =============================================================================
set -euo pipefail
IFS=$'\n\t'
R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'
C='\033[0;36m'; B='\033[1m'; DIM='\033[2m'; NC='\033[0m'
OK="${G}[✓]${NC}"; ERR="${R}[✗]${NC}"; WARN="${Y}[!]${NC}"; INFO="${C}[→]${NC}"
hdr() { echo -e "\n${B}${C}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n $1\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"; }
log() { echo -e "${OK} $1"; }
info() { echo -e "${INFO} $1"; }
warn() { echo -e "${WARN} $1"; }
die() { echo -e "${ERR} $1"; exit 1; }
[[ $EUID -ne 0 ]] && die "Запустите от root: sudo bash install.sh"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
[[ ! -d "${SCRIPT_DIR}/backend" ]] && die "Запустите из папки проекта: cd botfactory && sudo bash install.sh"
. /etc/os-release 2>/dev/null || true
if [[ "${ID:-}" != "ubuntu" ]]; then
warn "Скрипт рассчитан на Ubuntu 22.04/24.04. Текущая ОС: ${PRETTY_NAME:-неизвестна}"
read -rp "Продолжить? [y/N]: " _c; [[ "$_c" =~ ^[Yy]$ ]] || exit 0
elif [[ "${VERSION_ID:-}" != "22.04" && "${VERSION_ID:-}" != "24.04" ]]; then
warn "Проверены Ubuntu 22.04 и 24.04. Текущая версия: ${VERSION_ID:-неизвестна}"
read -rp "Продолжить? [y/N]: " _c; [[ "$_c" =~ ^[Yy]$ ]] || exit 0
fi
clear
echo -e "${B}${C}"
cat << 'BANNER'
____ _ _____ _
| __ ) ___| |_| ___|_ _ ___| |_ ___ _ __ _ _
| _ \ / _ \ __| |_ / _` |/ __| __/ _ \| '__| | | |
| |_) | __/ |_| _| (_| | (__| || (_) | | | |_| |
|____/ \___|\__|_| \__,_|\___|\__\___/|_| \__, |
|___/
BANNER
echo -e "${NC}${B} SaaS Platform — Interactive Installer v2.1${NC}\n"
ask() {
local var="$1" prompt="$2" default="${3:-}" secret="${4:-}"
local val=""
while [[ -z "$val" ]]; do
if [[ -n "$default" ]]; then
echo -ne "${C}?${NC} ${B}${prompt}${NC} ${DIM}[${default}]${NC}: "
else
echo -ne "${C}?${NC} ${B}${prompt}${NC}: "
fi
if [[ "$secret" == "secret" ]]; then
read -rs val; echo ""
else
read -r val
fi
val="${val:-$default}"
[[ -z "$val" ]] && echo -e " ${WARN} Поле обязательно для заполнения"
done
printf -v "$var" '%s' "$val"
}
ask_optional() {
local var="$1" prompt="$2" default="${3:-}"
if [[ -n "$default" ]]; then
echo -ne "${C}?${NC} ${B}${prompt}${NC} ${DIM}[${default}]${NC}: "
else
echo -ne "${C}?${NC} ${B}${prompt}${NC}: "
fi
local val=""; read -r val
printf -v "$var" '%s' "${val:-$default}"
}
ask_opt() {
local var="$1" prompt="$2" opts="${3:-Y/n}"
echo -ne "${C}?${NC} ${B}${prompt}${NC} ${DIM}[${opts}]${NC}: "
local ans=""; read -r ans
if [[ "$opts" == "Y/n" ]]; then
[[ "$ans" =~ ^[Nn] ]] && printf -v "$var" 'no' || printf -v "$var" 'yes'
else
[[ "$ans" =~ ^[Yy] ]] && printf -v "$var" 'yes' || printf -v "$var" 'no'
fi
}
valid_number() { [[ "$1" =~ ^[0-9]+([.][0-9]+)?$ ]]; }
hdr "Шаг 1 из 3 — Конфигурация"
echo -e "\n${B}▸ Домен и SSL${NC}"
echo -e "${DIM} Если домена нет — оставьте пустым, будет HTTP на IP-адресе${NC}\n"
ask_optional DOMAIN "Доменное имя" ""
DOMAIN="$(echo "$DOMAIN" | tr '[:upper:]' '[:lower:]' | sed -E 's#^https?://##;s#/$##')"
if [[ -n "$DOMAIN" ]]; then
ask CERTBOT_EMAIL "Email для SSL-сертификата" "admin@${DOMAIN}"
ask_opt WITH_WWW "Включить www.${DOMAIN} в сертификат?" "Y/n"
ask_opt SETUP_SSL "Получить SSL-сертификат сейчас?" "Y/n"
else
CERTBOT_EMAIL=""; WITH_WWW="no"; SETUP_SSL="no"
warn "Домен не задан — установка на HTTP (SSL можно добавить позже)"
fi
echo -e "\n${B}▸ Telegram — платформенный бот${NC}"
echo -e "${DIM} Создайте бота через @BotFather → /newbot → скопируйте токен${NC}\n"
ask PLATFORM_BOT_TOKEN "Токен платформенного бота" ""
echo -e "${DIM} Несколько ID через запятую. Свой ID узнайте у @userinfobot${NC}\n"
ask PLATFORM_ADMIN_IDS "Telegram ID администраторов" ""
echo -e "\n${B}▸ База данных PostgreSQL${NC}\n"
ask_opt CUSTOM_DB_PASS "Задать пароль БД вручную?" "y/N"
if [[ "$CUSTOM_DB_PASS" == "yes" ]]; then
ask DB_PASS "Пароль PostgreSQL" "" secret
else
DB_PASS="$(openssl rand -hex 16)"
log "Пароль БД сгенерирован автоматически"
fi
echo -e "\n${B}▸ Комиссии платформы${NC}\n"
ask_opt CUSTOM_COMMISSIONS "Изменить комиссии? (Тест 7д 10% / Trial 10% / Basic 7% / Pro 5% / Ent 3% / Постоплата default 5%)" "y/N"
if [[ "$CUSTOM_COMMISSIONS" == "yes" ]]; then
ask COMM_TRIAL_WEEK "Тест 7 дней (%)" "10"
ask COMM_TRIAL "Trial (%)" "10"
ask COMM_BASIC "Basic (%)" "7"
ask COMM_PRO "Pro (%)" "5"
ask COMM_ENTERPRISE "Enterprise (%)" "3"
ask COMM_POSTPAID_DEFAULT "Постоплата default (%)" "5"
ask POSTPAID_DUE_DAY "День оплаты постоплаты по умолчанию (1-28)" "5"
else
COMM_TRIAL_WEEK=10; COMM_TRIAL=10; COMM_BASIC=7; COMM_PRO=5; COMM_ENTERPRISE=3
COMM_POSTPAID_DEFAULT=5; POSTPAID_DUE_DAY=5
fi
for n in "$COMM_TRIAL_WEEK" "$COMM_TRIAL" "$COMM_BASIC" "$COMM_PRO" "$COMM_ENTERPRISE" "$COMM_POSTPAID_DEFAULT"; do
valid_number "$n" || die "Комиссии должны быть числами"
done
[[ "$POSTPAID_DUE_DAY" =~ ^[0-9]+$ ]] && (( POSTPAID_DUE_DAY >= 1 && POSTPAID_DUE_DAY <= 28 )) || die "День оплаты постоплаты должен быть от 1 до 28"
APP_DIR="/opt/botfactory"
APP_USER="botfactory"
DB_NAME="botfactory"
DB_USER="botfactory"
API_PORT=8000
SECRET_KEY="$(openssl rand -hex 32)"
VENV="${APP_DIR}/venv"
FRONT_DIR="${APP_DIR}/frontend"
PYTHON_BIN="$(command -v python3 || true)"
hdr "Шаг 2 из 3 — Подтверждение"
SERVER_IP="$(curl -4 -s --max-time 4 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')"
[[ -z "$SERVER_IP" ]] && SERVER_IP="$(hostname -I | awk '{print $1}')"
echo -e "${B}Параметры установки:${NC}\n"
printf " %-25s %s\n" "Директория:" "${APP_DIR}"
printf " %-25s %s\n" "IP сервера:" "${SERVER_IP}"
printf " %-25s %s\n" "Домен:" "${DOMAIN:-"(нет — HTTP)"}"
printf " %-25s %s\n" "SSL:" "${SETUP_SSL}"
[[ -n "$DOMAIN" ]] && printf " %-25s %s\n" "www в сертификат:" "${WITH_WWW}"
[[ -n "$DOMAIN" ]] && printf " %-25s %s\n" "Email SSL:" "${CERTBOT_EMAIL}"
printf " %-25s %s\n" "Platform Bot Token:" "${PLATFORM_BOT_TOKEN:0:24}"
printf " %-25s %s\n" "Admin Telegram IDs:" "${PLATFORM_ADMIN_IDS}"
printf " %-25s %s\n" "DB пароль:" "${DB_PASS:0:8}… (скрыт)"
printf " %-25s %s\n" "Комиссии:" "Тест7д ${COMM_TRIAL_WEEK}% | Trial ${COMM_TRIAL}% | Basic ${COMM_BASIC}% | Pro ${COMM_PRO}% | Ent ${COMM_ENTERPRISE}% | Postpay ${COMM_POSTPAID_DEFAULT}%"
printf " %-25s %s\n" "Постоплата default:" "до ${POSTPAID_DUE_DAY} числа"
echo ""
read -rp "$(echo -e "${B}Начать установку?${NC} [y/N]: ")" START
[[ "$START" =~ ^[Yy]$ ]] || { echo "Отменено."; exit 0; }
hdr "Шаг 3 из 3 — Установка"
START_TIME=$(date +%s)
step() { echo -e "\n${B}${C}$1${NC}"; }
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
wait_for_apt() {
local locks=(/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock)
for i in {1..60}; do
local busy=0
for lock in "${locks[@]}"; do
fuser "$lock" >/dev/null 2>&1 && busy=1 || true
done
[[ "$busy" == "0" ]] && return 0
sleep 2
done
die "APT занят другим процессом. Остановите apt/dpkg и повторите установку."
}
apt_install() {
wait_for_apt
apt-get install -y -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" "$@"
}
step "Системные пакеты"
wait_for_apt
apt-get update -qq
apt_install \
ca-certificates curl wget git nano htop unzip gnupg lsb-release \
build-essential libssl-dev libffi-dev libpq-dev \
python3 python3-venv python3-dev python3-pip \
postgresql postgresql-client \
redis-server nginx \
certbot python3-certbot-nginx \
ufw fail2ban
PYTHON_BIN="$(command -v python3)"
log "Системные пакеты установлены без apt upgrade"
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v 2>/dev/null | cut -d. -f1)" != "v20" ]]; then
info "Устанавливаем Node.js 20 LTS..."
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt_install nodejs
fi
log "Node.js $(node -v) готов"
step "Системный пользователь"
id "$APP_USER" >/dev/null 2>&1 || useradd -m -s /bin/bash "$APP_USER"
log "Пользователь ${APP_USER} готов"
step "PostgreSQL"
systemctl enable --now postgresql
su - postgres -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'\"" | grep -q 1 || su - postgres -c "createdb ${DB_NAME}"
su - postgres -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'\"" | grep -q 1 || su - postgres -c "psql -c \"CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}'\""
su - postgres -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}'\""
su - postgres -c "psql -c \"GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER}\""
su - postgres -c "psql -c \"ALTER DATABASE ${DB_NAME} OWNER TO ${DB_USER}\""
log "PostgreSQL: база ${DB_NAME} готова"
step "Redis"
systemctl enable --now redis-server
redis-cli ping | grep -q PONG && log "Redis работает"
step "Структура директорий"
mkdir -p "${APP_DIR}"/{backend,frontend,logs,uploads,backups}
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
log "Директории созданы"
step "Конфигурация (.env)"
cat > "${APP_DIR}/.env" << ENV
APP_DIR=${APP_DIR}
DEBUG=false
SECRET_KEY=${SECRET_KEY}
DATABASE_URL=postgresql+asyncpg://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}
DB_POOL_SIZE=20
REDIS_URL=redis://127.0.0.1:6379/0
PLATFORM_BOT_TOKEN=${PLATFORM_BOT_TOKEN}
PLATFORM_ADMIN_IDS=${PLATFORM_ADMIN_IDS}
API_HOST=127.0.0.1
API_PORT=${API_PORT}
DOMAIN=${DOMAIN}
ALLOWED_ORIGINS=http://localhost:3000${DOMAIN:+,https://$DOMAIN,http://$DOMAIN}
UPLOAD_DIR=${APP_DIR}/uploads
MAX_UPLOAD_MB=10
COMMISSION_TRIAL_WEEK=${COMM_TRIAL_WEEK}
COMMISSION_TRIAL=${COMM_TRIAL}
COMMISSION_BASIC=${COMM_BASIC}
COMMISSION_PRO=${COMM_PRO}
COMMISSION_ENTERPRISE=${COMM_ENTERPRISE}
COMMISSION_POSTPAID_DEFAULT=${COMM_POSTPAID_DEFAULT}
POSTPAID_DEFAULT_DUE_DAY=${POSTPAID_DUE_DAY}
ENV
chmod 600 "${APP_DIR}/.env"
log ".env создан"
step "Копирование backend"
rsync -a --delete "${SCRIPT_DIR}/backend/" "${APP_DIR}/backend/"
PY_COUNT=$(find "${APP_DIR}/backend" -name "*.py" | wc -l)
log "Backend скопирован (${PY_COUNT} Python-файлов)"
step "Python виртуальное окружение"
rm -rf "$VENV"
"$PYTHON_BIN" -m venv "$VENV"
# shellcheck disable=SC1091
source "${VENV}/bin/activate"
pip install -q --upgrade pip wheel setuptools
pip install -q -r "${APP_DIR}/backend/requirements.txt"
deactivate
log "Python venv готов: $(${VENV}/bin/python --version)"
step "Frontend (React + Vite)"
rsync -a --delete "${SCRIPT_DIR}/frontend/" "${FRONT_DIR}/"
cat > "${FRONT_DIR}/.env.production" << FENV
VITE_API_URL=/api
FENV
npm install --prefix "$FRONT_DIR" --silent --no-fund --no-audit
npm run build --prefix "$FRONT_DIR" --silent
log "Frontend собран"
step "Nginx"
mkdir -p /var/www/certbot
NGINX_CONF="/etc/nginx/sites-available/botfactory"
SERVER_NAMES="_"
[[ -n "$DOMAIN" ]] && SERVER_NAMES="$DOMAIN"
[[ -n "$DOMAIN" && "$WITH_WWW" == "yes" ]] && SERVER_NAMES="$SERVER_NAMES www.$DOMAIN"
cat > "$NGINX_CONF" << NGINX
server {
listen 80;
server_name ${SERVER_NAMES};
client_max_body_size 20M;
gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
root ${FRONT_DIR}/dist;
try_files \$uri \$uri/ /index.html;
add_header Cache-Control "no-cache";
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
root ${FRONT_DIR}/dist;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /api/ {
proxy_pass http://127.0.0.1:${API_PORT};
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
proxy_read_timeout 120s;
}
location /uploads/ {
alias ${APP_DIR}/uploads/;
expires 7d;
add_header Cache-Control "public";
}
}
NGINX
rm -f /etc/nginx/sites-enabled/default
ln -sf "$NGINX_CONF" /etc/nginx/sites-enabled/botfactory
nginx -t || die "Ошибка конфигурации Nginx"
systemctl enable nginx
systemctl restart nginx
log "Nginx запущен"
check_dns() {
local host="$1" expected="$2"
local ip
ip="$(getent ahostsv4 "$host" 2>/dev/null | awk '{print $1}' | head -1 || true)"
[[ -z "$ip" ]] && { warn "$host не резолвится. Настройте A-запись → $expected"; return 1; }
[[ "$ip" != "$expected" ]] && { warn "$host указывает на $ip, а сервер: $expected"; return 1; }
log "DNS OK: $host$ip"
return 0
}
step "SSL — Let's Encrypt"
run_certbot() {
local d_args=(-d "$DOMAIN")
check_dns "$DOMAIN" "$SERVER_IP" || return 1
if [[ "$WITH_WWW" == "yes" ]]; then
check_dns "www.$DOMAIN" "$SERVER_IP" || return 1
d_args+=(-d "www.$DOMAIN")
fi
certbot --nginx "${d_args[@]}" \
--email "$CERTBOT_EMAIL" \
--agree-tos --no-eff-email \
--redirect --non-interactive
}
if [[ -n "$DOMAIN" && "$SETUP_SSL" == "yes" ]]; then
if run_certbot; then
systemctl reload nginx
systemctl enable --now certbot.timer >/dev/null 2>&1 || true
log "SSL сертификат получен"
else
warn "SSL не получен. Исправьте DNS и запустите: certbot --nginx -d ${DOMAIN} --email ${CERTBOT_EMAIL} --agree-tos --redirect"
fi
elif [[ -n "$DOMAIN" ]]; then
warn "SSL пропущен. После DNS: certbot --nginx -d ${DOMAIN} --email ${CERTBOT_EMAIL} --agree-tos --redirect"
else
warn "SSL недоступен без домена"
fi
step "Systemd сервисы"
cat > /etc/systemd/system/botfactory-api.service << SERVICE
[Unit]
Description=BotFactory API (FastAPI)
After=network.target postgresql.service redis-server.service
Requires=postgresql.service redis-server.service
[Service]
Type=exec
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}/backend
Environment=PYTHONPATH=${APP_DIR}/backend
EnvironmentFile=${APP_DIR}/.env
ExecStart=${VENV}/bin/uvicorn main:app --host 127.0.0.1 --port ${API_PORT} --workers 1 --loop uvloop --log-level info
Restart=always
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5
StandardOutput=append:${APP_DIR}/logs/api.log
StandardError=append:${APP_DIR}/logs/api-error.log
[Install]
WantedBy=multi-user.target
SERVICE
cat > /etc/systemd/system/botfactory-bots.service << SERVICE
[Unit]
Description=BotFactory Bots Runner (platform + shops)
After=botfactory-api.service
Wants=botfactory-api.service
[Service]
Type=exec
User=${APP_USER}
Group=${APP_USER}
WorkingDirectory=${APP_DIR}/backend
Environment=PYTHONPATH=${APP_DIR}/backend
EnvironmentFile=${APP_DIR}/.env
ExecStart=${VENV}/bin/python shop_bots_runner.py
Restart=always
RestartSec=10
StartLimitIntervalSec=60
StartLimitBurst=5
StandardOutput=append:${APP_DIR}/logs/bots.log
StandardError=append:${APP_DIR}/logs/bots-error.log
[Install]
WantedBy=multi-user.target
SERVICE
systemctl daemon-reload
systemctl enable botfactory-api botfactory-bots
systemctl restart botfactory-api && log "API запущен" || warn "API не стартовал — см. journalctl -u botfactory-api"
systemctl restart botfactory-bots && log "Боты запущены" || warn "Боты не стартовали — см. journalctl -u botfactory-bots"
step "Firewall (UFW)"
ufw allow 22/tcp comment "SSH" >/dev/null || true
ufw allow 80/tcp comment "HTTP" >/dev/null || true
ufw allow 443/tcp comment "HTTPS" >/dev/null || true
ufw --force enable >/dev/null || warn "UFW не удалось включить автоматически"
log "UFW: правила SSH + HTTP + HTTPS добавлены без reset"
step "Fail2ban"
cat > /etc/fail2ban/jail.local << F2B
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
ignoreip = 127.0.0.1/8
[sshd]
enabled = true
[nginx-http-auth]
enabled = true
F2B
systemctl enable --now fail2ban || warn "Fail2ban не стартовал"
log "Fail2ban настроен"
step "Права доступа"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
chmod 750 "$APP_DIR"
chmod 600 "$APP_DIR/.env"
chmod -R 755 "${APP_DIR}/backend" "${APP_DIR}/frontend"
chmod -R 770 "${APP_DIR}/logs" "${APP_DIR}/uploads" "${APP_DIR}/backups"
log "Права выставлены"
step "Logrotate"
cat > /etc/logrotate.d/botfactory << LR
${APP_DIR}/logs/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0640 ${APP_USER} ${APP_USER}
}
LR
log "Logrotate настроен"
step "Автоматическое резервное копирование"
cat > "${APP_DIR}/backups/backup.sh" << BACKUP
#!/usr/bin/env bash
set -euo pipefail
DIR="${APP_DIR}/backups"
DATE=\$(date +%Y%m%d_%H%M%S)
PGPASSWORD="${DB_PASS}" pg_dump -U ${DB_USER} -h 127.0.0.1 ${DB_NAME} | gzip > "\${DIR}/db_\${DATE}.sql.gz"
tar -czf "\${DIR}/uploads_\${DATE}.tar.gz" -C ${APP_DIR} uploads/ 2>/dev/null || true
find "\${DIR}" -name "*.gz" -mtime +30 -delete 2>/dev/null || true
echo "[\$(date '+%Y-%m-%d %H:%M:%S')] Backup OK: \${DATE}"
BACKUP
chmod +x "${APP_DIR}/backups/backup.sh"
chown "$APP_USER:$APP_USER" "${APP_DIR}/backups/backup.sh"
( crontab -l 2>/dev/null | grep -v "botfactory/backups"; echo "0 2 * * * ${APP_DIR}/backups/backup.sh >> ${APP_DIR}/logs/backup.log 2>&1" ) | crontab -
log "Бекап: ежедневно в 02:00"
cat > "${APP_DIR}/install-info.txt" << INFO
BotFactory — параметры установки
Дата: $(date '+%Y-%m-%d %H:%M:%S')
Директория: ${APP_DIR}
Server IP: ${SERVER_IP}
Домен: ${DOMAIN:-нет}
SSL requested: ${SETUP_SSL}
DB User: ${DB_USER}
DB Name: ${DB_NAME}
DB Pass: ${DB_PASS}
Secret Key: ${SECRET_KEY}
API Port: ${API_PORT}
Комиссии: trial_week=${COMM_TRIAL_WEEK}% trial=${COMM_TRIAL}% basic=${COMM_BASIC}% pro=${COMM_PRO}% enterprise=${COMM_ENTERPRISE}% postpaid_default=${COMM_POSTPAID_DEFAULT}%
Постоплата default due day: ${POSTPAID_DUE_DAY}
INFO
chmod 600 "${APP_DIR}/install-info.txt"
chown "$APP_USER:$APP_USER" "${APP_DIR}/install-info.txt"
ELAPSED=$(( $(date +%s) - START_TIME ))
clear
echo -e "${B}${G}"
echo " ╔═══════════════════════════════════════════╗"
echo " ║ BotFactory успешно установлен! ✓ ║"
printf " ║ Время установки: %-4s сек. ║\n" "${ELAPSED}"
echo " ╚═══════════════════════════════════════════╝"
echo -e "${NC}"
echo -e "${B}▸ Доступ:${NC}"
if [[ -n "$DOMAIN" && -d "/etc/letsencrypt/live/${DOMAIN}" ]]; then
echo -e " ${G}${NC} Панель: ${C}https://${DOMAIN}${NC}"
echo -e " ${G}${NC} API: ${C}https://${DOMAIN}/api${NC}"
else
echo -e " ${G}${NC} Панель: ${C}http://${DOMAIN:-$SERVER_IP}${NC}"
echo -e " ${G}${NC} API: ${C}http://${DOMAIN:-$SERVER_IP}/api${NC}"
fi
echo -e "\n${B}▸ Управление:${NC}"
echo -e " ${DIM}systemctl status botfactory-api${NC}"
echo -e " ${DIM}systemctl status botfactory-bots${NC}"
echo -e " ${DIM}journalctl -u botfactory-api -f${NC}"
echo -e " ${DIM}journalctl -u botfactory-bots -f${NC}"
echo -e "\n${G}${B}⚠ Сохраните ${APP_DIR}/install-info.txt в надёжное место.${NC}\n"