Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,17 @@ An automated Telegram bot designed to help gamers find the lowest prices for Pla
- **Global Search:** Compare prices across multiple regions (TR, US, UA, PL, etc.) in one click
- **Price Tracking:** Subscribe to your favorite games
- **Smart Notifications:** Get alerts when prices drop or a sale starts
- **Sale History:** Past sales for subscribed games while you track them — see [`docs/features/price-history.md`](docs/features/price-history.md)
- **Currency Conversion:** View all prices converted to your preferred currency (USD by default)

### Commands

| Command | Description |
|---------------------|------------------------------------------------------------------|
| `/start` | Welcome message |
| `/currency` | View or change your display currency |
| `/add_region` | Search and add a PS Store region to track |
| `/my_regions` | Manage your tracked regions |
| `/settings` | Display currency, tracked regions, sale history format |
| `/search` | Search for a game and compare prices across your tracked regions |
| `/my_subscriptions` | View your subscribed games with current prices |
| `/subscriptions` | View your subscribed games with current prices |

---

Expand Down Expand Up @@ -159,3 +158,4 @@ Migrations run automatically before `bot` and `worker` start.
| [`services/README.md`](services/README.md) | Services: currency conversion logic, exchange rates, display |
| [`deploy/vector/README.md`](deploy/vector/README.md) | Vector alerting pipeline: sources, transforms, metrics |
| [`research/README.md`](research/README.md) | PS Store search grouping research |
| [`docs/features/`](docs/features/) | Feature overviews (product scope, user-facing behaviour) |
80 changes: 75 additions & 5 deletions bot/formatters.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from datetime import datetime, timezone

from services.currency import DEFAULT_BASE_CURRENCY, PS_CURRENCY_MAP, PS_ISO_TO_SYMBOL, convert
from services.price_history import UserGameSaleHistory, format_sale_when
from services.ps_store import GameInfo, RegionPrice

