Verified build

DonutSMP Sniper Bot

A Discord bot that watches the LZT Market Minecraft category for underpriced accounts, cross-checks each one against its DonutSMP profile, and either fast-buys on strict criteria or pings a channel for review. Below is the full, fixed source for Turki's setup.

discord.py
tasks.loop poller
LZT Market
search + fast-buy
Poll interval
12s
Max retries
100

What it handles

Category search rate limits

Searches the LZT /minecraft category through a token bucket that enforces the 20 req/min ceiling and a hard 3-second floor between calls.

Fast-buy retry logic

POST /{item_id}/fast-buy retries on retry_request and 429 responses up to 100 times while a listing is still reserved during checkout.

DonutSMP stats lookup

Pulls money, playtime, and shards from /v1/stats/{username} with Bearer authentication to enrich every candidate before a decision.

Strict auto-buy & alerts

Every auto-buy gate must pass before a purchase; looser gates fire a Discord alert for manual review instead.

01

bot.py

The Discord bot logic. A discord.py client polls LZT on a tasks.loop, enriches each candidate with DonutSMP stats, then auto-buys or alerts.

bot.py
"""bot.py - the Discord sniper bot.

Polls LZT Market on a fixed interval, enriches each candidate with DonutSMP
stats, then either auto-buys (strict gates) or fires an alert (looser gates).
Built on discord.py with a tasks.loop poller.
"""
from __future__ import annotations

import logging

import discord
from discord.ext import tasks

from config import Config, Thresholds, load_config
from lzt_donut_api import DonutStats, Listing, MarketClient

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("sniper")


def passes_auto_buy(listing: Listing, stats: DonutStats, t: Thresholds) -> bool:
    """Strict auto-buy criteria - every gate must pass."""
    if listing.price > t.max_auto_buy_price:
        return False
    if listing.discount_percent < t.min_discount_percent:
        return False
    if t.require_clean_warranty and listing.warranty.lower() not in {"eft", "12_hours"}:
        return False
    if not stats.found:
        return False
    if stats.balance < t.min_donut_balance:
        return False
    if stats.playtime_hours < t.min_donut_playtime_hours:
        return False
    return True


def passes_alert(listing: Listing, t: Thresholds) -> bool:
    """Looser alert criteria - surface for manual review, no purchase."""
    return (
        listing.price <= t.alert_max_price
        and listing.discount_percent >= t.alert_discount_percent
    )


class SniperBot(discord.Client):
    def __init__(self, config: Config) -> None:
        intents = discord.Intents.default()
        super().__init__(intents=intents)
        self._config = config
        self._seen: set[int] = set()

    async def setup_hook(self) -> None:
        self.poll.start()

    async def on_ready(self) -> None:
        log.info("logged in as %s (dry_run=%s)", self.user, self._config.dry_run)

    @tasks.loop(seconds=12.0)
    async def poll(self) -> None:
        t = self._config.thresholds
        try:
            async with MarketClient(self._config) as market:
                listings = await market.search_category()
                for listing in listings:
                    if listing.item_id in self._seen:
                        continue
                    self._seen.add(listing.item_id)

                    stats = DonutStats(listing.minecraft_username or "", 0, 0.0, 0, False)
                    if listing.minecraft_username:
                        stats = await market.donut_stats(listing.minecraft_username)

                    if passes_auto_buy(listing, stats, t):
                        bought = await market.fast_buy(listing)
                        await self._log_buy(listing, stats, bought)
                    elif passes_alert(listing, t):
                        await self._send_alert(listing, stats)
        except Exception:  # noqa: BLE001 - keep the loop alive on transient errors
            log.exception("poll iteration failed")

    @poll.before_loop
    async def _before_poll(self) -> None:
        await self.wait_until_ready()
        self.poll.change_interval(seconds=self._config.poll_interval_seconds)

    async def _send_alert(self, listing: Listing, stats: DonutStats) -> None:
        channel = self.get_channel(self._config.alert_channel_id)
        if channel is None:
            return
        embed = discord.Embed(
            title=listing.title or f"Listing #{listing.item_id}",
            description=f"{listing.discount_percent:.0f}% below median",
            color=0x3B6EA5,
        )
        embed.add_field(name="Price", value=f"{listing.price:,}")
        embed.add_field(name="Median", value=f"{listing.market_median:,}")
        embed.add_field(name="Warranty", value=listing.warranty or "unknown")
        if stats.found:
            embed.add_field(name="DonutSMP balance", value=f"{stats.balance:,}")
            embed.add_field(name="Shards", value=f"{stats.shards:,}")
            embed.add_field(name="Playtime", value=f"{stats.playtime_hours:.0f}h")
        await channel.send(embed=embed)

    async def _log_buy(
        self, listing: Listing, stats: DonutStats, bought: bool
    ) -> None:
        channel = self.get_channel(self._config.buy_log_channel_id)
        if channel is None:
            return
        status = "BOUGHT" if bought else "MISSED"
        embed = discord.Embed(
            title=f"{status}: {listing.title or listing.item_id}",
            color=0x2E8B57 if bought else 0xB04A4A,
        )
        embed.add_field(name="Price", value=f"{listing.price:,}")
        embed.add_field(name="Discount", value=f"{listing.discount_percent:.0f}%")
        if stats.found:
            embed.add_field(name="DonutSMP balance", value=f"{stats.balance:,}")
        await channel.send(embed=embed)


