Skip to content

Commit ee6e19c

Browse files
author
Derssen
committed
Initial commit
0 parents  commit ee6e19c

File tree

10 files changed

+902
-0
lines changed

10 files changed

+902
-0
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.env
2+
__pycache__/
3+
*.sqlite3
4+
venv/
5+
.DS_Store
6+
.github
7+
*.zip

config.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import os
2+
from dotenv import load_dotenv
3+
4+
# Загружаем переменные из .env
5+
load_dotenv()
6+
7+
class Settings:
8+
# Telegram
9+
BOT_TOKEN: str = os.getenv("BOT_TOKEN")
10+
TARGET_CHAT_ID: int = int(os.getenv("TARGET_CHAT_ID", -1)) # Важный ID чата
11+
12+
# Database
13+
DATABASE_URL: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///balances.sqlite3")
14+
15+
# API Credentials
16+
ZADARMA_KEY: str = os.getenv("ZADARMA_KEY")
17+
ZADARMA_SECRET: str = os.getenv("ZADARMA_SECRET")
18+
WAZZUP_TOKEN: str = os.getenv("WAZZUP_TOKEN")
19+
DIDWW_KEY: str = os.getenv("DIDWW_KEY")
20+
21+
# Constants
22+
LOW_BALANCE_THRESHOLD: float = 10.0
23+
MIN_TOP_UP_AMOUNT: float = 5.0
24+
CALLII_DAILY_COST: float = float(os.getenv("CALLII_DAILY_COST", 2.2))
25+
WAZZUP_DAILY_COST: float = float(os.getenv("WAZZUP_DAILY_COST", 400.0))
26+
STREAMTELE_MONTHLY_FEE: float = float(os.getenv("STREAMTELE_MONTHLY_FEE", 1500.0))
27+
WAZZUP_MONTHLY_FEE: float = float(os.getenv("WAZZUP_MONTHLY_FEE", 6000.0))
28+
DIDWW_MONTHLY_FEE: float = float(os.getenv("DIDWW_MONTHLY_FEE", 45.0))
29+
WAZZUP_PHONE: str = os.getenv("WAZZUP_PHONE", "+6281239838440")
30+
SERVICE_CURRENCIES: dict = {
31+
'Zadarma': 'USD',
32+
'Wazzup24 Подписка': 'RUB',
33+
'Wazzup24 Баланс номера': 'RUB',
34+
'DIDWW': 'USD',
35+
'Streamtele': 'UAH',
36+
'Callii': 'USD',
37+
}
38+
CURRENCY_SIGNS: dict = {
39+
'USD': '$',
40+
'UAH': '₴',
41+
'RUB': '₽',
42+
}
43+
# Статус активности API-сервисов
44+
API_SERVICE_STATUSES: dict = {
45+
'Zadarma': os.getenv("ZADARMA_ENABLED", "True").lower() in ('true', '1', 't'),
46+
'DIDWW': os.getenv("DIDWW_ENABLED", "True").lower() in ('true', '1', 't'),
47+
}
48+
49+
SETTINGS = Settings()
50+
51+
# Проверка, что критические переменные загружены
52+
if not SETTINGS.BOT_TOKEN or SETTINGS.TARGET_CHAT_ID == -1:
53+
raise ValueError("BOT_TOKEN or TARGET_CHAT_ID is not configured correctly.")

