implement admin api
This commit is contained in:
parent
7e71464ddb
commit
35ac15dd84
|
@ -14,6 +14,7 @@ Available options:
|
|||
- `clientSecret` (string): Twitch client secret.
|
||||
- `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.
|
||||
- `adminAPIKey` (string): API key for admin requests
|
||||
|
||||
Example config:
|
||||
```json
|
||||
|
@ -27,6 +28,7 @@ Example config:
|
|||
"clientID": "id",
|
||||
"clientSecret": "secret",
|
||||
"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 prometheus::{register_int_counter_vec, IntCounterVec};
|
||||
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 twitch_irc::{
|
||||
login::LoginCredentials,
|
||||
|
@ -22,6 +25,12 @@ const CHANENLS_REFETCH_RETRY_INTERVAL_SECONDS: u64 = 5;
|
|||
|
||||
type TwitchClient<C> = TwitchIRCClient<SecureTCPTransport, C>;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BotMessage {
|
||||
JoinChannels(Vec<String>),
|
||||
PartChannels(Vec<String>),
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref MESSAGES_RECEIVED_COUNTERS: IntCounterVec = register_int_counter_vec!(
|
||||
"rustlog_messages_received",
|
||||
|
@ -38,11 +47,13 @@ pub async fn run<C: LoginCredentials>(
|
|||
app: App,
|
||||
writer_tx: Sender<Message<'static>>,
|
||||
shutdown_rx: ShutdownRx,
|
||||
command_rx: Receiver<BotMessage>,
|
||||
) {
|
||||
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 {
|
||||
app: App,
|
||||
writer_tx: Sender<Message<'static>>,
|
||||
|
@ -53,7 +64,12 @@ impl Bot {
|
|||
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 (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 {
|
||||
tokio::select! {
|
||||
Some(msg) = receiver.recv() => {
|
||||
|
|
|
@ -25,6 +25,8 @@ pub struct Config {
|
|||
pub admins: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub opt_out: DashMap<String, bool>,
|
||||
#[serde(rename = "adminAPIKey")]
|
||||
pub admin_api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
|
|
@ -26,7 +26,7 @@ use std::{
|
|||
};
|
||||
use tokio::{
|
||||
signal::unix::{signal, SignalKind},
|
||||
sync::watch,
|
||||
sync::{mpsc, watch},
|
||||
time::timeout,
|
||||
};
|
||||
use tracing::{debug, info};
|
||||
|
@ -101,14 +101,17 @@ async fn run(config: Config, db: clickhouse::Client) -> anyhow::Result<()> {
|
|||
optout_codes: Arc::default(),
|
||||
};
|
||||
|
||||
let (bot_tx, bot_rx) = mpsc::channel(1);
|
||||
|
||||
let login_credentials = StaticLoginCredentials::anonymous();
|
||||
let mut bot_handle = tokio::spawn(bot::run(
|
||||
login_credentials,
|
||||
app.clone(),
|
||||
writer_tx,
|
||||
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! {
|
||||
_ = 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 handlers;
|
||||
mod responders;
|
||||
pub mod schema;
|
||||
mod trace_layer;
|
||||
|
||||
use crate::{app::App, ShutdownRx};
|
||||
use crate::{app::App, bot::BotMessage, web::admin::admin_auth, ShutdownRx};
|
||||
use aide::{
|
||||
axum::{
|
||||
routing::{get, get_with, post},
|
||||
routing::{get, get_with, post, post_with},
|
||||
ApiRouter, IntoApiResponse,
|
||||
},
|
||||
openapi::OpenApi,
|
||||
redoc::Redoc,
|
||||
};
|
||||
use axum::{response::IntoResponse, Extension, Json, ServiceExt};
|
||||
use axum::{middleware, response::IntoResponse, Extension, Json, ServiceExt};
|
||||
use prometheus::TextEncoder;
|
||||
use std::{
|
||||
net::{AddrParseError, SocketAddr},
|
||||
str::FromStr,
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tower_http::{cors::CorsLayer, normalize_path::NormalizePath, trace::TraceLayer};
|
||||
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| {
|
||||
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 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()
|
||||
.nest("/admin", admin_routes)
|
||||
.api_route(
|
||||
"/channels",
|
||||
get_with(handlers::get_channels, |op| {
|
||||
|
|
Loading…
Reference in New Issue