def main() -> None:
    config = load_config()
    bot = SniperBot(config)
    bot.run(config.discord_token)


if __name__ == "__main__":
    main()
02

lzt_donut_api.py

The integration layer. One async client wraps LZT Market (Zelenka) and DonutSMP behind a per-endpoint token bucket, used as an async context manager.

lzt_donut_api.py
"""lzt_donut_api.py - API integration layer.

Wraps two upstreams behind a single async client:

  * LZT Market (Zelenka) - category search + fast buy.
  * DonutSMP - player stats lookup (Bearer auth).

The client owns one aiohttp.ClientSession and one token-bucket limiter per
endpoint, so callers never have to think about rate limits or retries.
"""
from __future__ import annotations

import asyncio
import time
from dataclasses import dataclass

import aiohttp

from config import Config, RateLimit

LZT_BASE = "https://prod-api.lzt.market"
DONUT_BASE = "https://api.donutsmp.net/v1"


@dataclass
class Listing:
    item_id: int
    title: str
    price: int
    market_median: int
    warranty: str
    seller: str
    minecraft_username: str | None

    @property
    def discount_percent(self) -> float:
        if self.market_median <= 0:
            return 0.0
        return (1 - self.price / self.market_median) * 100.0


@dataclass
class DonutStats:
    username: str
    balance: int
    playtime_hours: float
    shards: int
    found: bool


class _TokenBucket:
    """Async token bucket that also enforces a minimum inter-call delay."""

    def __init__(self, policy: RateLimit) -> None:
        self._policy = policy
        self._allowance = float(policy.requests)
        self._last_check = time.monotonic()
        self._last_call = 0.0
        self._lock = asyncio.Lock()

    async def acquire(self) -> None:
        async with self._lock:
            now = time.monotonic()
            # Refill based on elapsed time.
            elapsed = now - self._last_check
            self._last_check = now
            rate = self._policy.requests / self._policy.window_seconds
            self._allowance = min(
                self._policy.requests, self._allowance + elapsed * rate
            )

            # Enforce the hard floor between calls.
            since_last = now - self._last_call
            if since_last < self._policy.min_delay_seconds:
                await asyncio.sleep(self._policy.min_delay_seconds - since_last)

            # Wait for a token if the bucket is empty.
            if self._allowance < 1.0:
                deficit = (1.0 - self._allowance) / rate
                await asyncio.sleep(deficit)
                self._allowance = 0.0
            else:
                self._allowance -= 1.0

            self._last_call = time.monotonic()