db/models.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, text
2+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
3+
from sqlalchemy.orm import sessionmaker, declarative_base
4+
from datetime import datetime
5+
import pytz
6+
from config import SETTINGS
7+
8+
# Используем вашу таймзону
9+
TIMEZONE = pytz.timezone('Europe/Kyiv')
10+
11+
Base = declarative_base()
12+
13+
class Service(Base):
14+
__tablename__ = 'services'
15+
16+
id = Column(Integer, primary_key=True)
17+
name = Column(String, unique=True, nullable=False)
18+
19+
# Мониторинг баланса
20+
last_balance = Column(Float, default=0.0)
21+
low_balance_alert_sent = Column(Boolean, default=False)
22+
currency = Column(String, default='USD')
23+
daily_cost = Column(Float, nullable=True)
24+
monthly_fee = Column(Float, nullable=True)
25+
26+
# Для Callii и Streamtele
27+
next_alert_date = Column(DateTime, nullable=True)
28+
next_monthly_alert = Column(DateTime, nullable=True)
29+
30+
def __repr__(self):
31+
return f"<Service(name='{self.name}', balance={self.last_balance})>"
32+
33+
# Инициализация
34+
async def init_db(database_url: str):
35+
engine = create_async_engine(database_url, echo=False)
36+
async with engine.begin() as conn:
37+
await conn.run_sync(Base.metadata.create_all)
38+
39+
AsyncSessionLocal = sessionmaker(
40+
autocommit=False,
41+
autoflush=False,
42+
bind=engine,
43+
class_=AsyncSession,
44+
expire_on_commit=False,
45+
)
46+
return AsyncSessionLocal
47+
48+
# Вспомогательная функция для добавления начальных данных
49+
async def initialize_services(SessionLocal):
50+
async with SessionLocal() as session:
51+
# --- simple sqlite migration to add new columns if missing ---
52+
pragma_stmt = text("PRAGMA table_info(services)")
53+
result = await session.execute(pragma_stmt)
54+
columns = {row[1] for row in result.fetchall()}
55+
56+
alter_statements = []
57+
if 'currency' not in columns:
58+
alter_statements.append("ALTER TABLE services ADD COLUMN currency VARCHAR")
59+
if 'daily_cost' not in columns:
60+
alter_statements.append("ALTER TABLE services ADD COLUMN daily_cost FLOAT")
61+
if 'monthly_fee' not in columns:
62+
alter_statements.append("ALTER TABLE services ADD COLUMN monthly_fee FLOAT")
63+
if 'next_monthly_alert' not in columns:
64+
alter_statements.append("ALTER TABLE services ADD COLUMN next_monthly_alert DATETIME")
65+
66+
for stmt in alter_statements:
67+
try:
68+
await session.execute(text(stmt))
69+
except Exception:
70+
# ignore if already exists or other minor issues
71+
pass
72+
if alter_statements:
73+
await session.commit()
74+
75+
services_to_add = [
76+
# API сервисы
77+
{
78+
'name': 'Zadarma',
79+
'last_balance': 0.0,
80+
'currency': SETTINGS.SERVICE_CURRENCIES.get('Zadarma', 'USD'),
81+
},
82+
# Wazzup разделён: подписка (ежемесячно) и баланс номера (ежедневный расход)
83+
{
84+
'name': 'Wazzup24 Подписка',
85+
'last_balance': 0.0,
86+
'currency': SETTINGS.SERVICE_CURRENCIES.get('Wazzup24 Подписка', 'RUB'),
87+
'monthly_fee': SETTINGS.WAZZUP_MONTHLY_FEE,
88+
'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
89+
},
90+
{
91+
'name': 'Wazzup24 Баланс номера',
92+
'last_balance': 0.0,
93+
'currency': SETTINGS.SERVICE_CURRENCIES.get('Wazzup24 Баланс номера', 'RUB'),
94+
'daily_cost': SETTINGS.WAZZUP_DAILY_COST,
95+
'next_alert_date': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
96+
},
97+
{
98+
'name': 'DIDWW',
99+
'last_balance': 0.0,
100+
'currency': SETTINGS.SERVICE_CURRENCIES.get('DIDWW', 'USD'),
101+
'monthly_fee': SETTINGS.DIDWW_MONTHLY_FEE,
102+
'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 20, 10, 0)),
103+
},
104+
# Callii (Управляемый FSM)
105+
{
106+
'name': 'Callii',
107+
'next_alert_date': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
108+
'currency': SETTINGS.SERVICE_CURRENCIES.get('Callii', 'USD'),
109+
'daily_cost': SETTINGS.CALLII_DAILY_COST,
110+
},
111+
# Streamtele (Ежемесячное напоминание)
112+
{
113+
'name': 'Streamtele',
114+
'currency': SETTINGS.SERVICE_CURRENCIES.get('Streamtele', 'UAH'),
115+
'monthly_fee': SETTINGS.STREAMTELE_MONTHLY_FEE,
116+
'next_monthly_alert': TIMEZONE.localize(datetime(2025, 12, 11, 10, 0)),
117+
},
118+
]
119+
120+
from sqlalchemy.future import select # Дополнительный импорт нужен для 'select'
121+
122+
for data in services_to_add:
123+
# ИСПРАВЛЕНО: Используем select и where для поиска по уникальному полю 'name'
124+
stmt = select(Service).filter(Service.name == data['name'])
125+
result = await session.execute(stmt)
126+
exists = result.scalar_one_or_none()
127+
128+
if not exists:
129+
session.add(Service(name=data['name'], **{k: v for k, v in data.items() if k != 'name'}))
130+
131+
await session.commit()

