Sadeep Sachintha commited on
Commit ·
7803d4e
0
Parent(s):
first commit
Browse files- .gitignore +26 -0
- Dockerfile +25 -0
- README.md +0 -0
- bot/__init__.py +1 -0
- bot/handlers.py +112 -0
- bot/scheduler.py +60 -0
- core/__init__.py +1 -0
- core/config.py +13 -0
- db/__init__.py +1 -0
- db/models.py +41 -0
- db/session.py +36 -0
- main.py +64 -0
- requirements.txt +10 -0
- services/__init__.py +1 -0
- services/fx_service.py +51 -0
.gitignore
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Environments
|
| 2 |
+
.env
|
| 3 |
+
.venv
|
| 4 |
+
env/
|
| 5 |
+
venv/
|
| 6 |
+
ENV/
|
| 7 |
+
env.bak/
|
| 8 |
+
venv.bak/
|
| 9 |
+
|
| 10 |
+
# Byte-compiled / optimized / DLL files
|
| 11 |
+
__pycache__/
|
| 12 |
+
*.py[cod]
|
| 13 |
+
*$py.class
|
| 14 |
+
|
| 15 |
+
# SQLite
|
| 16 |
+
*.db
|
| 17 |
+
*.sqlite3
|
| 18 |
+
|
| 19 |
+
# OS generated files
|
| 20 |
+
.DS_Store
|
| 21 |
+
.DS_Store?
|
| 22 |
+
._*
|
| 23 |
+
.Spotlight-V100
|
| 24 |
+
.Trashes
|
| 25 |
+
ehthumbs.db
|
| 26 |
+
Thumbs.db
|
Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use the official Python base image
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Install system dependencies (required for some Python packages like asyncpg)
|
| 8 |
+
RUN apt-get update && apt-get install -y \
|
| 9 |
+
gcc \
|
| 10 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
+
|
| 12 |
+
# Copy requirements file
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
|
| 15 |
+
# Install Python dependencies
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy the rest of the application
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Expose the port FastAPI will run on (Hugging Face Spaces defaults to 7860)
|
| 22 |
+
EXPOSE 7860
|
| 23 |
+
|
| 24 |
+
# Command to run the application
|
| 25 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
Binary file (26 Bytes). View file
|
|
|
bot/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Init for bot module
|
bot/handlers.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiogram import Router, types
|
| 2 |
+
from aiogram.filters import Command
|
| 3 |
+
from services.fx_service import fx_service, ALLOWED_CURRENCIES
|
| 4 |
+
from db.session import get_session
|
| 5 |
+
from db.models import User, Subscription, Threshold
|
| 6 |
+
from sqlalchemy import select
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
router = Router()
|
| 11 |
+
|
| 12 |
+
@router.message(Command("start"))
|
| 13 |
+
async def cmd_start(message: types.Message):
|
| 14 |
+
"""Registers user and sends welcome message."""
|
| 15 |
+
chat_id = message.chat.id
|
| 16 |
+
# Create an async session explicitly since we're in a handler, not a FastAPI endpoint
|
| 17 |
+
from db.session import async_session
|
| 18 |
+
async with async_session() as session:
|
| 19 |
+
result = await session.execute(select(User).where(User.chat_id == chat_id))
|
| 20 |
+
user = result.scalar_one_or_none()
|
| 21 |
+
if not user:
|
| 22 |
+
new_user = User(chat_id=chat_id)
|
| 23 |
+
session.add(new_user)
|
| 24 |
+
await session.commit()
|
| 25 |
+
|
| 26 |
+
currencies_str = ", ".join(ALLOWED_CURRENCIES)
|
| 27 |
+
await message.answer(
|
| 28 |
+
f"Welcome to FlyRates! 🌍💸\n\n"
|
| 29 |
+
f"I can track real-time exchange rates for you.\n"
|
| 30 |
+
f"Supported Currencies: {currencies_str}\n\n"
|
| 31 |
+
f"Commands:\n"
|
| 32 |
+
f"/current <base> <target> - Get live rate (e.g., /current USD EUR)\n"
|
| 33 |
+
f"/subscribe <base> <target> <daily/hourly> - Get automated updates\n"
|
| 34 |
+
f"/threshold <base> <target> << or >> <value> - Get alerts (e.g., /threshold USD EUR < 0.90)"
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
@router.message(Command("current"))
|
| 38 |
+
async def cmd_current(message: types.Message):
|
| 39 |
+
"""Fetches the current exchange rate."""
|
| 40 |
+
args = message.text.split()[1:]
|
| 41 |
+
if len(args) != 2:
|
| 42 |
+
await message.answer("Usage: /current <base_currency> <target_currency>\nExample: /current USD EUR")
|
| 43 |
+
return
|
| 44 |
+
|
| 45 |
+
base, target = args[0].upper(), args[1].upper()
|
| 46 |
+
rate = await fx_service.get_rate(base, target)
|
| 47 |
+
|
| 48 |
+
if rate:
|
| 49 |
+
await message.answer(f"📈 Current Rate:\n1 {base} = {rate} {target}")
|
| 50 |
+
else:
|
| 51 |
+
await message.answer("❌ Failed to fetch rate. Please check if the currencies are supported or try again later.")
|
| 52 |
+
|
| 53 |
+
@router.message(Command("subscribe"))
|
| 54 |
+
async def cmd_subscribe(message: types.Message):
|
| 55 |
+
"""Subscribes the user to periodic updates."""
|
| 56 |
+
args = message.text.split()[1:]
|
| 57 |
+
if len(args) not in [2, 3]:
|
| 58 |
+
await message.answer("Usage: /subscribe <base> <target> [daily|hourly]\nExample: /subscribe USD EUR daily")
|
| 59 |
+
return
|
| 60 |
+
|
| 61 |
+
base, target = args[0].upper(), args[1].upper()
|
| 62 |
+
freq = args[2].lower() if len(args) == 3 else "daily"
|
| 63 |
+
|
| 64 |
+
if not fx_service.is_valid_currency(base) or not fx_service.is_valid_currency(target):
|
| 65 |
+
await message.answer("❌ Invalid currency pair.")
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
if freq not in ["daily", "hourly"]:
|
| 69 |
+
await message.answer("❌ Frequency must be 'daily' or 'hourly'.")
|
| 70 |
+
return
|
| 71 |
+
|
| 72 |
+
from db.session import async_session
|
| 73 |
+
async with async_session() as session:
|
| 74 |
+
sub = Subscription(chat_id=message.chat.id, base_currency=base, target_currency=target, frequency=freq)
|
| 75 |
+
session.add(sub)
|
| 76 |
+
await session.commit()
|
| 77 |
+
|
| 78 |
+
await message.answer(f"✅ Subscribed to {freq} updates for {base} to {target}.")
|
| 79 |
+
|
| 80 |
+
@router.message(Command("threshold"))
|
| 81 |
+
async def cmd_threshold(message: types.Message):
|
| 82 |
+
"""Sets a threshold alert."""
|
| 83 |
+
args = message.text.split()[1:]
|
| 84 |
+
if len(args) != 4:
|
| 85 |
+
await message.answer("Usage: /threshold <base> <target> <condition> <value>\nExample: /threshold USD EUR < 0.90")
|
| 86 |
+
return
|
| 87 |
+
|
| 88 |
+
base, target, condition, value_str = args[0].upper(), args[1].upper(), args[2], args[3]
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
value = float(value_str)
|
| 92 |
+
except ValueError:
|
| 93 |
+
await message.answer("❌ Value must be a number.")
|
| 94 |
+
return
|
| 95 |
+
|
| 96 |
+
if condition not in ['<', '>', '<=', '>=']:
|
| 97 |
+
await message.answer("❌ Condition must be one of: <, >, <=, >=")
|
| 98 |
+
return
|
| 99 |
+
|
| 100 |
+
from db.session import async_session
|
| 101 |
+
async with async_session() as session:
|
| 102 |
+
threshold = Threshold(
|
| 103 |
+
chat_id=message.chat.id,
|
| 104 |
+
base_currency=base,
|
| 105 |
+
target_currency=target,
|
| 106 |
+
condition=condition,
|
| 107 |
+
target_value=value
|
| 108 |
+
)
|
| 109 |
+
session.add(threshold)
|
| 110 |
+
await session.commit()
|
| 111 |
+
|
| 112 |
+
await message.answer(f"✅ Alert set! I will notify you when 1 {base} {condition} {value} {target}.")
|
bot/scheduler.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from aiogram import Bot
|
| 3 |
+
from db.session import async_session
|
| 4 |
+
from db.models import Subscription, Threshold
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from services.fx_service import fx_service
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
async def process_subscriptions(bot: Bot, frequency: str):
|
| 11 |
+
"""Processes scheduled subscriptions."""
|
| 12 |
+
logger.info(f"Processing {frequency} subscriptions...")
|
| 13 |
+
async with async_session() as session:
|
| 14 |
+
result = await session.execute(select(Subscription).where(Subscription.frequency == frequency))
|
| 15 |
+
subscriptions = result.scalars().all()
|
| 16 |
+
|
| 17 |
+
for sub in subscriptions:
|
| 18 |
+
rate = await fx_service.get_rate(sub.base_currency, sub.target_currency)
|
| 19 |
+
if rate:
|
| 20 |
+
try:
|
| 21 |
+
await bot.send_message(
|
| 22 |
+
chat_id=sub.chat_id,
|
| 23 |
+
text=f"🔔 Scheduled Update ({frequency}):\n1 {sub.base_currency} = {rate} {sub.target_currency}"
|
| 24 |
+
)
|
| 25 |
+
except Exception as e:
|
| 26 |
+
logger.error(f"Failed to send sub update to {sub.chat_id}: {e}")
|
| 27 |
+
|
| 28 |
+
async def process_thresholds(bot: Bot):
|
| 29 |
+
"""Processes active thresholds."""
|
| 30 |
+
logger.info("Processing thresholds...")
|
| 31 |
+
async with async_session() as session:
|
| 32 |
+
result = await session.execute(select(Threshold).where(Threshold.is_active == True))
|
| 33 |
+
thresholds = result.scalars().all()
|
| 34 |
+
|
| 35 |
+
for thresh in thresholds:
|
| 36 |
+
rate = await fx_service.get_rate(thresh.base_currency, thresh.target_currency)
|
| 37 |
+
if rate is None:
|
| 38 |
+
continue
|
| 39 |
+
|
| 40 |
+
triggered = False
|
| 41 |
+
if thresh.condition == '<' and rate < thresh.target_value:
|
| 42 |
+
triggered = True
|
| 43 |
+
elif thresh.condition == '>' and rate > thresh.target_value:
|
| 44 |
+
triggered = True
|
| 45 |
+
elif thresh.condition == '<=' and rate <= thresh.target_value:
|
| 46 |
+
triggered = True
|
| 47 |
+
elif thresh.condition == '>=' and rate >= thresh.target_value:
|
| 48 |
+
triggered = True
|
| 49 |
+
|
| 50 |
+
if triggered:
|
| 51 |
+
try:
|
| 52 |
+
await bot.send_message(
|
| 53 |
+
chat_id=thresh.chat_id,
|
| 54 |
+
text=f"🚨 ALERT Triggered!\n\n1 {thresh.base_currency} is now {rate} {thresh.target_currency}\n(Condition: {thresh.condition} {thresh.target_value})"
|
| 55 |
+
)
|
| 56 |
+
# Deactivate the threshold after triggering to prevent spam
|
| 57 |
+
thresh.is_active = False
|
| 58 |
+
await session.commit()
|
| 59 |
+
except Exception as e:
|
| 60 |
+
logger.error(f"Failed to send alert to {thresh.chat_id}: {e}")
|
core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Init for core module
|
core/config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
bot_token: str = ""
|
| 6 |
+
webhook_url: Optional[str] = None
|
| 7 |
+
fx_api_key: str = ""
|
| 8 |
+
database_url: str = "sqlite+aiosqlite:///./flyrates.db"
|
| 9 |
+
log_level: str = "INFO"
|
| 10 |
+
|
| 11 |
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
| 12 |
+
|
| 13 |
+
settings = Settings()
|
db/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Init for db module
|
db/models.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean, ForeignKey
|
| 2 |
+
from sqlalchemy.orm import declarative_base, relationship
|
| 3 |
+
from datetime import datetime, timezone
|
| 4 |
+
|
| 5 |
+
Base = declarative_base()
|
| 6 |
+
|
| 7 |
+
class User(Base):
|
| 8 |
+
__tablename__ = "users"
|
| 9 |
+
|
| 10 |
+
chat_id = Column(Integer, primary_key=True, index=True)
|
| 11 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 12 |
+
|
| 13 |
+
subscriptions = relationship("Subscription", back_populates="user", cascade="all, delete-orphan")
|
| 14 |
+
thresholds = relationship("Threshold", back_populates="user", cascade="all, delete-orphan")
|
| 15 |
+
|
| 16 |
+
class Subscription(Base):
|
| 17 |
+
__tablename__ = "subscriptions"
|
| 18 |
+
|
| 19 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 20 |
+
chat_id = Column(Integer, ForeignKey("users.chat_id"))
|
| 21 |
+
base_currency = Column(String(3), nullable=False)
|
| 22 |
+
target_currency = Column(String(3), nullable=False)
|
| 23 |
+
# Frequency could be 'daily', 'hourly'
|
| 24 |
+
frequency = Column(String(20), default="daily")
|
| 25 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 26 |
+
|
| 27 |
+
user = relationship("User", back_populates="subscriptions")
|
| 28 |
+
|
| 29 |
+
class Threshold(Base):
|
| 30 |
+
__tablename__ = "thresholds"
|
| 31 |
+
|
| 32 |
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
| 33 |
+
chat_id = Column(Integer, ForeignKey("users.chat_id"))
|
| 34 |
+
base_currency = Column(String(3), nullable=False)
|
| 35 |
+
target_currency = Column(String(3), nullable=False)
|
| 36 |
+
condition = Column(String(5), nullable=False) # e.g., '<', '>', '<=', '>='
|
| 37 |
+
target_value = Column(Float, nullable=False)
|
| 38 |
+
is_active = Column(Boolean, default=True)
|
| 39 |
+
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
| 40 |
+
|
| 41 |
+
user = relationship("User", back_populates="thresholds")
|
db/session.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
|
| 3 |
+
from core.config import settings
|
| 4 |
+
from db.models import Base
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
# Initialize the async engine
|
| 9 |
+
engine = create_async_engine(
|
| 10 |
+
settings.database_url,
|
| 11 |
+
echo=(settings.log_level == "DEBUG"),
|
| 12 |
+
future=True,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
# Create an async session factory
|
| 16 |
+
async_session = async_sessionmaker(
|
| 17 |
+
bind=engine,
|
| 18 |
+
class_=AsyncSession,
|
| 19 |
+
expire_on_commit=False,
|
| 20 |
+
autoflush=False
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
async def init_db():
|
| 24 |
+
"""Initializes the database and creates all tables."""
|
| 25 |
+
try:
|
| 26 |
+
async with engine.begin() as conn:
|
| 27 |
+
await conn.run_sync(Base.metadata.create_all)
|
| 28 |
+
logger.info("Database initialized successfully.")
|
| 29 |
+
except Exception as e:
|
| 30 |
+
logger.error(f"Error initializing database: {e}")
|
| 31 |
+
raise
|
| 32 |
+
|
| 33 |
+
async def get_session() -> AsyncSession:
|
| 34 |
+
"""Dependency to get a database session."""
|
| 35 |
+
async with async_session() as session:
|
| 36 |
+
yield session
|
main.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from contextlib import asynccontextmanager
|
| 3 |
+
from fastapi import FastAPI, Request, Response
|
| 4 |
+
from aiogram import Bot, Dispatcher, types
|
| 5 |
+
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
| 6 |
+
|
| 7 |
+
from core.config import settings
|
| 8 |
+
from db.session import init_db
|
| 9 |
+
from bot.handlers import router as bot_router
|
| 10 |
+
from bot.scheduler import process_subscriptions, process_thresholds
|
| 11 |
+
|
| 12 |
+
# Configure Logging
|
| 13 |
+
logging.basicConfig(level=getattr(logging, settings.log_level))
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
|
| 16 |
+
# Initialize Aiogram
|
| 17 |
+
bot = Bot(token=settings.bot_token)
|
| 18 |
+
dp = Dispatcher()
|
| 19 |
+
dp.include_router(bot_router)
|
| 20 |
+
|
| 21 |
+
# Initialize Scheduler
|
| 22 |
+
scheduler = AsyncIOScheduler()
|
| 23 |
+
|
| 24 |
+
@asynccontextmanager
|
| 25 |
+
async def lifespan(app: FastAPI):
|
| 26 |
+
# 1. Initialize Database
|
| 27 |
+
await init_db()
|
| 28 |
+
|
| 29 |
+
# 2. Setup Webhook (if URL is provided)
|
| 30 |
+
if settings.webhook_url:
|
| 31 |
+
webhook_info = await bot.get_webhook_info()
|
| 32 |
+
if webhook_info.url != f"{settings.webhook_url}/webhook":
|
| 33 |
+
await bot.set_webhook(url=f"{settings.webhook_url}/webhook")
|
| 34 |
+
logger.info(f"Webhook set to {settings.webhook_url}/webhook")
|
| 35 |
+
else:
|
| 36 |
+
logger.warning("WEBHOOK_URL not set. Running in webhook mode requires a public URL.")
|
| 37 |
+
|
| 38 |
+
# 3. Setup Scheduled Jobs
|
| 39 |
+
scheduler.add_job(process_thresholds, 'interval', minutes=10, args=[bot])
|
| 40 |
+
scheduler.add_job(process_subscriptions, 'cron', hour=8, args=[bot, 'daily'])
|
| 41 |
+
scheduler.add_job(process_subscriptions, 'interval', hours=1, args=[bot, 'hourly'])
|
| 42 |
+
scheduler.start()
|
| 43 |
+
|
| 44 |
+
yield
|
| 45 |
+
|
| 46 |
+
# Shutdown logic
|
| 47 |
+
scheduler.shutdown()
|
| 48 |
+
if settings.webhook_url:
|
| 49 |
+
await bot.delete_webhook()
|
| 50 |
+
await bot.session.close()
|
| 51 |
+
|
| 52 |
+
app = FastAPI(lifespan=lifespan)
|
| 53 |
+
|
| 54 |
+
@app.post("/webhook")
|
| 55 |
+
async def telegram_webhook(request: Request):
|
| 56 |
+
"""Endpoint for Telegram to send updates to."""
|
| 57 |
+
update = types.Update.model_validate(await request.json(), context={"bot": bot})
|
| 58 |
+
await dp.feed_update(bot, update)
|
| 59 |
+
return Response(status_code=200)
|
| 60 |
+
|
| 61 |
+
@app.get("/health")
|
| 62 |
+
async def health_check():
|
| 63 |
+
"""Health check endpoint required by Hugging Face Spaces."""
|
| 64 |
+
return {"status": "ok"}
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.29.0
|
| 3 |
+
aiogram==3.5.0
|
| 4 |
+
SQLAlchemy==2.0.30
|
| 5 |
+
aiosqlite==0.20.0
|
| 6 |
+
asyncpg==0.29.0
|
| 7 |
+
aiohttp==3.9.5
|
| 8 |
+
pydantic==2.7.1
|
| 9 |
+
pydantic-settings==2.2.1
|
| 10 |
+
APScheduler==3.10.4
|
services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Init for services module
|
services/fx_service.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import aiohttp
|
| 2 |
+
import logging
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from core.config import settings
|
| 5 |
+
|
| 6 |
+
logger = logging.getLogger(__name__)
|
| 7 |
+
|
| 8 |
+
ALLOWED_CURRENCIES = {
|
| 9 |
+
"USD", "GBP", "EUR", "AED", "SAR",
|
| 10 |
+
"AUD", "INR", "JPY", "CNY", "QAR"
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
class FXService:
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.api_key = settings.fx_api_key
|
| 16 |
+
# Using ExchangeRate-API as an example.
|
| 17 |
+
self.base_url = f"https://v6.exchangerate-api.com/v6/{self.api_key}"
|
| 18 |
+
|
| 19 |
+
def is_valid_currency(self, currency: str) -> bool:
|
| 20 |
+
"""Validates if the currency is supported in Phase 1."""
|
| 21 |
+
return currency.upper() in ALLOWED_CURRENCIES
|
| 22 |
+
|
| 23 |
+
async def get_rate(self, base_currency: str, target_currency: str) -> Optional[float]:
|
| 24 |
+
"""Fetches the real-time exchange rate."""
|
| 25 |
+
base_currency = base_currency.upper()
|
| 26 |
+
target_currency = target_currency.upper()
|
| 27 |
+
|
| 28 |
+
if not self.is_valid_currency(base_currency) or not self.is_valid_currency(target_currency):
|
| 29 |
+
logger.warning(f"Invalid currency pair requested: {base_currency}/{target_currency}")
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
if not self.api_key:
|
| 33 |
+
logger.error("FX_API_KEY is not set.")
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
url = f"{self.base_url}/pair/{base_currency}/{target_currency}"
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
async with aiohttp.ClientSession() as session:
|
| 40 |
+
async with session.get(url) as response:
|
| 41 |
+
if response.status == 200:
|
| 42 |
+
data = await response.json()
|
| 43 |
+
return data.get("conversion_rate")
|
| 44 |
+
else:
|
| 45 |
+
logger.error(f"Failed to fetch rate. Status: {response.status}")
|
| 46 |
+
return None
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Exception during API call: {e}")
|
| 49 |
+
return None
|
| 50 |
+
|
| 51 |
+
fx_service = FXService()
|