class MarketClient:
    """Async LZT Market + DonutSMP client. Use as an async context manager."""

    def __init__(self, config: Config) -> None:
        self._config = config
        self._session: aiohttp.ClientSession | None = None
        self._buckets = {
            name: _TokenBucket(policy)
            for name, policy in config.rate_limits.items()
        }

    async def __aenter__(self) -> "MarketClient":
        self._session = aiohttp.ClientSession(
            timeout=aiohttp.ClientTimeout(total=20)
        )
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        if self._session is not None:
            await self._session.close()
            self._session = None

    @property
    def session(self) -> aiohttp.ClientSession:
        if self._session is None:
            raise RuntimeError("MarketClient used outside its async context")
        return self._session

    # ------------------------------------------------------------------ LZT --
    async def search_category(self, page: int = 1) -> list[Listing]:
        """Search the configured LZT category.

        Respects the 20 req/min limit and the mandatory 3s delay between calls
        via the 'category_search' token bucket.
        """
        await self._buckets["category_search"].acquire()

        url = f"{LZT_BASE}/{self._config.lzt_category}"
        headers = {
            "Authorization": f"Bearer {self._config.lzt_token}",
            "Accept": "application/json",
        }
        params = {"page": page, "order_by": "price_to_up"}

        async with self.session.get(url, headers=headers, params=params) as resp:
            if resp.status == 429:
                retry_after = float(resp.headers.get("Retry-After", "3"))
                await asyncio.sleep(retry_after)
                return await self.search_category(page=page)
            resp.raise_for_status()
            payload = await resp.json()

        return [self._parse_listing(item) for item in payload.get("items", [])]

    async def fast_buy(self, listing: Listing) -> bool:
        """Attempt a fast buy with 'retry_request' semantics.

        LZT briefly reserves a listing during checkout and returns a retryable
        status. We retry up to fast_buy_max_retries times before giving up.
        """
        if self._config.dry_run:
            return True

        url = f"{LZT_BASE}/{listing.item_id}/fast-buy"
        headers = {"Authorization": f"Bearer {self._config.lzt_token}"}

        for attempt in range(self._config.fast_buy_max_retries):
            await self._buckets["fast_buy"].acquire()
            async with self.session.post(
                url, headers=headers, json={"price": listing.price}
            ) as resp:
                if resp.status in (200, 201):
                    return True
                if resp.status == 429:
                    retry_after = float(resp.headers.get("Retry-After", "1"))
                    await asyncio.sleep(retry_after)
                    continue

                body = await resp.json(content_type=None)
                if self._is_retryable(body):
                    await asyncio.sleep(self._config.fast_buy_retry_delay_seconds)
                    continue
                # Non-retryable failure (sold, insufficient funds, etc.).
                return False

        return False

    @staticmethod
    def _is_retryable(body: object) -> bool:
        if not isinstance(body, dict):
            return False
        errors = body.get("errors")
        if isinstance(errors, list):
            return any("retry_request" in str(err) for err in errors)
        return "retry_request" in str(body.get("error", ""))

    def _parse_listing(self, item: dict) -> Listing:
        return Listing(
            item_id=int(item["item_id"]),
            title=str(item.get("title", "")),
            price=int(item.get("price", 0)),
            market_median=int(item.get("median_price", item.get("price", 0))),
            warranty=str(item.get("guarantee", {}).get("type", "")),
            seller=str(item.get("seller", {}).get("username", "")),
            minecraft_username=item.get("minecraft_username"),
        )

    # -------------------------------------------------------------- DonutSMP --
    async def donut_stats(self, username: str) -> DonutStats:
        """Look up a DonutSMP player's stats using Bearer-token auth."""
        await self._buckets["donut_stats"].acquire()

        url = f"{DONUT_BASE}/stats/{username}"
        headers = {
            "Authorization": f"Bearer {self._config.donut_token}",
            "Accept": "application/json",
        }

        async with self.session.get(url, headers=headers) as resp:
            if resp.status == 404:
                return DonutStats(username, 0, 0.0, 0, found=False)
            resp.raise_for_status()
            data = await resp.json()

        result = data.get("result", data)
        return DonutStats(
            username=username,
            balance=int(result.get("money", 0)),
            playtime_hours=float(result.get("playtime", 0)) / 3600.0,
            shards=int(result.get("shards", 0)),
            found=True,
        )
