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
30 changes: 16 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,18 @@ 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
- **Currency Conversion:** View all prices in your local currency
- **Currency Conversion:** View all prices converted to your preferred currency (USD by default)

### Commands

| Command | Description |
|----------------------|------------------------------------------------------------------|
| `/start` | Welcome message |
| `/add_region` | Search and add a PS Store region to track |
| `/my_regions` | Manage your tracked regions |
| `/search` | Search for a game and compare prices across your tracked regions |
| `/my_subscriptions` | View your subscribed games with current prices |
| 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 |
| `/search` | Search for a game and compare prices across your tracked regions |
| `/my_subscriptions` | View your subscribed games with current prices |

---

Expand Down Expand Up @@ -151,9 +152,10 @@ Migrations run automatically before `bot` and `worker` start.

## Documentation

| Document | Description |
|------------------------------------------------------|-------------------------------------------------------------|
| [`worker/README.md`](worker/README.md) | Worker jobs: price check, notifications, aggregation window |
| [`db/models/README.md`](db/models/README.md) | Database schema: tables, relationships, key constraints |
| [`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 |
| Document | Description |
|------------------------------------------------------|--------------------------------------------------------------|
| [`worker/README.md`](worker/README.md) | Worker jobs: price check, notifications, aggregation window |
| [`db/models/README.md`](db/models/README.md) | Database schema: tables, relationships, key constraints |
| [`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 |
58 changes: 35 additions & 23 deletions bot/formatters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@

from services.currency import PS_CURRENCY_MAP, convert_to_usd
from services.currency import DEFAULT_BASE_CURRENCY, PS_CURRENCY_MAP, PS_ISO_TO_SYMBOL, convert
from services.ps_store import GameInfo, RegionPrice

TYPE_EMOJI = {
Expand Down Expand Up @@ -27,27 +26,39 @@ def _format_price(amount: float, currency: str) -> str:
return f"{currency}{sep}{amount:.2f}"


def _usd_by_locale(
def _base_by_locale(
prices: dict[str, RegionPrice],
rates: dict[str, float] | None,
base_currency: str,
) -> dict[str, float | None]:
result = {}
for locale, rp in prices.items():
if rp.price is not None and rp.currency is not None and rates is not None:
result[locale] = convert_to_usd(rp.price, rp.currency, rates)
result[locale] = convert(rp.price, rp.currency, base_currency, rates)
else:
result[locale] = None
return result


def _base_str(base_value: float | None, base_currency: str, region_iso: str) -> str:
if base_value is None or region_iso == base_currency:
return ""
symbol = PS_ISO_TO_SYMBOL.get(base_currency, base_currency)
sep = " " if symbol.isalpha() else ""
if base_value == int(base_value):
return f" ({symbol}{sep}{int(base_value)})"
return f" ({symbol}{sep}{base_value:.2f})"


def _price_line(
prices: dict[str, RegionPrice],
rates: dict[str, float] | None,
base_currency: str = DEFAULT_BASE_CURRENCY,
) -> str:
usd_map = _usd_by_locale(prices, rates)
base_map = _base_by_locale(prices, rates, base_currency)
cheapest = min(
(loc for loc, usd in usd_map.items() if usd is not None),
key=lambda loc: usd_map[loc],
(loc for loc, val in base_map.items() if val is not None),
key=lambda loc: base_map[loc],
default=None,
)

Expand All @@ -58,12 +69,12 @@ def _price_line(
parts.append(f"{flag} N/A")
continue

usd = usd_map.get(locale)
base_val = base_map.get(locale)
iso = PS_CURRENCY_MAP.get(rp.currency, rp.currency)
usd_str = f" (${usd:.2f})" if usd is not None and iso != "USD" else ""
base_suffix = _base_str(base_val, base_currency, iso)
strike = f"<s>{_format_price(rp.base_price, rp.currency)}</s> " if rp.base_price is not None else ""
discount_str = f" {rp.discount_text}" if rp.discount_text else ""
price_label = f"{strike}{_format_price(rp.price, rp.currency)}{discount_str}{usd_str}"
price_label = f"{strike}{_format_price(rp.price, rp.currency)}{discount_str}{base_suffix}"
text = f"{flag} {price_label}"

if locale == cheapest and cheapest is not None and len(prices) > 1:
Expand All @@ -84,11 +95,12 @@ def _card_price_lines(
prices: dict[str, RegionPrice],
rates: dict[str, float] | None,
old_prices: dict[str, float] | None = None,
base_currency: str = DEFAULT_BASE_CURRENCY,
) -> list[str]:
usd_map = _usd_by_locale(prices, rates)
base_map = _base_by_locale(prices, rates, base_currency)
cheapest = min(
(loc for loc, usd in usd_map.items() if usd is not None),
key=lambda loc: usd_map[loc],
(loc for loc, val in base_map.items() if val is not None),
key=lambda loc: base_map[loc],
default=None,
)

Expand All @@ -99,12 +111,12 @@ def _card_price_lines(
lines.append(f"{flag} N/A")
continue

usd = usd_map.get(locale)
base_val = base_map.get(locale)
iso = PS_CURRENCY_MAP.get(rp.currency, rp.currency)
usd_str = f" (${usd:.2f})" if usd is not None and iso != "USD" else ""
base_suffix = _base_str(base_val, base_currency, iso)
strike = f"<s>{_format_price(rp.base_price, rp.currency)}</s> " if rp.base_price is not None else ""
discount_str = f" {rp.discount_text}" if rp.discount_text else ""
price_label = f"{strike}{_format_price(rp.price, rp.currency)}{discount_str}{usd_str}"
price_label = f"{strike}{_format_price(rp.price, rp.currency)}{discount_str}{base_suffix}"

url = f"https://store.playstation.com/{locale}/product/{rp.ps_id}"
is_cheapest = locale == cheapest and cheapest is not None and len(prices) > 1
Expand All @@ -115,14 +127,12 @@ def _card_price_lines(

old_price = (old_prices or {}).get(locale)
if old_price is not None and old_price != rp.price:
# Skip when the game is on sale and old_price == base_price:
# the strikethrough base_price in the card already shows the pre-sale price.
if rp.base_price is None or abs(rp.base_price - old_price) > 0.001:
old_usd = convert_to_usd(old_price, rp.currency, rates) if rates else None
old_base = convert(old_price, rp.currency, base_currency, rates) if rates else None
old_iso = PS_CURRENCY_MAP.get(rp.currency, rp.currency)
old_usd_str = f" (${old_usd:.2f})" if old_usd is not None and old_iso != "USD" else ""
old_base_suffix = _base_str(old_base, base_currency, old_iso)
arrow = "↓" if rp.price < old_price else "↑"
text += f" {arrow} <s>{_format_price(old_price, rp.currency)}{old_usd_str}</s>"
text += f" {arrow} <s>{_format_price(old_price, rp.currency)}{old_base_suffix}</s>"

lines.append(text)

Expand All @@ -135,6 +145,7 @@ def format_game_list(
games: list[GameInfo],
prices: list[dict[str, RegionPrice]],
rates: dict[str, float] | None = None,
base_currency: str = DEFAULT_BASE_CURRENCY,
) -> str:
if not games:
return "Nothing found. Try a different query."
Expand All @@ -143,7 +154,7 @@ def format_game_list(
for game, game_prices in zip(games, prices):
lines = _game_header(game)
if game_prices:
lines.append(_price_line(game_prices, rates))
lines.append(_price_line(game_prices, rates, base_currency))
cards.append("\n".join(lines))

return f"{title}\n\n" + "\n\n".join(cards) + f"\n\n{footer}"
Expand All @@ -166,11 +177,12 @@ def format_game_card(
old_prices: dict[str, float] | None = None,
title: str = "",
footer: str = "",
base_currency: str = DEFAULT_BASE_CURRENCY,
) -> str:
lines = _game_header(game)
if prices:
lines.append("\nPrices by region:")
lines.extend(_card_price_lines(prices, rates, old_prices))
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>")
Expand Down
3 changes: 2 additions & 1 deletion bot/handlers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from aiogram import Router

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

router = Router()
router.include_router(start.router)
router.include_router(currency.router)
router.include_router(regions.router)
router.include_router(search.router)
router.include_router(subscriptions.router)
65 changes: 65 additions & 0 deletions bot/handlers/currency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession

from bot.keyboards.inline import currency_suggestions_keyboard
from services.currency import DEFAULT_BASE_CURRENCY, PS_ISO_TO_SYMBOL, find_currency_suggestions, get_rates
from services.user import get_or_create_user

router = Router()


def _currency_label(iso: str) -> str:
symbol = PS_ISO_TO_SYMBOL.get(iso, iso)
return f"{iso} ({symbol})" if symbol != iso else iso


async def _set_currency(iso: str, user, session: AsyncSession) -> str:
user.preferred_currency = iso
await session.commit()
return f"Display currency set to <b>{_currency_label(iso)}</b>."


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

arg = message.text.partition(" ")[2].strip().upper()

if not arg:
current = user.preferred_currency or DEFAULT_BASE_CURRENCY
await message.answer(
f"Your current display currency: <b>{_currency_label(current)}</b>\n\n"
f"To change it, use:\n<code>/currency EUR</code>\n\n"
f"Any ISO 4217 currency code is accepted (e.g. SGD, HKD, MYR)."
)
return

rates = await get_rates()

if arg in rates or arg == "USD":
await message.answer(await _set_currency(arg, user, session))
return

suggestions = find_currency_suggestions(arg, rates)
if not suggestions:
await message.answer(
f"<b>{arg}</b> is not a recognised currency code and no similar codes were found.\n"
"Use a valid ISO 4217 code like EUR, GBP, SGD, HKD, etc."
)
return

await message.answer(
f"<b>{arg}</b> not found. Did you mean:",
reply_markup=currency_suggestions_keyboard(suggestions),
)


@router.callback_query(F.data.startswith("currency_select:"))
async def on_currency_select(callback: CallbackQuery, session: AsyncSession) -> None:
iso = callback.data.split(":", 1)[1]
user = await get_or_create_user(session, callback.from_user.id, callback.from_user.username)
text = await _set_currency(iso, user, session)
await callback.message.edit_text(text)
await callback.answer()
9 changes: 7 additions & 2 deletions bot/handlers/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from bot.formatters import format_game_card, format_game_list
from bot.keyboards.inline import search_results_keyboard, subscribe_keyboard, unsubscribe_keyboard
from bot.states.subscription import SearchForm
from services.currency import get_rates
from services.currency import DEFAULT_BASE_CURRENCY, get_rates
from services.ps_store import GameInfo, RegionPrice, best_ps_id, get_game_info, search_games
from services.region import get_user_regions
from services.subscription import is_subscribed
Expand Down Expand Up @@ -181,8 +181,10 @@ async def _do_search(message: Message, state: FSMContext, session: AsyncSession,
for key in visible_keys
]

base_currency = user.preferred_currency or DEFAULT_BASE_CURRENCY

await state.set_state(SearchForm.showing_results)
await state.update_data(entries=entries, rates=rates)
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"
Expand All @@ -196,6 +198,7 @@ async def _do_search(message: Message, state: FSMContext, session: AsyncSession,
games=games,
prices=[by_key[k] for k in visible_keys],
rates=rates,
base_currency=base_currency,
)
await message.answer(text, reply_markup=search_results_keyboard(games))

Expand Down Expand Up @@ -227,6 +230,7 @@ async def on_game_select(callback: CallbackQuery, state: FSMContext, session: As
data = await state.get_data()
entries = data.get("entries", [])
rates = data.get("rates")
base_currency = data.get("base_currency", DEFAULT_BASE_CURRENCY)

index = int(callback.data.split(":", 1)[1])
if index >= len(entries):
Expand Down Expand Up @@ -261,6 +265,7 @@ async def on_game_select(callback: CallbackQuery, state: FSMContext, session: As
prices,
rates,
footer="Want to track prices in more regions?\nAdd a new one: /add_region",
base_currency=base_currency,
)
game_id = await is_subscribed(session, callback.from_user.id, game.composite_key, game.ps_id_suffix)
keyboard = unsubscribe_keyboard(game_id) if game_id else subscribe_keyboard(index)
Expand Down
5 changes: 4 additions & 1 deletion bot/handlers/start.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ async def cmd_start(message: Message, session: AsyncSession) -> None:
await session.commit()
await message.answer(
"Hi! I'm PriceStation — I track prices in the PS Store.\n\n"
"Use /add_region to choose the regions you want to follow."
"/currency — set your display currency\n"
"/add_region — choose regions to follow\n"
"/search — find a game and subscribe to price drops\n"
"/my_subscriptions — manage your subscriptions"
)
8 changes: 5 additions & 3 deletions bot/handlers/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from bot.keyboards.inline import subscriptions_list_keyboard
from bot.metrics import bot_handler_errors
from bot.states.subscription import SearchForm
from services.currency import get_rates
from services.currency import DEFAULT_BASE_CURRENCY, get_rates
from services.ps_store import GameInfo, RegionPrice
from services.subscription import get_user_subscriptions_page, subscribe_to_game, unsubscribe_from_game
from services.user import get_or_create_user
Expand Down Expand Up @@ -86,7 +86,8 @@ async def _build_subs_page(
page: int,
) -> tuple[str, InlineKeyboardMarkup] | None:
"""Return (text, keyboard) for the given page, or None if no subscriptions."""
await get_or_create_user(session, telegram_id, username)
user = await get_or_create_user(session, telegram_id, username)
base_currency = user.preferred_currency or DEFAULT_BASE_CURRENCY

total, page_items = await get_user_subscriptions_page(
session, telegram_id, page, _SUBS_PAGE_SIZE
Expand All @@ -106,14 +107,15 @@ async def _build_subs_page(
for gi, prices in page_items
]
await state.set_state(SearchForm.showing_results)
await state.update_data(entries=entries, rates=rates)
await state.update_data(entries=entries, rates=rates, base_currency=base_currency)

text = format_game_list(
title=f"Your subscriptions ({total} total):",
footer="Prices may be slightly outdated.",
games=games,
prices=[prices for _, prices in page_items],
rates=rates,
base_currency=base_currency,
)
return text, subscriptions_list_keyboard(games, page, total_pages)

Expand Down
8 changes: 8 additions & 0 deletions bot/keyboards/inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,14 @@ def subscriptions_list_keyboard(
return builder.as_markup()


def currency_suggestions_keyboard(suggestions: list[tuple[str, str]]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for iso, name in suggestions:
builder.button(text=f"{iso} — {name}", callback_data=f"currency_select:{iso}")
builder.adjust(1)
return builder.as_markup()


def cancel_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="Cancel", callback_data="cancel")
Expand Down
Loading
Loading