Skip to content

Commit 24554c7

Browse files
committed
slash commands
Introduces a `/ping` slash command and a `/restart` admin slash command
1 parent 768f3a5 commit 24554c7

File tree

11 files changed

+529
-73
lines changed

11 files changed

+529
-73
lines changed

.github/template-values.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[values]
2+
admin_guild_id = "123"
3+
application_id = "456"

.github/workflows/check.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424
with:
2525
tool: cargo-generate
2626

27-
- run: cargo generate --path . --name instance
27+
- run: cargo generate --path . --name instance --template-values-file .github/template-values.toml
2828

2929
- run: cargo clippy
3030
working-directory: instance
@@ -45,7 +45,7 @@ jobs:
4545
with:
4646
tool: cargo-generate
4747

48-
- run: cargo generate --path . --name instance
48+
- run: cargo generate --path . --name instance --template-values-file .github/template-values.toml
4949

5050
- run: cargo fmt -- --check
5151
working-directory: instance

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ edition = "2024"
55

66
[dependencies]
77
anyhow = "1"
8+
dashmap = "6"
89
rustls = "0.23"
910
serde = { version = "1", features = ["derive"] }
1011
serde_json = "1"
@@ -15,3 +16,5 @@ tracing-subscriber = "0.3"
1516
twilight-gateway = "0.17"
1617
twilight-http = "0.17"
1718
twilight-model = "0.17"
19+
twilight-standby = "0.17"
20+
twilight-util = { version = "0.17", features = ["builder"] }

cargo-generate.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ ignore = [".github", "Cargo.lock", "target"]
33

44
[hooks]
55
init = ["init-script.rhai"]
6+
7+
[placeholders]
8+
admin_guild_id = { prompt = "Enter admin guild ID (where admin commands are available)", type = "string" }
9+
application_id = { prompt = "Enter the application ID (available in the application's dashboard)", type = "string" }

src/command.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
mod ping;
2+
mod restart;
3+
4+
use twilight_model::{
5+
application::{
6+
command::Command,
7+
interaction::{InteractionData, InteractionType},
8+
},
9+
gateway::payload::incoming::InteractionCreate,
10+
};
11+
12+
pub fn admin_commands(shards: u32) -> [Command; 1] {
13+
[restart::command(shards)]
14+
}
15+
16+
pub fn global_commands() -> [Command; 1] {
17+
[ping::command()]
18+
}
19+
20+
#[derive(Clone, Copy, Debug)]
21+
enum Type {
22+
Ping,
23+
Restart,
24+
}
25+
26+
impl From<&str> for Type {
27+
fn from(value: &str) -> Self {
28+
match value {
29+
ping::NAME => Type::Ping,
30+
restart::NAME => Type::Restart,
31+
_ => panic!("unknown command name: '{value}'"),
32+
}
33+
}
34+
}
35+
36+
pub async fn interaction(mut event: Box<InteractionCreate>) -> anyhow::Result<()> {
37+
match event.kind {
38+
InteractionType::ApplicationCommandAutocomplete => {
39+
let InteractionData::ApplicationCommand(data) = event.data.take().unwrap() else {
40+
unreachable!();
41+
};
42+
let kind = data.name.as_str().into();
43+
44+
match kind {
45+
Type::Ping => ping::autocomplete(event, data).await?,
46+
Type::Restart => restart::autocomplete(event, data).await?,
47+
}
48+
}
49+
InteractionType::ApplicationCommand => {
50+
let InteractionData::ApplicationCommand(data) = event.data.take().unwrap() else {
51+
unreachable!();
52+
};
53+
let kind = data.name.as_str().into();
54+
55+
match kind {
56+
Type::Ping => ping::run(event, data).await?,
57+
Type::Restart => restart::run(event, data).await?,
58+
}
59+
}
60+
_ => {}
61+
}
62+
63+
Ok(())
64+
}

