Sadeep Sachintha commited on
Commit ·
fec8feb
1
Parent(s): 853c8a4
Simplify Telegram Bot to unified daily rate list subscription and drop legacy tables
Browse files- bot/handlers.py +128 -891
- bot/scheduler.py +83 -48
- db/models.py +3 -32
- db/session.py +22 -32
- main.py +8 -12
- static/index.html +5 -5
bot/handlers.py
CHANGED
|
@@ -1,931 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from aiogram import Router, types
|
| 2 |
from aiogram.filters import Command
|
| 3 |
-
from aiogram.fsm.state import StatesGroup, State
|
| 4 |
-
from aiogram.fsm.context import FSMContext
|
| 5 |
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
| 6 |
-
from
|
| 7 |
-
from db.
|
| 8 |
-
from db.models import User, Subscription, Threshold
|
| 9 |
from sqlalchemy import select
|
| 10 |
-
import
|
|
|
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
router = Router()
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
"USD": "🇺🇸",
|
| 18 |
-
"GBP": "🇬🇧",
|
| 19 |
-
"EUR": "🇪🇺",
|
| 20 |
-
"AED": "🇦🇪",
|
| 21 |
-
"SAR": "🇸🇦",
|
| 22 |
-
"AUD": "🇦🇺",
|
| 23 |
-
"INR": "🇮🇳",
|
| 24 |
-
"JPY": "🇯🇵",
|
| 25 |
-
"CNY": "🇨🇳",
|
| 26 |
-
"QAR": "🇶🇦",
|
| 27 |
-
"LKR": "🇱🇰"
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
def get_currency_text(currency_code: str) -> str:
|
| 31 |
-
"""Returns currency code prefixed with its country flag emoji."""
|
| 32 |
-
flag = CURRENCY_FLAGS.get(currency_code.upper(), "🏳️")
|
| 33 |
-
return f"{flag} {currency_code.upper()}"
|
| 34 |
-
|
| 35 |
-
async def ensure_user_registered(session, chat_id: int):
|
| 36 |
-
"""Automatically registers a user if they haven't run /start first."""
|
| 37 |
-
result = await session.execute(select(User).where(User.chat_id == chat_id))
|
| 38 |
-
user = result.scalar_one_or_none()
|
| 39 |
-
if not user:
|
| 40 |
-
user = User(chat_id=chat_id)
|
| 41 |
-
session.add(user)
|
| 42 |
-
await session.flush()
|
| 43 |
-
return user
|
| 44 |
-
|
| 45 |
-
class ThresholdForm(StatesGroup):
|
| 46 |
-
waiting_for_value = State()
|
| 47 |
-
|
| 48 |
-
def get_currency_keyboard(prefix: str, exclude: str = None, include_presets: bool = True, preset_action: str = "curr"):
|
| 49 |
-
"""
|
| 50 |
-
Generates a keyboard with Quick Presets at the top and a Custom Grid below.
|
| 51 |
-
- include_presets: if True, shows 1-Tap quick conversion to LKR buttons.
|
| 52 |
-
- preset_action: 'curr' (current rate), 'sub' (subscribe), or 'th' (threshold).
|
| 53 |
-
"""
|
| 54 |
builder = InlineKeyboardBuilder()
|
| 55 |
-
|
| 56 |
-
# 1. Quick Presets (1-Tap Conversion to LKR)
|
| 57 |
-
if include_presets and exclude != "LKR":
|
| 58 |
-
presets = ["USD", "EUR", "GBP", "AUD"]
|
| 59 |
-
# Filter presets to only those allowed
|
| 60 |
-
valid_presets = [p for p in presets if p in ALLOWED_CURRENCIES]
|
| 61 |
-
|
| 62 |
-
for base in valid_presets:
|
| 63 |
-
if base == exclude:
|
| 64 |
-
continue
|
| 65 |
-
# Define standard preset target callbacks to LKR directly
|
| 66 |
-
if preset_action == "curr":
|
| 67 |
-
callback_data = f"curr_target:{base}:LKR"
|
| 68 |
-
elif preset_action == "sub":
|
| 69 |
-
callback_data = f"sub_target:{base}:LKR"
|
| 70 |
-
else: # 'th'
|
| 71 |
-
callback_data = f"th_target:{base}:LKR"
|
| 72 |
-
|
| 73 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 74 |
-
flag_lkr = CURRENCY_FLAGS.get("LKR", "")
|
| 75 |
-
builder.button(
|
| 76 |
-
text=f"{flag_base} {base} ➡️ {flag_lkr} LKR",
|
| 77 |
-
callback_data=callback_data
|
| 78 |
-
)
|
| 79 |
-
builder.adjust(2) # Show presets in 2 columns
|
| 80 |
-
|
| 81 |
-
# 2. Custom Grid (Grid of all currencies)
|
| 82 |
-
grid_builder = InlineKeyboardBuilder()
|
| 83 |
-
for curr in sorted(ALLOWED_CURRENCIES):
|
| 84 |
-
if curr == exclude:
|
| 85 |
-
continue
|
| 86 |
-
grid_builder.button(text=get_currency_text(curr), callback_data=f"{prefix}:{curr}")
|
| 87 |
-
grid_builder.adjust(4) # Custom grid in 4 columns
|
| 88 |
-
|
| 89 |
-
# Combine builders
|
| 90 |
-
builder.attach(grid_builder)
|
| 91 |
-
|
| 92 |
-
# 3. Add Cancel button at the bottom row
|
| 93 |
-
builder.row(types.InlineKeyboardButton(text="❌ Cancel", callback_data="cancel_action"))
|
| 94 |
return builder.as_markup()
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
@router.message(Command("start"))
|
| 98 |
async def cmd_start(message: types.Message):
|
| 99 |
-
"""
|
| 100 |
chat_id = message.chat.id
|
| 101 |
-
# Create an async session explicitly since we're in a handler, not a FastAPI endpoint
|
| 102 |
-
from db.session import async_session
|
| 103 |
async with async_session() as session:
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
user = result.scalar_one_or_none()
|
| 107 |
-
if not user:
|
| 108 |
-
new_user = User(chat_id=chat_id)
|
| 109 |
-
session.add(new_user)
|
| 110 |
-
await session.commit()
|
| 111 |
-
except Exception as e:
|
| 112 |
-
logger.warning(f"Error or concurrent insert in cmd_start for {chat_id}: {e}")
|
| 113 |
-
await session.rollback()
|
| 114 |
-
|
| 115 |
-
currencies_str = ", ".join(ALLOWED_CURRENCIES)
|
| 116 |
-
await message.answer(
|
| 117 |
-
f"Welcome to FlyRates! 🌍💸\n\n"
|
| 118 |
-
f"I can track real-time exchange rates for you.\n"
|
| 119 |
-
f"Supported Currencies: {currencies_str}\n\n"
|
| 120 |
-
f"Use /help to see all available commands!"
|
| 121 |
-
)
|
| 122 |
-
|
| 123 |
-
@router.message(Command("current"))
|
| 124 |
-
async def cmd_current(message: types.Message):
|
| 125 |
-
"""Fetches the current exchange rate (direct or interactive)."""
|
| 126 |
-
args = message.text.split()[1:]
|
| 127 |
-
if not args:
|
| 128 |
-
# Interactive mode
|
| 129 |
-
reply_markup = get_currency_keyboard("curr_base", preset_action="curr")
|
| 130 |
-
await message.answer(
|
| 131 |
-
"💱 <b>Live Exchange Rates</b>\n\n"
|
| 132 |
-
"Select a <b>Quick Preset</b> or select a custom <b>Base Currency</b> below:",
|
| 133 |
-
reply_markup=reply_markup,
|
| 134 |
-
parse_mode="HTML"
|
| 135 |
-
)
|
| 136 |
-
return
|
| 137 |
-
|
| 138 |
-
if len(args) not in [1, 2]:
|
| 139 |
-
await message.answer(
|
| 140 |
-
"Usage: <code>/current <base> [target]</code>\n"
|
| 141 |
-
"Example: <code>/current USD</code> or <code>/current USD EUR</code>\n\n"
|
| 142 |
-
"Or just type <code>/current</code> for interactive selection.",
|
| 143 |
-
parse_mode="HTML"
|
| 144 |
-
)
|
| 145 |
-
return
|
| 146 |
-
|
| 147 |
-
base = args[0].upper()
|
| 148 |
-
target = args[1].upper() if len(args) == 2 else "LKR"
|
| 149 |
-
|
| 150 |
-
if not fx_service.is_valid_currency(base) or not fx_service.is_valid_currency(target):
|
| 151 |
-
await message.answer(
|
| 152 |
-
"❌ Invalid currency pair. Let's select interactively:",
|
| 153 |
-
reply_markup=get_currency_keyboard("curr_base", preset_action="curr")
|
| 154 |
-
)
|
| 155 |
-
return
|
| 156 |
-
|
| 157 |
-
rate = await fx_service.get_rate(base, target)
|
| 158 |
-
if rate:
|
| 159 |
-
builder = InlineKeyboardBuilder()
|
| 160 |
-
builder.button(text="🔄 Refresh", callback_data=f"curr_target:{base}:{target}")
|
| 161 |
-
builder.button(text="⬅️ Change Currencies", callback_data="curr_start")
|
| 162 |
-
builder.row(types.InlineKeyboardButton(text="❌ Close", callback_data="delete_message"))
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
"💱 <b>Live Exchange Rates</b>\n\n"
|
| 199 |
-
"Select a <b>Quick Preset</b> or select a custom <b>Base Currency</b> below:",
|
| 200 |
-
reply_markup=reply_markup,
|
| 201 |
-
parse_mode="HTML"
|
| 202 |
-
)
|
| 203 |
-
await callback.answer()
|
| 204 |
-
|
| 205 |
-
@router.callback_query(lambda c: c.data.startswith("curr_base:"))
|
| 206 |
-
async def callback_curr_base(callback: types.CallbackQuery):
|
| 207 |
-
base = callback.data.split(":")[1]
|
| 208 |
-
# No presets for target selection, just select currency
|
| 209 |
-
reply_markup = get_currency_keyboard(f"curr_target:{base}", exclude=base, include_presets=False)
|
| 210 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 211 |
-
await callback.message.edit_text(
|
| 212 |
-
f"💱 <b>Live Exchange Rates</b>\n\n"
|
| 213 |
-
f"Base Currency: {flag_base} <b>{base}</b>\n\n"
|
| 214 |
-
f"Select the <b>Target Currency</b>:",
|
| 215 |
-
reply_markup=reply_markup,
|
| 216 |
-
parse_mode="HTML"
|
| 217 |
-
)
|
| 218 |
-
await callback.answer()
|
| 219 |
-
|
| 220 |
-
@router.callback_query(lambda c: c.data.startswith("curr_target:"))
|
| 221 |
-
async def callback_curr_target(callback: types.CallbackQuery):
|
| 222 |
-
parts = callback.data.split(":")
|
| 223 |
-
base = parts[1]
|
| 224 |
-
target = parts[2]
|
| 225 |
-
|
| 226 |
-
rate = await fx_service.get_rate(base, target)
|
| 227 |
-
|
| 228 |
-
builder = InlineKeyboardBuilder()
|
| 229 |
-
builder.button(text="🔄 Refresh", callback_data=f"curr_target:{base}:{target}")
|
| 230 |
-
builder.button(text="⬅️ Change Currencies", callback_data="curr_start")
|
| 231 |
-
builder.row(types.InlineKeyboardButton(text="❌ Close", callback_data="delete_message"))
|
| 232 |
-
|
| 233 |
-
from datetime import datetime
|
| 234 |
-
time_str = datetime.now().strftime("%H:%M:%S")
|
| 235 |
-
|
| 236 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 237 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 238 |
-
|
| 239 |
-
if rate:
|
| 240 |
-
await callback.message.edit_text(
|
| 241 |
-
f"📈 <b>Current Rate:</b>\n"
|
| 242 |
-
f"1 {flag_base} {base} = <b>{rate}</b> {flag_target} {target}\n\n"
|
| 243 |
-
f"<i>Last updated: {time_str} UTC</i>",
|
| 244 |
-
reply_markup=builder.as_markup(),
|
| 245 |
-
parse_mode="HTML"
|
| 246 |
-
)
|
| 247 |
-
else:
|
| 248 |
-
await callback.message.edit_text(
|
| 249 |
-
f"❌ Failed to fetch rate. Let's try again:",
|
| 250 |
-
reply_markup=builder.as_markup(),
|
| 251 |
-
parse_mode="HTML"
|
| 252 |
-
)
|
| 253 |
-
await callback.answer()
|
| 254 |
|
| 255 |
@router.message(Command("subscribe"))
|
| 256 |
async def cmd_subscribe(message: types.Message):
|
| 257 |
-
"""
|
| 258 |
-
args = message.text.split()[1:]
|
| 259 |
-
if not args:
|
| 260 |
-
reply_markup = get_currency_keyboard("sub_base", preset_action="sub")
|
| 261 |
-
await message.answer(
|
| 262 |
-
"🔔 <b>Set Up Rate Subscription</b>\n\n"
|
| 263 |
-
"Select a <b>Quick Preset</b> or select a custom <b>Base Currency</b> below:",
|
| 264 |
-
reply_markup=reply_markup,
|
| 265 |
-
parse_mode="HTML"
|
| 266 |
-
)
|
| 267 |
-
return
|
| 268 |
-
|
| 269 |
-
if len(args) not in [1, 2, 3]:
|
| 270 |
-
await message.answer(
|
| 271 |
-
"Usage: <code>/subscribe <base> [target] [daily|hourly]</code>\n"
|
| 272 |
-
"Example: <code>/subscribe USD daily</code>\n\n"
|
| 273 |
-
"Or just type <code>/subscribe</code> for interactive selection.",
|
| 274 |
-
parse_mode="HTML"
|
| 275 |
-
)
|
| 276 |
-
return
|
| 277 |
-
|
| 278 |
-
base = args[0].upper()
|
| 279 |
-
|
| 280 |
-
if len(args) == 1:
|
| 281 |
-
target = "LKR"
|
| 282 |
-
freq = "daily"
|
| 283 |
-
elif len(args) == 2:
|
| 284 |
-
if args[1].lower() in ["daily", "hourly"]:
|
| 285 |
-
target = "LKR"
|
| 286 |
-
freq = args[1].lower()
|
| 287 |
-
else:
|
| 288 |
-
target = args[1].upper()
|
| 289 |
-
freq = "daily"
|
| 290 |
-
else:
|
| 291 |
-
target = args[1].upper()
|
| 292 |
-
freq = args[2].lower()
|
| 293 |
-
|
| 294 |
-
if not fx_service.is_valid_currency(base) or not fx_service.is_valid_currency(target):
|
| 295 |
-
await message.answer(
|
| 296 |
-
"❌ Invalid currency pair. Let's select interactively:",
|
| 297 |
-
reply_markup=get_currency_keyboard("sub_base", preset_action="sub")
|
| 298 |
-
)
|
| 299 |
-
return
|
| 300 |
-
|
| 301 |
-
if freq not in ["daily", "hourly"]:
|
| 302 |
-
await message.answer("❌ Frequency must be 'daily' or 'hourly'.")
|
| 303 |
-
return
|
| 304 |
-
|
| 305 |
-
from db.session import async_session
|
| 306 |
-
async with async_session() as session:
|
| 307 |
-
await ensure_user_registered(session, message.chat.id)
|
| 308 |
-
sub = Subscription(chat_id=message.chat.id, base_currency=base, target_currency=target, frequency=freq)
|
| 309 |
-
session.add(sub)
|
| 310 |
-
await session.commit()
|
| 311 |
-
|
| 312 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 313 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 314 |
-
|
| 315 |
-
builder = InlineKeyboardBuilder()
|
| 316 |
-
builder.button(text="❌ Close", callback_data="delete_message")
|
| 317 |
-
|
| 318 |
-
await message.answer(
|
| 319 |
-
f"✅ <b>Subscribed!</b>\n"
|
| 320 |
-
f"You will receive <b>{freq}</b> updates for {flag_base} {base} to {flag_target} {target}.",
|
| 321 |
-
reply_markup=builder.as_markup(),
|
| 322 |
-
parse_mode="HTML"
|
| 323 |
-
)
|
| 324 |
-
|
| 325 |
-
# --- Callback Handlers for Subscriptions ---
|
| 326 |
-
|
| 327 |
-
@router.callback_query(lambda c: c.data == "sub_start")
|
| 328 |
-
async def callback_sub_start(callback: types.CallbackQuery):
|
| 329 |
-
reply_markup = get_currency_keyboard("sub_base", preset_action="sub")
|
| 330 |
-
await callback.message.edit_text(
|
| 331 |
-
"🔔 <b>Set Up Rate Subscription</b>\n\n"
|
| 332 |
-
"Select a <b>Quick Preset</b> or select a custom <b>Base Currency</b> below:",
|
| 333 |
-
reply_markup=reply_markup,
|
| 334 |
-
parse_mode="HTML"
|
| 335 |
-
)
|
| 336 |
-
await callback.answer()
|
| 337 |
-
|
| 338 |
-
@router.callback_query(lambda c: c.data.startswith("sub_base:"))
|
| 339 |
-
async def callback_sub_base(callback: types.CallbackQuery):
|
| 340 |
-
base = callback.data.split(":")[1]
|
| 341 |
-
reply_markup = get_currency_keyboard(f"sub_target:{base}", exclude=base, include_presets=False)
|
| 342 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 343 |
-
await callback.message.edit_text(
|
| 344 |
-
f"🔔 <b>Set Up Rate Subscription</b>\n\n"
|
| 345 |
-
f"Base Currency: {flag_base} <b>{base}</b>\n\n"
|
| 346 |
-
f"Select the <b>Target Currency</b>:",
|
| 347 |
-
reply_markup=reply_markup,
|
| 348 |
-
parse_mode="HTML"
|
| 349 |
-
)
|
| 350 |
-
await callback.answer()
|
| 351 |
-
|
| 352 |
-
@router.callback_query(lambda c: c.data.startswith("sub_target:"))
|
| 353 |
-
async def callback_sub_target(callback: types.CallbackQuery):
|
| 354 |
-
parts = callback.data.split(":")
|
| 355 |
-
base = parts[1]
|
| 356 |
-
target = parts[2]
|
| 357 |
-
|
| 358 |
-
builder = InlineKeyboardBuilder()
|
| 359 |
-
builder.button(text="📅 Daily (8:00 AM UTC)", callback_data=f"sub_freq:{base}:{target}:daily")
|
| 360 |
-
builder.button(text="⏰ Hourly", callback_data=f"sub_freq:{base}:{target}:hourly")
|
| 361 |
-
builder.row(types.InlineKeyboardButton(text="❌ Cancel", callback_data="cancel_action"))
|
| 362 |
-
|
| 363 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 364 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 365 |
-
await callback.message.edit_text(
|
| 366 |
-
f"🔔 <b>Set Up Rate Subscription</b>\n\n"
|
| 367 |
-
f"Base: {flag_base} <b>{base}</b>\n"
|
| 368 |
-
f"Target: {flag_target} <b>{target}</b>\n\n"
|
| 369 |
-
f"Select the <b>Update Frequency</b>:",
|
| 370 |
-
reply_markup=builder.as_markup(),
|
| 371 |
-
parse_mode="HTML"
|
| 372 |
-
)
|
| 373 |
-
await callback.answer()
|
| 374 |
-
|
| 375 |
-
@router.callback_query(lambda c: c.data.startswith("sub_freq:"))
|
| 376 |
-
async def callback_sub_freq(callback: types.CallbackQuery):
|
| 377 |
-
parts = callback.data.split(":")
|
| 378 |
-
base = parts[1]
|
| 379 |
-
target = parts[2]
|
| 380 |
-
freq = parts[3]
|
| 381 |
-
|
| 382 |
-
from db.session import async_session
|
| 383 |
-
async with async_session() as session:
|
| 384 |
-
await ensure_user_registered(session, callback.message.chat.id)
|
| 385 |
-
sub = Subscription(chat_id=callback.message.chat.id, base_currency=base, target_currency=target, frequency=freq)
|
| 386 |
-
session.add(sub)
|
| 387 |
-
await session.commit()
|
| 388 |
-
|
| 389 |
-
builder = InlineKeyboardBuilder()
|
| 390 |
-
builder.button(text="❌ Close", callback_data="delete_message")
|
| 391 |
-
|
| 392 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 393 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 394 |
-
await callback.message.edit_text(
|
| 395 |
-
f"✅ <b>Subscription Activated!</b>\n\n"
|
| 396 |
-
f"You will receive <b>{freq}</b> updates for {flag_base} {base} to {flag_target} {target}.",
|
| 397 |
-
reply_markup=builder.as_markup(),
|
| 398 |
-
parse_mode="HTML"
|
| 399 |
-
)
|
| 400 |
-
await callback.answer()
|
| 401 |
-
|
| 402 |
-
@router.message(Command("threshold"))
|
| 403 |
-
async def cmd_threshold(message: types.Message):
|
| 404 |
-
"""Sets a threshold alert (direct or interactive)."""
|
| 405 |
-
args = message.text.split()[1:]
|
| 406 |
-
if not args:
|
| 407 |
-
reply_markup = get_currency_keyboard("th_base", preset_action="th")
|
| 408 |
-
await message.answer(
|
| 409 |
-
"🚨 <b>Set Up Threshold Alert</b>\n\n"
|
| 410 |
-
"Select a <b>Quick Preset</b> or select a custom <b>Base Currency</b> below:",
|
| 411 |
-
reply_markup=reply_markup,
|
| 412 |
-
parse_mode="HTML"
|
| 413 |
-
)
|
| 414 |
-
return
|
| 415 |
-
|
| 416 |
-
if len(args) not in [3, 4]:
|
| 417 |
-
await message.answer(
|
| 418 |
-
"Usage: <code>/threshold <base> [target] <condition> <value></code>\n"
|
| 419 |
-
"Example: <code>/threshold USD < 300</code>\n\n"
|
| 420 |
-
"Or just type <code>/threshold</code> for interactive setup.",
|
| 421 |
-
parse_mode="HTML"
|
| 422 |
-
)
|
| 423 |
-
return
|
| 424 |
-
|
| 425 |
-
if len(args) == 3:
|
| 426 |
-
base, target, condition, value_str = args[0].upper(), "LKR", args[1], args[2]
|
| 427 |
-
else:
|
| 428 |
-
base, target, condition, value_str = args[0].upper(), args[1].upper(), args[2], args[3]
|
| 429 |
-
|
| 430 |
-
try:
|
| 431 |
-
value = float(value_str)
|
| 432 |
-
except ValueError:
|
| 433 |
-
await message.answer("❌ Value must be a valid number.")
|
| 434 |
-
return
|
| 435 |
-
|
| 436 |
-
if condition not in ['<', '>', '<=', '>=']:
|
| 437 |
-
await message.answer("❌ Condition must be one of: <, >, <=, >=")
|
| 438 |
-
return
|
| 439 |
-
|
| 440 |
-
if not fx_service.is_valid_currency(base) or not fx_service.is_valid_currency(target):
|
| 441 |
-
await message.answer(
|
| 442 |
-
"❌ Invalid currency pair. Let's select interactively:",
|
| 443 |
-
reply_markup=get_currency_keyboard("th_base", preset_action="th")
|
| 444 |
-
)
|
| 445 |
-
return
|
| 446 |
-
|
| 447 |
-
from db.session import async_session
|
| 448 |
-
async with async_session() as session:
|
| 449 |
-
await ensure_user_registered(session, message.chat.id)
|
| 450 |
-
threshold = Threshold(
|
| 451 |
-
chat_id=message.chat.id,
|
| 452 |
-
base_currency=base,
|
| 453 |
-
target_currency=target,
|
| 454 |
-
condition=condition,
|
| 455 |
-
target_value=value
|
| 456 |
-
)
|
| 457 |
-
session.add(threshold)
|
| 458 |
-
await session.commit()
|
| 459 |
-
|
| 460 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 461 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 462 |
-
|
| 463 |
-
builder = InlineKeyboardBuilder()
|
| 464 |
-
builder.button(text="❌ Close", callback_data="delete_message")
|
| 465 |
-
|
| 466 |
-
await message.answer(
|
| 467 |
-
f"✅ <b>Alert Set!</b>\n"
|
| 468 |
-
f"I will notify you when 1 {flag_base} {base} {condition} <b>{value}</b> {flag_target} {target}.",
|
| 469 |
-
reply_markup=builder.as_markup(),
|
| 470 |
-
parse_mode="HTML"
|
| 471 |
-
)
|
| 472 |
-
|
| 473 |
-
# --- Callback Handlers for Threshold Alerts ---
|
| 474 |
-
|
| 475 |
-
@router.callback_query(lambda c: c.data == "th_start")
|
| 476 |
-
async def callback_th_start(callback: types.CallbackQuery):
|
| 477 |
-
reply_markup = get_currency_keyboard("th_base", preset_action="th")
|
| 478 |
-
await callback.message.edit_text(
|
| 479 |
-
"🚨 <b>Set Up Threshold Alert</b>\n\n"
|
| 480 |
-
"Select a <b>Quick Preset</b> or select a custom <b>Base Currency</b> below:",
|
| 481 |
-
reply_markup=reply_markup,
|
| 482 |
-
parse_mode="HTML"
|
| 483 |
-
)
|
| 484 |
-
await callback.answer()
|
| 485 |
-
|
| 486 |
-
@router.callback_query(lambda c: c.data.startswith("th_base:"))
|
| 487 |
-
async def callback_th_base(callback: types.CallbackQuery):
|
| 488 |
-
base = callback.data.split(":")[1]
|
| 489 |
-
reply_markup = get_currency_keyboard(f"th_target:{base}", exclude=base, include_presets=False)
|
| 490 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 491 |
-
await callback.message.edit_text(
|
| 492 |
-
f"🚨 <b>Set Up Threshold Alert</b>\n\n"
|
| 493 |
-
f"Base Currency: {flag_base} <b>{base}</b>\n\n"
|
| 494 |
-
f"Select the <b>Target Currency</b>:",
|
| 495 |
-
reply_markup=reply_markup,
|
| 496 |
-
parse_mode="HTML"
|
| 497 |
-
)
|
| 498 |
-
await callback.answer()
|
| 499 |
-
|
| 500 |
-
@router.callback_query(lambda c: c.data.startswith("th_target:"))
|
| 501 |
-
async def callback_th_target(callback: types.CallbackQuery):
|
| 502 |
-
parts = callback.data.split(":")
|
| 503 |
-
base = parts[1]
|
| 504 |
-
target = parts[2]
|
| 505 |
-
|
| 506 |
-
builder = InlineKeyboardBuilder()
|
| 507 |
-
builder.button(text="📈 Rises Above (>)", callback_data=f"th_cond:{base}:{target}:>")
|
| 508 |
-
builder.button(text="📉 Drops Below (<)", callback_data=f"th_cond:{base}:{target}:<")
|
| 509 |
-
builder.row(types.InlineKeyboardButton(text="❌ Cancel", callback_data="cancel_action"))
|
| 510 |
-
|
| 511 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 512 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 513 |
-
await callback.message.edit_text(
|
| 514 |
-
f"🚨 <b>Set Up Threshold Alert</b>\n\n"
|
| 515 |
-
f"Base: {flag_base} <b>{base}</b>\n"
|
| 516 |
-
f"Target: {flag_target} <b>{target}</b>\n\n"
|
| 517 |
-
f"Select the <b>Alert Condition</b>:",
|
| 518 |
-
reply_markup=builder.as_markup(),
|
| 519 |
-
parse_mode="HTML"
|
| 520 |
-
)
|
| 521 |
-
await callback.answer()
|
| 522 |
-
|
| 523 |
-
@router.callback_query(lambda c: c.data.startswith("th_cond:"))
|
| 524 |
-
async def callback_th_cond(callback: types.CallbackQuery, state: FSMContext):
|
| 525 |
-
parts = callback.data.split(":")
|
| 526 |
-
base = parts[1]
|
| 527 |
-
target = parts[2]
|
| 528 |
-
cond = parts[3]
|
| 529 |
-
|
| 530 |
-
await state.set_state(ThresholdForm.waiting_for_value)
|
| 531 |
-
await state.update_data(base=base, target=target, condition=cond, message_id=callback.message.message_id)
|
| 532 |
-
|
| 533 |
-
builder = InlineKeyboardBuilder()
|
| 534 |
-
builder.button(text="❌ Cancel", callback_data="cancel_action")
|
| 535 |
-
|
| 536 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 537 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 538 |
-
|
| 539 |
-
# Text instruction
|
| 540 |
-
cond_name = "Rises Above" if cond == ">" else "Drops Below"
|
| 541 |
-
await callback.message.edit_text(
|
| 542 |
-
f"🚨 <b>Set Up Threshold Alert</b>\n\n"
|
| 543 |
-
f"Base: {flag_base} <b>{base}</b>\n"
|
| 544 |
-
f"Target: {flag_target} <b>{target}</b>\n"
|
| 545 |
-
f"Condition: <b>{cond_name} ({cond})</b>\n\n"
|
| 546 |
-
f"✍️ Please <b>type the target value</b> (e.g. 325.50) as a chat reply:",
|
| 547 |
-
reply_markup=builder.as_markup(),
|
| 548 |
-
parse_mode="HTML"
|
| 549 |
-
)
|
| 550 |
-
await callback.answer()
|
| 551 |
-
|
| 552 |
-
# --- FSM Handler for Threshold Input Value ---
|
| 553 |
-
|
| 554 |
-
@router.message(ThresholdForm.waiting_for_value)
|
| 555 |
-
async def process_threshold_value(message: types.Message, state: FSMContext):
|
| 556 |
-
data = await state.get_data()
|
| 557 |
-
base = data.get("base")
|
| 558 |
-
target = data.get("target")
|
| 559 |
-
condition = data.get("condition")
|
| 560 |
-
message_id = data.get("message_id")
|
| 561 |
-
|
| 562 |
-
val_str = message.text.strip()
|
| 563 |
-
try:
|
| 564 |
-
value = float(val_str)
|
| 565 |
-
except ValueError:
|
| 566 |
-
# Prompt user to input again
|
| 567 |
-
await message.answer("❌ Value must be a valid number. Please enter a valid decimal (e.g., 300.50) or click Cancel.")
|
| 568 |
-
return
|
| 569 |
-
|
| 570 |
-
from db.session import async_session
|
| 571 |
-
async with async_session() as session:
|
| 572 |
-
await ensure_user_registered(session, message.chat.id)
|
| 573 |
-
threshold = Threshold(
|
| 574 |
-
chat_id=message.chat.id,
|
| 575 |
-
base_currency=base,
|
| 576 |
-
target_currency=target,
|
| 577 |
-
condition=condition,
|
| 578 |
-
target_value=value
|
| 579 |
-
)
|
| 580 |
-
session.add(threshold)
|
| 581 |
-
await session.commit()
|
| 582 |
-
|
| 583 |
-
await state.clear()
|
| 584 |
-
|
| 585 |
-
# Try deleting the interactive prompt message to keep the chat clean
|
| 586 |
-
if message_id:
|
| 587 |
-
try:
|
| 588 |
-
await message.bot.delete_message(chat_id=message.chat.id, message_id=message_id)
|
| 589 |
-
except Exception:
|
| 590 |
-
pass
|
| 591 |
-
|
| 592 |
-
builder = InlineKeyboardBuilder()
|
| 593 |
-
builder.button(text="❌ Close", callback_data="delete_message")
|
| 594 |
-
|
| 595 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 596 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 597 |
-
await message.answer(
|
| 598 |
-
f"✅ <b>Alert Set Successfully!</b>\n\n"
|
| 599 |
-
f"I will notify you when 1 {flag_base} {base} {condition} <b>{value}</b> {flag_target} {target}.",
|
| 600 |
-
reply_markup=builder.as_markup(),
|
| 601 |
-
parse_mode="HTML"
|
| 602 |
-
)
|
| 603 |
-
|
| 604 |
-
@router.message(Command("mysubs"))
|
| 605 |
-
async def cmd_mysubs(message: types.Message):
|
| 606 |
-
"""Lists all active subscriptions and thresholds for the user."""
|
| 607 |
-
from db.session import async_session
|
| 608 |
chat_id = message.chat.id
|
| 609 |
-
|
| 610 |
async with async_session() as session:
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
thresholds_result = await session.execute(select(Threshold).where(Threshold.chat_id == chat_id))
|
| 615 |
-
thresholds = thresholds_result.scalars().all()
|
| 616 |
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
if thresholds:
|
| 631 |
-
response += "🚨 <b>Threshold Alerts:</b>\n"
|
| 632 |
-
for th in thresholds:
|
| 633 |
-
status = " (Active)" if th.is_active else " (Triggered/Inactive)"
|
| 634 |
-
flag_base = CURRENCY_FLAGS.get(th.base_currency, "")
|
| 635 |
-
flag_target = CURRENCY_FLAGS.get(th.target_currency, "")
|
| 636 |
-
response += f"• {flag_base} {th.base_currency} ➡️ {flag_target} {th.target_currency} <b>{th.condition} {th.target_value}</b><i>{status}</i>\n"
|
| 637 |
-
else:
|
| 638 |
-
response += "🚨 <b>Threshold Alerts:</b> None\n"
|
| 639 |
-
|
| 640 |
-
builder = InlineKeyboardBuilder()
|
| 641 |
-
builder.button(text="➕ Add Sub", callback_data="sub_start")
|
| 642 |
-
builder.button(text="➕ Add Alert", callback_data="th_start")
|
| 643 |
-
builder.row(
|
| 644 |
-
types.InlineKeyboardButton(text="➖ Manage Subs", callback_data="unsub_start"),
|
| 645 |
-
types.InlineKeyboardButton(text="➖ Manage Alerts", callback_data="delth_start")
|
| 646 |
-
)
|
| 647 |
-
builder.row(types.InlineKeyboardButton(text="❌ Close Dashboard", callback_data="delete_message"))
|
| 648 |
-
|
| 649 |
-
await message.answer(response, reply_markup=builder.as_markup(), parse_mode="HTML")
|
| 650 |
|
| 651 |
@router.message(Command("unsubscribe"))
|
| 652 |
async def cmd_unsubscribe(message: types.Message):
|
| 653 |
-
"""
|
| 654 |
-
args = message.text.split()[1:]
|
| 655 |
-
if not args:
|
| 656 |
-
# Interactive unsubscribe
|
| 657 |
-
from db.session import async_session
|
| 658 |
-
async with async_session() as session:
|
| 659 |
-
result = await session.execute(select(Subscription).where(Subscription.chat_id == message.chat.id))
|
| 660 |
-
subs = result.scalars().all()
|
| 661 |
-
|
| 662 |
-
if not subs:
|
| 663 |
-
await message.answer("ℹ️ You don't have any active subscriptions to remove.")
|
| 664 |
-
return
|
| 665 |
-
|
| 666 |
-
builder = InlineKeyboardBuilder()
|
| 667 |
-
for sub in subs:
|
| 668 |
-
flag_base = CURRENCY_FLAGS.get(sub.base_currency, "")
|
| 669 |
-
flag_target = CURRENCY_FLAGS.get(sub.target_currency, "")
|
| 670 |
-
builder.button(
|
| 671 |
-
text=f"❌ {flag_base} {sub.base_currency} ➡️ {flag_target} {sub.target_currency} ({sub.frequency})",
|
| 672 |
-
callback_data=f"unsub_id:{sub.id}"
|
| 673 |
-
)
|
| 674 |
-
builder.adjust(1)
|
| 675 |
-
builder.row(types.InlineKeyboardButton(text="❌ Cancel", callback_data="cancel_action"))
|
| 676 |
-
|
| 677 |
-
await message.answer(
|
| 678 |
-
"➖ <b>Manage Subscriptions</b>\n\nClick on any subscription to remove it:",
|
| 679 |
-
reply_markup=builder.as_markup(),
|
| 680 |
-
parse_mode="HTML"
|
| 681 |
-
)
|
| 682 |
-
return
|
| 683 |
-
|
| 684 |
-
if len(args) not in [1, 2]:
|
| 685 |
-
await message.answer(
|
| 686 |
-
"Usage: <code>/unsubscribe <base> [target]</code>\n"
|
| 687 |
-
"Example: <code>/unsubscribe USD</code>\n\n"
|
| 688 |
-
"Or just type <code>/unsubscribe</code> to list and remove.",
|
| 689 |
-
parse_mode="HTML"
|
| 690 |
-
)
|
| 691 |
-
return
|
| 692 |
-
|
| 693 |
-
base = args[0].upper()
|
| 694 |
-
target = args[1].upper() if len(args) == 2 else "LKR"
|
| 695 |
chat_id = message.chat.id
|
| 696 |
-
|
| 697 |
-
from db.session import async_session
|
| 698 |
-
async with async_session() as session:
|
| 699 |
-
result = await session.execute(select(Subscription).where(
|
| 700 |
-
Subscription.chat_id == chat_id,
|
| 701 |
-
Subscription.base_currency == base,
|
| 702 |
-
Subscription.target_currency == target
|
| 703 |
-
))
|
| 704 |
-
subs = result.scalars().all()
|
| 705 |
-
|
| 706 |
-
if not subs:
|
| 707 |
-
await message.answer(f"❌ You don't have a subscription for {base} to {target}.")
|
| 708 |
-
return
|
| 709 |
-
|
| 710 |
-
for sub in subs:
|
| 711 |
-
await session.delete(sub)
|
| 712 |
-
await session.commit()
|
| 713 |
-
|
| 714 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 715 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 716 |
-
await message.answer(f"✅ Unsubscribed from {flag_base} {base} to {flag_target} {target} updates.")
|
| 717 |
-
|
| 718 |
-
# --- Callback Handlers for Unsubscribing ---
|
| 719 |
-
|
| 720 |
-
@router.callback_query(lambda c: c.data == "unsub_start")
|
| 721 |
-
async def callback_unsub_start(callback: types.CallbackQuery):
|
| 722 |
-
from db.session import async_session
|
| 723 |
async with async_session() as session:
|
| 724 |
-
result = await session.execute(select(
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
if not subs:
|
| 728 |
-
await callback.message.edit_text("ℹ️ You don't have any active subscriptions to remove.")
|
| 729 |
-
await callback.answer()
|
| 730 |
-
return
|
| 731 |
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
flag_base = CURRENCY_FLAGS.get(sub.base_currency, "")
|
| 735 |
-
flag_target = CURRENCY_FLAGS.get(sub.target_currency, "")
|
| 736 |
-
builder.button(
|
| 737 |
-
text=f"❌ {flag_base} {sub.base_currency} ➡️ {flag_target} {sub.target_currency} ({sub.frequency})",
|
| 738 |
-
callback_data=f"unsub_id:{sub.id}"
|
| 739 |
-
)
|
| 740 |
-
builder.adjust(1)
|
| 741 |
-
builder.row(types.InlineKeyboardButton(text="❌ Cancel", callback_data="cancel_action"))
|
| 742 |
-
|
| 743 |
-
await callback.message.edit_text(
|
| 744 |
-
"➖ <b>Manage Subscriptions</b>\n\nClick on any subscription to remove it:",
|
| 745 |
-
reply_markup=builder.as_markup(),
|
| 746 |
-
parse_mode="HTML"
|
| 747 |
-
)
|
| 748 |
-
await callback.answer()
|
| 749 |
-
|
| 750 |
-
@router.callback_query(lambda c: c.data.startswith("unsub_id:"))
|
| 751 |
-
async def callback_unsub_id(callback: types.CallbackQuery):
|
| 752 |
-
sub_id = int(callback.data.split(":")[1])
|
| 753 |
-
|
| 754 |
-
from db.session import async_session
|
| 755 |
-
async with async_session() as session:
|
| 756 |
-
sub_result = await session.execute(select(Subscription).where(Subscription.id == sub_id))
|
| 757 |
-
sub = sub_result.scalar_one_or_none()
|
| 758 |
-
if sub:
|
| 759 |
-
base = sub.base_currency
|
| 760 |
-
target = sub.target_currency
|
| 761 |
-
await session.delete(sub)
|
| 762 |
await session.commit()
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 768 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 769 |
-
await callback.message.edit_text(
|
| 770 |
-
f"✅ Unsubscribed from <b>{flag_base} {base}</b> to <b>{flag_target} {target}</b> updates.",
|
| 771 |
-
reply_markup=builder.as_markup(),
|
| 772 |
-
parse_mode="HTML"
|
| 773 |
)
|
| 774 |
else:
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
@router.message(Command("delthreshold"))
|
| 779 |
-
async def cmd_delthreshold(message: types.Message):
|
| 780 |
-
"""Removes a threshold alert (direct or interactive)."""
|
| 781 |
-
args = message.text.split()[1:]
|
| 782 |
-
if not args:
|
| 783 |
-
# Interactive delthreshold
|
| 784 |
-
from db.session import async_session
|
| 785 |
-
async with async_session() as session:
|
| 786 |
-
result = await session.execute(select(Threshold).where(Threshold.chat_id == message.chat.id))
|
| 787 |
-
thresholds = result.scalars().all()
|
| 788 |
-
|
| 789 |
-
if not thresholds:
|
| 790 |
-
await message.answer("ℹ️ You don't have any active threshold alerts to remove.")
|
| 791 |
-
return
|
| 792 |
-
|
| 793 |
-
builder = InlineKeyboardBuilder()
|
| 794 |
-
for th in thresholds:
|
| 795 |
-
status = " (Active)" if th.is_active else " (Triggered)"
|
| 796 |
-
flag_base = CURRENCY_FLAGS.get(th.base_currency, "")
|
| 797 |
-
flag_target = CURRENCY_FLAGS.get(th.target_currency, "")
|
| 798 |
-
builder.button(
|
| 799 |
-
text=f"❌ {flag_base} {th.base_currency} ➡️ {flag_target} {th.target_currency} {th.condition} {th.target_value}{status}",
|
| 800 |
-
callback_data=f"delth_id:{th.id}"
|
| 801 |
)
|
| 802 |
-
builder.adjust(1)
|
| 803 |
-
builder.row(types.InlineKeyboardButton(text="❌ Cancel", callback_data="cancel_action"))
|
| 804 |
-
|
| 805 |
-
await message.answer(
|
| 806 |
-
"➖ <b>Manage Threshold Alerts</b>\n\nClick on any alert to delete it:",
|
| 807 |
-
reply_markup=builder.as_markup(),
|
| 808 |
-
parse_mode="HTML"
|
| 809 |
-
)
|
| 810 |
-
return
|
| 811 |
-
|
| 812 |
-
if len(args) not in [1, 2]:
|
| 813 |
-
await message.answer(
|
| 814 |
-
"Usage: <code>/delthreshold <base> [target]</code>\n"
|
| 815 |
-
"Example: <code>/delthreshold USD</code>\n\n"
|
| 816 |
-
"Or just type <code>/delthreshold</code> to list and delete.",
|
| 817 |
-
parse_mode="HTML"
|
| 818 |
-
)
|
| 819 |
-
return
|
| 820 |
-
|
| 821 |
-
base = args[0].upper()
|
| 822 |
-
target = args[1].upper() if len(args) == 2 else "LKR"
|
| 823 |
-
chat_id = message.chat.id
|
| 824 |
-
|
| 825 |
-
from db.session import async_session
|
| 826 |
-
async with async_session() as session:
|
| 827 |
-
result = await session.execute(select(Threshold).where(
|
| 828 |
-
Threshold.chat_id == chat_id,
|
| 829 |
-
Threshold.base_currency == base,
|
| 830 |
-
Threshold.target_currency == target
|
| 831 |
-
))
|
| 832 |
-
thresholds = result.scalars().all()
|
| 833 |
-
|
| 834 |
-
if not thresholds:
|
| 835 |
-
await message.answer(f"❌ You don't have a threshold alert for {base} to {target}.")
|
| 836 |
-
return
|
| 837 |
|
| 838 |
-
|
| 839 |
-
await session.delete(th)
|
| 840 |
-
await session.commit()
|
| 841 |
-
|
| 842 |
-
flag_base = CURRENCY_FLAGS.get(base, "")
|
| 843 |
-
flag_target = CURRENCY_FLAGS.get(target, "")
|
| 844 |
-
await message.answer(f"✅ Deleted threshold alerts for {flag_base} {base} to {flag_target} {target}.")
|
| 845 |
-
|
| 846 |
-
# --- Callback Handlers for Deleting Thresholds ---
|
| 847 |
|
| 848 |
-
@router.
|
| 849 |
-
async def
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
thresholds = result.scalars().all()
|
| 854 |
-
|
| 855 |
-
if not thresholds:
|
| 856 |
-
await callback.message.edit_text("ℹ️ You don't have any active threshold alerts to remove.")
|
| 857 |
-
await callback.answer()
|
| 858 |
-
return
|
| 859 |
-
|
| 860 |
-
builder = InlineKeyboardBuilder()
|
| 861 |
-
for th in thresholds:
|
| 862 |
-
status = " (Active)" if th.is_active else " (Triggered)"
|
| 863 |
-
flag_base = CURRENCY_FLAGS.get(th.base_currency, "")
|
| 864 |
-
flag_target = CURRENCY_FLAGS.get(th.target_currency, "")
|
| 865 |
-
builder.button(
|
| 866 |
-
text=f"❌ {flag_base} {th.base_currency} ➡️ {flag_target} {th.target_currency} {th.condition} {th.target_value}{status}",
|
| 867 |
-
callback_data=f"delth_id:{th.id}"
|
| 868 |
-
)
|
| 869 |
-
builder.adjust(1)
|
| 870 |
-
builder.row(types.InlineKeyboardButton(text="❌ Cancel", callback_data="cancel_action"))
|
| 871 |
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
parse_mode="HTML"
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
|
|
|
|
|
|
| 882 |
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
await callback.message.edit_text(
|
| 899 |
-
f"✅ Deleted threshold alert for <b>{flag_base} {base}</b> to <b>{flag_target} {target}</b>.",
|
| 900 |
-
reply_markup=builder.as_markup(),
|
| 901 |
-
parse_mode="HTML"
|
| 902 |
-
)
|
| 903 |
-
else:
|
| 904 |
-
await callback.message.edit_text("❌ Threshold alert not found or already deleted.")
|
| 905 |
-
await callback.answer()
|
| 906 |
|
| 907 |
@router.message(Command("help"))
|
| 908 |
async def cmd_help(message: types.Message):
|
| 909 |
-
"""
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
"
|
| 913 |
-
"
|
| 914 |
-
"
|
| 915 |
-
"
|
| 916 |
-
"
|
| 917 |
-
"
|
| 918 |
-
"<
|
| 919 |
-
"
|
| 920 |
-
"<
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
"<code>/unsubscribe <base> [target]</code> - Remove a subscription\n"
|
| 924 |
-
"<code>/delthreshold <base> [target]</code> - Remove an alert\n\n"
|
| 925 |
-
f"<b>Supported Currencies:</b> {currencies_str}\n\n"
|
| 926 |
-
"<b>Examples:</b>\n"
|
| 927 |
-
"<code>/current USD</code> (defaults to LKR)\n"
|
| 928 |
-
"<code>/subscribe USD daily</code>\n"
|
| 929 |
-
"<code>/threshold USD < 300</code>"
|
| 930 |
-
)
|
| 931 |
-
await message.answer(help_text, parse_mode="HTML")
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import asyncio
|
| 3 |
+
from datetime import datetime
|
| 4 |
from aiogram import Router, types
|
| 5 |
from aiogram.filters import Command
|
|
|
|
|
|
|
| 6 |
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
| 7 |
+
from db.session import async_session
|
| 8 |
+
from db.models import User
|
|
|
|
| 9 |
from sqlalchemy import select
|
| 10 |
+
from services.fx_service import fx_service
|
| 11 |
+
from bot.scheduler import CURRENCIES, FLAGS, format_rates_list
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
router = Router()
|
| 15 |
|
| 16 |
+
def get_refresh_keyboard():
|
| 17 |
+
"""Generates the inline keyboard for rates list refresh."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
builder = InlineKeyboardBuilder()
|
| 19 |
+
builder.button(text="🔄 Refresh Rates", callback_data="refresh_rates")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return builder.as_markup()
|
| 21 |
|
| 22 |
+
async def get_formatted_current_rates() -> str:
|
| 23 |
+
"""Fetches the latest rates concurrently and formats the LKR list."""
|
| 24 |
+
tasks = [fx_service.get_rate(cur, "LKR") for cur in CURRENCIES]
|
| 25 |
+
rates_values = await asyncio.gather(*tasks)
|
| 26 |
+
rates = dict(zip(CURRENCIES, rates_values))
|
| 27 |
+
return format_rates_list(rates)
|
| 28 |
|
| 29 |
@router.message(Command("start"))
|
| 30 |
async def cmd_start(message: types.Message):
|
| 31 |
+
"""Greets the user and automatically subscribes them to daily updates."""
|
| 32 |
chat_id = message.chat.id
|
|
|
|
|
|
|
| 33 |
async with async_session() as session:
|
| 34 |
+
result = await session.execute(select(User).where(User.chat_id == chat_id))
|
| 35 |
+
user = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
+
if not user:
|
| 38 |
+
user = User(chat_id=chat_id, is_subscribed=True)
|
| 39 |
+
session.add(user)
|
| 40 |
+
await session.commit()
|
| 41 |
+
text = (
|
| 42 |
+
"🌍 <b>Welcome to FlyRates!</b> 🌍\n\n"
|
| 43 |
+
"You have been successfully registered and subscribed to receive a <b>daily LKR exchange rate list</b>!\n\n"
|
| 44 |
+
"Every morning at 8:00 AM UTC, you will receive a beautifully formatted list of all major currencies to Sri Lankan Rupee (LKR).\n\n"
|
| 45 |
+
"🛠️ <b>Quick Commands:</b>\n"
|
| 46 |
+
"• /current - Get the latest rate list instantly\n"
|
| 47 |
+
"• /unsubscribe - Stop receiving daily updates\n"
|
| 48 |
+
"• /help - Display the help guide"
|
| 49 |
+
)
|
| 50 |
+
else:
|
| 51 |
+
if not user.is_subscribed:
|
| 52 |
+
user.is_subscribed = True
|
| 53 |
+
await session.commit()
|
| 54 |
+
text = (
|
| 55 |
+
"🔄 <b>Welcome Back to FlyRates!</b>\n\n"
|
| 56 |
+
"Your subscription to the <b>daily exchange rate list updates</b> has been successfully reactivated!\n\n"
|
| 57 |
+
"🛠️ <b>Quick Commands:</b>\n"
|
| 58 |
+
"• /current - Get the latest rate list instantly\n"
|
| 59 |
+
"• /unsubscribe - Stop receiving daily updates"
|
| 60 |
+
)
|
| 61 |
+
else:
|
| 62 |
+
text = (
|
| 63 |
+
"ℹ️ <b>You are already subscribed!</b>\n\n"
|
| 64 |
+
"You will receive a daily LKR exchange rate summary every morning.\n\n"
|
| 65 |
+
"🛠️ <b>Quick Commands:</b>\n"
|
| 66 |
+
"• /current - Get the latest rate list instantly\n"
|
| 67 |
+
"• /unsubscribe - Stop receiving daily updates"
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
await message.answer(text, parse_mode="HTML")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
@router.message(Command("subscribe"))
|
| 73 |
async def cmd_subscribe(message: types.Message):
|
| 74 |
+
"""Registers or re-enrolls a user in daily updates."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
chat_id = message.chat.id
|
|
|
|
| 76 |
async with async_session() as session:
|
| 77 |
+
result = await session.execute(select(User).where(User.chat_id == chat_id))
|
| 78 |
+
user = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
if not user:
|
| 81 |
+
user = User(chat_id=chat_id, is_subscribed=True)
|
| 82 |
+
session.add(user)
|
| 83 |
+
await session.commit()
|
| 84 |
+
text = "✅ <b>Successfully Subscribed!</b>\n\nYou will now receive a daily LKR exchange rate list update every morning."
|
| 85 |
+
elif not user.is_subscribed:
|
| 86 |
+
user.is_subscribed = True
|
| 87 |
+
await session.commit()
|
| 88 |
+
text = "✅ <b>Subscription Activated!</b>\n\nYou will now receive a daily LKR exchange rate list update every morning."
|
| 89 |
+
else:
|
| 90 |
+
text = "ℹ️ <b>You are already subscribed to daily LKR updates!</b>"
|
| 91 |
+
|
| 92 |
+
await message.answer(text, parse_mode="HTML")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
@router.message(Command("unsubscribe"))
|
| 95 |
async def cmd_unsubscribe(message: types.Message):
|
| 96 |
+
"""Opts out a user from daily broadcasts."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
chat_id = message.chat.id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
async with async_session() as session:
|
| 99 |
+
result = await session.execute(select(User).where(User.chat_id == chat_id))
|
| 100 |
+
user = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
if user and user.is_subscribed:
|
| 103 |
+
user.is_subscribed = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
await session.commit()
|
| 105 |
+
text = (
|
| 106 |
+
"❌ <b>Successfully Unsubscribed</b>\n\n"
|
| 107 |
+
"You have opted-out of daily exchange rate updates. You will no longer receive daily broadcasts.\n\n"
|
| 108 |
+
"💡 You can type /subscribe at any time to activate them again!"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
)
|
| 110 |
else:
|
| 111 |
+
text = (
|
| 112 |
+
"ℹ️ <b>You are not currently subscribed.</b>\n\n"
|
| 113 |
+
"💡 Type /subscribe if you wish to enroll in daily LKR rate updates."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
+
await message.answer(text, parse_mode="HTML")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
@router.message(Command("current"))
|
| 119 |
+
async def cmd_current(message: types.Message):
|
| 120 |
+
"""Instantly fetches and sends the LKR rate list with a Refresh button."""
|
| 121 |
+
# Send a quick loading message so the user knows we are scraping/calculating
|
| 122 |
+
loading_msg = await message.answer("🔄 <i>Fetching live rates from CBSL... Please wait.</i>", parse_mode="HTML")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
+
try:
|
| 125 |
+
rates_text = await get_formatted_current_rates()
|
| 126 |
+
await loading_msg.delete()
|
| 127 |
+
await message.answer(rates_text, reply_markup=get_refresh_keyboard(), parse_mode="HTML")
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.exception("Error serving /current:")
|
| 130 |
+
await loading_msg.edit_text("⚠️ <i>Unable to fetch live rates at this time. Please try again later.</i>", parse_mode="HTML")
|
| 131 |
+
|
| 132 |
+
@router.callback_query(lambda c: c.data == "refresh_rates")
|
| 133 |
+
async def process_refresh_rates(callback_query: types.CallbackQuery):
|
| 134 |
+
"""Updates the LKR rate list message in-place if rates changed."""
|
| 135 |
+
original_text = callback_query.message.text
|
| 136 |
|
| 137 |
+
try:
|
| 138 |
+
new_text = await get_formatted_current_rates()
|
| 139 |
+
|
| 140 |
+
# Clean HTML tags for simple comparison since callback_query.message.text is raw text
|
| 141 |
+
# aiogram's message.text strips HTML tags, so we can just compare content length or basic text
|
| 142 |
+
# To be safe, we always update the message to reflect a new timestamp or show rate updates
|
| 143 |
+
await callback_query.message.edit_text(
|
| 144 |
+
new_text,
|
| 145 |
+
reply_markup=get_refresh_keyboard(),
|
| 146 |
+
parse_mode="HTML"
|
| 147 |
+
)
|
| 148 |
+
await callback_query.answer("Rates updated successfully!")
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logger.error(f"Error handling inline refresh: {e}")
|
| 151 |
+
await callback_query.answer("⚠️ Could not refresh rates right now.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
@router.message(Command("help"))
|
| 154 |
async def cmd_help(message: types.Message):
|
| 155 |
+
"""Displays a clean help manual for the bot."""
|
| 156 |
+
text = (
|
| 157 |
+
"📚 <b>FlyRates Bot Help Guide</b> 📚\n\n"
|
| 158 |
+
"FlyRates is a single-purpose, high-availability exchange rate bot that sends a <b>daily summary of major global currencies converted to Sri Lankan Rupee (LKR)</b>.\n\n"
|
| 159 |
+
"🛠️ <b>Available Commands:</b>\n"
|
| 160 |
+
"• /subscribe - Subscribe to daily rate list updates\n"
|
| 161 |
+
"• /unsubscribe - Opt-out of daily rate updates\n"
|
| 162 |
+
"• /current - Retrieve the live exchange rates list instantly\n"
|
| 163 |
+
"• /help - Display this help manual\n\n"
|
| 164 |
+
"🌍 <b>Supported Currencies:</b>\n"
|
| 165 |
+
"USD, EUR, GBP, AUD, JPY, AED, SAR, INR, CNY, QAR\n\n"
|
| 166 |
+
"🔄 <i>All rates are pulled live from the official Central Bank of Sri Lanka (CBSL).</i>"
|
| 167 |
+
)
|
| 168 |
+
await message.answer(text, parse_mode="HTML")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bot/scheduler.py
CHANGED
|
@@ -1,60 +1,95 @@
|
|
| 1 |
import logging
|
|
|
|
|
|
|
| 2 |
from aiogram import Bot
|
|
|
|
| 3 |
from db.session import async_session
|
| 4 |
-
from db.models import
|
| 5 |
from sqlalchemy import select
|
| 6 |
from services.fx_service import fx_service
|
| 7 |
|
| 8 |
logger = logging.getLogger(__name__)
|
| 9 |
|
| 10 |
-
|
| 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 |
-
|
| 29 |
-
""
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
await session.commit()
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
| 2 |
+
import asyncio
|
| 3 |
+
from datetime import datetime
|
| 4 |
from aiogram import Bot
|
| 5 |
+
from aiogram.exceptions import TelegramForbiddenError, TelegramAPIError
|
| 6 |
from db.session import async_session
|
| 7 |
+
from db.models import User
|
| 8 |
from sqlalchemy import select
|
| 9 |
from services.fx_service import fx_service
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
+
CURRENCIES = ["USD", "EUR", "GBP", "AUD", "JPY", "AED", "SAR", "INR", "CNY", "QAR"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
+
FLAGS = {
|
| 16 |
+
"USD": "🇺🇸",
|
| 17 |
+
"EUR": "🇪🇺",
|
| 18 |
+
"GBP": "🇬🇧",
|
| 19 |
+
"AUD": "🇦🇺",
|
| 20 |
+
"JPY": "🇯🇵",
|
| 21 |
+
"AED": "🇦🇪",
|
| 22 |
+
"SAR": "🇸🇦",
|
| 23 |
+
"INR": "🇮🇳",
|
| 24 |
+
"CNY": "🇨🇳",
|
| 25 |
+
"QAR": "🇶🇦"
|
| 26 |
+
}
|
| 27 |
|
| 28 |
+
def format_rates_list(rates: dict) -> str:
|
| 29 |
+
"""Formats the rates dictionary into a beautifully styled HTML message."""
|
| 30 |
+
lines = [
|
| 31 |
+
"🌍 <b>Daily LKR Exchange Rates</b> 🌍",
|
| 32 |
+
f"📅 <i>Date: {datetime.now().strftime('%Y-%m-%d')}</i>",
|
| 33 |
+
""
|
| 34 |
+
]
|
| 35 |
+
for cur in CURRENCIES:
|
| 36 |
+
rate = rates.get(cur)
|
| 37 |
+
flag = FLAGS.get(cur, "💱")
|
| 38 |
+
if rate:
|
| 39 |
+
lines.append(f"{flag} <b>1 {cur}</b> = {rate:.2f} LKR")
|
| 40 |
+
else:
|
| 41 |
+
lines.append(f"{flag} <b>1 {cur}</b> = <i>Scraper Offline</i>")
|
| 42 |
+
|
| 43 |
+
lines.extend([
|
| 44 |
+
"",
|
| 45 |
+
"🔄 <i>Rates are sourced directly from the Central Bank of Sri Lanka (CBSL).</i>",
|
| 46 |
+
"❌ Type /unsubscribe to opt-out of these daily updates."
|
| 47 |
+
])
|
| 48 |
+
return "\n".join(lines)
|
| 49 |
|
| 50 |
+
async def process_daily_broadcast(bot: Bot):
|
| 51 |
+
"""Processes the scheduled daily broadcast to all subscribed users."""
|
| 52 |
+
logger.info("Starting daily exchange rate broadcast...")
|
| 53 |
+
|
| 54 |
+
# 1. Fetch all exchange rates concurrently
|
| 55 |
+
tasks = [fx_service.get_rate(cur, "LKR") for cur in CURRENCIES]
|
| 56 |
+
rates_values = await asyncio.gather(*tasks)
|
| 57 |
+
rates = dict(zip(CURRENCIES, rates_values))
|
| 58 |
+
|
| 59 |
+
# 2. Format the message
|
| 60 |
+
broadcast_message = format_rates_list(rates)
|
| 61 |
+
|
| 62 |
+
# 3. Retrieve all active subscribers
|
| 63 |
+
async with async_session() as session:
|
| 64 |
+
result = await session.execute(select(User.chat_id).where(User.is_subscribed == True))
|
| 65 |
+
subscribers = result.scalars().all()
|
| 66 |
+
|
| 67 |
+
if not subscribers:
|
| 68 |
+
logger.info("No active subscribers found for daily broadcast.")
|
| 69 |
+
return
|
| 70 |
+
|
| 71 |
+
logger.info(f"Broadcasting to {len(subscribers)} active subscribers...")
|
| 72 |
+
|
| 73 |
+
# 4. Deliver updates
|
| 74 |
+
for chat_id in subscribers:
|
| 75 |
+
try:
|
| 76 |
+
await bot.send_message(
|
| 77 |
+
chat_id=chat_id,
|
| 78 |
+
text=broadcast_message,
|
| 79 |
+
parse_mode="HTML"
|
| 80 |
+
)
|
| 81 |
+
logger.info(f"Daily broadcast sent successfully to chat_id: {chat_id}")
|
| 82 |
+
except TelegramForbiddenError:
|
| 83 |
+
# User blocked the bot - deactivate subscription to save future API limits
|
| 84 |
+
logger.warning(f"User {chat_id} has blocked the bot. Opting them out of daily updates...")
|
| 85 |
+
user_res = await session.execute(select(User).where(User.chat_id == chat_id))
|
| 86 |
+
user_obj = user_res.scalar_one_or_none()
|
| 87 |
+
if user_obj:
|
| 88 |
+
user_obj.is_subscribed = False
|
| 89 |
await session.commit()
|
| 90 |
+
except TelegramAPIError as e:
|
| 91 |
+
logger.error(f"Telegram API error when broadcasting to {chat_id}: {e}")
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Unexpected error when broadcasting to {chat_id}: {e}")
|
| 94 |
+
|
| 95 |
+
logger.info("Daily exchange rate broadcast completed successfully.")
|
db/models.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
from sqlalchemy import Column,
|
| 2 |
-
from sqlalchemy.orm import declarative_base
|
| 3 |
from datetime import datetime, timezone
|
| 4 |
|
| 5 |
Base = declarative_base()
|
|
@@ -8,34 +8,5 @@ class User(Base):
|
|
| 8 |
__tablename__ = "users"
|
| 9 |
|
| 10 |
chat_id = Column(BigInteger, primary_key=True, index=True)
|
|
|
|
| 11 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
|
| 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(BigInteger, 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).replace(tzinfo=None))
|
| 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(BigInteger, 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).replace(tzinfo=None))
|
| 40 |
-
|
| 41 |
-
user = relationship("User", back_populates="thresholds")
|
|
|
|
| 1 |
+
from sqlalchemy import Column, DateTime, Boolean, BigInteger
|
| 2 |
+
from sqlalchemy.orm import declarative_base
|
| 3 |
from datetime import datetime, timezone
|
| 4 |
|
| 5 |
Base = declarative_base()
|
|
|
|
| 8 |
__tablename__ = "users"
|
| 9 |
|
| 10 |
chat_id = Column(BigInteger, primary_key=True, index=True)
|
| 11 |
+
is_subscribed = Column(Boolean, default=True)
|
| 12 |
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc).replace(tzinfo=None))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
db/session.py
CHANGED
|
@@ -38,40 +38,30 @@ async def init_db():
|
|
| 38 |
"""Initializes the database and creates all tables."""
|
| 39 |
try:
|
| 40 |
async with engine.begin() as conn:
|
|
|
|
| 41 |
await conn.run_sync(Base.metadata.create_all)
|
| 42 |
|
| 43 |
-
#
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
ALTER TABLE
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
FOREIGN KEY (chat_id) REFERENCES users(chat_id) ON DELETE CASCADE;
|
| 65 |
-
ALTER TABLE thresholds ADD CONSTRAINT thresholds_chat_id_fkey
|
| 66 |
-
FOREIGN KEY (chat_id) REFERENCES users(chat_id) ON DELETE CASCADE;
|
| 67 |
-
EXCEPTION
|
| 68 |
-
WHEN OTHERS THEN
|
| 69 |
-
RAISE NOTICE 'Migration encountered an error: %', SQLERRM;
|
| 70 |
-
END $$;
|
| 71 |
-
"""
|
| 72 |
-
from sqlalchemy import text
|
| 73 |
-
await conn.execute(text(migration_sql))
|
| 74 |
-
logger.info("Database chat_id columns successfully migrated/verified as BIGINT.")
|
| 75 |
|
| 76 |
logger.info("Database initialized successfully.")
|
| 77 |
except Exception as e:
|
|
|
|
| 38 |
"""Initializes the database and creates all tables."""
|
| 39 |
try:
|
| 40 |
async with engine.begin() as conn:
|
| 41 |
+
# 1. Create tables defined in models.py (creates users table if not exists)
|
| 42 |
await conn.run_sync(Base.metadata.create_all)
|
| 43 |
|
| 44 |
+
# 2. Database migrations and cleanups
|
| 45 |
+
is_postgres = "postgresql" in engine.url.drivername or "postgres" in engine.url.drivername
|
| 46 |
+
from sqlalchemy import text
|
| 47 |
+
|
| 48 |
+
if is_postgres:
|
| 49 |
+
logger.info("Running on PostgreSQL. Ensuring schema matches simplified design...")
|
| 50 |
+
# Run each statement individually to comply with asyncpg single-statement execution rules
|
| 51 |
+
await conn.execute(text("DROP TABLE IF EXISTS thresholds CASCADE"))
|
| 52 |
+
await conn.execute(text("DROP TABLE IF EXISTS subscriptions CASCADE"))
|
| 53 |
+
await conn.execute(text("ALTER TABLE users ALTER COLUMN chat_id TYPE BIGINT"))
|
| 54 |
+
await conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_subscribed BOOLEAN DEFAULT TRUE"))
|
| 55 |
+
logger.info("PostgreSQL database schema successfully migrated and simplified.")
|
| 56 |
+
else:
|
| 57 |
+
logger.info("Running on SQLite. Running schema verification...")
|
| 58 |
+
# SQLite fallback to add is_subscribed column if it does not exist
|
| 59 |
+
try:
|
| 60 |
+
await conn.execute(text("ALTER TABLE users ADD COLUMN is_subscribed BOOLEAN DEFAULT 1"))
|
| 61 |
+
logger.info("Successfully added is_subscribed column to SQLite users table.")
|
| 62 |
+
except Exception:
|
| 63 |
+
# Column already exists
|
| 64 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
logger.info("Database initialized successfully.")
|
| 67 |
except Exception as e:
|
main.py
CHANGED
|
@@ -26,9 +26,9 @@ socket.getaddrinfo = custom_getaddrinfo
|
|
| 26 |
|
| 27 |
from core.config import settings
|
| 28 |
from db.session import init_db, async_session
|
| 29 |
-
from db.models import User
|
| 30 |
from bot.handlers import router as bot_router
|
| 31 |
-
from bot.scheduler import
|
| 32 |
from services.fx_service import fx_service
|
| 33 |
|
| 34 |
# Configure Logging
|
|
@@ -161,10 +161,8 @@ async def lifespan(app: FastAPI):
|
|
| 161 |
except Exception as e:
|
| 162 |
logger.error(f"Failed to setup Telegram interaction during startup: {e}")
|
| 163 |
|
| 164 |
-
# 3. Setup Scheduled Jobs
|
| 165 |
-
scheduler.add_job(
|
| 166 |
-
scheduler.add_job(process_subscriptions, 'cron', hour=8, args=[bot, 'daily'])
|
| 167 |
-
scheduler.add_job(process_subscriptions, 'interval', hours=1, args=[bot, 'hourly'])
|
| 168 |
scheduler.start()
|
| 169 |
|
| 170 |
yield
|
|
@@ -227,14 +225,12 @@ async def get_system_stats():
|
|
| 227 |
stats = {}
|
| 228 |
try:
|
| 229 |
async with async_session() as session:
|
| 230 |
-
# Gather
|
| 231 |
-
users_count = await session.scalar(select(func.count(User.chat_id)))
|
| 232 |
-
subs_count = await session.scalar(select(func.count(Subscription.id)))
|
| 233 |
-
thresholds_count = await session.scalar(select(func.count(Threshold.id)).where(Threshold.is_active == True))
|
| 234 |
|
| 235 |
stats["subscribers"] = users_count or 0
|
| 236 |
-
stats["active_subscriptions"] =
|
| 237 |
-
stats["active_thresholds"] =
|
| 238 |
stats["db_status"] = "connected"
|
| 239 |
except Exception as e:
|
| 240 |
logger.error(f"Error fetching stats from DB: {e}")
|
|
|
|
| 26 |
|
| 27 |
from core.config import settings
|
| 28 |
from db.session import init_db, async_session
|
| 29 |
+
from db.models import User
|
| 30 |
from bot.handlers import router as bot_router
|
| 31 |
+
from bot.scheduler import process_daily_broadcast
|
| 32 |
from services.fx_service import fx_service
|
| 33 |
|
| 34 |
# Configure Logging
|
|
|
|
| 161 |
except Exception as e:
|
| 162 |
logger.error(f"Failed to setup Telegram interaction during startup: {e}")
|
| 163 |
|
| 164 |
+
# 3. Setup Scheduled Jobs (exactly one clean daily broadcast at 8:00 AM)
|
| 165 |
+
scheduler.add_job(process_daily_broadcast, 'cron', hour=8, args=[bot])
|
|
|
|
|
|
|
| 166 |
scheduler.start()
|
| 167 |
|
| 168 |
yield
|
|
|
|
| 225 |
stats = {}
|
| 226 |
try:
|
| 227 |
async with async_session() as session:
|
| 228 |
+
# Gather active subscribers count
|
| 229 |
+
users_count = await session.scalar(select(func.count(User.chat_id)).where(User.is_subscribed == True))
|
|
|
|
|
|
|
| 230 |
|
| 231 |
stats["subscribers"] = users_count or 0
|
| 232 |
+
stats["active_subscriptions"] = 10 # 10 core LKR currencies tracked
|
| 233 |
+
stats["active_thresholds"] = 1 # Unified active broadcast schedule
|
| 234 |
stats["db_status"] = "connected"
|
| 235 |
except Exception as e:
|
| 236 |
logger.error(f"Error fetching stats from DB: {e}")
|
static/index.html
CHANGED
|
@@ -49,16 +49,16 @@
|
|
| 49 |
</div>
|
| 50 |
</div>
|
| 51 |
<div class="metric-card" id="metric-subs">
|
| 52 |
-
<div class="card-icon"><i class="fa-solid fa-
|
| 53 |
<div class="card-info">
|
| 54 |
-
<h3>
|
| 55 |
<div class="value" id="stats-subscriptions">-</div>
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
<div class="metric-card" id="metric-thresholds">
|
| 59 |
-
<div class="card-icon"><i class="fa-solid fa-
|
| 60 |
<div class="card-info">
|
| 61 |
-
<h3>Active
|
| 62 |
<div class="value" id="stats-thresholds">-</div>
|
| 63 |
</div>
|
| 64 |
</div>
|
|
@@ -99,7 +99,7 @@
|
|
| 99 |
<div class="glass-card telegram-promotion">
|
| 100 |
<div class="promo-content">
|
| 101 |
<h3>Get rate updates directly in Telegram!</h3>
|
| 102 |
-
<p>Subscribe to
|
| 103 |
<a href="https://t.me/FlyRatesBot" target="_blank" class="btn btn-primary">
|
| 104 |
<i class="fa-brands fa-telegram"></i> Launch Telegram Bot
|
| 105 |
</a>
|
|
|
|
| 49 |
</div>
|
| 50 |
</div>
|
| 51 |
<div class="metric-card" id="metric-subs">
|
| 52 |
+
<div class="card-icon"><i class="fa-solid fa-globe"></i></div>
|
| 53 |
<div class="card-info">
|
| 54 |
+
<h3>Currencies Tracked</h3>
|
| 55 |
<div class="value" id="stats-subscriptions">-</div>
|
| 56 |
</div>
|
| 57 |
</div>
|
| 58 |
<div class="metric-card" id="metric-thresholds">
|
| 59 |
+
<div class="card-icon"><i class="fa-solid fa-calendar-check"></i></div>
|
| 60 |
<div class="card-info">
|
| 61 |
+
<h3>Active Schedules</h3>
|
| 62 |
<div class="value" id="stats-thresholds">-</div>
|
| 63 |
</div>
|
| 64 |
</div>
|
|
|
|
| 99 |
<div class="glass-card telegram-promotion">
|
| 100 |
<div class="promo-content">
|
| 101 |
<h3>Get rate updates directly in Telegram!</h3>
|
| 102 |
+
<p>Subscribe to receive a beautifully formatted daily exchange rate list automatically.</p>
|
| 103 |
<a href="https://t.me/FlyRatesBot" target="_blank" class="btn btn-primary">
|
| 104 |
<i class="fa-brands fa-telegram"></i> Launch Telegram Bot
|
| 105 |
</a>
|