Sadeep Sachintha commited on
Commit
7803d4e
·
0 Parent(s):

first commit

Browse files
.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()