handlers/balance.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from aiogram import Router
2+
from aiogram.filters import Command
3+
from aiogram.types import Message
4+
from sqlalchemy.ext.asyncio import AsyncSession
5+
from sqlalchemy.future import select
6+
from db.models import Service, TIMEZONE
7+
from services.api_clients import API_CLIENTS
8+
from config import SETTINGS # Новый импорт
9+
10+
router = Router()
11+
12+
CURRENCY_SIGNS = SETTINGS.CURRENCY_SIGNS
13+
MANUAL_APPROX_VALUES = {
14+
'Streamtele': SETTINGS.STREAMTELE_MONTHLY_FEE,
15+
'Callii': 10.0,
16+
'Wazzup24 Подписка': SETTINGS.WAZZUP_MONTHLY_FEE,
17+
'Wazzup24 Баланс номера': SETTINGS.WAZZUP_DAILY_COST,
18+
}
19+
MANUAL_SERVICES = ('Streamtele', 'Callii', 'Wazzup24 Подписка', 'Wazzup24 Баланс номера')
20+
21+
@router.message(Command("balance"))
22+
async def handle_balance_command(message: Message, session: AsyncSession):
23+
24+
response_parts = ["💰 **Текущие балансы сервисов:**"]
25+
26+
# --- 1. Проверка API-сервисов ---
27+
for service_name, client in API_CLIENTS.items():
28+
29+
if not SETTINGS.API_SERVICE_STATUSES.get(service_name, False):
30+
# Сервис отключен
31+
response_parts.append(f"• **{service_name}:** _Отключен в конфигурации_ 🚫")
32+
continue
33+
34+
try:
35+
current_balance = await client.get_balance()
36+
if current_balance is None:
37+
response_parts.append(f"• **{service_name}:** _Баланс через API недоступен_ ⚙️")
38+
else:
39+
currency = SETTINGS.SERVICE_CURRENCIES.get(service_name, 'USD')
40+
symbol = CURRENCY_SIGNS.get(currency, currency)
41+
response_parts.append(f"• **{service_name}:** `{symbol}{current_balance:.2f}` (API)")
42+
except Exception as e:
43+
response_parts.append(f"• **{service_name}:** Ошибка API (см. логи)")
44+
45+
# --- 2. Проверка ручных сервисов ---
46+
stmt = select(Service).where(Service.name.in_(MANUAL_SERVICES))
47+
manual_result = await session.execute(stmt)
48+
manual_services = {service.name: service for service in manual_result.scalars()}
49+
50+
for name in MANUAL_SERVICES:
51+
approx = MANUAL_APPROX_VALUES.get(name, 0.0)
52+
service = manual_services.get(name)
53+
currency = SETTINGS.SERVICE_CURRENCIES.get(name, 'USD')
54+
symbol = CURRENCY_SIGNS.get(currency, currency)
55+
56+
if name == 'Callii':
57+
next_date = service.next_alert_date.astimezone(TIMEZONE).strftime('%Y-%m-%d') if service and service.next_alert_date else "N/A"
58+
response_parts.append(
59+
f"• **{name}:** `{symbol}{approx:.2f}` (примерно)\n"
60+
f" _След. оплата:_ **{next_date}**"
61+
)
62+
elif name == 'Streamtele':
63+
next_monthly = service.next_monthly_alert.astimezone(TIMEZONE).strftime('%Y-%m-%d') if service and service.next_monthly_alert else "N/A"
64+
response_parts.append(
65+
f"• **{name}:** Подписка: `{symbol}{approx:.2f}`)\n"
66+
f" _След. оплата:_ **{next_monthly}**"
67+
)
68+
elif name == 'Wazzup24 Подписка':
69+
next_monthly = service.next_monthly_alert.astimezone(TIMEZONE).strftime('%Y-%m-%d') if service and service.next_monthly_alert else "N/A"
70+
response_parts.append(
71+
f"• **{name}:** `{symbol}{approx:.2f}`\n"
72+
f" _След. оплата:_ **{next_monthly}**"
73+
)
74+
elif name == 'Wazzup24 Баланс номера':
75+
next_daily = service.next_alert_date.astimezone(TIMEZONE).strftime('%Y-%m-%d') if service and service.next_alert_date else "N/A"
76+
current_balance = service.last_balance if service and service.last_balance is not None else approx
77+
response_parts.append(
78+
f"• **{name}:** `{symbol}{current_balance:.1f}`\n"
79+
f" _След. оплата:_ **{next_daily}**"
80+
)
81+
82+
83+
await message.answer('\n'.join(response_parts))

