diff --git a/.cargo/config.toml b/.cargo/config.toml deleted file mode 100644 index 6189a7c..0000000 --- a/.cargo/config.toml +++ /dev/null @@ -1,14 +0,0 @@ -[target.x86_64-unknown-linux-musl] -linker = "x86_64-linux-musl-gcc" - -[build] -# Enable link-time optimization for smaller binaries -rustflags = ["-C", "link-arg=-s"] - -[profile.release] -# Optimize for size and performance -opt-level = "z" # Optimize for size -lto = true # Link-time optimization -codegen-units = 1 # Better optimization -panic = "abort" # Smaller binaries -strip = true # Remove debug symbols \ No newline at end of file diff --git a/.env.example b/.env.example index 941b044..0afa21a 100644 --- a/.env.example +++ b/.env.example @@ -1,41 +1,17 @@ -# Price update interval (shared by all services) -UPDATE_INTERVAL_SECONDS=30 +# Price update interval in seconds +UPDATE_INTERVAL_SECONDS=12 -# How often to clean up old price data (in hours) -CLEANUP_INTERVAL_HOURS=48 - -# Log level: error, warn, info, debug, trace -RUST_LOG=info - -# Discord Bot Tokens - get these from https://discord.com/developers/applications -# Create a bot, go to Bot, and click "Reset Token" to get a new token -# Then invite the bot to your server with appropriate permissions +# Discord Bot Tokens +# Format: DISCORD_TOKEN_=token DISCORD_TOKEN_BTC=your_btc_bot_token_here DISCORD_TOKEN_ETH=your_eth_bot_token_here DISCORD_TOKEN_SOL=your_sol_bot_token_here -DISCORD_TOKEN_DOGE=your_doge_bot_token_here -DISCORD_TOKEN_AVAX=your_avax_bot_token_here -DISCORD_TOKEN_BNB=your_bnb_bot_token_here -DISCORD_TOKEN_SUI=your_sui_bot_token_here -DISCORD_TOKEN_SEI=your_sei_bot_token_here -DISCORD_TOKEN_JLP=your_jlp_bot_token_here -DISCORD_TOKEN_PUMP=your_pump_bot_token_here -DISCORD_TOKEN_XPL=your_xpl_bot_token_here -DISCORD_TOKEN_MSTR=your_mstr_bot_token_here -DISCORD_TOKEN_OIL=your_oil_bot_token_here -DISCORD_TOKEN_VOO=your_voo_bot_token_here -DISCORD_TOKEN_DXY=your_dxy_bot_token_here -DISCORD_TOKEN_HOOD=your_hood_bot_token_here -DISCORD_TOKEN_SBET=your_sbet_bot_token_here -DISCORD_TOKEN_GOLD=your_gold_bot_token_here -DISCORD_TOKEN_SILVER=your_silver_bot_token_here -DISCORD_TOKEN_FARTCOIN=your_fartcoin_bot_token_here -DISCORD_TOKEN_2Z=your_2z_bot_token_here -DISCORD_TOKEN_ASTER=your_aster_bot_token_here -DISCORD_TOKEN_EURO=your_euro_bot_token_here -DISCORD_TOKEN_SHANGHAISILVER=your_shanghai_silver_bot_token_here -DISCORD_TOKEN_SHANGHAI=your_shanghai_bot_token_here -# Crypto Feed IDs (Pyth Network) - these are public and safe to share -# Get fresh IDs from https://pyth.network/docs/developers -CRYPTO_FEEDS=BTC:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43,ETH:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace,SOL:0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d,DOGE:0xdcef50dd0a4cd2dcc17e45df1676dcb336a11a61c69df7a0299b0150c672d25c,DXY:yahoo_finance +# Pyth Network Feed IDs (comma-separated) +# Format: CRYPTO:feed_id,CRYPTO:feed_id,... +CRYPTO_FEEDS=BTC:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43,ETH:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace,SOL:0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d + +# Database - CHANGE THESE PASSWORDS +POSTGRES_USER=postgres +POSTGRES_PASSWORD=PdefSMMIa8N22nKwHxmWz5znC13bUFo +DATABASE_URL=postgresql://postgres:PdefSMMIa8N22nKwHxmWz5znC13bUFo@postgres:5432/pricebot diff --git a/.factory/settings.json b/.factory/settings.json new file mode 100644 index 0000000..565f14a --- /dev/null +++ b/.factory/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "core@factory-plugins": true + } +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8fa896..f636d30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,51 +2,83 @@ name: CI on: push: - branches: [main, dev] + branches: [main, v2] pull_request: - branches: [main] + branches: [main, v2] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: - test: + lint: + name: Lint Python runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libssl-dev libfreetype6-dev libfontconfig1-dev + - uses: actions/checkout@v5 - - name: Cache cargo - uses: Swatinem/rust-cache@v2 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" - - name: Build - run: cargo build --release + - name: Install lint tools + run: pip install ruff - - name: Run tests - run: cargo test + - name: Run ruff + run: ruff check . docker: + name: Docker Build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + - name: Create test .env + run: | + cp .env.example .env + echo "DATABASE_URL=postgresql://postgres:postgres@postgres:5432/pricebot" >> .env - - name: Build Docker image - uses: docker/build-push-action@v5 - with: - context: . - load: true - tags: rustymcpriceface:test + - name: Build Docker Compose + run: docker compose build + + - name: Check Docker Compose validity + run: docker compose config --quiet + + smoke-test: + name: Smoke Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 - - name: Verify Docker image runs + - name: Create test .env run: | - docker run -d --name test-container -e DISCORD_TOKEN_BTC=test rustymcpriceface:test - sleep 10 - docker logs test-container - docker rm -f test-container + cp .env.example .env + echo "DATABASE_URL=postgresql://postgres:postgres@postgres:5432/pricebot" >> .env + + - name: Start services + run: docker compose up -d + + - name: Wait for postgres + run: | + for i in {1..30}; do + if docker compose exec -T postgres pg_isready -U postgres > /dev/null 2>&1; then + echo "Postgres ready" + exit 0 + fi + sleep 1 + done + echo "Postgres failed to start" + exit 1 + + - name: Check bot container + run: | + sleep 5 + docker compose ps + + - name: Check bot logs + run: | + docker compose logs bot | head -20 + + - name: Stop services + if: always() + run: docker compose down diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 83df11f..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Tests - -on: - push: - branches: [main, dev] - pull_request: - branches: [main] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y pkg-config libssl-dev libfreetype6-dev libfontconfig1-dev - - - name: Cache cargo - uses: Swatinem/rust-cache@v2 - - - name: Build - run: cargo build --release - - - name: Run tests - run: cargo test diff --git a/Cargo.lock b/Cargo.lock deleted file mode 100644 index 57888db..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,4098 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "aligned" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" -dependencies = [ - "as-slice", -] - -[[package]] -name = "aligned-vec" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" -dependencies = [ - "equator", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "arg_enum_proc_macro" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "serde", -] - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "async-trait" -version = "0.1.88" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "av-scenechange" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" -dependencies = [ - "aligned", - "anyhow", - "arg_enum_proc_macro", - "arrayvec", - "log", - "num-rational", - "num-traits", - "pastey", - "rayon", - "thiserror 2.0.18", - "v_frame", - "y4m", -] - -[[package]] -name = "av1-grain" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" -dependencies = [ - "anyhow", - "arrayvec", - "log", - "nom", - "num-rational", - "v_frame", -] - -[[package]] -name = "avif-serialize" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper 1.6.0", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tower 0.5.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper 1.0.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "bitstream-io" -version = "4.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" -dependencies = [ - "core2", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "built" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "bytecount" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "camino" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4acbb09d9ee8e23699b9634375c72795d095bf268439da88562cf9b501f181fa" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", -] - -[[package]] -name = "cc" -version = "1.2.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a42d84bb6b69d3a8b3eaacf0d88f179e1929695e1ad012b6cf64d9caaa5fd2" -dependencies = [ - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "chrono" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.1.3", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "command_attr" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fcc89439e1bb4e19050a9586a767781a3060000d2f3296fd2a40597ad9421c5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "core-text" -version = "20.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" -dependencies = [ - "core-foundation", - "core-graphics", - "foreign-types 0.5.0", - "libc", -] - -[[package]] -name = "core2" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" -dependencies = [ - "memchr", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", - "serde", -] - -[[package]] -name = "data-encoding" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" - -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", - "serde", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.60.2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "dlib" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" -dependencies = [ - "libloading", -] - -[[package]] -name = "dotenv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" - -[[package]] -name = "dwrote" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" -dependencies = [ - "lazy_static", - "libc", - "winapi", - "wio", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equator" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" -dependencies = [ - "equator-macro", -] - -[[package]] -name = "equator-macro" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "error-chain" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" -dependencies = [ - "version_check", -] - -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "fax" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "flate2" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "float-ord" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "font-kit" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" -dependencies = [ - "bitflags 2.9.1", - "byteorder", - "core-foundation", - "core-graphics", - "core-text", - "dirs", - "dwrote", - "float-ord", - "freetype-sys", - "lazy_static", - "libc", - "log", - "pathfinder_geometry", - "pathfinder_simd", - "walkdir", - "winapi", - "yeslogic-fontconfig-sys", -] - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared 0.1.1", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared 0.3.1", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "freetype-sys" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", -] - -[[package]] -name = "gif" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80792593675e051cf94a4b111980da2ba60d4a83e43e0048c5693baab3977045" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gif" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "glob" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" - -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.15.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" - -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http 1.3.1", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http 1.3.1", - "http-body 1.0.1", - "pin-project-lite", -] - -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - -[[package]] -name = "hyper-util" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" -dependencies = [ - "bytes", - "futures-core", - "http 1.3.1", - "http-body 1.0.1", - "hyper 1.6.0", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.24.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" -dependencies = [ - "bytemuck", - "byteorder", - "color_quant", - "jpeg-decoder", - "num-traits", - "png 0.17.16", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "exr", - "gif 0.14.1", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "rgb", - "tiff", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imgref" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - -[[package]] -name = "indexmap" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" -dependencies = [ - "equivalent", - "hashbrown 0.15.4", -] - -[[package]] -name = "interpolate_name" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "io-uring" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.3", - "libc", -] - -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" - -[[package]] -name = "js-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "levenshtein" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" - -[[package]] -name = "libc" -version = "0.2.174" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags 2.9.1", - "libc", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - -[[package]] -name = "lock_api" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" -dependencies = [ - "autocfg", - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "matchers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" -dependencies = [ - "regex-automata 0.1.10", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "maybe-rayon" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" -dependencies = [ - "cfg-if", - "rayon", -] - -[[package]] -name = "memchr" -version = "2.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "mini-moka" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c325dfab65f261f386debee8b0969da215b3fa0037e74c8a1234db7ba986d803" -dependencies = [ - "crossbeam-channel", - "crossbeam-utils", - "dashmap", - "skeptic", - "smallvec", - "tagptr", - "triomphe", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "openssl" -version = "0.10.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "foreign-types 0.3.2", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "parking_lot" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-targets 0.52.6", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pathfinder_geometry" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" -dependencies = [ - "log", - "pathfinder_simd", -] - -[[package]] -name = "pathfinder_simd" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" -dependencies = [ - "rustc_version", -] - -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plotters" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" -dependencies = [ - "chrono", - "font-kit", - "image 0.24.9", - "lazy_static", - "num-traits", - "pathfinder_geometry", - "plotters-backend", - "plotters-bitmap", - "plotters-svg", - "ttf-parser", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-bitmap" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" -dependencies = [ - "gif 0.12.0", - "image 0.24.9", - "plotters-backend", -] - -[[package]] -name = "plotters-svg" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" -dependencies = [ - "plotters-backend", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.9.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "potential_utf" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" -dependencies = [ - "profiling-procmacros", -] - -[[package]] -name = "profiling-procmacros" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" -dependencies = [ - "quote", - "syn 2.0.104", -] - -[[package]] -name = "pulldown-cmark" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" -dependencies = [ - "bitflags 2.9.1", - "memchr", - "unicase", -] - -[[package]] -name = "pxfm" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" - -[[package]] -name = "pyth-sol-price-demo" -version = "0.1.0" -dependencies = [ - "anyhow", - "axum", - "chrono", - "dotenv", - "futures", - "image 0.25.10", - "plotters", - "r2d2", - "r2d2_sqlite", - "regex", - "reqwest", - "rusqlite", - "serde", - "serde_json", - "serenity", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tower 0.4.13", - "tower-http", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - -[[package]] -name = "r2d2_sqlite" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dc290b669d30e20751e813517bbe13662d020419c5c8818ff10b6e8bb7777f6" -dependencies = [ - "r2d2", - "rusqlite", - "uuid", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.16", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.3", -] - -[[package]] -name = "rav1e" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" -dependencies = [ - "aligned-vec", - "arbitrary", - "arg_enum_proc_macro", - "arrayvec", - "av-scenechange", - "av1-grain", - "bitstream-io", - "built", - "cfg-if", - "interpolate_name", - "itertools", - "libc", - "libfuzzer-sys", - "log", - "maybe-rayon", - "new_debug_unreachable", - "noop_proc_macro", - "num-derive", - "num-traits", - "paste", - "profiling", - "rand 0.9.2", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.18", - "v_frame", - "wasm-bindgen", -] - -[[package]] -name = "ravif" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" -dependencies = [ - "avif-serialize", - "imgref", - "loop9", - "quick-error", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "redox_syscall" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" -dependencies = [ - "bitflags 2.9.1", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", -] - -[[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" - -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-rustls", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "mime_guess", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tokio-rustls 0.24.1", - "tokio-util", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots 0.25.4", - "winreg", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rusqlite" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a78046161564f5e7cd9008aff3b2990b3850dc8e0349119b98e8f251e099f24d" -dependencies = [ - "bitflags 2.9.1", - "fallible-iterator", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" -dependencies = [ - "bitflags 2.9.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.60.2", -] - -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - -[[package]] -name = "rustls" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" -dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - -[[package]] -name = "rustls-pki-types" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "secrecy" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" -dependencies = [ - "serde", - "zeroize", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.9.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" -dependencies = [ - "serde", -] - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_cow" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7bbbec7196bfde255ab54b65e34087c0849629280028238e67ee25d6a4b7da" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "serde_json" -version = "1.0.142" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" -dependencies = [ - "itoa", - "serde", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serenity" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d72ec4323681bf9a3cabe40fd080abc2435859b502a1b5aa9bf693f125bfa76" -dependencies = [ - "arrayvec", - "async-trait", - "base64 0.22.1", - "bitflags 2.9.1", - "bytes", - "chrono", - "command_attr", - "dashmap", - "flate2", - "futures", - "fxhash", - "levenshtein", - "mime_guess", - "parking_lot", - "percent-encoding", - "reqwest", - "secrecy", - "serde", - "serde_cow", - "serde_json", - "static_assertions", - "time", - "tokio", - "tokio-tungstenite", - "tracing", - "typemap_rev", - "typesize", - "url", - "uwl", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" -dependencies = [ - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "skeptic" -version = "0.13.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d23b015676c90a0f01c197bfdc786c20342c73a0afdda9025adb0bc42940a8" -dependencies = [ - "bytecount", - "cargo_metadata", - "error-chain", - "glob", - "pulldown-cmark", - "tempfile", - "walkdir", -] - -[[package]] -name = "slab" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tempfile" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" -dependencies = [ - "fastrand", - "getrandom 0.3.3", - "once_cell", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tiff" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" -dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", - "zune-jpeg", -] - -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - -[[package]] -name = "time-macros" -version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.47.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" -dependencies = [ - "backtrace", - "bytes", - "io-uring", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "slab", - "socket2 0.6.0", - "tokio-macros", - "windows-sys 0.59.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" -dependencies = [ - "rustls 0.22.4", - "rustls-pki-types", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" -dependencies = [ - "futures-util", - "log", - "rustls 0.22.4", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.25.0", - "tungstenite", - "webpki-roots 0.26.11", -] - -[[package]] -name = "tokio-util" -version = "0.7.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" -dependencies = [ - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper 1.0.2", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" -dependencies = [ - "bitflags 2.9.1", - "bytes", - "futures-util", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "http-range-header", - "httpdate", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "triomphe" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8f7726da4807b58ea5c96fdc122f80702030edc33b35aff9190a51148ccc85" - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ttf-parser" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" - -[[package]] -name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http 1.3.1", - "httparse", - "log", - "rand 0.8.5", - "rustls 0.22.4", - "rustls-pki-types", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - -[[package]] -name = "typemap_rev" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74b08b0c1257381af16a5c3605254d529d3e7e109f3c62befc5d168968192998" - -[[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - -[[package]] -name = "typesize" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da66c62c5b7017a2787e77373c03e6a5aafde77a73bff1ff96e91cd2e128179" -dependencies = [ - "chrono", - "dashmap", - "hashbrown 0.14.5", - "mini-moka", - "parking_lot", - "secrecy", - "serde_json", - "time", - "typesize-derive", - "url", -] - -[[package]] -name = "typesize-derive" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "536b6812192bda8551cfa0e52524e328c6a951b48e66529ee4522d6c721243d6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "getrandom 0.3.3", - "js-sys", - "rand 0.9.2", - "wasm-bindgen", -] - -[[package]] -name = "uwl" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0" - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.104", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.2", -] - -[[package]] -name = "webpki-roots" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.3", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" -dependencies = [ - "windows-link 0.1.3", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "wio" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" -dependencies = [ - "winapi", -] - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", -] - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" -dependencies = [ - "zune-core", -] diff --git a/Cargo.toml b/Cargo.toml deleted file mode 100644 index abf557e..0000000 --- a/Cargo.toml +++ /dev/null @@ -1,57 +0,0 @@ -[package] -name = "pyth-sol-price-demo" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "discord-bot" -path = "src/main.rs" - -[dependencies] -# Async runtime -tokio = { version = "1.0", features = ["full"] } -futures = "0.3" - -# HTTP client for API calls -reqwest = { version = "0.11", features = ["json"] } - -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -# Environment variables -dotenv = "0.15" - -# Error handling -anyhow = "1.0" -thiserror = "1.0" - -# Logging -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -# Time handling -chrono = { version = "0.4", features = ["serde", "clock"] } - -# Discord bot library -serenity = { version = "0.12", features = ["gateway", "http"] } - -# SQLite database -rusqlite = { version = "0.30", features = ["bundled"] } -r2d2 = "0.8" -r2d2_sqlite = "0.23" - -# Web framework -axum = "0.7" -tower = "0.4" -tower-http = { version = "0.5", features = ["fs", "cors"] } - -# Charting -plotters = "0.3" -image = "0.25" - -# HTML/Regex parsing -regex = "1.0" - -[dev-dependencies] -tempfile = "3.0" diff --git a/Dockerfile b/Dockerfile index bc6bd9f..85ff146 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,69 +1,25 @@ -# Build stage - compile Rust binaries -FROM rust:slim-bookworm AS builder +FROM python:3.12-alpine -# Install build dependencies -RUN apt-get update && apt-get install -y \ - pkg-config \ - libssl-dev \ - libfreetype6-dev \ - libfontconfig1-dev \ - && rm -rf /var/lib/apt/lists/* +RUN addgroup -g 1001 app && adduser -u 1001 -G app -s /bin/sh -D app WORKDIR /app -# Copy manifests first -COPY Cargo.toml Cargo.lock ./ - -# Create dummy src/main.rs to build dependencies -RUN mkdir src && echo "fn main() {}" > src/main.rs -RUN echo "fn main() {}" > src/db_query.rs -RUN echo "fn main() {}" > src/db_cleanup.rs -RUN echo "fn main() {}" > src/price_service.rs -RUN echo "fn main() {}" > src/shanghai_price_service.rs - -# Build dependencies -RUN cargo build --release - -# Now remove dummy source -RUN rm -rf src - -# Copy actual source code -COPY src/ ./src/ - -# Build the actual application -# We touch the main files to ensure cargo rebuilds them -RUN touch src/main.rs src/price_service.rs src/shanghai_price_service.rs -# Build only the main discord-bot binary -RUN cargo build --release --bin discord-bot - -# Runtime stage -FROM debian:bookworm-slim - -# Install runtime dependencies -RUN apt-get update && apt-get install -y \ - ca-certificates \ +RUN apk add --no-cache \ curl \ - jq \ - libfreetype6 \ - libfontconfig1 \ - fonts-dejavu-core \ - && rm -rf /var/lib/apt/lists/* \ - && groupadd -r appuser && useradd -r -g appuser appuser + libffi \ + fontconfig \ + freetype \ + libstdc++ -WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt -# Copy the single monolithic binary -COPY --from=builder /app/target/release/discord-bot ./rustymcpriceface +COPY *.py . -# Create shared directory and set permissions -RUN mkdir -p /app/shared \ - && chown -R appuser:appuser /app +RUN chown -R app:app /app -# Switch to non-root user for security -USER appuser +USER app -# Expose port for health check -EXPOSE 8080 +ENV PYTHONUNBUFFERED=1 -# Default command -CMD ["./rustymcpriceface"] +CMD ["python", "bot.py"] diff --git a/README.md b/README.md index fa7f781..298a6a1 100644 --- a/README.md +++ b/README.md @@ -1,132 +1,88 @@ # RustyMcPriceface -Discord bot that tracks cryptocurrency and asset prices using Pyth Network and posts updates to Discord channels. +Discord bot for tracking cryptocurrency and asset prices with beautiful charts. -## Architecture +## Features -``` - Pyth Network - | - Price Service - | - shared/prices.json - | - +----------------+----------------+ - | | | - Bot BTC Bot ETH Bot SOL - (token) (token) (token) - | | | - +----+----+ +----+----+ +----+----+ - | | | | | | - Discord SQLite Discord SQLite Discord SQLite -``` - -## How It Works - -1. Price Service fetches prices from Pyth Network every 30 seconds -2. Prices are written to shared JSON file and SQLite database -3. Each bot instance reads prices and updates its Discord nickname -4. Multiple bot instances run in parallel, one per token - -Each bot is independent - add more by adding tokens to .env. - -## Prerequisites - -- Docker and Docker Compose -- A Discord application with bot tokens +- Multiple independent bot instances, one per ticker +- Real-time price updates via Pyth Network, Yahoo Finance, and GoldSilver.ai +- Discord nicknames display ticker + current price +- Status cycles through BTC/ETH/SOL conversions and 1h change +- Historical price charts with high/low markers +- Detailed price embeds with 24h/7d/30d percentage changes +- PostgreSQL for persistent price history +- Lightweight Alpine-based Docker image (~266MB) -## Setup +## Quick Start -1. Copy the example environment file: - ``` - cp .env.example .env - ``` - -2. Edit `.env` and add your Discord bot tokens. Get tokens from https://discord.com/developers/applications - -3. In the Discord developer portal for each bot: - - Enable "Public Bot" - - Enable "Server Members Intent" - - Enable "Message Content Intent" - -4. Invite each bot to your server using the OAuth2 URL in the Discord developer portal - -5. The CRYPTO_FEEDS variable controls which assets to track: - ``` - CRYPTO_FEEDS=BTC:feed_id,ETH:feed_id,... - ``` - Get feed IDs from https://insights.pyth.network/price-feeds?search=btc - -## Running - -### Start -``` +```bash +cp .env.example .env +# Edit .env with your Discord bot tokens docker-compose up -d --build ``` -### Check Status -``` -docker-compose ps -docker-compose logs -f -``` +## Slash Commands -### Stop -``` -docker-compose down -``` +### /chart price +Generate a price chart with high/low markers and percentage change. -### Check Bot Status in Discord ``` -!status +/chart price timeframe:2w ``` -### Health Check -The health endpoint is available at localhost:8080/health - -``` -curl http://localhost:8080/health -``` +| Option | Default | Examples | +|--------|---------|----------| +| timeframe | 24h | 1h, 6h, 12h, 24h, 48h, 1w, 2w, 30d, 3m | -## Configuration +### /price current +Display current price with conversions and percentage changes. -### Update Interval -Edit `.env` and change `UPDATE_INTERVAL_SECONDS` (default 12 seconds): ``` -UPDATE_INTERVAL_SECONDS=30 +/price current +/price current crypto:ETH ``` -### Adding New Assets +Shows USD price, 24h/7d/30d changes, and BTC/ETH/SOL conversions. -1. Add a new bot token in `.env`: `DISCORD_TOKEN_ASSETNAME=your_token` -2. Add the Pyth Network feed ID in `CRYPTO_FEEDS`: `ASSETNAME:feed_id` -3. Rebuild: `docker-compose up -d --build` +## Supported Tickers -### Adding More Bots +| Ticker | Source | +|--------|--------| +| BTC, ETH, SOL, and other Pyth feeds | Pyth Network | +| DXY | Yahoo Finance | +| SSILVER | GoldSilver.ai | -Simply add more tokens to `.env`: -``` -DISCORD_TOKEN_BTC=your_btc_token -DISCORD_TOKEN_ETH=your_eth_token -DISCORD_TOKEN_NEWTICKER=your_new_token -``` +## Environment Variables -The bot will automatically spawn new instances for each token. +```bash +# Bot tokens - one per ticker +DISCORD_TOKEN_BTC=your_token +DISCORD_TOKEN_ETH=your_token -## Bot Commands +# Pyth feed IDs +CRYPTO_FEEDS=BTC:feed_id,ETH:feed_id,SOL:feed_id -- `!BTC` - Get BTC price -- `!ETH` - Get ETH price -- `/price` - Slash command for prices -- `!silverchart` - Get silver price chart -- `!status` - Check system status (BTC bot only) +# Optional +UPDATE_INTERVAL_SECONDS=12 +``` ## Tech Stack -- Docker with Docker Compose -- Debian (Docker base image) -- Rust (edition 2021) -- Serenity (Discord bot library) -- SQLite (database) -- Pyth Network (price feeds) -- Axum (HTTP server) -- Plotters (chart generation) +- Python 3.12 (Alpine) +- discord.py +- asyncpg / PostgreSQL +- matplotlib +- aiohttp +- Docker / Docker Compose + +## Project Structure + +``` +β”œβ”€β”€ bot.py # Main bot, commands, status cycling +β”œβ”€β”€ database.py # PostgreSQL operations +β”œβ”€β”€ price_service.py # Price fetching +β”œβ”€β”€ chart_service.py # Chart generation +β”œβ”€β”€ docker-compose.yml +β”œβ”€β”€ Dockerfile +└── requirements.txt +``` diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..5c16992 --- /dev/null +++ b/bot.py @@ -0,0 +1,464 @@ +""" +Discord Bot for cryptocurrency price tracking. +Uses discord.py, asyncpg, and Pyth Network API. +""" +import asyncio +import io +import logging +import os +import sys +from dataclasses import dataclass +from typing import Optional + +import discord +from discord import app_commands +from dotenv import load_dotenv + +from database import Database +from price_service import PriceService +from chart_service import ChartService + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +logger = logging.getLogger(__name__) + +load_dotenv() + + +@dataclass +class BotConfig: + name: str + token: str + crypto: str + feed_id: str + pyth_feed_id: Optional[str] = None + + +def load_bot_configs() -> list[BotConfig]: + """Load bot configurations from environment variables.""" + configs = [] + + for key, value in os.environ.items(): + if key.startswith("DISCORD_TOKEN_") and value and key != "DISCORD_TOKEN": + name = key.replace("DISCORD_TOKEN_", "") + crypto = os.environ.get(f"CRYPTO_{name}", name.lower()) + feed_id = os.environ.get(f"FEED_ID_{name}", "") + configs.append(BotConfig( + name=name, + token=value, + crypto=crypto, + feed_id=feed_id, + )) + + return configs + + +DISPLAY_NAME_MAP = { + "SHANGHAISILVER": "SSILVER", +} + + +def get_display_name(crypto: str) -> str: + """Get display name for a ticker, using shorter aliases where defined.""" + return DISPLAY_NAME_MAP.get(crypto.upper(), crypto.upper()) + + +def format_price(price: float) -> str: + """Format price for display.""" + if price >= 1000: + return f"${price:,.0f}" + elif price >= 1: + return f"${price:,.2f}" + else: + return f"${price:.6f}" + + +def calculate_change_percent(current: float, previous: float) -> float: + """Calculate percentage change.""" + if previous <= 0: + return 0.0 + return ((current - previous) / previous) * 100 + + +class PriceBot(discord.Client): + def __init__(self, config: BotConfig, db: Database, price_service: PriceService, chart_service: ChartService): + intents = discord.Intents.default() + super().__init__(intents=intents) + self.config = config + self.db = db + self.price_service = price_service + self.chart_service = chart_service + self.tree = app_commands.CommandTree(self) + + async def setup_hook(self): + price_group = PriceGroup(self.db, self.price_service, self.config.crypto) + self.tree.add_command(price_group) + + chart_group = ChartGroup(self.db, self.chart_service, self.config.crypto) + self.tree.add_command(chart_group) + + await self.tree.sync() + logger.info(f"Synced commands for {self.config.name}") + + async def on_ready(self): + logger.info(f"Logged in as {self.user} ({self.user.id}) for {self.config.name}") + await self.start_price_updates() + + async def get_1h_change(self, crypto: str) -> float: + """Get 1 hour percentage change from database.""" + try: + history = await self.db.get_price_history(crypto, hours=1) + if len(history) >= 2: + oldest = history[0][1] + newest = history[-1][1] + return calculate_change_percent(newest, oldest) + except Exception as e: + logger.debug(f"Could not get 1h change for {crypto}: {e}") + return 0.0 + + async def get_price_for_crypto(self, crypto: str) -> Optional[float]: + """Get price, using database fallback for SSILVER.""" + price = await self.price_service.get_price(crypto) + + if price is None or price <= 0: + db_price = await self.db.get_latest_price(crypto) + if db_price and db_price > 0: + logger.debug(f"Using cached {crypto} price: ${db_price}") + return db_price + return None + + if crypto == "SSILVER" and price < 10: + db_price = await self.db.get_latest_price(crypto) + if db_price and db_price > 10: + logger.debug(f"SSILVER: Live price {price} seems low, using cached: ${db_price}") + return db_price + logger.warning(f"SSILVER: Price {price} below threshold, using anyway") + + return price + + async def get_conversion_prices(self) -> dict: + """Get BTC, ETH, SOL prices for conversion.""" + prices = {} + for ticker in ["BTC", "ETH", "SOL"]: + try: + p = await self.price_service.get_price(ticker) + if p and p > 0: + prices[ticker] = p + else: + db_p = await self.db.get_latest_price(ticker) + if db_p and db_p > 0: + prices[ticker] = db_p + except Exception as e: + logger.debug(f"Could not get {ticker} price: {e}") + return prices + + async def update_discord_presence(self, price: float, change_percent: float, display_crypto: str, conversions: dict, show_index: int): + """Update nickname and custom status.""" + try: + guilds = self.guilds + if not guilds: + return + + formatted_price = format_price(price) + nickname = f"{get_display_name(display_crypto)} {formatted_price}" + + # Cycle through: BTC value, ETH value, SOL value, 1h% + tickers = ["BTC", "ETH", "SOL"] + ticker = tickers[show_index % 3] + + if ticker in conversions and conversions[ticker] > 0 and display_crypto.upper() != ticker: + converted = price / conversions[ticker] + status_text = f"{converted:.6f} {ticker}" + else: + change_sign = "+" if change_percent >= 0 else "" + status_text = f"{change_sign}{change_percent:.2f}% (1h)" + + activity = discord.Activity( + type=discord.ActivityType.watching, + name=status_text + ) + + for guild in guilds: + member = guild.get_member(self.user.id) + if member: + try: + await member.edit(nick=nickname) + except Exception as e: + logger.debug(f"Could not update nickname in {guild.name}: {e}") + + await self.change_presence(activity=activity) + logger.debug(f"Updated {self.config.name}: {nickname} | {status_text}") + + except Exception as e: + logger.error(f"Failed to update Discord presence: {e}") + + async def start_price_updates(self): + """Background task to update price and Discord presence periodically.""" + async def update_loop(): + interval = int(os.environ.get("UPDATE_INTERVAL_SECONDS", "12")) + current_price = None + current_change = 0.0 + conversions = {} + show_index = 0 # Cycles: 0=BTC, 1=ETH, 2=SOL, 3=1h%, then repeats + + while True: + try: + price = await self.get_price_for_crypto(self.config.crypto) + if price and price > 0: + await self.db.save_price(self.config.crypto, price) + current_price = price + current_change = await self.get_1h_change(self.config.crypto) + conversions = await self.get_conversion_prices() + + for ticker, ticker_price in conversions.items(): + await self.db.save_price(ticker, ticker_price) + + if current_price: + await self.update_discord_presence( + current_price, + current_change, + self.config.crypto, + conversions, + show_index + ) + show_index += 1 + logger.debug(f"Updated {self.config.name}: {self.config.crypto} ${current_price} {current_change:+.2f}%") + + except Exception as e: + logger.error(f"Failed to update for {self.config.name}: {e}") + + await asyncio.sleep(interval) + + asyncio.create_task(update_loop()) + + +class ChartGroup(app_commands.Group): + TIMEFRAME_OPTIONS = { + "1h": 1, "1hr": 1, "1hour": 1, + "6h": 6, "6hr": 6, "6hour": 6, + "12h": 12, "12hr": 12, "12hour": 12, + "24h": 24, "1d": 24, "24hr": 24, "1day": 24, + "48h": 48, "2d": 48, "48hr": 48, "2day": 48, + "168h": 168, "7d": 168, "1w": 168, "1wk": 168, "1week": 168, + "336h": 336, "14d": 336, "2w": 336, "2wk": 336, "2week": 336, + "720h": 720, "30d": 720, "30day": 720, "1m": 720, "1month": 720, + "2160h": 2160, "90d": 2160, "3m": 2160, "3month": 2160, "90day": 2160, + } + + def __init__(self, db: Database, chart_service: ChartService, crypto_name: str): + super().__init__(name="chart", description=f"{crypto_name} chart commands") + self.db = db + self.chart_service = chart_service + self.crypto_name = crypto_name + + @app_commands.command() + @app_commands.describe(timeframe="Timeframe (e.g., 24h, 2d, 1w, 30d, 3m)") + async def price(self, interaction: discord.Interaction, timeframe: str = "24h"): + """Generate price chart.""" + hours = self.TIMEFRAME_OPTIONS.get(timeframe.lower()) + if not hours: + await interaction.response.send_message( + f"Invalid timeframe '{timeframe}'. Try: 24h, 2d, 1w, 30d, 3m", + ephemeral=True + ) + return + await self._send_chart(interaction, self.crypto_name, hours, timeframe) + + @price.autocomplete("timeframe") + async def timeframe_autocomplete(self, interaction: discord.Interaction, current: str): + options = list(self.TIMEFRAME_OPTIONS.keys()) + filtered = [opt for opt in options if current.lower() in opt.lower()] if current else options[:9] + return [app_commands.Choice(name=opt, value=opt) for opt in filtered[:25]] + + async def _send_chart(self, interaction: discord.Interaction, crypto: str, hours: int, timeframe_str: str = "24h"): + await interaction.response.defer() + + try: + chart_bytes = await self.chart_service.get_chart_bytes(self.db, crypto, hours, timeframe_str) + + if not chart_bytes: + await interaction.followup.send(f"No price data available for {crypto} (need at least 2 data points)") + return + + buf = io.BytesIO(chart_bytes) + buf.name = f"{crypto.lower()}_chart.png" + file = discord.File(buf, filename=buf.name) + + await interaction.followup.send( + content=f"**{crypto.upper()} - {timeframe_str} chart**", + file=file + ) + except Exception as e: + logger.error(f"Chart command failed for {crypto}: {e}") + await interaction.followup.send(f"Error generating chart: {e}") + + + + + +class PriceGroup(app_commands.Group): + def __init__(self, db: Database, price_service: PriceService, default_crypto: str): + super().__init__(name="price", description="Crypto price commands") + self.db = db + self.price_service = price_service + self.default_crypto = default_crypto.upper() + + def _get_change(self, history: list) -> float: + if len(history) < 2: + return 0.0 + oldest = history[0][1] + newest = history[-1][1] + if oldest <= 0: + return 0.0 + return ((newest - oldest) / oldest) * 100 + + @app_commands.command() + async def current(self, interaction: discord.Interaction, crypto: str = None): + """Get current price of a cryptocurrency with conversions.""" + crypto = (crypto or self.default_crypto).upper() + + try: + price = await self.db.get_latest_price(crypto) + if not price: + fresh_price = await self.price_service.get_price(crypto) + if fresh_price: + await self.db.save_price(crypto, fresh_price) + price = fresh_price + else: + await interaction.response.send_message(f"No price data for {crypto}") + return + + conversions = {} + for ticker in ["BTC", "ETH", "SOL"]: + try: + conv_price = await self.price_service.get_price(ticker) + if conv_price and conv_price > 0: + conversions[ticker] = conv_price + else: + db_p = await self.db.get_latest_price(ticker) + if db_p and db_p > 0: + conversions[ticker] = db_p + except Exception: + pass + + history_24h = await self.db.get_price_history(crypto, hours=24) + history_7d = await self.db.get_price_history(crypto, hours=168) + history_30d = await self.db.get_price_history(crypto, hours=720) + + change_24h = self._get_change(history_24h) + change_7d = self._get_change(history_7d) + change_30d = self._get_change(history_30d) + + def change_block(changes: float, label: str) -> str: + color = "🟒" if changes >= 0 else "πŸ”΄" + sign = "+" if changes >= 0 else "" + return f"**{label}**\n{color} {sign}{changes:.2f}%" + + embed = discord.Embed( + title=f"{crypto}", + color=0x00ff00 if change_24h >= 0 else 0xff0000 + ) + + embed.add_field( + name="USD", + value=f"**${price:,.6f}**" if price < 1 else f"**${price:,.2f}**" if price >= 100 else f"**${price:,.4f}**", + inline=False + ) + + embed.add_field( + name="24h", + value=change_block(change_24h, ""), + inline=True + ) + + embed.add_field( + name="7d", + value=change_block(change_7d, ""), + inline=True + ) + + embed.add_field( + name="30d", + value=change_block(change_30d, ""), + inline=True + ) + + conversions_text = "" + if "BTC" in conversions and conversions["BTC"] > 0 and crypto != "BTC": + btc_val = price / conversions["BTC"] + conversions_text += f"BTC: `{btc_val:.8f}`\n" + if "ETH" in conversions and conversions["ETH"] > 0 and crypto != "ETH": + eth_val = price / conversions["ETH"] + conversions_text += f"ETH: `{eth_val:.8f}`\n" + if "SOL" in conversions and conversions["SOL"] > 0 and crypto != "SOL": + sol_val = price / conversions["SOL"] + conversions_text += f"SOL: `{sol_val:.8f}`\n" + + if conversions_text: + embed.add_field( + name="Conversions", + value=conversions_text, + inline=False + ) + + await interaction.response.send_message(embed=embed) + + except Exception as e: + logger.error(f"Price command failed: {e}") + await interaction.response.send_message(f"Error: {e}") + + +async def run_bot(cfg: BotConfig): + """Run a single bot with its own db connection.""" + db = Database() + await db.connect() + price_service = PriceService() + chart_service = ChartService() + + client = PriceBot(cfg, db, price_service, chart_service) + + while True: + try: + logger.info(f"Starting bot {cfg.name}...") + await client.start(cfg.token) + logger.warning(f"Bot {cfg.name} disconnected, reconnecting in 5s...") + except discord.LoginFailure: + logger.error(f"Bot {cfg.name} login failed - invalid token") + break + except KeyboardInterrupt: + logger.info(f"Bot {cfg.name} shutting down...") + break + except Exception as e: + logger.error(f"Bot {cfg.name} error: {e}, reconnecting in 5s...") + + await asyncio.sleep(5) + + await price_service.close() + await db.disconnect() + logger.info(f"Bot {cfg.name} stopped") + + +if __name__ == "__main__": + configs = load_bot_configs() + + if not configs: + logger.error("No bot configurations found!") + sys.exit(1) + + logger.info(f"Found {len(configs)} bot configuration(s)") + + async def run_all(): + tasks = [asyncio.create_task(run_bot(cfg)) for cfg in configs] + + try: + await asyncio.gather(*tasks) + except KeyboardInterrupt: + logger.info("Shutting down...") + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + + asyncio.run(run_all()) diff --git a/chart_service.py b/chart_service.py new file mode 100644 index 0000000..84c5e74 --- /dev/null +++ b/chart_service.py @@ -0,0 +1,182 @@ +""" +Chart generation service using matplotlib. +""" +import io +import logging +from datetime import datetime +from typing import Optional + +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +import matplotlib.dates as mdates +import numpy as np + +logger = logging.getLogger(__name__) + + +class ChartService: + + def _format_price(self, price: float) -> str: + """Format price nicely.""" + if price >= 10000: + return f"${price:,.0f}" + elif price >= 100: + return f"${price:,.2f}" + elif price >= 1: + return f"${price:.4f}" + else: + return f"${price:.6f}" + + def generate_price_chart( + self, + timestamps: list, + prices: list, + crypto_name: str, + timeframe: str = "24h", + hours: int = 24 + ) -> Optional[bytes]: + """Generate a price chart and return as PNG bytes.""" + if not timestamps or not prices or len(timestamps) < 2: + return None + + try: + fig = plt.figure(figsize=(14, 8), facecolor='#0d1117') + ax = fig.add_subplot(111, facecolor='#0d1117') + + dates = [datetime.fromtimestamp(ts) for ts in timestamps] + prices_arr = np.array(prices) + + start_price = prices[0] + end_price = prices[-1] + change = ((end_price - start_price) / start_price) * 100 + line_color = '#00d26a' if change >= 0 else '#ff4757' + + ax.plot(dates, prices_arr, color=line_color, linewidth=2.5, zorder=3) + + ax.fill_between(dates, prices_arr, + alpha=0.15, color=line_color, zorder=2) + + ax.scatter(dates, prices_arr, color=line_color, s=30, zorder=4, + edgecolors='#0d1117', linewidths=0.5) + + min_idx = np.argmin(prices_arr) + max_idx = np.argmax(prices_arr) + + ax.scatter(dates[min_idx], prices_arr[min_idx], color='#ff4757', + s=100, zorder=5, marker='v', edgecolors='white', linewidths=1) + ax.scatter(dates[max_idx], prices_arr[max_idx], color='#00d26a', + s=100, zorder=5, marker='^', edgecolors='white', linewidths=1) + + ax.annotate(f'LOW\n{self._format_price(prices_arr[min_idx])}', + xy=(dates[min_idx], prices_arr[min_idx]), + xytext=(10, -30), textcoords='offset points', + fontsize=8, color='#888888', + bbox=dict(boxstyle='round,pad=0.3', facecolor='#161b22', + edgecolor='#30363d', pad=0.3), + arrowprops=dict(arrowstyle='->', color='#ff4757', lw=1)) + + ax.annotate(f'HIGH\n{self._format_price(prices_arr[max_idx])}', + xy=(dates[max_idx], prices_arr[max_idx]), + xytext=(10, 20), textcoords='offset points', + fontsize=8, color='#888888', + bbox=dict(boxstyle='round,pad=0.3', facecolor='#161b22', + edgecolor='#30363d', pad=0.3), + arrowprops=dict(arrowstyle='->', color='#00d26a', lw=1)) + + ax.annotate(f'{self._format_price(end_price)}', + xy=(dates[-1], prices_arr[-1]), + xytext=(10, 0), textcoords='offset points', + fontsize=11, color=line_color, fontweight='bold', + bbox=dict(boxstyle='round,pad=0.4', facecolor='#161b22', + edgecolor=line_color, pad=0.4), + arrowprops=dict(arrowstyle='->', color=line_color, lw=1)) + + ax.set_title( + f'{crypto_name} | {timeframe} | {change:+.2f}%', + fontsize=16, fontweight='bold', color='white', + pad=20, loc='center' + ) + + ax.set_ylabel('Price (USD)', fontsize=11, color='#888888', labelpad=10) + ax.set_xlabel('Time', fontsize=11, color='#888888', labelpad=10) + + ax.tick_params(colors='#888888', labelsize=9) + ax.spines['bottom'].set_color('#30363d') + ax.spines['left'].set_color('#30363d') + ax.spines['top'].set_visible(False) + ax.spines['right'].set_visible(False) + + if hours <= 24: + ax.xaxis.set_major_formatter(mdates.DateFormatter('%H:%M')) + elif hours <= 168: + ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d %H:%M')) + else: + ax.xaxis.set_major_formatter(mdates.DateFormatter('%b %d')) + ax.xaxis.set_major_locator(mdates.AutoDateLocator()) + + price_min = min(prices_arr) + price_max = max(prices_arr) + price_range = price_max - price_min + ax.set_ylim(price_min - price_range * 0.1, price_max + price_range * 0.15) + + fig.autofmt_xdate() + + ax.grid(True, alpha=0.1, color='#30363d', linestyle='--', zorder=1) + + for spine in ax.spines.values(): + spine.set_zorder(0) + + buf = io.BytesIO() + plt.savefig( + buf, + format='png', + bbox_inches='tight', + facecolor='#0d1117', + edgecolor='none', + dpi=100 + ) + buf.seek(0) + plt.close(fig) + + return buf.read() + + except Exception as e: + logger.error(f"Failed to generate chart for {crypto_name}: {e}") + return None + + def _downsample(self, timestamps: list, prices: list, max_points: int = 500) -> tuple: + """Downsample data to max_points for performance.""" + if len(timestamps) <= max_points: + return timestamps, prices + + step = len(timestamps) // max_points + return timestamps[::step], prices[::step] + + async def get_chart_bytes( + self, + db, + crypto: str, + hours: int = 24, + timeframe_str: str = None + ) -> Optional[bytes]: + """Get price history from DB and generate chart.""" + limit = 1000 + history = await db.get_price_history(crypto, hours=hours, limit=limit) + + if not history or len(history) < 2: + return None + + timestamps = [h[0] for h in history] + prices = [float(h[1]) for h in history] + + if not timeframe_str: + if hours <= 24: + timeframe_str = f"{hours}h" + elif hours <= 720: + timeframe_str = f"{hours//24}d" + elif hours <= 8760: + timeframe_str = f"{hours//24}d" + else: + timeframe_str = f"{hours//720}mo" + return self.generate_price_chart(timestamps, prices, crypto.upper(), timeframe_str, hours) \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..8f4c4de --- /dev/null +++ b/database.py @@ -0,0 +1,284 @@ +""" +PostgreSQL database operations using asyncpg. +""" +import asyncio +import logging +import os +import time +from typing import Optional + +import asyncpg + +logger = logging.getLogger(__name__) + +ONE_HOUR = 3600 +ONE_DAY = 86400 +ONE_WEEK = 604800 + + +class Database: + def __init__(self): + self.pool: Optional[asyncpg.Pool] = None + self.dsn = os.environ.get("DATABASE_URL") + if not self.dsn: + raise ValueError("DATABASE_URL environment variable is required") + self._last_cleanup = 0 + self._last_aggregate = 0 + + async def connect(self): + """Connect to PostgreSQL and create tables.""" + try: + self.pool = await asyncpg.create_pool( + self.dsn, + min_size=2, + max_size=10, + ) + await self._create_tables() + logger.info("Connected to PostgreSQL") + except Exception as e: + logger.error(f"Failed to connect to database: {e}") + raise + + async def disconnect(self): + """Close database connection.""" + if self.pool: + await self.pool.close() + logger.info("Disconnected from PostgreSQL") + + async def _create_tables(self): + """Create necessary tables if they don't exist.""" + async with self.pool.acquire() as conn: + try: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS prices ( + id BIGSERIAL PRIMARY KEY, + crypto_name TEXT NOT NULL, + price DOUBLE PRECISION NOT NULL, + timestamp BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """) + except Exception as e: + if "already exists" not in str(e): + logger.warning(f"Table creation warning (may be OK): {e}") + + try: + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_prices_crypto_timestamp + ON prices(crypto_name, timestamp DESC) + """) + except Exception as e: + logger.warning(f"Index creation warning (may be OK): {e}") + + try: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS price_aggregates ( + id BIGSERIAL PRIMARY KEY, + crypto_name TEXT NOT NULL, + bucket_start BIGINT NOT NULL, + bucket_duration INTEGER NOT NULL, + open_price REAL NOT NULL, + high_price REAL NOT NULL, + low_price REAL NOT NULL, + close_price REAL NOT NULL, + avg_price REAL NOT NULL, + sample_count INTEGER NOT NULL, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ) + """) + except Exception as e: + if "already exists" not in str(e): + logger.warning(f"Table creation warning (may be OK): {e}") + + try: + await conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_aggregates_crypto_bucket + ON price_aggregates(crypto_name, bucket_start, bucket_duration) + """) + except Exception as e: + logger.warning(f"Index creation warning (may be OK): {e}") + + logger.info("Database tables initialized") + + async def _should_run_task(self, last_run: float, interval: int) -> bool: + """Check if a task should run based on interval.""" + return (time.time() - last_run) >= interval + + async def _aggregate_prices(self): + """Aggregate raw prices into time buckets.""" + now = int(time.time()) + + buckets = [ + (300, 7 * ONE_DAY), # 5-min aggregates for 7 days + (ONE_HOUR, 30 * ONE_DAY), # hourly aggregates for 30 days + (ONE_DAY, 365 * ONE_DAY), # daily aggregates for 1 year + (ONE_WEEK, 5 * 365 * ONE_DAY), # weekly for 5 years + ] + + async with self.pool.acquire() as conn: + for duration, max_age in buckets: + bucket_start = (now // duration) * duration + cutoff = now - max_age + + await conn.execute(""" + INSERT INTO price_aggregates + (crypto_name, bucket_start, bucket_duration, open_price, high_price, + low_price, close_price, avg_price, sample_count) + SELECT + crypto_name, + $1 as bucket_start, + $2 as bucket_duration, + (ARRAY_AGG(price ORDER BY timestamp ASC))[1] as open_price, + MAX(price) as high_price, + MIN(price) as low_price, + (ARRAY_AGG(price ORDER BY timestamp DESC))[1] as close_price, + AVG(price) as avg_price, + COUNT(*) as sample_count + FROM prices + WHERE timestamp >= $3 AND timestamp < $1 + AND NOT EXISTS ( + SELECT 1 FROM price_aggregates + WHERE crypto_name = prices.crypto_name + AND bucket_start = $1 + AND bucket_duration = $2 + ) + GROUP BY crypto_name + HAVING COUNT(*) > 0 + """, bucket_start, duration, cutoff) + + async def _cleanup_old_data(self): + """Delete price data older than retention period.""" + now = int(time.time()) + retention = 5 * 365 * ONE_DAY # 5 years + + async with self.pool.acquire() as conn: + await conn.execute(""" + DELETE FROM prices WHERE timestamp < $1 + """, now - retention) + + await conn.execute(""" + DELETE FROM price_aggregates WHERE bucket_start < $1 + """, now - retention) + + logger.info("Cleanup: deleted old price data (retention: 5 years)") + + async def _run_maintenance(self): + """Run periodic maintenance tasks.""" + now = time.time() + + if self._should_run_task(self._last_aggregate, ONE_HOUR): + await self._aggregate_prices() + self._last_aggregate = now + + if self._should_run_task(self._last_cleanup, ONE_DAY): + await self._cleanup_old_data() + self._last_cleanup = now + + async def save_price(self, crypto_name: str, price: float) -> bool: + """Save a price to the database.""" + if price <= 0: + return False + + timestamp = int(time.time()) + + async with self.pool.acquire() as conn: + await conn.execute(""" + INSERT INTO prices (crypto_name, price, timestamp) + VALUES ($1, $2, $3) + """, crypto_name.upper(), price, timestamp) + + asyncio.create_task(self._run_maintenance()) + return True + + async def get_latest_price(self, crypto_name: str) -> Optional[float]: + """Get the latest price for a cryptocurrency.""" + async with self.pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT price FROM prices + WHERE crypto_name = $1 + ORDER BY timestamp DESC + LIMIT 1 + """, crypto_name.upper()) + + if row: + return float(row['price']) + return None + + def _get_bucket_for_hours(self, hours: int) -> tuple: + """Get appropriate bucket duration and SQL for given timeframe.""" + if hours <= 24: + return ("raw", """ + SELECT timestamp, price FROM prices + WHERE crypto_name = $1 AND timestamp > $2 + ORDER BY timestamp ASC + LIMIT $3 + """) + elif hours <= 168: + return ("5min", """ + SELECT bucket_start as timestamp, avg_price as price + FROM price_aggregates + WHERE crypto_name = $1 AND bucket_start > $2 AND bucket_duration = 300 + ORDER BY bucket_start ASC + """) + elif hours <= 720: + return ("hourly", """ + SELECT bucket_start as timestamp, avg_price as price + FROM price_aggregates + WHERE crypto_name = $1 AND bucket_start > $2 AND bucket_duration = 3600 + ORDER BY bucket_start ASC + """) + elif hours <= 8760: + return ("daily", """ + SELECT bucket_start as timestamp, avg_price as price + FROM price_aggregates + WHERE crypto_name = $1 AND bucket_start > $2 AND bucket_duration = 86400 + ORDER BY bucket_start ASC + """) + elif hours <= 43800: + return ("weekly", """ + SELECT bucket_start as timestamp, avg_price as price + FROM price_aggregates + WHERE crypto_name = $1 AND bucket_start > $2 AND bucket_duration = 604800 + ORDER BY bucket_start ASC + """) + else: + return ("monthly", """ + SELECT bucket_start as timestamp, avg_price as price + FROM price_aggregates + WHERE crypto_name = $1 AND bucket_start > $2 AND bucket_duration = 2592000 + ORDER BY bucket_start ASC + """) + + async def get_price_history(self, crypto_name: str, hours: int = 24, limit: int = 2000) -> list: + """Get price history for a cryptocurrency using appropriate aggregation.""" + cutoff = int(time.time()) - (hours * 3600) + bucket_type, query = self._get_bucket_for_hours(hours) + + async with self.pool.acquire() as conn: + rows = await conn.fetch( + query, + crypto_name.upper(), cutoff, limit + ) + + if not rows and bucket_type != "raw": + query = """ + SELECT timestamp, price FROM prices + WHERE crypto_name = $1 AND timestamp > $2 + ORDER BY timestamp ASC + LIMIT $3 + """ + rows = await conn.fetch(query, crypto_name.upper(), cutoff, limit) + + return [(r['timestamp'], float(r['price'])) for r in rows] + + async def get_all_latest_prices(self) -> dict: + """Get latest price for all cryptocurrencies.""" + async with self.pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT DISTINCT ON (crypto_name) + crypto_name, price, timestamp + FROM prices + ORDER BY crypto_name, timestamp DESC + """) + + return {r['crypto_name']: float(r['price']) for r in rows} diff --git a/docker-compose.yml b/docker-compose.yml index c1ff352..1526644 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,27 +1,36 @@ services: - app: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: pricebot + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + bot: build: . - container_name: rustymcpriceface restart: unless-stopped + depends_on: + postgres: + condition: service_healthy env_file: - .env - volumes: - - ./shared:/app/shared environment: - - RUST_LOG=info,rustymcpriceface=info,discord_bot=info + - DATABASE_URL=${DATABASE_URL} - UPDATE_INTERVAL_SECONDS=${UPDATE_INTERVAL_SECONDS:-12} - - CLEANUP_INTERVAL_HOURS=${CLEANUP_INTERVAL_HOURS:-48} - - CRYPTO_FEEDS=${CRYPTO_FEEDS:-BTC:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43,ETH:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace,SOL:0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d,DXY:yahoo_finance} - - ports: - - "127.0.0.1:8080:8080" + - CRYPTO_FEEDS=${CRYPTO_FEEDS:-BTC:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43,ETH:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace,SOL:0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d} logging: driver: "json-file" options: max-size: "10m" max-file: "3" - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health/all"] - interval: 30s - timeout: 10s - retries: 3 + +volumes: + postgres_data: diff --git a/price_service.py b/price_service.py new file mode 100644 index 0000000..374367d --- /dev/null +++ b/price_service.py @@ -0,0 +1,190 @@ +""" +Price fetching service using Pyth Network API. +""" +import logging +import os +import re +from typing import Optional + +import aiohttp + +logger = logging.getLogger(__name__) + +HERMES_API_URL = "https://hermes.pyth.network/api/latest_price_feeds" +GOLDSILVER_AI_URL = "https://goldsilver.ai/metal-prices/shanghai-silver-price" + + +class PriceService: + def __init__(self): + self.feeds = self._load_feeds() + self.session: Optional[aiohttp.ClientSession] = None + + def _load_feeds(self) -> dict: + """Load feed IDs from environment.""" + feeds_str = os.environ.get( + "CRYPTO_FEEDS", + "BTC:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43," + "ETH:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace," + "SOL:0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d" + ) + + feeds = {} + for pair in feeds_str.split(","): + if ":" in pair: + name, feed_id = pair.split(":", 1) + feeds[name.strip().upper()] = feed_id.strip() + + logger.info(f"Loaded {len(feeds)} price feeds") + return feeds + + async def _get_session(self) -> aiohttp.ClientSession: + if self.session is None or self.session.closed: + timeout = aiohttp.ClientTimeout(total=15) + self.session = aiohttp.ClientSession(timeout=timeout) + return self.session + + async def get_shanghai_silver_price(self) -> Optional[float]: + """Fetch Shanghai Silver price from goldsilver.ai.""" + try: + session = await self._get_session() + headers = { + "User-Agent": "RustyMcPriceface/1.0 (crypto price bot)", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + } + async with session.get(GOLDSILVER_AI_URL, headers=headers) as resp: + if resp.status != 200: + logger.warning(f"goldsilver.ai returned {resp.status}") + return None + + text = await resp.text() + + # Extract number after "Shanghai Spot" and "$" + shanghai_price = self._extract_price_after(text, "Shanghai Spot") + if shanghai_price and shanghai_price > 10: + logger.info(f"Shanghai Silver: ${shanghai_price}") + return shanghai_price + + logger.warning("Could not extract valid Shanghai price") + return None + + except Exception as e: + logger.error(f"Failed to fetch Shanghai Silver: {e}") + return None + + def _extract_price_after(self, html: str, prefix: str) -> Optional[float]: + """Extract dollar amount after a prefix.""" + pos = html.find(prefix) + if pos == -1: + return None + + after = html[pos:pos+200] + + # Find $ followed by number + match = re.search(r'\$([0-9,]+\.?[0-9]*)', after) + if match: + price_str = match.group(1).replace(",", "") + try: + return float(price_str) + except ValueError: + return None + return None + + async def get_yahoo_price(self, ticker: str) -> Optional[float]: + """Fetch price from Yahoo Finance API.""" + try: + session = await self._get_session() + url = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker}?interval=1d&range=1d" + headers = { + "User-Agent": "Mozilla/5.0 (compatible; RustyMcPriceface/1.0)", + } + async with session.get(url, headers=headers) as resp: + if resp.status != 200: + logger.warning(f"Yahoo returned {resp.status} for {ticker}") + return None + + data = await resp.json() + + # Extract price from Yahoo Finance JSON structure + result = data.get("chart", {}).get("result", []) + if not result: + logger.warning(f"No result from Yahoo for {ticker}") + return None + + meta = result[0].get("meta", {}) + price = meta.get("regularMarketPrice") + + if price: + logger.info(f"Yahoo {ticker}: {price}") + return float(price) + + logger.warning(f"No price in Yahoo response for {ticker}") + return None + + except Exception as e: + logger.error(f"Failed to fetch {ticker} from Yahoo: {e}") + return None + + async def get_price(self, crypto: str) -> Optional[float]: + """Get price for a single cryptocurrency.""" + crypto = crypto.upper() + + # Special handling for Shanghai Silver (not in Pyth feeds) + if crypto == "SSILVER": + return await self.get_shanghai_silver_price() + + # Special handling for DXY (Yahoo Finance) + if crypto == "DXY": + return await self.get_yahoo_price("DX-Y.NYB") + + if crypto not in self.feeds: + logger.warning(f"No feed ID for {crypto}") + return None + + feed_id = self.feeds[crypto] + url = f"{HERMES_API_URL}?ids[]={feed_id}" + + try: + session = await self._get_session() + async with session.get(url) as resp: + if resp.status != 200: + logger.warning(f"Pyth API returned {resp.status} for {crypto}") + return None + + data = await resp.json() + if not data or not isinstance(data, list): + return None + + price_data = data[0].get("price", {}) + price_str = price_data.get("price") + expo = price_data.get("expo", 0) + + if price_str is None: + return None + + price = int(price_str) * (10 ** expo) + + if price <= 0: + logger.warning(f"Invalid price {price} for {crypto}") + return None + + logger.debug(f"Fetched {crypto} price: ${price}") + return float(price) + + except Exception as e: + logger.error(f"Failed to fetch {crypto} price: {e}") + return None + + async def get_all_prices(self) -> dict: + """Get prices for all configured cryptocurrencies.""" + results = {} + for crypto in self.feeds: + price = await self.get_price(crypto) + if price: + results[crypto] = price + return results + + async def close(self): + """Close the HTTP session.""" + if self.session and not self.session.closed: + await self.session.close() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..356a577 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +line-length = 100 +target-version = "py312" + +[lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "T20"] +ignore = ["E501", "B028"] + +[lint.per-file-ignores] +"__init__.py" = ["F401"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7e4f69f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +discord.py>=2.4.0 +asyncpg>=0.29.0 +aiohttp>=3.9.0 +brotli>=1.1.0 +python-dotenv>=1.0.0 +matplotlib>=3.8.0 +numpy>=1.26.0 diff --git a/rustfmt.toml b/rustfmt.toml deleted file mode 100644 index 7dc359a..0000000 --- a/rustfmt.toml +++ /dev/null @@ -1,3 +0,0 @@ -max_width = 100 -tab_spaces = 4 -edition = "2021" diff --git a/src/bot.rs b/src/bot.rs deleted file mode 100644 index 5b1ccf5..0000000 --- a/src/bot.rs +++ /dev/null @@ -1,1454 +0,0 @@ -use crate::config::BotConfig; -use crate::database::PriceDatabase; -use crate::discord_api::DiscordApi; -use crate::errors::{BotError, BotResult}; -use crate::health::{HealthAggregator, HealthState}; - -use crate::charting::generate_price_chart; -use crate::price_service::PricesFile; -use crate::utils::{format_price, get_current_timestamp, validate_crypto_name, validate_price}; -use serenity::{ - all::{ - ActivityData, Command, CommandDataOptionValue, CommandOptionType, CreateAttachment, - CreateCommand, CreateCommandOption, GatewayIntents, - }, - async_trait, - builder::{CreateInteractionResponse, CreateInteractionResponseMessage}, - http::Http, - model::{application::CommandInteraction, channel::Message, gateway::Ready}, - prelude::*, - Client, -}; -use std::collections::HashMap; -use std::fs; -use std::sync::Arc; -use std::time::Duration; -use tokio::time::sleep; -use tracing::{debug, error, info, warn}; - -const MAX_CONSECUTIVE_FAILURES: u32 = 5; -const RECONNECT_DELAY_SECONDS: u64 = 30; - -fn format_uptime(seconds: u64) -> String { - let days = seconds / 86400; - let hours = (seconds % 86400) / 3600; - let mins = (seconds % 3600) / 60; - if days > 0 { - format!("{}d{}h", days, hours) - } else if hours > 0 { - format!("{}h{}m", hours, mins) - } else { - format!("{}m", mins) - } -} - -/// Discord bot for tracking cryptocurrency prices -#[derive(Debug, Clone)] -pub struct Bot { - config: BotConfig, - health: Arc, - health_aggregator: Arc, - database: Arc, -} - -impl Bot { - /// Create a new bot instance with configuration, shared database, and health state - pub fn new( - config: BotConfig, - database: Arc, - health: Arc, - health_aggregator: Arc, - ) -> BotResult { - Ok(Self { - config, - health, - health_aggregator, - database, - }) - } - - /// Register slash commands with Discord - async fn register_commands(&self, http: &Http) -> BotResult<()> { - info!("Registering slash commands..."); - - let current_crypto = &self.config.crypto_name; - let price_command = CreateCommand::new("price") - .description(format!( - "Get current price for a cryptocurrency (defaults to {})", - current_crypto - )) - .add_option( - CreateCommandOption::new( - CommandOptionType::String, - "crypto", - format!("Cryptocurrency symbol (defaults to {})", current_crypto), - ) - .required(false), - ); - - let chart_command = CreateCommand::new("silverchart") - .description("Get a 1-year historical chart for the current crypto"); - - let status_command = - CreateCommand::new("status").description("Get bot system status (BTC bot only)"); - - info!("Creating global command..."); - - Command::create_global_command(http, price_command) - .await - .map_err(|e| BotError::Discord(format!("Failed to register /price command: {}", e)))?; - - Command::create_global_command(http, chart_command) - .await - .map_err(|e| { - BotError::Discord(format!("Failed to register /silverchart command: {}", e)) - })?; - - Command::create_global_command(http, status_command) - .await - .map_err(|e| BotError::Discord(format!("Failed to register /status command: {}", e)))?; - - info!("Successfully registered /price command globally"); - info!("Note: Global commands can take up to 1 hour to appear in Discord"); - - Ok(()) - } - - /// Handle the /price slash command - async fn handle_price_command(&self, interaction: &CommandInteraction) -> BotResult { - // Get crypto name from command option, or default to current bot's crypto - let crypto_name = if let Some(crypto_option) = interaction - .data - .options - .iter() - .find(|opt| opt.name == "crypto") - { - match &crypto_option.value { - CommandDataOptionValue::String(s) => { - let name = s.clone(); - validate_crypto_name(&name)?; - name - } - _ => return Err(BotError::InvalidInput("Invalid crypto option".into())), - } - } else { - // No crypto specified, use the current bot's crypto - self.config.crypto_name.clone() - }; - - debug!("Price command called for: {}", crypto_name); - - // Get current price from database - let current_price = self.database.get_latest_price(&crypto_name)?; - validate_price(current_price)?; - - // Get all prices from database for conversions - let all_prices = self.database.get_all_latest_prices()?; - - // Build response using helper - let response = self.build_price_response(&crypto_name, current_price, &all_prices)?; - - Ok(response) - } - - async fn handle_chart_command( - &self, - interaction: &CommandInteraction, - ctx: &Context, - ) -> BotResult<()> { - // Defer response as charting might take a moment - interaction - .defer(&ctx.http) - .await - .map_err(|e| BotError::Discord(format!("Failed to defer interaction: {}", e)))?; - - // silverchart command always uses SILVER - let crypto_name = "SILVER"; - - let history = self - .database - .get_price_history(crypto_name, 30) - .map_err(|e| BotError::Discord(format!("Failed to fetch history: {}", e)))?; - - if history.is_empty() { - interaction - .edit_response( - &ctx.http, - serenity::builder::EditInteractionResponse::new().content( - "❌ No historical data available yet (waiting for data to be collected)", - ), - ) - .await - .map_err(|e| BotError::Discord(format!("Failed to send empty response: {}", e)))?; - return Ok(()); - } - - let image_data = generate_price_chart(&history, crypto_name) - .map_err(|e| BotError::Discord(format!("Failed to generate chart: {}", e)))?; - let attachment = CreateAttachment::bytes(image_data, "chart.png"); - interaction - .edit_response( - &ctx.http, - serenity::builder::EditInteractionResponse::new() - .content(format!("πŸ“Š 30-Day Chart for {}", crypto_name)) - .new_attachment(attachment), - ) - .await - .map_err(|e| BotError::Discord(format!("Failed to send chart response: {}", e)))?; - - Ok(()) - } - - async fn send_chart_to_channel( - &self, - ctx: &Context, - channel_id: &serenity::model::id::ChannelId, - crypto_name: &str, - title: &str, - ) -> BotResult<()> { - match self.database.get_price_history(crypto_name, 30) { - Ok(history) => { - if history.is_empty() { - if let Err(e) = channel_id.say(&ctx.http, "❌ No historical data available yet (waiting for data to be collected)").await { - warn!("Failed to send empty history message: {}", e); - } - return Ok(()); - } - match generate_price_chart(&history, crypto_name) { - Ok(image_data) => { - let attachment = CreateAttachment::bytes(image_data, "chart.png"); - let _ = channel_id - .send_message( - &ctx.http, - serenity::builder::CreateMessage::new() - .content(title) - .add_file(attachment), - ) - .await; - self.health.update_discord_timestamp(); - } - Err(e) => { - error!("Failed to generate chart: {}", e); - let _ = channel_id - .say(&ctx.http, format!("❌ Failed to generate chart: {}", e)) - .await; - } - } - } - Err(e) => { - error!("Failed to fetch history: {}", e); - let _ = channel_id - .say(&ctx.http, format!("❌ Failed to fetch history: {}", e)) - .await; - } - } - Ok(()) - } - - /// Build a price response string with conversions and additional info - /// This is the core logic shared between slash commands and message commands - fn build_price_response( - &self, - crypto_name: &str, - current_price: f64, - all_prices: &HashMap, - ) -> BotResult { - let formatted_price = format_price(current_price); - - info!("{} price: ${}", crypto_name, current_price); - - // Calculate price changes over different time periods using database - let change_info = self - .database - .get_price_changes(crypto_name, current_price) - .unwrap_or_else(|e| { - error!("Failed to get price changes for {}: {}", crypto_name, e); - " πŸ”„ Building history".to_string() - }); - - // Build the main response - let mut response = format!("{}: {} {}", crypto_name, formatted_price, change_info); - - // Add prices in terms of BTC, ETH, and SOL (excluding the crypto's own price) - let mut conversion_prices = Vec::new(); - - if crypto_name != "BTC" { - if let Some(btc_price) = all_prices.get("BTC") { - if *btc_price > 0.0 { - let btc_conversion = current_price / btc_price; - conversion_prices.push(format!("{:.8} BTC", btc_conversion)); - } - } - } - - if crypto_name != "ETH" { - if let Some(eth_price) = all_prices.get("ETH") { - if *eth_price > 0.0 { - let eth_conversion = current_price / eth_price; - conversion_prices.push(format!("{:.6} ETH", eth_conversion)); - } - } - } - - if crypto_name != "SOL" { - if let Some(sol_price) = all_prices.get("SOL") { - if *sol_price > 0.0 { - let sol_conversion = current_price / sol_price; - conversion_prices.push(format!("{:.4} SOL", sol_conversion)); - } - } - } - - // Add Gold/Silver ratio if this is Silver - if crypto_name == "SILVER" || crypto_name == "XAG" { - let gold_price = all_prices - .get("GOLD") - .or_else(|| all_prices.get("XAU")) - .or_else(|| all_prices.get("PAXG")); - - if let Some(gold) = gold_price { - if current_price > 0.0 { - let ratio = gold / current_price; - conversion_prices.push(format!("Ratio: {:.2} (Au/Ag)", ratio)); - } - } - } - - // Add Shanghai Premium info for SHANGHAISILVER only - // Note: SHANGHAI premium data is not available - if crypto_name == "SHANGHAISILVER" { - if let Some(silver_price) = all_prices.get("SILVER") { - if *silver_price > 0.0 { - let prem = current_price - silver_price; - let prem_pct = (prem / silver_price) * 100.0; - response.push_str(&format!( - "\nπŸ‡¨πŸ‡³ Shanghai Premium: ${:.2} (+{:.2}%)", - prem, prem_pct - )); - } - } - } - - // Add conversion prices to response if available - if !conversion_prices.is_empty() { - response.push_str(&format!("\nπŸ’± Also: {}", conversion_prices.join(" | "))); - } - - Ok(response) - } - - /// Handle price command for message-based commands (like !btc, !sol) - async fn handle_price_command_for_message( - &self, - channel_id: &serenity::model::id::ChannelId, - ctx: &Context, - ) -> BotResult<()> { - let crypto_name = self.config.crypto_name.clone(); - - debug!("Message price command called for: {}", crypto_name); - - // Get current price from database - let current_price = self.database.get_latest_price(&crypto_name)?; - validate_price(current_price)?; - - // Get all prices from database for conversions - let all_prices = self.database.get_all_latest_prices()?; - - // Build response using helper - let response = self.build_price_response(&crypto_name, current_price, &all_prices)?; - - // Send the response to the channel - channel_id - .say(&ctx.http, response) - .await - .map_err(|e| BotError::Discord(format!("Failed to send message: {}", e)))?; - - Ok(()) - } -} - -/// Helper to start a bot instance (used by main.rs) -pub async fn start_bot( - config: BotConfig, - database: Arc, - health: Arc, - health_aggregator: Arc, -) -> BotResult<()> { - let token = config.discord_token.clone(); - let intents = - GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT; - - let bot = Bot::new(config, database, health, health_aggregator)?; - - let mut client = Client::builder(&token, intents) - .event_handler(bot) - .await - .map_err(|e| BotError::Discord(format!("Error creating client: {}", e)))?; - - if let Err(why) = client.start().await { - return Err(BotError::Discord(format!("Client error: {}", why))); - } - - Ok(()) -} - -#[async_trait] -impl EventHandler for Bot { - async fn ready(&self, ctx: Context, ready: Ready) { - info!("Bot is ready! Logged in as: {}", ready.user.name); - info!("Bot ID: {}", ready.user.id); - info!("Connected to {} guilds", ready.guilds.len()); - - // Update Discord timestamp to indicate successful connection - self.health.update_discord_timestamp(); - - info!("Starting command registration..."); - - // Register slash commands with retry logic - for attempt in 1..=3 { - match self.register_commands(&ctx.http).await { - Ok(_) => { - info!("Command registration completed successfully"); - break; - } - Err(e) => { - error!("Command registration failed (attempt {}): {}", attempt, e); - if attempt < 3 { - sleep(Duration::from_secs(5)).await; - } else { - error!("Failed to register commands after 3 attempts"); - return; - } - } - } - } - - info!("Starting price update loop..."); - - let http = ctx.http.clone(); - let ctx_arc = Arc::new(ctx); - - // Run the price update loop in a separate task so ready() returns - // Cloning Bot is expensive if it has deep state, but here it's Config + Health (Arc-like internals) + Arc - // Use a wrapper or simply spawn the loop with cloned components - - let config = self.config.clone(); - let health = self.health.clone(); - let database = self.database.clone(); - - tokio::spawn(async move { - price_update_loop(http, ctx_arc, config, health, database).await; - }); - - info!("Bot initialization complete!"); - } - - async fn resume(&self, _ctx: Context, _resumed: serenity::model::event::ResumedEvent) { - info!("Bot resumed connection to Discord gateway"); - self.health.update_discord_timestamp(); - self.health.reset_gateway_failures(); - } - - async fn interaction_create( - &self, - ctx: Context, - interaction: serenity::model::application::Interaction, - ) { - debug!("Interaction received: {:?}", interaction.kind()); - - if let serenity::model::application::Interaction::Command(command_interaction) = interaction - { - debug!("Command interaction: {}", command_interaction.data.name); - - let response = match command_interaction.data.name.as_str() { - "price" => { - debug!("Handling /price command"); - match self.handle_price_command(&command_interaction).await { - Ok(message) => { - debug!("Price command successful, responding with: {}", message); - let data = CreateInteractionResponseMessage::new().content(message); - let builder = CreateInteractionResponse::Message(data); - command_interaction - .create_response(&ctx.http, builder) - .await - } - Err(e) => { - error!("Price command failed: {}", e); - let data = CreateInteractionResponseMessage::new() - .content(format!("❌ Error: {}", e.user_message())); - let builder = CreateInteractionResponse::Message(data); - command_interaction - .create_response(&ctx.http, builder) - .await - } - } - } - "silverchart" => { - debug!("Handling /silverchart command"); - if let Err(e) = self.handle_chart_command(&command_interaction, &ctx).await { - error!("Chart command failed: {}", e); - let _ = command_interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new() - .content(format!("❌ Error: {}", e.user_message())), - ), - ) - .await; - } - Ok(()) // Response handled inside function - } - "status" => { - debug!("Handling /status command"); - - // Check if user has admin permissions - let has_permission = command_interaction - .member - .as_ref() - .map(|m| m.permissions.map(|p| p.administrator()).unwrap_or(false)) - .unwrap_or(false); - - if !has_permission { - let data = CreateInteractionResponseMessage::new() - .content("❌ This command is restricted to server administrators."); - let builder = CreateInteractionResponse::Message(data); - command_interaction - .create_response(&ctx.http, builder) - .await - } else if self.config.crypto_name == "BTC" { - let status = self.health_aggregator.to_json(); - let total_bots = status - .get("total_bots") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let healthy_bots = status - .get("healthy_bots") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let mut lines = vec![ - "```".to_string(), - format!("{:14} {:>8}", "Bot", "Status"), - "----------------------------".to_string(), - ]; - - if let Some(bots) = status.get("bots").and_then(|v| v.as_array()) { - for bot in bots { - let name = - bot.get("bot_name").and_then(|v| v.as_str()).unwrap_or("?"); - let healthy = bot - .get("healthy") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let status_str = if healthy { "OK" } else { "DEGRADED" }; - - lines.push(format!("{:14} {:>8}", name, status_str)); - } - } - - lines.push("----------------------------".to_string()); - lines.push(format!("Total: {} | Healthy: {}", total_bots, healthy_bots)); - lines.push("```".to_string()); - - let message = lines.join("\n"); - command_interaction - .create_response( - &ctx.http, - CreateInteractionResponse::Message( - CreateInteractionResponseMessage::new().content(message), - ), - ) - .await - } else { - Ok(()) as Result<(), serenity::Error> - } - } - _ => { - warn!("Unknown command: {}", command_interaction.data.name); - let data = - CreateInteractionResponseMessage::new().content("❌ Unknown command"); - let builder = CreateInteractionResponse::Message(data); - command_interaction - .create_response(&ctx.http, builder) - .await - } - }; - - match response { - Ok(_) => { - // Update Discord timestamp on successful interaction response - self.health.update_discord_timestamp(); - } - Err(e) => { - error!("Failed to respond to interaction: {}", e); - } - } - } - } - - async fn message(&self, ctx: Context, msg: Message) { - info!( - "πŸ”” MESSAGE RECEIVED: '{}' from {} in channel {}", - msg.content, msg.author.name, msg.channel_id - ); - debug!( - "Received message: '{}' from {}", - msg.content, msg.author.name - ); - - // Ignore messages from bots - if msg.author.bot { - debug!("Ignoring message from bot: {}", msg.author.name); - return; - } - - // Check if message starts with ! followed by this bot's crypto name OR if bot is mentioned - let command = format!("!{}", self.config.crypto_name.to_lowercase()); - let content_lower = msg.content.to_lowercase(); - - // Handle !shanghai as alias for SHANGHAISILVER - let is_shanghai_alias = - self.config.crypto_name == "SHANGHAISILVER" && content_lower == "!shanghai"; - - let is_command = content_lower == command - || content_lower.starts_with(&format!("{} ", command)) - || is_shanghai_alias; - - // SILVER and SHANGHAISILVER bots respond to their respective chart commands - let is_chart = self.config.crypto_name == "SILVER" && content_lower == "!silverchart"; - - let is_shanghai_chart = - self.config.crypto_name == "SHANGHAISILVER" && content_lower == "!shanghaichart"; - - // !status command - only BTC bot responds - let is_status = self.config.crypto_name == "BTC" && content_lower == "!status"; - - // Generic chart command: !chart (e.g., !solchart) - let generic_chart_cmd = format!("!{}chart", self.config.crypto_name.to_lowercase()); - let is_generic_chart = msg.content.to_lowercase() == generic_chart_cmd; - - let is_mentioned = msg.mentions_me(&ctx).await.unwrap_or(false); - - debug!( - "Looking for command: '{}' in message: '{}', is_command: {}, is_mentioned: {}", - command, msg.content, is_command, is_mentioned - ); - - if is_command || is_mentioned { - debug!("Received {} command from {}", command, msg.author.name); - - // Get the same price data as the slash command - match self - .handle_price_command_for_message(&msg.channel_id, &ctx) - .await - { - Ok(_) => { - debug!("Successfully responded to {} command", command); - self.health.update_discord_timestamp(); - } - Err(e) => { - error!("Failed to handle {} command: {}", command, e); - // Try to send an error message - if let Err(send_err) = msg - .channel_id - .say(&ctx.http, format!("❌ Error: {}", e.user_message())) - .await - { - error!("Failed to send error message: {}", send_err); - } - } - } - } else if is_chart { - debug!("Received chart command from {}", msg.author.name); - - // silverchart always uses SILVER (database history) - let _ = self - .send_chart_to_channel( - &ctx, - &msg.channel_id, - "SILVER", - "πŸ“Š 30-Day Chart for SILVER", - ) - .await; - } else if is_shanghai_chart { - debug!("Received !shanghaichart command from {}", msg.author.name); - - // SHANGHAISILVER uses database history - let _ = self - .send_chart_to_channel( - &ctx, - &msg.channel_id, - "SHANGHAISILVER", - "πŸ“Š 30-Day Chart for Shanghai Silver", - ) - .await; - } else if is_generic_chart { - debug!("Received generic chart command from {}", msg.author.name); - let crypto_name = &self.config.crypto_name; - let title = format!("πŸ“Š 30-Day History for {}", crypto_name); - let _ = self - .send_chart_to_channel(&ctx, &msg.channel_id, crypto_name, &title) - .await; - } else if is_status { - debug!("Received !status command from {}", msg.author.name); - let status = self.health_aggregator.to_json(); - let total_bots = status - .get("total_bots") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let healthy_bots = status - .get("healthy_bots") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - - let mut lines = vec![ - "```".to_string(), - format!("{:14} {:>8}", "Bot", "Status"), - "----------------------------".to_string(), - ]; - - if let Some(bots) = status.get("bots").and_then(|v| v.as_array()) { - for bot in bots { - let name = bot.get("bot_name").and_then(|v| v.as_str()).unwrap_or("?"); - let healthy = bot - .get("healthy") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let status_str = if healthy { "OK" } else { "DEGRADED" }; - - lines.push(format!("{:14} {:>8}", name, status_str)); - } - } - - lines.push("----------------------------".to_string()); - lines.push(format!("Total: {} | Healthy: {}", total_bots, healthy_bots)); - lines.push("```".to_string()); - - let message = lines.join("\n"); - if let Err(e) = msg.channel_id.say(&ctx.http, message).await { - warn!("Failed to send health status message: {}", e); - } - self.health.update_discord_timestamp(); - } else { - debug!( - "Message '{}' does not match command '{}'", - msg.content, command - ); - } - } -} - -/// Read prices from the shared JSON file with retry logic -async fn read_prices_from_file() -> BotResult { - let file_path = "shared/prices.json"; - const MAX_RETRIES: u32 = 3; - - for attempt in 1..=MAX_RETRIES { - // Check if file exists - if !std::path::Path::new(file_path).exists() { - if attempt < MAX_RETRIES { - warn!("Prices file not found (attempt {}), retrying...", attempt); - sleep(Duration::from_millis(1000 * attempt as u64)).await; - continue; - } - return Err(BotError::Io(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Prices file not found. Make sure price-service is running.", - ))); - } - - match fs::read_to_string(file_path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(prices) => return Ok(prices), - Err(e) => { - error!("Failed to parse prices file (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - sleep(Duration::from_millis(1000 * attempt as u64)).await; - continue; - } - return Err(BotError::Json(e)); - } - }, - Err(e) => { - error!("Failed to read prices file (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - sleep(Duration::from_millis(1000 * attempt as u64)).await; - continue; - } - return Err(BotError::Io(e)); - } - } - } - - Err(BotError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "Unexpected error in prices file read retry loop", - ))) -} - -/// Main price update loop with comprehensive error handling -async fn price_update_loop( - http: Arc, - ctx: Arc, - config: BotConfig, - health: Arc, - database: Arc, -) { - let crypto_name = &config.crypto_name; - let mut consecutive_failures = 0; - let discord_api = DiscordApi::new(http); - - info!("Starting price update loop for {}", crypto_name); - - loop { - let loop_start = std::time::Instant::now(); - - // Wrap the entire update logic in error handling - let update_result = async { - // Get current price with error handling - let current_price = match get_crypto_price(&config, &database).await { - Ok(price) => { - consecutive_failures = 0; // Reset failure counter on success - health.reset_failures(); - health.update_price_timestamp(); - price - } - Err(e) => { - consecutive_failures += 1; - health.increment_failures(); - error!( - "Failed to get {} price (failure {}/{}): {}", - crypto_name, consecutive_failures, MAX_CONSECUTIVE_FAILURES, e - ); - - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - error!( - "Too many consecutive failures for {}. Entering recovery mode.", - crypto_name - ); - sleep(Duration::from_secs(RECONNECT_DELAY_SECONDS)).await; - consecutive_failures = 0; // Reset after recovery delay - health.reset_failures(); - } - return Err(e); - } - }; - - // Get price change indicator with error handling - let (arrow, change_percent) = database.get_price_indicator(crypto_name, current_price); - - // Format the nickname - let nickname = if crypto_name == "SHANGHAI" || crypto_name == "SHANGHAISILVER" { - format!("SILVER {}", format_price(current_price)) - } else { - format!("{} {}", crypto_name, format_price(current_price)) - }; - - // Format the custom status with rotation - let update_interval_secs = config.update_interval.as_secs().max(1); - let update_count = match get_current_timestamp() { - Ok(time) => (time / update_interval_secs) % 4, - Err(_) => 0, - }; - - let custom_status = match read_prices_from_file().await { - Ok(shared_prices) => { - format_custom_status( - crypto_name, - current_price, - &shared_prices, - update_count, - &arrow, - change_percent, - ) - } - Err(e) => { - warn!("Failed to read shared prices for status: {}", e); - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!("{} {}{:.2}% (1h)", arrow, change_sign, change_percent) - } - } - }; - - debug!("Updating nickname to: {}", nickname); - debug!("Updating custom status to: {}", custom_status); - - // Update custom status (activity) - this doesn't return a Result but we can still track attempts - ctx.set_activity(Some(ActivityData::playing(custom_status.clone()))); - debug!("Updated activity status"); - - // Note: set_activity doesn't return errors, so we can't directly detect failures here - // The periodic Discord test will catch connectivity issues - - // Save current price to database with error handling - if let Err(e) = database.save_price(crypto_name, current_price) { - error!("Failed to save price to database: {}", e); - } else { - health.update_db_timestamp(); - } - - // Update nickname in guilds with rate limiting and error handling - let guilds = ctx.cache.guilds(); - let guild_count = guilds.len(); - - if guild_count > 0 { - info!("Updating nickname in {} guilds", guild_count); - - let results = discord_api - .update_nicknames_in_guilds(&guilds, &nickname) - .await; - - // Count successful updates and track failures more aggressively - let successful_updates = results.iter().filter(|r| r.is_ok()).count(); - let failed_updates = results.iter().filter(|r| r.is_err()).count(); - - if successful_updates > 0 { - health.update_discord_timestamp(); - // Only reset gateway failures if most updates succeeded - if successful_updates > failed_updates { - health.reset_gateway_failures(); - } - } else { - // All updates failed - increment gateway failures - health.increment_gateway_failures(); - warn!("All {} Discord nickname updates failed", guild_count); - - // If no Discord updates succeeded, check if we should exit for restart - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let last_discord = health.last_discord_update.load(std::sync::atomic::Ordering::Relaxed); - - // If Discord communication has been failing for more than 2 minutes, exit for restart - if now.saturating_sub(last_discord) > 120 { - error!("Discord communication has been failing for over 2 minutes. Exiting for restart."); - return Err(BotError::Discord("Gateway connection lost - restarting".into())); - } - } - - // Track partial failures - if failed_updates > 0 { - warn!("Some Discord updates failed: {}/{} failed", failed_updates, guild_count); - } - - debug!( - "Updated nicknames: {}/{} successful", - successful_updates, guild_count - ); - } else { - warn!("No guilds found in cache - Discord connection may be lost!"); - health.increment_gateway_failures(); - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let last_discord = health.last_discord_update.load(std::sync::atomic::Ordering::Relaxed); - - if now.saturating_sub(last_discord) > 120 { - error!("No guilds for over 2 minutes. Exiting for restart."); - return Err(BotError::Discord("Gateway connection lost - no guilds detected".into())); - } - } - - Ok(()) - } - .await; - - // Handle update result - match update_result { - Ok(_) => { - debug!("Price update completed successfully for {}", crypto_name); - } - Err(e) => { - error!("Price update failed for {}: {}", crypto_name, e); - } - } - - // Periodic cleanup of old prices - database.maybe_cleanup(); - - // Periodic Discord connectivity test (every 10 update cycles) - let update_count = match get_current_timestamp() { - Ok(time) => time / config.update_interval.as_secs(), - Err(_) => 0, - }; - - if update_count % 10 == 0 { - debug!( - "Running periodic Discord connectivity test for {}", - crypto_name - ); - let health_clone = health.clone(); - tokio::spawn(async move { - test_discord_connectivity(health_clone).await; - }); - } - - // Calculate how long the update took and adjust sleep time - let loop_duration = loop_start.elapsed(); - let target_interval = config.update_interval; - - if loop_duration < target_interval { - let sleep_time = target_interval - loop_duration; - debug!( - "Update took {:?}, sleeping for {:?}", - loop_duration, sleep_time - ); - sleep(sleep_time).await; - } else { - warn!( - "Update took longer than interval: {:?} > {:?}", - loop_duration, target_interval - ); - // Still sleep for a minimum time to prevent tight loops - sleep(Duration::from_secs(1)).await; - } - } -} - -/// Format custom status based on crypto type and rotation -fn format_custom_status( - crypto_name: &str, - current_price: f64, - shared_prices: &PricesFile, - update_count: u64, - arrow: &str, - change_percent: f64, -) -> String { - // Check if current crypto's price is from fallback - let is_stale = shared_prices - .prices - .get(crypto_name) - .map(|p| p.is_fallback) - .unwrap_or(false); - let stale_indicator = if is_stale { " (stale)" } else { "" }; - - // Calculate ticker price in terms of BTC, ETH, SOL - // Use fallback values only if key doesn't exist; guard against zero prices - let btc_price = shared_prices - .prices - .get("BTC") - .map(|p| p.price) - .unwrap_or(45000.0); - let eth_price = shared_prices - .prices - .get("ETH") - .map(|p| p.price) - .unwrap_or(2800.0); - let sol_price = shared_prices - .prices - .get("SOL") - .map(|p| p.price) - .unwrap_or(95.0); - - let btc_amount = if btc_price > 0.0 { - current_price / btc_price - } else { - 0.0 - }; - let eth_amount = if eth_price > 0.0 { - current_price / eth_price - } else { - 0.0 - }; - let sol_amount = if sol_price > 0.0 { - current_price / sol_price - } else { - 0.0 - }; - - match crypto_name { - "BTC" => { - // For BTC bot, show ETH and SOL amounts, skip BTC/BTC - match update_count { - 0 => { - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!( - "{} {}{:.2}% (1h){}", - arrow, change_sign, change_percent, stale_indicator - ) - } - } - 1 => format!("{:.8} Ξ", eth_amount), - 2 => format!("{:.8} β—Ž", sol_amount), - 3 => format!("${:.2}", current_price), - _ => unreachable!(), - } - } - "ETH" => { - // For ETH bot, show BTC and SOL amounts, skip ETH/ETH - match update_count { - 0 => { - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!( - "{} {}{:.2}% (1h){}", - arrow, change_sign, change_percent, stale_indicator - ) - } - } - 1 => format!("{:.8} β‚Ώ", btc_amount), - 2 => format!("{:.8} β—Ž", sol_amount), - 3 => format!("{:.8} β‚Ώ", btc_amount), - _ => unreachable!(), - } - } - "SOL" => { - // For SOL bot, show BTC and ETH amounts, skip SOL/SOL - match update_count { - 0 => { - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!( - "{} {}{:.2}% (1h){}", - arrow, change_sign, change_percent, stale_indicator - ) - } - } - 1 => format!("{:.8} β‚Ώ", btc_amount), - 2 => format!("{:.8} Ξ", eth_amount), - 3 => format!("{:.8} β‚Ώ", btc_amount), - _ => unreachable!(), - } - } - "SILVER" | "XAG" => { - // For Silver bot, show Gold/Silver ratio - let gold_price = shared_prices - .prices - .get("GOLD") - .or_else(|| shared_prices.prices.get("XAU")) - .or_else(|| shared_prices.prices.get("PAXG")) - .map(|p| p.price); - - let ratio_str = if let Some(gold) = gold_price { - if current_price > 0.0 { - format!("Au/Ag: {:.2}", gold / current_price) - } else { - format!("{:.8} β‚Ώ", btc_amount) - } - } else { - format!("{:.8} β‚Ώ", btc_amount) // Fallback - }; - - match update_count { - 0 => { - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!( - "{} {}{:.2}% (1h){}", - arrow, change_sign, change_percent, stale_indicator - ) - } - } - 1 => ratio_str, - 2 => format!("{:.8} β‚Ώ", btc_amount), - 3 => format!("{:.8} Ξ", eth_amount), - _ => unreachable!(), - } - } - "SHANGHAI" => { - // For Shanghai bot, scroll through Premium and Premium Percent - match update_count { - 0 | 3 => { - // Show arrow/building history on 0 and 3 (half the time, or custom cycle) - // User asked for "always update price... and then cycle 2 and 3 would be underneath" - // Actually user said: "watching area scroll through the price delta 'premium'... and percentage delta" - // The default status (update_count 0) usually shows price change. - // Let's make it: - // 0: Price Change (standard) - // 1: Premium $ - // 2: Premium % - // 3: Source or back to standard - - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!( - "{} {}{:.2}% (1h){}", - arrow, change_sign, change_percent, stale_indicator - ) - } - } - - 1 => { - let premium = shared_prices - .prices - .get("SHANGHAI") - .and_then(|p| p.premium) - .unwrap_or(0.0); - format!("Prem: ${:.2}", premium) - } - - 2 => { - let premium_pct = shared_prices - .prices - .get("SHANGHAI") - .and_then(|p| p.premium_percent) - .unwrap_or(0.0); - format!("Prem: {:.2}%", premium_pct) - } - _ => unreachable!(), - } - } - "SHANGHAISILVER" => { - match update_count { - 0 | 3 => { - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!( - "{} {}{:.2}% (1h){}", - arrow, change_sign, change_percent, stale_indicator - ) - } - } - - 1 => { - // Calculate premium: SHANGHAISILVER - SILVER - let silver_price = shared_prices - .prices - .get("SILVER") - .map(|p| p.price) - .unwrap_or(0.0); - let premium = if silver_price > 0.0 { - current_price - silver_price - } else { - 0.0 - }; - format!("Prem: ${:.2}", premium) - } - - 2 => { - // Calculate premium percent - let silver_price = shared_prices - .prices - .get("SILVER") - .map(|p| p.price) - .unwrap_or(0.0); - let premium_pct = if silver_price > 0.0 { - ((current_price - silver_price) / silver_price) * 100.0 - } else { - 0.0 - }; - format!("Prem: {:.2}%", premium_pct) - } - _ => unreachable!(), - } - } - _ => { - // For other tickers, show all three conversions - match update_count { - 0 => { - if change_percent == 0.0 && arrow == "πŸ”„" { - format!("{} Building history", arrow) - } else { - let change_sign = if change_percent >= 0.0 { "+" } else { "" }; - format!( - "{} {}{:.2}% (1h){}", - arrow, change_sign, change_percent, stale_indicator - ) - } - } - 1 => format!("{:.8} β‚Ώ", btc_amount), - 2 => format!("{:.8} Ξ", eth_amount), - 3 => format!("{:.8} β—Ž", sol_amount), - _ => unreachable!(), - } - } - } -} - -/// Get current cryptocurrency price -async fn get_crypto_price(config: &BotConfig, database: &Arc) -> BotResult { - // For SHANGHAISILVER, read directly from database (not in prices.json) - if config.crypto_name == "SHANGHAISILVER" { - debug!("Getting SHANGHAISILVER price from database"); - match database.get_latest_price(&config.crypto_name) { - Ok(price) if price > 0.0 => { - debug!("Got SHANGHAISILVER price from database: {}", price); - validate_price(price)?; - return Ok(price); - } - Ok(price) => { - debug!( - "Got SHANGHAISILVER price but it's zero or negative: {}", - price - ); - } - Err(e) => { - debug!("Failed to get SHANGHAISILVER from database: {}", e); - } - } - } - - // First try to get from shared prices file - match read_prices_from_file().await { - Ok(prices) => { - if let Some(price_data) = prices.prices.get(&config.crypto_name) { - validate_price(price_data.price)?; - return Ok(price_data.price); - } - } - Err(_) => { - // If shared file doesn't exist or doesn't have our crypto, try direct API call - } - } - - // Fallback to direct API call if we have a feed ID - if let Some(feed_id) = &config.pyth_feed_id { - return get_individual_crypto_price(feed_id).await; - } - - Err(BotError::PriceNotFound(config.crypto_name.clone())) -} - -/// Fetch individual cryptocurrency price from Pyth Network with retry logic -async fn get_individual_crypto_price(feed_id: &str) -> BotResult { - let url = format!( - "https://hermes.pyth.network/v2/updates/price/latest?ids%5B%5D={}", - feed_id - ); - const MAX_RETRIES: u32 = 3; - - for attempt in 1..=MAX_RETRIES { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|e| BotError::Http(e.to_string()))?; - - match client - .get(&url) - .header("User-Agent", "Crypto-Price-Bot/1.0") - .send() - .await - { - Ok(response) => { - if !response.status().is_success() { - error!( - "HTTP request failed (attempt {}): {}", - attempt, - response.status() - ); - if attempt < MAX_RETRIES { - sleep(Duration::from_millis(1000 * attempt as u64)).await; - continue; - } - return Err(BotError::Http(format!( - "HTTP request failed: {}", - response.status() - ))); - } - - match response.json::().await { - Ok(json) => { - // Parse the price from the parsed array - let parsed_data = json - .get("parsed") - .and_then(|p| p.as_array()) - .ok_or_else(|| BotError::Parse("No parsed data found".into()))?; - - let first_feed = parsed_data - .first() - .ok_or_else(|| BotError::Parse("No feed data found".into()))?; - - let price_data = first_feed - .get("price") - .ok_or_else(|| BotError::Parse("No price data found".into()))?; - - let price_str = price_data - .get("price") - .and_then(|p| p.as_str()) - .ok_or_else(|| BotError::Parse("No price string found".into()))?; - - let price = price_str - .parse::() - .map_err(|_| BotError::Parse("Invalid price format".into()))?; - - let expo = price_data.get("expo").and_then(|e| e.as_i64()).unwrap_or(0); - let real_price = price as f64 * 10f64.powi(expo as i32); - - validate_price(real_price)?; - return Ok(real_price); - } - Err(e) => { - error!("JSON parsing failed (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - sleep(Duration::from_millis(1000 * attempt as u64)).await; - continue; - } - return Err(BotError::Http(e.to_string())); - } - } - } - Err(e) => { - error!("Network request failed (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - sleep(Duration::from_millis(1000 * attempt as u64)).await; - continue; - } - return Err(BotError::Http(e.to_string())); - } - } - } - - Err(BotError::Io(std::io::Error::new( - std::io::ErrorKind::Other, - "Unexpected error in prices file read retry loop", - ))) -} - -/// Test Discord connectivity by making a simple API call -async fn test_discord_connectivity(health: Arc) { - use reqwest::Client; - - let client = match Client::builder().timeout(Duration::from_secs(10)).build() { - Ok(client) => client, - Err(e) => { - error!("Failed to create HTTP client for Discord test: {}", e); - health.increment_discord_test_failures(); - return; - } - }; - - match client - .get("https://discord.com/api/v10/gateway") - .header("User-Agent", "Discord-Bot-Health-Check/1.0") - .send() - .await - { - Ok(response) => { - if response.status().is_success() { - debug!("Discord connectivity test passed"); - health.update_discord_test_timestamp(); - health.reset_discord_test_failures(); - } else { - warn!( - "Discord connectivity test failed with status: {}", - response.status() - ); - health.increment_discord_test_failures(); - } - } - Err(e) => { - warn!("Discord connectivity test failed with error: {}", e); - health.increment_discord_test_failures(); - } - } -} diff --git a/src/charting.rs b/src/charting.rs deleted file mode 100644 index b74538f..0000000 --- a/src/charting.rs +++ /dev/null @@ -1,256 +0,0 @@ -use crate::price_service::HistoryData; -use image::ImageEncoder; -use plotters::prelude::*; -use std::error::Error; - -/// Generate a chart image buffer from history data -pub fn generate_shanghai_chart( - data: &[HistoryData], - symbol: &str, -) -> Result, Box> { - let width = 800; - let height = 400; - let mut buffer = vec![0u8; width as usize * height as usize * 3]; // RGB buffer - - { - let root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area(); - let background_color = RGBColor(30, 30, 30); // Dark gray - root.fill(&background_color)?; - - let title = format!("{} Shanghai Premium (1Y)", symbol.to_uppercase()); - - let shanghai_color = RGBColor(255, 99, 71); // Tomato Red - let western_color = RGBColor(100, 149, 237); // Cornflower Blue - let text_style = ("sans-serif", 30).into_font().color(&WHITE); - - // Find range - let min_price = data - .iter() - .map(|d| d.shanghai.min(d.western)) - .fold(f64::INFINITY, f64::min); - let max_price = data - .iter() - .map(|d| d.shanghai.max(d.western)) - .fold(f64::NEG_INFINITY, f64::max); - - // Add some padding - let range_padding = (max_price - min_price) * 0.05; - let y_min = min_price - range_padding; - let y_max = max_price + range_padding; - - let mut chart = ChartBuilder::on(&root) - .caption(title, text_style) - .margin(10) - .x_label_area_size(30) - .y_label_area_size(40) - .right_y_label_area_size(40) // Add right Y-axis - .build_cartesian_2d(0..data.len(), y_min..y_max)?; - - chart - .configure_mesh() - .x_labels(12) // Increase from 5 to 12 - .x_label_formatter(&|idx| { - if let Some(d) = data.get(*idx) { - // Simplistic date format "MM-DD" or similar - // data.date is "YYYY-MM-DD" - if d.date.len() >= 10 { - return format!("{}", &d.date[5..]); - } - return d.date.clone(); - } - "".to_string() - }) - .x_label_style(("sans-serif", 15).into_font().color(&WHITE)) - .y_label_style(("sans-serif", 15).into_font().color(&WHITE)) - .axis_style(WHITE) - .bold_line_style(WHITE.mix(0.1)) - .light_line_style(WHITE.mix(0.05)) - .draw()?; - - // Shanghai Series - chart - .draw_series(LineSeries::new( - data.iter().enumerate().map(|(i, d)| (i, d.shanghai)), - &shanghai_color, - ))? - .label("Shanghai") - .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], shanghai_color)); - - // Western Series - chart - .draw_series(LineSeries::new( - data.iter().enumerate().map(|(i, d)| (i, d.western)), - &western_color, - ))? - .label("Western") - .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], western_color)); - - chart - .configure_series_labels() - .background_style(&RGBColor(50, 50, 50)) - .border_style(&WHITE) - .label_font(("sans-serif", 15).into_font().color(&WHITE)) - .draw()?; - } - - // Encode to PNG - let mut image_data = Vec::new(); - let image = - image::RgbImage::from_raw(width, height, buffer).ok_or("Failed to create image buffer")?; - - // plotters uses RGB, we can straightforwardly encode to PNG - // Note: plotters doesn't include png encoding itself in the core, we need `image` crate or `plotters` feature - // Let's rely on `image` crate if available from plotters features or check Cargo.toml - // Wait, plotters 0.3 with "image" feature re-exports it or allows usage. - // Actually, `BitMapBackend` writes to a buffer. We can use the `image` crate to save/encode it. - // Check if we have `image` dependency separately or via plotters. - // If not, we might need to add `image` crate to Cargo.toml or use plotters `into_drawing_area` on an `image` crate type if supported. - // - // Simpler: Use `plotters::backend::BitMapBackend` which creates a raw RGB buffer? - // Wait, the buffer I created `vec![0u8; ...]` is populated. - // To encode to PNG, I need an encoder. - // Let's assume `image` crate is needed. - // I'll add `image = "0.24"` to Cargo.toml as well if not present (it's not). - - // Let's check if plotters re-exports it. - // It does not default expose generic png encoding for a raw buffer. - - // BETTER APPROACH: Use `BitMapBackend` tied to a path, OR `BitMapBackend` with a phantom and encode later. - // Actually, declaring `image` dependency is safer. I'll add that to Cargo.toml in the next step or now. - - // For now, I'll write the code assuming `image` crate usage. - - let encoder = image::codecs::png::PngEncoder::new(&mut image_data); - encoder.write_image( - image.as_raw(), - width, - height, - image::ExtendedColorType::Rgb8, - )?; - - Ok(image_data) -} - -/// Generate a line chart for generic crypto history from local connection -pub fn generate_price_chart( - data: &[(i64, f64)], - symbol: &str, -) -> Result, Box> { - let width = 800; - let height = 400; - let mut buffer = vec![0u8; width as usize * height as usize * 3]; // RGB buffer - - { - let root = BitMapBackend::with_buffer(&mut buffer, (width, height)).into_drawing_area(); - let background_color = RGBColor(30, 30, 30); // Dark gray - root.fill(&background_color)?; - - let title = format!("{} Price History (30 Days)", symbol.to_uppercase()); - let line_color = RGBColor(0, 255, 127); // Spring Green - let text_style = ("sans-serif", 30).into_font().color(&WHITE); - - if data.is_empty() { - root.draw_text("No Data Available", &text_style, (300, 200))?; - drop(root); // Finish drawing - - let mut image_data = Vec::new(); - let image = image::RgbImage::from_raw(width, height, buffer) - .ok_or("Failed to create image buffer")?; - let encoder = image::codecs::png::PngEncoder::new(&mut image_data); - encoder.write_image( - image.as_raw(), - width, - height, - image::ExtendedColorType::Rgb8, - )?; - return Ok(image_data); - } - - // Find min/max for Y axis - let min_price = data.iter().map(|(_, p)| *p).fold(f64::INFINITY, f64::min); - let max_price = data - .iter() - .map(|(_, p)| *p) - .fold(f64::NEG_INFINITY, f64::max); - - // Ensure minimum range for Y axis - let price_range = (max_price - min_price).abs(); - let (y_min, y_max) = if price_range < 0.0001 { - (min_price - 1.0, max_price + 1.0) - } else { - // Add padding - let range_padding = price_range * 0.05; - (min_price - range_padding, max_price + range_padding) - }; - - let mut chart = ChartBuilder::on(&root) - .caption(title, text_style) - .margin(10) - .x_label_area_size(30) - .y_label_area_size(50) - .right_y_label_area_size(50) - .build_cartesian_2d(0..data.len(), y_min..y_max)?; - - chart - .configure_mesh() - .x_labels(10) - .x_label_formatter(&|idx| { - if let Some((ts, _)) = data.get(*idx) { - // Convert timestamp to date string (simplified) - // We don't have chrono in scope yet, check if we do in Cargo.toml? - // assuming we might need to rely on basic formatting or add chrono - // Actually, let's use a simple approach using `chrono` if typically available or string manipulation if not. - // The project likely has `chrono` or similar. Let's assume user has `chrono` or we format simply. - // To be safe without digging into Cargo.toml right now (time is short), I'll just skip detailed date formatting - // or implement a basic one if I recall the utils has `get_current_timestamp`. - // Actually, let's just show relative days or just index if we can't format. - // WAIT, the Shanghai chart used `d.date` string. Here we have `i64` timestamp. - // I will formatting assuming chrono is available (very standard). - - // Hacky fallback if no chrono: just show simple index? No that's bad. - // I'll try to use standard library or just `format!("{}", ts)` temporarily until verified. - // BETTER: Use `chrono` properly. - use chrono::TimeZone; - match chrono::Utc.timestamp_opt(*ts, 0) { - chrono::LocalResult::Single(dt) => return dt.format("%m-%d").to_string(), - _ => return format!("{}", ts), - } - } - "".to_string() - }) - .x_label_style(("sans-serif", 15).into_font().color(&WHITE)) - .y_label_style(("sans-serif", 15).into_font().color(&WHITE)) - .axis_style(WHITE) - .bold_line_style(WHITE.mix(0.1)) - .light_line_style(WHITE.mix(0.05)) - .draw()?; - - chart - .draw_series(LineSeries::new( - data.iter().enumerate().map(|(i, &(_, price))| (i, price)), - &line_color, - ))? - .label(symbol) - .legend(move |(x, y)| PathElement::new(vec![(x, y), (x + 20, y)], line_color)); - - chart - .configure_series_labels() - .background_style(&RGBColor(50, 50, 50)) - .border_style(&WHITE) - .label_font(("sans-serif", 15).into_font().color(&WHITE)) - .draw()?; - } - - let mut image_data = Vec::new(); - let image = - image::RgbImage::from_raw(width, height, buffer).ok_or("Failed to create image buffer")?; - let encoder = image::codecs::png::PngEncoder::new(&mut image_data); - encoder.write_image( - image.as_raw(), - width, - height, - image::ExtendedColorType::Rgb8, - )?; - - Ok(image_data) -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 0dd14dc..0000000 --- a/src/config.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::errors::{BotError, BotResult}; -use std::time::Duration; - -/// Configuration for the Discord bot -#[derive(Debug, Clone)] -pub struct BotConfig { - /// Discord bot token - pub discord_token: String, - /// Cryptocurrency name to track - pub crypto_name: String, - /// Update interval in seconds - pub update_interval: Duration, - /// Pyth Network feed ID (optional) - pub pyth_feed_id: Option, -} - -impl BotConfig { - /// Load global configuration from environment variables - pub fn from_env() -> BotResult { - // These are global defaults, typically unused in multi-bot mode except for defaults - let discord_token = std::env::var("DISCORD_TOKEN").unwrap_or_default(); - let crypto_name = std::env::var("CRYPTO_NAME").unwrap_or_else(|_| "SOL".to_string()); - - let update_interval_secs = std::env::var("UPDATE_INTERVAL_SECONDS") - .unwrap_or_else(|_| UPDATE_INTERVAL_SECONDS.to_string()) - .parse::() - .map_err(|_| BotError::Parse("Invalid UPDATE_INTERVAL_SECONDS".into()))?; - - let pyth_feed_id = std::env::var("PYTH_FEED_ID").ok(); - - Ok(Self { - discord_token, - crypto_name, - update_interval: Duration::from_secs(update_interval_secs), - pyth_feed_id, - }) - } - - /// Load all bot instances defined in environment variables (DISCORD_TOKEN_BTC, etc.) - pub fn load_bot_instances() -> Vec<(String, String)> { - let mut instances = Vec::new(); - - // Scan environment variables - for (key, value) in std::env::vars() { - if key.starts_with("DISCORD_TOKEN_") { - let ticker = key.trim_start_matches("DISCORD_TOKEN_").to_string(); - if !ticker.is_empty() && !value.is_empty() { - instances.push((ticker, value)); - } - } - } - - // Sort for consistent startup order - instances.sort_by(|a, b| a.0.cmp(&b.0)); - - // If no specific tokens found, fallback to single instance config if present - if instances.is_empty() { - if let Ok(token) = std::env::var("DISCORD_TOKEN") { - let name = std::env::var("CRYPTO_NAME").unwrap_or_else(|_| "SOL".to_string()); - if !token.is_empty() { - instances.push((name, token)); - } - } - } - - instances - } -} - -/// Constants for the application -pub const DATABASE_PATH: &str = "/app/shared/prices.db"; - -/// Default update interval in seconds -pub const UPDATE_INTERVAL_SECONDS: u64 = 12; - -/// Price history retention in days -pub const PRICE_HISTORY_DAYS: u64 = 365; // Keep 1 year of history - -/// Data retention tiers for aggregation -pub const RAW_DATA_RETENTION_HOURS: u64 = 24; // Keep raw 15-second data for 24 hours -pub const MINUTE_DATA_RETENTION_DAYS: u64 = 7; // Keep 1-minute data for 7 days -pub const FIVE_MINUTE_DATA_RETENTION_DAYS: u64 = 30; // Keep 5-minute data for 30 days -pub const FIFTEEN_MINUTE_DATA_RETENTION_DAYS: u64 = 365; // Keep 15-minute data for 1 year diff --git a/src/database.rs b/src/database.rs deleted file mode 100644 index 8dbab30..0000000 --- a/src/database.rs +++ /dev/null @@ -1,458 +0,0 @@ -use crate::config::PRICE_HISTORY_DAYS; -use crate::errors::{BotError, BotResult}; -use crate::utils::{ - calculate_percentage_change, get_change_arrow, get_current_timestamp, validate_crypto_name, - validate_price, -}; -use r2d2::Pool; -use r2d2_sqlite::SqliteConnectionManager; -use rusqlite::Connection; -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use tracing::{debug, error, info}; - -const CLEANUP_INTERVAL_SECONDS: u64 = 86400; // 24 hours - -/// Database abstraction layer for price data -#[derive(Debug)] -pub struct PriceDatabase { - pool: Pool, -} - -impl PriceDatabase { - pub fn new(db_path: &str) -> BotResult { - let manager = SqliteConnectionManager::file(db_path).with_init(|c| { - c.execute_batch( - "PRAGMA journal_mode = WAL; -- Enable WAL mode - PRAGMA busy_timeout = 30000; -- Set busy timeout to 30s - PRAGMA synchronous = NORMAL; -- Faster sync - CREATE TABLE IF NOT EXISTS prices ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - crypto_name TEXT NOT NULL, - price REAL NOT NULL, - timestamp INTEGER NOT NULL, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_prices_crypto_timestamp_unique ON prices(crypto_name, timestamp); - CREATE INDEX IF NOT EXISTS idx_prices_crypto_timestamp ON prices(crypto_name, timestamp); - CREATE TABLE IF NOT EXISTS price_aggregates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - crypto_name TEXT NOT NULL, - bucket_start INTEGER NOT NULL, - bucket_duration INTEGER NOT NULL, - open_price REAL NOT NULL, - high_price REAL NOT NULL, - low_price REAL NOT NULL, - close_price REAL NOT NULL, - avg_price REAL NOT NULL, - sample_count INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - CREATE INDEX IF NOT EXISTS idx_aggregates_crypto_bucket - ON price_aggregates(crypto_name, bucket_start, bucket_duration);", - ) - }); - - let pool = Pool::builder() - .max_size(4) // SQLite performs better with fewer connections - .build(manager) - .map_err(|e| { - BotError::Database(rusqlite::Error::ToSqlConversionFailure(Box::new(e))) - })?; - - Ok(Self { pool }) - } - - /// Get a database connection from the pool - pub fn get_connection(&self) -> BotResult> { - self.pool - .get() - .map_err(|e| BotError::Database(rusqlite::Error::ToSqlConversionFailure(Box::new(e)))) - } - - /// Save a price record to the database - pub fn save_price(&self, crypto_name: &str, price: f64) -> BotResult<()> { - // Skip invalid prices (0 or negative) - if price <= 0.0 { - debug!( - "Skipping save for {} - invalid price: {}", - crypto_name, price - ); - return Ok(()); - } - - let conn = self.get_connection()?; - let current_time = get_current_timestamp()?; - - conn.execute( - "INSERT OR REPLACE INTO prices (crypto_name, price, timestamp) VALUES (?1, ?2, ?3)", - [crypto_name, &price.to_string(), ¤t_time.to_string()], - )?; - debug!("Saved {} price to database: ${}", crypto_name, price); - Ok(()) - } - - /// Get the latest price for a cryptocurrency from the database - pub fn get_latest_price(&self, crypto_name: &str) -> BotResult { - let conn = self.get_connection()?; - - let mut stmt = conn.prepare_cached( - "SELECT price FROM prices WHERE crypto_name = ? ORDER BY timestamp DESC LIMIT 1", - )?; - - let price: f64 = stmt - .query_row([crypto_name], |row| row.get(0)) - .map_err(|e| BotError::Database(e))?; - - Ok(price) - } - - /// Get all latest prices from the database (one per crypto) - pub fn get_all_latest_prices(&self) -> BotResult> { - let conn = self.get_connection()?; - - let mut stmt = conn.prepare_cached( - "SELECT p.crypto_name, p.price - FROM prices p - INNER JOIN ( - SELECT crypto_name, MAX(timestamp) as max_ts - FROM prices GROUP BY crypto_name - ) latest ON p.crypto_name = latest.crypto_name AND p.timestamp = latest.max_ts", - )?; - - let mut prices = HashMap::new(); - let rows = stmt.query_map([], |row| { - let name: String = row.get(0)?; - let price: f64 = row.get(1)?; - Ok((name, price)) - })?; - - for row in rows { - let (name, price) = row.map_err(|e| BotError::Database(e))?; - prices.insert(name, price); - } - - debug!( - "Fetched all latest prices: {:?}", - prices.keys().collect::>() - ); - Ok(prices) - } - - /// Get price changes for different time periods (works with both raw and aggregated data) - pub fn get_price_changes(&self, crypto: &str, current_price: f64) -> BotResult { - info!( - "πŸ” Getting price changes for {} at ${}", - crypto, current_price - ); - validate_crypto_name(crypto)?; - validate_price(current_price)?; - - let conn = self.get_connection()?; - let current_time = get_current_timestamp()?; - - let mut changes = Vec::new(); - - // Define time periods and their labels - let periods = vec![ - (3600, "1h"), - (43200, "12h"), - (86400, "24h"), - (604800, "7d"), - (2592000, "30d"), // 30 days in seconds - ]; - - for (seconds, label) in periods { - // Guard against underflow if clock goes backwards - let time_ago = if current_time >= seconds { - current_time - seconds - } else { - debug!( - "Clock appears to have gone backwards, skipping {} price lookup", - label - ); - continue; - }; - - // Try to get price from appropriate data source based on age - let old_price = if seconds <= 24 * 3600 { - // For recent data (< 24h), use raw prices table - debug!( - "Looking for {} {} data in raw table, time_ago: {}", - label, crypto, time_ago - ); - self.get_price_from_raw_data(&conn, crypto, time_ago)? - } else if seconds <= 7 * 24 * 3600 { - // For 1-7 days old, use 1-minute aggregates - debug!( - "Looking for {} {} data in 60s aggregates, time_ago: {}", - label, crypto, time_ago - ); - self.get_price_from_aggregates(&conn, crypto, time_ago, 60)? - } else if seconds < 30 * 24 * 3600 { - // For 7-30 days old, use 5-minute aggregates - debug!( - "Looking for {} {} data in 300s aggregates, time_ago: {}", - label, crypto, time_ago - ); - self.get_price_from_aggregates(&conn, crypto, time_ago, 300)? - } else { - // For older data, use 15-minute aggregates - debug!( - "Looking for {} {} data in 900s aggregates, time_ago: {}", - label, crypto, time_ago - ); - self.get_price_from_aggregates(&conn, crypto, time_ago, 900)? - }; - - // Only add the change if we have data for that time period - if let Some(price) = old_price { - debug!( - "Found {} {} price: ${} (current: ${})", - label, crypto, price, current_price - ); - let change_percent = calculate_percentage_change(current_price, price)?; - let arrow = get_change_arrow(change_percent); - let sign = if change_percent >= 0.0 { "+" } else { "" }; - changes.push(format!( - "{} {}{:.2}% ({})", - arrow, sign, change_percent, label - )); - } else { - debug!( - "No {} {} price data found for time_ago: {}", - label, crypto, time_ago - ); - } - } - - info!( - "Found {} price changes for {}: {:?}", - changes.len(), - crypto, - changes - ); - - if changes.is_empty() { - Ok("πŸ”„ Building history".to_string()) - } else { - Ok(format!(" {}", changes.join(" | "))) - } - } - - /// Get price from raw data table - fn get_price_from_raw_data( - &self, - conn: &Connection, - crypto: &str, - time_ago: u64, - ) -> BotResult> { - let mut stmt = conn.prepare_cached( - "SELECT price FROM prices WHERE crypto_name = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1" - )?; - - let rows = stmt.query_map([crypto, &time_ago.to_string()], |row| Ok(row.get(0)?))?; - - let mut prices = rows.collect::, _>>()?; - Ok(prices.pop()) - } - - /// Get price from aggregated data table - fn get_price_from_aggregates( - &self, - conn: &Connection, - crypto: &str, - time_ago: u64, - bucket_duration: u64, - ) -> BotResult> { - // Find the bucket that contains or is closest to the target time - // We want the bucket where bucket_start <= time_ago < bucket_start + bucket_duration - // Or the closest bucket if no exact match - let mut stmt = conn.prepare_cached( - "SELECT open_price FROM price_aggregates - WHERE crypto_name = ? AND bucket_duration = ? - AND bucket_start <= ? - ORDER BY bucket_start DESC LIMIT 1", - )?; - - let rows = stmt.query_map( - [crypto, &bucket_duration.to_string(), &time_ago.to_string()], - |row| Ok(row.get(0)?), - )?; - - let mut prices = rows.collect::, _>>()?; - Ok(prices.pop()) - } - - /// Get price indicator from database for status display - pub fn get_price_indicator(&self, crypto_name: &str, current_price: f64) -> (String, f64) { - let current_time = match get_current_timestamp() { - Ok(time) => time, - Err(_) => return ("πŸ”„".to_string(), 0.0), - }; - - let conn = match self.get_connection() { - Ok(conn) => conn, - Err(_) => return ("πŸ”„".to_string(), 0.0), - }; - - let mut stmt = match conn.prepare_cached( - "SELECT price FROM prices WHERE crypto_name = ? AND timestamp >= ? ORDER BY timestamp ASC LIMIT 1" - ) { - Ok(stmt) => stmt, - Err(_) => return ("πŸ”„".to_string(), 0.0), - }; - - let one_hour_ago = current_time - 3600; // 1 hour - let rows = match stmt.query_map([crypto_name, &one_hour_ago.to_string()], |row| { - Ok(row.get(0)?) - }) { - Ok(rows) => rows, - Err(_) => return ("πŸ”„".to_string(), 0.0), - }; - - let mut prices = match rows.collect::, _>>() { - Ok(prices) => prices, - Err(_) => return ("πŸ”„".to_string(), 0.0), - }; - - if let Some(oldest_price) = prices.pop() { - match calculate_percentage_change(current_price, oldest_price) { - Ok(change_percent) => { - let arrow = get_change_arrow(change_percent); - return (arrow.to_string(), change_percent); - } - Err(_) => return ("πŸ”„".to_string(), 0.0), - } - } - - // No history yet - ("πŸ”„".to_string(), 0.0) - } - - /// Get price history for charting (up to specified days) - /// Returns vector of (timestamp, price) tuples - pub fn get_price_history(&self, crypto_name: &str, days: u64) -> BotResult> { - let conn = self.get_connection()?; - let current_time = get_current_timestamp()?; - let start_time = current_time - (days * 86400); - - // Strategy: Combine aggregated history + Recent raw data - // 1. Fetch best available aggregates - // 2. Fetch raw data that is newer than the newest aggregate - // 3. Merge and sort - - let mut history = Vec::new(); - let mut last_aggregated_time = start_time as i64; - - // 1. Fetch Aggregates - // Try to get 5-minute buckets first, then 1-minute (fallback), then 15m, then 1h - // This ensures we get the best resolution available for the time range - let bucket_durations = vec![300, 60, 900, 3600]; - - for duration in bucket_durations { - let mut stmt = conn.prepare_cached( - "SELECT bucket_start, open_price FROM price_aggregates - WHERE crypto_name = ? AND bucket_duration = ? AND bucket_start >= ? - ORDER BY bucket_start ASC", - )?; - - let rows = stmt.query_map( - [crypto_name, &duration.to_string(), &start_time.to_string()], - |row| Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)), - )?; - - let data: Vec<(i64, f64)> = rows.collect::, _>>()?; - - if !data.is_empty() { - // If we found data, record the last timestamp so we know where to start raw data - if let Some((ts, _)) = data.last() { - last_aggregated_time = *ts; - } - history = data; - debug!( - "Found {} aggregated points for {} using {}-second buckets", - history.len(), - crypto_name, - duration - ); - break; - } - } - - // 2. Fetch Raw Data (Newer than last aggregate) - // This covers the gap from the last cleanup/aggregation run to NOW - // Also helps if no aggregates exist at all (last_aggregated_time == start_time) - - debug!( - "Fetching raw prices for {} newer than {}", - crypto_name, last_aggregated_time - ); - - let mut stmt = conn.prepare_cached( - "SELECT timestamp, price FROM prices - WHERE crypto_name = ? AND timestamp > ? - ORDER BY timestamp ASC", - )?; - - let rows = stmt.query_map([crypto_name, &last_aggregated_time.to_string()], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, f64>(1)?)) - })?; - - let raw_data: Vec<(i64, f64)> = rows.collect::, _>>()?; - - // Downsample raw data if there's too much (e.g., if we have no aggregates and 30 days of raw data) - // But typically this will just be the last 24h ~ few hundred points max - if raw_data.len() > 1000 { - let step = raw_data.len() / 500; - let downsampled = raw_data - .into_iter() - .enumerate() - .filter(|(i, _)| i % step == 0) - .map(|(_, val)| val); - history.extend(downsampled); - } else { - history.extend(raw_data); - } - - // 3. Final Sort (just in case, though append should be sorted) - history.sort_by_key(|k| k.0); - - Ok(history) - } - - /// Clean up old price records from the database - pub fn cleanup_old_prices(&self) -> BotResult<()> { - let conn = self.get_connection()?; - - // Keep only the last 60 days of data - let cutoff_time = get_current_timestamp()? - (PRICE_HISTORY_DAYS * 24 * 3600); - - let deleted = conn.execute( - "DELETE FROM prices WHERE timestamp < ?", - [&cutoff_time.to_string()], - )?; - - if deleted > 0 { - info!("Cleaned up {} old price records from database", deleted); - } - - Ok(()) - } - - /// Perform periodic cleanup if needed - pub fn maybe_cleanup(&self) { - static LAST_CLEANUP: AtomicU64 = AtomicU64::new(0); - - if let Ok(current_time) = get_current_timestamp() { - let last_cleanup = LAST_CLEANUP.load(Ordering::Relaxed); - if current_time - last_cleanup > CLEANUP_INTERVAL_SECONDS { - match self.cleanup_old_prices() { - Ok(_) => debug!("Database cleanup completed"), - Err(e) => error!("Failed to cleanup old prices: {}", e), - } - LAST_CLEANUP.store(current_time, Ordering::Relaxed); - } - } - } -} diff --git a/src/database_tests.rs b/src/database_tests.rs deleted file mode 100644 index 160074a..0000000 --- a/src/database_tests.rs +++ /dev/null @@ -1,46 +0,0 @@ -#[cfg(test)] -mod tests { - use crate::PriceDatabase; - use tempfile::TempDir; - - fn setup_temp_db() -> (TempDir, PriceDatabase) { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let db_path = temp_dir.path().join("test.db"); - let db = PriceDatabase::new(db_path.to_str().unwrap()).expect("Failed to create database"); - (temp_dir, db) - } - - #[test] - fn test_database_initializes() { - let (_temp_dir, db) = setup_temp_db(); - // Just verify database can be created and queried - assert!(db.get_all_latest_prices().is_ok()); - } - - #[test] - fn test_save_and_retrieve_price() { - let (_temp_dir, db) = setup_temp_db(); - - // Save a price - let result = db.save_price("BTC", 50000.0); - assert!(result.is_ok()); - - // Retrieve it - let price = db.get_latest_price("BTC"); - assert!(price.is_ok()); - assert_eq!(price.unwrap(), 50000.0); - } - - #[test] - fn test_save_invalid_price_rejected() { - let (_temp_dir, db) = setup_temp_db(); - - // Zero price should be skipped (not saved) - let result = db.save_price("BTC", 0.0); - assert!(result.is_ok()); // Returns ok but doesn't save zero - - // Negative price should be skipped - let result = db.save_price("BTC", -100.0); - assert!(result.is_ok()); // Returns ok but doesn't save negative - } -} diff --git a/src/db_cleanup.rs b/src/db_cleanup.rs deleted file mode 100644 index 997ea37..0000000 --- a/src/db_cleanup.rs +++ /dev/null @@ -1,750 +0,0 @@ -use crate::config::{ - FIFTEEN_MINUTE_DATA_RETENTION_DAYS, FIVE_MINUTE_DATA_RETENTION_DAYS, - MINUTE_DATA_RETENTION_DAYS, RAW_DATA_RETENTION_HOURS, -}; -use crate::database::PriceDatabase; -use crate::errors::{BotError, BotResult}; -use crate::health::HealthState; - -use rusqlite::Connection; -use std::sync::Arc; -use std::time::Duration; -use tokio::time::sleep; -use tracing::{debug, error, info, warn}; - -/// Database cleanup service for aggregating and compacting price data -pub struct DatabaseCleanup { - health: Arc, - database: Arc, -} - -impl DatabaseCleanup { - pub fn new(database: Arc) -> Self { - let health = Arc::new(HealthState::new("DB-CLEANUP".to_string())); - Self { health, database } - } - - /// Get a database connection from the pool - fn get_connection( - &self, - ) -> BotResult> { - self.database.get_connection() - } - - /// Initialize the aggregated data table - fn init_aggregated_table(&self) -> BotResult<()> { - let conn = self.get_connection()?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS price_aggregates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - crypto_name TEXT NOT NULL, - bucket_start INTEGER NOT NULL, - bucket_duration INTEGER NOT NULL, - open_price REAL NOT NULL, - high_price REAL NOT NULL, - low_price REAL NOT NULL, - close_price REAL NOT NULL, - avg_price REAL NOT NULL, - sample_count INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - )", - [], - )?; - - // Create indexes for efficient queries - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_aggregates_crypto_bucket - ON price_aggregates(crypto_name, bucket_start, bucket_duration)", - [], - )?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_prices_crypto_timestamp - ON prices(crypto_name, timestamp)", - [], - )?; - - info!("βœ… Initialized aggregated data table and indexes"); - Ok(()) - } - - /// Aggregate raw data into time buckets with batching to reduce lock time - fn aggregate_data( - &self, - bucket_duration_seconds: u64, - older_than_seconds: u64, - ) -> BotResult { - info!( - " πŸ” Checking for data older than {} seconds to aggregate into {}-second buckets", - older_than_seconds, bucket_duration_seconds - ); - - let conn = self.get_connection()?; - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| BotError::SystemTime(format!("System time error: {}", e)))? - .as_secs(); - - let cutoff_time = current_time - older_than_seconds; - let bucket_duration = bucket_duration_seconds as i64; - - // Process in smaller batches to reduce lock contention - let batch_size = 100; - let mut total_aggregated = 0u64; - let mut batch_number = 0; - - loop { - batch_number += 1; - debug!( - " πŸ“¦ Processing batch {} for {}-second aggregation", - batch_number, bucket_duration_seconds - ); - - // Get a small batch of data to aggregate - let mut stmt = conn.prepare( - "SELECT crypto_name, - (timestamp / ?) * ? as bucket_start, - MIN(price) as low_price, - MAX(price) as high_price, - AVG(price) as avg_price, - COUNT(*) as sample_count - FROM prices - WHERE timestamp < ? - AND NOT EXISTS ( - SELECT 1 FROM price_aggregates pa - WHERE pa.crypto_name = prices.crypto_name - AND pa.bucket_start = (prices.timestamp / ?) * ? - AND pa.bucket_duration = ? - ) - GROUP BY crypto_name, bucket_start - HAVING COUNT(*) > 0 - ORDER BY crypto_name, bucket_start - LIMIT ?", - )?; - - let rows = stmt.query_map( - [ - bucket_duration, - bucket_duration, // bucket_start calculation - cutoff_time as i64, // WHERE timestamp < cutoff - bucket_duration, - bucket_duration, - bucket_duration, // NOT EXISTS check - batch_size as i64, // LIMIT - ], - |row| { - Ok(( - row.get::<_, String>(0)?, // crypto_name - row.get::<_, i64>(1)?, // bucket_start - row.get::<_, f64>(2)?, // low_price - row.get::<_, f64>(3)?, // high_price - row.get::<_, f64>(4)?, // avg_price - row.get::<_, i64>(5)?, // sample_count - )) - }, - )?; - - let batch_data: Vec<_> = rows.collect::, _>>()?; - - if batch_data.is_empty() { - debug!( - " βœ… No more data to aggregate for {}-second buckets", - bucket_duration_seconds - ); - break; // No more data to process - } - - debug!( - " πŸ“Š Found {} records to aggregate in batch {}", - batch_data.len(), - batch_number - ); - - // Process this batch in a transaction - let tx = conn.unchecked_transaction()?; - let mut batch_count = 0u64; - - for (crypto_name, bucket_start, low_price, high_price, avg_price, sample_count) in - batch_data - { - // Get open and close prices separately for accuracy - let open_price = - self.get_bucket_open_price(&conn, &crypto_name, bucket_start, bucket_duration)?; - let close_price = self.get_bucket_close_price( - &conn, - &crypto_name, - bucket_start, - bucket_duration, - )?; - - // Insert the aggregated data - tx.execute( - "INSERT INTO price_aggregates - (crypto_name, bucket_start, bucket_duration, open_price, high_price, low_price, close_price, avg_price, sample_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - [ - &crypto_name, - &bucket_start.to_string(), - &bucket_duration.to_string(), - &open_price.to_string(), - &high_price.to_string(), - &low_price.to_string(), - &close_price.to_string(), - &avg_price.to_string(), - &sample_count.to_string(), - ] - )?; - - batch_count += 1; - } - - // Commit this batch - tx.commit()?; - total_aggregated += batch_count; - - debug!( - " βœ… Batch {} completed: {} buckets aggregated (total: {})", - batch_number, batch_count, total_aggregated - ); - - // Small delay between batches to allow other processes to access DB - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - if total_aggregated > 0 { - info!( - "πŸ“Š Aggregated {} buckets of {}-second data", - total_aggregated, bucket_duration_seconds - ); - } - - Ok(total_aggregated) - } - - /// Get the opening price for a bucket - fn get_bucket_open_price( - &self, - conn: &Connection, - crypto_name: &str, - bucket_start: i64, - bucket_duration: i64, - ) -> BotResult { - let bucket_end = bucket_start + bucket_duration; - let mut stmt = conn.prepare( - "SELECT price FROM prices - WHERE crypto_name = ? AND timestamp >= ? AND timestamp < ? - ORDER BY timestamp ASC LIMIT 1", - )?; - - let price: f64 = stmt.query_row( - [ - crypto_name, - &bucket_start.to_string(), - &bucket_end.to_string(), - ], - |row| row.get(0), - )?; - - Ok(price) - } - - /// Get the closing price for a bucket - fn get_bucket_close_price( - &self, - conn: &Connection, - crypto_name: &str, - bucket_start: i64, - bucket_duration: i64, - ) -> BotResult { - let bucket_end = bucket_start + bucket_duration; - let mut stmt = conn.prepare( - "SELECT price FROM prices - WHERE crypto_name = ? AND timestamp >= ? AND timestamp < ? - ORDER BY timestamp DESC LIMIT 1", - )?; - - let price: f64 = stmt.query_row( - [ - crypto_name, - &bucket_start.to_string(), - &bucket_end.to_string(), - ], - |row| row.get(0), - )?; - - Ok(price) - } - - /// Delete raw data that has been successfully aggregated - fn cleanup_aggregated_raw_data(&self, older_than_seconds: u64) -> BotResult { - info!( - " πŸ” Checking how many raw records are older than {} seconds", - older_than_seconds - ); - - let conn = self.get_connection()?; - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| BotError::SystemTime(format!("System time error: {}", e)))? - .as_secs(); - - let cutoff_time = current_time - older_than_seconds; - - // First, count how many records we're about to delete - let mut count_stmt = conn.prepare("SELECT COUNT(*) FROM prices WHERE timestamp < ?")?; - let count: i64 = count_stmt.query_row([cutoff_time as i64], |row| row.get(0))?; - - info!( - " πŸ“Š Found {} raw records older than {} seconds", - count, older_than_seconds - ); - - if count == 0 { - info!(" βœ… No old raw data to clean up"); - return Ok(0); - } - - info!(" πŸ—‘οΈ Deleting old raw records in batches..."); - - // Delete in batches to avoid hanging - let mut total_deleted = 0i64; - let batch_size = 10000; - - loop { - let deleted = conn.execute( - "DELETE FROM prices - WHERE timestamp < ? - AND EXISTS ( - SELECT 1 FROM price_aggregates pa - WHERE pa.crypto_name = prices.crypto_name - AND pa.bucket_start <= prices.timestamp - AND pa.bucket_start + pa.bucket_duration > prices.timestamp - ) - LIMIT ?", - rusqlite::params![cutoff_time as i64, batch_size], - )?; - - if deleted == 0 { - break; - } - - total_deleted += deleted as i64; - info!(" Deleted {} records (total: {})", deleted, total_deleted); - } - - info!( - " {} raw price records βœ… Successfully deleted older than {} seconds", - total_deleted, older_than_seconds - ); - - Ok(total_deleted as u64) - } - - /// Delete old aggregated data beyond retention period - fn cleanup_old_aggregates( - &self, - bucket_duration_seconds: u64, - older_than_seconds: u64, - ) -> BotResult { - info!( - " 🧹 Cleaning up {}-second aggregates older than {} seconds", - bucket_duration_seconds, older_than_seconds - ); - - let conn = self.get_connection()?; - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| BotError::SystemTime(format!("System time error: {}", e)))? - .as_secs(); - - let cutoff_time = current_time - older_than_seconds; - - // First count what we're about to delete - let mut count_stmt = conn.prepare( - "SELECT COUNT(*) FROM price_aggregates WHERE bucket_start < ? AND bucket_duration = ?", - )?; - let count: i64 = count_stmt.query_row( - [cutoff_time as i64, bucket_duration_seconds as i64], - |row| row.get(0), - )?; - - if count > 0 { - info!( - " πŸ—‘οΈ Deleting {} old {}-second aggregate records...", - count, bucket_duration_seconds - ); - } else { - info!( - " βœ… No old {}-second aggregates to clean up", - bucket_duration_seconds - ); - } - - let deleted = conn.execute( - "DELETE FROM price_aggregates - WHERE bucket_start < ? AND bucket_duration = ?", - [cutoff_time as i64, bucket_duration_seconds as i64], - )?; - - if deleted > 0 { - info!( - " βœ… Deleted {} aggregated records ({}-second buckets)", - deleted, bucket_duration_seconds - ); - } - - Ok(deleted as u64) - } - - /// Vacuum the database to reclaim space - fn vacuum_database(&self) -> BotResult<()> { - let conn = self.get_connection()?; - - info!("🧹 Starting database vacuum..."); - conn.execute("VACUUM", [])?; - info!("βœ… Database vacuum completed"); - - Ok(()) - } - - /// Get database statistics - fn get_database_stats(&self) -> BotResult<()> { - let conn = self.get_connection()?; - - // Count raw price records - let raw_count: i64 = - conn.query_row("SELECT COUNT(*) FROM prices", [], |row: &rusqlite::Row| { - row.get(0) - })?; - - // Count aggregated records by bucket size - let mut stmt = conn.prepare( - "SELECT bucket_duration, COUNT(*) FROM price_aggregates GROUP BY bucket_duration ORDER BY bucket_duration" - )?; - - let rows = stmt.query_map([], |row: &rusqlite::Row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) - })?; - - info!("πŸ“Š Database Statistics:"); - info!(" Raw price records: {}", raw_count); - - for row in rows { - let (duration, count) = row?; - info!(" {}-second aggregates: {}", duration, count); - } - - Ok(()) - } - - /// Perform complete cleanup cycle with retry logic - pub async fn perform_cleanup(&self) -> BotResult<()> { - const MAX_RETRIES: u32 = 3; - - for attempt in 1..=MAX_RETRIES { - match self.perform_cleanup_attempt().await { - Ok(()) => return Ok(()), - Err(e) => { - error!("❌ Cleanup attempt {} failed: {}", attempt, e); - if attempt < MAX_RETRIES { - info!("⏳ Retrying cleanup in 30 seconds..."); - tokio::time::sleep(Duration::from_secs(30)).await; - } else { - return Err(e); - } - } - } - } - unreachable!() - } - - /// Aggregate data from smaller buckets into larger buckets (e.g., 1m -> 5m) - fn aggregate_buckets( - &self, - source_duration: u64, - target_duration: u64, - older_than_seconds: u64, - ) -> BotResult { - info!( - " πŸ” Aggregating {}-second buckets older than {} seconds into {}-second buckets", - source_duration, older_than_seconds, target_duration - ); - - let conn = self.get_connection()?; - let current_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| BotError::SystemTime(format!("System time error: {}", e)))? - .as_secs(); - - let cutoff_time = current_time - older_than_seconds; - - // Process in batches - let batch_size = 100; - let mut total_aggregated = 0u64; - let mut batch_number = 0; - - loop { - batch_number += 1; - - // Get a batch of source buckets to aggregate - // We group by the NEW bucket start time - let mut stmt = conn.prepare( - "SELECT crypto_name, - (bucket_start / ?) * ? as new_bucket_start, - MIN(low_price) as low_price, - MAX(high_price) as high_price, - SUM(avg_price * sample_count) / SUM(sample_count) as avg_price, - SUM(sample_count) as sample_count - FROM price_aggregates - WHERE bucket_duration = ? - AND bucket_start < ? - AND NOT EXISTS ( - SELECT 1 FROM price_aggregates pa - WHERE pa.crypto_name = price_aggregates.crypto_name - AND pa.bucket_start = (price_aggregates.bucket_start / ?) * ? - AND pa.bucket_duration = ? - ) - GROUP BY crypto_name, new_bucket_start - HAVING COUNT(*) > 0 - ORDER BY crypto_name, new_bucket_start - LIMIT ?", - )?; - - let rows = stmt.query_map( - [ - target_duration, - target_duration, // new_bucket_start calculation - source_duration, // WHERE bucket_duration = source - cutoff_time, // AND bucket_start < cutoff - target_duration, - target_duration, - target_duration, // NOT EXISTS check - batch_size as u64, // LIMIT - ], - |row| { - Ok(( - row.get::<_, String>(0)?, // crypto_name - row.get::<_, i64>(1)?, // new_bucket_start - row.get::<_, f64>(2)?, // low_price - row.get::<_, f64>(3)?, // high_price - row.get::<_, f64>(4)?, // avg_price - row.get::<_, i64>(5)?, // sample_count - )) - }, - )?; - - let batch_data: Vec<_> = rows.collect::, _>>()?; - - if batch_data.is_empty() { - break; // No more data to process - } - - debug!( - " πŸ“Š Found {} bucket groups to aggregate in batch {}", - batch_data.len(), - batch_number - ); - - // Process this batch in a transaction - let tx = conn.unchecked_transaction()?; - let mut batch_count = 0u64; - - for (crypto_name, bucket_start, low_price, high_price, avg_price, sample_count) in - batch_data - { - // For open/close, we need to query the source buckets - // Open price = Open price of the earliest source bucket in this range - // Close price = Close price of the latest source bucket in this range - let bucket_end = bucket_start + target_duration as i64; - - // Get open price - let open_price: f64 = match tx.query_row( - "SELECT open_price FROM price_aggregates - WHERE crypto_name = ? AND bucket_duration = ? - AND bucket_start >= ? AND bucket_start < ? - ORDER BY bucket_start ASC LIMIT 1", - [ - &crypto_name, - &source_duration.to_string(), - &bucket_start.to_string(), - &bucket_end.to_string(), - ], - |row| row.get(0), - ) { - Ok(price) => price, - Err(e) => { - warn!( - "Failed to get open price for {}: {}, using avg", - crypto_name, e - ); - avg_price - } - }; - - // Get close price - let close_price: f64 = match tx.query_row( - "SELECT close_price FROM price_aggregates - WHERE crypto_name = ? AND bucket_duration = ? - AND bucket_start >= ? AND bucket_start < ? - ORDER BY bucket_start DESC LIMIT 1", - [ - &crypto_name, - &source_duration.to_string(), - &bucket_start.to_string(), - &bucket_end.to_string(), - ], - |row| row.get(0), - ) { - Ok(price) => price, - Err(e) => { - warn!( - "Failed to get close price for {}: {}, using avg", - crypto_name, e - ); - avg_price - } - }; - - // Insert the aggregated data - tx.execute( - "INSERT INTO price_aggregates - (crypto_name, bucket_start, bucket_duration, open_price, high_price, low_price, close_price, avg_price, sample_count) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", - [ - &crypto_name, - &bucket_start.to_string(), - &target_duration.to_string(), - &open_price.to_string(), - &high_price.to_string(), - &low_price.to_string(), - &close_price.to_string(), - &avg_price.to_string(), - &sample_count.to_string(), - ] - )?; - - batch_count += 1; - } - - // Commit this batch - tx.commit()?; - total_aggregated += batch_count; - - // Small delay - std::thread::sleep(std::time::Duration::from_millis(50)); - } - - if total_aggregated > 0 { - info!( - "πŸ“Š Aggregated {} buckets of {}-second data (sourced from {}-second buckets)", - total_aggregated, target_duration, source_duration - ); - } - - Ok(total_aggregated) - } - - /// Single cleanup attempt - async fn perform_cleanup_attempt(&self) -> BotResult<()> { - info!("🧹 Starting database cleanup cycle..."); - self.health.update_price_timestamp(); // Use as "last activity" timestamp - - // Initialize aggregated table if needed - info!("πŸ“‹ Step 1/7: Initializing aggregated data table..."); - self.init_aggregated_table()?; - - // Tier 1: Aggregate raw data older than 24 hours into 1-minute buckets - info!("πŸ“Š Step 2/7: Aggregating raw data into 1-minute buckets..."); - let aggregated_1m = self.aggregate_data(60, RAW_DATA_RETENTION_HOURS * 3600)?; - - // Tier 2: Aggregate 1-minute data older than 7 days into 5-minute buckets - info!("πŸ“Š Step 3/7: Aggregating 1-minute data into 5-minute buckets..."); - // CHANGED: Source from 60s buckets instead of raw data - let aggregated_5m = - self.aggregate_buckets(60, 300, MINUTE_DATA_RETENTION_DAYS * 24 * 3600)?; - - // Tier 3: Aggregate 5-minute data older than 30 days into 15-minute buckets - info!("πŸ“Š Step 4/7: Aggregating 5-minute data into 15-minute buckets..."); - // CHANGED: Source from 300s buckets instead of raw data - let aggregated_15m = - self.aggregate_buckets(300, 900, FIVE_MINUTE_DATA_RETENTION_DAYS * 24 * 3600)?; - - // Clean up raw data that has been aggregated (older than 24 hours) - info!("πŸ—‘οΈ Step 5/7: Cleaning up old raw data (older than 24 hours)..."); - let deleted_raw = self.cleanup_aggregated_raw_data(RAW_DATA_RETENTION_HOURS * 3600)?; - - // Clean up old aggregated data beyond retention periods - info!("πŸ—‘οΈ Step 6/7: Cleaning up old aggregated data..."); - let deleted_1m = self.cleanup_old_aggregates(60, MINUTE_DATA_RETENTION_DAYS * 24 * 3600)?; - let deleted_5m = - self.cleanup_old_aggregates(300, FIVE_MINUTE_DATA_RETENTION_DAYS * 24 * 3600)?; - let deleted_15m = - self.cleanup_old_aggregates(900, FIFTEEN_MINUTE_DATA_RETENTION_DAYS * 24 * 3600)?; - - // Vacuum database if significant cleanup occurred - let total_deleted = deleted_raw + deleted_1m + deleted_5m + deleted_15m; - if total_deleted > 1000 { - info!( - "πŸ”§ Step 7/7: Running database vacuum (deleted {} records)...", - total_deleted - ); - self.vacuum_database()?; - } else { - info!( - "⏭️ Step 7/7: Skipping vacuum (only {} records deleted)", - total_deleted - ); - } - - // Update health timestamp - self.health.update_db_timestamp(); - - // Show final statistics - info!("πŸ“ˆ Generating final database statistics..."); - self.get_database_stats()?; - - info!("βœ… Cleanup cycle completed:"); - info!( - " πŸ“Š Aggregated: {}x1m + {}x5m + {}x15m buckets", - aggregated_1m, aggregated_5m, aggregated_15m - ); - info!(" πŸ—‘οΈ Deleted: {} total records", total_deleted); - - Ok(()) - } - - /// Run the cleanup service with periodic execution - pub async fn run(&self) -> BotResult<()> { - let interval_hours = std::env::var("CLEANUP_INTERVAL_HOURS") - .unwrap_or_else(|_| "24".to_string()) - .parse::() - .unwrap_or(24); - - let interval = Duration::from_secs(interval_hours * 3600); - - info!("πŸš€ Database cleanup service started"); - info!("⏰ Cleanup interval: {} hours", interval_hours); - - // Note: Health server is now started by main.rs with aggregated health from all bots - - // Run initial cleanup after a short delay - sleep(Duration::from_secs(30)).await; - - loop { - match self.perform_cleanup().await { - Ok(_) => { - info!("βœ… Cleanup completed successfully"); - self.health.reset_failures(); - } - Err(e) => { - error!("❌ Cleanup failed: {}", e); - self.health.increment_failures(); - } - } - - info!("⏰ Next cleanup in {} hours", interval_hours); - sleep(interval).await; - } - } -} diff --git a/src/discord_api.rs b/src/discord_api.rs deleted file mode 100644 index be598b7..0000000 --- a/src/discord_api.rs +++ /dev/null @@ -1,136 +0,0 @@ -use crate::errors::{BotError, BotResult}; -use serenity::http::Http; -use serenity::model::id::GuildId; -use std::sync::Arc; -use std::time::Duration; -use tokio::sync::Semaphore; -use tokio::time::sleep; -use tracing::{debug, warn}; - -const MAX_RETRIES: u32 = 3; -const MAX_CONCURRENT_CALLS: usize = 2; -const RATE_LIMIT_DELAY_MS: u64 = 2000; // 2 seconds between Discord API calls - -/// Discord API wrapper with rate limiting and error handling -#[derive(Clone)] -pub struct DiscordApi { - http: Arc, - semaphore: Arc, -} - -impl DiscordApi { - pub fn new(http: Arc) -> Self { - Self { - http, - semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_CALLS)), - } - } - - /// Rate-limited Discord API call helper - #[allow(unused_variables)] - async fn rate_limited_call(&self, mut operation: F) -> Result - where - F: FnMut() -> Fut, - Fut: std::future::Future>, - { - let permit = self - .semaphore - .acquire() - .await - .map_err(|_| serenity::Error::Other("Semaphore acquire error"))?; - - // Enforce minimum delay between calls - sleep(Duration::from_millis(RATE_LIMIT_DELAY_MS)).await; - - // Execute the operation with retry logic - permit is held during all attempts - for attempt in 1..=MAX_RETRIES { - match operation().await { - Ok(result) => return Ok(result), - Err(e) => { - if e.to_string().contains("rate limit") || e.to_string().contains("429") { - let backoff_time = Duration::from_secs(2_u64.pow(attempt)); - warn!( - "Rate limited, backing off for {:?} (attempt {})", - backoff_time, attempt - ); - sleep(backoff_time).await; - } else if attempt < MAX_RETRIES { - warn!("Discord API call failed (attempt {}): {}", attempt, e); - sleep(Duration::from_millis(1000 * attempt as u64)).await; - } else { - return Err(e); - } - } - } - } - - Err(serenity::Error::Other("Rate limiting exhausted")) - } - - /// Update bot nickname in a specific guild - pub async fn update_nickname(&self, guild_id: GuildId, nickname: &str) -> BotResult<()> { - let http_ref = self.http.clone(); - let nickname_owned = nickname.to_string(); - - match self - .rate_limited_call(|| { - let http_clone = http_ref.clone(); - let nickname_clone = nickname_owned.clone(); - async move { - http_clone - .edit_nickname(guild_id, Some(&nickname_clone), None) - .await - } - }) - .await - { - Ok(_) => { - debug!("Updated nickname in guild {}", guild_id); - Ok(()) - } - Err(e) => { - if e.to_string().contains("rate limit") || e.to_string().contains("429") { - warn!( - "Rate limited while updating nickname in guild {}: {}", - guild_id, e - ); - } else { - warn!("Failed to update nickname in guild {}: {}", guild_id, e); - } - Err(BotError::Discord(e.to_string())) - } - } - } - - /// Update nicknames in multiple guilds in parallel - pub async fn update_nicknames_in_guilds( - &self, - guilds: &[GuildId], - nickname: &str, - ) -> Vec> { - use futures::stream::StreamExt; - use std::sync::Arc; - - let nickname = nickname.to_string(); - let self_arc = Arc::new(self.clone()); - - let futures: Vec<_> = guilds - .iter() - .map(|guild_id| { - let api = self_arc.clone(); - let nickname = nickname.clone(); - let guild_id = *guild_id; - async move { api.update_nickname(guild_id, &nickname).await } - }) - .collect(); - - let mut results = Vec::new(); - let mut stream = futures::stream::iter(futures).buffer_unordered(3); - - while let Some(result) = stream.next().await { - results.push(result); - } - - results - } -} diff --git a/src/errors.rs b/src/errors.rs deleted file mode 100644 index dd84296..0000000 --- a/src/errors.rs +++ /dev/null @@ -1,72 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum BotError { - #[error("Database error: {0}")] - Database(#[from] rusqlite::Error), - - #[error("HTTP request error: {0}")] - Http(String), - - #[error("JSON parsing error: {0}")] - Json(#[from] serde_json::Error), - - #[error("Environment variable not set: {0}")] - EnvVar(String), - - #[error("Invalid input: {0}")] - InvalidInput(String), - - #[error("System time error: {0}")] - SystemTime(String), - - #[error("Price data not available for {0}")] - PriceNotFound(String), - - #[error("Discord API error: {0}")] - Discord(String), - - #[error("File I/O error: {0}")] - Io(#[from] std::io::Error), - - #[error("Parse error: {0}")] - Parse(String), -} - -impl BotError { - pub fn user_message(&self) -> &'static str { - match self { - BotError::PriceNotFound(_) => "Price data not available for this asset", - BotError::InvalidInput(_) => "Invalid input provided", - BotError::Discord(_) => "Discord API error - please try again", - BotError::EnvVar(_) => "Configuration error - please contact support", - // Internal errors - don't expose details to users - BotError::Database(_) - | BotError::Http(_) - | BotError::Json(_) - | BotError::SystemTime(_) - | BotError::Io(_) - | BotError::Parse(_) => "An internal error occurred. Please try again later.", - } - } -} - -impl From for BotError { - fn from(err: std::env::VarError) -> Self { - BotError::EnvVar(err.to_string()) - } -} - -impl From for BotError { - fn from(err: std::num::ParseIntError) -> Self { - BotError::Parse(err.to_string()) - } -} - -impl From for BotError { - fn from(err: std::num::ParseFloatError) -> Self { - BotError::Parse(err.to_string()) - } -} - -pub type BotResult = Result; diff --git a/src/health.rs b/src/health.rs deleted file mode 100644 index 91aad26..0000000 --- a/src/health.rs +++ /dev/null @@ -1,243 +0,0 @@ -use serde_json::json; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Health check state shared across the application -#[derive(Debug, Clone)] -pub struct HealthState { - pub last_price_update: Arc, - pub last_db_write: Arc, - pub last_discord_update: Arc, - pub last_discord_test: Arc, - pub consecutive_failures: Arc, - pub gateway_failures: Arc, - pub discord_test_failures: Arc, - pub start_time: Arc, - pub bot_name: String, -} - -impl HealthState { - pub fn new(bot_name: String) -> Self { - let start = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - Self { - last_price_update: Arc::new(AtomicU64::new(0)), - last_db_write: Arc::new(AtomicU64::new(0)), - last_discord_update: Arc::new(AtomicU64::new(0)), - last_discord_test: Arc::new(AtomicU64::new(0)), - consecutive_failures: Arc::new(AtomicU64::new(0)), - gateway_failures: Arc::new(AtomicU64::new(0)), - discord_test_failures: Arc::new(AtomicU64::new(0)), - start_time: Arc::new(AtomicU64::new(start)), - bot_name, - } - } - - pub fn update_price_timestamp(&self) { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.last_price_update.store(now, Ordering::Relaxed); - } - - pub fn update_db_timestamp(&self) { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.last_db_write.store(now, Ordering::Relaxed); - } - - pub fn update_discord_timestamp(&self) { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.last_discord_update.store(now, Ordering::Relaxed); - } - - pub fn update_discord_test_timestamp(&self) { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.last_discord_test.store(now, Ordering::Relaxed); - } - - pub fn increment_failures(&self) { - self.consecutive_failures.fetch_add(1, Ordering::Relaxed); - } - - pub fn reset_failures(&self) { - self.consecutive_failures.store(0, Ordering::Relaxed); - } - - pub fn increment_gateway_failures(&self) { - self.gateway_failures.fetch_add(1, Ordering::Relaxed); - } - - pub fn reset_gateway_failures(&self) { - self.gateway_failures.store(0, Ordering::Relaxed); - } - - pub fn increment_discord_test_failures(&self) { - self.discord_test_failures.fetch_add(1, Ordering::Relaxed); - } - - pub fn reset_discord_test_failures(&self) { - self.discord_test_failures.store(0, Ordering::Relaxed); - } - - pub fn get_uptime_seconds(&self) -> u64 { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let start = self.start_time.load(Ordering::Relaxed); - now.saturating_sub(start) - } - - pub fn is_healthy(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let _start_time = self.start_time.load(Ordering::Relaxed); - let last_price = self.last_price_update.load(Ordering::Relaxed); - let last_db = self.last_db_write.load(Ordering::Relaxed); - let last_discord = self.last_discord_update.load(Ordering::Relaxed); - let last_discord_test = self.last_discord_test.load(Ordering::Relaxed); - let failures = self.consecutive_failures.load(Ordering::Relaxed); - let gateway_failures = self.gateway_failures.load(Ordering::Relaxed); - let discord_test_failures = self.discord_test_failures.load(Ordering::Relaxed); - - // Consider unhealthy if: - // - No price update in last 5 minutes - // - No database write in last 5 minutes - // - No Discord update in last 3 minutes (more aggressive for gateway issues) - // - No successful Discord connectivity test in last 10 minutes - // - More than 3 consecutive failures - // - More than 5 gateway failures (indicates broken Discord connection) - // - More than 3 Discord test failures (indicates connection issues) - // Treat 0 (never updated) as using start_time for staleness check - let price_stale = last_price > 0 && now.saturating_sub(last_price) > 300; - let db_stale = last_db > 0 && now.saturating_sub(last_db) > 300; - let discord_stale = last_discord > 0 && now.saturating_sub(last_discord) > 180; - let discord_test_stale = - last_discord_test > 0 && now.saturating_sub(last_discord_test) > 600; - let too_many_failures = failures > 3; - let gateway_broken = gateway_failures > 5; - let discord_test_broken = discord_test_failures > 3; - - !price_stale - && !db_stale - && !discord_stale - && !discord_test_stale - && !too_many_failures - && !gateway_broken - && !discord_test_broken - } - - pub fn to_json(&self) -> serde_json::Value { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let last_price = self.last_price_update.load(Ordering::Relaxed); - let last_db = self.last_db_write.load(Ordering::Relaxed); - let last_discord = self.last_discord_update.load(Ordering::Relaxed); - let last_discord_test = self.last_discord_test.load(Ordering::Relaxed); - let failures = self.consecutive_failures.load(Ordering::Relaxed); - let gateway_failures = self.gateway_failures.load(Ordering::Relaxed); - let discord_test_failures = self.discord_test_failures.load(Ordering::Relaxed); - let uptime = self.get_uptime_seconds(); - - json!({ - "bot_name": self.bot_name, - "healthy": self.is_healthy(), - "uptime_seconds": uptime, - "timestamp": now, - "last_price_update": last_price, - "last_db_write": last_db, - "last_discord_update": last_discord, - "last_discord_test": last_discord_test, - "consecutive_failures": failures, - "gateway_failures": gateway_failures, - "discord_test_failures": discord_test_failures, - "seconds_since_price_update": now.saturating_sub(last_price), - "seconds_since_db_write": now.saturating_sub(last_db), - "seconds_since_discord_update": now.saturating_sub(last_discord), - "seconds_since_discord_test": now.saturating_sub(last_discord_test) - }) - } -} - -/// Aggregates health status from all bots in the container -/// Returns healthy if at least one bot is functioning -#[derive(Debug, Clone)] -pub struct HealthAggregator { - bots: Arc>>>, -} - -impl HealthAggregator { - pub fn new() -> Self { - Self { - bots: Arc::new(std::sync::Mutex::new(Vec::new())), - } - } - - pub fn add_bot(&self, health: Arc) { - if let Ok(mut bots) = self.bots.lock() { - bots.push(health); - } - } - - pub fn is_healthy(&self) -> bool { - if let Ok(bots) = self.bots.lock() { - if bots.is_empty() { - return true; - } - return bots.iter().any(|b| b.is_healthy()); - } - false - } - - pub fn is_all_healthy(&self) -> bool { - if let Ok(bots) = self.bots.lock() { - if bots.is_empty() { - return true; - } - return bots.iter().all(|b| b.is_healthy()); - } - false - } - - pub fn to_json(&self) -> serde_json::Value { - let bots = match self.bots.lock() { - Ok(bots) => bots, - Err(_) => return json!({"error": "lock poisoned"}), - }; - let bots_json: Vec = bots.iter().map(|b| b.to_json()).collect(); - - let any_healthy = bots.iter().any(|b| b.is_healthy()); - - json!({ - "healthy": any_healthy, - "total_bots": bots.len(), - "healthy_bots": bots.iter().filter(|b| b.is_healthy()).count(), - "bots": bots_json - }) - } -} - -impl Default for HealthAggregator { - fn default() -> Self { - Self::new() - } -} diff --git a/src/health_server.rs b/src/health_server.rs deleted file mode 100644 index cd049fd..0000000 --- a/src/health_server.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::health::HealthAggregator; -use axum::{extract::State, http::StatusCode, response::Json, routing::get, Router}; -use std::sync::Arc; -use tokio::net::TcpListener; -use tracing::{error, info}; - -pub type SharedHealth = Arc; - -pub async fn start_health_server( - health: SharedHealth, - port: u16, -) -> Result<(), Box> { - let app = Router::new() - .route("/health", get(health_check)) - .route("/health/all", get(health_check_all)) - .route("/", get(health_check)) - .route("/test-discord", get(test_discord_connectivity)) - .with_state(health); - - let addr = format!("127.0.0.1:{}", port); - - let listener = TcpListener::bind(&addr).await.map_err(|e| { - error!("Failed to bind health server to {}: {}", addr, e); - e - })?; - - info!("Health check server listening on {}", addr); - axum::serve(listener, app).await?; - - Ok(()) -} - -async fn health_check( - State(health): State, -) -> Result, StatusCode> { - let is_healthy = health.is_healthy(); - - let response = serde_json::json!({ - "healthy": is_healthy - }); - - if is_healthy { - Ok(Json(response)) - } else { - Err(StatusCode::SERVICE_UNAVAILABLE) - } -} - -async fn health_check_all( - State(health): State, -) -> Result, (StatusCode, Json)> { - let is_all_healthy = health.is_all_healthy(); - let status = health.to_json(); - - if is_all_healthy { - Ok(Json(status)) - } else { - Err((StatusCode::SERVICE_UNAVAILABLE, Json(status))) - } -} - -async fn test_discord_connectivity( - State(_health): State, -) -> Result, StatusCode> { - use reqwest::Client; - use std::time::Duration; - - let client = Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - let test_result = match client - .get("https://discord.com/api/v10/gateway") - .header("User-Agent", "Discord-Bot-Health-Check/1.0") - .send() - .await - { - Ok(response) => response.status().is_success(), - Err(_) => false, - }; - - let response = serde_json::json!({ - "discord_reachable": test_result, - "timestamp": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() - }); - - if test_result { - Ok(Json(response)) - } else { - Err(StatusCode::SERVICE_UNAVAILABLE) - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 20c238a..0000000 --- a/src/main.rs +++ /dev/null @@ -1,176 +0,0 @@ -mod bot; -mod charting; -mod config; -mod database; -#[cfg(test)] -mod database_tests; -mod db_cleanup; -mod discord_api; -mod errors; -mod health; -mod health_server; -mod price_service; -mod shanghai_price_service; -mod utils; - -use bot::start_bot; -use config::BotConfig; -use database::PriceDatabase; -use db_cleanup::DatabaseCleanup; -use errors::BotResult; -use health::{HealthAggregator, HealthState}; -use health_server::start_health_server; - -use dotenv::dotenv; -use std::sync::Arc; -use std::time::Duration; -use tokio::time::sleep; -use tracing::{error, info, warn}; - -const RECONNECT_DELAY_SECONDS: u64 = 30; - -#[tokio::main] -async fn main() -> BotResult<()> { - // Initialize logging - tracing_subscriber::fmt() - .with_env_filter("info,discord_bot=debug,discord_bot::database=info") - .init(); - - info!("πŸš€ Starting RustyMcPriceface Unified Container..."); - dotenv().ok(); - - // Initialize shared database - info!("πŸ“¦ Initializing shared database..."); - let db = match PriceDatabase::new(config::DATABASE_PATH) { - Ok(db) => Arc::new(db), - Err(e) => { - error!("Failed to initialize database: {}", e); - return Err(e); - } - }; - - // Start Database Cleanup Service - info!("🧹 Starting Database Cleanup Service..."); - { - let db_clone = db.clone(); - tokio::spawn(async move { - let cleanup = DatabaseCleanup::new(db_clone); - if let Err(e) = cleanup.run().await { - error!("Cleanup service crashed: {}", e); - } - }); - } - - // Start Price Service - info!("πŸ’Ή Starting Price Fetching Service..."); - { - let db_clone = db.clone(); - tokio::spawn(async move { - if let Err(e) = price_service::run(db_clone).await { - error!("Price service crashed: {}", e); - } - }); - } - - // Start Shanghai Silver Price Service - info!("🏭 Starting Shanghai Silver Price Service..."); - { - let db_clone = db.clone(); - tokio::spawn(async move { - if let Err(e) = shanghai_price_service::run(db_clone).await { - error!("Shanghai price service crashed: {}", e); - } - }); - } - - // Load all bot instances - let instances = BotConfig::load_bot_instances(); - if instances.is_empty() { - warn!("⚠️ No bot configurations found! Set DISCORD_TOKEN or DISCORD_TOKEN_[TICKER]"); - } else { - info!("πŸ€– Found {} bot configurations", instances.len()); - } - - // Global configuration for update interval - let global_config = BotConfig::from_env()?; - - // Create health aggregator for all bots - let health_aggregator = Arc::new(HealthAggregator::new()); - - // Spawn a task for each bot - let mut handles = vec![]; - - for (ticker, token) in instances { - let db_clone = db.clone(); - let health_agg_clone = health_aggregator.clone(); - let mut bot_config = global_config.clone(); - bot_config.crypto_name = ticker.clone(); - bot_config.discord_token = token.clone(); - - // Create health state for this bot and register with aggregator - let health = Arc::new(HealthState::new(ticker.clone())); - let health_clone = health.clone(); - - // Add to aggregator - health_agg_clone.add_bot(health); - - info!("πŸš€ Spawning bot for {}...", ticker); - - let handle = tokio::spawn(async move { - loop { - // Determine appropriate emoji for logs - let emoji = utils::get_crypto_emoji(&ticker); - info!("{} Starting {} bot...", emoji, ticker); - - match start_bot( - bot_config.clone(), - db_clone.clone(), - health_clone.clone(), - health_agg_clone.clone(), - ) - .await - { - Ok(_) => { - error!("{} {} bot exited unexpectedly", emoji, ticker); - } - Err(e) => { - error!("{} {} bot crashed: {}", emoji, ticker, e); - } - } - - error!( - "{} Restarting {} bot in {} seconds...", - emoji, ticker, RECONNECT_DELAY_SECONDS - ); - sleep(Duration::from_secs(RECONNECT_DELAY_SECONDS)).await; - } - }); - handles.push(handle); - } - - // Start health check server - info!("πŸ₯ Starting health check server..."); - let health_for_server = health_aggregator.clone(); - tokio::spawn(async move { - if let Err(e) = start_health_server(health_for_server, 8080).await { - error!("❌ Health server failed: {}", e); - panic!("Health server must start successfully for container health checks"); - } - }); - - // Give health server time to start - sleep(Duration::from_secs(1)).await; - - // Keep the main process alive - if !handles.is_empty() { - info!("βœ… All bots spawned. Main process entering monitor loop."); - // Wait for all handles (they shouldn't return unless panicked/cancelled) - for handle in handles { - let _ = handle.await; - } - } else { - warn!("⚠️ No bots to run. Exiting."); - } - - Ok(()) -} diff --git a/src/price_service.rs b/src/price_service.rs deleted file mode 100644 index 44fd17c..0000000 --- a/src/price_service.rs +++ /dev/null @@ -1,578 +0,0 @@ -use reqwest; -use serde_json::Value; -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use std::time::Duration; -use tokio::time::sleep; -use tracing::{error, info, warn}; - -const HERMES_API_URL: &str = "https://hermes.pyth.network/api/latest_price_feeds"; - -#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] -pub struct PriceData { - pub price: f64, - pub timestamp: u64, - // Optional fields for detailed data (e.g., Shanghai Premium) - pub premium: Option, - pub premium_percent: Option, - pub source: Option, - #[serde(default)] - pub is_fallback: bool, -} - -#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] -pub struct HistoryData { - pub date: String, // "YYYY-MM-DD" - pub shanghai: f64, - pub western: f64, - pub premium: f64, - #[serde(rename = "premiumPercent")] - pub premium_percent: f64, -} - -#[derive(serde::Serialize, serde::Deserialize)] -pub struct PricesFile { - pub prices: HashMap, - pub timestamp: u64, -} - -fn get_feed_ids() -> HashMap { - let mut feeds = HashMap::new(); - - // Read from environment variable CRYPTO_FEEDS - // Format: BTC:0x...,ETH:0x...,SOL:0x...,WIF:0x... - let feeds_str = std::env::var("CRYPTO_FEEDS").unwrap_or_else(|_| { - // Default feeds if not specified - "BTC:0xe62df6c8b4a85fe1a67db44dc12de5db330f7ac66b72dc658afedf0f4a415b43,ETH:0xff61491a931112ddf1bd8147cd1b641375f79f5825126d665480874634fd0ace,SOL:0xef0d8b6fda2ceba41da15d4095d1da392a0d2f8ed0c6c7bc0f4cfac8c280b56d".to_string() - }); - - for pair in feeds_str.split(',') { - if let Some((name, feed_id)) = pair.split_once(':') { - feeds.insert(name.trim().to_string(), feed_id.trim().to_string()); - } - } - - feeds -} - -async fn get_crypto_price(feed_id: &str) -> Result> { - let url = format!("{}?ids[]={}", HERMES_API_URL, feed_id); - const MAX_RETRIES: u32 = 3; - - for attempt in 1..=MAX_RETRIES { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build()?; - - match client - .get(&url) - .header("User-Agent", "Crypto-Price-Service/1.0") - .send() - .await - { - Ok(response) => { - if !response.status().is_success() { - error!( - "HTTP request failed (attempt {}): {}", - attempt, - response.status() - ); - if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) - .await; - continue; - } - return Err(format!("HTTP request failed: {}", response.status()).into()); - } - - match response.json::().await { - Ok(json) => { - // Parse the price from the JSON array format - if let Some(feeds_array) = json.as_array() { - if let Some(first_feed) = feeds_array.first() { - if let Some(price_data) = first_feed.get("price") { - if let Some(price_str) = - price_data.get("price").and_then(|p| p.as_str()) - { - if let Ok(price) = price_str.parse::() { - let expo = price_data - .get("expo") - .and_then(|e| e.as_i64()) - .unwrap_or(0); - let real_price = price as f64 * 10f64.powi(expo as i32); - - if real_price <= 0.0 - || real_price.is_nan() - || real_price.is_infinite() - { - error!( - "Invalid price value from Pyth API: {}", - real_price - ); - return Err("Invalid price value from API".into()); - } - - return Ok(real_price); - } - } - } - } - } - return Err("Failed to parse price data".into()); - } - Err(e) => { - error!("JSON parsing failed (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis( - 1000 * attempt as u64, - )) - .await; - continue; - } - return Err(e.into()); - } - } - } - Err(e) => { - error!("Network request failed (attempt {}): {}", attempt, e); - if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) - .await; - continue; - } - return Err(e.into()); - } - } - } - - Err("Unexpected error in price fetch retry loop".into()) -} - -pub async fn fetch_shanghai_history( - range: &str, - symbol: Option<&str>, -) -> Result, Box> { - let symbol_param = symbol.unwrap_or(""); - let url = format!( - "https://metalcharts.org/api/shanghai/history?range={}{}", - range, - if symbol_param.is_empty() { - "".to_string() - } else { - format!("&symbol={}", symbol_param) - } - ); - - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build()?; - - let response = client - .get(&url) - .header("Origin", "https://metalcharts.org") - .header("Referer", "https://metalcharts.org/") - .header( - "User-Agent", - "Mozilla/5.0 (compatible; RustyMcPriceface/1.0)", - ) - .send() - .await?; - - if !response.status().is_success() { - return Err(format!("Shanghai History API request failed: {}", response.status()).into()); - } - - let json: Value = response.json().await?; - - // The API returns { data: [...], symbol: "..." } - if let Some(data_array) = json.get("data") { - let history: Vec = serde_json::from_value(data_array.clone())?; - return Ok(history); - } - - Err("Failed to parse Shanghai History JSON structure".into()) -} - -async fn fetch_yahoo_price( - ticker: &str, -) -> Result> { - let url = format!( - "https://query1.finance.yahoo.com/v8/finance/chart/{}?interval=1d&range=1d", - ticker - ); - const MAX_RETRIES: u32 = 3; - - for attempt in 1..=MAX_RETRIES { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build()?; - - match client - .get(&url) - .header( - "User-Agent", - "Mozilla/5.0 (compatible; RustyMcPriceface/1.0)", - ) - .send() - .await - { - Ok(response) => { - if !response.status().is_success() { - error!( - "Yahoo API request failed for {} (attempt {}): {}", - ticker, - attempt, - response.status() - ); - if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) - .await; - continue; - } - return Err( - format!("Yahoo API HTTP request failed: {}", response.status()).into(), - ); - } - - match response.json::().await { - Ok(json) => { - // Navigate to chart.result[0].meta.regularMarketPrice - if let Some(result) = json - .get("chart") - .and_then(|c| c.get("result")) - .and_then(|r| r.get(0)) - { - if let Some(meta) = result.get("meta") { - let price = meta - .get("regularMarketPrice") - .and_then(|p| p.as_f64()) - .ok_or("Missing regularMarketPrice")?; - - if price <= 0.0 || price.is_nan() || price.is_infinite() { - error!("Invalid Yahoo price value: {}", price); - return Err("Invalid price value from Yahoo API".into()); - } - - // Parse timestamp if available, else use current - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - - return Ok(PriceData { - price, - timestamp, - premium: None, - premium_percent: None, - source: Some("yahoo".to_string()), - is_fallback: false, - }); - } - } - return Err("Failed to parse Yahoo JSON structure".into()); - } - Err(e) => { - error!("Yahoo JSON parsing failed: {}", e); - return Err(e.into()); - } - } - } - Err(e) => { - error!("Yahoo Network request failed: {}", e); - if attempt < MAX_RETRIES { - tokio::time::sleep(std::time::Duration::from_millis(1000 * attempt as u64)) - .await; - continue; - } - return Err(e.into()); - } - } - } - Err("Max retries exceeded for Yahoo API".into()) -} - -async fn fetch_all_prices() -> Result> { - let feeds = get_feed_ids(); - let mut prices = HashMap::new(); - - for (crypto, feed_id) in &feeds { - match get_crypto_price(&feed_id).await { - Ok(price) => { - prices.insert( - crypto.clone(), - PriceData { - price, - timestamp: 0, - premium: None, - premium_percent: None, - source: None, - is_fallback: false, - }, - ); - info!("Fetched {} price: ${:.6}", crypto, price); - } - Err(e) => { - error!("Failed to fetch {} price: {}", crypto, e); - warn!("⚠️ Using FALLBACK price for {} - API may be down!", crypto); - let default_price = match crypto.as_str() { - "BTC" => 100000.0, - "ETH" => 3500.0, - "SOL" => 150.0, - "WIF" => 2.5, - _ => 1.0, - }; - prices.insert( - crypto.clone(), - PriceData { - price: default_price, - timestamp: 0, - premium: None, - premium_percent: None, - source: None, - is_fallback: true, - }, - ); - } - } - } - - // Shanghai Silver API disabled - using regular SILVER from Pyth network instead - // if feeds.contains_key("SHANGHAI") { - // match fetch_shanghai_price().await { - // Ok(data) => { - // prices.insert("SHANGHAI".to_string(), data.clone()); - // println!("βœ… Fetched SHANGHAI price: ${:.2} (Premium: ${:.2})", data.price, data.premium.unwrap_or(0.0)); - // } - // Err(e) => { - // println!("❌ Failed to fetch SHANGHAI price: {}", e); - // } - // } - // } - - // Fetch DXY via Yahoo if configured - if feeds.contains_key("DXY") { - match fetch_yahoo_price("DX-Y.NYB").await { - Ok(data) => { - prices.insert("DXY".to_string(), data.clone()); - info!("Fetched DXY price: ${:.2}", data.price); - } - Err(e) => { - error!("Failed to fetch DXY price: {}", e); - } - } - } - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| format!("System time error: {}", e))? - .as_secs(); - - // Update timestamps for all prices - for price_data in prices.values_mut() { - price_data.timestamp = timestamp; - } - - Ok(PricesFile { prices, timestamp }) -} - -async fn write_prices_to_file( - prices: &PricesFile, - file_path: &str, -) -> Result<(), Box> { - let json_string = serde_json::to_string_pretty(prices)?; - fs::write(file_path, json_string)?; - info!("Wrote prices to {}", file_path); - Ok(()) -} - -use crate::database::PriceDatabase; -use std::sync::Arc; - -const GOLDSILVER_AI_URL: &str = "https://goldsilver.ai/metal-prices/shanghai-silver-price"; - -pub async fn fetch_shanghai_silver_price( -) -> Result> { - // Use goldsilver.ai for Shanghai Spot price - let (shanghai_spot, western_spot) = fetch_goldsilver_ai_prices().await?; - - let premium = shanghai_spot - western_spot; - let premium_percent = if western_spot > 0.0 { - (premium / western_spot) * 100.0 - } else { - 0.0 - }; - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - - info!( - "βœ… Shanghai Silver Spot: ${:.2} | Western Spot: ${:.2} | Premium: ${:.2} (+{:.2}%)", - shanghai_spot, western_spot, premium, premium_percent - ); - - Ok(PriceData { - price: shanghai_spot, - timestamp, - premium: Some(premium), - premium_percent: Some(premium_percent), - source: Some("shanghaisilver_spot".to_string()), - is_fallback: false, - }) -} - -async fn fetch_goldsilver_ai_prices() -> Result<(f64, f64), Box> -{ - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(15)) - .build()?; - - let response = client - .get(GOLDSILVER_AI_URL) - .header("User-Agent", "RustyMcPriceface/1.0 (crypto price bot)") - .header( - "Accept", - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - ) - .header("Accept-Language", "en-US,en;q=0.9") - .send() - .await?; - - let text = response.text().await?; - - extract_goldsilver_prices(&text) -} - -fn extract_goldsilver_prices( - html: &str, -) -> Result<(f64, f64), Box> { - // Debug: print a snippet of the HTML around "Shanghai Spot" - if let Some(pos) = html.find("Shanghai Spot") { - let start = pos.saturating_sub(50); - let end = (pos + 100).min(html.len()); - let snippet = &html[start..end]; - info!("HTML snippet around Shanghai Spot: {:?}", snippet); - } - - // More flexible: find the first number after "Shanghai Spot" - let shanghai_spot = extract_first_number_after(html, "Shanghai Spot") - .ok_or("Failed to extract Shanghai Spot price")?; - - // More flexible: find the first number after "Western Spot" - let western_spot = extract_first_number_after(html, "Western Spot") - .ok_or("Failed to extract Western Spot price")?; - - info!( - "Extracted Shanghai Spot: ${:.2}, Western Spot: ${:.2}", - shanghai_spot, western_spot - ); - - Ok((shanghai_spot, western_spot)) -} - -fn extract_first_number_after(html: &str, prefix: &str) -> Option { - // Find prefix and extract number after it - if let Some(pos) = html.find(prefix) { - let after_prefix = &html[pos + prefix.len()..]; - - // Use safer regex pattern to avoid ReDoS - requires at least one digit - let re = regex::Regex::new(r"-?\d+(?:\.\d+)?").ok()?; - if let Some(m) = re.find(after_prefix) { - let num_str = m.as_str(); - // Skip if it's just a decimal point or empty - if num_str.is_empty() || num_str == "." { - return None; - } - return num_str.parse::().ok(); - } - } - - None -} - -pub async fn run( - database: Arc, -) -> Result<(), Box> { - // Get update interval from environment - let update_interval = std::env::var("UPDATE_INTERVAL_SECONDS") - .unwrap_or_else(|_| "12".to_string()) - .parse::() - .unwrap_or(12); - - // Create shared directory if it doesn't exist - let shared_dir = "shared"; - if !Path::new(shared_dir).exists() { - if let Err(e) = fs::create_dir(shared_dir) { - error!("Failed to create shared directory: {}", e); - return Err(e.into()); - } - info!("πŸ“ Created shared directory"); - } - - let file_path = format!("{}/prices.json", shared_dir); - - info!("πŸš€ Starting Price Service Task..."); - info!("πŸ“Š Update interval: {} seconds", update_interval); - info!("πŸ“ Prices file: {}", file_path); - - // Print configured cryptos - let feeds = get_feed_ids(); - info!( - "πŸͺ™ Tracking cryptos: {}", - feeds.keys().cloned().collect::>().join(", ") - ); - - let mut consecutive_failures = 0; - const MAX_CONSECUTIVE_FAILURES: u32 = 5; - - loop { - let loop_start = std::time::Instant::now(); - - match fetch_all_prices().await { - Ok(prices) => { - consecutive_failures = 0; // Reset failure counter on success - - // Store in JSON file (for backward compatibility) - if let Err(e) = write_prices_to_file(&prices, &file_path).await { - error!("Failed to write prices to JSON: {}", e); - } - - // Store in SQLite database using shared pool - for (crypto, price_data) in &prices.prices { - if let Err(e) = database.save_price(crypto, price_data.price) { - error!("Failed to store {} price in database: {}", crypto, e); - } - } - } - Err(e) => { - consecutive_failures += 1; - error!( - "❌ Failed to fetch prices (failure {}/{}): {}", - consecutive_failures, MAX_CONSECUTIVE_FAILURES, e - ); - - if consecutive_failures >= MAX_CONSECUTIVE_FAILURES { - warn!("⚠️ Too many consecutive failures. Entering recovery mode for 60 seconds..."); - sleep(Duration::from_secs(60)).await; - consecutive_failures = 0; // Reset after recovery delay - } - } - } - - // Calculate how long the update took and adjust sleep time - let loop_duration = loop_start.elapsed(); - let target_interval = Duration::from_secs(update_interval); - - if loop_duration < target_interval { - let sleep_time = target_interval - loop_duration; - sleep(sleep_time).await; - } else { - warn!( - "⚠️ Update took longer than interval: {:?} > {:?}", - loop_duration, target_interval - ); - // Still sleep for a minimum time to prevent tight loops - sleep(Duration::from_secs(1)).await; - } - } -} diff --git a/src/shanghai_price_service.rs b/src/shanghai_price_service.rs deleted file mode 100644 index c9425b3..0000000 --- a/src/shanghai_price_service.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::database::PriceDatabase; -use crate::price_service::{fetch_shanghai_silver_price, PriceData, PricesFile}; -use chrono::Utc; -use std::collections::HashMap; -use std::fs; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; -use tokio::time::sleep; -use tracing::{error, info, warn}; - -const CRYPTO_NAME: &str = "SHANGHAISILVER"; -const FILE_PATH: &str = "shared/prices.json"; - -fn get_update_interval() -> u64 { - std::env::var("UPDATE_INTERVAL_SECONDS") - .unwrap_or_else(|_| "1800".to_string()) - .parse::() - .unwrap_or(1800) -} - -/// Check if Shanghai Gold Exchange is currently in trading hours -/// SGE Trading Hours in UTC: -/// - Day Session: 01:00 - 07:30 UTC -/// - Night Session: 12:00 - 18:30 UTC -fn is_sge_market_open() -> bool { - let now = Utc::now(); - let timestamp = now.timestamp(); - let hour = ((timestamp / 3600) % 24) as u32; - let minute = ((timestamp / 60) % 60) as u32; - let time = hour * 60 + minute; // minutes since midnight UTC - - // Day session: 01:00-07:30 UTC (60-450) - let day_session = time >= 60 && time <= 450; - - // Night session: 12:00-18:30 UTC (720-1110) - let night_session = time >= 720 && time <= 1110; - - day_session || night_session -} - -pub async fn run( - database: Arc, -) -> Result<(), Box> { - let update_interval = get_update_interval(); - info!("πŸš€ Starting Shanghai Silver Price Service..."); - info!( - "πŸ“Š Update interval: {} seconds ({} minutes)", - update_interval, - update_interval / 60 - ); - info!("πŸ• SGE Trading Hours - Day: 01:00-07:30 UTC, Night: 12:00-18:30 UTC"); - - let shared_dir = "shared"; - if !Path::new(shared_dir).exists() { - fs::create_dir(shared_dir)?; - info!("πŸ“ Created shared directory"); - } - - loop { - let loop_start = std::time::Instant::now(); - - if is_sge_market_open() { - info!("🟒 SGE Market is OPEN - fetching price..."); - match fetch_shanghai_silver_price().await { - Ok(price_data) => { - info!( - "βœ… Shanghai Silver: ${:.2} (Premium: ${:.2}, {:.2}%)", - price_data.price, - price_data.premium.unwrap_or(0.0), - price_data.premium_percent.unwrap_or(0.0) - ); - - if let Err(e) = database.save_price(CRYPTO_NAME, price_data.price) { - error!("❌ Failed to save Shanghai Silver price to database: {}", e); - } else { - info!("πŸ’Ύ Saved Shanghai Silver price to database"); - } - - if let Err(e) = update_prices_json(&price_data) { - error!("❌ Failed to update prices.json: {}", e); - } else { - info!("πŸ“ Updated prices.json with Shanghai Silver price"); - } - } - Err(e) => { - error!("❌ Failed to fetch Shanghai Silver price: {}", e); - } - } - } else { - let now = Utc::now(); - info!( - "πŸ”΄ SGE Market is CLOSED - skipping fetch at {} UTC", - now.format("%H:%M") - ); - info!("πŸ’Ύ Using last known price from database"); - } - - let loop_duration = loop_start.elapsed(); - let target_interval = Duration::from_secs(update_interval); - - if loop_duration < target_interval { - let sleep_time = target_interval - loop_duration; - info!("😴 Sleeping for {:?}", sleep_time); - sleep(sleep_time).await; - } else { - warn!( - "⚠️ Update took longer than interval: {:?} > {:?}", - loop_duration, target_interval - ); - sleep(Duration::from_secs(1)).await; - } - } -} - -fn update_prices_json( - price_data: &PriceData, -) -> Result<(), Box> { - let mut prices = HashMap::new(); - - if Path::new(FILE_PATH).exists() { - let content = fs::read_to_string(FILE_PATH)?; - if let Ok(existing) = serde_json::from_str::(&content) { - prices = existing.prices; - } - } - - prices.insert(CRYPTO_NAME.to_string(), price_data.clone()); - - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs(); - - let prices_file = PricesFile { prices, timestamp }; - - let json_string = serde_json::to_string_pretty(&prices_file)?; - fs::write(FILE_PATH, json_string)?; - - Ok(()) -} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 272b929..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::errors::{BotError, BotResult}; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Validate a cryptocurrency name -pub fn validate_crypto_name(name: &str) -> BotResult<()> { - if name.is_empty() { - return Err(BotError::InvalidInput("Crypto name cannot be empty".into())); - } - - if name.len() > 10 { - return Err(BotError::InvalidInput( - "Crypto name too long (max 10 chars)".into(), - )); - } - - if !name.chars().all(|c| c.is_alphanumeric()) { - return Err(BotError::InvalidInput( - "Crypto name must be alphanumeric".into(), - )); - } - - Ok(()) -} - -/// Get current Unix timestamp safely -pub fn get_current_timestamp() -> BotResult { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .map_err(|e| BotError::SystemTime(e.to_string())) -} - -/// Format price based on magnitude -pub fn format_price(price: f64) -> String { - if price >= 1000.0 { - // No decimals for prices >= $1000 - format!("${:.0}", price) - } else if price >= 100.0 { - // 2 decimal places for prices >= $100 - format!("${:.2}", price) - } else if price >= 1.0 { - // 3 decimal places for prices >= $1 - format!("${:.3}", price) - } else { - // 4 decimal places for prices < $1 - format!("${:.4}", price) - } -} - -/// Get emoji for cryptocurrency -pub fn get_crypto_emoji(crypto: &str) -> &'static str { - match crypto { - "BTC" => "πŸͺ™", - "ETH" => "πŸͺ™", - "SOL" => "πŸ“Š", - "WIF" => "πŸ•", - "DOGE" => "πŸ•", - "MATIC" => "πŸ”·", - "AVAX" => "❄️", - "ADA" => "πŸ”·", - "DOT" => "πŸ”΄", - "LINK" => "πŸ”—", - "UNI" => "πŸ¦„", - "ATOM" => "βš›οΈ", - "LTC" => "Ł", - "BCH" => "β‚Ώ", - "XRP" => "πŸ’Ž", - "TRX" => "⚑", - "EURO" => "πŸ’Ά", - "SHANGHAI" => "πŸ‡¨πŸ‡³", - "SHANGHAISILVER" => "πŸ‡¨πŸ‡³", - "DXY" => "πŸ‡ΊπŸ‡Έ", - _ => "πŸͺ™", - } -} - -/// Validate price value -pub fn validate_price(price: f64) -> BotResult<()> { - if price.is_nan() || price.is_infinite() { - return Err(BotError::InvalidInput("Invalid price value".into())); - } - - if price < 0.0 { - return Err(BotError::InvalidInput("Price cannot be negative".into())); - } - - Ok(()) -} - -/// Calculate percentage change safely -pub fn calculate_percentage_change(current: f64, previous: f64) -> BotResult { - validate_price(current)?; - validate_price(previous)?; - - if previous == 0.0 { - return Err(BotError::InvalidInput( - "Previous price cannot be zero".into(), - )); - } - - Ok(((current - previous) / previous) * 100.0) -} - -/// Get arrow emoji for price change -pub fn get_change_arrow(change_percent: f64) -> &'static str { - if change_percent > 0.0 { - "πŸ“ˆ" - } else if change_percent < 0.0 { - "πŸ“‰" - } else { - "➑️" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_validate_crypto_name_valid() { - assert!(validate_crypto_name("BTC").is_ok()); - assert!(validate_crypto_name("SOL").is_ok()); - assert!(validate_crypto_name("ETH").is_ok()); - assert!(validate_crypto_name("1234567890").is_ok()); // 10 chars - } - - #[test] - fn test_validate_crypto_name_invalid() { - assert!(validate_crypto_name("").is_err()); - assert!(validate_crypto_name("12345678901").is_err()); // 11 chars - assert!(validate_crypto_name("BTC!").is_err()); - assert!(validate_crypto_name("BTC-USDT").is_err()); - } - - #[test] - fn test_validate_price_valid() { - assert!(validate_price(100.0).is_ok()); - assert!(validate_price(0.01).is_ok()); - assert!(validate_price(1000000.0).is_ok()); - } - - #[test] - fn test_validate_price_invalid() { - assert!(validate_price(-10.0).is_err()); - assert!(validate_price(f64::NAN).is_err()); - assert!(validate_price(f64::INFINITY).is_err()); - } - - #[test] - fn test_format_price() { - assert_eq!(format_price(50000.0), "$50000"); - assert_eq!(format_price(500.0), "$500.00"); - assert_eq!(format_price(5.0), "$5.000"); - assert_eq!(format_price(0.5), "$0.5000"); - } - - #[test] - fn test_calculate_percentage_change() { - assert!((calculate_percentage_change(110.0, 100.0).unwrap() - 10.0).abs() < 0.01); - assert!((calculate_percentage_change(90.0, 100.0).unwrap() - (-10.0)).abs() < 0.01); - assert!(calculate_percentage_change(100.0, 0.0).is_err()); // divide by zero - } - - #[test] - fn test_get_change_arrow() { - assert_eq!(get_change_arrow(5.0), "πŸ“ˆ"); - assert_eq!(get_change_arrow(-5.0), "πŸ“‰"); - assert_eq!(get_change_arrow(0.0), "➑️"); - } - - #[test] - fn test_get_crypto_emoji() { - assert_eq!(get_crypto_emoji("BTC"), "πŸͺ™"); - assert_eq!(get_crypto_emoji("ETH"), "πŸͺ™"); - assert_eq!(get_crypto_emoji("DOGE"), "πŸ•"); - assert_eq!(get_crypto_emoji("DXY"), "πŸ‡ΊπŸ‡Έ"); - assert_eq!(get_crypto_emoji("UNKNOWN"), "πŸͺ™"); - } -}