diff --git a/Cargo.lock b/Cargo.lock index c0b81c0..ad7d1fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -164,6 +164,12 @@ version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +[[package]] +name = "bytemuck" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374d28ec25809ee0e23827c2ab573d729e293f281dfe393500e7ad618baa61c6" + [[package]] name = "byteorder" version = "1.5.0" @@ -206,6 +212,27 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "cmake" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130" +dependencies = [ + "cc", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "const-cstr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3d0b5ff30645a68f35ece8cea4556ca14ef8a1651455f789a099a0513532a6" + [[package]] name = "const-oid" version = "0.9.5" @@ -228,6 +255,42 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" +[[package]] +name = "core-graphics" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb142d41022986c1d8ff29103a1411c8a3dfad3552f87a4f8dc50d61d4f4e33" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core-text" +version = "19.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d74ada66e07c1cefa18f8abfba765b486f250de2e4a999e5727fc0dd4b4a25" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.10" @@ -383,12 +446,54 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dlib" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +dependencies = [ + "libloading", +] + [[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dwrote" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439a1c2ba5611ad3ed731280541d36d2e9c4ac5e7fb818a27b604bdc5a6aa65b" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + [[package]] name = "either" version = "1.9.0" @@ -440,12 +545,30 @@ version = "2.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +[[package]] +name = "fallible_collections" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88c69768c0a15262df21899142bc6df9b9b823546d4b4b9a7bc2d6c448ec6fd" +dependencies = [ + "hashbrown 0.13.2", +] + [[package]] name = "fastrand" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +[[package]] +name = "fdeflate" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d329bdeac514ee06249dabc27877490f17f5d371ec693360768b838e19f3ae10" +dependencies = [ + "simd-adler32", +] + [[package]] name = "finl_unicode" version = "1.2.0" @@ -462,6 +585,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-ord" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bad48618fdb549078c333a7a8528acb57af271d0433bdecd523eb620628364e" + [[package]] name = "flume" version = "0.11.0" @@ -479,6 +608,46 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "font-kit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fe28504d371085fae9ac7a3450f0b289ab71e07c8e57baa3fb68b9e57d6ce5" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "core-foundation", + "core-graphics", + "core-text", + "dirs-next", + "dwrote", + "float-ord", + "freetype", + "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", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.0" @@ -488,6 +657,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "freetype" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee38378a9e3db1cc693b4f88d166ae375338a0ff75cb8263e1c601d51f35dc6" +dependencies = [ + "freetype-sys", + "libc", +] + +[[package]] +name = "freetype-sys" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a37d4011c0cc628dfa766fcc195454f4b068d7afdc2adfd28861191d866e731a" +dependencies = [ + "cmake", + "libc", + "pkg-config", +] + [[package]] name = "futures" version = "0.3.28" @@ -608,6 +798,16 @@ dependencies = [ "wasi", ] +[[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 = "gimli" version = "0.28.0" @@ -639,6 +839,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.14.2" @@ -817,6 +1026,21 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "image" +version = "0.24.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f3dfdbdd72063086ff443e297b61695500514b1e41095b6fb9a5ab48a70a711" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-rational", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -858,6 +1082,12 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" +[[package]] +name = "jpeg-decoder" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0000e42512c92e31c2252315bda326620a4e034105e900c98ec492fa077b3e" + [[package]] name = "js-sys" version = "0.3.64" @@ -882,6 +1112,16 @@ version = "0.2.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +[[package]] +name = "libloading" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c571b676ddfc9a8c12f1f3d3085a7b163966a8fd8098a90640953ce5f6170161" +dependencies = [ + "cfg-if", + "windows-sys", +] + [[package]] name = "libm" version = "0.2.8" @@ -915,6 +1155,19 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "lodepng" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cdccd0cf57a5d456f0656ebcff72c2e19503287e1afbf3b84382812adc0606" +dependencies = [ + "crc32fast", + "fallible_collections", + "flate2", + "libc", + "rgb", +] + [[package]] name = "log" version = "0.4.20" @@ -966,6 +1219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" dependencies = [ "adler", + "simd-adler32", ] [[package]] @@ -1037,6 +1291,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -1116,6 +1381,25 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0444332826c70dc47be74a7c6a5fc44e23a7905ad6858d4162b658320455ef93" +dependencies = [ + "rustc_version", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -1170,6 +1454,65 @@ version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" +[[package]] +name = "plotters" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c224ba00d7cadd4d5c660deaf2098e5e80e07846537c51f9cfa4be50c1fd45" +dependencies = [ + "chrono", + "font-kit", + "image", + "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e76628b4d3a7581389a35d5b6e2139607ad7c75b17aed325f210aa91f4a9609" + +[[package]] +name = "plotters-bitmap" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cebbe1f70205299abc69e8b295035bb52a6a70ee35474ad10011f0a4efb8543" +dependencies = [ + "gif", + "image", + "plotters-backend", +] + +[[package]] +name = "plotters-svg" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f6d39893cca0701371e3c27294f09797214b86f1fb951b89ade8ec04e2abab" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.17.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd75bf2d8dd3702b9707cdbc56a5b9ef42cec752eb8b3bafc01234558442aa64" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "poise" version = "0.5.6" @@ -1228,6 +1571,8 @@ version = "0.1.0" dependencies = [ "chrono", "dotenvy", + "lodepng", + "plotters", "poise", "sqlx", "tokio", @@ -1275,6 +1620,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.3.5" @@ -1293,6 +1647,17 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall 0.2.16", + "thiserror", +] + [[package]] name = "regex" version = "1.10.2" @@ -1365,6 +1730,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "rgb" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20ec2d3e3fc7a92ced357df9cebd5a10b6fb2aa1ee797bf7e9ce2f17dffc8f59" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.16.20" @@ -1422,6 +1796,15 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.20" @@ -1490,6 +1873,15 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741" +[[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 = "scopeguard" version = "1.2.0" @@ -1506,6 +1898,12 @@ dependencies = [ "untrusted 0.7.1", ] +[[package]] +name = "semver" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090" + [[package]] name = "serde" version = "1.0.189" @@ -1643,6 +2041,12 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "slab" version = "0.4.9" @@ -2213,6 +2617,12 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +[[package]] +name = "ttf-parser" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375812fa44dab6df41c195cd2f7fecb488f6c09fbaafb62807488cefab642bff" + [[package]] name = "tungstenite" version = "0.17.3" @@ -2345,6 +2755,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2483,6 +2903,12 @@ version = "0.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14247bb57be4f377dfb94c72830b8ce8fc6beac03cf4bf7b9732eadd414123fc" +[[package]] +name = "weezl" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" + [[package]] name = "whoami" version = "1.4.1" @@ -2505,6 +2931,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -2596,6 +3031,27 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bbd69036d397ebbff671b1b8e4d918610c181c5a16073b96f984a38d08c386" +dependencies = [ + "const-cstr", + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "zeroize" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index a7a762d..353c38a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,5 @@ dotenvy = "0.15.7" sqlx = { version = "0.7.2", features = [ "runtime-tokio", "tls-rustls", "postgres", "uuid" ] } chrono = "0.4.31" uuid = { version = "1.5.0", features = ["v4"] } +plotters = "0.3.5" +lodepng = "3.9.1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0766013 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +#################################################################################################### +## Builder +#################################################################################################### +FROM rust:latest AS builder + +RUN update-ca-certificates + +RUN apt install pkg-config libfreetype6-dev libfontconfig1-dev + +# Create appuser +ENV USER=queery +ENV UID=10001 + +ARG SQLX_OFFLINE=true + +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + "${USER}" + + +WORKDIR /queery + +COPY ./ . + +RUN cargo build --release + +#################################################################################################### +## Final image +#################################################################################################### +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y libfontconfig1 + +# Import from builder. +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group + +WORKDIR /queery + +# Copy our build +COPY --from=builder /queery/target/release/queery /usr/bin/queery + +# Use an unprivileged user. +USER queery:queery + +CMD ["queery"] diff --git a/build.rs b/build.rs index 7609593..4a943a5 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,5 @@ // generated by `sqlx migrate build-script` fn main() { - // trigger recompilation when a new migration is added - println!("cargo:rerun-if-changed=migrations"); -} \ No newline at end of file + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/docker-compose.yml b/docker-compose.yml index 6b3f573..c7f93ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,24 @@ version: '3.8' services: + queery: + container_name: "queery" + image: queery:latest + env_file: .env + command: ["queery"] + build: + dockerfile: Dockerfile + depends_on: + - db + restart: unless-stopped db: + container_name: "queery-db" image: postgres:16 - restart: always env_file: - .env ports: - '5432:5432' volumes: - db-data:/var/lib/postgresql/data + restart: unless-stopped volumes: db-data: diff --git a/src/commands.rs b/src/commands.rs index b6c0efc..24b0e51 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,8 +1,13 @@ use poise::command; -use poise::serenity_prelude::Timestamp; +use poise::serenity_prelude::{AttachmentType, Channel, Timestamp}; +use tracing::{error, info}; +use crate::log::ChangeResolution; +use crate::plotting::create_log_graph; +use crate::time_period::TimePeriod; use crate::{Context, Error}; +/// Test connection speed. #[command(slash_command)] pub async fn ping(ctx: Context<'_>) -> Result<(), Error> { let before_timestamp = Timestamp::now().timestamp_millis(); @@ -23,3 +28,66 @@ pub async fn ping(ctx: Context<'_>) -> Result<(), Error> { Ok(()) } + +/// Fetch the logs for the current channel. +#[command(slash_command, required_permissions="MANAGE_CHANNELS")] +pub async fn logs( + ctx: Context<'_>, + #[description="How far back to search."] + time_period: TimePeriod, + #[description="The channel to fetch the logs for, defaults to the current channel."] + channel: Option, +) -> Result<(), Error> { + let channel_id = channel.map_or_else(|| ctx.channel_id(), |channel| channel.id()); + + let now_timestamp = Timestamp::now(); + let search_start_timestamp = time_period.relative_timestamp_from(now_timestamp.timestamp()); + + let logs = ctx + .data() + .fetch_logs_between(channel_id.0 as i64, search_start_timestamp, now_timestamp.timestamp()) + .await?; + + // Combign logs together to make displaying on graph easier + // Todo: This has not been fully tested yet + let logs = match time_period { + TimePeriod::Hour => logs, + TimePeriod::HalfDay => logs.change_resolution(360), + TimePeriod::Day => logs.change_resolution(720), + TimePeriod::Week => logs.change_resolution(2520), + }; + + if logs.is_empty() { + ctx.say("No logs found for this period").await?; + return Ok(()); + } + + let channel_name = channel_id + .name(ctx.cache()) + .await + .unwrap_or_else(|| "Current Channel".to_string()); + + let graph = match create_log_graph(logs, &channel_name, search_start_timestamp, now_timestamp.timestamp(), time_period) { + Ok(graph) => graph, + Err(err) => { + error!("Failed to generate log graph: {}", err); + return Err(err) + } + }; + + ctx.send(|reply| { + reply.attachment(AttachmentType::Bytes { + data: graph.into(), + filename: "graph.png".to_string(), + }) + }) + .await?; + + + // Time it took for command to be run. + let finished_timestamp = Timestamp::now().timestamp_millis() - now_timestamp.timestamp_millis(); + + info!("Command took {}ms", finished_timestamp); + + Ok(()) +} diff --git a/src/database.rs b/src/database.rs index 76c20be..30ca9b1 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,14 +1,24 @@ -use sqlx::{postgres::PgPoolOptions, Pool, Postgres, migrate::MigrateDatabase, query_as, query}; +use sqlx::migrate::MigrateDatabase; +use sqlx::postgres::PgPoolOptions; +use sqlx::{query, query_as, Pool, Postgres}; use tracing::info; use uuid::Uuid; -use crate::{Error, log::Log}; +use crate::log::Log; +use crate::Error; +/// Core data within bot, containing a +/// connection to its corresponding database pub struct App { pool: Pool, } impl App { + /// Create a postgres connection, creating database if nessessary. + /// + /// # Errors + /// This function requires that the environment variable `DATABASE_URL` + /// is set to a url to a postgres database. #[allow(clippy::cognitive_complexity)] pub async fn new() -> Result { let db_url = std::env::var("DATABASE_URL")?; @@ -19,76 +29,107 @@ impl App { info!("Database created"); } - info!("Attempting to connect to database"); + info!("Attempting to connect to database"); let pool = PgPoolOptions::new() .max_connections(5) .connect(&db_url) .await?; - info!("Connected to database"); + info!("Connected to database"); Ok(Self { pool }) } + /// Creates a new log for a channel at the given timstamp + /// + /// # Assumptions + /// This function assumes that `timestamp` is a UNIX timestamp pub async fn new_log(&self, timestamp: i64, channel_id: i64) -> Result<(), Error> { - - - query!(r#" + query!( + r#" INSERT INTO logs (id, channel_id, count, time) VALUES ($1, $2 , 1, $3) "#, Uuid::new_v4(), channel_id, timestamp - ).execute(&self.pool).await?; + ) + .execute(&self.pool) + .await?; info!("New log created"); Ok(()) } + /// Updates a given log with a new count. + /// + /// # Errors + /// This functions assumes that there is an entry with `log_id` pub async fn update_log(&self, log_id: Uuid, new_count: i32) -> Result<(), Error> { - query!(r#" + query!( + r#" UPDATE logs SET count = $1 WHERE id = $2; "#, - new_count, - log_id - ).execute(&self.pool).await?; + new_count, + log_id + ) + .execute(&self.pool) + .await?; info!("Log {log_id} updated"); Ok(()) } + /// Fetches a log for a channel at `timestamp`. + /// + /// # Assumptions + /// This function assumes that `timestamp` is a factor of `RESOLUTION`. + /// If it is not, this function will never return Some. + /// + /// `timestamp` is expected to be a UNIX timestamp pub async fn fetch_log(&self, channel_id: i64, timestamp: i64) -> Option { - let log = query_as! (Log, + let log = query_as!( + Log, r#"SELECT * FROM logs WHERE channel_id = $1 AND time = $2"#, - channel_id, - timestamp - ).fetch_one(&self.pool).await.ok(); + channel_id, + timestamp + ) + .fetch_one(&self.pool) + .await + .ok(); info!("Recent log fetched"); log } - pub async fn fetch_logs(&self, channel_id: i64, lower_time_bound: i64, upper_time_bound: i64) -> Result, Error> { - #[allow(clippy::cast_possible_wrap)] - let logs: Vec = query_as! (Log, + /// Fetch all of the logs from a channel between two UNIX timestamps. + pub async fn fetch_logs_between( + &self, + channel_id: i64, + lower_time_bound: i64, + upper_time_bound: i64, + ) -> Result, Error> { + let logs: Vec = query_as!( + Log, r#"SELECT * FROM logs WHERE channel_id = $1 AND time BETWEEN $2 AND $3"#, - channel_id, - lower_time_bound, - upper_time_bound - ).fetch_all(&self.pool).await?; + channel_id, + lower_time_bound, + upper_time_bound + ) + .fetch_all(&self.pool) + .await?; info!("Logs between `{lower_time_bound}` and `{upper_time_bound}` fetched"); diff --git a/src/event_handler.rs b/src/event_handler.rs index 1daf100..70b6eaf 100644 --- a/src/event_handler.rs +++ b/src/event_handler.rs @@ -1,53 +1,52 @@ -use poise::Event; -use poise::serenity_prelude as serenity; use poise::serenity_prelude::Message; -use tracing::error; -use tracing::info; +use poise::{serenity_prelude as serenity, Event}; +use tracing::{error, info}; -use crate::RESOLUTION; -use crate::{database::App, Error}; +use crate::database::App; +use crate::{Error, RESOLUTION}; +/// Runs for each event recieved in the discord server. pub async fn event_handler( - _ctx: &serenity::Context, - event: &Event<'_>, - _framework: poise::FrameworkContext<'_, App, Error>, - data: &App, + _ctx: &serenity::Context, + event: &Event<'_>, + _framework: poise::FrameworkContext<'_, App, Error>, + data: &App, ) -> Result<(), Error> { match event { - Event::Ready { data_about_bot } => { - info!("Logged in as {}", data_about_bot.user.name); - } - Event::Message { new_message } => { + Event::Ready { .. } => { + info!("Event handler active"); + } + Event::Message { new_message } => { // Doesnt detect itself if new_message.author.id == 848_902_037_957_115_916 { - return Ok(()) + return Ok(()); } - log(data, new_message).await; - } - _ => {} - } - Ok(()) + log(data, new_message).await; + } + _ => {} + } + Ok(()) } +/// Logs the message recieved for a message event in `event_handler`. async fn log(data: &App, message: &Message) { - let timestamp = message.timestamp.timestamp(); - #[allow(clippy::cast_possible_wrap)] - let channel_id = *message.channel_id.as_u64() as i64; + let timestamp = message.timestamp.timestamp(); + let channel_id = *message.channel_id.as_u64() as i64; - let normalized_timestamp = timestamp - timestamp % RESOLUTION; + let normalized_timestamp = timestamp - timestamp % RESOLUTION; - info!("Logging message"); + info!("Logging message"); - let result = if let Some(log) = data.fetch_log(channel_id, normalized_timestamp).await { - info!("Recent log found, updating"); - data.update_log(log.id, log.count + 1).await - } else { - info!("Could not find recent log, creating new"); - data.new_log(normalized_timestamp, channel_id).await - }; + let result = if let Some(log) = data.fetch_log(channel_id, normalized_timestamp).await { + info!("Recent log found, updating"); + data.update_log(log.id, log.count + 1).await + } else { + info!("Could not find recent log, creating new"); + data.new_log(normalized_timestamp, channel_id).await + }; - if let Err(error) = result { - error!("Log failed: {error}"); - } + if let Err(error) = result { + error!("Log failed: {error}"); + } } diff --git a/src/log.rs b/src/log.rs index c870ab2..9c66625 100644 --- a/src/log.rs +++ b/src/log.rs @@ -1,10 +1,52 @@ use sqlx::FromRow; use uuid::Uuid; +/// Represents the data accumulated from `RESOLUTION` seconds of time +/// within a channel. #[derive(Debug, Clone, Copy, FromRow)] pub struct Log { + /// ID used within database. pub id: Uuid, + /// Discord channel ID for log. pub channel_id: i64, + /// Amount of messages sent during pas `RESOLUTION` seconds pub count: i32, + /// Time of log period. + /// Must be a multiple of `RESOLUTION` pub time: i64, } + +pub trait ChangeResolution { + fn change_resolution(self, new_resolution: i64) -> Vec; +} + +impl ChangeResolution for Vec { + fn change_resolution(self, new_resolution: i64) -> Vec { + if self.is_empty() { + return self; + } + + let mut updated_logs = Self::new(); + + let mut current_log = Log { + count: 0, + ..self[0] + }; + + let mut next_timestamp = current_log.time + new_resolution; + + for log in self { + if log.time < next_timestamp { + current_log.count += log.count; + } else { + updated_logs.push(current_log); + next_timestamp = log.time + new_resolution; + current_log = log; + } + } + + updated_logs.push(current_log); + + updated_logs + } +} diff --git a/src/main.rs b/src/main.rs index 664e4b9..8cc27d7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,14 @@ #![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::unwrap_used)] +#![allow(clippy::cast_possible_wrap)] mod commands; mod database; -mod log; mod event_handler; +mod log; +mod plotting; +mod time_period; -use commands::ping; +use commands::{logs, ping}; use dotenvy::dotenv; use poise::serenity_prelude::GatewayIntents; use tracing::{error, info}; @@ -28,13 +31,10 @@ async fn main() -> Result<(), poise::serenity_prelude::Error> { let app = App::new().await.expect("Failed to initialise app state"); let options = poise::FrameworkOptions { - commands: vec![ping()], - command_check: Some(|ctx| { - Box::pin(async move { Ok(ctx.author().id.0 == 366_491_742_679_072_768) }) - }), + commands: vec![ping(), logs()], event_handler: |ctx, event, framework, data| { - Box::pin(event_handler::event_handler(ctx, event, framework, data)) - }, + Box::pin(event_handler::event_handler(ctx, event, framework, data)) + }, pre_command: |ctx| { Box::pin(async move { info!("Executing command {}...", ctx.command().qualified_name); @@ -47,7 +47,7 @@ async fn main() -> Result<(), poise::serenity_prelude::Error> { }, on_error: |err| { Box::pin(async move { - error!("Error while executing command: {}", err); + error!("Error while executing command: {}", err.to_string()); }) }, ..Default::default() diff --git a/src/plotting.rs b/src/plotting.rs new file mode 100644 index 0000000..f577921 --- /dev/null +++ b/src/plotting.rs @@ -0,0 +1,138 @@ +use chrono::NaiveDateTime; +use lodepng::encode24; +use plotters::backend::{PixelFormat, RGBPixel}; +use plotters::prelude::*; +use plotters::style::full_palette::{GREY_900, GREY_A100}; +use tracing::info; + +use crate::log::Log; +use crate::time_period::TimePeriod; +use crate::{Error, RESOLUTION}; + +const WIDTH: u32 = 1400; +const HEIGHT: u32 = 700; + +#[allow(clippy::cognitive_complexity)] +/// Generates a graph counting the amount of logs between two points in time. +/// +/// # Parameters +/// `channel_name` The name of the channel, used for the title of the graph. +/// +/// `start_timestamp` `end_timestamp` The starting and ending UNIX timestamps to visualise. +pub fn create_log_graph( + mut logs: Vec, + channel_name: &str, + start_timestamp: i64, + end_timestamp: i64, + time_period: TimePeriod, +) -> Result, Error> { + info!("Creating log graph"); + + logs.sort_by(|lhs, rhs| lhs.time.cmp(&rhs.time)); + + if logs.is_empty() { + info!("Logs vector empty, cannot generate graph"); + return Err("Logs vector empty, cannot generate graph".into()); + } + + // Calculates the highest count of messages. + let max = logs.iter().map(|log| log.count).max().unwrap_or(0); + + // Text for X label + let time_interval_text = format!( + "Time (Per {})", + match time_period { + TimePeriod::Hour => "30 seconds", + TimePeriod::HalfDay => "6 minutes", + TimePeriod::Day => "12 minutes", + TimePeriod::Week => "1.4 hours", + } + ); + + // Generate graph data. + let buffer = generate_graph( + &logs, + channel_name, + max.into(), + start_timestamp, + end_timestamp, + &time_interval_text, + )?; + + info!("Graph image generated"); + + // Encode data to png. + let image = encode24(&buffer, WIDTH as usize, HEIGHT as usize)?; + + info!("Graph image encoded"); + + Ok(image) +} + +/// Core logic for generating the graph for `create_log_graph`. +/// +/// # Parameters +/// `channel_name` The name of the channel, used for the title of the graph. +/// +/// `max` The highest amount of messages to show on the graph. +/// +/// `start_timestamp` `end_timestamp` The starting and ending UNIX timestamps to visualise. +fn generate_graph( + logs: &[Log], + channel_name: &str, + max: i64, + start_timestamp: i64, + end_timestamp: i64, + time_interval_text: &str, +) -> Result, Error> { + let mut buffer = vec![0; WIDTH as usize * HEIGHT as usize * RGBPixel::PIXEL_SIZE]; + + let root = BitMapBackend::with_buffer(&mut buffer, (WIDTH, HEIGHT)).into_drawing_area(); + root.fill(&BLACK.mix(0.2))?; + + // Draw background and title. + let mut chart = ChartBuilder::on(&root) + .caption( + format!("Logs for {channel_name}"), + ("sans-serif", 45).into_font().with_color(GREY_A100), + ) + .margin(5) + .x_label_area_size(65) + .y_label_area_size(65) + .margin_right(30) + .build_cartesian_2d( + start_timestamp - RESOLUTION..end_timestamp + RESOLUTION, + 0..max, + )?; + + // Draw axis labels. + chart + .configure_mesh() + .bold_line_style(GREY_900) + .x_desc(time_interval_text) + .y_desc("Messages") + .x_label_formatter(&|x| { + let dt = NaiveDateTime::from_timestamp_opt(*x, 0).unwrap_or_default(); + dt.format("%H:%M").to_string() + }) + .x_label_style(("sans-serif", 20).into_font().color(&GREY_A100)) + .y_label_style(("sans-serif", 25).into_font().color(&GREY_A100)) + .axis_desc_style(("sans-serif", 30).with_color(GREY_A100)) + .draw()?; + + // Draw data. + chart.draw_series( + Histogram::vertical(&chart) + .style(GREEN.mix(0.9).filled()) + .data(logs.iter().map(|log| (log.time, log.count.into()))), + )?; + + // Save data to buffer. + root.present()?; + + // Drop chart and root to allow return of buffer. + drop(chart); + drop(root); + + Ok(buffer) +} diff --git a/src/time_period.rs b/src/time_period.rs new file mode 100644 index 0000000..9b28bdd --- /dev/null +++ b/src/time_period.rs @@ -0,0 +1,36 @@ +/// Represent a period of time. +#[derive(Debug, Clone, Copy, poise::ChoiceParameter)] +pub enum TimePeriod { + Hour, + #[name = "Half Day"] + HalfDay, + Day, + Week, +} + +impl TimePeriod { + const HOUR: i64 = 3600; + const HALFDAY: i64 = Self::HOUR * 12; + const DAY: i64 = Self::HOUR * 24; + const WEEK: i64 = Self::DAY * 7; + + /// Gets the timestamp that is the cases before the `timestamp` + /// + /// # Example + /// + /// ``` + /// let now = Timestamp::now().timestamp(); // Eg 1698569658 + /// let period = TimePeriod::Hour; + /// let an_hour_ago = 1698566058 // 3600 less than now + /// + /// assert!(period.relative_timestamp_from(now), an_hour_ago) + /// ``` + pub const fn relative_timestamp_from(self, timestamp: i64) -> i64 { + match self { + Self::Hour => timestamp - Self::HOUR, + Self::HalfDay => timestamp - Self::HALFDAY, + Self::Day => timestamp - Self::DAY, + Self::Week => timestamp - Self::WEEK, + } + } +}