handlers/callii.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from aiogram import Router, F, Bot
2+
from aiogram.types import CallbackQuery, Message
3+
from aiogram.fsm.context import FSMContext
4+
from aiogram.fsm.state import State, StatesGroup
5+
from sqlalchemy.ext.asyncio import AsyncSession
6+
from sqlalchemy.future import select
7+
from db.models import Service, TIMEZONE
8+
from config import SETTINGS
9+
from datetime import datetime, timedelta
10+
import math
11+
12+
router = Router()
13+
14+
# FSM States
15+
class CalliiPayment(StatesGroup):
16+
waiting_for_amount = State()
17+
18+
@router.callback_query(F.data == "callii_paid")
19+
async def process_callii_paid(callback: CallbackQuery, state: FSMContext):
20+
"""Обработка нажатия кнопки 'Оплатил'."""
21+
await callback.message.edit_text(
22+
"Спасибо за оплату. **Введите сумму пополнения (числом в USD):**"
23+
)
24+
await state.set_state(CalliiPayment.waiting_for_amount)
25+
await callback.answer() # Убираем "часы" с кнопки
26+
27+
@router.message(CalliiPayment.waiting_for_amount, F.text.regexp(r'^\d+(\.\d{1,2})?$'))
28+
async def process_callii_amount(message: Message, state: FSMContext, session: AsyncSession):
29+
"""Обработка ввода суммы пополнения и расчет следующей даты."""
30+
31+
try:
32+
amount = float(message.text)
33+
daily_cost = SETTINGS.CALLII_DAILY_COST
34+
35+
# Расчет дней (округляем до меньшего)
36+
days = math.floor(amount / daily_cost)
37+
38+
if days < 1:
39+
await message.answer(
40+
"Сумма слишком мала для покрытия дневного расхода. Пожалуйста, введите сумму, достаточную для хотя бы одного дня."
41+
)
42+
return
43+
44+
# Расчет следующей даты
45+
next_alert_datetime = datetime.now(TIMEZONE) + timedelta(days=days)
46+
# Устанавливаем время оповещения на 10:00
47+
next_alert_datetime = next_alert_datetime.replace(hour=10, minute=0, second=0, microsecond=0)
48+
49+
# Обновление БД
50+
stmt = select(Service).where(Service.name == 'Callii')
51+
result = await session.execute(stmt)
52+
service = result.scalar_one()
53+
54+
service.next_alert_date = next_alert_datetime
55+
await session.commit()
56+
57+
await message.answer(
58+
f"💰 **Платеж Callii обработан!**\n"
59+
f"Сумма: **${amount:.2f}**\n"
60+
f"Дней покрытия (расход ${daily_cost}/день): **{days}**.\n"
61+
f"Следующий контроль баланса запланирован на **{next_alert_datetime.strftime('%Y-%m-%d в 10:00')}**."
62+
)
63+
await state.clear()
64+
65+
except ValueError:
66+
await message.answer("Ошибка: Пожалуйста, введите сумму пополнения как число.")
67+
except Exception as e:
68+
await message.answer(f"Произошла ошибка при сохранении данных: {e}")
69+
await state.clear()
70+
71+
72+
@router.message(CalliiPayment.waiting_for_amount)
73+
async def process_callii_amount_invalid(message: Message):
74+
"""Обработка невалидного ввода суммы."""
75+
await message.answer("Неверный формат. Пожалуйста, введите сумму числом (например, 50 или 50.50).")

0 commit comments

Comments
 (0)