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
Files changed (6) hide show
  1. bot/handlers.py +128 -891
  2. bot/scheduler.py +83 -48
  3. db/models.py +3 -32
  4. db/session.py +22 -32
  5. main.py +8 -12
  6. 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 services.fx_service import fx_service, ALLOWED_CURRENCIES
7
- from db.session import get_session
8
- from db.models import User, Subscription, Threshold
9
  from sqlalchemy import select
10
- import logging
 
11
 
12
  logger = logging.getLogger(__name__)
13
  router = Router()
14
 
15
- # Flag mapping for clean and visual currency buttons
16
- CURRENCY_FLAGS = {
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
- """Registers user and sends welcome message."""
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
- try:
105
- result = await session.execute(select(User).where(User.chat_id == chat_id))
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 &lt;base&gt; [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
- flag_base = CURRENCY_FLAGS.get(base, "")
165
- flag_target = CURRENCY_FLAGS.get(target, "")
166
- await message.answer(
167
- f"📈 <b>Current Rate:</b>\n"
168
- f"1 {flag_base} {base} = <b>{rate}</b> {flag_target} {target}\n\n"
169
- f"<i>Last updated: Just now</i>",
170
- reply_markup=builder.as_markup(),
171
- parse_mode="HTML"
172
- )
173
- else:
174
- await message.answer(" Failed to fetch rate. Please try again later.")
175
-
176
- # --- Global Callback Handlers ---
177
-
178
- @router.callback_query(lambda c: c.data == "cancel_action")
179
- async def callback_cancel_action(callback: types.CallbackQuery, state: FSMContext):
180
- await state.clear()
181
- await callback.message.edit_text("❌ Action cancelled.")
182
- await callback.answer()
183
-
184
- @router.callback_query(lambda c: c.data == "delete_message")
185
- async def callback_delete_message(callback: types.CallbackQuery):
186
- try:
187
- await callback.message.delete()
188
- except Exception:
189
- pass
190
- await callback.answer()
191
-
192
- # --- Callback Handlers for Current Rates ---
193
-
194
- @router.callback_query(lambda c: c.data == "curr_start")
195
- async def callback_curr_start(callback: types.CallbackQuery):
196
- reply_markup = get_currency_keyboard("curr_base", preset_action="curr")
197
- await callback.message.edit_text(
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
- """Subscribes the user to periodic updates."""
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 &lt;base&gt; [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 &lt;base&gt; [target] &lt;condition&gt; &lt;value&gt;</code>\n"
419
- "Example: <code>/threshold USD &lt; 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
- subs_result = await session.execute(select(Subscription).where(Subscription.chat_id == chat_id))
612
- subs = subs_result.scalars().all()
613
-
614
- thresholds_result = await session.execute(select(Threshold).where(Threshold.chat_id == chat_id))
615
- thresholds = thresholds_result.scalars().all()
616
 
617
- response = "📋 <b>Your Active Settings Dashboard</b>\n\n"
618
-
619
- if subs:
620
- response += "🔔 <b>Subscriptions:</b>\n"
621
- for sub in subs:
622
- flag_base = CURRENCY_FLAGS.get(sub.base_currency, "")
623
- flag_target = CURRENCY_FLAGS.get(sub.target_currency, "")
624
- response += f"• {flag_base} {sub.base_currency} ➡️ {flag_target} {sub.target_currency} (<i>{sub.frequency}</i>)\n"
625
- else:
626
- response += "🔔 <b>Subscriptions:</b> None\n"
627
-
628
- response += "\n"
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
- """Removes a subscription (direct or interactive)."""
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 &lt;base&gt; [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(Subscription).where(Subscription.chat_id == callback.message.chat.id))
725
- subs = result.scalars().all()
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
- builder = InlineKeyboardBuilder()
733
- for sub in subs:
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
- builder = InlineKeyboardBuilder()
765
- builder.button(text=" Close", callback_data="delete_message")
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
- await callback.message.edit_text("❌ Subscription not found or already deleted.")
776
- await callback.answer()
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 &lt;base&gt; [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
- for th in thresholds:
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.callback_query(lambda c: c.data == "delth_start")
849
- async def callback_delth_start(callback: types.CallbackQuery):
850
- from db.session import async_session
851
- async with async_session() as session:
852
- result = await session.execute(select(Threshold).where(Threshold.chat_id == callback.message.chat.id))
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
- await callback.message.edit_text(
873
- "➖ <b>Manage Threshold Alerts</b>\n\nClick on any alert to delete it:",
874
- reply_markup=builder.as_markup(),
875
- parse_mode="HTML"
876
- )
877
- await callback.answer()
878
-
879
- @router.callback_query(lambda c: c.data.startswith("delth_id:"))
880
- async def callback_delth_id(callback: types.CallbackQuery):
881
- th_id = int(callback.data.split(":")[1])
 
 
882
 
883
- from db.session import async_session
884
- async with async_session() as session:
885
- th_result = await session.execute(select(Threshold).where(Threshold.id == th_id))
886
- th = th_result.scalar_one_or_none()
887
- if th:
888
- base = th.base_currency
889
- target = th.target_currency
890
- await session.delete(th)
891
- await session.commit()
892
-
893
- builder = InlineKeyboardBuilder()
894
- builder.button(text=" Close", callback_data="delete_message")
895
-
896
- flag_base = CURRENCY_FLAGS.get(base, "")
897
- flag_target = CURRENCY_FLAGS.get(target, "")
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
- """Shows detailed usage instructions."""
910
- currencies_str = ", ".join(ALLOWED_CURRENCIES)
911
- help_text = (
912
- "📚 <b>FlyRates Bot Help</b> 📚\n\n"
913
- "Here are the commands you can use:\n\n"
914
- "<b>Basic Commands</b>\n"
915
- "<code>/start</code> - Get welcome message\n"
916
- "<code>/help</code> - Show this help menu\n\n"
917
- "<b>Rates & Updates</b>\n"
918
- "<code>/current &lt;base&gt; [target]</code> - Get the live rate\n"
919
- "<code>/subscribe &lt;base&gt; [target] [daily/hourly]</code> - Automate updates\n"
920
- "<code>/threshold &lt;base&gt; [target] &lt;condition&gt; &lt;value&gt;</code> - Get custom alerts (&lt;, &gt;, &lt;=, &gt;=)\n\n"
921
- "<b>Management</b>\n"
922
- "<code>/mysubs</code> - View all your active subscriptions and alerts\n"
923
- "<code>/unsubscribe &lt;base&gt; [target]</code> - Remove a subscription\n"
924
- "<code>/delthreshold &lt;base&gt; [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 &lt; 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 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}")
 
 
 
 
 
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, Integer, String, Float, DateTime, Boolean, ForeignKey, BigInteger
2
- from sqlalchemy.orm import declarative_base, relationship
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
- # Check if running on PostgreSQL/Supabase and perform auto-migration of chat_id columns to BIGINT
44
- if "postgresql" in engine.url.drivername or "postgres" in engine.url.drivername:
45
- logger.info("Running on PostgreSQL. Ensuring chat_id columns are BIGINT...")
46
- migration_sql = """
47
- DO $$
48
- BEGIN
49
- -- Drop foreign key constraints if they exist
50
- IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'subscriptions_chat_id_fkey') THEN
51
- ALTER TABLE subscriptions DROP CONSTRAINT subscriptions_chat_id_fkey;
52
- END IF;
53
- IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'thresholds_chat_id_fkey') THEN
54
- ALTER TABLE thresholds DROP CONSTRAINT thresholds_chat_id_fkey;
55
- END IF;
56
-
57
- -- Alter columns to BIGINT
58
- ALTER TABLE users ALTER COLUMN chat_id TYPE BIGINT;
59
- ALTER TABLE subscriptions ALTER COLUMN chat_id TYPE BIGINT;
60
- ALTER TABLE thresholds ALTER COLUMN chat_id TYPE BIGINT;
61
-
62
- -- Re-create constraints with CASCADE delete (matching SQLAlchemy cascade configuration)
63
- ALTER TABLE subscriptions ADD CONSTRAINT subscriptions_chat_id_fkey
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, Subscription, Threshold
30
  from bot.handlers import router as bot_router
31
- from bot.scheduler import process_subscriptions, process_thresholds
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(process_thresholds, 'interval', minutes=10, args=[bot])
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 subscriber (user) counts, subscriptions, and active thresholds
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"] = subs_count or 0
237
- stats["active_thresholds"] = thresholds_count or 0
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-bell"></i></div>
53
  <div class="card-info">
54
- <h3>Active Subscriptions</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-triangle-exclamation"></i></div>
60
  <div class="card-info">
61
- <h3>Active Alerts</h3>
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 automated updates and custom threshold alerts instantly.</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>
 
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>