├── .github ├── ISSSUE_TEMPLATE │ ├── config.yaml │ ├── feature-request.yaml │ └── bug.yaml ├── pull_request_template.md ├── workflows │ ├── sonarqube-analysis.yml │ ├── docker-deploy.yml │ ├── api-test.yml │ └── codeql-analysis.yml └── FUNDING.yml ├── api ├── src │ ├── fairings │ │ ├── mod.rs │ │ ├── cors.rs │ │ ├── timer.rs │ │ └── counter.rs │ ├── tests │ │ ├── mod.rs │ │ ├── zmq_down │ │ │ ├── mod.rs │ │ │ ├── stats.rs │ │ │ ├── commands.rs │ │ │ ├── vote.rs │ │ │ ├── diagnostics.rs │ │ │ ├── user.rs │ │ │ └── discord_webhooks.rs │ │ ├── non_zmq │ │ │ ├── mod.rs │ │ │ ├── cards.rs │ │ │ ├── list.rs │ │ │ └── image.rs │ │ ├── zmq_running │ │ │ ├── mod.rs │ │ │ ├── stats.rs │ │ │ ├── commands.rs │ │ │ ├── vote.rs │ │ │ └── update.rs │ │ ├── README.md │ │ └── common.rs │ ├── routes │ │ ├── common │ │ │ ├── mod.rs │ │ │ ├── keys.rs │ │ │ ├── utils.rs │ │ │ └── discord_auth.rs │ │ ├── mod.rs │ │ ├── stats.rs │ │ ├── vote.rs │ │ ├── commands.rs │ │ ├── update.rs │ │ ├── cards.rs │ │ └── diagnostics.rs │ ├── db │ │ └── mod.rs │ └── main.rs ├── Rocket.toml ├── Cargo.toml └── Dockerfile ├── assets ├── hugs │ └── 0.jpg ├── news │ ├── news.png │ ├── post.png │ └── update.png ├── powerups │ ├── 2x.png │ ├── logo.png │ ├── bomb_detector.png │ └── treasure_map.png ├── boxes │ ├── big_box.png │ ├── booster_box.png │ ├── diamond_box.png │ ├── fancy_box.png │ ├── golden_box.png │ ├── haunted_box.png │ ├── box_of_titans.png │ ├── standart_box.png │ ├── box_of_legends.png │ ├── mysterious_box.png │ └── advanced_spell_box.png ├── misc │ ├── book_default.png │ └── book_first.png └── cards │ ├── PLACEHOLDER_NORMAL.png │ ├── PLACEHOLDER_RULER.png │ ├── PLACEHOLDER_SPELL.png │ └── PLACEHOLDER_RESTRICTED_SLOT.png ├── killua ├── static │ ├── font.ttf │ └── enums.py ├── __main__.py ├── tests │ ├── groups │ │ ├── __init__.py │ │ └── dev.py │ ├── types │ │ ├── asset.py │ │ ├── role.py │ │ ├── utils.py │ │ ├── user.py │ │ ├── member.py │ │ ├── testing_results.py │ │ ├── __init__.py │ │ ├── guild.py │ │ ├── context.py │ │ ├── channel.py │ │ ├── message.py │ │ ├── permissions.py │ │ ├── bot.py │ │ └── interaction.py │ └── testing.py ├── utils │ ├── classes │ │ ├── exceptions.py │ │ ├── __init__.py │ │ └── guild.py │ ├── converters.py │ ├── interactions.py │ └── test_db.py ├── metrics │ ├── logger.py │ ├── __init__.py │ └── metrics.py ├── cogs │ └── __init__.py ├── Dockerfile ├── download.py ├── __init__.py ├── migrate.py └── args.py ├── .dockerignore ├── zmq_proxy ├── Cargo.toml ├── Dockerfile └── src │ └── main.rs ├── prometheus └── prometheus.yml ├── grafana ├── datasources │ ├── prometheus.yml │ └── loki.yml └── dashboard.yml ├── script_service ├── Cargo.toml └── src │ └── main.rs ├── .sync_config.template ├── cleanup.sh ├── .env.template ├── sonar-project.properties ├── algorithms ├── README.md ├── rps.md └── compression.md ├── .gitignore ├── scripts ├── update.sh └── post-push-hook.sh ├── requirements.txt ├── loki └── config.yml ├── alloy └── config.alloy └── docker-compose.yaml /.github/ISSSUE_TEMPLATE/config.yaml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /api/src/fairings/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cors; 2 | pub mod counter; 3 | pub mod timer; 4 | -------------------------------------------------------------------------------- /assets/hugs/0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/hugs/0.jpg -------------------------------------------------------------------------------- /assets/news/news.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/news/news.png -------------------------------------------------------------------------------- /assets/news/post.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/news/post.png -------------------------------------------------------------------------------- /assets/news/update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/news/update.png -------------------------------------------------------------------------------- /assets/powerups/2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/powerups/2x.png -------------------------------------------------------------------------------- /killua/static/font.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/killua/static/font.ttf -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Including previous rust builds may cause errors in the build process 2 | target/* -------------------------------------------------------------------------------- /assets/boxes/big_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/big_box.png -------------------------------------------------------------------------------- /assets/powerups/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/powerups/logo.png -------------------------------------------------------------------------------- /assets/boxes/booster_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/booster_box.png -------------------------------------------------------------------------------- /assets/boxes/diamond_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/diamond_box.png -------------------------------------------------------------------------------- /assets/boxes/fancy_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/fancy_box.png -------------------------------------------------------------------------------- /assets/boxes/golden_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/golden_box.png -------------------------------------------------------------------------------- /assets/boxes/haunted_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/haunted_box.png -------------------------------------------------------------------------------- /assets/misc/book_default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/misc/book_default.png -------------------------------------------------------------------------------- /assets/misc/book_first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/misc/book_first.png -------------------------------------------------------------------------------- /api/src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod common; 2 | pub mod non_zmq; 3 | pub mod zmq_down; 4 | pub mod zmq_running; 5 | -------------------------------------------------------------------------------- /assets/boxes/box_of_titans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/box_of_titans.png -------------------------------------------------------------------------------- /assets/boxes/standart_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/standart_box.png -------------------------------------------------------------------------------- /assets/boxes/box_of_legends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/box_of_legends.png -------------------------------------------------------------------------------- /assets/boxes/mysterious_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/mysterious_box.png -------------------------------------------------------------------------------- /assets/powerups/bomb_detector.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/powerups/bomb_detector.png -------------------------------------------------------------------------------- /assets/powerups/treasure_map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/powerups/treasure_map.png -------------------------------------------------------------------------------- /assets/boxes/advanced_spell_box.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/boxes/advanced_spell_box.png -------------------------------------------------------------------------------- /assets/cards/PLACEHOLDER_NORMAL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/cards/PLACEHOLDER_NORMAL.png -------------------------------------------------------------------------------- /assets/cards/PLACEHOLDER_RULER.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/cards/PLACEHOLDER_RULER.png -------------------------------------------------------------------------------- /assets/cards/PLACEHOLDER_SPELL.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/cards/PLACEHOLDER_SPELL.png -------------------------------------------------------------------------------- /api/src/routes/common/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod discord_auth; 2 | pub mod discord_security; 3 | pub mod keys; 4 | pub mod utils; 5 | -------------------------------------------------------------------------------- /killua/__main__.py: -------------------------------------------------------------------------------- 1 | from . import main 2 | from asyncio import run 3 | 4 | if __name__ == "__main__": 5 | run(main()) 6 | -------------------------------------------------------------------------------- /assets/cards/PLACEHOLDER_RESTRICTED_SLOT.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Kile/Killua/HEAD/assets/cards/PLACEHOLDER_RESTRICTED_SLOT.png -------------------------------------------------------------------------------- /api/src/tests/zmq_down/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod commands; 2 | pub mod diagnostics; 3 | pub mod discord_webhooks; 4 | pub mod news; 5 | pub mod user; 6 | pub mod vote; 7 | -------------------------------------------------------------------------------- /api/src/tests/non_zmq/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cards; 2 | pub mod delete_edit; 3 | pub mod discord_auth; 4 | pub mod image; 5 | pub mod list; 6 | pub mod news; 7 | pub mod upload; 8 | pub mod user; 9 | -------------------------------------------------------------------------------- /zmq_proxy/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zmq_proxy" 3 | version = "1.3.1" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | env_logger = "0.11.3" 8 | log = "0.4.21" 9 | zmq = "0.10.0" 10 | -------------------------------------------------------------------------------- /api/src/tests/zmq_running/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod commands; 2 | pub mod diagnostics; 3 | pub mod discord_webhooks; 4 | pub mod news; 5 | pub mod stats; 6 | pub mod update; 7 | pub mod user; 8 | pub mod vote; 9 | -------------------------------------------------------------------------------- /prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | scrape_configs: 2 | - job_name: "Killua" 3 | static_configs: 4 | - targets: ["bot:8000"] 5 | - job_name: 'node' 6 | static_configs: 7 | - targets: ['node-exporter:9100'] -------------------------------------------------------------------------------- /grafana/datasources/prometheus.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 2 2 | 3 | datasources: 4 | - name: Prometheus 5 | type: prometheus 6 | url: http://prometheus:9090 7 | isDefault: true 8 | access: proxy 9 | uid: prometheusdatasource -------------------------------------------------------------------------------- /killua/tests/groups/__init__.py: -------------------------------------------------------------------------------- 1 | from .actions import TestingActions 2 | from .cards import TestingCards 3 | from .dev import TestingDev 4 | 5 | tests = [TestingActions, TestingCards, TestingDev] 6 | 7 | __all__ = ["tests"] 8 | -------------------------------------------------------------------------------- /grafana/datasources/loki.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | datasources: 4 | - name: Loki 5 | type: loki 6 | access: proxy 7 | url: http://loki:3100 8 | isDefault: false 9 | editable: false 10 | uid: lokidatasource -------------------------------------------------------------------------------- /api/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cards; 2 | pub mod commands; 3 | pub mod common; 4 | pub mod diagnostics; 5 | pub mod discord_webhooks; 6 | pub mod image; 7 | pub mod news; 8 | pub mod stats; 9 | pub mod update; 10 | pub mod user; 11 | pub mod vote; 12 | -------------------------------------------------------------------------------- /killua/utils/classes/exceptions.py: -------------------------------------------------------------------------------- 1 | class NotInPossession(Exception): 2 | pass 3 | 4 | 5 | class CardLimitReached(Exception): 6 | pass 7 | 8 | 9 | class TodoListNotFound(Exception): 10 | pass 11 | 12 | 13 | class NoMatches(Exception): 14 | pass -------------------------------------------------------------------------------- /script_service/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "script_service" 3 | version = "1.4.0" 4 | edition = "2024" 5 | 6 | [dependencies] 7 | env_logger = "0.11.8" 8 | log = "0.4.27" 9 | serde = {"version" = "1.0.219", "features" = ["derive"]} 10 | serde_json = "1.0.140" 11 | zmq = "0.10.0" 12 | -------------------------------------------------------------------------------- /grafana/dashboard.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 2 2 | 3 | providers: 4 | - name: "Dashboard provider" 5 | orgId: 1 6 | type: file 7 | disableDeletion: false 8 | updateIntervalSeconds: 10 9 | allowUiUpdates: false 10 | options: 11 | path: /var/lib/grafana/dashboards 12 | foldersFromFilesStructure: true -------------------------------------------------------------------------------- /.sync_config.template: -------------------------------------------------------------------------------- 1 | # Remote server details 2 | REMOTE_USER="your-username" 3 | REMOTE_HOST="remote-host.com" 4 | REMOTE_BASE_PATH="/path/to/remote/directory" 5 | 6 | # Use SSH key for authentication (true/false) 7 | USE_SSH_KEY=true 8 | 9 | # Path to the SSH private key (only needed if USE_SSH_KEY=true) 10 | SSH_KEY_PATH="~/.ssh/id_rsa" 11 | -------------------------------------------------------------------------------- /api/src/tests/README.md: -------------------------------------------------------------------------------- 1 | Make sure you run tests with `-- --test-threads=1` due to some of the tests relying on zeromq being down and the others on it being up and I have not yet found a way to set this up for each individual test. 2 | 3 | Tests are executed in alphabetical order of module name. If you are not sure about how to run tests, GitHub actions will also run them for you on any new PR. -------------------------------------------------------------------------------- /killua/tests/types/asset.py: -------------------------------------------------------------------------------- 1 | from discord import Asset 2 | 3 | 4 | class Asset: 5 | 6 | __class__ = Asset 7 | 8 | def __init__(self, url: str = None, **kwargs): 9 | self._url = url 10 | self.__dict__.update(kwargs) 11 | 12 | @property 13 | def url(self) -> str: 14 | return "https://images.com/image.png" if self._url is None else self._url 15 | -------------------------------------------------------------------------------- /cleanup.sh: -------------------------------------------------------------------------------- 1 | # Kill what is running on the port specified docker-compose.yaml services.api.ports: x:y 2 | port=$(python3 -c "import yaml; print(yaml.safe_load(open('docker-compose.yaml'))['services']['api']['ports'][0].split(':')[0])") 3 | echo $port 4 | pid=$(lsof -t -i:$port) 5 | if [ -n "$pid" ]; then 6 | kill $pid 7 | else 8 | echo "No process found running on port $port" 9 | fi 10 | echo "Cleanup complete" -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | MONGODB="mongodb connection" 2 | TOKEN="bot token" 3 | PXLAPI="plxapi token" 4 | PATREON="patreon token" 5 | DBL_TOKEN="discord bot list token" 6 | TOPGG_TOKEN="top.gg token" 7 | API_KEY="key used in Killua API" 8 | MODE="dev" 9 | HASH_SECRET="hash secret" 10 | GF_SECURITY_ADMIN_USER="admin" 11 | GF_SECURITY_ADMIN_PASSWORD="admin" 12 | ADMIN_IDS="" 13 | PUBLIC_KEY="Public key on the Discord Developer Portal" -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=Kile_Killua 2 | sonar.organization=kile 3 | 4 | 5 | # This is the name and version displayed in the SonarCloud UI. 6 | sonar.projectName=Killua 7 | sonar.projectVersion=1.4.0 8 | 9 | 10 | # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. 11 | sonar.sources=. 12 | 13 | # Encoding of the source code. Default is default system encoding 14 | sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /api/src/tests/zmq_down/stats.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::local::blocking::Client; 3 | use rocket::http::Status; 4 | 5 | #[test] 6 | fn test_get_stats_error() { 7 | // zmq server is down 8 | let client = Client::tracked(rocket()).unwrap(); 9 | let response = client.get("/stats").dispatch(); 10 | assert_eq!(response.status(), Status::BadRequest); 11 | assert_eq!(response.into_string().unwrap(), r#"{"error":"Failed to get stats"}"#); 12 | } -------------------------------------------------------------------------------- /killua/metrics/logger.py: -------------------------------------------------------------------------------- 1 | from logging import StreamHandler 2 | from prometheus_client import Counter 3 | 4 | LOGGING_COUNTER = Counter("logging", "Log entries", ["logger", "level"]) 5 | 6 | 7 | class PrometheusLoggingHandler(StreamHandler): 8 | """ 9 | A logging handler that adds logging metrics to prometheus 10 | """ 11 | 12 | def emit(self, record): 13 | LOGGING_COUNTER.labels(record.name, record.levelname).inc() 14 | super().emit(record) 15 | -------------------------------------------------------------------------------- /api/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod models; 2 | 3 | use mongodb::Client; 4 | use rocket::fairing::AdHoc; 5 | 6 | // Tries opening config.json and reading the URI for the MongoDB database 7 | fn get_mongo_uri() -> String { 8 | // Read environment variable 9 | std::env::var("MONGODB").unwrap() 10 | } 11 | 12 | pub fn init() -> AdHoc { 13 | AdHoc::on_ignite("Connecting to MongoDB", |rocket| async { 14 | rocket.manage(Client::with_uri_str(get_mongo_uri()).await.unwrap()) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /api/src/tests/zmq_down/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::http::Status; 3 | use rocket::local::blocking::Client; 4 | 5 | #[test] 6 | fn get_commands_error() { 7 | // zmq server is down 8 | let client = Client::tracked(rocket()).unwrap(); 9 | let response = client.get("/commands").dispatch(); 10 | assert_eq!(response.status(), Status::BadRequest); 11 | assert_eq!( 12 | response.into_string().unwrap(), 13 | r#"{"error":"Failed to get commands"}"# 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /algorithms/README.md: -------------------------------------------------------------------------------- 1 | ## What is this? 2 | This folder contains a collection of different interesting algorithms that are used in Killua which are pretty smart and useful along with a detailed explanation of how they work and how I came up with them 3 | 4 | ## Algorithms 5 | + [Compressing discord ids](./compression.md) 6 | + [Using a math forula for rps](./rps.md) 7 | + [Own OOP testing framework](../killua/tests/README.md) 8 | + [Argument parser for command](./parser.md) 9 | + [Using tokens to limit access to staticly hosted images](image-cdn.md) -------------------------------------------------------------------------------- /api/Rocket.toml: -------------------------------------------------------------------------------- 1 | ## defaults for _all_ profiles 2 | [default] 3 | address = "0.0.0.0" 4 | limits = { form = "64 kB", json = "1 MiB", data-form = "500 MiB" } 5 | 6 | ## set only when compiled in debug mode, i.e, `cargo build` 7 | [debug] 8 | port = 7650 9 | ## only the `json` key from `default` will be overridden; `form` will remain 10 | limits = { json = "10MiB", data-form = "500 MiB" } 11 | 12 | ## set only when compiled in release mode, i.e, `cargo build --release` 13 | [release] 14 | port = 7650 15 | ip_header = false 16 | limits = { data-form = "500 MiB" } -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Thanks for contributing to this repository! 2 | 3 | ### What is the pull request for? 4 | Please describe what this pull request add 5 | 6 | 7 | ### Please check the boxes if appropriate 8 | 9 | - [ ] I have tested these changes 10 | - [ ] This PR fixes an open issue 11 | - [ ] This PR adds a new feature 12 | 13 | 14 | ### What issues are fixed by this PR? 15 | If this PR closes any open issues, please add them here 16 | 17 | 18 | ### How can we get in touch with you if we need more info? 19 | Please provide a discord id or tag 20 | -------------------------------------------------------------------------------- /.github/workflows/sonarqube-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | jobs: 9 | sonarqube: 10 | name: SonarQube 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 16 | - name: SonarQube Scan 17 | uses: SonarSource/sonarqube-scan-action@v6 18 | env: 19 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Env file 2 | .env 3 | 4 | # SonarQube scan config 5 | .scannerwork/ 6 | 7 | # VSCode config 8 | .vscode/ 9 | 10 | # Downloaded cards 11 | cards.json 12 | 13 | # Sync config 14 | .sync_config 15 | 16 | # Some assets 17 | assets/hugs/* 18 | # Still allow an "official" hug for development 19 | !assets/hugs/0.jpg 20 | assets/cards/* 21 | # Still allow the placeholder cards for development 22 | !assets/cards/PLACEHOLDER*.png 23 | 24 | # CDN uploads 25 | assets/cdn/* 26 | 27 | # API log 28 | /api.log 29 | 30 | #python stuff 31 | **/__pycache__ 32 | 33 | # Rust build 34 | **/target 35 | 36 | **/env 37 | 38 | **/.DS_Store -------------------------------------------------------------------------------- /api/src/tests/zmq_down/vote.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::http::{Header, Status}; 3 | use rocket::local::blocking::Client; 4 | 5 | use crate::tests::common::get_key; 6 | 7 | #[test] 8 | fn vote_error() { 9 | // zmq server is down 10 | let client = Client::tracked(rocket()).unwrap(); 11 | let response = client 12 | .post("/vote") 13 | .body(r#"{"user": "1", "id": "1", "isWeekend": true}"#) 14 | .header(Header::new("Authorization", get_key())) 15 | .dispatch(); 16 | assert_eq!(response.status(), Status::BadRequest); 17 | assert_eq!( 18 | response.into_string().unwrap(), 19 | r#"{"error":"Failed to register vote"}"# 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: KileAlkuri 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /killua/utils/classes/__init__.py: -------------------------------------------------------------------------------- 1 | from .user import User 2 | from .guild import Guild 3 | from .card import Card, SuccessfulDefense, CheckFailure, CardNotFound 4 | from .exceptions import ( 5 | CardLimitReached, 6 | NoMatches, 7 | NotInPossession, 8 | TodoListNotFound, 9 | ) 10 | from .todo import TodoList, Todo 11 | from .lootbox import LootBox 12 | from .book import Book 13 | from .card import Card 14 | 15 | __all__ = [ 16 | "User", 17 | "Guild", 18 | "Card", 19 | "CardNotFound", 20 | "CardLimitReached", 21 | "CheckFailure", 22 | "NoMatches", 23 | "NotInPossession", 24 | "SuccessfulDefense", 25 | "TodoListNotFound", 26 | "TodoList", 27 | "Todo", 28 | "LootBox", 29 | "Book", 30 | "Card", 31 | ] 32 | -------------------------------------------------------------------------------- /api/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "api" 3 | version = "1.4.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | lazy_static = "1.5.0" 10 | load_file = "1.0.1" 11 | mongodb = "3.3.0" 12 | bson = { version = "2.15.0", features = ["chrono-0_4"] } 13 | regex = "1.11.1" 14 | rocket = { version = "0.5.1", features = ["json"] } 15 | serde = { version = "1.0.219", features = ["derive"] } 16 | serde_json = "1.0.140" 17 | sha2 = "0.10.9" 18 | tokio = "1.46.1" 19 | toml = "0.8.23" 20 | zmq = "0.10.0" 21 | reqwest = { version = "0.11", features = ["json"] } 22 | chrono = { version = "0.4", features = ["serde"] } 23 | ed25519-dalek = "2.0.0" 24 | hex = "0.4.3" 25 | rand = { version = "0.8.5", features = ["std"] } 26 | -------------------------------------------------------------------------------- /api/src/routes/stats.rs: -------------------------------------------------------------------------------- 1 | use rocket::response::status::BadRequest; 2 | use rocket::serde::json::{Json, Value}; 3 | use rocket::serde::{Deserialize, Serialize}; 4 | 5 | use super::common::utils::{make_request, NoData, ResultExt}; 6 | 7 | #[derive(Serialize, Deserialize, Clone)] 8 | #[serde(crate = "rocket::serde")] 9 | pub struct Stats { 10 | pub guilds: u32, 11 | pub shards: u8, 12 | pub user_installs: u32, 13 | pub registered_users: u32, 14 | pub last_restart: f64, 15 | } 16 | 17 | #[get("/stats")] 18 | pub async fn get_stats() -> Result, BadRequest>> { 19 | let stats = make_request("stats", NoData {}, 0_u8, false) 20 | .await 21 | .context("Failed to get stats")?; 22 | let stats: Stats = serde_json::from_str(&stats).unwrap(); 23 | Ok(Json(stats)) 24 | } 25 | -------------------------------------------------------------------------------- /zmq_proxy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.87 AS base 2 | 3 | ARG MYUID=1000 4 | ARG MYGID=1000 5 | 6 | COPY . . 7 | 8 | FROM base AS prod 9 | 10 | EXPOSE 5558 11 | 12 | # Build the Rust application 13 | RUN cargo build --release 14 | 15 | # Create a user and group to run the application 16 | RUN groupmod -g "${MYGID}" proxy && usermod -u "${MYUID}" -g "${MYGID}" proxy 17 | USER proxy 18 | 19 | # Set the binary as the entrypoint 20 | CMD ["./target/release/zmq_proxy"] 21 | 22 | # run without release flag for development 23 | FROM base AS dev 24 | 25 | # Build the Rust application 26 | RUN cargo build 27 | 28 | # Create a user and group to run the application 29 | RUN groupmod -g "${MYGID}" proxy && usermod -u "${MYUID}" -g "${MYGID}" proxy 30 | USER proxy 31 | 32 | # Set the binary as the entrypoint 33 | CMD ["./target/debug/zmq_proxy"] 34 | -------------------------------------------------------------------------------- /.github/workflows/docker-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Docker deploy 2 | 3 | on: 4 | # workflow_run: 5 | # workflows: ["Run API Tests"] 6 | # branches: [main, api-rewrite] 7 | # types: 8 | # - completed 9 | push: 10 | # Publish `main` as Docker `latest` image. 11 | branches: 12 | - main 13 | 14 | jobs: 15 | # Push image to GitHub Packages. 16 | # See also https://docs.docker.com/docker-hub/builds/ 17 | push: 18 | # Ensure test job passes before pushing image. 19 | runs-on: ubuntu-latest 20 | if: github.event_name == 'push' 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Log into registry 26 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 27 | 28 | - name: Build image 29 | run: docker compose build --push -------------------------------------------------------------------------------- /api/src/routes/common/keys.rs: -------------------------------------------------------------------------------- 1 | use rocket::http::Status; 2 | use rocket::request::{FromRequest, Outcome, Request}; 3 | 4 | pub struct ApiKey(()); 5 | 6 | #[derive(Debug)] 7 | pub enum ApiKeyError { 8 | Missing, 9 | Invalid, 10 | } 11 | 12 | #[rocket::async_trait] 13 | impl<'r> FromRequest<'r> for ApiKey { 14 | type Error = ApiKeyError; 15 | 16 | /// Returns true if `key` is a valid API key string. 17 | async fn from_request(req: &'r Request<'_>) -> Outcome { 18 | let correct_key = std::env::var("API_KEY").unwrap(); 19 | 20 | match req.headers().get_one("Authorization") { 21 | None => Outcome::Error((Status::Forbidden, ApiKeyError::Missing)), 22 | Some(key) if key == correct_key => Outcome::Success(ApiKey(())), 23 | Some(_) => Outcome::Error((Status::Forbidden, ApiKeyError::Invalid)), 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /scripts/update.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if --test is supplied 4 | if [[ "$1" == "--test-pass" ]]; then 5 | echo "Test passed" 6 | exit 0 7 | fi 8 | 9 | # Check if --test-failed is supplied 10 | if [[ "$1" == "--test-fail" ]]; then 11 | >&2 echo "Test failed" 12 | exit 1 13 | fi 14 | 15 | # Git pull 16 | echo "Running git pull..." 17 | git pull || { echo "git pull failed"; exit 1; } 18 | 19 | # Docker compose pull 20 | echo "Running docker compose pull..." 21 | docker compose pull || { echo "docker compose pull failed"; exit 1; } 22 | 23 | # Determine if --force was passed 24 | FORCE_RECREATE="" 25 | if [[ "$1" == "--force" ]]; then 26 | FORCE_RECREATE="--force-recreate" 27 | fi 28 | 29 | # Docker compose up 30 | echo "Running docker compose up -d $FORCE_RECREATE..." 31 | docker compose up -d $FORCE_RECREATE || { echo "docker compose up failed"; exit 1; } 32 | 33 | echo "Deployment complete." 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.4.4 2 | aiohttp==3.11.9 3 | aiosignal==1.3.1 4 | async-timeout==5.0.1 5 | attrs==25.2.0 6 | beautifulsoup4==4.12.3 7 | braceexpand==0.1.7 8 | click==8.1.7 9 | contourpy==1.3.0 10 | cycler==0.12.1 11 | discord.py==2.6.4 12 | dnspython==2.7.0 13 | fonttools==4.55.1 14 | frozenlist==1.5.0 15 | idna==3.10 16 | import_expression==2.2.1.post1 17 | importlib_metadata==8.5.0 18 | importlib_resources==6.4.5 19 | jishaku==2.6.0 20 | kiwisolver==1.4.7 21 | matplotlib==3.9.3 22 | multidict==6.1.0 23 | numpy==2.0.2 24 | packaging==25.0 25 | pillow==11.0.0 26 | prometheus_client==0.22.1 27 | propcache==0.3.1 28 | psutil==7.0.0 29 | pymongo==4.13.0 30 | pyparsing==3.2.0 31 | pypxl==0.2.4 32 | python-dateutil==2.9.0.post0 33 | PyYAML==6.0.2 34 | pyzmq==27.0.0 35 | six==1.16.0 36 | soupsieve==2.6 37 | tabulate==0.9.0 38 | toml==0.10.2 39 | typing_extensions==4.12.2 40 | yarl==1.18.3 41 | zipp==3.21.0 42 | zmq==0.0.0 43 | -------------------------------------------------------------------------------- /.github/ISSSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Make a feature request 3 | title: "[FEATURE REQUEST]: " 4 | labels: [feature-request] 5 | assignees: 6 | - Kile 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for giving feedback by making a feature request! 12 | - type: input 13 | id: contact 14 | attributes: 15 | label: Contact Details 16 | description: How can we get in touch with you if we need more info? 17 | placeholder: ex. Kile#0606 18 | validations: 19 | required: false 20 | - type: textarea 21 | id: request 22 | attributes: 23 | label: What is your request 24 | description: Please describe your feature request as detailed as possible 25 | placeholder: Descibe your wish! 26 | value: "I want... cats! And dogs! And- and I want a really really big loli!" 27 | validations: 28 | required: true 29 | -------------------------------------------------------------------------------- /api/src/tests/zmq_running/stats.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::http::Status; 3 | use rocket::local::blocking::Client; 4 | 5 | use crate::tests::common::{test_zmq_server, INIT}; 6 | 7 | #[test] 8 | fn get_stats() { 9 | INIT.call_once(|| { 10 | test_zmq_server(); 11 | }); 12 | 13 | let client = Client::tracked(rocket()).unwrap(); 14 | let response = client.get("/stats").dispatch(); 15 | assert_eq!(response.status(), Status::Ok); 16 | let response_text = response.into_string().unwrap(); 17 | let response_json: serde_json::Value = serde_json::from_str(&response_text).unwrap(); 18 | 19 | // Check individual fields instead of exact string match due to JSON field ordering 20 | assert_eq!(response_json["guilds"], 1); 21 | assert_eq!(response_json["shards"], 1); 22 | assert_eq!(response_json["registered_users"], 1); 23 | assert_eq!(response_json["user_installs"], 1); 24 | assert_eq!(response_json["last_restart"], 1.0); 25 | } 26 | -------------------------------------------------------------------------------- /killua/tests/types/role.py: -------------------------------------------------------------------------------- 1 | from discord import Role 2 | 3 | from .utils import random_name 4 | from .permissions import Permissions 5 | 6 | from typing import Optional 7 | 8 | 9 | class TestingRole: 10 | 11 | __class__ = Role 12 | 13 | def __init__(self, **kwargs): 14 | self.name: str = kwargs.pop("name", random_name()) 15 | self._permissions: int = kwargs.pop("permissions", 0) 16 | self.position: int = kwargs.pop("position", 0) 17 | self._colour: int = kwargs.pop("colour", 0) 18 | self.hoist: bool = kwargs.pop("hoist", False) 19 | self._icon: Optional[str] = kwargs.pop("icon", None) 20 | self.unicorn_emoji: Optional[str] = kwargs.pop("unicorn_emoji", None) 21 | self.managed: bool = kwargs.pop("managed", False) 22 | self.mentionable: bool = kwargs.pop("mentionable", False) 23 | self.tags = kwargs.pop("tags", None) 24 | 25 | @property 26 | def permissions(self) -> int: 27 | return Permissions(self._permissions) 28 | -------------------------------------------------------------------------------- /api/src/fairings/cors.rs: -------------------------------------------------------------------------------- 1 | /// Adapted from https://github.com/TaeyoonKwon/rust-rocket-sample/blob/main/src/fairings/cors.rs 2 | use rocket::fairing::{Fairing, Info, Kind}; 3 | use rocket::http::Header; 4 | use rocket::{Request, Response}; 5 | 6 | pub struct Cors; 7 | 8 | #[rocket::async_trait] 9 | impl Fairing for Cors { 10 | fn info(&self) -> Info { 11 | Info { 12 | name: "Add Cors headers to responses", 13 | kind: Kind::Response, 14 | } 15 | } 16 | 17 | async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { 18 | response.set_header(Header::new("Access-Control-Allow-Origin", "*")); 19 | response.set_header(Header::new( 20 | "Access-Control-Allow-Methods", 21 | "POST, GET, PATCH, OPTIONS", 22 | )); 23 | response.set_header(Header::new("Access-Control-Allow-Headers", "*")); 24 | response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /killua/utils/converters.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands 2 | 3 | from datetime import timedelta 4 | from re import compile 5 | 6 | time_regex = compile(r"(\d{1,5}(?:[.,]?\d{1,5})?)([smhd])") 7 | time_dict = {"h": 3600, "s": 1, "m": 60, "d": 86400} 8 | 9 | 10 | class TimeConverter(commands.Converter): 11 | async def convert(self, _: commands.Context, argument: str) -> timedelta: 12 | matches = time_regex.findall(argument.lower()) 13 | time = 0 14 | for v, k in matches: 15 | try: 16 | time += time_dict[k] * float(v) 17 | except KeyError: 18 | raise commands.BadArgument( 19 | "{} is an invalid time-key! h/m/s/d are valid!".format(k) 20 | ) 21 | except ValueError: 22 | raise commands.BadArgument("{} is not a number!".format(v)) 23 | 24 | if time > 2419200: 25 | raise commands.BadArgument("The maximum time allowed is 28 days!") 26 | 27 | return timedelta(seconds=time) 28 | -------------------------------------------------------------------------------- /killua/tests/types/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from random import randrange 3 | 4 | INCREMENT = 0 5 | 6 | 7 | def random_date() -> int: 8 | start_date = datetime(2015, 1, 1) 9 | end_date = datetime.now() 10 | 11 | time_between_dates = end_date - start_date 12 | days_between_dates = time_between_dates.days 13 | random_number_of_days = randrange(days_between_dates) 14 | return start_date + timedelta(days=random_number_of_days) 15 | 16 | 17 | # Adapted from https://github.com/discordjs/discord.js/blob/stable/src/util/SnowflakeUtil.js#L30 18 | # and translated into python 19 | def get_random_discord_id(time: datetime = None) -> int: 20 | global INCREMENT 21 | INCREMENT += 1 22 | if INCREMENT >= 4095: 23 | INCREMENT = 0 24 | if not time: 25 | time = random_date() 26 | return int((int(time.timestamp()) - 1420070400) * 100 << 22 | 1 << 17 | INCREMENT) 27 | 28 | 29 | def random_name() -> str: 30 | """Creates a random username""" 31 | return "".join([chr(randrange(97, 123)) for _ in range(randrange(4, 16))]) 32 | -------------------------------------------------------------------------------- /killua/cogs/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.abc import MetaPathFinder 2 | import pkgutil 3 | from importlib.util import find_spec 4 | import pkgutil 5 | 6 | # This module's submodules. 7 | all_cogs = [] 8 | 9 | for loader, name, pkg in pkgutil.walk_packages(__path__): 10 | # Load the module. 11 | loader = ( 12 | loader.find_module(name, None) 13 | if isinstance(loader, MetaPathFinder) 14 | else loader.find_module(name) 15 | ) 16 | module = loader.load_module(name) 17 | 18 | # Make it a global. 19 | globals()[name] = module 20 | # Put it in the list. 21 | all_cogs.append(module) 22 | # This module's submodules. 23 | all_cogs = [] 24 | for loader, name, is_pkg in pkgutil.iter_modules(__path__): 25 | # Load the module. 26 | spec = find_spec(name) 27 | if not spec: 28 | continue 29 | if spec.loader is None: 30 | continue 31 | module = spec.loader.load_module(name) 32 | # Make it a global. 33 | globals()[name] = module 34 | # Put it in the list. 35 | all_cogs.append(module) 36 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Rust runtime as a parent image 2 | FROM rust:1.90 AS base 3 | 4 | ARG MYUID=1000 5 | ARG MYGID=1000 6 | 7 | # Set the working directory in the container 8 | WORKDIR /app 9 | 10 | COPY api/ api/ 11 | COPY scripts/ scripts/ 12 | 13 | # This is mainly for reading the config.json 14 | WORKDIR /app/api 15 | 16 | # Runs in development and production on port 8000 (in the container) 17 | EXPOSE 8000 18 | 19 | FROM base AS prod 20 | 21 | # Build the Rust application 22 | RUN cargo build --release 23 | 24 | # Create a user and group to run the application 25 | RUN groupadd -g "${MYGID}" api \ 26 | && useradd --create-home --no-log-init -u "${MYUID}" -g "${MYGID}" api 27 | USER api 28 | 29 | # Set the binary as the entrypoint 30 | CMD ["./target/release/api"] 31 | 32 | # run without release flag for development 33 | FROM base AS dev 34 | 35 | # Build the Rust application no cache 36 | RUN cargo build 37 | 38 | # Create a user and group to run the application 39 | RUN groupadd -g "${MYGID}" api \ 40 | && useradd --create-home --no-log-init -u "${MYUID}" -g "${MYGID}" api 41 | USER api 42 | 43 | # Set the binary as the entrypoint 44 | CMD ["./target/debug/api"] 45 | 46 | -------------------------------------------------------------------------------- /api/src/routes/vote.rs: -------------------------------------------------------------------------------- 1 | use serde_json::Value; 2 | 3 | use rocket::response::status::BadRequest; 4 | use rocket::serde::json::Json; 5 | use rocket::serde::{Deserialize, Serialize}; 6 | 7 | use super::common::keys::ApiKey; 8 | use super::common::utils::{make_request, ResultExt}; 9 | 10 | #[derive(Deserialize, Serialize)] 11 | #[serde(rename_all = "camelCase")] 12 | // converts JSON keys to camelCase (needed for isWeekend) 13 | #[serde(crate = "rocket::serde")] 14 | pub struct Vote { 15 | user: Option, 16 | id: Option, 17 | is_weekend: Option, 18 | } 19 | 20 | #[post("/vote", data = "")] 21 | pub async fn register_vote( 22 | _key: ApiKey, 23 | vote: Json, 24 | ) -> Result, BadRequest>> { 25 | let response = make_request("vote", vote.into_inner(), 0_u8, false) 26 | .await 27 | .context("Failed to register vote")?; 28 | // Request also failed if the response key is "error" 29 | if response.contains("error") { 30 | return Err(BadRequest(Json(serde_json::from_str(&response).unwrap_or( 31 | serde_json::json!({"error": "Failed to register vote"}), 32 | )))); 33 | } 34 | 35 | Ok(Json(serde_json::json!({"message": "Success"}))) 36 | } 37 | -------------------------------------------------------------------------------- /killua/tests/types/user.py: -------------------------------------------------------------------------------- 1 | from discord import User 2 | 3 | from random import randint 4 | 5 | from .asset import Asset 6 | from .utils import get_random_discord_id, random_name 7 | 8 | 9 | class TestingUser: 10 | """A class imulating a discord user""" 11 | 12 | __class__ = User 13 | 14 | def __init__(self, **kwargs): 15 | self.id = kwargs.pop("id", get_random_discord_id()) 16 | self.name = kwargs.pop("name", random_name()) 17 | self.username = kwargs.pop("username", random_name()) 18 | self.discriminator = kwargs.pop("discriminator", self.__random_discriminator()) 19 | self.avatar = Asset(kwargs.pop("avatar")) if "avatar" in kwargs else Asset() 20 | self.bot = kwargs.pop("bot", False) 21 | self.premium_type = kwargs.pop("premium_type", 0) 22 | 23 | @property 24 | def mention(self) -> str: 25 | return "<@{}>".format(self.id) 26 | 27 | def __eq__(self, other: "TestingUser") -> bool: 28 | return self.id == other.id 29 | 30 | def __random_discriminator(self) -> str: 31 | """Creates a random discriminator""" 32 | return ( 33 | str(randint(0, 9)) 34 | + str(randint(0, 9)) 35 | + str(randint(0, 9)) 36 | + str(randint(0, 9)) 37 | ) 38 | -------------------------------------------------------------------------------- /loki/config.yml: -------------------------------------------------------------------------------- 1 | auth_enabled: false 2 | 3 | server: 4 | http_listen_port: 3100 5 | 6 | common: 7 | replication_factor: 1 8 | path_prefix: /loki 9 | ring: 10 | instance_addr: 0.0.0.0 11 | kvstore: 12 | store: inmemory 13 | 14 | limits_config: 15 | allow_structured_metadata: true 16 | volume_enabled: true 17 | # global retention (7 days) 18 | retention_period: 168h 19 | 20 | schema_config: 21 | configs: 22 | - from: 2020-05-15 23 | store: tsdb 24 | object_store: filesystem 25 | schema: v13 26 | index: 27 | prefix: index_ 28 | period: 24h # required for compactor/retention to work. Minimum index period = 24h. 29 | 30 | storage_config: 31 | tsdb_shipper: 32 | active_index_directory: /loki/index 33 | cache_location: /loki/index_cache 34 | filesystem: 35 | directory: /loki/chunks 36 | 37 | compactor: 38 | working_directory: /loki/compactor 39 | retention_enabled: true 40 | compaction_interval: 10m 41 | retention_delete_delay: 2h 42 | retention_delete_worker_count: 50 43 | # REQUIRED in Loki 3.x when retention_enabled = true: 44 | delete_request_store: filesystem 45 | delete_request_store_key_prefix: index/ 46 | delete_request_store_db_type: boltdb 47 | 48 | pattern_ingester: 49 | enabled: true 50 | -------------------------------------------------------------------------------- /killua/tests/types/member.py: -------------------------------------------------------------------------------- 1 | from discord import Member 2 | 3 | from discord.types.snowflake import Snowflake 4 | 5 | from random import randint 6 | from typing import List, Union 7 | from datetime import datetime 8 | 9 | from .utils import get_random_discord_id, random_date 10 | from .user import TestingUser as User 11 | 12 | 13 | class TestingMember(User): 14 | """A class imulating a discord member""" 15 | 16 | __class__ = Member 17 | 18 | def __init__(self, **kwargs): 19 | super().__init__(**kwargs) 20 | self.roles: list = kwargs.pop("roles", self.__random_roles()) 21 | self.joined_at: str = kwargs.pop("joined_at", str(random_date())) 22 | self.deaf: bool = kwargs.pop("deaf", False) 23 | self.muted: bool = kwargs.pop("muted", False) 24 | self.nick: str = kwargs.pop("nick", None) 25 | self.communication_disabled_until: str = kwargs.pop( 26 | "communication_disabled_until", "" 27 | ) 28 | self.premium_since: Union[datetime, None] = kwargs.pop("premium_since", None) 29 | 30 | @property 31 | def display_name(self) -> str: 32 | return self.nick or self.username 33 | 34 | def __random_roles(self) -> List[Snowflake]: 35 | """Creates a random list of roles a user has""" 36 | return [get_random_discord_id() for _ in range(randint(0, 10))] 37 | -------------------------------------------------------------------------------- /killua/tests/types/testing_results.py: -------------------------------------------------------------------------------- 1 | from .message import Message 2 | 3 | from discord.ext.commands import Command 4 | 5 | from enum import Enum 6 | from typing import Any 7 | 8 | 9 | class Result(Enum): 10 | passed = 0 11 | failed = 1 12 | errored = 2 13 | 14 | 15 | class ResultData: 16 | """An object containing the result of a command test""" 17 | 18 | def __init__( 19 | self, 20 | message: Message = None, 21 | error: Exception = None, 22 | actual_result: Any = None, 23 | ): 24 | self.message = message 25 | self.error = error 26 | self.actual_result = actual_result 27 | 28 | 29 | class TestResult: 30 | 31 | def __init__(self): 32 | self.passed = [] 33 | self.failed = [] 34 | self.errored = [] 35 | 36 | def completed_test( 37 | self, command: Command, result: Result, result_data: ResultData = None 38 | ) -> None: 39 | if result == Result.passed: 40 | self.passed.append(command) 41 | elif result == Result.failed: 42 | self.failed.append({"command": command, "result": result_data}) 43 | else: 44 | self.errored.append({"command": command, "error": result_data}) 45 | 46 | def add_result(self, result: "TestResult") -> None: 47 | self.passed.extend(result.passed) 48 | self.failed.extend(result.failed) 49 | self.errored.extend(result.errored) 50 | -------------------------------------------------------------------------------- /killua/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | from .metrics import ( 2 | CONNECTION_GAUGE, 3 | LATENCY_GAUGE, 4 | ON_INTERACTION_COUNTER, 5 | ON_COMMAND_COUNTER, 6 | GUILD_GAUGE, 7 | COMMANDS_GAUGE, 8 | RAM_USAGE_GAUGE, 9 | CPU_USAGE_GAUGE, 10 | MEMORY_USAGE_GAUGE, 11 | REGISTERED_USER_GAUGE, 12 | API_REQUESTS_COUNTER, 13 | API_RESPONSE_TIME, 14 | IPC_RESPONSE_TIME, 15 | API_SPAM_REQUESTS, 16 | DAILY_ACTIVE_USERS, 17 | APPROXIMATE_USER_COUNT, 18 | USER_INSTALLS, 19 | COMMAND_USAGE, 20 | PREMIUM_USERS, 21 | VOTES, 22 | TODO_LISTS, 23 | TODOS, 24 | TAGS, 25 | CARDS, 26 | BIGGEST_COLLECTION, 27 | LOCALE, 28 | IS_DEV, 29 | ) 30 | 31 | from .logger import PrometheusLoggingHandler 32 | 33 | __all__ = [ 34 | "PrometheusLoggingHandler", 35 | "CONNECTION_GAUGE", 36 | "LATENCY_GAUGE", 37 | "ON_INTERACTION_COUNTER", 38 | "ON_COMMAND_COUNTER", 39 | "GUILD_GAUGE", 40 | "COMMANDS_GAUGE", 41 | "RAM_USAGE_GAUGE", 42 | "CPU_USAGE_GAUGE", 43 | "MEMORY_USAGE_GAUGE", 44 | "REGISTERED_USER_GAUGE", 45 | "API_REQUESTS_COUNTER", 46 | "API_RESPONSE_TIME", 47 | "IPC_RESPONSE_TIME", 48 | "API_SPAM_REQUESTS", 49 | "DAILY_ACTIVE_USERS", 50 | "APPROXIMATE_USER_COUNT", 51 | "USER_INSTALLS", 52 | "COMMAND_USAGE", 53 | "PREMIUM_USERS", 54 | "VOTES", 55 | "TODO_LISTS", 56 | "TODOS", 57 | "TAGS", 58 | "CARDS", 59 | "BIGGEST_COLLECTION", 60 | "LOCALE", 61 | "IS_DEV", 62 | ] 63 | -------------------------------------------------------------------------------- /api/src/tests/zmq_running/commands.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::http::Status; 3 | use rocket::local::blocking::Client; 4 | 5 | use crate::tests::common::{test_zmq_server, INIT}; 6 | 7 | #[test] 8 | fn get_commands() { 9 | INIT.call_once(|| { 10 | test_zmq_server(); 11 | }); 12 | 13 | let client = Client::tracked(rocket()).unwrap(); 14 | let response = client.get("/commands").dispatch(); 15 | assert_eq!(response.status(), Status::Ok); 16 | assert_eq!( 17 | response.into_string().unwrap(), 18 | r#"{"CATEGORY":{"name":"category","commands":[],"description":"","emoji":{"normal":"a","unicode":"b"}}}"# 19 | ); 20 | } 21 | 22 | #[test] 23 | fn get_commands_twice() { 24 | INIT.call_once(|| { 25 | test_zmq_server(); 26 | }); 27 | 28 | let client = Client::tracked(rocket()).unwrap(); 29 | let response = client.get("/commands").dispatch(); 30 | assert_eq!(response.status(), Status::Ok); 31 | assert_eq!( 32 | response.into_string().unwrap(), 33 | r#"{"CATEGORY":{"name":"category","commands":[],"description":"","emoji":{"normal":"a","unicode":"b"}}}"# 34 | ); 35 | 36 | // Should have cached commands so not need a zmq server to be active 37 | let response = client.get("/commands").dispatch(); 38 | assert_eq!(response.status(), Status::Ok); 39 | assert_eq!( 40 | response.into_string().unwrap(), 41 | r#"{"CATEGORY":{"name":"category","commands":[],"description":"","emoji":{"normal":"a","unicode":"b"}}}"# 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /killua/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.9-slim AS base 3 | 4 | ARG MYUID=1000 5 | ARG MYGID=1000 6 | 7 | # Set the working directory in the container 8 | WORKDIR /app 9 | 10 | # Copy the current directory contents into the container at /app 11 | COPY killua/ killua/ 12 | 13 | # This is kinda dumb but helps keep things defined in one place 14 | COPY api/Rocket.toml api/Rocket.toml 15 | 16 | # Copy requirements.txt, hugs.json and config.json which are needed for the bot 17 | COPY requirements.txt . 18 | # Copy config.json . 19 | COPY hugs.json . 20 | # Copy docker-compose.yml to know which port the API runs on publicly 21 | COPY docker-compose.yaml . 22 | 23 | # Git needs to be installed for pip 24 | RUN apt-get update && apt-get install -y git 25 | 26 | # Install C++ cause a dependency needs it 27 | RUN apt-get install -y g++ 28 | 29 | # Clean up the cache 30 | RUN apt-get clean 31 | 32 | # Upgrade pip 33 | RUN pip3 install --no-cache-dir --upgrade pip 34 | 35 | # Install any needed packages specified in requirements.txt 36 | RUN pip3 install --no-cache-dir -r requirements.txt 37 | 38 | FROM base AS dev 39 | # Create a user and group to run the application 40 | RUN groupadd -g "${MYGID}" python \ 41 | && useradd --create-home --no-log-init -u "${MYUID}" -g "${MYGID}" python 42 | USER python 43 | CMD ["python3", "-m", "killua", "--development", "--docker"] 44 | 45 | FROM base AS prod 46 | # Create a user and group to run the application 47 | RUN groupadd -g "${MYGID}" python \ 48 | && useradd --create-home --no-log-init -u "${MYUID}" -g "${MYGID}" python 49 | USER python 50 | CMD ["python3", "-m", "killua", "--docker", "--force-local"] -------------------------------------------------------------------------------- /api/src/fairings/timer.rs: -------------------------------------------------------------------------------- 1 | /// Adapted from the Rocket example at https://api.rocket.rs/v0.5/rocket/fairing/trait.Fairing#example 2 | use std::time::SystemTime; 3 | 4 | use rocket::fairing::{Fairing, Info, Kind}; 5 | use rocket::{Data, Request, Response}; 6 | 7 | /// Fairing for timing requests. 8 | pub struct RequestTimer; 9 | 10 | /// Value stored in request-local state. 11 | #[derive(Copy, Clone)] 12 | struct TimerStart(Option); 13 | 14 | #[rocket::async_trait] 15 | impl Fairing for RequestTimer { 16 | fn info(&self) -> Info { 17 | Info { 18 | name: "Request Timer", 19 | kind: Kind::Request | Kind::Response, 20 | } 21 | } 22 | 23 | /// Stores the start time of the request in request-local state. 24 | async fn on_request(&self, request: &mut Request<'_>, _: &mut Data<'_>) { 25 | // Store a `TimerStart` instead of directly storing a `SystemTime` 26 | // to ensure that this usage doesn't conflict with anything else 27 | // that might store a `SystemTime` in request-local cache. 28 | request.local_cache(|| TimerStart(Some(SystemTime::now()))); 29 | } 30 | 31 | /// Adds a header to the response indicating how long the server took to 32 | /// process the request. 33 | async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) { 34 | let start_time = req.local_cache(|| TimerStart(None)); 35 | if let Some(Ok(duration)) = start_time.0.map(|st| st.elapsed()) { 36 | let ms = duration.as_secs() * 1000 + duration.subsec_millis() as u64; 37 | res.set_raw_header("X-Response-Time", format!("{ms} ms")); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /killua/download.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file includes one "download" function which downloads all cards to a local file to be able to run 3 | tests with them offline later 4 | """ 5 | 6 | import json 7 | import aiohttp 8 | from .static.constants import CARDS_URL 9 | from .static.enums import PrintColors 10 | from logging import info 11 | from typing import Literal 12 | from os import environ 13 | 14 | 15 | async def download(choice: Literal["public", "private"]) -> None: 16 | if choice == "private": 17 | AUTHORIZATION = environ.get("API_KEY") 18 | if AUTHORIZATION is None: 19 | info(f"{PrintColors.FAIL}API_KEY environment variable not set (needed to download the private version of cards){PrintColors.ENDC}") 20 | return 21 | headers = {"Authorization": AUTHORIZATION} 22 | else: 23 | headers = {} 24 | 25 | session = aiohttp.ClientSession() 26 | info(f"Downloading cards...") 27 | resp = await session.get(CARDS_URL + ("true" if choice == "public" else "false"), headers=headers) 28 | if resp.status != 200: 29 | info(f"{PrintColors.FAIL}Failed to download cards. API returned error code {resp.status}-{PrintColors.ENDC}") 30 | await session.close() 31 | return 32 | data = await resp.json() 33 | info(f"{PrintColors.OKGREEN}GET request successful for {choice} cards{PrintColors.ENDC}") 34 | with open("cards.json", "w+") as f: 35 | f.write(json.dumps(data)) 36 | f.close() 37 | info(f"{PrintColors.OKGREEN}Cards saved to file (cards.json){PrintColors.ENDC}") 38 | info("These cards are now available for offline testing. To use the locally downloaded cards, run the bot with the -fl flag.") 39 | await session.close() -------------------------------------------------------------------------------- /killua/tests/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import BOT as Bot 2 | from .channel import TestingTextChannel as TextChannel 3 | from .context import TestingContext as Context 4 | from .member import TestingMember as DiscordMember 5 | from .message import TestingMessage as Message 6 | from .permissions import Permission, PermissionOverwrite, Permissions 7 | from .role import TestingRole as Role 8 | from .user import TestingUser as DiscordUser 9 | from .guild import TestingGuild as DiscordGuild 10 | from .interaction import TestingInteraction as Interaction 11 | from .interaction import ArgumentInteraction, ArgumentResponseInteraction 12 | 13 | # from .db import TestingDatabase as Database 14 | # from .db_objects import TestingUser as User 15 | # from .db_objects import TestingGuild as Guild 16 | # from .db_objects import TestingTodo as Todo 17 | # from .db_objects import TestingTodoList as TodoList 18 | # from .db_objects import TestingCard as Card 19 | from .testing_results import TestResult, Result, ResultData 20 | from .utils import random_date, get_random_discord_id, random_name 21 | from typing import TYPE_CHECKING 22 | 23 | __all__ = [ 24 | "Bot", 25 | "TextChannel", 26 | "Context", 27 | "DiscordMember", 28 | "Message", 29 | "Permission", 30 | "PermissionOverwrite", 31 | "Permissions", 32 | "Role", 33 | "DiscordUser", 34 | "DiscordGuild", 35 | # "Database", 36 | # "User", 37 | # "Guild", 38 | "Interaction", 39 | "ArgumentInteraction", 40 | "ArgumentResponseInteraction", 41 | # "Todo", 42 | # "TodoList", 43 | # "Card", 44 | "TestResult", 45 | "Result", 46 | "ResultData", 47 | "random_date", 48 | "get_random_discord_id", 49 | "random_name", 50 | ] 51 | 52 | if TYPE_CHECKING: 53 | from .db_objects import TestingCard as Card 54 | 55 | __all__.append("Card") 56 | -------------------------------------------------------------------------------- /api/src/routes/commands.rs: -------------------------------------------------------------------------------- 1 | use serde_json::{from_str, Value}; 2 | use std::collections::HashMap; 3 | use tokio::sync::OnceCell; 4 | 5 | use rocket::response::status::BadRequest; 6 | use rocket::serde::json::Json; 7 | use rocket::serde::{Deserialize, Serialize}; 8 | 9 | use super::common::utils::{make_request, NoData, ResultExt}; 10 | 11 | #[derive(Serialize, Deserialize, Clone)] 12 | #[serde(crate = "rocket::serde")] 13 | struct Emoji { 14 | normal: String, 15 | unicode: String, 16 | } 17 | 18 | #[derive(Serialize, Deserialize, Clone)] 19 | #[serde(crate = "rocket::serde")] 20 | struct Command { 21 | name: String, 22 | description: String, 23 | message_usage: String, 24 | aliases: Vec, 25 | cooldown: u32, 26 | premium_guild: bool, 27 | premium_user: bool, 28 | slash_usage: String, 29 | } 30 | 31 | #[derive(Serialize, Deserialize, Clone)] 32 | #[serde(crate = "rocket::serde")] 33 | pub struct Category { 34 | name: String, 35 | commands: Vec, 36 | description: String, 37 | emoji: Emoji, 38 | } 39 | 40 | static CACHE: OnceCell> = OnceCell::const_new(); 41 | 42 | #[get("/commands")] 43 | pub async fn get_commands() -> Result>, BadRequest>> { 44 | let commands = CACHE 45 | .get_or_try_init(|| async { 46 | let commands = make_request("commands", NoData {}, 0_u8, false) 47 | .await 48 | .context("Failed to get commands")?; 49 | // Parse the commands into a HashMap using the defined structs and rocket 50 | let commands = from_str::>(&commands).unwrap(); 51 | // the final deserialized categories to store 52 | Ok(commands) 53 | }) 54 | .await; 55 | 56 | Ok(Json(commands?.clone())) 57 | } 58 | -------------------------------------------------------------------------------- /alloy/config.alloy: -------------------------------------------------------------------------------- 1 | // ############################### 2 | // #### Metrics Configuration #### 3 | // ############################### 4 | 5 | // Host Cadvisor on the Docker socket to expose container metrics. 6 | prometheus.exporter.cadvisor "example" { 7 | docker_host = "unix:///var/run/docker.sock" 8 | 9 | storage_duration = "5m" 10 | } 11 | 12 | 13 | // Configure a prometheus.scrape component to collect cadvisor metrics. 14 | prometheus.scrape "scraper" { 15 | targets = prometheus.exporter.cadvisor.example.targets 16 | forward_to = [ prometheus.remote_write.demo.receiver ] 17 | 18 | 19 | scrape_interval = "10s" 20 | } 21 | 22 | // Configure a prometheus.remote_write component to send metrics to a Prometheus server. 23 | prometheus.remote_write "demo" { 24 | endpoint { 25 | url = "http://prometheus:9090/api/v1/write" 26 | } 27 | } 28 | 29 | // ############################### 30 | // #### Logging Configuration #### 31 | // ############################### 32 | 33 | // Discover Docker containers and extract metadata. 34 | discovery.docker "linux" { 35 | host = "unix:///var/run/docker.sock" 36 | } 37 | 38 | // Define a relabeling rule to create a service name from the container name. 39 | discovery.relabel "logs_integrations_docker" { 40 | targets = [] 41 | 42 | rule { 43 | source_labels = ["__meta_docker_container_name"] 44 | regex = "/(.*)" 45 | target_label = "service_name" 46 | } 47 | 48 | } 49 | 50 | 51 | // Configure a loki.source.docker component to collect logs from Docker containers. 52 | loki.source.docker "default" { 53 | host = "unix:///var/run/docker.sock" 54 | targets = discovery.docker.linux.targets 55 | labels = {"platform" = "docker"} 56 | relabel_rules = discovery.relabel.logs_integrations_docker.rules 57 | forward_to = [loki.write.local.receiver] 58 | } 59 | 60 | loki.write "local" { 61 | endpoint { 62 | url = "http://loki:3100/loki/api/v1/push" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /api/src/tests/non_zmq/cards.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use crate::routes::cards::{parse_file, Card}; 3 | use crate::tests::common::get_key; 4 | use rocket::http::Status; 5 | use rocket::local::blocking::Client; 6 | 7 | #[test] 8 | fn get_private_cards_without_token() { 9 | let client = Client::tracked(rocket()).unwrap(); 10 | let response = client.get("/cards.json").dispatch(); 11 | assert_eq!(response.status(), Status::Forbidden); 12 | } 13 | 14 | #[test] 15 | fn get_private_cards_with_invalid_token() { 16 | let client = Client::tracked(rocket()).unwrap(); 17 | let response = client 18 | .get("/cards.json") 19 | .header(rocket::http::Header::new("Authorization", "invalid_token")) 20 | .dispatch(); 21 | assert_eq!(response.status(), Status::Forbidden); 22 | } 23 | 24 | #[test] 25 | fn get_private_cards() { 26 | let client = Client::tracked(rocket()).unwrap(); 27 | let response = client 28 | .get("/cards.json") 29 | .header(rocket::http::Header::new("Authorization", get_key())) 30 | .dispatch(); 31 | assert_eq!(response.status(), Status::Ok); 32 | let response_string = response.into_string().unwrap(); 33 | let cards: Vec = serde_json::from_str(&response_string).unwrap(); 34 | let real_cards = parse_file(); 35 | assert_eq!(cards.len(), real_cards.expect("Parsing failed").len()); 36 | } 37 | 38 | #[test] 39 | fn get_public_cards() { 40 | let client = Client::tracked(rocket()).unwrap(); 41 | let response = client.get("/cards.json?public=true").dispatch(); 42 | assert_eq!(response.status(), Status::Ok); 43 | let response_string = response.into_string().unwrap(); 44 | let cards: Vec = serde_json::from_str(&response_string).unwrap(); 45 | let real_cards = parse_file(); 46 | let real_cards_ref = real_cards.as_ref().expect("Parsing failed"); 47 | assert_eq!(cards.len(), real_cards_ref.len()); 48 | assert!(cards[0] != real_cards_ref[0]); // should be modified 49 | } 50 | -------------------------------------------------------------------------------- /api/src/tests/zmq_running/vote.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::http::{Header, Status}; 3 | use rocket::local::blocking::Client; 4 | 5 | use crate::tests::common::{get_key, test_zmq_server, INIT}; 6 | 7 | #[test] 8 | fn vote() { 9 | INIT.call_once(|| { 10 | test_zmq_server(); 11 | }); 12 | 13 | let client = Client::tracked(rocket()).unwrap(); 14 | let response = client 15 | .post("/vote") 16 | .body(r#"{"user": "1", "id": "1", "isWeekend": true}"#) 17 | .header(Header::new("Authorization", get_key())) 18 | .dispatch(); 19 | assert_eq!(response.status(), Status::Ok); 20 | assert_eq!(response.into_string().unwrap(), r#"{"message":"Success"}"#); 21 | } 22 | 23 | #[test] 24 | fn vote_invalid_key() { 25 | INIT.call_once(|| { 26 | test_zmq_server(); 27 | }); 28 | 29 | let client = Client::tracked(rocket()).unwrap(); 30 | let response = client 31 | .post("/vote") 32 | .body(r#"{"user": "1", "id": "1", "isWeekend": true}"#) 33 | .header(Header::new("Authorization", "invalid")) 34 | .dispatch(); 35 | assert_eq!(response.status(), Status::Forbidden); 36 | } 37 | 38 | #[test] 39 | fn vote_missing_key() { 40 | INIT.call_once(|| { 41 | test_zmq_server(); 42 | }); 43 | 44 | let client = Client::tracked(rocket()).unwrap(); 45 | let response = client 46 | .post("/vote") 47 | .body(r#"{"user": "1", "id": "1", "isWeekend": true}"#) 48 | .dispatch(); 49 | assert_eq!(response.status(), Status::Forbidden); 50 | } 51 | 52 | #[test] 53 | fn vote_invalid_json() { 54 | INIT.call_once(|| { 55 | test_zmq_server(); 56 | }); 57 | let client = Client::tracked(rocket()).unwrap(); 58 | let response = client 59 | .post("/vote") 60 | .body(r#"{"user": "1", "id": "1", "isWeekend": true"#) 61 | .header(Header::new("Authorization", get_key())) 62 | .dispatch(); 63 | assert_eq!(response.status(), Status::BadRequest); 64 | } 65 | -------------------------------------------------------------------------------- /api/src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate rocket; 3 | use rocket::routes; 4 | 5 | // add routes module 6 | mod db; 7 | mod fairings; 8 | mod routes; 9 | #[cfg(test)] 10 | mod tests; 11 | 12 | // import routes 13 | use routes::cards::{get_cards, get_public_cards}; 14 | use routes::commands::get_commands; 15 | use routes::diagnostics::get_diagnostics; 16 | use routes::discord_webhooks::{handle_discord_webhook, webhook_health_check}; 17 | use routes::image::{delete, edit, image, list, upload}; 18 | use routes::news::{delete_news, edit_news, get_news, get_news_by_id, like_news, save_news}; 19 | use routes::stats::get_stats; 20 | use routes::update::{update, update_cors}; 21 | use routes::user::{edit_user, edit_user_by_id, get_userinfo, get_userinfo_by_id}; 22 | use routes::vote::register_vote; 23 | 24 | use fairings::cors::Cors; 25 | use fairings::counter::Counter; 26 | use fairings::timer::RequestTimer; 27 | 28 | #[launch] 29 | fn rocket() -> _ { 30 | rocket::build() 31 | .mount( 32 | "/", 33 | routes![ 34 | get_commands, 35 | register_vote, 36 | get_stats, 37 | image, 38 | upload, 39 | delete, 40 | edit, 41 | list, 42 | get_diagnostics, 43 | get_cards, 44 | get_public_cards, 45 | update, 46 | update_cors, 47 | get_userinfo, 48 | get_userinfo_by_id, 49 | handle_discord_webhook, 50 | webhook_health_check, 51 | get_news, 52 | get_news_by_id, 53 | like_news, 54 | save_news, 55 | delete_news, 56 | edit_news, 57 | edit_user, 58 | edit_user_by_id, 59 | ], 60 | ) 61 | .attach(db::init()) 62 | .attach(Cors) 63 | //.manage(Arc::clone(&counter)) 64 | //.attach(counter) 65 | .attach(Counter) 66 | .attach(RequestTimer) 67 | } 68 | -------------------------------------------------------------------------------- /.github/ISSSUE_TEMPLATE/bug.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | title: "[Bug]: " 4 | labels: [bug, triage] 5 | assignees: 6 | - Kile 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | Thanks for taking the time to fill out this bug report! 12 | - type: input 13 | id: contact 14 | attributes: 15 | label: Contact Details 16 | description: How can we get in touch with you if we need more info? 17 | placeholder: ex. Kile#0606 18 | validations: 19 | required: false 20 | - type: textarea 21 | id: what-happened 22 | attributes: 23 | label: What happened? 24 | description: Describe briefly what issue you had 25 | placeholder: Tell us what you see! 26 | value: "A bug happened!" 27 | validations: 28 | required: true 29 | - type: textarea 30 | id: repro 31 | attributes: 32 | label: How can we reproduce this issue? 33 | description: Give us some steps to consistently reproduce this issue 34 | placeholder: What did you do? 35 | value: "I pressed the red button" 36 | validations: 37 | required: true 38 | - type: textarea 39 | id: expected 40 | attributes: 41 | label: What was the expected response? 42 | description: What did you expect to happen after following the steps described above? 43 | placeholder: What should have happened 44 | value: "I should have gotten cookies" 45 | validations: 46 | required: true 47 | - type: textarea 48 | id: reality 49 | attributes: 50 | label: What did happen? 51 | description: What actually happened after doing these steps? 52 | placeholder: What was the result 53 | value: "I got vegetables :c" 54 | validations: 55 | required: true 56 | - type: textarea 57 | id: additional-info 58 | attributes: 59 | label: Provide additional info 60 | description: Provide additional info here such as screenshots, ids or other comments you want to add 61 | placeholder: What else do you want to say? 62 | value: "The vegetables did taste pretty good though" 63 | validations: 64 | required: false 65 | -------------------------------------------------------------------------------- /killua/__init__.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import aiohttp 3 | import logging 4 | 5 | from . import cogs 6 | from .tests import run_tests 7 | from .migrate import migrate 8 | from .download import download 9 | from .bot import BaseBot as Bot, get_prefix 10 | 11 | # This needs to be in a separate file from the __init__ file to 12 | # avoid relative import errors when subclassing it in the testing module 13 | from .static.constants import TOKEN 14 | from .metrics import PrometheusLoggingHandler, IS_DEV 15 | 16 | import killua.args as args_file 17 | 18 | async def main(): 19 | args_file.Args.get_args() 20 | 21 | args = args_file.Args 22 | 23 | # Set up logger from command line arguments 24 | logging.basicConfig( 25 | level=getattr(logging, args.log.upper()), 26 | 27 | datefmt="%I:%M:%S", 28 | format="[%(asctime)s:%(msecs)03d] %(levelname)s: %(message)s", 29 | handlers=[PrometheusLoggingHandler()], 30 | ) 31 | 32 | if args.migrate: 33 | return await migrate() 34 | 35 | if args.test is not None: 36 | return await run_tests(args.test) 37 | 38 | if args.download: 39 | return await download(args.download.lower()) 40 | 41 | session = aiohttp.ClientSession() 42 | intents = discord.Intents( 43 | guilds=True, 44 | members=True, 45 | emojis_and_stickers=True, # this is not needed in the code but I'd like to have it 46 | messages=True, 47 | message_content=True, 48 | ) 49 | # Create the bot instance. 50 | bot = Bot( 51 | command_prefix=get_prefix, 52 | description="The discord bot Killua", 53 | case_insensitive=True, 54 | intents=intents, 55 | session=session, 56 | ) 57 | bot.session = session 58 | # Checks if the bot is a dev bot 59 | bot.is_dev = args.development 60 | bot.run_in_docker = args.docker 61 | bot.force_local = args.force_local 62 | 63 | # These are things we want Grafana to have access to 64 | IS_DEV.labels(str(bot.is_dev).lower()).set(1) 65 | 66 | # Setup cogs. 67 | for cog in cogs.all_cogs: 68 | await bot.add_cog(cog.Cog(bot)) 69 | 70 | await bot.start(TOKEN) 71 | -------------------------------------------------------------------------------- /killua/tests/types/guild.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from discord import Guild, Asset 4 | 5 | from .utils import get_random_discord_id, random_name 6 | 7 | from typing import Union 8 | 9 | 10 | class TestingGuild: 11 | """A class simulating a discord guild""" 12 | 13 | __class__ = Guild 14 | 15 | def __init__(self, **kwargs): 16 | self.id: int = kwargs.pop("id", get_random_discord_id()) 17 | self.name: str = kwargs.pop("name", random_name()) 18 | self.owner_id: int = kwargs.pop("owner_id", get_random_discord_id()) 19 | self.region: str = kwargs.pop("region", "us") 20 | self.afk_channel_id: int = kwargs.pop("afk_channel_id", None) 21 | self.afk_timeout: int = kwargs.pop("afk_timeout", 1) 22 | self.verification_level: int = kwargs.pop("verification_level", 0) 23 | self.default_message_notifications: int = kwargs.pop( 24 | "default_message_notifications", 0 25 | ) 26 | self.explicit_content_filter: int = kwargs.pop("explicit_content_filter", 0) 27 | self.roles: list = kwargs.pop("roles", []) 28 | self.mfa_level: int = kwargs.pop("mfa_level", 0) 29 | self.nsfw_level: int = kwargs.pop("nsfw_level", 0) 30 | self.application_id: Union[int, None] = kwargs.pop("application_id", None) 31 | self.system_channel_id: Union[int, None] = kwargs.pop("system_channel_id", None) 32 | self.system_channel_flags: int = kwargs.pop("system_channel_flags", 0) 33 | self.rules_channel_id: Union[int, None] = kwargs.pop("rules_channel_id", None) 34 | self.vanity_url_code: Union[int, None] = kwargs.pop("vanity_url_code", None) 35 | self.banner: Union[Asset, None] = kwargs.pop("banner", None) 36 | self.premium_tier: int = kwargs.pop("premium_tier", 0) 37 | self.preferred_locale: str = kwargs.pop("preferred_locale", "us") 38 | self.public_updates_channel_id: Union[int, None] = kwargs.pop( 39 | "public_updates_channel_id", None 40 | ) 41 | self.stickers: list = kwargs.pop("stickers", []) 42 | self.stage_instances: list = kwargs.pop("stage_instances", []) 43 | self.guild_scheduled_events: list = kwargs.pop("guild_sceduled_events", []) 44 | -------------------------------------------------------------------------------- /killua/migrate.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file contains one function, `migrate`, which is used to migrate the database from one version to another once when 3 | an update is released. It can be run through the command line with `python3 -m killua --migrate` 4 | """ 5 | import discord 6 | import logging 7 | from asyncio import sleep 8 | from typing import Type, cast 9 | from pymongo.asynchronous.collection import AsyncCollection as Collection 10 | from discord.ext.commands import AutoShardedBot, HybridGroup 11 | from killua.static.constants import DB 12 | 13 | 14 | async def migrate_requiring_bot(bot: Type[AutoShardedBot]): 15 | """ 16 | Migrates the database from one version to another, requiring a bot instance. Automatically called when the bot starts if `--migrate` was run before. 17 | """ 18 | logging.info("Migrating database...") 19 | 20 | for i, guild in enumerate(bot.guilds): 21 | logging.debug(f"Updating guild {i + 1}/{len(bot.guilds)}: {guild.name} ({guild.id})") 22 | # Update the guild data with the new structure 23 | await DB.guilds.update_one( 24 | {"id": cast(discord.Guild, guild).id}, 25 | { 26 | "$set": { 27 | "added_on": cast(discord.Guild, guild).me.joined_at, 28 | } 29 | }, 30 | ) 31 | if i % 100 == 0 and i != 0: 32 | logging.info(f"Updated {i} guilds") 33 | await sleep(1) 34 | 35 | logging.info("Migrated all guild data to include added_on field") 36 | 37 | 38 | async def migrate(): 39 | """ 40 | Migrates db versions without needing the bot instance. Also sets "migrate" to True in 41 | the database so the migrate requiring bots can be run afterwards. 42 | """ 43 | await DB.teams.update_many( 44 | {"achivements": {"$exists": True}}, 45 | [ 46 | {"$set": {"achievements": "$achivements"}}, 47 | ] 48 | ) 49 | 50 | await DB.teams.update_many( 51 | {"achievements": {"$exists": True}}, 52 | [ 53 | {"$unset": "achivements"}, 54 | ] 55 | ) 56 | 57 | logging.info("Migrated user achievements key to achievements successfully") 58 | 59 | await DB.const.update_one( 60 | {"_id": "migrate"}, 61 | {"$set": {"value": True}}, 62 | ) -------------------------------------------------------------------------------- /api/src/fairings/counter.rs: -------------------------------------------------------------------------------- 1 | use mongodb::Client; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use rocket::fairing::{Fairing, Info, Kind}; 5 | use rocket::http::Status; 6 | use rocket::{Data, Request, Response}; 7 | 8 | use crate::db::models::ApiStats; 9 | 10 | #[derive(Serialize, Deserialize, Clone, Debug, Default)] 11 | pub struct Endpoint { 12 | pub requests: Vec, // int representation of DateTime 13 | pub successful_responses: usize, 14 | } 15 | 16 | #[derive(Default, Debug)] 17 | pub struct Counter; 18 | 19 | /// Parse the endpoint to turn endpoints like /image/folder/image.png into 20 | /// just /image 21 | fn parse_endpoint(rocket: &rocket::Rocket, endpoint: String) -> String { 22 | let all_endpoints = rocket.routes(); 23 | // If and endpoint starts with endpoint, return the startswith part 24 | for route in all_endpoints { 25 | if route.name.is_none() { 26 | continue; 27 | } 28 | if endpoint.starts_with(&("/".to_owned() + route.name.as_ref().unwrap().as_ref())) { 29 | return "/".to_owned() + route.name.as_ref().unwrap().as_ref(); 30 | } 31 | } 32 | 33 | endpoint 34 | } 35 | 36 | #[rocket::async_trait] 37 | impl Fairing for Counter { 38 | fn info(&self) -> Info { 39 | Info { 40 | name: "GET/POST Counter", 41 | kind: Kind::Request | Kind::Response | Kind::Shutdown, 42 | } 43 | } 44 | 45 | async fn on_request(&self, req: &mut Request<'_>, _: &mut Data<'_>) { 46 | let dbclient = req.rocket().state::().unwrap(); 47 | let statsdb = ApiStats::new(dbclient); 48 | let id = parse_endpoint(req.rocket(), req.uri().path().to_string()); 49 | // Update the database with the current stats 50 | tokio::spawn(async move { 51 | statsdb.add_request(&id).await; 52 | }); 53 | } 54 | 55 | async fn on_response<'r>(&self, req: &'r Request<'_>, res: &mut Response<'r>) { 56 | let status = res.status(); 57 | if status == Status::Ok { 58 | let dbclient = req.rocket().state::().unwrap(); 59 | let statsdb = ApiStats::new(dbclient); 60 | let id = parse_endpoint(req.rocket(), req.uri().path().to_string()); 61 | // Update the database with the current stats 62 | tokio::spawn(async move { 63 | statsdb.add_successful_response(&id).await; 64 | }); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /.github/workflows/api-test.yml: -------------------------------------------------------------------------------- 1 | name: Run API Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | env: 8 | RUST_BACKTRACE: full 9 | MONGODB: mongodb://localhost:27017/Killua 10 | API_KEY: test 11 | HASH_SECRET: supersecretpassword 12 | PUBLIC_KEY: 1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef 13 | 14 | jobs: 15 | setup: 16 | runs-on: ubuntu-latest 17 | outputs: 18 | api: ${{ steps.changes.outputs.api }} 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: dorny/paths-filter@v3 22 | id: changes 23 | with: 24 | filters: | 25 | api: 26 | - 'api/**/*.rs' 27 | - 'api/Cargo.toml' 28 | - 'api/Cargo.lock' 29 | 30 | # run only if some file in 'api' folder was changed ending with .rs 31 | test: 32 | needs: setup 33 | if: ${{ needs.setup.outputs.api == 'true' }} 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Start MongoDB server 38 | uses: supercharge/mongodb-github-action@1.11.0 39 | with: 40 | mongodb-version: 'latest' 41 | - name: Setup mongodb-tools 42 | run: | 43 | wget https://downloads.mongodb.com/compass/mongodb-mongosh_2.2.6_amd64.deb 44 | sudo apt install ./mongodb-mongosh_2.2.6_amd64.deb 45 | # Initialise db and collections 46 | mongosh --eval "db.getSiblingDB('Killua').createCollection('api-stats')" 47 | 48 | # - name: Initialise MongoDB Database and Collection 49 | # run: | 50 | # mongo --host localhost:27017 << EOF 51 | # use Killua; 52 | # db.createCollection("api-stats"); 53 | # EOF 54 | - uses: actions-rs/toolchain@v1 55 | with: 56 | toolchain: stable 57 | 58 | - name: Setup local cards 59 | run: "echo '[{\"id\": 0, \"name\": \"Name\", \"description\": \"One two\", \"image\": \"/image/image.png\", \"emoji\": \":pensive\", \"rank\": \"S\", \"limit\": 10, \"type\": \"monster\", \"available\": true}]' > cards.json" 60 | - name: Make update.sh executable 61 | run: chmod +x scripts/update.sh 62 | - name: Run clippy 63 | run: cargo clippy --all --all-features --tests -- -D warnings 64 | working-directory: api 65 | - name: Run cargo fmt 66 | run: cargo fmt --all -- --check 67 | working-directory: api 68 | - name: Run tests 69 | run: cargo test -- --test-threads=1 70 | working-directory: api 71 | -------------------------------------------------------------------------------- /killua/args.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from typing import Optional 4 | 5 | class _Args: 6 | development: Optional[bool] = None 7 | migrate: Optional[bool] = None 8 | test: Optional[bool] = None 9 | log: Optional[str] = None 10 | download: Optional[str] = None 11 | 12 | @classmethod 13 | def get_args(cls) -> None: 14 | 15 | parser = argparse.ArgumentParser(description="CLI arguments for the bot") 16 | parser.add_argument( 17 | "-d", 18 | "--development", 19 | help="Run the bot in development mode", 20 | action="store_const", 21 | const=True, 22 | ) 23 | parser.add_argument( 24 | "-m", 25 | "--migrate", 26 | help="Migrates the database setup from a previous version to the current one", 27 | action="store_const", 28 | const=True, 29 | ) 30 | parser.add_argument( 31 | "-t", 32 | "--test", 33 | help="Run the tests", 34 | nargs="*", 35 | default=None, 36 | metavar=("cog", "command"), 37 | ) 38 | parser.add_argument( 39 | "-l", 40 | "--log", 41 | help="Set the logging level", 42 | default="INFO", 43 | choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], 44 | metavar="level", 45 | ) 46 | parser.add_argument( 47 | "-dl", 48 | "--download", 49 | help="Download all cards into a file for testing and modifying cards", 50 | default=None, 51 | choices=["public", "private"], 52 | metavar="type", 53 | ) 54 | parser.add_argument( 55 | "-dc", 56 | "--docker", 57 | help="Set if the bot is running in a docker container", 58 | action="store_const", 59 | const=True, 60 | ) 61 | parser.add_argument( 62 | "-fl", 63 | "--force-local", 64 | help="Force the bot to download the cards data from the local API. Only relevant for development. Useful if server is down or you want to test new cards defined locally.", 65 | action="store_const", 66 | const=True, 67 | ) 68 | 69 | parsed = parser.parse_args() 70 | 71 | cls.development = parsed.development 72 | cls.migrate = parsed.migrate 73 | cls.test = parsed.test 74 | cls.log = parsed.log 75 | cls.download = parsed.download 76 | cls.docker = parsed.docker 77 | cls.force_local = parsed.force_local 78 | 79 | 80 | def init(): 81 | global Args 82 | Args = _Args 83 | -------------------------------------------------------------------------------- /script_service/src/main.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use serde::Deserialize; 3 | use serde_json::from_str; 4 | use std::process::Command; 5 | use zmq::{Context, DONTWAIT, Message, POLLIN, ROUTER, poll}; 6 | 7 | #[allow(dead_code)] 8 | #[derive(Debug, Deserialize)] 9 | struct Instruction<'a> { 10 | route: &'a str, 11 | data: &'a str, 12 | } 13 | 14 | fn main() { 15 | // set logging level to info 16 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); 17 | 18 | let context = Context::new(); 19 | let responder = context.socket(ROUTER).unwrap(); 20 | let poller = responder.as_poll_item(POLLIN); 21 | 22 | assert!(responder.connect("tcp://127.0.0.1:5558").is_ok()); 23 | 24 | info!("Starting device..."); 25 | 26 | let items = &mut [poller]; 27 | 28 | // Wait for a request 29 | loop { 30 | // Poll for incoming messages 31 | info!("Waiting for messages..."); 32 | if poll(items, -1).is_err() { 33 | info!("Polling failed... Retrying..."); 34 | continue; // Skip to the next iteration if polling fails 35 | } 36 | if !items[0].is_readable() { 37 | info!("No messages received, continuing to wait..."); 38 | continue; // Skip to the next iteration if no messages are readable 39 | } 40 | info!("Message received, processing..."); 41 | let mut identity = Message::new(); 42 | responder.recv(&mut identity, DONTWAIT).unwrap(); 43 | let str = responder.recv_string(0).unwrap(); 44 | info!("Received string: {:?}", str); 45 | let str_unwrapped = str.unwrap(); 46 | let parsed = from_str::(&str_unwrapped).unwrap(); 47 | info!("Received message: {}", parsed.data); 48 | // Execute the command 49 | let output = Command::new("sh") 50 | .current_dir("..") 51 | .arg("-c") 52 | .arg(parsed.data) 53 | .output() 54 | .expect("Failed to run command"); 55 | // Get the exit code 56 | let exit_code = output.status.code().unwrap_or(-1); 57 | // Get the output 58 | let stdout = String::from_utf8_lossy(&output.stdout); 59 | let stderr = String::from_utf8_lossy(&output.stderr); 60 | let full_output = format!("{}{}", stdout, stderr); 61 | // Prepare the response 62 | let respond_with = format!("EXIT_CODE={}\nOUTPUT={}", exit_code, full_output); 63 | info!("Responding with: {}", respond_with); 64 | // Send the response back 65 | responder 66 | .send_multipart(vec![identity.to_vec(), respond_with.as_bytes().to_vec()], 0) 67 | .unwrap(); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /api/src/tests/zmq_down/diagnostics.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::http::{Header, Status}; 3 | use rocket::local::blocking::Client; 4 | use serde_json::from_str; 5 | 6 | use crate::routes::diagnostics::{DiagnosticsResponse, EndpointSummary}; 7 | use crate::tests::common::get_key; 8 | 9 | #[test] 10 | fn diagnostics_when_down() { 11 | // zmq server is down 12 | let client = Client::tracked(rocket()).unwrap(); 13 | let response = client 14 | .get("/diagnostics") 15 | .header(Header::new("Authorization", get_key())) 16 | .dispatch(); 17 | assert_eq!(response.status(), Status::Ok); 18 | // Check the json body ipc.success is false, ignore other fields which may not be empty 19 | let parsed_response = 20 | from_str::(&response.into_string().unwrap()).unwrap(); 21 | assert!(!parsed_response.ipc.success); 22 | assert_eq!(parsed_response.ipc.response_time, None); 23 | } 24 | 25 | #[test] 26 | fn diagnostics_plus_one_success() { 27 | let client = Client::tracked(rocket()).unwrap(); 28 | // Get initial stats 29 | let response = client 30 | .get("/diagnostics") 31 | .header(Header::new("Authorization", get_key())) 32 | .dispatch(); 33 | 34 | assert_eq!(response.status(), Status::Ok); 35 | let parsed_response = 36 | serde_json::from_str::(&response.into_string().unwrap()).unwrap(); 37 | 38 | let initial_values = parsed_response.usage.clone(); 39 | 40 | // Make a request to /stats 41 | let response = client.get("/stats").dispatch(); 42 | assert_eq!(response.status(), Status::BadRequest); // This fails because the zmq server is down 43 | 44 | // Get stats again 45 | let response = client 46 | .get("/diagnostics") 47 | .header(Header::new("Authorization", get_key())) 48 | .dispatch(); 49 | assert_eq!(response.status(), Status::Ok); 50 | 51 | let parsed_response = 52 | serde_json::from_str::(&response.into_string().unwrap()).unwrap(); 53 | let new_values = parsed_response.usage.clone(); 54 | 55 | // Check that the values have increased by one 56 | assert!( 57 | initial_values 58 | .get("/stats") 59 | .unwrap_or(&EndpointSummary::default()) 60 | .request_count 61 | + 1 62 | == new_values.get("/stats").unwrap().request_count 63 | ); 64 | // Request fails so the successful_responses should be the same 65 | assert!( 66 | initial_values 67 | .get("/stats") 68 | .unwrap_or(&EndpointSummary::default()) 69 | .successful_responses 70 | == new_values.get("/stats").unwrap().successful_responses 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /killua/tests/types/context.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Context, Command 2 | from discord.ui import View 3 | 4 | from .testing_results import ResultData 5 | from .message import TestingMessage as Message 6 | from .user import TestingUser as User 7 | from .channel import TestingTextChannel as TextChannel 8 | from .member import TestingMember as Member 9 | 10 | from typing import Union 11 | from functools import partial 12 | 13 | 14 | class TestingContext: 15 | """A class creating a suitable testing context object""" 16 | 17 | __class__ = Context 18 | 19 | def __init__(self, **kwargs): 20 | self.result: Union[ResultData, None] = None 21 | self.me: User = User() 22 | self.message: Message = kwargs.pop("message") 23 | self.bot: User = kwargs.pop("bot") 24 | self.channel: TextChannel = self.message.channel 25 | self.author: Member = self.message.author 26 | self.command: Union[Command, None] = None 27 | 28 | self.current_view: Union[View, None] = None 29 | self.message.channel.ctx: TestingContext = self 30 | self.message.ctx: TestingContext = self 31 | self.timeout_view: bool = False 32 | 33 | async def reply(self, content: str, *args, **kwargs) -> Message: 34 | """Replies to the current message""" 35 | message = Message( 36 | author=self.me, channel=self.channel, content=content, *args, **kwargs 37 | ) 38 | self.result = ResultData(message=message) 39 | self.current_view: Union[View, None] = kwargs.pop("view", None) 40 | 41 | if self.current_view: 42 | if self.timeout_view: 43 | await self.current_view.on_timeout() 44 | self.current_view.stop() 45 | else: 46 | self.current_view.wait = partial(self.respond_to_view, self) 47 | message.ctx = self 48 | return message 49 | 50 | async def send(self, content: str = None, *args, **kwargs) -> Message: 51 | """Sends a message""" 52 | message = Message( 53 | author=self.me, channel=self.channel, content=content, *args, **kwargs 54 | ) 55 | self.result = ResultData(message=message) 56 | self.current_view: Union[View, None] = kwargs.pop("view", None) 57 | 58 | if self.current_view: 59 | if self.timeout_view: 60 | await self.current_view.on_timeout() 61 | self.current_view.stop() 62 | else: 63 | self.current_view.wait = partial(self.respond_to_view, self) 64 | message.ctx = self 65 | return message 66 | 67 | async def invoke(self, command: str, *args, **kwargs) -> None: 68 | """Invokes a command""" 69 | ... 70 | 71 | async def respond_to_view(self, context: Context) -> None: 72 | """This determined how a view is responded to once it is sent. This is meant to be overwritten""" 73 | ... 74 | -------------------------------------------------------------------------------- /killua/tests/types/channel.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List, Union 4 | from functools import partial 5 | 6 | from .message import TestingMessage as Message 7 | from .guild import TestingGuild as Guild 8 | from .testing_results import ResultData 9 | 10 | from discord import Guild, TextChannel, ui 11 | 12 | if TYPE_CHECKING: 13 | from discord.types.channel import PermissionOverwrite, CategoryChannel 14 | 15 | from .utils import get_random_discord_id 16 | 17 | 18 | class TestingTextChannel: 19 | """A class imulating a discord text channel""" 20 | 21 | __class__ = TextChannel 22 | 23 | def __init__(self, guild: Guild, permissions: List[dict] = [], **kwargs): 24 | self.guild: Guild = guild 25 | self.name: str = kwargs.pop("name", "test") 26 | self.id: int = kwargs.pop("id", get_random_discord_id()) 27 | self.guild_i: int = kwargs.pop("guild_id", get_random_discord_id()) 28 | self.position: int = kwargs.pop("position", 1) 29 | self.permission_overwrites: List[PermissionOverwrite] = ( 30 | self.__handle_permissions(permissions) 31 | ) 32 | self.nsfw: bool = kwargs.pop("nsfw", False) 33 | self.parent: Union[CategoryChannel, None] = kwargs.pop("parent", None) 34 | self.type: int = kwargs.pop("type", 0) 35 | self._has_permission: int = kwargs.pop("has_permission", True) 36 | 37 | self.history_return: List[Message] = [] 38 | 39 | def __handle_permissions(self, permissions) -> None: 40 | """Handles permissions""" 41 | if len(permissions) == 0: 42 | return [] 43 | 44 | if isinstance(permissions[0], int): 45 | for perm in permissions: 46 | perm = PermissionOverwrite(perm) # lgtm [py/multiple-definition] 47 | 48 | return permissions 49 | 50 | async def history( 51 | self, limit: int = None, before: Message = None, after: Message = None 52 | ) -> List[Message]: 53 | """Gets the history of the channel""" 54 | for message in self.history_return[:limit]: 55 | yield message 56 | 57 | async def send(self, content: str, *args, **kwargs) -> None: 58 | """Sends a message""" 59 | message = Message( 60 | author=self.me, channel=self.channel, content=content, *args, **kwargs 61 | ) 62 | self.result = ResultData(message=message) 63 | self.ctx.current_view: Union[ui.View, None] = kwargs.pop("view", None) 64 | 65 | if self.ctx.current_view: 66 | if self.ctx.timeout_view: 67 | await self.ctx.current_view.on_timeout() 68 | self.ctx.current_view.stop() 69 | else: 70 | self.ctx.current_view.wait = partial(self.ctx.respond_to_view, self.ctx) 71 | return message 72 | 73 | def permissions_for(self, member: Guild.Member) -> ui.Permissions: 74 | """Gets the permissions for a member""" 75 | return self._has_permission 76 | -------------------------------------------------------------------------------- /killua/tests/types/message.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | from functools import partial 5 | 6 | from discord import Message, Member, TextChannel 7 | 8 | if TYPE_CHECKING: 9 | from discord.types.message import Message as MessagePayload 10 | 11 | from .utils import get_random_discord_id, random_date 12 | 13 | 14 | class TestingMessage: 15 | """A class to construct a testing""" 16 | 17 | __class__ = Message 18 | 19 | def __init__(self, author: Member, channel: TextChannel, **kwargs): 20 | 21 | self.deleted = False 22 | self.edited = False 23 | self.published = False 24 | 25 | self.author = author 26 | self.channel = channel 27 | self.channel_id = kwargs.pop("channel_id", get_random_discord_id()) 28 | self.guild_id = kwargs.pop("guild_id", get_random_discord_id()) 29 | self.id = kwargs.pop("id", get_random_discord_id()) 30 | self.content = kwargs.pop("content", "") 31 | self.timestamp = kwargs.pop("timestamp", str(random_date())) 32 | self.edited_timestamp = kwargs.pop("edited_timestamp", None) 33 | self.tts = (kwargs.pop("tts", False),) 34 | self.mention_everyone = (kwargs.pop("mention_everyone", False),) 35 | self.mentions = (kwargs.pop("mentions", []),) 36 | self.mention_roles = (kwargs.pop("mention_roles", []),) 37 | self.attachments = (kwargs.pop("attachments", []),) 38 | self.embeds = (kwargs.get("embeds", []),) 39 | self.pinned = (kwargs.pop("pinned", False),) 40 | self.type = kwargs.pop( 41 | "type", 0 42 | ) # https://discord.com/developers/docs/resources/channel#message-object-message-types 43 | self.referencing = kwargs.pop("reference", None) 44 | self.reference = self 45 | 46 | if "embed" in kwargs: 47 | self.embeds = [kwargs.pop("embed")] 48 | 49 | async def edit(self, **kwargs) -> None: 50 | """Edits the message""" 51 | self.__dict__.update(kwargs) # Changes the properties defined in the kwargs 52 | self.edited = True 53 | self.ctx.current_view = kwargs.pop("view", None) 54 | if "embed" in kwargs: 55 | self.embeds.append(kwargs["embed"]) 56 | 57 | if self.ctx.current_view: 58 | if not len( 59 | [c for c in self.ctx.current_view.children if c.disabled] 60 | ) == len(self.ctx.current_view.children): 61 | self.ctx.current_view.wait = partial(self.ctx.respond_to_view, self.ctx) 62 | else: 63 | return 64 | 65 | self.ctx.result.message = self 66 | # print( 67 | # "Edited view: ", self.view, 68 | # "Edited embeds: ", self.embeds, 69 | # "Edited content: ", self.content 70 | # ) 71 | 72 | async def delete(self) -> None: 73 | """Deletes the message""" 74 | self.deleted = True 75 | 76 | async def publish(self) -> None: 77 | """Publishes the message""" 78 | self.published = True 79 | -------------------------------------------------------------------------------- /killua/tests/types/permissions.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | class Permission: 5 | create_instant_invite = 1 << 0 6 | kick_members = 1 << 1 7 | ban_members = 1 << 2 8 | administrator = 1 << 3 9 | manage_channels = 1 << 4 10 | manage_guild = 1 << 5 11 | add_reaction = 1 << 6 12 | view_audit_log = 1 << 7 13 | priority_speaker = 1 << 8 14 | stream = 1 << 9 15 | read_messages = 1 << 10 16 | view_channel = 1 << 10 17 | send_messages = 1 << 11 18 | send_tts_messages = 1 << 12 19 | manage_messages = 1 << 13 20 | embed_links = 1 << 14 21 | attach_files = 1 << 15 22 | read_message_history = 1 << 16 23 | mention_everyone = 1 << 17 24 | external_emojis = 1 << 18 25 | use_external_emojis = 1 << 18 26 | view_guild_insights = 1 << 19 27 | connect = 1 << 20 28 | speak = 1 << 21 29 | mute_members = 1 << 22 30 | deafen_members = 1 << 23 31 | move_members = 1 << 24 32 | use_voice_activation = 1 << 25 33 | change_nickname = 1 << 26 34 | manage_nicknames = 1 << 27 35 | manage_roles = 1 << 28 36 | manage_permissions = 1 << 28 37 | manage_webhooks = 1 << 29 38 | manage_emojis = 1 << 30 39 | use_application_commands = 1 << 31 40 | request_to_speak = 1 << 32 41 | manage_events = 1 << 33 42 | manage_threads = 1 << 34 43 | create_public_threads = 1 << 35 44 | create_private_threads = 1 << 36 45 | external_stickers = 1 << 37 46 | use_external_stickers = 1 << 37 47 | send_messages_in_threads = 1 << 38 48 | use_embedded_activities = 1 << 39 49 | moderate_members = 1 << 40 50 | 51 | 52 | class PermissionOverwrite: 53 | def __init__(self): 54 | self.allow: int = 0 55 | self.deny: int = 0 56 | 57 | def allow_perms(self, perms: List[Permission]) -> None: 58 | for val in perms: 59 | self.allow |= int(val) 60 | 61 | def deny_perms(self, perms: List[Permission]) -> None: 62 | for val in perms: 63 | self.deny |= int(val) 64 | 65 | 66 | class Permissions: 67 | def __init__(self, permissions: int = 0, **kwargs): 68 | self.value = permissions 69 | for key, value in kwargs.items(): 70 | setattr(self, key, value) 71 | 72 | self.overwrites = [] 73 | 74 | def add_overwrite(self, **kwargs): 75 | overwrite = {} 76 | 77 | overwrite["type"] = kwargs.pop( 78 | "type", 1 79 | ) # For testing usecase it will be a member most of the time 80 | # 0 - role 81 | # 1 - member 82 | overwrite["id"] = kwargs.pop( 83 | "id" 84 | ) # I raise an error here because if the id is not provided 85 | # this enitre method has no point 86 | if "permissions" in kwargs: # Reads the permission from the enum shortcut 87 | overwrite["allow"] = kwargs["permissions"].allow 88 | overwrite["deny"] = kwargs["permissions"].deny 89 | else: 90 | overwrite["allow"] = kwargs["allow"] 91 | overwrite["deny"] = kwargs["deny"] 92 | 93 | self.overwrites.append(overwrite) 94 | -------------------------------------------------------------------------------- /scripts/post-push-hook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Load configuration from .sync_config 4 | if [ -f ".sync_config" ]; then 5 | source .sync_config 6 | else 7 | echo "Configuration file .sync_config not found. Exiting." 8 | exit 1 9 | fi 10 | 11 | # Ensure the current branch is main 12 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) 13 | if [ "$CURRENT_BRANCH" != "main" ]; then 14 | echo "Current branch is '$CURRENT_BRANCH'. Sync will only occur on 'main'." 15 | exit 0 16 | fi 17 | 18 | # Function to sync files (e.g., cards.json) 19 | sync_file() { 20 | local file_path=$1 21 | local remote_path="$REMOTE_BASE_PATH/$(basename $file_path)" 22 | echo "Syncing $file_path to $remote_path on $REMOTE_HOST" 23 | if [ "$USE_SSH_KEY" = "true" ]; then 24 | scp -i "$SSH_KEY_PATH" "$file_path" "$REMOTE_USER@$REMOTE_HOST:$remote_path" 25 | else 26 | scp "$file_pat" "$REMOTE_USER@$REMOTE_HOST:$remote_path" 27 | fi 28 | } 29 | 30 | # Function to sync folders (e.g., assets/hugs or assets/cards) 31 | sync_folder() { 32 | local folder_path=$1 33 | local remote_path="$REMOTE_BASE_PATH/assets/" 34 | if [ -d "assets/$folder_path" ]; then 35 | echo "Syncing folder assets/$folder_path to $remote_path$folder_path on $REMOTE_HOST" 36 | if [ "$USE_SSH_KEY" = "true" ]; then 37 | scp -i "$SSH_KEY_PATH" -r "assets/$folder_path" "$REMOTE_USER@$REMOTE_HOST:$remote_path" 38 | else 39 | scp -r "assets/$folder_path" "$REMOTE_USER@$REMOTE_HOST:$remote_path" 40 | fi 41 | else 42 | echo "assets/$folder_path does not exist. Skipping." 43 | fi 44 | } 45 | 46 | # Double confirmation before syncing 47 | echo "WARNING: This action could override files on the remote server." 48 | 49 | # Sync option menu 50 | echo "Select what you would like to sync:" 51 | echo "1. all - Sync all gitignored folders and cards.json" 52 | echo "2. images - Sync all gitignored image folders (hugs, cards)" 53 | echo "3. hugs - Sync gitignored hug images (assets/hugs/*)" 54 | echo "4. cards-images - Sync gitignored cards images (assets/cards/*)" 55 | echo "5. cards-json - Sync cards.json" 56 | echo "6. none/abort - Do not sync anything" 57 | 58 | read -p "Enter your choice (1/2/3/4/5/6): " choice 59 | 60 | case $choice in 61 | 1) 62 | # Sync all folders and cards.json 63 | sync_file "cards.json" 64 | sync_folder "hugs" 65 | sync_folder "cards" 66 | ;; 67 | 2) 68 | # Sync all image folders 69 | sync_folder "hugs" 70 | sync_folder "cards" 71 | ;; 72 | 3) 73 | # Sync hug images 74 | sync_folder "hugs" 75 | ;; 76 | 4) 77 | # Sync cards images 78 | sync_folder "cards" 79 | ;; 80 | 5) 81 | # Sync cards.json only 82 | sync_file "cards.json" 83 | ;; 84 | 6) 85 | # Abort sync 86 | echo "Sync aborted. No files will be synced." 87 | exit 0 88 | ;; 89 | *) 90 | echo "Invalid choice. Sync aborted." 91 | exit 0 92 | ;; 93 | esac 94 | 95 | echo "Sync complete." 96 | -------------------------------------------------------------------------------- /api/src/routes/update.rs: -------------------------------------------------------------------------------- 1 | use super::common::discord_auth::DiscordAuth; 2 | 3 | use serde_json::Value; 4 | 5 | use super::common::utils::{make_request, ResultExt}; 6 | use rocket::response::status::BadRequest; 7 | use rocket::serde::json::Json; 8 | 9 | #[derive(FromFormField, Debug)] 10 | pub enum TestOption { 11 | Pass, 12 | Fail, 13 | } 14 | 15 | struct CommandResult { 16 | exit_code: i32, 17 | output: String, 18 | } 19 | 20 | async fn send_command_and_get_result( 21 | command: String, 22 | first_bit: u8, 23 | is_test: bool, 24 | ) -> Result>> { 25 | // List files in the directory 26 | let response = make_request("update", command, first_bit, !is_test) 27 | .await 28 | .context("Failed to get command output")?; 29 | 30 | let exit_code = response 31 | .split("\n") 32 | .next() 33 | .unwrap_or("EXIT_CODE=0") 34 | .to_string(); 35 | let parsed_exit_code = exit_code 36 | .split('=') 37 | .nth(1) 38 | .unwrap_or("0") 39 | .parse::() 40 | .unwrap_or(0); 41 | let output = response 42 | .split("\n") 43 | .skip(1) 44 | .collect::>() 45 | .join("\n"); 46 | 47 | // Remove OUTPUT= prefix if it exists 48 | let output = if let Some(stripped) = output.strip_prefix("OUTPUT=") { 49 | stripped.to_string() 50 | } else { 51 | output 52 | }; 53 | 54 | Ok(CommandResult { 55 | exit_code: parsed_exit_code, 56 | output, 57 | }) 58 | } 59 | 60 | #[post("/update?&")] 61 | pub async fn update( 62 | auth: DiscordAuth, 63 | force: Option, 64 | test: Option, 65 | ) -> Result, BadRequest>> { 66 | // Check if user is admin 67 | if !auth.is_admin() { 68 | return Err(BadRequest(Json( 69 | serde_json::json!({"error": "Access denied: Admin privileges required"}), 70 | ))); 71 | } 72 | // Runs a shell script which is in scripts/update.sh 73 | let command = format!( 74 | "scripts/update.sh{}{}", 75 | match test { 76 | Some(TestOption::Pass) => " --test-pass", 77 | Some(TestOption::Fail) => " --test-fail", 78 | None => "", 79 | }, 80 | if force.unwrap_or(false) { 81 | " --force" 82 | } else { 83 | "" 84 | } 85 | ); 86 | 87 | let output = send_command_and_get_result(command, 1_u8, test.is_some()).await?; 88 | 89 | if output.exit_code != 0 { 90 | return Err(BadRequest(Json( 91 | serde_json::json!({"error": format!("Update script failed: {}", output.output)}), 92 | ))); 93 | } 94 | 95 | Ok(Json( 96 | serde_json::json!({"status": format!("Success: {}", output.output)}), 97 | )) 98 | } 99 | 100 | #[options("/update")] // Sucks I have to do this 101 | pub fn update_cors() -> Json { 102 | Json(serde_json::json!({ 103 | "status": "CORS preflight request" 104 | })) 105 | } 106 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '26 8 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | continue-on-error: true # The Python code does not have test coverage, 26 | # and it won't get it any time soon. But I don't want the entire build to fail 27 | # just because of that. 28 | 29 | name: Analyze 30 | runs-on: ubuntu-latest 31 | permissions: 32 | actions: read 33 | contents: read 34 | security-events: write 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: [ 'python' ] 40 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 41 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 42 | 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v3 46 | 47 | # Initializes the CodeQL tools for scanning. 48 | - name: Initialize CodeQL 49 | uses: github/codeql-action/init@v4 50 | with: 51 | languages: ${{ matrix.language }} 52 | # If you wish to specify custom queries, you can do so here or in a config file. 53 | # By default, queries listed here will override any specified in a config file. 54 | # Prefix the list here with "+" to use these queries and those in the config file. 55 | 56 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 57 | # queries: security-extended,security-and-quality 58 | 59 | 60 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 61 | # If this step fails, then you should remove it and run the build manually (see below) 62 | - name: Autobuild 63 | uses: github/codeql-action/autobuild@v4 64 | 65 | # ℹ️ Command-line programs to run using the OS shell. 66 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 67 | 68 | # If the Autobuild fails above, remove it and uncomment the following three lines. 69 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 70 | 71 | # - run: | 72 | # echo "Run, Build Application using script" 73 | # ./location_of_script_within_repo/buildscript.sh 74 | 75 | - name: Perform CodeQL Analysis 76 | uses: github/codeql-action/analyze@v4 77 | -------------------------------------------------------------------------------- /killua/tests/groups/dev.py: -------------------------------------------------------------------------------- 1 | from ..types import * 2 | from ..testing import Testing, test 3 | from ...cogs.dev import Dev 4 | from ...static.constants import DB 5 | 6 | 7 | class TestingDev(Testing): 8 | 9 | def __init__(self): 10 | super().__init__(cog=Dev) 11 | 12 | 13 | class Eval(TestingDev): 14 | 15 | def __init__(self): 16 | super().__init__() 17 | 18 | # more a formality, this command is not really complicated 19 | @test 20 | async def eval(self) -> None: 21 | await self.command(self.cog, self.base_context, code="1+1") 22 | 23 | assert ( 24 | self.base_context.result.message.content == "```py" + "\n" + "2```" 25 | ), self.base_context.result.message.content 26 | 27 | 28 | class Say(TestingDev): 29 | 30 | def __init__(self): 31 | super().__init__() 32 | 33 | # Same as eval, not a complicated command, not many tests 34 | @test 35 | async def say(self) -> None: 36 | await self.command(self.cog, self.base_context, content="Hello World") 37 | 38 | assert ( 39 | self.base_context.result.message.content == "Hello World" 40 | ), self.base_context.result.message.content 41 | 42 | 43 | class Publish_Update(TestingDev): 44 | 45 | def __init__(self): 46 | super().__init__() 47 | 48 | @test 49 | async def publish_already_published_version(self) -> None: 50 | DB.const._collection = [{"_id": "updates", "updates": [{"version": "1.0"}]}] 51 | await self.command(self.cog, self.base_context, version="1.0", update="Test") 52 | 53 | assert ( 54 | self.base_context.result.message.content 55 | == "This is an already existing version" 56 | ), self.base_context.result.message.content 57 | 58 | @test 59 | async def publish_update(self) -> None: 60 | DB.const._collection = [{"_id": "updates", "past_updates": []}] 61 | await self.command(self.cog, self.base_context, version="1.0", update="test") 62 | 63 | assert ( 64 | self.base_context.result.message.content == "Published update" 65 | ), self.base_context.result.message.content 66 | assert DB.const.find_one({"_id": "updates"})["updates"][0]["version"], "1.0" 67 | 68 | 69 | class Update(TestingDev): 70 | 71 | def __init__(self): 72 | super().__init__() 73 | 74 | @test 75 | async def incorrect_usage(self) -> None: 76 | await self.command(self.cog, self.base_context, version="incorrect") 77 | 78 | assert ( 79 | self.base_context.result.message.content == "Invalid version!" 80 | ), self.base_context.result.message.content 81 | 82 | @test 83 | async def correct_usage(self) -> None: 84 | await self.command(self.cog, self.base_context, version="1.0") 85 | 86 | assert ( 87 | self.base_context.result.message.embeds 88 | ), self.base_context.result.message.embeds 89 | assert ( 90 | self.base_context.result.message.embeds[0].title 91 | == "Infos about version `1.0`" 92 | ), self.base_context.result.message.embeds[0].title 93 | assert ( 94 | self.base_context.result.message.embeds[0].description == "test" 95 | ), self.base_context.result.message.embeds[0].description 96 | -------------------------------------------------------------------------------- /killua/tests/types/bot.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | from aiohttp import ClientSession 3 | 4 | # This is a necessary hacky fix for importing issues 5 | SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) 6 | sys.path.append(os.path.dirname(SCRIPT_DIR)) 7 | 8 | from discord import Intents, Message 9 | from discord.abc import Messageable 10 | from ...bot import BaseBot 11 | from .channel import TestingTextChannel 12 | from .user import TestingUser 13 | 14 | from typing import Any, Optional, Callable 15 | from asyncio import get_event_loop, TimeoutError, sleep 16 | 17 | 18 | class TestingBot(BaseBot): 19 | """A class simulating a discord.py bot instance""" 20 | 21 | def __init__(self, *args, **kwargs) -> None: 22 | self.fail_timeout = False 23 | super().__init__(*args, **kwargs) 24 | 25 | def get_channel(self, channel: int): 26 | """Returns a channel object""" 27 | return TestingTextChannel(channel) 28 | 29 | def get_user(self, user: int) -> Any: 30 | return TestingUser(id=user) 31 | 32 | @property 33 | def loop(self): 34 | return get_event_loop() 35 | 36 | @loop.setter 37 | def loop(self, _): # This attribute cannot be changed 38 | ... 39 | 40 | def _schedule_event(self, *args, **kwargs): ... 41 | 42 | def wait_for( 43 | self, 44 | event: str, 45 | /, 46 | *, 47 | check: Optional[Callable[..., bool]] = None, 48 | timeout: Optional[float] = None, 49 | ) -> Any: 50 | if self.fail_timeout: 51 | raise TimeoutError 52 | return BaseBot.wait_for(self, event, check=check, timeout=timeout) 53 | 54 | async def resolve(self, event: str, /, *args: Any) -> None: 55 | await sleep(0.1) # waiting for the command to set up the listener 56 | listeners = self._listeners.get(event) 57 | if listeners: 58 | removed = [] 59 | for i, (future, condition) in enumerate(listeners): 60 | if future.cancelled(): 61 | removed.append(i) 62 | continue 63 | 64 | try: 65 | result = condition(*args) 66 | except Exception as exc: 67 | future.set_exception(exc) 68 | removed.append(i) 69 | else: 70 | if result: 71 | if len(args) == 0: 72 | future.set_result(None) 73 | elif len(args) == 1: 74 | future.set_result(args[0]) 75 | else: 76 | future.set_result(args) 77 | removed.append(i) 78 | 79 | if len(removed) == len(listeners): 80 | self._listeners.pop(event) 81 | else: 82 | for idx in reversed(removed): 83 | del listeners[idx] 84 | 85 | async def send_message(self, messageable: Messageable, *args, **kwargs) -> Message: 86 | """We do not want a tip sent which would ruin the test checks so this is overwritten""" 87 | return await messageable.send( 88 | content=kwargs.pop("content", None), *args, **kwargs 89 | ) 90 | 91 | async def setup_hook(self) -> None: 92 | self.session = ClientSession() 93 | 94 | 95 | BOT = TestingBot(command_prefix="k!", intents=Intents.all()) 96 | -------------------------------------------------------------------------------- /api/src/routes/cards.rs: -------------------------------------------------------------------------------- 1 | use super::common::keys::ApiKey; 2 | use load_file::load_str; 3 | use rocket::response::status::BadRequest; 4 | use rocket::serde::json::Json; 5 | use rocket::serde::{Deserialize, Serialize}; 6 | use std::borrow::Cow; 7 | 8 | use serde_json::Value; 9 | use tokio::sync::OnceCell; 10 | 11 | #[derive(Serialize, Deserialize, Clone, PartialEq)] 12 | pub struct Card<'a> { 13 | id: u32, 14 | name: Cow<'a, str>, 15 | description: Cow<'a, str>, 16 | image: Cow<'a, str>, 17 | emoji: Cow<'a, str>, 18 | rank: Cow<'a, str>, 19 | limit: u32, 20 | r#type: Cow<'a, str>, 21 | available: bool, 22 | class: Option>>, 23 | range: Option>, 24 | } 25 | 26 | static CENSORED_CACHE: OnceCell>>> = OnceCell::const_new(); 27 | static CACHE: OnceCell>>> = OnceCell::const_new(); 28 | 29 | pub fn parse_file() -> Result>, BadRequest>> { 30 | let cards = load_str!("../../../cards.json"); 31 | let cards: Vec> = match serde_json::from_str(cards) { 32 | Ok(cards) => cards, 33 | Err(_) => { 34 | warn!("Failed to parse cards.json"); 35 | return Err(BadRequest(Json(serde_json::json!({ 36 | "error": "Failed to parse cards.json" 37 | })))); 38 | } 39 | }; 40 | Ok(cards) 41 | } 42 | 43 | #[get("/cards.json")] 44 | pub async fn get_cards(_key: ApiKey) -> Result>>, BadRequest>> { 45 | CACHE 46 | .get_or_try_init(|| async { 47 | let cards = parse_file()?; 48 | Ok(Json(cards)) 49 | }) 50 | .await 51 | .cloned() 52 | } 53 | 54 | #[get("/cards.json?public=true")] 55 | pub async fn get_public_cards() -> Result>>, BadRequest>> { 56 | CENSORED_CACHE.get_or_try_init(|| async { 57 | let cards = parse_file()?; 58 | // Censor the description, emoji and image 59 | let cards = cards.into_iter().map(|mut card| { 60 | if card.id == 0 { 61 | card.name = Cow::Borrowed("[REDACTED]"); 62 | } 63 | card.description = Cow::Borrowed("Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat"); 64 | card.emoji = Cow::Borrowed("<:badge_one_star_hunter:788935576836374548>"); 65 | card.image = match card.r#type { 66 | ref t if t == "normal" => if card.id < 100 {Cow::Borrowed("/image/cards/PLACEHOLDER_RESTRICTED_SLOT.png")} else {Cow::Borrowed("/image/cards/PLACEHOLDER_NORMAL.png")}, 67 | ref t if t == "spell" => Cow::Borrowed("/image/cards/PLACEHOLDER_SPELL.png"), 68 | ref t if t == "ruler" => Cow::Borrowed("/image/cards/PLACEHOLDER_RULER.png"), 69 | ref t if t =="monster" => Cow::Borrowed("/image/cards/PLACEHOLDER_NORMAL.png"), 70 | _ => Cow::Borrowed("/image/cards/PLACEHOLDER_NORMAL.png") 71 | }; 72 | card.class = card.class.map(|_| vec![Cow::Borrowed("X")]); 73 | card.range = card.range.map(|_| Cow::Borrowed("X")); 74 | card 75 | }).collect(); 76 | Ok(Json(cards)) 77 | }).await.cloned() 78 | } 79 | -------------------------------------------------------------------------------- /api/src/routes/common/utils.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::{json, Value}; 3 | use tokio::task; 4 | use zmq::{Context, SocketType::DEALER}; 5 | 6 | use rocket::response::status::BadRequest; 7 | use rocket::serde::json::Json; 8 | 9 | pub trait ResultExt { 10 | fn context(self, message: &str) -> Result>>; 11 | } 12 | impl ResultExt for Result { 13 | fn context(self, message: &str) -> Result>> { 14 | error!("{}", message); 15 | self.map_err(|_| BadRequest(Json(json!({ "error": message })))) 16 | } 17 | } 18 | 19 | #[derive(Serialize, Deserialize)] 20 | struct RequestData { 21 | route: String, 22 | data: T, 23 | } 24 | 25 | #[derive(Serialize, Deserialize)] 26 | pub struct NoData {} 27 | 28 | pub fn make_request_inner<'a, T: Serialize + Deserialize<'a>>( 29 | route: &str, 30 | data: T, 31 | first_bit: u8, 32 | no_timeout: bool, 33 | ) -> Result { 34 | let ctx = Context::new(); 35 | let socket = ctx.socket(DEALER).unwrap(); 36 | 37 | assert!(socket.set_linger(0).is_ok()); 38 | // Omg this function... 39 | // I have spent EIGHT MONTHS trying to first 40 | // trouble shoot why i need this function, then 41 | // when rewriting the API finding what it is called. 42 | // Without this, the memory will not get dropped 43 | // when the client is killed (in rust when an error happens), 44 | // leading to the API requesting it never responding 45 | // and silently timing out without error. 46 | // What a nightmare to debug. 47 | // I am so glad I am done with this. (thanks y21) 48 | 49 | // Here are the docs why this happens 50 | // https://libzmq.readthedocs.io/en/zeromq3-x/zmq_setsockopt.html 51 | 52 | // For endpoints that may take 5 > seconds, we disable the timeout 53 | if no_timeout { 54 | assert!(socket.set_rcvtimeo(-1).is_ok()); 55 | } else { 56 | assert!(socket.set_rcvtimeo(5000).is_ok()); 57 | } 58 | 59 | assert!(socket.set_sndtimeo(1000).is_ok()); 60 | assert!(socket.set_connect_timeout(5000).is_ok()); 61 | let address = std::env::var("ZMQ_ADDRESS").unwrap_or("tcp://127.0.0.1:3210".to_string()); 62 | assert!(socket.connect(&address).is_ok()); 63 | assert!(socket.set_identity("api-client".as_bytes()).is_ok()); 64 | 65 | let request_data = RequestData { 66 | route: route.to_owned(), 67 | data, 68 | }; 69 | 70 | let mut msg = zmq::Message::new(); 71 | let request_json = serde_json::to_string(&request_data).unwrap(); 72 | 73 | let mut data = vec![first_bit]; 74 | data.extend_from_slice(request_json.as_bytes()); 75 | socket.send("", zmq::SNDMORE)?; // delimiter 76 | socket.send(data, 0)?; 77 | socket.recv(&mut msg, 0)?; // Receive acknowledgment from the server 78 | socket.recv(&mut msg, 0)?; // Receive the actual response 79 | 80 | // Close the socket 81 | assert!(socket.disconnect(&address).is_ok()); 82 | 83 | Ok(msg.as_str().unwrap().to_string()) 84 | } 85 | 86 | pub async fn make_request + 'static>( 87 | route: &'static str, 88 | data: T, 89 | first_bit: u8, 90 | no_timeout: bool, 91 | ) -> Result { 92 | task::spawn_blocking(move || make_request_inner(route, data, first_bit, no_timeout)) 93 | .await 94 | .unwrap() 95 | } 96 | -------------------------------------------------------------------------------- /api/src/tests/zmq_running/update.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use crate::routes::common::discord_auth::{enable_test_mode, set_test_admin_ids}; 3 | use crate::tests::common::{test_zmq_server, INIT}; 4 | use rocket::http::Status; 5 | use rocket::local::blocking::Client; 6 | use std::thread; 7 | use std::time::Duration; 8 | 9 | #[test] 10 | fn run_script_without_token() { 11 | enable_test_mode(); 12 | set_test_admin_ids("555666777".to_string()); 13 | 14 | let client = Client::tracked(rocket()).unwrap(); 15 | let response = client.post("/update?test=Pass").dispatch(); 16 | assert_eq!(response.status(), Status::Forbidden); 17 | } 18 | 19 | #[test] 20 | fn run_script_succeeding() { 21 | enable_test_mode(); 22 | set_test_admin_ids("555666777".to_string()); 23 | 24 | INIT.call_once(|| { 25 | test_zmq_server(); 26 | }); 27 | 28 | let client = Client::tracked(rocket()).unwrap(); 29 | thread::sleep(Duration::from_secs(1)); // Give the thread time to start 30 | let response = client 31 | .post("/update?test=Pass") 32 | .header(rocket::http::Header::new( 33 | "Authorization", 34 | "Bearer admin_token", 35 | )) 36 | .dispatch(); 37 | assert_eq!(response.status(), Status::Ok); 38 | assert_eq!( 39 | response.into_string().unwrap(), 40 | r#"{"status":"Success: Test passed\n"}"#, 41 | ); 42 | } 43 | 44 | #[test] 45 | fn run_script_succeeding_with_force() { 46 | enable_test_mode(); 47 | set_test_admin_ids("555666777".to_string()); 48 | 49 | INIT.call_once(|| { 50 | test_zmq_server(); 51 | }); 52 | 53 | let client = Client::tracked(rocket()).unwrap(); 54 | let response = client 55 | .post("/update?test=Pass&force=true") 56 | .header(rocket::http::Header::new( 57 | "Authorization", 58 | "Bearer admin_token", 59 | )) 60 | .dispatch(); 61 | assert_eq!(response.status(), Status::Ok); 62 | assert_eq!( 63 | response.into_string().unwrap(), 64 | r#"{"status":"Success: Test passed\n"}"#, 65 | ); 66 | } 67 | 68 | #[test] 69 | fn run_script_failing() { 70 | enable_test_mode(); 71 | set_test_admin_ids("555666777".to_string()); 72 | 73 | INIT.call_once(|| { 74 | test_zmq_server(); 75 | }); 76 | 77 | let client = Client::tracked(rocket()).unwrap(); 78 | let response = client 79 | .post("/update?test=Fail") 80 | .header(rocket::http::Header::new( 81 | "Authorization", 82 | "Bearer admin_token", 83 | )) 84 | .dispatch(); 85 | assert_eq!(response.status(), Status::BadRequest); 86 | assert_eq!( 87 | response.into_string().unwrap(), 88 | r#"{"error":"Update script failed: Test failed\n"}"#, 89 | ); 90 | } 91 | 92 | #[test] 93 | fn run_script_non_admin() { 94 | enable_test_mode(); 95 | set_test_admin_ids("555666777".to_string()); 96 | 97 | let client = Client::tracked(rocket()).unwrap(); 98 | let response = client 99 | .post("/update?test=Pass") 100 | .header(rocket::http::Header::new( 101 | "Authorization", 102 | "Bearer valid_token_1", 103 | )) 104 | .dispatch(); 105 | assert_eq!(response.status(), Status::BadRequest); 106 | assert_eq!( 107 | response.into_string().unwrap(), 108 | r#"{"error":"Access denied: Admin privileges required"}"#, 109 | ); 110 | } 111 | -------------------------------------------------------------------------------- /killua/metrics/metrics.py: -------------------------------------------------------------------------------- 1 | from prometheus_client import Counter, Gauge 2 | 3 | METRIC_PREFIX = "discord_" 4 | 5 | CONNECTION_GAUGE = Gauge( 6 | METRIC_PREFIX + "connected", 7 | "Determines if the bot is connected to Discord", 8 | ["shard"], 9 | ) 10 | LATENCY_GAUGE = Gauge( 11 | METRIC_PREFIX + "latency", 12 | "latency to Discord", 13 | ["shard"], 14 | ) 15 | ON_INTERACTION_COUNTER = Counter( 16 | METRIC_PREFIX + "event_on_interaction", 17 | "Amount of interactions called by users", 18 | ["is_user_installed"], 19 | ) 20 | ON_COMMAND_COUNTER = Counter( 21 | METRIC_PREFIX + "event_on_command", 22 | "Amount of commands called by users", 23 | ) 24 | GUILD_GAUGE = Gauge( 25 | METRIC_PREFIX + "stat_total_guilds", "Amount of guild this bot is a member of" 26 | ) 27 | CHANNEL_GAUGE = Gauge( 28 | METRIC_PREFIX + "stat_total_channels", 29 | "Amount of channels this bot is has access to", 30 | ) 31 | REGISTERED_USER_GAUGE = Gauge( 32 | METRIC_PREFIX + "stat_total_registered_users", 33 | "Amount of users registered in the bot", 34 | ) 35 | 36 | COMMANDS_GAUGE = Gauge( 37 | METRIC_PREFIX + "stat_total_commands", "Amount of commands registered in the bot" 38 | ) 39 | 40 | RAM_USAGE_GAUGE = Gauge(METRIC_PREFIX + "ram_usage", "Amount of RAM used by the bot") 41 | 42 | CPU_USAGE_GAUGE = Gauge(METRIC_PREFIX + "cpu_usage", "Amount of CPU used by the bot") 43 | 44 | MEMORY_USAGE_GAUGE = Gauge( 45 | METRIC_PREFIX + "memory_usage", "Amount of memory used by the bot" 46 | ) 47 | 48 | API_REQUESTS_COUNTER = Counter( 49 | METRIC_PREFIX + "api_requests", 50 | "Amount of requests made to the Killua API", 51 | ["endpoint", "type"], 52 | ) 53 | 54 | API_RESPONSE_TIME = Gauge( 55 | METRIC_PREFIX + "api_response_time", "Response time of the Killua API" 56 | ) 57 | 58 | IPC_RESPONSE_TIME = Gauge( 59 | METRIC_PREFIX + "ipc_response_time", "Response time of the IPC server" 60 | ) 61 | 62 | API_SPAM_REQUESTS = Counter( 63 | METRIC_PREFIX + "api_spam_requests", 64 | "Amount of requests that are attempted malice to my API", 65 | ) 66 | 67 | DAILY_ACTIVE_USERS = Gauge( 68 | METRIC_PREFIX + "daily_active_users", "Amount of users that use the bot daily" 69 | ) 70 | 71 | APPROXIMATE_USER_COUNT = Gauge( 72 | METRIC_PREFIX + "approximate_user_count", 73 | "Combined amount of users in all guilds the bot is on", 74 | ) 75 | 76 | USER_INSTALLS = Gauge( 77 | METRIC_PREFIX + "user_installs", 78 | "Amount of users that have installed the bot to their account", 79 | ) 80 | 81 | COMMAND_USAGE = Gauge( 82 | METRIC_PREFIX + "command_usage", 83 | "Amount of times a command was used", 84 | ["group", "command", "command_id"], 85 | ) 86 | 87 | PREMIUM_USERS = Gauge( 88 | METRIC_PREFIX + "premium_users", "Amount of users that have premium" 89 | ) 90 | 91 | VOTES = Counter( 92 | METRIC_PREFIX + "votes", 93 | "Amount of votes the bot has received", 94 | ["platform"], 95 | ) 96 | 97 | TODO_LISTS = Gauge(METRIC_PREFIX + "todo_lists", "Amount of todo lists created") 98 | 99 | TODOS = Gauge( 100 | METRIC_PREFIX + "todos", 101 | "Amount of todos created", 102 | ) 103 | 104 | TAGS = Gauge( 105 | METRIC_PREFIX + "tags", 106 | "Amount of tags created", 107 | ) 108 | 109 | CARDS = Gauge( 110 | METRIC_PREFIX + "cards", 111 | "Amount of cards in circulation", 112 | ) 113 | 114 | BIGGEST_COLLECTION = Gauge( 115 | METRIC_PREFIX + "biggest_collection", 116 | "Amount of non fake cards in the biggest collection", 117 | ) 118 | 119 | LOCALE = Gauge( 120 | METRIC_PREFIX + "locale", 121 | "Where users are from", 122 | ["country"], 123 | ) 124 | 125 | IS_DEV = Gauge( 126 | METRIC_PREFIX + "is_dev", 127 | "If the bot is running in dev mode", 128 | ["dev"], 129 | ) 130 | -------------------------------------------------------------------------------- /algorithms/rps.md: -------------------------------------------------------------------------------- 1 | ## What is there to improve about a game like rock paper scissors? 2 | Rock paper scissors was one of the first commands for Kllua and by far the most complex one for a long time as it was far above my skill level when I started. Though this mainly related to the asyncio things going on. 3 | However even with my limited knowledge something bothered me... the way the winner would be determined. 4 | 5 | ## The original way 6 | This is what I did at the start to determine who won: 7 | ```py 8 | async def rpsf(choice1, choice2): 9 | 10 | if choice1.lower() == 'rock' and choice2.lower() == 'scissors': 11 | return 1 12 | if choice1.lower() == 'rock' and choice2.lower() == 'rock': 13 | return 2 14 | if choice1.lower() == 'rock' and choice2.lower() == 'paper': 15 | return 3 16 | if choice1.lower() == 'paper' and choice2.lower() == 'rock': 17 | return 1 18 | if choice1.lower() == 'paper' and choice2.lower() == 'paper': 19 | return 2 20 | if choice1.lower() == 'paper' and choice2.lower() == 'scissors': 21 | return 3 22 | if choice1.lower() == 'scissors' and choice2.lower() == 'paper': 23 | return 1 24 | if choice1.lower() == 'scissors' and choice2.lower() == 'scissors': 25 | return 2 26 | if choice1.lower() == 'scissors' and choice2.lower() == 'rock': 27 | return 3 28 | ``` 29 | ## Better yet not great 30 | Admitably this was horrible. So once I had gained some more experience I decided to rewrite it. This is what I came up with: 31 | ```py 32 | async def rpsf(choice1, choice2): 33 | if choice1.lower() == choice2.lower(): 34 | return 2 35 | if choice1.lower() == 'rock' and choice2.lower() == 'scissors': 36 | return 1 37 | if choice1.lower() == 'paper' and choice2.lower() == 'rock': 38 | return 1 39 | if choice1.lower() == 'scissors' and choice2.lower() == 'paper': 40 | return 1 41 | 42 | return 3 43 | ``` 44 | Now this is as simple and small as you can get the code for this and most people would stop there. However I have never liked the idea of static if checks. I wanted something dynamic, smart and cool. So I turned to many people's nightmare: maths. 45 | 46 | ## The math way 47 | At the time I had the luck of having a very smart maths teacher who had studied maths at Oxford. After failing to come up for a foruma I eventually decided to ask him for help. 48 | My query was the following: 49 | > The problem is as follows: Assuming I assign each option in rock paper scissors a number, I am trying to find a formula which can determine if, when given two of those numbers, number 1 wins or not. 50 | 51 | It took him less than a day to get back to. He suggested the following: 52 | ```js 53 | let rock = -1 54 | let paper = 0 55 | let scissors = 1 56 | ``` 57 | The outputs would be : 58 | 59 | `-1` Player 1 wins 60 | 61 | `0` Draw 62 | 63 | `1` Player 2 wins 64 | 65 | And finally, the formula: 66 | 67 | $$\ f(p, q) = sin(\pi/12*(q - p)*((q - p)^2 + 5))) $$ 68 | p would be the numerical representation of Player 1's choice, and q would be the numerical representation of Player 2's choice. 69 | 70 | Let's take an example: 71 | Player 1 chooses rock, so p = -1. Player 2 chooses paper, so q = 0. 72 | 73 | $$\ f(-1, 0) = sin(\pi/12*(0 - (-1))\*((0 - (-1))^2 + 5))) = sin(\pi/12*(1)*(1 + 5)) = sin({\pi/12}*6) = sin(\pi/2) = 1 $$ 74 | 75 | So the function returns 1, which means that Player 2 wins which is... correct. 76 | 77 | #### Implementing it 78 | ```py 79 | import math 80 | 81 | def result(p: int, q: int) -> int: 82 | return int(math.sin(math.pi/12*(q-p)*((q-p)**2+5))) 83 | ``` 84 | That's it. One line. It's not the most readable code, probably also not the most efficient *but* it's very smart. 85 | 86 | I have geniunely no idea how exactly this maths works but I do know that this proves that maths is not only cool but also magic. 87 | -------------------------------------------------------------------------------- /api/src/tests/non_zmq/list.rs: -------------------------------------------------------------------------------- 1 | use rocket::http::{Header, Status}; 2 | use rocket::local::blocking::Client; 3 | use serde_json::Value; 4 | use std::fs; 5 | use std::path::Path; 6 | 7 | use crate::rocket; 8 | use crate::routes::common::discord_auth::{ 9 | disable_test_mode, enable_test_mode, set_test_admin_ids, 10 | }; 11 | 12 | const ADMIN_USER_ID: &str = "555666777"; 13 | 14 | fn create_auth_header() -> Header<'static> { 15 | Header::new("Authorization", "Bearer admin_token") 16 | } 17 | 18 | fn setup_test_environment() { 19 | // Enable test mode for Discord authentication 20 | enable_test_mode(); 21 | 22 | // Set test admin IDs 23 | set_test_admin_ids(ADMIN_USER_ID.to_string()); 24 | 25 | // Set HASH_SECRET for image endpoint testing 26 | std::env::set_var("HASH_SECRET", "test_secret_key"); 27 | } 28 | 29 | fn cleanup_test_environment() { 30 | disable_test_mode(); 31 | std::env::remove_var("ADMIN_IDS"); 32 | std::env::remove_var("HASH_SECRET"); 33 | 34 | // Clean up the entire cdn directory 35 | let cdn_dir = Path::new("../assets/cdn"); 36 | if cdn_dir.exists() { 37 | fs::remove_dir_all(cdn_dir).ok(); 38 | } 39 | } 40 | 41 | #[test] 42 | fn test_list_empty_directory() { 43 | setup_test_environment(); 44 | cleanup_test_environment(); // Ensure clean state 45 | 46 | // Explicitly enable test mode again to ensure it's set 47 | enable_test_mode(); 48 | set_test_admin_ids(ADMIN_USER_ID.to_string()); 49 | 50 | let client = Client::tracked(rocket()).expect("valid rocket instance"); 51 | let auth_header = create_auth_header(); 52 | 53 | let response = client.get("/image/list").header(auth_header).dispatch(); 54 | 55 | assert_eq!(response.status(), Status::Ok); 56 | 57 | let body: Value = serde_json::from_str(&response.into_string().unwrap()).unwrap(); 58 | assert!(body.get("success").unwrap().as_bool().unwrap()); 59 | assert_eq!(body.get("files").unwrap().as_array().unwrap().len(), 0); 60 | 61 | cleanup_test_environment(); 62 | } 63 | 64 | #[test] 65 | fn test_list_with_files() { 66 | setup_test_environment(); 67 | cleanup_test_environment(); 68 | 69 | // Explicitly enable test mode and set admin IDs 70 | enable_test_mode(); 71 | set_test_admin_ids(ADMIN_USER_ID.to_string()); 72 | 73 | let client = Client::tracked(rocket()).expect("valid rocket instance"); 74 | let auth_header = create_auth_header(); 75 | 76 | // Create some test files 77 | let cdn_dir = std::path::Path::new("../assets/cdn"); 78 | std::fs::create_dir_all(cdn_dir).unwrap(); 79 | std::fs::write(cdn_dir.join("test1.png"), b"test1").unwrap(); 80 | std::fs::write(cdn_dir.join("test2.png"), b"test2").unwrap(); 81 | std::fs::create_dir_all(cdn_dir.join("subdir")).unwrap(); 82 | std::fs::write(cdn_dir.join("subdir").join("test3.png"), b"test3").unwrap(); 83 | 84 | let response = client.get("/image/list").header(auth_header).dispatch(); 85 | 86 | assert_eq!(response.status(), Status::Ok); 87 | 88 | let body: Value = serde_json::from_str(&response.into_string().unwrap()).unwrap(); 89 | assert!(body.get("success").unwrap().as_bool().unwrap()); 90 | 91 | let files = body.get("files").unwrap().as_array().unwrap(); 92 | assert_eq!(files.len(), 3); 93 | 94 | // Check that all expected files are present (order may vary due to sorting) 95 | let file_strings: Vec = files 96 | .iter() 97 | .map(|f| f.as_str().unwrap().to_string()) 98 | .collect(); 99 | 100 | assert!(file_strings.contains(&"test1.png".to_string())); 101 | assert!(file_strings.contains(&"test2.png".to_string())); 102 | assert!(file_strings.contains(&"subdir/test3.png".to_string())); 103 | 104 | cleanup_test_environment(); 105 | } 106 | 107 | #[test] 108 | fn test_list_unauthorized() { 109 | setup_test_environment(); 110 | 111 | let client = Client::tracked(rocket()).expect("valid rocket instance"); 112 | 113 | let response = client.get("/image/list").dispatch(); 114 | 115 | assert_eq!(response.status(), Status::Forbidden); 116 | 117 | // The response body might be empty for 403, so we don't try to parse JSON 118 | // Just verify the status code is correct 119 | 120 | cleanup_test_environment(); 121 | } 122 | -------------------------------------------------------------------------------- /api/src/routes/diagnostics.rs: -------------------------------------------------------------------------------- 1 | use mongodb::bson::DateTime; 2 | use mongodb::Client; 3 | use std::collections::HashMap; 4 | use std::time::SystemTime; 5 | 6 | use rocket::response::status::BadRequest; 7 | use rocket::serde::json::{Json, Value}; 8 | use rocket::serde::{Deserialize, Serialize}; 9 | use rocket::State; 10 | 11 | use super::common::keys::ApiKey; 12 | use super::common::utils::{make_request, NoData, ResultExt}; 13 | use crate::db::models::ApiStats; 14 | use crate::fairings::counter::Endpoint; 15 | 16 | #[derive(Serialize, Deserialize, Default, Clone)] 17 | pub struct IPCData { 18 | pub success: bool, 19 | pub response_time: Option, 20 | } 21 | 22 | #[derive(Serialize, Deserialize, Default, Clone)] 23 | pub struct EndpointSummary { 24 | pub request_count: usize, 25 | pub successful_responses: usize, 26 | } 27 | 28 | #[derive(Serialize, Deserialize, Default, Clone)] 29 | pub struct DiagnosticsResponse { 30 | pub usage: HashMap, 31 | pub ipc: IPCData, 32 | } 33 | 34 | #[derive(Serialize, Deserialize, Default, Clone)] 35 | pub struct DiagnosticsFullResponse { 36 | pub usage: HashMap, 37 | pub ipc: IPCData, 38 | } 39 | 40 | #[get("/diagnostics?")] 41 | pub async fn get_diagnostics( 42 | _key: ApiKey, 43 | diag: &State, 44 | full: Option, 45 | ) -> Result, BadRequest>> { 46 | let diag = ApiStats::new(diag); 47 | let stats = diag.get_all_stats().await.context("Failed to get stats")?; 48 | let mut formatted: HashMap = HashMap::new(); 49 | 50 | for item in stats.into_iter() { 51 | formatted.insert( 52 | item._id, 53 | Endpoint { 54 | requests: item 55 | .requests 56 | .into_iter() 57 | .map(|x| x.timestamp_millis()) 58 | .collect(), 59 | successful_responses: item.successful_responses as usize, 60 | }, 61 | ); 62 | } 63 | 64 | /// It is very likely that for the first time this endpoint is called, 65 | /// it is not yet in the database (the background task is too slow) 66 | /// so we need to kind of "fake" the data. This will not be a problem 67 | /// except for the first request 68 | fn insert(formatted: &mut HashMap) { 69 | formatted.insert( 70 | "/diagnostics".to_string(), 71 | Endpoint { 72 | requests: vec![DateTime::now().timestamp_millis()], 73 | successful_responses: 1, 74 | }, 75 | ); 76 | } 77 | 78 | // Add 1 to /diagnostics successful_responses since it is not yet incremented 79 | match formatted.get_mut("/diagnostics") { 80 | Some(endpoint) => endpoint.successful_responses += 1, 81 | None => insert(&mut formatted), 82 | }; 83 | let start_time = SystemTime::now(); 84 | let res = make_request("heartbeat", NoData {}, 0_u8, false).await; 85 | let success = res.is_ok(); 86 | let response_time = match success { 87 | true => Some(start_time.elapsed().unwrap().as_secs_f64() * 1000.0), 88 | false => None, 89 | }; 90 | 91 | let ipc_data = IPCData { 92 | success, 93 | response_time, 94 | }; 95 | 96 | // Return full data if ?full=true is specified 97 | if full.unwrap_or(false) { 98 | let data = DiagnosticsFullResponse { 99 | usage: formatted, 100 | ipc: ipc_data, 101 | }; 102 | Ok(Json(serde_json::to_value(data).unwrap())) 103 | } else { 104 | // Return summary data with counts 105 | let mut summary: HashMap = HashMap::new(); 106 | for (key, endpoint) in formatted { 107 | summary.insert( 108 | key, 109 | EndpointSummary { 110 | request_count: endpoint.requests.len(), 111 | successful_responses: endpoint.successful_responses, 112 | }, 113 | ); 114 | } 115 | 116 | let data = DiagnosticsResponse { 117 | usage: summary, 118 | ipc: ipc_data, 119 | }; 120 | Ok(Json(serde_json::to_value(data).unwrap())) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /zmq_proxy/src/main.rs: -------------------------------------------------------------------------------- 1 | use log::info; 2 | use zmq::{poll, Context, Message, DEALER, POLLIN, ROUTER}; 3 | 4 | #[repr(u8)] 5 | #[derive(PartialEq)] 6 | enum SocketType { 7 | Bot = 0_u8, 8 | Script = 1_u8, 9 | } 10 | 11 | impl SocketType { 12 | fn from_u8(value: u8) -> Option { 13 | match value { 14 | 0 => Some(SocketType::Bot), 15 | 1 => Some(SocketType::Script), 16 | _ => None, 17 | } 18 | } 19 | } 20 | 21 | fn main() { 22 | // set logging level to info 23 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); 24 | 25 | let api_address = std::env::var("ZMQ_API_ADDRESS").unwrap(); 26 | let bot_address = std::env::var("ZMQ_BOT_ADDRESS").unwrap(); 27 | let script_address = std::env::var("ZMQ_SCRIPT_ADDRESS").unwrap(); 28 | 29 | info!("Starting device..."); 30 | info!("Client address: {}", api_address); 31 | info!("Server address: {}", bot_address); 32 | info!("Script address: {}", script_address); 33 | 34 | let context = Context::new(); 35 | let api = context.socket(ROUTER).unwrap(); 36 | let bot = context.socket(DEALER).unwrap(); 37 | let script = context.socket(DEALER).unwrap(); 38 | api.set_identity("api-client".as_bytes()).unwrap(); 39 | bot.set_identity("bot-client".as_bytes()).unwrap(); 40 | script.set_identity("script-client".as_bytes()).unwrap(); 41 | assert!(api.bind(&api_address).is_ok()); 42 | assert!(bot.bind(&bot_address).is_ok()); 43 | assert!(script.bind(&script_address).is_ok()); 44 | 45 | let sockets = vec![&api, &bot, &script]; 46 | for socket in &sockets { 47 | assert!(socket.set_rcvtimeo(5000).is_ok()); 48 | assert!(socket.set_linger(0).is_ok()); 49 | assert!(socket.set_sndtimeo(1000).is_ok()); 50 | } 51 | 52 | let items = &mut [api.as_poll_item(POLLIN), bot.as_poll_item(POLLIN), script.as_poll_item(POLLIN)]; 53 | 54 | info!("Setup complete"); 55 | loop { 56 | match poll(items, -1) 57 | { 58 | Ok(_) => info!("Polling successful"), 59 | Err(e) => { 60 | info!("Polling failed: {:?}", e); 61 | continue; // Skip to the next iteration if polling fails 62 | } 63 | } 64 | if items[0].is_readable() { 65 | let mut identity = Message::new(); 66 | api.recv(&mut identity, 0).unwrap(); 67 | 68 | let mut delimiter = Message::new(); 69 | api.recv(&mut delimiter, 0).unwrap(); // This should be an empty frame 70 | 71 | let mut message = Message::new(); 72 | api.recv(&mut message, 0).unwrap(); 73 | 74 | let more = if api.get_rcvmore().unwrap() { 75 | zmq::SNDMORE 76 | } else { 77 | 0 78 | }; 79 | 80 | if let Some(&first_byte) = message.as_ref().first() { 81 | let remaining = &message.as_ref()[1..]; 82 | let new_msg = zmq::Message::from(remaining); 83 | let socket_type = SocketType::from_u8(first_byte).expect("Invalid first byte in message"); 84 | 85 | let socket = if socket_type == SocketType::Bot { 86 | &bot 87 | } else { 88 | &script 89 | }; 90 | 91 | socket.send(new_msg, more).unwrap(); 92 | info!("Forwarded to {}", if socket_type == SocketType::Bot { "bot" } else { "script" }); 93 | 94 | match socket.recv_multipart(0) { 95 | Ok(reply_parts) => { 96 | info!("Received response: {:?}", reply_parts); 97 | 98 | // Send back to the original client 99 | api.send(identity, zmq::SNDMORE).unwrap(); 100 | api.send("", zmq::SNDMORE).unwrap(); 101 | for (i, part) in reply_parts.iter().enumerate() { 102 | let flag = if i == reply_parts.len() - 1 { 103 | 0 104 | } else { 105 | zmq::SNDMORE 106 | }; 107 | api.send(part, flag).unwrap(); 108 | } 109 | info!("Forwarded response to API"); 110 | }, 111 | Err(e) => { 112 | info!("Timeout from {}: {:?}", if socket_type == SocketType::Bot { "bot" } else { "script" }, e); 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /api/src/tests/non_zmq/image.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, SystemTime}; 2 | 3 | use crate::rocket; 4 | use crate::routes::image::{sha256, HASH_SECRET}; 5 | use rocket::http::{ContentType, Status}; 6 | use rocket::local::blocking::Client; 7 | use std::thread::sleep; 8 | 9 | fn get_valid_token(endpoint: String, extra_secs: Option) -> (String, String) { 10 | let time = (SystemTime::now() + std::time::Duration::from_secs(extra_secs.unwrap_or(60))) 11 | .duration_since(SystemTime::UNIX_EPOCH) 12 | .unwrap() 13 | .as_secs() 14 | .to_string(); 15 | (sha256(&endpoint, &time, &HASH_SECRET), time) 16 | } 17 | 18 | #[test] 19 | fn get_image_without_token() { 20 | let client = Client::tracked(rocket()).unwrap(); 21 | let response = client.get("/image/boxes/big_box.png").dispatch(); 22 | assert_eq!(response.status(), Status::Forbidden); 23 | } 24 | 25 | #[test] 26 | fn get_image_with_invalid_token() { 27 | let client = Client::tracked(rocket()).unwrap(); 28 | let response = client 29 | .get("/image/boxes/big_box.png?token=invalid_token") 30 | .dispatch(); 31 | assert_eq!(response.status(), Status::Forbidden); 32 | } 33 | 34 | #[test] 35 | fn image_does_not_exist() { 36 | let client = Client::tracked(rocket()).unwrap(); 37 | let (token, expiry) = get_valid_token("boxes/does_not_exist.png".to_string(), None); 38 | let response = client 39 | .get(format!( 40 | "/image/boxes/does_not_exist.png?token={token}&expiry={expiry}" 41 | )) 42 | .dispatch(); 43 | assert_eq!(response.status(), Status::NotFound); 44 | } 45 | 46 | #[test] 47 | fn attempt_malice() { 48 | let client = Client::tracked(rocket()).unwrap(); 49 | let (token, expiry) = get_valid_token("Rocket.toml".to_string(), None); // Rocket will convert ../../Rocket.toml to Rocket.toml 50 | let response = client 51 | .get(format!( 52 | "/image/../../Rocket.toml?token={token}&expiry={expiry}" 53 | )) 54 | .dispatch(); 55 | assert_eq!(response.status(), Status::Forbidden); 56 | } 57 | 58 | #[test] 59 | fn get_image_with_expired_token() { 60 | let client = Client::tracked(rocket()).unwrap(); 61 | let (token, expiry) = get_valid_token("boxes/big_box.png".to_string(), Some(0)); 62 | // Wait for the token to expire 63 | sleep(Duration::from_secs(1)); 64 | let response = client 65 | .get(format!( 66 | "/image/boxes/big_box.png?token={token}&expiry={expiry}" 67 | )) 68 | .dispatch(); 69 | assert_eq!(response.status(), Status::Forbidden); 70 | } 71 | 72 | #[test] 73 | fn get_single_image() { 74 | let client = Client::tracked(rocket()).unwrap(); 75 | let (token, expiry) = get_valid_token("boxes/big_box.png".to_string(), None); 76 | let image_response = client 77 | .get(format!( 78 | "/image/boxes/big_box.png?token={token}&expiry={expiry}" 79 | )) 80 | .dispatch(); 81 | assert_eq!(image_response.status(), Status::Ok); 82 | assert_eq!(image_response.content_type(), Some(ContentType::PNG)); 83 | } 84 | 85 | #[test] 86 | fn get_single_image_with_wrong_expiry() { 87 | let client = Client::tracked(rocket()).unwrap(); 88 | let (token, _) = get_valid_token("boxes/big_box.png".to_string(), None); 89 | let image_response = client 90 | .get(format!("/image/boxes/big_box.png?token={token}&expiry=123")) 91 | .dispatch(); 92 | assert_eq!(image_response.status(), Status::Forbidden); 93 | } 94 | 95 | #[test] 96 | fn get_single_image_with_wrong_token() { 97 | let client = Client::tracked(rocket()).unwrap(); 98 | let (_, expiry) = get_valid_token("boxes/big_box.png".to_string(), None); 99 | let image_response = client 100 | .get(format!( 101 | "/image/boxes/big_box.png?token=wrong_token&expiry={expiry}" 102 | )) 103 | .dispatch(); 104 | assert_eq!(image_response.status(), Status::Forbidden); 105 | } 106 | 107 | #[test] 108 | fn get_multiple_images() { 109 | let client = Client::tracked(rocket()).unwrap(); 110 | let (token, expiry) = get_valid_token("vote_rewards".to_string(), None); 111 | let image_response = client 112 | .get(format!( 113 | "/image/boxes/big_box.png?token={token}&expiry={expiry}" 114 | )) 115 | .dispatch(); 116 | assert_eq!(image_response.status(), Status::Ok); 117 | assert_eq!(image_response.content_type(), Some(ContentType::PNG)); 118 | let image_response = client 119 | .get(format!( 120 | "/image/boxes/booster_box.png?token={token}&expiry={expiry}" 121 | )) 122 | .dispatch(); 123 | assert_eq!(image_response.status(), Status::Ok); 124 | assert_eq!(image_response.content_type(), Some(ContentType::PNG)); 125 | } 126 | -------------------------------------------------------------------------------- /killua/static/enums.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, auto 2 | 3 | 4 | class PrintColors: 5 | HEADER = "\033[95m" 6 | OKBLUE = "\033[94m" 7 | OKCYAN = "\033[96m" 8 | OKGREEN = "\033[92m" 9 | WARNING = "\033[93m" 10 | FAIL = "\033[91m" 11 | ENDC = "\033[0m" 12 | BOLD = "\033[1m" 13 | UNDERLINE = "\033[4m" 14 | 15 | 16 | class Booster(Enum): 17 | TREASURE_MAP = 1 18 | DOUBLE = 2 19 | BOMB_DETECTOR = 3 20 | 21 | 22 | class Category(Enum): 23 | 24 | ACTIONS = { 25 | "name": "actions", 26 | "description": "Commands that can be used to interact with other users, such as hugging them", 27 | "emoji": {"unicode": "\U0001f465", "normal": ":busts_in_silhouette:"}, 28 | } 29 | CARDS = { 30 | "name": "cards", 31 | "description": "The greed island card system with monster, spell and item cards", 32 | "emoji": { 33 | "unicode": "<:card_number_46:811776158966218802>", 34 | "normal": "<:card_number_46:811776158966218802>", 35 | }, 36 | } 37 | ECONOMY = { 38 | "name": "economy", 39 | "description": "Killua's economy with the currency Jenny including lootboxes, shops and more", 40 | "emoji": {"unicode": "\U0001f3c6", "normal": ":trophy:"}, 41 | } 42 | MODERATION = { 43 | "name": "moderation", 44 | "description": "Moderation commands", 45 | "emoji": {"unicode": "\U0001f6e0", "normal": ":tools:"}, 46 | } 47 | TODO = { 48 | "name": "todo", 49 | "description": "Todo lists on discord to help you be organised", 50 | "emoji": {"unicode": "\U0001f4dc", "normal": ":scroll:"}, 51 | } 52 | FUN = { 53 | "name": "fun", 54 | "description": "Commands to play around with with friends to pass the time", 55 | "emoji": {"unicode": "\U0001f921", "normal": ":clown:"}, 56 | } 57 | OTHER = { 58 | "name": "other", 59 | "description": "Commands that fit none of the other categories", 60 | "emoji": { 61 | "unicode": "<:killua_wink:769919176110112778>", 62 | "normal": "<:killua_wink:769919176110112778>", 63 | }, 64 | } 65 | 66 | GAMES = { 67 | "name": "games", 68 | "description": "Games you can play with friends or alone", 69 | "emoji": {"unicode": "\U0001f3ae", "normal": ":video_game:"}, 70 | } 71 | 72 | TAGS = { 73 | "name": "tags", 74 | "description": "Tags if you want to save some text.", 75 | "emoji": {"unicode": "\U0001f5c4", "normal": ":file_cabinet:"}, 76 | } 77 | 78 | 79 | # the values are not important as only Enum.name is used 80 | 81 | 82 | class SellOptions(Enum): 83 | all = auto() 84 | spells = auto() 85 | monsters = auto() 86 | 87 | 88 | # class FlagOptions(Enum): 89 | # asexual = auto() 90 | # aromantic = auto() 91 | # bisexual = auto() 92 | # pansexual = auto() 93 | # gay = auto() 94 | # lesbian = auto() 95 | # trans = auto() 96 | # nonbinary = auto() 97 | # genderfluid = auto() 98 | # genderqeeur = auto() 99 | # polysexual = auto() 100 | # austria = auto() 101 | # belguim = auto() 102 | # botswana = auto() 103 | # bulgaria = auto() 104 | # ivory = auto() 105 | # estonia = auto() 106 | # france = auto() 107 | # gabon = auto() 108 | # gambia = auto() 109 | # germany = auto() 110 | # guinea = auto() 111 | # hungary = auto() 112 | # indonesia = auto() 113 | # ireland = auto() 114 | # italy = auto() 115 | # luxembourg = auto() 116 | # monaco = auto() 117 | # nigeria = auto() 118 | # poland = auto() 119 | # russia = auto() 120 | # romania = auto() 121 | # sierraleone = auto() 122 | # thailand = auto() 123 | # ukraine = auto() 124 | # yemen = auto() 125 | 126 | # class SnapOptions(Enum): 127 | # dog = auto() 128 | # dog2 = auto() 129 | # dog3 = auto() 130 | # pig = auto() 131 | # flowers = auto() 132 | # random = auto() 133 | 134 | # class EyesOptions(Enum): 135 | # big = auto() 136 | # black = auto() 137 | # bloodshot = auto() 138 | # blue = auto() 139 | # default = auto() 140 | # googly = auto() 141 | # green = auto() 142 | # horror = auto() 143 | # illuminati = auto() 144 | # money = auto() 145 | # pink = auto() 146 | # red = auto() 147 | # small = auto() 148 | # spinner = auto() 149 | # spongebob = auto() 150 | # white = auto() 151 | # yellow = auto() 152 | # random = auto() 153 | 154 | # class StatsOptions(Enum): 155 | # usage = auto() 156 | # growth = auto() 157 | # general = auto() 158 | 159 | 160 | class GameOptions(Enum): 161 | rps = auto() 162 | counting = auto() 163 | trivia = auto() 164 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | x-common-labels: &common-labels 2 | labels: 3 | org.opencontainers.image.source: https://github.com/kile/Killua 4 | org.opencontainers.image.title: "Killua" 5 | org.opencontainers.image.description: "The Killua Discord bot with Rust API and ZMQ Proxy" 6 | org.opencontainers.image.version: "1.2.0" 7 | org.opencontainers.image.authors: "kile@killua.dev" 8 | 9 | services: 10 | proxy: 11 | container_name: zmq_proxy 12 | image: ghcr.io/kile/zmq-proxy:latest 13 | logging: 14 | driver: "json-file" 15 | options: 16 | max-file: "3" # number of files or file count 17 | max-size: "10m" # file size 18 | build: 19 | context: "./zmq_proxy" 20 | target: ${MODE:-prod} 21 | args: 22 | - "MYUID=${MYUID:-1000}" 23 | - "MYGID=${MYGID:-1000}" 24 | environment: 25 | - ZMQ_API_ADDRESS=tcp://*:5559 26 | - ZMQ_BOT_ADDRESS=tcp://*:5560 27 | - ZMQ_SCRIPT_ADDRESS=tcp://*:5558 28 | ports: 29 | - "5558:5558/tcp" 30 | restart: unless-stopped 31 | <<: *common-labels 32 | 33 | api: 34 | image: ghcr.io/kile/killua-api:latest 35 | build: 36 | context: ./ 37 | dockerfile: ./api/Dockerfile 38 | target: ${MODE:-prod} 39 | args: 40 | - "MYUID=${MYUID:-1000}" 41 | - "MYGID=${MYGID:-1000}" 42 | container_name: rust_api 43 | ports: 44 | - "6060:7650" 45 | volumes: 46 | - ./cards.json:/app/cards.json 47 | - ./assets:/app/assets 48 | - ./pipes:/app/pipes 49 | environment: 50 | - ZMQ_ADDRESS=tcp://proxy:5559 51 | env_file: 52 | - .env 53 | restart: unless-stopped 54 | depends_on: 55 | - proxy 56 | logging: 57 | driver: "json-file" 58 | options: 59 | max-file: "5" # number of files or file count 60 | max-size: "10m" # file size 61 | <<: *common-labels 62 | 63 | bot: 64 | image: ghcr.io/kile/killua-bot:latest 65 | build: 66 | context: ./ 67 | dockerfile: ./killua/Dockerfile 68 | target: ${MODE:-prod} 69 | args: 70 | - "MYUID=${MYUID:-1000}" 71 | - "MYGID=${MYGID:-1000}" 72 | container_name: python_bot 73 | restart: unless-stopped 74 | environment: 75 | - PORT=6060 76 | - ZMQ_ADDRESS=tcp://proxy:5560 77 | env_file: 78 | - .env 79 | volumes: 80 | - ./assets:/app/assets 81 | depends_on: 82 | - api 83 | - proxy 84 | logging: 85 | driver: "json-file" 86 | options: 87 | max-file: "10" # number of files or file count 88 | max-size: "10m" # file size 89 | <<: *common-labels 90 | 91 | grafana: 92 | image: grafana/grafana:10.4.2 93 | restart: unless-stopped 94 | ports: 95 | - '3000:3000' 96 | volumes: 97 | - ./grafana/dashboard.yml:/etc/grafana/provisioning/dashboards/main.yaml 98 | - ./grafana/dashboards:/var/lib/grafana/dashboards 99 | - ./grafana/datasources:/etc/grafana/provisioning/datasources 100 | env_file: 101 | - .env 102 | logging: 103 | driver: "json-file" 104 | options: 105 | max-file: "3" # number of files or file count 106 | max-size: "10m" # file size 107 | 108 | prometheus: 109 | image: prom/prometheus 110 | container_name: prometheus 111 | command: 112 | - '--config.file=/etc/prometheus/prometheus.yml' 113 | ports: 114 | - 9090:9090 115 | restart: unless-stopped 116 | volumes: 117 | - ./prometheus:/etc/prometheus 118 | - prom_data:/prometheus 119 | logging: 120 | driver: "json-file" 121 | options: 122 | max-file: "3" # number of files or file count 123 | max-size: "10m" # file size 124 | 125 | loki: 126 | image: grafana/loki:latest 127 | container_name: loki 128 | ports: 129 | - "3100:3100" 130 | command: -config.file=/etc/loki/config.yml 131 | volumes: 132 | - ./loki/config.yml:/etc/loki/config.yml 133 | - loki_data:/loki # Persistent data directory 134 | - loki_tmp:/tmp/loki # Optional safeguard 135 | logging: 136 | driver: "json-file" 137 | options: 138 | max-file: "5" 139 | max-size: "10m" 140 | 141 | alloy: 142 | image: grafana/alloy:latest 143 | container_name: alloy 144 | ports: 145 | - 12345:12345 146 | - 4317:4317 147 | - 4318:4318 148 | volumes: 149 | - ./alloy/config.alloy:/etc/alloy/config.alloy 150 | - ./logs:/tmp/app-logs/ 151 | - /var/run/docker.sock:/var/run/docker.sock 152 | command: run --server.http.listen-addr=0.0.0.0:12345 --storage.path=/var/lib/alloy/data /etc/alloy/config.alloy 153 | depends_on: 154 | - loki 155 | logging: 156 | driver: "json-file" 157 | options: 158 | max-file: "5" # number of files or file count 159 | max-size: "50m" # file size 160 | 161 | node-exporter: 162 | image: quay.io/prometheus/node-exporter:latest 163 | container_name: node-exporter 164 | # Mount the host filesystem so we get real host metrics (not container metrics) 165 | volumes: 166 | - /proc:/host/proc:ro 167 | - /sys:/host/sys:ro 168 | - /:/rootfs:ro 169 | command: 170 | - '--path.procfs=/host/proc' 171 | - '--path.sysfs=/host/sys' 172 | - '--path.rootfs=/rootfs' 173 | expose: 174 | - "9100" 175 | logging: 176 | driver: "json-file" 177 | options: 178 | max-file: "3" # number of files or file count 179 | max-size: "10m" # file size 180 | 181 | volumes: 182 | prom_data: 183 | loki_data: 184 | loki_tmp: -------------------------------------------------------------------------------- /killua/tests/types/interaction.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from discord import Interaction 4 | from discord.ext.commands import Context 5 | 6 | from typing import Literal 7 | from .utils import get_random_discord_id, random_name 8 | 9 | 10 | class ArgumentResponseInteraction: 11 | def __init__(self, interaction: ArgumentInteraction): 12 | self.interaction = interaction 13 | self._is_done = False 14 | 15 | async def defer(self) -> None: 16 | self._is_done = True 17 | 18 | async def send_message(self, *args, **kwargs) -> None: 19 | if self._is_done: 20 | raise Exception("Interaction can only be responded to once.") 21 | self._is_done = True 22 | view = kwargs.pop( 23 | "view", self.interaction.context.current_view 24 | ) # If no new view is responded we want the old one still as the current view 25 | await self.interaction.context.send(view=view, *args, **kwargs) 26 | 27 | async def edit_message(self, *args, **kwargs) -> None: 28 | if self._is_done: 29 | raise Exception("Interaction can only be responded to once.") 30 | self._is_done = True 31 | # await self.interaction.message.edit(*args, **kwargs) 32 | 33 | async def send_modal(self, *args, **kwargs) -> None: 34 | if self._is_done: 35 | raise Exception("Interaction can only be responded to once.") 36 | self._is_done = True 37 | await self.interaction.context.send_modal(*args, **kwargs) 38 | 39 | def is_done(self) -> bool: 40 | return self._is_done 41 | 42 | 43 | class ArgumentInteraction: 44 | """This classes purpose is purely to be supplied to callbacks of message interactions""" 45 | 46 | def __init__(self, context: Context, **kwargs): 47 | self.__dict__ = kwargs 48 | self.context = context 49 | self.user = context.author 50 | self.response = ArgumentResponseInteraction(self) 51 | 52 | 53 | class TestingInteraction(Interaction): 54 | """A testing class mocking an interaction class""" 55 | 56 | @classmethod 57 | def base_interaction(cls, **kwargs) -> dict: 58 | return { 59 | "id": kwargs.pop("id", get_random_discord_id()), 60 | "application_id": kwargs.pop("application_id", get_random_discord_id()), 61 | "token": kwargs.pop("token", ""), 62 | "version": 1, 63 | } 64 | 65 | @classmethod 66 | def context_menus_interaction( 67 | cls, type: Literal[2, 4], menu_type: Literal[2, 3], *kwargs 68 | ) -> TestingInteraction: 69 | """Creates a testing interaction for the app command""" 70 | base = cls.base_interaction(**kwargs) 71 | base["type"] = type 72 | base["data"] = { 73 | "type": menu_type, # User: 2, Message: 3 74 | "target": kwargs.pop("target", get_random_discord_id()), 75 | "id": kwargs.pop("data_id", get_random_discord_id()), 76 | "name": kwargs.pop("name", random_name()), 77 | "guild_id": kwargs.pop("guild_id", get_random_discord_id()), 78 | } 79 | return TestingInteraction(**base) 80 | 81 | @classmethod 82 | def app_command_interaction( 83 | cls, type: Literal[2, 4], **kwargs 84 | ) -> TestingInteraction: 85 | """Creates a testing interaction for the app command""" 86 | base = cls.base_interaction(**kwargs) 87 | base["type"] = type 88 | base["data"] = { 89 | "type": 1, 90 | "id": kwargs.pop("data_id", get_random_discord_id()), 91 | "name": kwargs.pop("name", random_name()), 92 | } 93 | return TestingInteraction(**base) 94 | 95 | @classmethod 96 | def button_interaction(cls, **kwargs) -> TestingInteraction: 97 | """Creates a testing interaction for the button message component""" 98 | base = cls.base_interaction(**kwargs) 99 | base["type"] = 3 100 | base["data"] = { 101 | "component_type": 2, 102 | "custom_id": kwargs.pop("custom_id", get_random_discord_id()), 103 | } 104 | return TestingInteraction(**base) 105 | 106 | @classmethod 107 | def select_interaction(cls, **kwargs) -> TestingInteraction: 108 | """Creates a testing interaction for the select message component""" 109 | base = cls.base_interaction(**kwargs) 110 | base["type"] = 3 111 | base["data"] = { 112 | "component_type": 3, 113 | "values": kwargs.pop("values", []), 114 | "custom_id": kwargs.pop("custom_id", get_random_discord_id()), 115 | } 116 | return TestingInteraction(**base) 117 | 118 | @classmethod 119 | def modal_interaction(cls, **kwargs) -> TestingInteraction: 120 | """Creates a testing interaction for the modal message component""" 121 | base = cls.base_interaction(**kwargs) 122 | base["type"] = 5 123 | base["data"] = { 124 | "custom_id": kwargs.pop("custom_id", get_random_discord_id()), 125 | "components": kwargs.pop("components", []), 126 | } 127 | # Components are in the structure either: 128 | # { 129 | # "type": 1, 130 | # "components": [ 131 | # { 132 | # "type": 4, 133 | # "custom_id": str, 134 | # "value": str 135 | # } 136 | # ] 137 | # } 138 | # Or just 139 | # { 140 | # "type": 4, 141 | # "custom_id": str, 142 | # "value": str 143 | # } 144 | # Source: https://github.com/Rapptz/discord.py/blob/b0cb458f9f76072db3d9e40a33b70ce5349b0235/discord/types/interactions.py#L184 145 | return TestingInteraction(**base) 146 | -------------------------------------------------------------------------------- /killua/utils/interactions.py: -------------------------------------------------------------------------------- 1 | import discord 2 | 3 | from typing import Union, List, Any 4 | 5 | 6 | class View(discord.ui.View): 7 | """Subclassing this for buttons enabled us to not have to define interaction_check anymore, also not if we want a select menu""" 8 | 9 | def __init__(self, user_id: Union[int, List[int]], **kwargs): 10 | super().__init__(**kwargs) 11 | self.user_id = user_id 12 | self.value: Any = None 13 | self.timed_out = False 14 | self.interaction = None 15 | 16 | async def on_timeout(self) -> None: 17 | self.timed_out = True 18 | 19 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 20 | if isinstance(self.user_id, int): 21 | if not (val := interaction.user.id == self.user_id): 22 | await interaction.response.defer() 23 | else: 24 | if not (val := (interaction.user.id in self.user_id)): 25 | await interaction.response.defer() 26 | self.interaction = interaction # So we can respond to it anywhere 27 | return val 28 | 29 | async def disable(self, msg: discord.Message) -> Union[discord.Message, None]: 30 | """ "Disables the children inside of the view""" 31 | if not [ 32 | c for c in self.children if not c.disabled 33 | ]: # if every child is already disabled, we don't need to edit the message again 34 | return 35 | 36 | for c in self.children: 37 | c.disabled = True 38 | 39 | if self.interaction and not self.interaction.response.is_done(): 40 | await self.interaction.response.edit_message(view=self) 41 | else: 42 | try: 43 | await msg.edit(view=self) 44 | except discord.HTTPException: 45 | pass # Idk why but this can be Forbidden 46 | 47 | 48 | class Modal(discord.ui.Modal): # lgtm [py/missing-call-to-init] 49 | """A modal for various usages""" 50 | 51 | def __init__(self, **kwargs): 52 | super().__init__(**kwargs) 53 | self.interaction: discord.Interaction = None 54 | self.timed_out = False 55 | 56 | async def on_timeout(self) -> None: 57 | self.timed_out = True 58 | 59 | async def on_submit(self, interaction: discord.Interaction) -> None: 60 | """Called when the modal is submitted""" 61 | self.interaction = interaction 62 | 63 | 64 | class Select(discord.ui.Select): 65 | """Creates a select menu to view the command groups""" 66 | 67 | def __init__(self, options, disable: bool = False, **kwargs): 68 | super().__init__(min_values=1, max_values=1, options=options, **kwargs) 69 | self.disable = disable 70 | 71 | async def callback(self, interaction: discord.Interaction): 72 | self.view.value = int(interaction.data["values"][0]) 73 | for opt in self.options: 74 | if opt.value == str(self.view.value): 75 | opt.default = True 76 | 77 | if self.disable: 78 | await self.view.disable(interaction.message) 79 | 80 | self.view.stop() 81 | 82 | 83 | class Button(discord.ui.Button): 84 | 85 | def __init__(self, **kwargs): 86 | super().__init__(**kwargs) 87 | 88 | async def callback(self, _: discord.Interaction): 89 | self.view.value = self.custom_id 90 | self.view.stop() 91 | 92 | class ConfirmButtonRow(discord.ui.ActionRow): 93 | def __init__(self, view: 'ConfirmButton') -> None: 94 | self.__view = view 95 | super().__init__() 96 | 97 | @discord.ui.button( 98 | label="confirm", style=discord.ButtonStyle.green, custom_id="confirm" 99 | ) 100 | async def confirm(self, *_): 101 | self.__view.value = True 102 | self.__view.timed_out = False 103 | self.__view.stop() 104 | 105 | @discord.ui.button(label="cancel", style=discord.ButtonStyle.red, custom_id="cancel") 106 | async def cancel(self, *_): 107 | self.__view.value = False 108 | self.__view.timed_out = False 109 | self.__view.stop() 110 | 111 | class ConfirmButton(discord.ui.LayoutView): 112 | """A button that is used to confirm a certain action or deny it""" 113 | 114 | def __init__(self, user_id: int, text: str, **kwargs): 115 | super().__init__(**kwargs) 116 | self.user_id = user_id 117 | self.timed_out = ( 118 | False # helps subclasses using Button to have set this to False 119 | ) 120 | self.interaction = None 121 | self.value = False 122 | self.buttons = ConfirmButtonRow(self) 123 | container = discord.ui.Container( 124 | discord.ui.TextDisplay( 125 | content=text, 126 | ), 127 | self.buttons, 128 | accent_colour=discord.Colour.blurple(), 129 | ) 130 | self.add_item(container) 131 | 132 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 133 | if not (val := interaction.user.id == self.user_id): 134 | await interaction.response.defer() 135 | self.interaction = interaction 136 | return val 137 | 138 | async def disable(self, msg: discord.Message) -> discord.Message: 139 | for child in self._children[0]._children[1]._children: 140 | # I tried to do this more dynamically but it didn't work 141 | child.disabled = True 142 | if self.interaction and not self.interaction.response.is_done(): 143 | await self.interaction.response.edit_message(view=self) 144 | else: 145 | try: 146 | await msg.edit(view=self) 147 | except discord.HTTPException: 148 | pass # Idk why but this can be Forbidden 149 | 150 | async def on_timeout(self): 151 | self.timed_out = True 152 | -------------------------------------------------------------------------------- /killua/utils/classes/guild.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import List, Dict, Any, ClassVar, Optional 4 | from dataclasses import dataclass, field 5 | from inspect import signature 6 | from datetime import datetime 7 | 8 | from killua.static.constants import DB 9 | from killua.utils.classes.user import User 10 | 11 | @dataclass 12 | class Guild: 13 | """A class to handle basic guild data""" 14 | 15 | id: int 16 | prefix: str 17 | approximate_member_count: int = 0 18 | badges: List[str] = field(default_factory=list) 19 | commands: dict = field( 20 | default_factory=dict 21 | ) # The logic behind this is not used and needs to be rewritten 22 | polls: dict = field(default_factory=dict) 23 | tags: List[dict] = field(default_factory=list) 24 | added_on: Optional[datetime] = None 25 | cache: ClassVar[Dict[int, Guild]] = {} 26 | 27 | @classmethod 28 | def from_dict(cls, raw: dict): 29 | return cls( 30 | **{k: v for k, v in raw.items() if k in signature(cls).parameters} 31 | ) 32 | 33 | @classmethod 34 | async def update_member_count(cls, guild_id: int, old_member_count: Optional[int], member_count: int) -> Optional[int]: 35 | """If saved member count is inaccurate by > 5%, update it""" 36 | old_member_count = old_member_count or 0 37 | if member_count > old_member_count * 1.05 or member_count < old_member_count * 0.95: 38 | await DB.guilds.update_one( 39 | {"id": guild_id}, {"$set": {"approximate_member_count": member_count}} 40 | ) 41 | return member_count 42 | return None 43 | 44 | @classmethod 45 | async def _member_count_helper(cls, guild_id: int, approximate_member_count: Optional[int], member_count: Optional[int]) -> int: 46 | """Helper function to get the member count""" 47 | if member_count: 48 | return await cls.update_member_count( 49 | guild_id, approximate_member_count, member_count 50 | ) or approximate_member_count or member_count or 0 51 | return approximate_member_count or 0 52 | 53 | @classmethod 54 | async def new(cls, guild_id: int, member_count: Optional[int] = None) -> Guild: 55 | if guild_id in cls.cache: 56 | cls.cache[guild_id].approximate_member_count = await cls._member_count_helper( 57 | guild_id, cls.cache[guild_id].approximate_member_count, member_count 58 | ) 59 | return cls.cache[guild_id] 60 | 61 | raw: Optional[dict] = await DB.guilds.find_one({"id": guild_id}) # type: ignore 62 | if raw is None: 63 | await cls.add_default(guild_id, member_count) 64 | raw: dict = await DB.guilds.find_one({"id": guild_id}) # type: ignore 65 | 66 | del raw["_id"] 67 | raw["approximate_member_count"] = await cls._member_count_helper( 68 | guild_id, raw.get("approximate_member_count", None), member_count 69 | ) 70 | guild = cls.from_dict(raw) 71 | cls.cache[guild_id] = guild 72 | 73 | return guild 74 | 75 | @property 76 | def is_premium(self) -> bool: 77 | return ("partner" in self.badges) or ("premium" in self.badges) 78 | 79 | @classmethod 80 | async def add_default(cls, guild_id: int, member_count: Optional[int]) -> None: 81 | """Adds a guild to the database""" 82 | await DB.guilds.insert_one( 83 | {"id": guild_id, "points": 0, "items": "", "badges": [], "prefix": "k!", "approximate_member_count": member_count or 0, "added_on": datetime.now()} 84 | ) 85 | 86 | @classmethod 87 | async def bulk_remove_premium(cls, guild_ids: List[int]) -> None: 88 | """Removes premium from all guilds specified, if possible""" 89 | for guild in guild_ids: 90 | try: 91 | User.cache[guild].badges.remove("premium") 92 | except Exception: 93 | guild_ids.remove( 94 | guild 95 | ) # in case something got messed up it removes the guild id before making the db interaction 96 | 97 | await DB.guilds.update_many( 98 | {"id": {"$in": guild_ids}}, {"$pull": {"badges": "premium"}} 99 | ) 100 | 101 | async def _update_val(self, key: str, value: Any, operator: str = "$set") -> None: 102 | """An easier way to update a value""" 103 | await DB.guilds.update_one({"id": self.id}, {operator: {key: value}}) 104 | 105 | async def delete(self) -> None: 106 | """Deletes a guild from the database""" 107 | del self.cache[self.id] 108 | await DB.guilds.delete_one({"id": self.id}) 109 | 110 | async def change_prefix(self, prefix: str) -> None: 111 | "Changes the prefix of a guild" 112 | self.prefix = prefix 113 | await self._update_val("prefix", self.prefix) 114 | 115 | async def add_premium(self) -> None: 116 | """Adds premium to a guild""" 117 | self.badges.append("premium") 118 | await self._update_val("badges", "premium", "$push") 119 | 120 | async def remove_premium(self) -> None: 121 | """ "Removes premium from a guild""" 122 | self.badges.remove("premium") 123 | await self._update_val("badges", "premium", "$pull") 124 | 125 | async def add_poll(self, id: int, poll_data: dict) -> None: 126 | """Adds a poll to a guild""" 127 | self.polls[id] = poll_data 128 | await self._update_val("polls", self.polls) 129 | 130 | async def close_poll(self, id: int) -> None: 131 | """Closes a poll""" 132 | del self.polls[id] 133 | await self._update_val("polls", self.polls) 134 | 135 | async def update_poll_votes(self, id: int, updated: dict) -> None: 136 | """Updates the votes of a poll""" 137 | self.polls[str(id)]["votes"] = updated 138 | await self._update_val(f"polls.{id}.votes", updated) 139 | -------------------------------------------------------------------------------- /algorithms/compression.md: -------------------------------------------------------------------------------- 1 | *(this is the original description of the Pull Request introducing this algorithm)* 2 | 3 | The main purpose of this branch was to rewrite the poll command but it also includes small other changes. 4 | 5 | 6 | ## Why rewrite 7 | It started when I used Killua for a poll for my server. I noticed a very simple but big mistake I made. One of the options had 6 voted (5 shown as mentions, 1 being "+ 1 more...") but when I would click on said option nothing would happen. 8 | 9 | ![](https://i.imgur.com/gQm0Z1i.png) 10 | 11 | It turns out that something did in fact happen. However, the way Killua used to check for votes is check the mention. However as there was "+ 1 more..." ofc it would never register more than 5 votes, then add you as the sixth. This would repeat leading to no option ever being able to get more than 6 votes. It would also allow you to vote for two options if you were the "+ 1 more..." in one of them as there was no id associated with that "1 more". 12 | 13 | ## So use a database, problem solved. 14 | Well... no. For two reasons. 15 | 1) I was quite proud polls being my first command in Killua with persistent views that do not need a database, persistent meaning they still work after a bot restart by reading the custom ids of a button 16 | 2) Killua is all about learning more about programming for me. Sure a database was the easy way out. But that would not have taught me anything. 17 | 18 | Because of these reasons I thought about how I could implement this without using a database 19 | 20 | ## The journey 21 | The task was to store as much as possible in one discord message, the poll message, as that was the only information I would be able to access on a new button click. There were two places where I could store information: 22 | 1) The embed. This would be visible to the user so there could not be too much information as it would look off. I could save things like what the options were and the first 5 users that voted on an option were. 23 | 2) The custom ids of the buttons. This would not be visible to users but had a 100 character limit for each button. 24 | 25 | I quickly realised that for it to be able to scale I *would* need a database. But I decided to make this as well and use the database for unlimited votes as a premium feature. 26 | 27 | My initial idea was in each button's custom id I could save the option and votes if there were more than 5. It would then look something like this: 28 | ```py 29 | poll:option-1:606162661184372736:495556188881027092,270148059269300224 30 | ``` 31 | 32 | Quickly I realised that 33 | 1) the author id (after the option-1) would only be needed in the close button as it was necessary to store it 5 times. 34 | 2) option was unnecessary long and could be reduced to `opt` 35 | 3) Lastly and most importantly that discord ids were too damn long. Up to 19 characters per id. That means a maximum of 4 extra votes on an option. Not a huge bonus. 36 | So I needed to find a way to reduce the length of the ids while still being able to compare them to normal ids. 37 | 38 | ### The compression 39 | My first approach was using encryption. Specifically XOR encryption. My idea was to split the id in two half, XOR both half, then take the new result and repeat it long enough until I had a small string. However it was not unlikely for those end results to overlap, producing false positives. So I had to chose an end length reliable enough while also being as short as possible. 40 | In my tests I found out 4 characters had the best short to unique ratio, scoring an about 10% overlap in my tests while with its 4 characters would allow for much more votes. 41 | 42 | ![](https://i.imgur.com/8Io2AZ5.png) 43 | 44 | But I wasn't satisfied. I tried to bring the number down through various other algorithms before so it would be more unique with a smaller amount of characters. This gave me an idea: number bases. 45 | As an example, the number "10" in binary (base 2) is `1010`. 4 characters. Yet in hexadecimal, it is `A` (base 16), one character! So if I was now able to convert a discord id in a very large base it would have a very small amount of characters! 46 | 47 | After experimenting I quickly discarded the old XOR idea completely and focused on this. I found out that there were 11000 unicode characters so I chose base 100000 as the base I would use. I did not stick to normal base standards (eg start with A after 9) as as long as it remained consistent it did not matter. So now I was able to reduce a discord id to about 3-4 characters which were completely unique to that id! But I thought I could do better. It did not have to be 100% accurate. 48 | Because the last characters of a number are the most unique digits of that number as they are the most exact I started experimenting with the accuracy of only using the last 2 characters/digits. And that worked pretty well. 49 | 50 | ![](https://i.imgur.com/OI5ghpc.png) 51 | 52 | ![](https://i.imgur.com/X9Ge7pA.png) 53 | 54 | So I decided this is what I would use as you would never have to go from compressed id back to id, just from id to compressed id the last two characters were enough for a check. After all `2^1000 * 2^1000` is still a pretty large number of possibilities. However I did decide I would keep the author id in full accuracy to make sure no one else would be able to close the poll, even if the probability was small. 55 | 56 | ## One more thought 57 | 58 | Another thought I had that if there was to be a poll on a huge server all options would quickly be at the max amount and even though the members may favour one side, all options would have an even number of votes. So I decided I would allow for other votes to be saved somewhere in case there was not enough space for them but there was in another custom id. 59 | 60 | I quickly gave up on doing this for all button and dedicated the custom id of the close button for this. 61 | 62 | ## The implementation 63 | 64 | Overall this was not easy to implement, but I think I have managed to do so. Theoretically it is incredibly reliable, should not need a database unless on huge servers and looks good. I also decided to not use a database backup for war questions which use the same code as they cannot be closed and can easily fill up my db. 65 | -------------------------------------------------------------------------------- /api/src/tests/zmq_down/user.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use rocket::http::{Header, Status}; 3 | use rocket::local::blocking::Client; 4 | 5 | use crate::routes::common::discord_auth::{disable_test_mode, enable_test_mode}; 6 | 7 | // Test fixtures 8 | const TEST_USER_ID: &str = "123456789"; 9 | 10 | fn create_auth_header(user_id: &str) -> Header<'static> { 11 | // Map user IDs to the correct test tokens 12 | let token = match user_id { 13 | "123456789" => "Bearer valid_token_1", 14 | _ => "Bearer invalid_token", 15 | }; 16 | 17 | Header::new("Authorization", token) 18 | } 19 | 20 | #[test] 21 | fn test_userinfo_zmq_down() { 22 | // Test userinfo when ZMQ server is down 23 | // Enable test mode for Discord authentication 24 | enable_test_mode(); 25 | 26 | let client = Client::tracked(rocket()).unwrap(); 27 | let auth_header = create_auth_header(TEST_USER_ID); 28 | 29 | let response = client.get("/user/info").header(auth_header).dispatch(); 30 | 31 | // Should return an error when ZMQ server is down 32 | // The exact error depends on how the application handles ZMQ failures 33 | // It could be InternalServerError, BadRequest, or ServiceUnavailable 34 | assert!( 35 | response.status() == Status::InternalServerError 36 | || response.status() == Status::BadRequest 37 | || response.status() == Status::ServiceUnavailable 38 | ); 39 | 40 | // Disable test mode 41 | disable_test_mode(); 42 | } 43 | 44 | #[test] 45 | fn test_userinfo_by_id_zmq_down() { 46 | // Test userinfo by ID when ZMQ server is down 47 | // Enable test mode for Discord authentication 48 | enable_test_mode(); 49 | 50 | let client = Client::tracked(rocket()).unwrap(); 51 | let auth_header = create_auth_header(TEST_USER_ID); 52 | 53 | let response = client 54 | .get("/user/info/123456789") 55 | .header(auth_header) 56 | .dispatch(); 57 | 58 | // Should return an error when ZMQ server is down 59 | assert!( 60 | response.status() == Status::InternalServerError 61 | || response.status() == Status::BadRequest 62 | || response.status() == Status::ServiceUnavailable 63 | ); 64 | 65 | // Disable test mode 66 | disable_test_mode(); 67 | } 68 | 69 | #[test] 70 | fn test_userinfo_zmq_down_without_auth() { 71 | // Test userinfo when ZMQ server is down and no auth provided 72 | let client = Client::tracked(rocket()).unwrap(); 73 | 74 | let response = client.get("/user/info").dispatch(); 75 | 76 | // Should return Forbidden due to missing auth, not ZMQ error 77 | assert_eq!(response.status(), Status::Forbidden); 78 | } 79 | 80 | #[test] 81 | fn test_userinfo_zmq_down_invalid_auth() { 82 | // Test userinfo when ZMQ server is down with invalid auth 83 | // Enable test mode for Discord authentication 84 | enable_test_mode(); 85 | 86 | let client = Client::tracked(rocket()).unwrap(); 87 | let invalid_header = Header::new("Authorization", "Bearer invalid_token"); 88 | 89 | let response = client.get("/user/info").header(invalid_header).dispatch(); 90 | 91 | // Should return Forbidden due to invalid auth, not ZMQ error 92 | assert_eq!(response.status(), Status::Forbidden); 93 | 94 | // Disable test mode 95 | disable_test_mode(); 96 | } 97 | 98 | // ===== USER EDIT TESTS (ZMQ DOWN) ===== 99 | 100 | #[test] 101 | fn test_user_edit_zmq_down() { 102 | // Test user edit when ZMQ server is down 103 | // Enable test mode for Discord authentication 104 | enable_test_mode(); 105 | 106 | let client = Client::tracked(rocket()).unwrap(); 107 | let auth_header = create_auth_header(TEST_USER_ID); 108 | 109 | let edit_data = serde_json::json!({ 110 | "voting_reminder": true 111 | }); 112 | 113 | let response = client 114 | .put("/user/edit") 115 | .header(auth_header) 116 | .header(Header::new("Content-Type", "application/json")) 117 | .body(edit_data.to_string()) 118 | .dispatch(); 119 | 120 | // Should return an error when ZMQ server is down 121 | // The exact error depends on how the application handles ZMQ failures 122 | // It could be InternalServerError, BadRequest, or ServiceUnavailable 123 | assert!( 124 | response.status() == Status::InternalServerError 125 | || response.status() == Status::BadRequest 126 | || response.status() == Status::ServiceUnavailable 127 | ); 128 | 129 | // Disable test mode 130 | disable_test_mode(); 131 | } 132 | 133 | #[test] 134 | fn test_user_edit_zmq_down_without_auth() { 135 | // Test user edit when ZMQ server is down and no auth provided 136 | let client = Client::tracked(rocket()).unwrap(); 137 | 138 | let edit_data = serde_json::json!({ 139 | "voting_reminder": true 140 | }); 141 | 142 | let response = client 143 | .put("/user/edit") 144 | .header(Header::new("Content-Type", "application/json")) 145 | .body(edit_data.to_string()) 146 | .dispatch(); 147 | 148 | // Should return Forbidden due to missing auth, not ZMQ error 149 | assert_eq!(response.status(), Status::Forbidden); 150 | } 151 | 152 | #[test] 153 | fn test_user_edit_zmq_down_invalid_auth() { 154 | // Test user edit when ZMQ server is down with invalid auth 155 | // Enable test mode for Discord authentication 156 | enable_test_mode(); 157 | 158 | let client = Client::tracked(rocket()).unwrap(); 159 | let invalid_header = Header::new("Authorization", "Bearer invalid_token"); 160 | 161 | let edit_data = serde_json::json!({ 162 | "voting_reminder": true 163 | }); 164 | 165 | let response = client 166 | .put("/user/edit") 167 | .header(invalid_header) 168 | .header(Header::new("Content-Type", "application/json")) 169 | .body(edit_data.to_string()) 170 | .dispatch(); 171 | 172 | // Should return Forbidden due to invalid auth, not ZMQ error 173 | assert_eq!(response.status(), Status::Forbidden); 174 | 175 | // Disable test mode 176 | disable_test_mode(); 177 | } 178 | -------------------------------------------------------------------------------- /killua/utils/test_db.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List, Dict 2 | 3 | 4 | class TestingDatabase: 5 | """A database class imitating pymongos collection classes""" 6 | 7 | db: Dict[str, List[dict]] = {} 8 | 9 | def __init__(self, collection: str): 10 | self._collection = collection 11 | 12 | @property 13 | def collection(self) -> str: 14 | if self._collection not in self.db: 15 | self.db[self._collection] = [] 16 | return self._collection 17 | 18 | # def _random_id(self) -> int: 19 | # """Creates a random 8 digit number""" 20 | # res = int(str(randint(0, 99999999)).zfill(8)) 21 | # if res in [x["_id"] for x in self.db[self.collection]]: 22 | # return self._random_id() 23 | # else: 24 | # return res 25 | 26 | def _normalize_dict(self, dictionary: dict) -> dict: 27 | """Changes the {one.two: } to {one: {two: }}""" 28 | for _, d in dictionary.items(): 29 | if isinstance(d, dict): 30 | for key, val in d.items(): 31 | if "." in key: 32 | k1 = key.split(".")[0] 33 | k2 = key.split(".")[1] 34 | d[k1][k2] = val 35 | del d[key] 36 | return dictionary 37 | 38 | async def find_one(self, where: dict) -> Optional[dict]: 39 | coll = self.db[self.collection] 40 | for d in coll: 41 | for key, value in d.items(): 42 | if len([k for k, v in where.items() if k == key and v == value]) == len( 43 | where 44 | ): # When all conditions defined in "where" are met 45 | return d 46 | 47 | async def find(self, where: dict) -> Optional[list]: 48 | coll = self.db[self.collection] 49 | results = [] 50 | 51 | for d in coll: 52 | for key, value in d.items(): 53 | if [ 54 | x 55 | for x in list(where.values()) 56 | if isinstance(x, dict) and "$in" in x.keys() 57 | ]: 58 | for k, v in [ 59 | (k, v) 60 | for k, v in list(where.items()) 61 | if isinstance(v, dict) and "$in" in v.keys() 62 | ]: 63 | if k == key and value in v["$in"]: 64 | results.append(d) 65 | 66 | elif len( 67 | [k for k, v in where.items() if k == key and v == value] 68 | ) == len( 69 | where 70 | ): # When all conditions defined in "where" are met 71 | results.append(d) 72 | 73 | return results 74 | 75 | async def insert_one(self, object: dict) -> None: 76 | self.db[self.collection].append(object) 77 | 78 | async def insert_many(self, objects: List[dict]) -> None: 79 | for obj in objects: 80 | await self.insert_one(obj) 81 | 82 | async def update_one(self, where: dict, update: Dict[str, dict]) -> dict: 83 | # updated = False 84 | operator = list(update.keys())[0] # This does not support multiple keys 85 | 86 | for v in update.values(): # Making sure it is all in the right format 87 | v = self._normalize_dict(v) # lgtm [py/multiple-definition] 88 | 89 | for p, item in enumerate(self.db[self.collection]): 90 | for key, value in item.items(): 91 | if len([k for k, v in where.items() if key == k and value == v]) == len( 92 | where 93 | ): 94 | if operator == "$set": 95 | for k, val in update[operator].items(): 96 | if isinstance(val, dict): 97 | self.db[self.collection][p][k][list(val.keys())[0]] = ( 98 | list(val.values())[0] 99 | ) 100 | else: 101 | self.db[self.collection][p][k] = val 102 | if operator == "$push": 103 | for k, val in update[operator].items(): 104 | if isinstance(val, dict): 105 | self.db[self.collection][p][k][ 106 | list(val.keys())[0] 107 | ].append(list(val.values())[0]) 108 | else: 109 | self.db[self.collection][p][k].append(val) 110 | if operator == "$pull": 111 | for k, val in update[operator].items(): 112 | if isinstance(val, dict): 113 | self.db[self.collection][p][k][ 114 | list(val.keys())[0] 115 | ].remove(list(val.values())[0]) 116 | else: 117 | self.db[self.collection][p][k].remove(val) 118 | elif operator == "$inc": 119 | for k, val in update[operator].items(): 120 | if isinstance(val, dict): 121 | self.db[self.collection][p][k][ 122 | list(val.keys())[0] 123 | ] += list(val.values())[0] 124 | else: 125 | self.db[self.collection][p][k] += val 126 | # updated = True 127 | 128 | # if not updated: 129 | # self.insert_one(update) 130 | 131 | return update # I only need this when the update would equal the object 132 | 133 | async def count_documents(self, where: dict = {}) -> int: 134 | return len(await self.find(where) or []) 135 | 136 | async def delete_one(self, where: dict) -> None: 137 | ... # TODO: Implement this 138 | 139 | async def delete_many(self, where: dict) -> None: 140 | ... # TODO: Implement this 141 | 142 | async def update_many(self, where: dict, update: dict) -> None: 143 | ... # TODO: Implement this 144 | -------------------------------------------------------------------------------- /killua/tests/testing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from discord.ext.commands import Cog, Command 4 | from discord.ext.commands.view import StringView 5 | 6 | import sys, traceback 7 | import logging 8 | from typing import TYPE_CHECKING, List, Coroutine, Optional 9 | 10 | if TYPE_CHECKING: 11 | from .types import Context, TestResult 12 | 13 | 14 | class Testing: 15 | """Modifies several discord classes to be suitable in a testing environment""" 16 | 17 | def __new__( 18 | cls, *args, **kwargs 19 | ): # This prevents this class from direct instatioation 20 | if cls is Testing: 21 | raise TypeError(f"only children of '{cls.__name__}' may be instantiated") 22 | return object.__new__(cls, *args, **kwargs) 23 | 24 | def __init__(self, cog: Cog): 25 | from .types import ( 26 | DiscordGuild, 27 | TextChannel, 28 | DiscordMember, 29 | Message, 30 | Context, 31 | TestResult, 32 | Bot, 33 | ) 34 | 35 | self.base_guild: DiscordGuild = DiscordGuild() 36 | self.base_channel: TextChannel = TextChannel(guild=self.base_guild) 37 | self.base_author: DiscordMember = DiscordMember() 38 | self.base_message: Message = Message( 39 | author=self.base_author, channel=self.base_channel 40 | ) 41 | self.base_context: Context = Context( 42 | message=self.base_message, bot=Bot, view=StringView("testing") 43 | ) 44 | # StringView is not used in any method I use and even if it would be, I would 45 | # be overwriting that method anyways 46 | self.result: TestResult = TestResult() 47 | self.cog: Cog = cog(Bot) 48 | 49 | @property 50 | def all_tests(self) -> List[Testing]: 51 | """Automatically checks what functions are test based on their name and the overlap with the Cog method names""" 52 | cog_methods = [] 53 | for cmd in [(command.name, command) for command in self.cog.get_commands()]: 54 | if hasattr(cmd[1], "walk_commands") and cmd[1].walk_commands(): 55 | for child in cmd[1].walk_commands(): 56 | cog_methods.append((child.name, child)) 57 | else: 58 | cog_methods.append(cmd) 59 | 60 | command_classes: List[Testing] = [] 61 | 62 | for cls in self.__class__.__subclasses__(): 63 | # print(cls) 64 | if cls.__subclasses__(): 65 | for subcls in cls.__subclasses__(): 66 | command_classes.append(subcls) 67 | else: 68 | command_classes.append(cls) 69 | # print(command_classes) 70 | # return [cls.test_command for cls in command_classes] 71 | return [ 72 | cls 73 | for cls in command_classes 74 | if cls.__name__.lower() in [n for n, _ in cog_methods] 75 | ] 76 | 77 | @property 78 | def command(self) -> Coroutine: 79 | """The command that is being tested""" 80 | for command in self.cog.walk_commands(): 81 | if isinstance(command, Command): 82 | if command.name.lower() == self.__class__.__name__.lower(): 83 | return command 84 | 85 | async def run_tests(self, only_command: Optional[str] = None) -> TestResult: 86 | """The function that returns the test result for this group""" 87 | for test in self.all_tests: 88 | command = test() 89 | 90 | if only_command and command.__class__.__name__.lower() != only_command: 91 | continue # Skip if the command is not the one we want to test 92 | 93 | await command.test_command() 94 | self.result.add_result(command.result) 95 | 96 | # await self.cog.client.session.close() 97 | return self.result 98 | 99 | async def test_command(self) -> None: 100 | """Runs all tests of a command""" 101 | 102 | for method in test.tests(self): 103 | await method(self) 104 | 105 | @classmethod 106 | async def press_confirm(cls, context: Context): 107 | """Presses the confirm button of a ConfirmView""" 108 | from .types import ArgumentInteraction 109 | 110 | for child in context.current_view.children: 111 | if child.custom_id == "confirm": 112 | await child.callback(ArgumentInteraction(context)) 113 | 114 | 115 | class test(object): 116 | 117 | def __init__(self, method): 118 | self._method = method 119 | 120 | async def __call__(self, obj: Testing, *args, **kwargs): 121 | from .types import Result, ResultData 122 | 123 | try: 124 | logging.debug( 125 | f"Running test {self._method.__name__} of command {obj.__class__.__name__}" 126 | ) 127 | await self._method(obj, *args, **kwargs) 128 | logging.debug("successfully passed test") 129 | obj.result.completed_test(self._method, Result.passed) 130 | except Exception as e: 131 | _, _, var = sys.exc_info() 132 | traceback.print_tb(var) 133 | tb_info = traceback.extract_tb(var) 134 | filename, line_number, _, text = tb_info[-1] 135 | 136 | if isinstance(e, AssertionError): 137 | parsed_text = text.split(",")[0] 138 | 139 | logging.error( 140 | f'{filename}:{line_number} test "{self._method.__name__}" of command "{obj.__class__.__name__.lower()}" failed at \n{parsed_text} \nwith actual result \n"{e}"' 141 | ) 142 | obj.result.completed_test( 143 | self._method, Result.failed, result_data=ResultData(error=e) 144 | ) 145 | else: 146 | logging.critical( 147 | f'{filename}:{line_number} test "{self._method.__name__}" of command "{obj.__class__.__name__.lower()}" raised the the following exception in the statement {text}: \n"{e}"' 148 | ) 149 | obj.result.completed_test( 150 | self._method, Result.errored, ResultData(error=e) 151 | ) 152 | 153 | @classmethod 154 | def tests(cls, subject): 155 | def g(): 156 | for name in dir(subject): 157 | method = getattr(subject, name) 158 | if isinstance(method, test): 159 | yield name, method 160 | 161 | return [method for _, method in g()] 162 | -------------------------------------------------------------------------------- /api/src/tests/common.rs: -------------------------------------------------------------------------------- 1 | use std::{process::Command, sync::Once}; 2 | use zmq::{Context, Message, SocketType::ROUTER}; 3 | 4 | pub static INIT: Once = Once::new(); 5 | 6 | #[derive(serde::Deserialize)] 7 | struct SimpleRequest { 8 | route: String, 9 | } 10 | 11 | #[allow(dead_code)] 12 | #[derive(serde::Deserialize)] 13 | struct Request { 14 | route: String, 15 | data: String, 16 | } 17 | 18 | /// Spins up a zmq server in the background 19 | /// with the provided response. 20 | pub fn test_zmq_server() { 21 | let context = Context::new(); 22 | let responder = context.socket(ROUTER).unwrap(); 23 | 24 | assert!(responder.set_rcvtimeo(5000).is_ok()); 25 | assert!(responder.set_linger(0).is_ok()); 26 | assert!(responder.bind("tcp://127.0.0.1:3210").is_ok()); 27 | 28 | // Wait for a request in the background 29 | std::thread::spawn(move || { 30 | let poller = responder.as_poll_item(zmq::POLLIN); 31 | let items = &mut [poller]; 32 | 33 | loop { 34 | if zmq::poll(items, -1).is_err() { 35 | continue; // Skip to the next iteration if polling fails 36 | } 37 | let mut identity = Message::new(); 38 | responder.recv(&mut identity, 0).unwrap(); 39 | let mut buffer = Message::new(); 40 | responder.recv(&mut buffer, 0).unwrap(); 41 | let mut msg = Message::new(); 42 | responder.recv(&mut msg, 0).unwrap(); 43 | 44 | let stripped: Vec = (msg.as_ref() as &[u8])[1..].to_vec(); 45 | let str = String::from_utf8(stripped.clone()).unwrap(); 46 | 47 | let request = serde_json::from_str::(str.as_str()) 48 | .expect("Failed to parse request"); 49 | 50 | let respond_with = if request.route.as_str() == "update" { 51 | // If the request is an update, we need to parse the data as well 52 | let request = serde_json::from_str::(str.as_str()) 53 | .expect("Failed to parse request with data"); 54 | 55 | let output = Command::new("sh") 56 | .current_dir("..") 57 | .arg("-c") 58 | .arg(request.data) 59 | .output() 60 | .expect("Failed to run command"); 61 | // Get the exit code 62 | let exit_code = output.status.code().unwrap_or(-1); 63 | // Get the output 64 | let stdout = String::from_utf8_lossy(&output.stdout); 65 | let stderr = String::from_utf8_lossy(&output.stderr); 66 | let full_output = format!("{stdout}{stderr}"); 67 | // Prepare the response 68 | format!("EXIT_CODE={exit_code}\nOUTPUT={full_output}") 69 | } else { 70 | match request.route 71 | .as_str() 72 | { 73 | "commands" => { 74 | r#"{"CATEGORY": {"name": "category", "description": "", "emoji": {"normal": "a", "unicode": "b"}, "commands": []}}"# 75 | } 76 | "stats" => r#"{"guilds": 1, "shards": 1, "registered_users": 1, "user_installs": 1, "last_restart": 1.0}"#, 77 | "vote" => r#"{"success": "true"}"#, 78 | "heartbeat" => r#"{"success": "true"}"#, 79 | "user/info" => { 80 | // Mock userinfo response with all required fields 81 | r#"{ 82 | "id": "123456789", 83 | "email": "user@example.com", 84 | "display_name": "Test User", 85 | "avatar_url": "https://cdn.discordapp.com/avatars/123456789/avatar.png", 86 | "jenny": 1000, 87 | "daily_cooldown": "2025-01-01T12:00:00", 88 | "met_user": ["111111111", "222222222"], 89 | "effects": {}, 90 | "rs_cards": [], 91 | "fs_cards": [], 92 | "badges": ["developer"], 93 | "rps_stats": {"pvp": {"won": 10, "lost": 5, "tied": 2}}, 94 | "counting_highscore": {"easy": 3, "hard": 1}, 95 | "trivia_stats": {"easy": {"right": 5, "wrong": 2}}, 96 | "achievements": ["first_win"], 97 | "votes": 50, 98 | "voting_streak": {"topgg": {"streak": 5}}, 99 | "voting_reminder": true, 100 | "premium_guilds": {}, 101 | "lootboxes": [1, 2, 3], 102 | "boosters": {"1": 5, "2": 3}, 103 | "weekly_cooldown": "2025-01-01T12:00:00", 104 | "action_settings": {"hug": true}, 105 | "action_stats": {"hug": {"used": 10}}, 106 | "locale": "en-US", 107 | "has_user_installed": true, 108 | "is_premium": true, 109 | "premium_tier": "2", 110 | "email_notifications": {"news": true, "updates": false, "posts": true} 111 | }"# 112 | } 113 | "discord/application_authorized" => r#"{"success": true, "message": "Application authorized event processed successfully"}"#, 114 | "discord/application_deauthorized" => r#"{"success": true, "message": "Application deauthorized event processed successfully"}"#, 115 | "user_get_basic_details" => r#"{"display_name": "Test User", "avatar_url": "https://cdn.discordapp.com/avatars/123456789/avatar.png"}"#, 116 | "news/save" => r#"{"news_id": "test_news_id", "message_id": 1234567890123456789}"#, 117 | "news/delete" => r#"{"status": "deleted"}"#, 118 | "news/edit" => r#"{"news_id": "test_news_id", "message_id": 1234567890123456789}"#, 119 | "user/edit" => r#"{"success": true, "message": "User settings updated successfully"}"#, 120 | _ => r#"{}"#, 121 | }.to_string() 122 | }; 123 | let buffer = Message::from(""); 124 | let message = Message::from(respond_with.as_str()); 125 | responder 126 | .send_multipart(vec![identity, buffer, message], 0) 127 | .unwrap(); 128 | } 129 | }); 130 | } 131 | 132 | /// Gets the API key from Rocket.toml 133 | pub fn get_key() -> String { 134 | std::env::var("API_KEY").unwrap() 135 | } 136 | -------------------------------------------------------------------------------- /api/src/tests/zmq_down/discord_webhooks.rs: -------------------------------------------------------------------------------- 1 | use crate::rocket; 2 | use hex; 3 | use rocket::http::{ContentType, Header, Status}; 4 | use rocket::local::blocking::Client; 5 | use rocket::serde::json::json; 6 | use sha2::{Digest, Sha256}; 7 | 8 | use crate::routes::common::discord_security::enable_test_mode; 9 | 10 | // Test Discord public key (this is a test key, not a real one) 11 | #[allow(dead_code)] 12 | const TEST_PUBLIC_KEY: &str = "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"; 13 | 14 | // For testing purposes, we'll use a simple approach 15 | // In production, this would be a real Ed25519 signature 16 | fn create_test_signature(timestamp: &str, body: &str) -> String { 17 | // Create a simple hash-based signature for testing 18 | // This is not a real Ed25519 signature, but it's sufficient for testing the validation logic 19 | let mut hasher = Sha256::new(); 20 | hasher.update(format!("{}{}", timestamp, body).as_bytes()); 21 | let result = hasher.finalize(); 22 | hex::encode(result) 23 | } 24 | 25 | #[test] 26 | fn test_application_authorized_webhook_zmq_down() { 27 | // ZMQ server is down 28 | enable_test_mode(); 29 | let client = Client::tracked(rocket()).unwrap(); 30 | 31 | let webhook_data = json!({ 32 | "version": 1, 33 | "application_id": "1234560123453231555", 34 | "type": 0, 35 | "event": { 36 | "type": "APPLICATION_AUTHORIZED", 37 | "timestamp": "2024-10-18T14:42:53.064834", 38 | "data": { 39 | "integration_type": 1, 40 | "scopes": ["applications.commands"], 41 | "user": { 42 | "id": "123456789012345678", 43 | "username": "testuser", 44 | "discriminator": "1234", 45 | "avatar": "test_avatar_hash", 46 | "bot": false, 47 | "system": false, 48 | "mfa_enabled": true, 49 | "verified": true, 50 | "email": "test@example.com" 51 | } 52 | } 53 | } 54 | }); 55 | 56 | let body = serde_json::to_string(&webhook_data).unwrap(); 57 | let timestamp = "1703000000"; 58 | let signature = create_test_signature(timestamp, &body); 59 | 60 | let response = client 61 | .post("/webhooks/discord") 62 | .header(ContentType::JSON) 63 | .header(Header::new("X-Signature-Ed25519", signature)) 64 | .header(Header::new("X-Signature-Timestamp", timestamp)) 65 | .body(body) 66 | .dispatch(); 67 | 68 | assert_eq!(response.status(), Status::InternalServerError); 69 | } 70 | 71 | #[test] 72 | fn test_application_deauthorized_webhook_zmq_down() { 73 | // ZMQ server is down 74 | enable_test_mode(); 75 | let client = Client::tracked(rocket()).unwrap(); 76 | 77 | let webhook_data = json!({ 78 | "version": 1, 79 | "application_id": "1234560123453231555", 80 | "type": 0, 81 | "event": { 82 | "type": "APPLICATION_DEAUTHORIZED", 83 | "timestamp": "2024-10-18T14:42:53.064834", 84 | "data": { 85 | "user": { 86 | "id": "123456789012345678", 87 | "username": "testuser", 88 | "discriminator": "1234", 89 | "avatar": "test_avatar_hash", 90 | "bot": false, 91 | "system": false, 92 | "mfa_enabled": true, 93 | "verified": true, 94 | "email": "test@example.com" 95 | } 96 | } 97 | } 98 | }); 99 | 100 | let body = serde_json::to_string(&webhook_data).unwrap(); 101 | let timestamp = "1703000000"; 102 | let signature = create_test_signature(timestamp, &body); 103 | 104 | let response = client 105 | .post("/webhooks/discord") 106 | .header(ContentType::JSON) 107 | .header(Header::new("X-Signature-Ed25519", signature)) 108 | .header(Header::new("X-Signature-Timestamp", timestamp)) 109 | .body(body) 110 | .dispatch(); 111 | 112 | assert_eq!(response.status(), Status::InternalServerError); 113 | } 114 | 115 | #[test] 116 | fn test_webhook_ping_event_no_auth_zmq_down() { 117 | // ZMQ server is down, but ping should still work without auth 118 | enable_test_mode(); 119 | let client = Client::tracked(rocket()).unwrap(); 120 | 121 | let webhook_data = json!({ 122 | "version": 1, 123 | "application_id": "1234560123453231555", 124 | "type": 0 125 | }); 126 | 127 | let body = serde_json::to_string(&webhook_data).unwrap(); 128 | 129 | // No authentication headers for ping events 130 | let response = client 131 | .post("/webhooks/discord") 132 | .header(ContentType::JSON) 133 | .body(body) 134 | .dispatch(); 135 | 136 | assert_eq!(response.status(), Status::NoContent); 137 | } 138 | 139 | #[test] 140 | fn test_webhook_ping_event_zmq_down() { 141 | // ZMQ server is down, but ping should still work 142 | let client = Client::tracked(rocket()).unwrap(); 143 | 144 | let webhook_data = json!({ 145 | "version": 1, 146 | "application_id": "1234560123453231555", 147 | "type": 0 148 | }); 149 | 150 | let body = serde_json::to_string(&webhook_data).unwrap(); 151 | let timestamp = "1703000000"; 152 | let signature = create_test_signature(timestamp, &body); 153 | 154 | let response = client 155 | .post("/webhooks/discord") 156 | .header(ContentType::JSON) 157 | .header(Header::new("X-Signature-Ed25519", signature)) 158 | .header(Header::new("X-Signature-Timestamp", timestamp)) 159 | .body(body) 160 | .dispatch(); 161 | 162 | assert_eq!(response.status(), Status::NoContent); 163 | } 164 | 165 | #[test] 166 | fn test_webhook_health_check_zmq_down() { 167 | // ZMQ server is down, but health check should still work 168 | enable_test_mode(); 169 | let client = Client::tracked(rocket()).unwrap(); 170 | let response = client.get("/webhooks/discord").dispatch(); 171 | 172 | assert_eq!(response.status(), Status::Ok); 173 | 174 | let body = response.into_string().expect("response body"); 175 | let response_data: serde_json::Value = serde_json::from_str(&body).expect("valid json"); 176 | 177 | assert_eq!(response_data["success"], true); 178 | assert_eq!( 179 | response_data["message"], 180 | "Discord webhook endpoint is active" 181 | ); 182 | } 183 | -------------------------------------------------------------------------------- /api/src/routes/common/discord_auth.rs: -------------------------------------------------------------------------------- 1 | use rocket::http::Status; 2 | use rocket::request::{FromRequest, Outcome, Request}; 3 | use serde::{Deserialize, Serialize}; 4 | use std::env; 5 | 6 | // Test mode flag - set to true during tests 7 | static TEST_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); 8 | // Test admin IDs - set during tests 9 | static TEST_ADMIN_IDS: std::sync::atomic::AtomicPtr = 10 | std::sync::atomic::AtomicPtr::new(std::ptr::null_mut()); 11 | 12 | /// Enable test mode for Discord authentication 13 | #[allow(dead_code)] 14 | pub fn enable_test_mode() { 15 | TEST_MODE.store(true, std::sync::atomic::Ordering::Relaxed); 16 | } 17 | 18 | /// Disable test mode for Discord authentication 19 | #[allow(dead_code)] 20 | pub fn disable_test_mode() { 21 | TEST_MODE.store(false, std::sync::atomic::Ordering::Relaxed); 22 | // Clear test admin IDs 23 | let null_ptr = std::ptr::null_mut(); 24 | TEST_ADMIN_IDS.store(null_ptr, std::sync::atomic::Ordering::Relaxed); 25 | } 26 | 27 | /// Set test admin IDs for testing 28 | #[allow(dead_code)] 29 | pub fn set_test_admin_ids(admin_ids: String) { 30 | let boxed_string = Box::new(admin_ids); 31 | let ptr = Box::into_raw(boxed_string); 32 | TEST_ADMIN_IDS.store(ptr, std::sync::atomic::Ordering::Relaxed); 33 | } 34 | 35 | #[derive(Debug, Serialize, Deserialize)] 36 | pub struct DiscordUser { 37 | pub id: String, 38 | pub username: String, 39 | pub discriminator: String, 40 | pub avatar: Option, 41 | pub email: Option, 42 | } 43 | 44 | #[derive(Debug)] 45 | pub enum DiscordAuthError { 46 | Missing, 47 | Invalid, 48 | DiscordApiError, 49 | } 50 | 51 | pub struct DiscordAuth(pub DiscordUser); 52 | 53 | #[rocket::async_trait] 54 | impl<'r> FromRequest<'r> for DiscordAuth { 55 | type Error = DiscordAuthError; 56 | 57 | async fn from_request(req: &'r Request<'_>) -> Outcome { 58 | // Get the Authorization header 59 | let auth_header = match req.headers().get_one("Authorization") { 60 | None => return Outcome::Error((Status::Forbidden, DiscordAuthError::Missing)), 61 | Some(header) => header, 62 | }; 63 | 64 | // Check if it's a Bearer token 65 | if !auth_header.starts_with("Bearer ") { 66 | return Outcome::Error((Status::Forbidden, DiscordAuthError::Invalid)); 67 | } 68 | 69 | // Verify the token with Discord API 70 | match verify_discord_token(auth_header).await { 71 | Ok(user) => Outcome::Success(DiscordAuth(user)), 72 | Err(_) => Outcome::Error((Status::Forbidden, DiscordAuthError::Invalid)), 73 | } 74 | } 75 | } 76 | 77 | async fn verify_discord_token(token: &str) -> Result { 78 | // Check if we're in test mode 79 | let test_mode = TEST_MODE.load(std::sync::atomic::Ordering::Relaxed); 80 | if test_mode { 81 | return verify_discord_token_test(token); 82 | } 83 | 84 | let client = reqwest::Client::new(); 85 | 86 | let response = client 87 | .get("https://discord.com/api/v10/users/@me") 88 | .header("Authorization", token) 89 | .send() 90 | .await; 91 | 92 | match response { 93 | Ok(resp) => { 94 | if resp.status().is_success() { 95 | let response_text = resp.text().await.unwrap_or_default(); 96 | 97 | match serde_json::from_str::(&response_text) { 98 | Ok(user) => Ok(user), 99 | Err(_e) => Err(DiscordAuthError::DiscordApiError), 100 | } 101 | } else { 102 | Err(DiscordAuthError::Invalid) 103 | } 104 | } 105 | Err(_e) => Err(DiscordAuthError::DiscordApiError), 106 | } 107 | } 108 | 109 | fn verify_discord_token_test(token: &str) -> Result { 110 | // Remove "Bearer " prefix if present 111 | let token = if let Some(stripped) = token.strip_prefix("Bearer ") { 112 | stripped 113 | } else { 114 | token 115 | }; 116 | 117 | match token { 118 | "valid_token_1" => Ok(DiscordUser { 119 | id: "123456789".to_string(), 120 | username: "testuser1".to_string(), 121 | discriminator: "0001".to_string(), 122 | avatar: Some("avatar1".to_string()), 123 | email: Some("user1@example.com".to_string()), 124 | }), 125 | "valid_token_2" => Ok(DiscordUser { 126 | id: "987654321".to_string(), 127 | username: "testuser2".to_string(), 128 | discriminator: "0002".to_string(), 129 | avatar: Some("avatar2".to_string()), 130 | email: Some("user2@example.com".to_string()), 131 | }), 132 | "admin_token" => Ok(DiscordUser { 133 | id: "555666777".to_string(), 134 | username: "adminuser".to_string(), 135 | discriminator: "0003".to_string(), 136 | avatar: Some("avatar3".to_string()), 137 | email: Some("admin@example.com".to_string()), 138 | }), 139 | _ => Err(DiscordAuthError::Invalid), 140 | } 141 | } 142 | 143 | impl DiscordAuth { 144 | /// Check if the authenticated user is an admin 145 | pub fn is_admin(&self) -> bool { 146 | let test_mode = TEST_MODE.load(std::sync::atomic::Ordering::Relaxed); 147 | 148 | if test_mode { 149 | // Use test admin IDs 150 | let test_admin_ids_ptr = TEST_ADMIN_IDS.load(std::sync::atomic::Ordering::Relaxed); 151 | if !test_admin_ids_ptr.is_null() { 152 | let test_admin_ids = unsafe { &*test_admin_ids_ptr }; 153 | let admin_ids: Vec<&str> = test_admin_ids.split(',').collect(); 154 | return admin_ids.contains(&self.0.id.as_str()); 155 | } 156 | } 157 | 158 | // Use environment variable 159 | let admin_ids = env::var("ADMIN_IDS").unwrap_or_default(); 160 | let admin_ids: Vec<&str> = admin_ids.split(',').collect(); 161 | admin_ids.contains(&self.0.id.as_str()) 162 | } 163 | 164 | /// Check if the authenticated user can access data for the given user_id 165 | pub fn can_access_user(&self, user_id: &str) -> bool { 166 | // Users can always access their own data 167 | if self.0.id == user_id { 168 | return true; 169 | } 170 | 171 | // Admins can access any user's data 172 | if self.is_admin() { 173 | return true; 174 | } 175 | 176 | false 177 | } 178 | } 179 | --------------------------------------------------------------------------------