implement admin api
This commit is contained in:
parent
7e71464ddb
commit
35ac15dd84
|
@ -14,6 +14,7 @@ Available options:
|
||||||
- `clientSecret` (string): Twitch client secret.
|
- `clientSecret` (string): Twitch client secret.
|
||||||
- `admins` (array of strings): List of usernames who are allowed to use administration commands.
|
- `admins` (array of strings): List of usernames who are allowed to use administration commands.
|
||||||
- `optOut` (object of strings: booleans): List of user ids who opted out from being logged.
|
- `optOut` (object of strings: booleans): List of user ids who opted out from being logged.
|
||||||
|
- `adminAPIKey` (string): API key for admin requests
|
||||||
|
|
||||||
Example config:
|
Example config:
|
||||||
```json
|
```json
|
||||||
|
@ -27,6 +28,7 @@ Example config:
|
||||||
"clientID": "id",
|
"clientID": "id",
|
||||||
"clientSecret": "secret",
|
"clientSecret": "secret",
|
||||||
"admins": [],
|
"admins": [],
|
||||||
"optOut": {}
|
"optOut": {},
|
||||||
|
"adminAPIKey": "verysecurekey"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
55
src/bot.rs
55
src/bot.rs
|
@ -9,7 +9,10 @@ use chrono::Utc;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use prometheus::{register_int_counter_vec, IntCounterVec};
|
use prometheus::{register_int_counter_vec, IntCounterVec};
|
||||||
use std::{borrow::Cow, time::Duration};
|
use std::{borrow::Cow, time::Duration};
|
||||||
use tokio::{sync::mpsc::Sender, time::sleep};
|
use tokio::{
|
||||||
|
sync::mpsc::{Receiver, Sender},
|
||||||
|
time::sleep,
|
||||||
|
};
|
||||||
use tracing::{debug, error, info, trace};
|
use tracing::{debug, error, info, trace};
|
||||||
use twitch_irc::{
|
use twitch_irc::{
|
||||||
login::LoginCredentials,
|
login::LoginCredentials,
|
||||||
|
@ -22,6 +25,12 @@ const CHANENLS_REFETCH_RETRY_INTERVAL_SECONDS: u64 = 5;
|
||||||
|
|
||||||
type TwitchClient<C> = TwitchIRCClient<SecureTCPTransport, C>;
|
type TwitchClient<C> = TwitchIRCClient<SecureTCPTransport, C>;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum BotMessage {
|
||||||
|
JoinChannels(Vec<String>),
|
||||||
|
PartChannels(Vec<String>),
|
||||||
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref MESSAGES_RECEIVED_COUNTERS: IntCounterVec = register_int_counter_vec!(
|
static ref MESSAGES_RECEIVED_COUNTERS: IntCounterVec = register_int_counter_vec!(
|
||||||
"rustlog_messages_received",
|
"rustlog_messages_received",
|
||||||
|
@ -38,11 +47,13 @@ pub async fn run<C: LoginCredentials>(
|
||||||
app: App,
|
app: App,
|
||||||
writer_tx: Sender<Message<'static>>,
|
writer_tx: Sender<Message<'static>>,
|
||||||
shutdown_rx: ShutdownRx,
|
shutdown_rx: ShutdownRx,
|
||||||
|
command_rx: Receiver<BotMessage>,
|
||||||
) {
|
) {
|
||||||
let bot = Bot::new(app, writer_tx);
|
let bot = Bot::new(app, writer_tx);
|
||||||
bot.run(login_credentials, shutdown_rx).await;
|
bot.run(login_credentials, shutdown_rx, command_rx).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
struct Bot {
|
struct Bot {
|
||||||
app: App,
|
app: App,
|
||||||
writer_tx: Sender<Message<'static>>,
|
writer_tx: Sender<Message<'static>>,
|
||||||
|
@ -53,7 +64,12 @@ impl Bot {
|
||||||
Self { app, writer_tx }
|
Self { app, writer_tx }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run<C: LoginCredentials>(self, login_credentials: C, mut shutdown_rx: ShutdownRx) {
|
pub async fn run<C: LoginCredentials>(
|
||||||
|
self,
|
||||||
|
login_credentials: C,
|
||||||
|
mut shutdown_rx: ShutdownRx,
|
||||||
|
mut command_rx: Receiver<BotMessage>,
|
||||||
|
) {
|
||||||
let client_config = ClientConfig::new_simple(login_credentials);
|
let client_config = ClientConfig::new_simple(login_credentials);
|
||||||
let (mut receiver, client) = TwitchIRCClient::<SecureTCPTransport, C>::new(client_config);
|
let (mut receiver, client) = TwitchIRCClient::<SecureTCPTransport, C>::new(client_config);
|
||||||
|
|
||||||
|
@ -83,6 +99,39 @@ impl Bot {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let bot = self.clone();
|
||||||
|
let msg_client = client.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(msg) = command_rx.recv().await {
|
||||||
|
match msg {
|
||||||
|
BotMessage::JoinChannels(channels) => {
|
||||||
|
if let Err(err) = bot
|
||||||
|
.update_channels(
|
||||||
|
&msg_client,
|
||||||
|
&channels.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
|
ChannelAction::Join,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Could not join channels: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BotMessage::PartChannels(channels) => {
|
||||||
|
if let Err(err) = bot
|
||||||
|
.update_channels(
|
||||||
|
&msg_client,
|
||||||
|
&channels.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||||
|
ChannelAction::Part,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
error!("Could not join channels: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
Some(msg) = receiver.recv() => {
|
Some(msg) = receiver.recv() => {
|
||||||
|
|
|
@ -25,6 +25,8 @@ pub struct Config {
|
||||||
pub admins: Vec<String>,
|
pub admins: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub opt_out: DashMap<String, bool>,
|
pub opt_out: DashMap<String, bool>,
|
||||||
|
#[serde(rename = "adminAPIKey")]
|
||||||
|
pub admin_api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
|
|
@ -26,7 +26,7 @@ use std::{
|
||||||
};
|
};
|
||||||
use tokio::{
|
use tokio::{
|
||||||
signal::unix::{signal, SignalKind},
|
signal::unix::{signal, SignalKind},
|
||||||
sync::watch,
|
sync::{mpsc, watch},
|
||||||
time::timeout,
|
time::timeout,
|
||||||
};
|
};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
@ -101,14 +101,17 @@ async fn run(config: Config, db: clickhouse::Client) -> anyhow::Result<()> {
|
||||||
optout_codes: Arc::default(),
|
optout_codes: Arc::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (bot_tx, bot_rx) = mpsc::channel(1);
|
||||||
|
|
||||||
let login_credentials = StaticLoginCredentials::anonymous();
|
let login_credentials = StaticLoginCredentials::anonymous();
|
||||||
let mut bot_handle = tokio::spawn(bot::run(
|
let mut bot_handle = tokio::spawn(bot::run(
|
||||||
login_credentials,
|
login_credentials,
|
||||||
app.clone(),
|
app.clone(),
|
||||||
writer_tx,
|
writer_tx,
|
||||||
shutdown_rx.clone(),
|
shutdown_rx.clone(),
|
||||||
|
bot_rx,
|
||||||
));
|
));
|
||||||
let mut web_handle = tokio::spawn(web::run(app, shutdown_rx.clone()));
|
let mut web_handle = tokio::spawn(web::run(app, shutdown_rx.clone(), bot_tx));
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = shutdown_rx.changed() => {
|
_ = shutdown_rx.changed() => {
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
use crate::{app::App, bot::BotMessage, error::Error};
|
||||||
|
use axum::{
|
||||||
|
extract::State,
|
||||||
|
http::Request,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Extension, Json,
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
pub async fn admin_auth<B>(
|
||||||
|
app: State<App>,
|
||||||
|
request: Request<B>,
|
||||||
|
next: Next<B>,
|
||||||
|
) -> Result<Response, impl IntoResponse> {
|
||||||
|
if let Some(admin_key) = &app.config.admin_api_key {
|
||||||
|
if request
|
||||||
|
.headers()
|
||||||
|
.get("X-Api-Key")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
== Some(admin_key)
|
||||||
|
{
|
||||||
|
let response = next.run(request).await;
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err((StatusCode::FORBIDDEN, "No, I don't think so"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, JsonSchema)]
|
||||||
|
pub struct ChannelsRequest {
|
||||||
|
/// List of channel ids
|
||||||
|
pub channels: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_channels(
|
||||||
|
Extension(bot_tx): Extension<Sender<BotMessage>>,
|
||||||
|
app: State<App>,
|
||||||
|
Json(ChannelsRequest { channels }): Json<ChannelsRequest>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let users = app.get_users(channels, vec![]).await?;
|
||||||
|
let names = users.into_values().collect();
|
||||||
|
|
||||||
|
bot_tx.send(BotMessage::JoinChannels(names)).await.unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_channels(
|
||||||
|
Extension(bot_tx): Extension<Sender<BotMessage>>,
|
||||||
|
app: State<App>,
|
||||||
|
Json(ChannelsRequest { channels }): Json<ChannelsRequest>,
|
||||||
|
) -> Result<(), Error> {
|
||||||
|
let users = app.get_users(channels, vec![]).await?;
|
||||||
|
let names = users.into_values().collect();
|
||||||
|
|
||||||
|
bot_tx.send(BotMessage::PartChannels(names)).await.unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -1,29 +1,31 @@
|
||||||
|
mod admin;
|
||||||
mod frontend;
|
mod frontend;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod responders;
|
mod responders;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
mod trace_layer;
|
mod trace_layer;
|
||||||
|
|
||||||
use crate::{app::App, ShutdownRx};
|
use crate::{app::App, bot::BotMessage, web::admin::admin_auth, ShutdownRx};
|
||||||
use aide::{
|
use aide::{
|
||||||
axum::{
|
axum::{
|
||||||
routing::{get, get_with, post},
|
routing::{get, get_with, post, post_with},
|
||||||
ApiRouter, IntoApiResponse,
|
ApiRouter, IntoApiResponse,
|
||||||
},
|
},
|
||||||
openapi::OpenApi,
|
openapi::OpenApi,
|
||||||
redoc::Redoc,
|
redoc::Redoc,
|
||||||
};
|
};
|
||||||
use axum::{response::IntoResponse, Extension, Json, ServiceExt};
|
use axum::{middleware, response::IntoResponse, Extension, Json, ServiceExt};
|
||||||
use prometheus::TextEncoder;
|
use prometheus::TextEncoder;
|
||||||
use std::{
|
use std::{
|
||||||
net::{AddrParseError, SocketAddr},
|
net::{AddrParseError, SocketAddr},
|
||||||
str::FromStr,
|
str::FromStr,
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
use tower_http::{cors::CorsLayer, normalize_path::NormalizePath, trace::TraceLayer};
|
use tower_http::{cors::CorsLayer, normalize_path::NormalizePath, trace::TraceLayer};
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
pub async fn run(app: App, mut shutdown_rx: ShutdownRx) {
|
pub async fn run(app: App, mut shutdown_rx: ShutdownRx, bot_tx: Sender<BotMessage>) {
|
||||||
aide::gen::on_error(|error| {
|
aide::gen::on_error(|error| {
|
||||||
panic!("Could not generate docs: {error}");
|
panic!("Could not generate docs: {error}");
|
||||||
});
|
});
|
||||||
|
@ -37,7 +39,21 @@ pub async fn run(app: App, mut shutdown_rx: ShutdownRx) {
|
||||||
|
|
||||||
let mut api = OpenApi::default();
|
let mut api = OpenApi::default();
|
||||||
|
|
||||||
|
let admin_routes = ApiRouter::new()
|
||||||
|
.api_route(
|
||||||
|
"/channels",
|
||||||
|
post_with(admin::add_channels, |op| {
|
||||||
|
op.tag("Admin").description("Join the specified channels")
|
||||||
|
})
|
||||||
|
.delete_with(admin::remove_channels, |op| {
|
||||||
|
op.tag("Admin").description("Leave the specified channels")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.route_layer(middleware::from_fn_with_state(app.clone(), admin_auth))
|
||||||
|
.layer(Extension(bot_tx));
|
||||||
|
|
||||||
let app = ApiRouter::new()
|
let app = ApiRouter::new()
|
||||||
|
.nest("/admin", admin_routes)
|
||||||
.api_route(
|
.api_route(
|
||||||
"/channels",
|
"/channels",
|
||||||
get_with(handlers::get_channels, |op| {
|
get_with(handlers::get_channels, |op| {
|
||||||
|
|
Loading…
Reference in New Issue