Исправлен установщик и добавлены тарифы BotFactory v2.1
Этот коммит содержится в:
@@ -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()
|
||||
Ссылка в новой задаче
Block a user