first commit
This commit is contained in:
commit
634f9cb00c
80
.github/workflows/rust.yml
vendored
Normal file
80
.github/workflows/rust.yml
vendored
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
name: Cargo Build, Test, and Linting
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUSTFLAGS: "-Dwarnings"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format:
|
||||||
|
name: Check formatting
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo fmt --check
|
||||||
|
clippy:
|
||||||
|
name: Run lints
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo clippy --all-targets --all-features
|
||||||
|
build_and_test:
|
||||||
|
name: Build project and test
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo test --verbose
|
||||||
|
build_release:
|
||||||
|
name: Build project in release
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo build --verbose --release
|
||||||
|
- name: Export executable
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: pumpkin-${{ matrix.os }}
|
||||||
|
compression-level: 9
|
||||||
|
path: target/${{ matrix.target }}/release/pumpkin*
|
||||||
|
clippy_release:
|
||||||
|
name: Run lints in release mode
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
toolchain:
|
||||||
|
- stable
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }}
|
||||||
|
- uses: Swatinem/rust-cache@v2
|
||||||
|
- run: cargo clippy --release --all-targets --all-features
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1502
Cargo.lock
generated
Normal file
1502
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "botmark"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# log
|
||||||
|
log = "0.4"
|
||||||
|
simple_logger = "5.0.0"
|
||||||
|
# CLI
|
||||||
|
clap = { version = "4.5.30", features = ["derive"] }
|
||||||
|
# Networking
|
||||||
|
tokio = { version = "1.43.0", features = ["net", "io-util"] }
|
||||||
|
pumpkin-protocol = { git = "https://github.com/Pumpkin-MC/Pumpkin", package = "pumpkin-protocol", default-features = false, features = ["packets"]}
|
||||||
|
|
||||||
|
crossbeam = "0.8.4"
|
||||||
|
uuid = "1.14.0"
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Pumpkin MC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
16
README.md
Normal file
16
README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|

|
||||||
|
|
||||||
|
A tool to stress test Minecraft servers by simulating numerous bot connections, helping identify performance bottlenecks and stability issues.
|
||||||
|

