Skip to content
Open
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
94 changes: 93 additions & 1 deletion tools/steam-sniper/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,18 @@ def init_db() -> None:
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_lists_unique
ON user_lists(user_id, item_name, list_type);

-- Steam Market price cache for arbitrage delta vs lis-skins.
-- Updated by background task (rate-limited, ~40 items/min).
-- Only watched items (favorites + wishlist + watchlist) are tracked,
-- not the full 23k catalog (Steam rate limit makes that infeasible).
CREATE TABLE IF NOT EXISTS steam_prices (
name TEXT PRIMARY KEY,
lowest_usd REAL,
median_usd REAL,
volume INTEGER,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
# Migrate: add columns if missing (safe for existing DBs)
cols = {row[1] for row in conn.execute("PRAGMA table_info(watchlist)")}
Expand All @@ -119,6 +131,14 @@ def init_db() -> None:
conn.execute("ALTER TABLE user_lists ADD COLUMN last_notified_below_at TEXT")
if "last_notified_above_at" not in list_cols:
conn.execute("ALTER TABLE user_lists ADD COLUMN last_notified_above_at TEXT")
# Float-aware alerts: only fire when a listing has BOTH price and float
# in the user's accepted range. Without these, the alert can trigger on
# a listing with the right price but unusable wear (e.g. float 0.32 on
# a knife where the user wanted ≤ 0.10).
if "target_float_max" not in list_cols:
conn.execute("ALTER TABLE user_lists ADD COLUMN target_float_max REAL")
if "target_float_min" not in list_cols:
conn.execute("ALTER TABLE user_lists ADD COLUMN target_float_min REAL")


@beartype
Expand Down Expand Up @@ -456,6 +476,7 @@ def get_list_items(user_id: str, list_type: str | None = None) -> list[dict]:
rows = conn.execute(
"SELECT id, item_name, list_type, added_at, "
"target_below_rub, target_above_rub, "
"target_float_max, target_float_min, "
"last_notified_below_at, last_notified_above_at "
"FROM user_lists "
"WHERE user_id=? AND list_type=? ORDER BY added_at DESC",
Expand All @@ -465,6 +486,7 @@ def get_list_items(user_id: str, list_type: str | None = None) -> list[dict]:
rows = conn.execute(
"SELECT id, item_name, list_type, added_at, "
"target_below_rub, target_above_rub, "
"target_float_max, target_float_min, "
"last_notified_below_at, last_notified_above_at "
"FROM user_lists "
"WHERE user_id=? ORDER BY added_at DESC",
Expand All @@ -480,8 +502,14 @@ def set_list_item_targets(
list_type: str,
target_below_rub: float | None,
target_above_rub: float | None,
target_float_max: float | None = None,
target_float_min: float | None = None,
) -> int:
"""Set list thresholds. Changing a threshold resets its notification cooldown."""
"""Set list thresholds. Changing a threshold resets its notification cooldown.

Float bounds (0.0..1.0) optionally restrict alerts to listings whose float
falls in [target_float_min, target_float_max]. Either bound is None → ignored.
"""
with get_conn() as conn:
current = conn.execute(
"""
Expand All @@ -502,13 +530,17 @@ def set_list_item_targets(
UPDATE user_lists
SET target_below_rub=?,
target_above_rub=?,
target_float_max=?,
target_float_min=?,
last_notified_below_at=?,
last_notified_above_at=?
WHERE user_id=? AND item_name=? AND list_type=?
""",
(
target_below_rub,
target_above_rub,
target_float_max,
target_float_min,
None if below_changed else current["last_notified_below_at"],
None if above_changed else current["last_notified_above_at"],
user_id,
Expand All @@ -527,6 +559,7 @@ def get_all_list_items_with_targets() -> list[dict]:
"""
SELECT id, user_id, item_name, list_type, added_at,
target_below_rub, target_above_rub,
target_float_max, target_float_min,
last_notified_below_at, last_notified_above_at
FROM user_lists
WHERE target_below_rub IS NOT NULL OR target_above_rub IS NOT NULL
Expand Down Expand Up @@ -576,3 +609,62 @@ def get_all_list_names() -> set[str]:
with get_conn() as conn:
rows = conn.execute("SELECT DISTINCT item_name FROM user_lists").fetchall()
return {row[0] for row in rows}


# --- Steam Market price cache (arbitrage delta vs lis-skins) ---


@beartype
def upsert_steam_price(
name: str,
lowest_usd: float | None,
median_usd: float | None,
volume: int | None,
) -> None:
"""Insert or replace Steam Market price for a single item."""
with get_conn() as conn:
conn.execute(
"""
INSERT INTO steam_prices (name, lowest_usd, median_usd, volume, fetched_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(name) DO UPDATE SET
lowest_usd = excluded.lowest_usd,
median_usd = excluded.median_usd,
volume = excluded.volume,
fetched_at = excluded.fetched_at
""",
(name, lowest_usd, median_usd, volume),
)


@beartype
def get_steam_prices() -> dict[str, dict]:
"""Return {name: {lowest_usd, median_usd, volume, fetched_at}} for all cached items."""
with get_conn() as conn:
rows = conn.execute("SELECT * FROM steam_prices").fetchall()
return {
row["name"]: {
"lowest_usd": row["lowest_usd"],
"median_usd": row["median_usd"],
"volume": row["volume"],
"fetched_at": row["fetched_at"],
}
for row in rows
}


@beartype
def get_steam_price(name: str) -> dict | None:
"""Lookup Steam Market price for a single item. None if not cached yet."""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM steam_prices WHERE name = ?", (name,)
).fetchone()
if not row:
return None
return {
"lowest_usd": row["lowest_usd"],
"median_usd": row["median_usd"],
"volume": row["volume"],
"fetched_at": row["fetched_at"],
}
3 changes: 3 additions & 0 deletions tools/steam-sniper/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"main.py",
"db.py",
"category.py",
"digest.py",
"dashboard.html",
"pyproject.toml",
".env",
Expand All @@ -57,6 +58,8 @@
"deploy/steam-sniper-bot.service": f"/etc/systemd/system/{SERVICE_BOT}.service",
"deploy/steam-sniper-snapshot.service": "/etc/systemd/system/steam-sniper-snapshot.service",
"deploy/steam-sniper-snapshot.timer": f"/etc/systemd/system/{SERVICE_SNAPSHOT_TIMER}",
"deploy/steam-sniper-digest.service": "/etc/systemd/system/steam-sniper-digest.service",
"deploy/steam-sniper-digest.timer": "/etc/systemd/system/steam-sniper-digest.timer",
}

# Nginx config (local path -> remote path)
Expand Down
13 changes: 13 additions & 0 deletions tools/steam-sniper/deploy/steam-sniper-digest.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[Unit]
Description=Steam Sniper Weekly Telegram Digest
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/steam-sniper
EnvironmentFile=/opt/steam-sniper/.env
ExecStart=/opt/steam-sniper/.venv/bin/python3 digest.py
Nice=10
TimeoutStartSec=5min
12 changes: 12 additions & 0 deletions tools/steam-sniper/deploy/steam-sniper-digest.timer
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[Unit]
Description=Weekly Steam Sniper Telegram digest (Sunday 18:00 MSK)

[Timer]
# Sunday at 18:00 МСК (15:00 UTC)
OnCalendar=Sun *-*-* 18:00:00
Persistent=true
RandomizedDelaySec=10min
Unit=steam-sniper-digest.service

[Install]
WantedBy=timers.target
16 changes: 15 additions & 1 deletion tools/steam-sniper/deploy_quick.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,30 @@
FILES = [
"db.py",
"server.py",
"digest.py",
"category.py",
"dashboard.html",
"static/css/styles.css",
"static/js/catalog.js",
# ALL js modules — historically only some were listed and adding a new
# export to utils.js silently 502'd the frontend (catalog imported
# `steamDeltaBadge` from a stale utils.js on disk, JS module load failed,
# whole dashboard went blank). Glob-style "everything in static/js" is
# safer than picking-and-choosing.
"static/js/alerts.js",
"static/js/cases.js",
"static/js/catalog.js",
"static/js/chart.js",
"static/js/events.js",
"static/js/item_detail.js",
"static/js/lists.js",
"static/js/main.js",
"static/js/modal.js",
"static/js/router.js",
"static/js/search.js",
"static/js/state.js",
"static/js/stats.js",
"static/js/theme.js",
"static/js/utils.js",
"static/js/watchlist.js",
"static/sw.js",
"static/icons/logo.png",
Expand Down
Loading