use crate::adapter::{LLOneBotEventMode, OneBotAdapter};
use nagisa_core::adapter::ActionInvoker; use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::routing::post;
use axum::Router;
use hmac::{Hmac, KeyInit, Mac};
use nagisa_core::adapter::OneBotActions; use nagisa_core::ShutdownToken;
use nagisa_types::error::{Error, Result, TransportError};
use nagisa_types::event::Event;
use sha1::Sha1;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
#[derive(Clone)]
struct HttpState {
adapter: Arc<OneBotAdapter>,
sink: mpsc::Sender<Event>,
secret: Option<String>,
}
#[allow(clippy::too_many_arguments)]
pub async fn run_http_post(
adapter: Arc<OneBotAdapter>,
api_url: String,
post_bind: SocketAddr,
post_path: String,
secret: Option<String>,
sink: mpsc::Sender<Event>,
shutdown: ShutdownToken,
) -> Result<()> {
adapter.install_http_api(reqwest::Client::new(), api_url);
let state = HttpState { adapter, sink, secret };
let app = Router::new()
.route(&post_path, post(handle_post))
.with_state(state);
let listener = TcpListener::bind(post_bind)
.await
.map_err(|e| Error::Transport(TransportError::Http(e.to_string())))?;
tracing::info!(%post_bind, %post_path, "onebot http-post webhook listening");
let sd = shutdown.clone();
axum::serve(listener, app)
.with_graceful_shutdown(async move { sd.cancelled().await })
.await
.map_err(|e| Error::Transport(TransportError::Http(e.to_string())))?;
Ok(())
}
async fn handle_post(
State(state): State<HttpState>,
headers: HeaderMap,
body: Bytes,
) -> impl IntoResponse {
if let Some(secret) = &state.secret {
let provided = headers
.get("X-Signature")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !verify_signature(secret, &body, provided) {
tracing::warn!("http-post: X-Signature mismatch; rejecting");
return (StatusCode::UNAUTHORIZED, axum::body::Body::empty()).into_response();
}
}
let txt = match std::str::from_utf8(&body) {
Ok(t) => t,
Err(_) => return (StatusCode::BAD_REQUEST, axum::body::Body::empty()).into_response(),
};
let maybe_event = state.adapter.dispatch_and_decode(txt, &state.sink).await;
if let Some(event) = maybe_event {
if let Some(quick_resp) = state.adapter.quick_op_response(&event) {
let json_body = match serde_json::to_vec(&quick_resp) {
Ok(b) => b,
Err(e) => {
tracing::warn!("quick-op: failed to serialize response: {e}");
return (StatusCode::INTERNAL_SERVER_ERROR, axum::body::Body::empty()).into_response();
}
};
return (
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
json_body,
).into_response();
}
}
(StatusCode::NO_CONTENT, axum::body::Body::empty()).into_response()
}
fn verify_signature(secret: &str, body: &[u8], provided: &str) -> bool {
let Some(hex) = provided.strip_prefix("sha1=") else { return false };
let Ok(mut mac) = Hmac::<Sha1>::new_from_slice(secret.as_bytes()) else { return false };
mac.update(body);
let expected = mac.finalize().into_bytes();
let expected_hex: String = expected.iter().map(|b| format!("{b:02x}")).collect();
constant_time_eq(expected_hex.as_bytes(), hex.to_ascii_lowercase().as_bytes())
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
const SSE_MAX_BACKOFF_MS: u64 = 30_000;
const SSE_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
pub async fn run_llonebot_http(
adapter: Arc<OneBotAdapter>,
api_url: String,
events: LLOneBotEventMode,
sink: mpsc::Sender<Event>,
shutdown: ShutdownToken,
) -> Result<()> {
adapter.install_http_api(reqwest::Client::new(), api_url.clone());
match events {
LLOneBotEventMode::Sse => run_llonebot_sse(adapter, api_url, sink, shutdown).await,
LLOneBotEventMode::LongPoll { interval } => {
run_llonebot_long_poll(adapter, interval, sink, shutdown).await
}
}
}
async fn run_llonebot_sse(
adapter: Arc<OneBotAdapter>,
api_url: String,
sink: mpsc::Sender<Event>,
shutdown: ShutdownToken,
) -> Result<()> {
let url = format!("{}/_events", api_url.trim_end_matches('/'));
let client = reqwest::Client::new();
let backoff = nagisa_core::reconnect::Backoff::new(
Duration::from_millis(500),
Duration::from_millis(SSE_MAX_BACKOFF_MS),
);
nagisa_core::reconnect::run(&shutdown, backoff, |_| 0, || async {
match sse_connect_and_pump(&adapter, &client, &url, &sink, &shutdown).await {
Ok(true) => nagisa_core::reconnect::Step::Stop,
Ok(false) => nagisa_core::reconnect::Step::Reconnect,
Err(e) => {
tracing::warn!(error = %e, "llonebot sse /_events disconnected; reconnecting");
nagisa_core::reconnect::Step::Reconnect
}
}
})
.await
}
async fn sse_connect_and_pump(
adapter: &Arc<OneBotAdapter>,
client: &reqwest::Client,
url: &str,
sink: &mpsc::Sender<Event>,
shutdown: &ShutdownToken,
) -> Result<bool> {
let mut req = client.get(url).header(reqwest::header::ACCEPT, "text/event-stream");
if let Some(token) = adapter.access_token() {
req = req.bearer_auth(token);
}
let resp = req
.send()
.await
.map_err(|e| Error::Transport(TransportError::Http(e.to_string())))?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Transport(TransportError::Http(format!(
"llonebot sse GET /_events: HTTP {status}"
))));
}
tracing::info!("llonebot sse /_events connected: {url}");
let mut source = LLOneBotSseSource { resp, parser: nagisa_core::sse::SseParser::new() };
nagisa_core::framesource::pump(&mut source, SSE_IDLE_TIMEOUT, shutdown, |payload| async move {
adapter.dispatch_sse_event(&payload, sink).await
})
.await
}
struct LLOneBotSseSource {
resp: reqwest::Response,
parser: nagisa_core::sse::SseParser,
}
#[async_trait::async_trait]
impl nagisa_core::framesource::FrameSource for LLOneBotSseSource {
async fn next_frame(&mut self) -> nagisa_core::framesource::Frame {
use nagisa_core::framesource::Frame;
match self.resp.chunk().await {
Ok(Some(c)) => Frame::Inbound(self.parser.feed(&c)),
Ok(None) => Frame::Closed,
Err(e) => {
tracing::warn!("llonebot sse /_events error: {e}");
Frame::Closed
}
}
}
}
async fn run_llonebot_long_poll(
adapter: Arc<OneBotAdapter>,
interval: Duration,
sink: mpsc::Sender<Event>,
shutdown: ShutdownToken,
) -> Result<()> {
tracing::info!(?interval, "llonebot get_event long-poll started");
loop {
if shutdown.is_cancelled() {
return Ok(());
}
match adapter.get_event().await {
Ok(events) => {
let vendor = adapter.vendor();
for event in events {
if crate::adapter::dispatch_event(&sink, event, vendor).await {
return Ok(());
}
}
}
Err(e) => {
tracing::warn!(error = %e, "llonebot get_event poll failed; retrying");
}
}
tokio::select! {
_ = shutdown.cancelled() => return Ok(()),
_ = tokio::time::sleep(interval) => {}
}
}
}