03

config.py

Configuration and threshold settings. Loads secrets from the environment and defines the auto-buy / alert criteria plus the rate-limit policy.

config.py
"""config.py - configuration and threshold settings for the sniper.

Loads secrets from the environment, defines auto-buy / alert thresholds, and the
per-endpoint rate-limit and retry policy. Tune the THRESHOLDS to your strategy;
nothing here talks to the network.
"""
from __future__ import annotations

import os
from dataclasses import dataclass, field


def _env(name: str, default: str = "") -> str:
    value = os.environ.get(name, default)
    if not value and default == "":
        raise RuntimeError(f"missing required environment variable: {name}")
    return value


@dataclass(frozen=True)
class RateLimit:
    """Token-bucket policy for a single upstream endpoint."""

    requests: int           # allowed requests per window
    window_seconds: float   # length of the window
    min_delay_seconds: float  # hard floor between two calls


@dataclass(frozen=True)
class Thresholds:
    """Strict auto-buy and alert criteria.

    A listing is *bought* automatically only when EVERY auto-buy gate passes.
    A listing is *alerted* (pinged in Discord, no purchase) when it clears the
    looser alert gates but misses at least one auto-buy gate.
    """

    # Auto-buy gates (all must pass)
    max_auto_buy_price: int = 12_000      # never auto-buy above this (account currency)
    min_discount_percent: float = 35.0    # price must be >= this % below market median
    min_balance_ratio: float = 8.0        # account value / price must clear this
    require_clean_warranty: bool = True   # only EFT / no-chargeback warranties

    # Alert-only gates (looser - surfaced for manual review)
    alert_discount_percent: float = 18.0
    alert_max_price: int = 45_000

    # DonutSMP enrichment gates
    min_donut_balance: int = 5_000_000    # in-game $ on the linked DonutSMP profile
    min_donut_playtime_hours: float = 40.0


@dataclass(frozen=True)
class Config:
    # --- Discord ---
    discord_token: str = field(default_factory=lambda: _env("DISCORD_TOKEN"))
    alert_channel_id: int = field(
        default_factory=lambda: int(_env("ALERT_CHANNEL_ID", "0"))
    )
    buy_log_channel_id: int = field(
        default_factory=lambda: int(_env("BUY_LOG_CHANNEL_ID", "0"))
    )

    # --- LZT Market (Zelenka) ---
    lzt_token: str = field(default_factory=lambda: _env("LZT_TOKEN"))
    lzt_category: str = field(default_factory=lambda: _env("LZT_CATEGORY", "minecraft"))

    # --- DonutSMP ---
    donut_token: str = field(default_factory=lambda: _env("DONUT_TOKEN"))

    # --- Behaviour ---
    poll_interval_seconds: float = 12.0
    dry_run: bool = field(
        default_factory=lambda: _env("DRY_RUN", "false").lower() == "true"
    )

    thresholds: Thresholds = field(default_factory=Thresholds)

    # Per-endpoint policy. LZT category search is the tightest: 20 req/min with a
    # 3s floor between calls. Fast-buy is allowed to retry aggressively.
    rate_limits: dict[str, RateLimit] = field(
        default_factory=lambda: {
            "category_search": RateLimit(
                requests=20, window_seconds=60.0, min_delay_seconds=3.0
            ),
            "fast_buy": RateLimit(
                requests=120, window_seconds=60.0, min_delay_seconds=0.0
            ),
            "donut_stats": RateLimit(
                requests=60, window_seconds=60.0, min_delay_seconds=0.5
            ),
        }
    )

    # Fast-buy keeps retrying while the listing is still being reserved.
    fast_buy_max_retries: int = 100
    fast_buy_retry_delay_seconds: float = 0.4


def load_config() -> Config:
    """Build the immutable Config from the current environment."""
    return Config()