TYPE_EMOJI = {
Expand Down Expand Up @@ -163,13 +166,68 @@ def format_game_list(
def _offer_end_line(prices: dict[str, RegionPrice]) -> str | None:
for rp in prices.values():
if rp.discount_end and rp.base_price is not None:
d = rp.discount_end
if d.hour or d.minute:
return f"{d.day}/{d.month}/{d.year} {d.strftime('%H:%M')} UTC"
return f"{d.day}/{d.month}/{d.year} UTC"
return _format_offer_end(rp.discount_end)
return None


def _format_tracking_since(dt) -> str:
return dt.strftime("%d %b %Y")


def _format_offer_end(dt: datetime) -> str:
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
else:
dt = dt.astimezone(timezone.utc)
if dt.hour or dt.minute:
return f"{dt.strftime('%d %b %Y %H:%M')} UTC"
return dt.strftime("%d %b %Y")


def _format_sale_price(
price: float,
region_currency: str,
rates: dict[str, float] | None,
base_currency: str,
) -> str:
native = _format_price(price, region_currency)
iso = PS_CURRENCY_MAP.get(region_currency, region_currency)
converted = convert(price, region_currency, base_currency, rates) if rates else None
return f"{native}{_base_str(converted, base_currency, iso)}"


def format_past_sales_lines(
sale_history: UserGameSaleHistory | None,
history_format: str,
*,
limit_per_region: int,
rates: dict[str, float] | None = None,
base_currency: str = DEFAULT_BASE_CURRENCY,
show_tracking_footer: bool = True,
) -> list[str]:
if sale_history is None:
return []

lines: list[str] = []
if sale_history.total_sales > 0:
lines.append("\n📉 Past sales:")
for region_hist in sale_history.regions:
if not region_hist.sales:
continue
flag = locale_flag(region_hist.region_code)
lines.append(f"{flag}")
for price, recorded_at in region_hist.sales[:limit_per_region]:
when = format_sale_when(recorded_at, history_format)
price_label = _format_sale_price(price, region_hist.currency, rates, base_currency)
lines.append(f"• {price_label} — {when}")

if show_tracking_footer:
since = _format_tracking_since(sale_history.tracking_since)
lines.append(f"\n<i>Tracking since {since}</i>")

return lines


def format_game_card(
game: GameInfo,
prices: dict[str, RegionPrice],
Expand All @@ -178,14 +236,26 @@ def format_game_card(
title: str = "",
footer: str = "",
base_currency: str = DEFAULT_BASE_CURRENCY,
sale_history: UserGameSaleHistory | None = None,
history_format: str = "duration",
history_limit: int = 3,
) -> str:
lines = _game_header(game)
if prices:
lines.append("\nPrices by region:")
lines.extend(_card_price_lines(prices, rates, old_prices, base_currency))
offer_end = _offer_end_line(prices)
if offer_end:
lines.append(f"\nOffer ends:\n<b>{offer_end}</b>")
lines.append(f"\nOffer ends <b>{offer_end}</b>")
lines.extend(
format_past_sales_lines(
sale_history,
history_format,
limit_per_region=history_limit,
rates=rates,
base_currency=base_currency,
)
)
body = "\n".join(lines)
parts = [p for p in (title, body, footer) if p]
return "\n\n".join(parts)
4 changes: 2 additions & 2 deletions bot/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
from aiogram import Router

from bot.handlers import currency, regions, search, start, subscriptions
from bot.handlers import regions, search, settings, start, subscriptions

router = Router()
router.include_router(start.router)
router.include_router(currency.router)
router.include_router(settings.router)
router.include_router(regions.router)
router.include_router(search.router)
router.include_router(subscriptions.router)
65 changes: 0 additions & 65 deletions bot/handlers/currency.py

This file was deleted.

59 changes: 15 additions & 44 deletions bot/handlers/regions.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession

from bot.keyboards.inline import (
cancel_keyboard,
ps_regions_keyboard,
user_regions_keyboard,
)
from bot.keyboards.inline import cancel_keyboard, ps_regions_keyboard, settings_regions_keyboard
from bot.states.subscription import RegionForm
from services.ps_api import get_ps_regions
from services.region import (
Expand Down Expand Up @@ -51,39 +46,14 @@ async def _do_region_search(message: Message, session: AsyncSession, query: str)
)


@router.message(Command("add_region"))
async def cmd_add_region(message: Message, state: FSMContext, session: AsyncSession) -> None:
query = message.text.partition(" ")[2].strip()
if query:
await _do_region_search(message, session, query)
else:
await state.set_state(RegionForm.waiting_for_search)
await message.answer(
"Type a country name to search:",
reply_markup=cancel_keyboard(),
)


@router.message(Command("my_regions"))
async def cmd_my_regions(message: Message, session: AsyncSession) -> None:
user = await get_or_create_user(
session, message.from_user.id, message.from_user.username
)
await session.commit()

regions = await get_user_regions(session, user.id)
if not regions:
await message.answer(
"You have no tracked regions yet.\n"
"Add one with /add_region"
)
return

await message.answer(
"Your regions (tap to remove):\n\n"
"Add a new one: /add_region",
reply_markup=user_regions_keyboard(regions),
@router.callback_query(F.data == "settings:regions:add")
async def on_settings_regions_add(callback: CallbackQuery, state: FSMContext) -> None:
await state.set_state(RegionForm.waiting_for_search)
await callback.message.edit_text(
"Type a country name to search:",
reply_markup=cancel_keyboard(),
)
await callback.answer()


@router.message(RegionForm.waiting_for_search, ~F.text.startswith("/"))
Expand All @@ -105,7 +75,7 @@ async def on_noop(callback: CallbackQuery) -> None:
@router.callback_query(F.data == "cancel")
async def on_cancel(callback: CallbackQuery, state: FSMContext) -> None:
await state.clear()
await callback.message.edit_text("Cancelled.")
await callback.message.edit_text("Cancelled. Open /settings to continue.")


@router.callback_query(F.data.startswith("region_add:"))
Expand All @@ -131,9 +101,10 @@ async def on_region_add(
await state.clear()

if added:
regions = await get_user_regions(session, user.id)
await callback.message.edit_text(
f"✓ <b>{country['name']}</b> added to your tracked regions.\n\n"
"View your regions: /my_regions"
f"✓ <b>{country['name']}</b> added to your tracked regions.",
reply_markup=settings_regions_keyboard(regions),
)
await sync_subscriptions_for_new_region(session, user, region)
else:
Expand All @@ -156,12 +127,12 @@ async def on_region_remove(
regions = await get_user_regions(session, user.id)
if not regions:
await callback.message.edit_text(
"You have no tracked regions yet.\n"
"Add one with /add_region"
"You have no tracked regions yet.\nAdd one to search games and track prices.",
reply_markup=settings_regions_keyboard(regions),
)
return

await callback.message.edit_reply_markup(
reply_markup=user_regions_keyboard(regions)
reply_markup=settings_regions_keyboard(regions)
)
await callback.answer()
4 changes: 2 additions & 2 deletions bot/handlers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ async def _do_search(message: Message, state: FSMContext, session: AsyncSession,
user_regions = await get_user_regions(session, user.id)

if not user_regions:
await message.answer("No regions added yet.\nAdd one with /add_region")
await message.answer("No regions added yet.\nAdd one in /settings")
return

user_region_codes = [r.code for r in user_regions]
Expand Down Expand Up @@ -187,7 +187,7 @@ async def _do_search(message: Message, state: FSMContext, session: AsyncSession,
await state.update_data(entries=entries, rates=rates, base_currency=base_currency)

hidden = len(all_games) - len(games)
footer = "Want to track prices in more regions?\nAdd a new one: /add_region"
footer = "Want to track prices in more regions?\nAdd a new one in /settings"
if hidden:
notice = f"<b>Showing {len(games)} of {len(all_games)} results</b>. Refine your query to see more."
footer = f"{notice}\n\n{footer}"
Expand Down
Loading
Loading