add http API, channel-driven life loop, predefined diary timer

- Extract http.rs: unified HTTP server with /api/timers and gitea webhook
- Life loop: select! on interval tick + mpsc channel for force-fire
- Predefined diary timer (cron 22:55 daily), auto-registered on startup
- BufferOutput for system timers (chat_id=0), no TG message
- state: ensure_timer(), get_timer()
- context.md: add blog and Hugo docs for AI
This commit is contained in:
Fam Zheng
2026-04-10 22:58:39 +01:00
parent 9d2d2af33f
commit c2be8e6930
6 changed files with 311 additions and 116 deletions

98
src/http.rs Normal file
View File

@@ -0,0 +1,98 @@
use std::sync::Arc;
use axum::extract::{Path, State as AxumState};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::Json;
use tokio::sync::mpsc;
use tracing::{error, info};
use crate::config::Config;
use crate::life::LifeEvent;
use crate::state::AppState;
#[derive(Clone)]
pub struct HttpState {
pub app_state: Arc<AppState>,
pub life_tx: mpsc::Sender<LifeEvent>,
}
pub async fn start_http_server(
config: &Config,
app_state: Arc<AppState>,
life_tx: mpsc::Sender<LifeEvent>,
) {
let port = config
.gitea
.as_ref()
.map(|g| g.webhook_port)
.unwrap_or(9880);
let state = Arc::new(HttpState {
app_state,
life_tx,
});
let mut app = axum::Router::new()
.route("/api/timers", get(list_timers))
.route("/api/timers/{id}/fire", post(fire_timer))
.with_state(state);
// merge gitea webhook router if configured
if let Some(gitea_config) = &config.gitea {
let bot_user = std::env::var("GITEA_ADMIN_USER").unwrap_or_else(|_| "noc".into());
app = app.merge(crate::gitea::webhook_router(gitea_config, bot_user));
}
let addr = format!("0.0.0.0:{port}");
info!("http server listening on {addr}");
let listener = tokio::net::TcpListener::bind(&addr)
.await
.unwrap_or_else(|e| panic!("bind {addr}: {e}"));
if let Err(e) = axum::serve(listener, app).await {
error!("http server error: {e}");
}
}
async fn list_timers(AxumState(state): AxumState<Arc<HttpState>>) -> impl IntoResponse {
let timers = state.app_state.list_timers(None).await;
let items: Vec<serde_json::Value> = timers
.iter()
.map(|(id, chat_id, label, schedule, next_fire, enabled)| {
serde_json::json!({
"id": id,
"chat_id": chat_id,
"label": label,
"schedule": schedule,
"next_fire": next_fire,
"enabled": enabled,
})
})
.collect();
Json(serde_json::json!(items))
}
async fn fire_timer(
AxumState(state): AxumState<Arc<HttpState>>,
Path(id): Path<i64>,
) -> impl IntoResponse {
match state.life_tx.send(LifeEvent::FireTimer(id)).await {
Ok(_) => {
info!(timer_id = id, "timer fire requested via API");
(
StatusCode::OK,
Json(serde_json::json!({"status": "fired", "timer_id": id})),
)
}
Err(e) => {
error!(timer_id = id, "failed to send fire event: {e}");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "life loop not responding"})),
)
}
}
}