first commit

This commit is contained in:
st committed 2026-08-09 16:51:01 -05:00
commit 7349b743e3
21 files changed
+1527

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <vector>
#include <optional>
// Per-watched-link settings (mirrors the Python `link_configs` dicts).
struct LinkConfig {
std::string url;
std::string label;
std::optional<double> min_price;
std::optional<double> max_price;
std::vector<std::string> keywords;
bool paused = false;
};
// Whole-application configuration, loaded from / saved to a JSON file on disk
// so the bot remembers its watch list and settings between restarts.
struct AppConfig {
std::string discord_bot_token; // required: bot token for the control bot
std::string discord_channel_id; // channel the bot posts stock alerts to
std::string discord_webhook_url; // optional legacy fallback (like the python script)
std::string command_guild_id; // optional: register slash commands to one guild (instant sync while testing)
std::string cookie_header; // optional "Cookie: ..." value for authenticated scraping
std::string user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) LZTSniperCpp/1.0";
int interval_seconds = 180;
bool autostart = false; // start monitoring immediately on launch
std::vector<LinkConfig> links;
// Loads config from `path`. Returns a default-constructed AppConfig (with
// an empty token) if the file does not exist yet.
static AppConfig load(const std::string& path);
// Persists the current config (including live link list / pause state)
// back to `path` as pretty-printed JSON.
void save(const std::string& path) const;
};
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include <string>
#include "Config.hpp"
#include "State.hpp"
#include "SniperEngine.hpp"
// This is the whole point of the C++ rewrite request: instead of a Flask web
// dashboard you had to open in a browser, the sniper is now controlled
// entirely from Discord with slash commands:
//
// /start start watching all configured links
// /stop stop watching
// /status running state + per-link stock + totals
// /links list configured links (with pause state)
// /addlink url [label] [min_price] [max_price] [keywords]
// /removelink url
// /pause url toggle pause for one link
// /scan url force an immediate one-off scan
// /interval seconds change the polling interval
// /history last 15 scan results
//
// Uses D++ (https://dpp.dev/) for the gateway connection + slash commands.
class DiscordBot {
public:
DiscordBot(AppConfig& config, SniperState& state, SniperEngine& engine, std::string config_path);
// Blocks forever running the gateway event loop.
void run();
private:
AppConfig& config_;
SniperState& state_;
SniperEngine& engine_;
std::string config_path_;
void persist();
};
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vector>
#include <optional>
// Builds and sends the same "stock update" embed the python script sent via
// webhook, but preferring the bot's own REST API (POST /channels/{id}/messages)
// since we now have a real bot token. Falls back to a legacy webhook URL if
// no channel id is configured.
class DiscordNotifier {
public:
DiscordNotifier(std::string bot_token, std::string channel_id, std::string webhook_url);
void sendStockUpdate(const std::string& url,
const std::vector<std::string>& offers,
const std::string& change_text,
bool ping,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords);
private:
std::string bot_token_;
std::string channel_id_;
std::string webhook_url_;
std::string buildPayload(const std::string& url,
const std::vector<std::string>& offers,
const std::string& change_text,
bool ping,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords) const;
};
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <string>
#include <map>
struct HttpResponse {
long status = 0;
std::string body;
bool ok() const { return status >= 200 && status < 300; }
};
// Minimal libcurl wrapper used for both scraping (GET) and talking to the
// Discord REST API / webhooks (POST json). Kept tiny and dependency-light
// rather than reusing a full framework.
class HttpClient {
public:
static void globalInit(); // call once at process start
static void globalCleanup();
static HttpResponse get(const std::string& url,
const std::map<std::string, std::string>& headers = {},
long timeout_seconds = 30);
static HttpResponse postJson(const std::string& url,
const std::string& json_body,
const std::map<std::string, std::string>& headers = {},
long timeout_seconds = 15);
};
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include <string>
#include <functional>
enum class LogTag { Info, Ok, Warn, Err };
// Thread-safe console logger (stand-in for the python `push_log` + log_queue/SSE).
// Also supports a "sink" callback so the Discord bot can mirror log lines into
// a Discord channel/log command without polling a queue.
class Logger {
public:
using Sink = std::function<void(const std::string& ts, const std::string& msg, LogTag tag)>;
static void log(const std::string& msg, LogTag tag = LogTag::Info);
static void setSink(Sink sink);
private:
static Sink sink_;
};
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <string>
#include <vector>
#include <optional>
// Fetches an LZT Market search-result page and extracts individual offer
// "cards" as plain text, mirroring the heuristic the original Python script
// used inside the browser (`document.querySelectorAll` + innerText filtering
// for blocks that mention "followers").
//
// IMPORTANT: the python version drove a real (headless) Chrome via Selenium,
// which meant it saw the fully JS-rendered page and re-used a logged-in
// browser profile. This C++ version fetches the raw HTML over HTTP instead
// (no JS execution). LZT Market's search results are server-rendered, so
// this works for public listings; if you need an authenticated view, set
// `cookie_header` in config.json to your browser's `Cookie:` header value.
class Scraper {
public:
explicit Scraper(std::string cookie_header = "", std::string user_agent = "");
// Returns cleaned, deduplicated offer text blocks for the given URL.
std::vector<std::string> checkOffers(const std::string& url);
// Client-side post-filter equivalent to python's filter_offers().
static std::vector<std::string> filterOffers(const std::vector<std::string>& offers,
std::optional<double> min_price,
std::optional<double> max_price,
const std::vector<std::string>& keywords);
private:
std::string cookie_header_;
std::string user_agent_;
static std::string cleanText(std::string text);
static std::vector<std::string> extractOfferBlocks(const std::string& html);
};
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <thread>
#include <atomic>
#include <mutex>
#include <vector>
#include <string>
#include <optional>
#include "Config.hpp"
#include "State.hpp"
#include "Scraper.hpp"
#include "DiscordNotifier.hpp"
// Owns the background watch loop (equivalent to python's `bot_loop`) plus
// all the mutating operations the Flask `/api/*` routes used to expose.
// The Discord bot's slash-command handlers call straight into this class.
class SniperEngine {
public:
SniperEngine(AppConfig& config, SniperState& state, DiscordNotifier& notifier);
~SniperEngine();
bool start(std::string* error_out = nullptr);
void stop();
bool isRunning() const;
// Fire-and-forget manual rescan of a single URL (spawns a detached thread,
// matching python's api_scan_now()).
void scanNow(const std::string& url);
bool addLink(LinkConfig link);
bool removeLink(const std::string& url);
bool togglePause(const std::string& url, bool* now_paused_out = nullptr);
std::vector<LinkConfig> links();
int intervalSeconds() const;
void setIntervalSeconds(int seconds);
private:
AppConfig& config_;
SniperState& state_;
DiscordNotifier& notifier_;
Scraper scraper_;
std::mutex links_mtx_;
std::thread worker_;
std::atomic<bool> stop_requested_{false};
void loop();
void runOneScan(const LinkConfig& cfg, bool isManual);
static std::string getChangeText(size_t n, const std::optional<std::vector<std::string>>& old);
};
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <string>
#include <vector>
#include <map>
#include <deque>
#include <mutex>
#include <atomic>
#include <optional>
struct ScanRecord {
std::string ts;
std::string url;
std::string label;
int count = 0;
bool changed = false;
};
// Thread-safe replacement for the python `state` dict. Shared between the
// SniperEngine worker thread and the Discord bot command handlers.
class SniperState {
public:
std::atomic<bool> running{false};
std::atomic<int> total_scans{0};
std::atomic<int> total_changes{0};
void setLastOffers(const std::string& url, std::vector<std::string> offers);
std::optional<std::vector<std::string>> getLastOffers(const std::string& url);
std::map<std::string, int> stockCounts();
void addHistory(ScanRecord rec);
std::vector<ScanRecord> getHistory();
void clearOffers();
void resetCounters();
void resetHistory();
private:
std::mutex mtx_;
std::map<std::string, std::vector<std::string>> last_offers_;
std::deque<ScanRecord> history_;
static constexpr size_t kMaxHistory = 200;
};