Skip to content

Commit e054766

Browse files
committed
remove static panel_base_url, implement cooldowns
1 parent 66f76f9 commit e054766

4 files changed

Lines changed: 192 additions & 47 deletions

File tree

rust_poc/src/commands.rs

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
use crate::cooldown_service::CooldownService;
12
use crate::AppState;
23
use regex::Regex;
34
use std::ops::Deref;
45
use std::sync::Arc;
56
use std::time::Instant;
67
use tokio::sync::broadcast::error::RecvError;
78
use tokio::sync::broadcast::Receiver;
8-
99
// ChatMessage
1010

1111
#[derive(Clone, Ord, PartialOrd, PartialEq, Eq)]
@@ -23,20 +23,23 @@ pub enum TwitchUserPermission {
2323
System,
2424
}
2525

26+
pub type TwitchUserId = str;
27+
2628
#[derive(Clone)]
2729
pub struct TwitchUser {
28-
pub id: String,
30+
pub id: Box<TwitchUserId>,
2931
pub name: String,
3032
pub permission: TwitchUserPermission,
3133
pub subscriber_months: u16,
3234
pub subscription_tier: u16,
3335
}
3436

37+
// official id, uuid
38+
pub type TwitchMessageId = String;
39+
3540
#[derive(Clone)]
3641
pub struct ChatMessage {
37-
pub message_id: String,
38-
pub user_message_index: u32,
39-
pub global_message_index: u32,
42+
pub message_id: TwitchMessageId,
4043
pub message: String,
4144
pub user: TwitchUser,
4245
pub is_highlighted_message: bool,
@@ -51,26 +54,21 @@ pub struct ChatMessage {
5154

5255
// Triggers
5356

54-
type TriggerId<'a> = &'a str;
55-
type TriggerCallback = fn(&AppState, TriggerId, &ChatMessage) -> ();
56-
57-
pub enum CooldownType {
58-
SECONDS,
59-
MESSAGES
60-
}
57+
pub type TriggerId = str;
58+
pub type TriggerCallback = fn(&AppState, &TriggerId, &ChatMessage) -> ();
6159

62-
pub struct ChatCooldown {
63-
cooldown_type: CooldownType,
64-
amount: u16,
60+
pub enum ChatCooldown {
61+
SECONDS(u32),
62+
MESSAGES(u16)
6563
}
6664

6765
pub struct CommandTrigger {
68-
id: String,
69-
patterns: Vec<Regex>,
70-
permission: TwitchUserPermission,
71-
user_cooldown: ChatCooldown,
72-
global_cooldown: ChatCooldown,
73-
callback: Box<TriggerCallback>,
66+
pub id: Box<TriggerId>,
67+
pub patterns: Vec<Regex>,
68+
pub permission: TwitchUserPermission,
69+
pub user_cooldown: ChatCooldown,
70+
pub global_cooldown: ChatCooldown,
71+
pub callback: Box<TriggerCallback>,
7472
}
7573

7674
static TEXT_COMMAND_CALLBACK: TriggerCallback = |_app_state , _trigger_id, _chat_message| {
@@ -88,6 +86,7 @@ static TEXT_COMMAND_CALLBACK: TriggerCallback = |_app_state , _trigger_id, _chat
8886

8987
pub struct CommandService {
9088
triggers: Vec<CommandTrigger>,
89+
cooldown_service: CooldownService,
9190
}
9291

9392
struct ReceiverClosed;
@@ -104,13 +103,13 @@ impl CommandService {
104103
}
105104
};
106105
for trigger in self.triggers.iter() {
107-
CommandService::execute_trigger_if_matching(app_state.deref(), trigger, &message)
106+
self.execute_trigger_if_matching(app_state.deref(), trigger, &message)
108107
}
109108
}
110109
Ok(())
111110
}
112111

113-
fn execute_trigger_if_matching(app_state: &AppState, trigger: &CommandTrigger, chat_message: &ChatMessage) {
112+
fn execute_trigger_if_matching(&self, app_state: &AppState, trigger: &CommandTrigger, chat_message: &ChatMessage) {
114113
if chat_message.user.permission < trigger.permission {
115114
// logger.debug("User {} with {}, missing {} permission for command {}", message.user().name(), message.user().permission(), trigger.permission(), trigger.id());
116115
return;
@@ -120,17 +119,14 @@ impl CommandService {
120119
return;
121120
}
122121

123-
// if (inGlobalCooldown(message, trigger.id(), trigger.globalCooldown())) {
124-
// logger.debug("Call to command {} from {} rejected because of global cooldowns", trigger.id(), message.user().name());
125-
// return;
126-
// }
127-
// if (inUserCooldown(message, trigger.id(), trigger.userCooldown())) {
128-
// logger.debug("Call to command {} from {} rejected because of user cooldowns", trigger.id(), message.user().name());
129-
// return;
130-
// }
131-
// updateCooldownState(message, trigger.id(), trigger.globalCooldown(), trigger.userCooldown());
132-
133-
(trigger.callback)(app_state, trigger.id.as_str(), chat_message);
122+
let cooldown_res = self.cooldown_service.check_update_cooldown(chat_message, trigger.id.as_ref(), &trigger.user_cooldown, &trigger.global_cooldown);
123+
if cooldown_res.is_some() {
124+
// logger.debug("Call to command {} from {} rejected because of global cooldowns", trigger.id(), message.user().name());
125+
// logger.debug("Call to command {} from {} rejected because of user cooldowns", trigger.id(), message.user().name());
126+
return;
127+
}
128+
129+
(trigger.callback)(app_state, trigger.id.as_ref(), chat_message);
134130
}
135131
}
136132

rust_poc/src/cooldown_service.rs

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
use crate::commands::{ChatCooldown, ChatMessage, TriggerId, TwitchUserId};
2+
use std::collections::BTreeMap;
3+
use std::sync::atomic::AtomicU64;
4+
use std::sync::atomic::Ordering::Relaxed;
5+
use std::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
6+
use std::time::Instant;
7+
8+
#[derive(Default)]
9+
pub struct CooldownService {
10+
global_message_counter: AtomicU64,
11+
state: RwLock<InnerState>
12+
}
13+
14+
type SequentialMessageId = u64;
15+
16+
//TODO test if we can replace this lock and inner solution with concurrent: dashmap
17+
18+
#[derive(Default)]
19+
struct InnerState {
20+
global_message: BTreeMap<Box<TriggerId>, SequentialMessageId>,
21+
global_seconds: BTreeMap<Box<TriggerId>, Instant>,
22+
user_message: BTreeMap<(Box<TriggerId>, Box<TwitchUserId>), SequentialMessageId>,
23+
user_seconds: BTreeMap<(Box<TriggerId>, Box<TwitchUserId>), Instant>,
24+
}
25+
26+
pub enum RejectionReason {
27+
GlobalCooldown,
28+
UserCooldown
29+
}
30+
31+
impl CooldownService {
32+
/// Checks and updates the cooldowns in one operation to reduce blocking of the locks
33+
/// ## **Only call this function once for each message**
34+
/// No deduplication is performed, and the same message will be interpreted as two new messages.
35+
/// This will lead to errors in the behavior of the cooldowns
36+
pub fn check_update_cooldown(
37+
&self,
38+
chat_message: &ChatMessage,
39+
trigger_id: &TriggerId,
40+
user_cooldown: &ChatCooldown,
41+
global_cooldown: &ChatCooldown,
42+
) -> Option<RejectionReason> {
43+
let mut wg = self.write_guard();
44+
let sequential_message_id = self.global_message_counter.fetch_add(1, Relaxed) + 1;
45+
match global_cooldown {
46+
ChatCooldown::MESSAGES(amount) => {
47+
let last_sequential_message_id = wg.global_message.get(trigger_id);
48+
if last_sequential_message_id.is_some() {
49+
let delta = sequential_message_id - last_sequential_message_id.unwrap();
50+
if delta <= *amount as u64 {
51+
return Some(RejectionReason::GlobalCooldown);
52+
}
53+
}
54+
wg.global_message.insert(Box::from(trigger_id), sequential_message_id);
55+
}
56+
ChatCooldown::SECONDS(seconds) => {
57+
let last_instant = wg.global_seconds.get(trigger_id);
58+
if last_instant.is_some() {
59+
let seconds_between = last_instant.unwrap().duration_since(chat_message.send_at).as_secs();
60+
if seconds_between <= *seconds as u64 {
61+
return Some(RejectionReason::GlobalCooldown);
62+
}
63+
}
64+
wg.global_seconds.insert(Box::from(trigger_id), chat_message.send_at);
65+
}
66+
};
67+
match user_cooldown {
68+
ChatCooldown::MESSAGES(amount) => {
69+
let key = (Box::from(trigger_id), chat_message.user.id.clone());
70+
let last_sequential_message_id = wg.user_message.get(&key);
71+
if last_sequential_message_id.is_some() {
72+
let delta = sequential_message_id - last_sequential_message_id.unwrap();
73+
if delta <= *amount as u64 {
74+
return Some(RejectionReason::UserCooldown);
75+
}
76+
}
77+
wg.user_message.insert(key, sequential_message_id);
78+
}
79+
ChatCooldown::SECONDS(seconds) => {
80+
let key = (Box::from(trigger_id), chat_message.user.id.clone());
81+
let last_instant = wg.user_seconds.get(&key);
82+
if last_instant.is_some() {
83+
let seconds_between = last_instant.unwrap().duration_since(chat_message.send_at).as_secs();
84+
if seconds_between <= *seconds as u64 {
85+
return Some(RejectionReason::UserCooldown);
86+
}
87+
}
88+
wg.user_seconds.insert(key, chat_message.send_at);
89+
}
90+
};
91+
None
92+
}
93+
94+
fn read_guard(&self) -> RwLockReadGuard<InnerState> {
95+
match self.state.read() {
96+
Ok(guard) => guard,
97+
Err(poisoned) => {
98+
self.state.clear_poison();
99+
drop(poisoned);
100+
let mut wg = self.state.write().unwrap();
101+
*wg = InnerState::default();
102+
self.state.read().unwrap()
103+
},
104+
}
105+
}
106+
107+
fn write_guard(&self) -> RwLockWriteGuard<InnerState> {
108+
match self.state.write() {
109+
Ok(guard) => guard,
110+
Err(poisoned) => {
111+
self.state.clear_poison();
112+
drop(poisoned);
113+
let mut wg = self.state.write().unwrap();
114+
*wg = InnerState::default();
115+
wg
116+
},
117+
}
118+
}
119+
120+
}

rust_poc/src/lib.rs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ use crate::oauth_service::OAuthService;
33
use crate::session_service::SessionService;
44
use rand::distr::Alphanumeric;
55
use rand::Rng;
6+
use serde::{Deserialize, Serialize};
67
use sqlx::mysql::MySqlConnectOptions;
78
use sqlx::MySqlPool;
8-
use std::sync::Arc;
9+
use std::str::FromStr;
10+
use std::sync::{Arc, RwLock};
911
use tokio::runtime::Handle;
12+
use url::Url;
1013

1114
mod oauth_service;
1215
mod webserver_authentication;
@@ -17,8 +20,7 @@ mod session_service;
1720
mod panel_user;
1821
mod panel_user_service;
1922
mod commands;
20-
21-
pub(crate) static PANEL_BASE_URL: &'static str = "http://localhost:5173";
23+
mod cooldown_service;
2224

2325
pub async fn start() {
2426
//check if all mandatory configuration values are set
@@ -34,6 +36,10 @@ pub async fn start() {
3436
session_service: SessionService::default(),
3537
oauth_service: OAuthService::default(),
3638
prod_db,
39+
webserver_config: RwLock::new(WebserverConfig {
40+
panel_base_url: Url::from_str("http://localhost:5173").unwrap(),
41+
server_base_url: Url::from_str("http://localhost:4771").unwrap(),
42+
})
3743
});
3844
let a2 = app_state.clone();
3945
Handle::current().spawn(async { axum::axum(5000, a2).await });
@@ -44,10 +50,27 @@ pub async fn start() {
4450
println!("oauth: {:?}", oauth);
4551
}
4652

53+
#[derive(Clone, Deserialize, Serialize)]
54+
struct DbConfig {
55+
db_host: String,
56+
db_port: u16,
57+
db_username: String,
58+
db_password: String,
59+
db_database: String,
60+
}
61+
62+
/// Very basic, but can already be saved in the Database
63+
struct WebserverConfig {
64+
panel_base_url: Url,
65+
server_base_url: Url
66+
// maybe we need to add stuff like cors and disable auth here
67+
}
68+
4769
struct AppState {
4870
pub prod_db: ProdDB,
4971
pub session_service: SessionService,
5072
pub oauth_service: OAuthService,
73+
pub webserver_config: RwLock<WebserverConfig>
5174
}
5275

5376
#[allow(dead_code)]
@@ -62,7 +85,7 @@ mod _services {
6285
// AuthenticationService;
6386
struct PanelWebServer;
6487
struct WatchtimeService;
65-
struct CommandsService;
88+
// CommandsService;
6689
struct TimerService;
6790
struct GiveawayService;
6891
struct StreamInfoEditorService;

rust_poc/src/oauth_endpoint.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
use crate::axum::{url_encode, AxumState};
22
use crate::oauth_service::OauthReturnError;
33
use crate::webserver_authentication::Moderator;
4-
use crate::PANEL_BASE_URL;
54
use axum::body::Body;
65
use axum::extract::{Path, Query, State};
76
use axum::response::IntoResponse;
87
use axum::Json;
8+
use reqwest::StatusCode;
99
use serde::Deserialize;
1010

1111
/// Get redirect url for a particular service. The url is fully formed with the host accessible from the outside.
@@ -34,24 +34,30 @@ pub async fn receive_oauth(
3434
Path(service): Path<String>,
3535
Query(query): Query<ReceiveOAuthQuery>,
3636
) -> impl IntoResponse {
37+
let panel_base_url = if let Ok(v) = state.webserver_config.read() {
38+
v.panel_base_url.as_str().to_string()
39+
} else {
40+
// log lock poisoned
41+
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
42+
};
3743
// Body:new( should be Redirect:to(& but for testing with postman, this is deactivated
3844
if query.error.is_some() || query.error_description.is_some() {
39-
return Body::new(format!("{}?success=false&error={}", PANEL_BASE_URL, url_encode(&query.error_description.unwrap_or(query.error.unwrap()))));
45+
return Body::new(format!("{}?success=false&error={}", panel_base_url, url_encode(&query.error_description.unwrap_or(query.error.unwrap())))).into_response();
4046
}
4147
if query.scope.is_none() {
42-
return Body::new(format!("{}?success=false&error={}", PANEL_BASE_URL, url_encode("Query param scope is required for non error Oauth response")))
48+
return Body::new(format!("{}?success=false&error={}", panel_base_url, url_encode("Query param scope is required for non error Oauth response"))).into_response()
4349
}
4450
if query.code.is_none() {
45-
return Body::new(format!("{}?success=false&error={}", PANEL_BASE_URL, url_encode("Query param code is required for non error Oauth response")))
51+
return Body::new(format!("{}?success=false&error={}", panel_base_url, url_encode("Query param code is required for non error Oauth response"))).into_response()
4652
}
47-
Body::new(format!("{PANEL_BASE_URL}{}", match state.oauth_service.return_oauth(service, query.state, query.scope.unwrap(), query.code.unwrap()) {
48-
Ok(()) => format!("{}?success=true", PANEL_BASE_URL),
49-
Err(OauthReturnError::NotRequested) => format!("{}?success=false&error={}", PANEL_BASE_URL, url_encode("This oauth was never requested from the bot")),
53+
Body::new(format!("{}{}", panel_base_url, match state.oauth_service.return_oauth(service, query.state, query.scope.unwrap(), query.code.unwrap()) {
54+
Ok(()) => format!("{}?success=true", panel_base_url),
55+
Err(OauthReturnError::NotRequested) => format!("{}?success=false&error={}", panel_base_url, url_encode("This oauth was never requested from the bot")),
5056
Err(OauthReturnError::ReturnChannelClosed) => {
5157
eprintln!("Failed to process auth code, return channel was closed");
52-
format!("{}?success=false&error={}", PANEL_BASE_URL, url_encode("Could not process auth code. Internal server error"))
58+
format!("{}?success=false&error={}", panel_base_url, url_encode("Could not process auth code. Internal server error"))
5359
}
54-
}))
60+
})).into_response()
5561
}
5662

5763
pub async fn list_oauth(_: Moderator, State(state): State<AxumState>) -> impl IntoResponse {

0 commit comments

Comments
 (0)