|
||||||
|
</div>
|
||||||
|
|
||||||
|
### How to Run
|
||||||
|
1. **Disable Online Mode:** Ensure that `online-mode=false` in your server's `server.properties` file. This is essential for Botmark to connect.
|
||||||
|
|
||||||
|
2. **Run Botmark:** Use the following command in your terminal:
|
||||||
|
`./botmark --ip <ip:port> --count <bot-count>`
|
||||||
|
**Example**
|
||||||
|
`./botmark --ip 0.0.0.0:25565 --count 50`
|
||||||
281
src/client.rs
Normal file
281
src/client.rs
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
use crossbeam::atomic::AtomicCell;
|
||||||
|
use pumpkin_protocol::bytebuf::ReadingError;
|
||||||
|
use pumpkin_protocol::bytebuf::packet::Packet;
|
||||||
|
use pumpkin_protocol::client::config::{CConfigDisconnect, CFinishConfig};
|
||||||
|
use pumpkin_protocol::client::login::{
|
||||||
|
CEncryptionRequest, CLoginDisconnect, CLoginSuccess, CSetCompression,
|
||||||
|
};
|
||||||
|
use pumpkin_protocol::client::play::{CKeepAlive, CPlayDisconnect, CPlayerPosition};
|
||||||
|
use pumpkin_protocol::codec::var_int::VarInt;
|
||||||
|
use pumpkin_protocol::server::config::{SAcknowledgeFinishConfig, SKnownPacks};
|
||||||
|
use pumpkin_protocol::server::handshake::SHandShake;
|
||||||
|
use pumpkin_protocol::server::login::{SLoginAcknowledged, SLoginStart};
|
||||||
|
use pumpkin_protocol::server::play::{SConfirmTeleport, SKeepAlive};
|
||||||
|
use pumpkin_protocol::{CURRENT_MC_PROTOCOL, RawPacket, ServerPacket};
|
||||||
|
use pumpkin_protocol::{
|
||||||
|
ClientPacket, CompressionLevel, CompressionThreshold, ConnectionState,
|
||||||
|
packet_decoder::PacketDecoder, packet_encoder::PacketEncoder,
|
||||||
|
};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::{Arc, atomic::AtomicBool};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::{
|
||||||
|
net::{
|
||||||
|
TcpStream,
|
||||||
|
tcp::{OwnedReadHalf, OwnedWriteHalf},
|
||||||
|
},
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Everything which makes a Connection with our Server is a `Client`.
|
||||||
|
pub struct Client {
|
||||||
|
/// The current connection state of the client (e.g., Handshaking, Status, Play).
|
||||||
|
pub connection_state: AtomicCell<ConnectionState>,
|
||||||
|
/// Indicates if the client connection is closed.
|
||||||
|
pub closed: AtomicBool,
|
||||||
|
/// The packet encoder for outgoing packets.
|
||||||
|
pub enc: Arc<Mutex<PacketEncoder>>,
|
||||||
|
/// The packet decoder for incoming packets.
|
||||||
|
pub dec: Arc<Mutex<PacketDecoder>>,
|
||||||
|
pub connection_reader: Mutex<OwnedReadHalf>,
|
||||||
|
pub connection_writer: Mutex<OwnedWriteHalf>,
|
||||||
|
pub client_packets_queue: Arc<Mutex<VecDeque<RawPacket>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
pub fn new(stream: TcpStream) -> Self {
|
||||||
|
let (connection_reader, connection_writer) = stream.into_split();
|
||||||
|
Self {
|
||||||
|
connection_state: AtomicCell::new(ConnectionState::HandShake),
|
||||||
|
enc: Arc::new(Mutex::new(PacketEncoder::default())),
|
||||||
|
dec: Arc::new(Mutex::new(PacketDecoder::default())),
|
||||||
|
closed: AtomicBool::new(false),
|
||||||
|
connection_reader: Mutex::new(connection_reader),
|
||||||
|
connection_writer: Mutex::new(connection_writer),
|
||||||
|
client_packets_queue: Arc::new(Mutex::new(VecDeque::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables or disables packet compression for the connection.
|
||||||
|
///
|
||||||
|
/// This function takes an optional `CompressionInfo` struct as input. If the `CompressionInfo` is provided,
|
||||||
|
/// packet compression is enabled with the specified threshold. Otherwise, compression is disabled.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `compression`: An optional `CompressionInfo` struct containing the compression threshold and compression level.
|
||||||
|
pub async fn set_compression(
|
||||||
|
&self,
|
||||||
|
compression: Option<(CompressionThreshold, CompressionLevel)>,
|
||||||
|
) {
|
||||||
|
self.dec.lock().await.set_compression(compression.is_some());
|
||||||
|
self.enc
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.set_compression(compression.map(|s| (s.0, s.1)))
|
||||||
|
.unwrap_or_else(|_| log::warn!("invalid compression level"));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_packets(&self) {
|
||||||
|
let mut packet_queue = self.client_packets_queue.lock().await;
|
||||||
|
while let Some(mut packet) = packet_queue.pop_front() {
|
||||||
|
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
log::debug!("Canceling client packet processing (pre)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(error) = self.handle_packet(&mut packet).await {
|
||||||
|
log::error!(
|
||||||
|
"Failed to read incoming packet with id {}: {}",
|
||||||
|
i32::from(packet.id),
|
||||||
|
error
|
||||||
|
);
|
||||||
|
self.close().await;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn add_packet(&self, packet: RawPacket) {
|
||||||
|
let mut client_packets_queue = self.client_packets_queue.lock().await;
|
||||||
|
client_packets_queue.push_back(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn poll(&self) -> bool {
|
||||||
|
loop {
|
||||||
|
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
// If we manually close (like a kick) we dont want to keep reading bytes
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut dec = self.dec.lock().await;
|
||||||
|
|
||||||
|
match dec.decode() {
|
||||||
|
Ok(Some(packet)) => {
|
||||||
|
self.add_packet(packet).await;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Ok(None) => (), //log::debug!("Waiting for more data to complete packet..."),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to decode packet for: {}", err.to_string());
|
||||||
|
self.close().await;
|
||||||
|
return false; // return to avoid reserving additional bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dec.reserve(4096);
|
||||||
|
let mut buf = dec.take_capacity();
|
||||||
|
|
||||||
|
let bytes_read = self.connection_reader.lock().await.read_buf(&mut buf).await;
|
||||||
|
match bytes_read {
|
||||||
|
Ok(cnt) => {
|
||||||
|
//log::debug!("Read {} bytes", cnt);
|
||||||
|
if cnt == 0 {
|
||||||
|
self.close().await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("Error while reading incoming packet {}", error);
|
||||||
|
self.close().await;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// This should always be an O(1) unsplit because we reserved space earlier and
|
||||||
|
// the call to `read_buf` shouldn't have grown the allocation.
|
||||||
|
dec.queue_bytes(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a clientbound packet to the connected client.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `packet`: A reference to a packet object implementing the `ClientPacket` trait.
|
||||||
|
pub async fn send_packet<P: ClientPacket>(&self, packet: &P) {
|
||||||
|
//log::debug!("Sending packet with id {} to {}", P::PACKET_ID, self.id);
|
||||||
|
// assert!(!self.closed);
|
||||||
|
if self.closed.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut enc = self.enc.lock().await;
|
||||||
|
if let Err(_error) = enc.append_packet(packet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut writer = self.connection_writer.lock().await;
|
||||||
|
if let Err(error) = writer.write_all(&enc.take()).await {
|
||||||
|
log::debug!("Unable to write to connection: {}", error.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join_server(&self, address: SocketAddr, name: String) {
|
||||||
|
self.send_packet(&SHandShake {
|
||||||
|
protocol_version: VarInt(CURRENT_MC_PROTOCOL.get() as i32),
|
||||||
|
server_address: address.ip().to_string(),
|
||||||
|
server_port: address.port(),
|
||||||
|
next_state: pumpkin_protocol::ConnectionState::Login,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
self.connection_state.store(ConnectionState::Login);
|
||||||
|
self.send_packet(&SLoginStart {
|
||||||
|
name,
|
||||||
|
uuid: Uuid::new_v4(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_packet(&self, packet: &mut RawPacket) -> Result<(), ReadingError> {
|
||||||
|
match self.connection_state.load() {
|
||||||
|
ConnectionState::HandShake => unreachable!(),
|
||||||
|
ConnectionState::Status => todo!(),
|
||||||
|
ConnectionState::Login => self.handle_login_packet(packet).await?,
|
||||||
|
ConnectionState::Transfer => log::debug!("Got packet in transfer state"),
|
||||||
|
ConnectionState::Config => self.handle_config_packet(packet).await?,
|
||||||
|
ConnectionState::Play => self.handle_play_packet(packet).await?,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_login_packet(&self, packet: &mut RawPacket) -> Result<(), ReadingError> {
|
||||||
|
let bytebuf = &mut packet.bytebuf;
|
||||||
|
match packet.id.0 {
|
||||||
|
CEncryptionRequest::PACKET_ID => {
|
||||||
|
log::debug!("Got Encryption Request")
|
||||||
|
}
|
||||||
|
CSetCompression::PACKET_ID => {
|
||||||
|
log::trace!("Set Compression");
|
||||||
|
let packet = CSetCompression::read(bytebuf)?;
|
||||||
|
self.set_compression(Some((
|
||||||
|
CompressionThreshold(packet.threshold.0 as u32),
|
||||||
|
CompressionLevel(6),
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
CLoginDisconnect::PACKET_ID => {
|
||||||
|
log::error!("Kicking in Login State");
|
||||||
|
self.close().await;
|
||||||
|
}
|
||||||
|
CLoginSuccess::PACKET_ID => {
|
||||||
|
log::trace!("Login -> Config");
|
||||||
|
self.send_packet(&SLoginAcknowledged).await;
|
||||||
|
self.connection_state.store(ConnectionState::Config);
|
||||||
|
log::trace!("Sending Known packs");
|
||||||
|
self.send_packet(&SKnownPacks {
|
||||||
|
known_pack_count: VarInt(0),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_config_packet(&self, packet: &mut RawPacket) -> Result<(), ReadingError> {
|
||||||
|
match packet.id.0 {
|
||||||
|
CConfigDisconnect::PACKET_ID => {
|
||||||
|
log::error!("Kicking in Config State");
|
||||||
|
self.close().await;
|
||||||
|
}
|
||||||
|
CFinishConfig::PACKET_ID => {
|
||||||
|
log::trace!("Config -> Play");
|
||||||
|
self.send_packet(&SAcknowledgeFinishConfig).await;
|
||||||
|
self.connection_state.store(ConnectionState::Play);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_play_packet(&self, packet: &mut RawPacket) -> Result<(), ReadingError> {
|
||||||
|
let bytebuf = &mut packet.bytebuf;
|
||||||
|
match packet.id.0 {
|
||||||
|
CKeepAlive::PACKET_ID => {
|
||||||
|
let packet = CKeepAlive::read(bytebuf)?;
|
||||||
|
self.send_packet(&SKeepAlive {
|
||||||
|
keep_alive_id: packet.keep_alive_id,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
CPlayerPosition::PACKET_ID => {
|
||||||
|
let packet = CPlayerPosition::read(bytebuf)?;
|
||||||
|
self.send_packet(&SConfirmTeleport {
|
||||||
|
teleport_id: packet.teleport_id,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
CPlayDisconnect::PACKET_ID => {
|
||||||
|
log::error!("Kicking in Play State");
|
||||||
|
self.close().await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn close(&self) {
|
||||||
|
self.closed
|
||||||
|
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/main.rs
Normal file
51
src/main.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use client::Client;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
mod client;
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
struct Args {
|
||||||
|
#[arg(short, long)]
|
||||||
|
ip: String,
|
||||||
|
#[arg(short, long, default_value_t = 1)]
|
||||||
|
count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
simple_logger::init_with_level(log::Level::Info).unwrap();
|
||||||
|
let args = Args::parse();
|
||||||
|
let address = args.ip.parse::<SocketAddr>().unwrap();
|
||||||
|
log::info!("{} Bots will Join {}", args.count, address);
|
||||||
|
let mut join_handles = Vec::with_capacity(args.count as usize);
|
||||||
|
|
||||||
|
for i in 0..args.count {
|
||||||
|
let stream = TcpStream::connect(address)
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to Ip");
|
||||||
|
let client = Client::new(stream);
|
||||||
|
client.join_server(address, format!("BOT_{}", i)).await;
|
||||||
|
let join_handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
if !client.poll().await {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
client.process_packets().await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
join_handles.push(join_handle);
|
||||||
|
log::info!("{}/{} Bots Joined", i + 1, args.count);
|
||||||
|
}
|
||||||
|
// Graceful shutdown on Ctrl+C
|
||||||
|
tokio::signal::ctrl_c().await.unwrap();
|
||||||
|
log::info!("Shutting down...");
|
||||||
|
|
||||||
|
// Wait for all bot tasks to exit
|
||||||
|
for handle in join_handles {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user