src/command/ping.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use crate::{APPLICATION_ID, CONTEXT};
2+
use twilight_model::{
3+
application::{
4+
command::{Command, CommandType},
5+
interaction::application_command::CommandData,
6+
},
7+
channel::message::MessageFlags,
8+
gateway::payload::incoming::InteractionCreate,
9+
http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType},
10+
};
11+
use twilight_util::builder::command::CommandBuilder;
12+
13+
pub const NAME: &str = "ping";
14+
15+
pub fn command() -> Command {
16+
CommandBuilder::new(NAME, "Ping the bot", CommandType::ChatInput).build()
17+
}
18+
19+
pub async fn autocomplete(
20+
_event: Box<InteractionCreate>,
21+
_data: Box<CommandData>,
22+
) -> anyhow::Result<()> {
23+
Ok(())
24+
}
25+
26+
pub async fn run(event: Box<InteractionCreate>, _data: Box<CommandData>) -> anyhow::Result<()> {
27+
let data = InteractionResponseData {
28+
content: Some("Pong!".to_owned()),
29+
flags: Some(MessageFlags::EPHEMERAL),
30+
..Default::default()
31+
};
32+
33+
let response = InteractionResponse {
34+
kind: InteractionResponseType::ChannelMessageWithSource,
35+
data: Some(data),
36+
};
37+
CONTEXT
38+
.http
39+
.interaction(APPLICATION_ID)
40+
.create_response(event.id, &event.token, &response)
41+
.await?;
42+
43+
Ok(())
44+
}

