"""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"🏪 {self.name}\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"📊 {self.name} · {datetime.now():%d.%m.%Y %H:%M}\n{'─'*26}\n\n"
f"📦 Заказов: {ordn} | ⏳ Ждут: {pend}\n"
f"💰 Оборот: {fmt(rev)}\n"
f"📊 Комиссия: {fmt(comm)}\n"
f"💵 Мой доход: {fmt(rev-comm)}\n\n"
f"🛍 Товаров: {prods} | 💳 Карт: {cards}\n\n"
f"{be} Баланс BotFactory: {fmt(tenant.balance)}",
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"📦 Заказы на проверке ({len(ords)}):\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"📦 Заказ #{o.id}\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"✅ Заказ #{oid} подтверждён. Товар выдан, {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 = "📋 Последние выполненные:\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"🛍 Товары ({len(prods)}):\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("🛍 Название товара:", 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"✅ Товар {data['name']} добавлен!", 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"🛍 {p.name}\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="💳 Пул карт:\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"💳 {c.bank}\nНомер: {c.number}\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="👥 Кассиры:\n\n"
bld=InlineKeyboardBuilder()
for m in members:
txt+=f"💼 {m.name} · {m.username}\nTG: {m.telegram_id}\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="⚙️ Токены бота магазина:\n\n"
bld=InlineKeyboardBuilder()
for t in toks:
em="🟢" if t.is_active else "⬛"
txt+=f"{em} {t.username} [{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"💰 Баланс BotFactory\n{be} {fmt(tenant.balance)}\n"
f"⚠️ Порог: {fmt(tenant.alert_threshold)}\n\n")
if tenant.balance<=0: txt+="🔴 Боты приостановлены!\n\n"
if txs:
txt+="Последние транзакции:\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(
"💳 Реквизиты для пополнения BotFactory\n\n"
"🏦 Тинькофф: 5536 **** **** 0001\n"
"👤 ООО БОТФАКТОРИ\n\n"
"📝 Назначение: Пополнение BotFactory\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"✅ Оплата подтверждена!\n\nЗаказ #{order_id}\n\n"
f"{content}", 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"🔔 Новый платёж!\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()