src/command/restart.rs

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
use crate::{APPLICATION_ID, CONTEXT, ShardRestartType};
2+
use std::iter;
3+
use twilight_gateway::Event;
4+
use twilight_model::{
5+
application::{
6+
command::{Command, CommandOptionChoice, CommandOptionChoiceValue, CommandType},
7+
interaction::application_command::{CommandData, CommandDataOption, CommandOptionValue},
8+
},
9+
gateway::payload::incoming::InteractionCreate,
10+
guild::Permissions,
11+
http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType},
12+
};
13+
use twilight_util::builder::command::{BooleanBuilder, CommandBuilder, IntegerBuilder};
14+
15+
pub const NAME: &str = "restart";
16+
17+
pub fn command(shards: u32) -> Command {
18+
CommandBuilder::new(NAME, "Restart a shard", CommandType::ChatInput)
19+
.default_member_permissions(Permissions::empty())
20+
.option(
21+
IntegerBuilder::new("id", "Shard ID")
22+
.autocomplete(true)
23+
.max_value(shards as i64)
24+
.min_value(0)
25+
.required(true),
26+
)
27+
.option(BooleanBuilder::new(
28+
"resume",
29+
"Resume ression? [default: false]",
30+
))
31+
.build()
32+
}
33+
34+
pub async fn autocomplete(
35+
event: Box<InteractionCreate>,
36+
mut data: Box<CommandData>,
37+
) -> anyhow::Result<()> {
38+
let choice = |shard_id: u32| CommandOptionChoice {
39+
name: shard_id.to_string(),
40+
name_localizations: None,
41+
value: CommandOptionChoiceValue::Integer(shard_id.into()),
42+
};
43+
44+
let mut options = data.options.drain(..);
45+
let CommandOptionValue::Focused(value, _) = options.next().unwrap().value else {
46+
unreachable!()
47+
};
48+
49+
let choices = match value.parse() {
50+
Ok(shard_id) if shard_id == 0 => vec![choice(shard_id)],
51+
Ok(shard_id) => starts_with(shard_id, CONTEXT.shard_handles.len() as u32)
52+
.take(25)
53+
.map(choice)
54+
.collect::<Vec<_>>(),
55+
Err(_) => Vec::new(),
56+
};
57+
let data = InteractionResponseData {
58+
choices: Some(choices),
59+
..Default::default()
60+
};
61+
62+
let response = InteractionResponse {
63+
kind: InteractionResponseType::ApplicationCommandAutocompleteResult,
64+
data: Some(data),
65+
};
66+
CONTEXT
67+
.http
68+
.interaction(APPLICATION_ID)
69+
.create_response(event.id, &event.token, &response)
70+
.await?;
71+
72+
Ok(())
73+
}
74+
75+
pub async fn run(event: Box<InteractionCreate>, mut data: Box<CommandData>) -> anyhow::Result<()> {
76+
let mut options = data.options.drain(..);
77+
let CommandOptionValue::Integer(shard_id) = options.next().unwrap().value else {
78+
unreachable!()
79+
};
80+
let kind = match options.next() {
81+
Some(CommandDataOption {
82+
name: _,
83+
value: CommandOptionValue::Boolean(resume),
84+
}) => match resume {
85+
true => ShardRestartType::Resume,
86+
false => ShardRestartType::Normal,
87+
},
88+
None => ShardRestartType::Normal,
89+
Some(_) => unreachable!(),
90+
};
91+
92+
let shard_handle = CONTEXT
93+
.shard_handles
94+
.get(&(shard_id as u32))
95+
.unwrap()
96+
.clone();
97+
let is_force_restarted = shard_handle.restart(kind).is_forced();
98+
99+
let response = if is_force_restarted {
100+
tracing::debug!(shard_id, "force restarting shard");
101+
let data = InteractionResponseData {
102+
content: Some("Force restarted shard".to_owned()),
103+
..Default::default()
104+
};
105+
InteractionResponse {
106+
kind: InteractionResponseType::ChannelMessageWithSource,
107+
data: Some(data),
108+
}
109+
} else {
110+
tracing::debug!(shard_id, type = ?kind, "restarting shard");
111+
InteractionResponse {
112+
kind: InteractionResponseType::DeferredChannelMessageWithSource,
113+
data: None,
114+
}
115+
};
116+
117+
CONTEXT
118+
.http
119+
.interaction(APPLICATION_ID)
120+
.create_response(event.id, &event.token, &response)
121+
.await?;
122+
if is_force_restarted {
123+
return Ok(());
124+
}
125+
126+
shard_handle.restarted().await;
127+
let is_shutdown = !CONTEXT
128+
.shard_handles
129+
.get(&(shard_id as u32))
130+
.unwrap()
131+
.is_valid();
132+
133+
let content = match is_shutdown {
134+
true => "Bot shut down",
135+
false => {
136+
tracing::debug!(shard_id, "shard restarted");
137+
"Shard restarted"
138+
}
139+
};
140+
CONTEXT
141+
.http
142+
.interaction(APPLICATION_ID)
143+
.update_response(&event.token)
144+
.content(Some(content))
145+
.await?;
146+
if is_shutdown {
147+
return Ok(());
148+
}
149+
150+
CONTEXT.standby.wait_for_event(
151+
move |event: &Event| matches!(event, Event::Ready(ready) if ready.shard.is_some_and(|id| id.number() == shard_id as u32)),
152+
).await?;
153+
tracing::debug!(shard_id, "shard ready");
154+
CONTEXT
155+
.http
156+
.interaction(APPLICATION_ID)
157+
.create_followup(&event.token)
158+
.content("Shard ready")
159+
.await?;
160+
161+
Ok(())
162+
}
163+
164+
/// Creates an iterator which computes values up to `max` whose string
165+
/// representanion starts with `value`.
166+
///
167+
/// Produces invalid values if `value` is 0.
168+
///
169+
/// # Example
170+
///
171+
/// ```
172+
/// let values = starts_with(5, 100);
173+
/// assert!(values.eq([5..6, 50..60].into_iter().flatten()));
174+
///
175+
/// let values = starts_with(50, 1000);
176+
/// assert!(values.eq([50..51, 500..510].into_iter().flatten()));
177+
///
178+
/// let values = starts_with(5, 1000);
179+
/// assert!(values.eq([5..6, 50..60, 500..600].into_iter().flatten()));
180+
/// ```
181+
fn starts_with(value: u32, max: u32) -> impl Iterator<Item = u32> {
182+
debug_assert_ne!(value, 0);
183+
184+
iter::successors(Some(1_u32), |n| n.checked_mul(10))
185+
.take_while(move |&n| value * n <= max)
186+
.flat_map(move |n| {
187+
let start = value * n;
188+
let end = ((value + 1) * n - 1).min(max);
189+
start..=end
190+
})
191+
}

src/context.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1+
use crate::ShardHandle;
2+
use dashmap::DashMap;
13
use std::{ops::Deref, sync::OnceLock};
24
use twilight_http::Client;
5+
use twilight_standby::Standby;
36

47
pub static CONTEXT: Ref = Ref(OnceLock::new());
58

69
#[derive(Debug)]
710
pub struct Context {
811
pub http: Client,
12+
pub shard_handles: DashMap<u32, ShardHandle>,
13+
pub standby: Standby,
914
}
1015

11-
pub fn initialize(http: Client) {
12-
let context = Context { http };
16+
pub fn initialize(http: Client, shard_handles: DashMap<u32, ShardHandle>, standby: Standby) {
17+
let context = Context {
18+
http,
19+
shard_handles,
20+
standby,
21+
};
1322
assert!(CONTEXT.0.set(context).is_ok());
1423
}
1524

0 commit comments

Comments
 (0)