//! Scheduled agents ("automations"). The instructions are a Markdown file (only the //! prompt, e.g. `/.codecraft/jobs/.md`); name, schedule, mode, model and //! limits are settings of the automation in the database, edited in the UI. //! //! Older job files carried the settings as frontmatter: //! //! ```markdown //! --- //! name: Nightly dependency check //! schedule: "0 3 * * *" # cron (minute hour day month weekday), server time zone //! agent: build //! permission_mode: auto //! timeout_minutes: 60 //! --- //! Check for outdated dependencies and open a summary … //! ``` //! //! They are adopted once (settings into the database, the frontmatter removed from the //! file). The file is re-read on every run, so edits apply to the next run. Each run is //! a normal chat session owned by the job's owner. use crate::app::App; use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str}; use crate::types::{Part, Session, new_id, now_ms}; use anyhow::{Context, Result, anyhow, bail}; use async_trait::async_trait; use serde::Serialize; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; #[derive(Debug, Clone, Serialize)] pub struct Job { pub id: String, pub owner_id: String, pub path: String, pub enabled: bool, pub last_run: Option, pub last_session_id: Option, pub last_status: Option, pub created_at: i64, /// Webhook enabled (the secret – also the HMAC key – is only shown once when created). pub webhook: bool, /// None: not adopted yet (settings still in the file's frontmatter). #[serde(skip)] pub settings: Option, } /// Settings of an automation (database; the file holds only the prompt). #[derive(Debug, Clone, Serialize, serde::Deserialize, PartialEq)] pub struct JobSettings { pub name: String, /// 5-field cron in the server's time zone. pub schedule: String, #[serde(default = "default_agent")] pub agent: String, #[serde(default)] pub model: Option, #[serde(default = "default_mode")] pub permission_mode: String, #[serde(default = "default_timeout")] pub timeout_minutes: u64, /// Working directory (absolute or relative to the file); default: the project of the file. #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option, /// Chat in which the user works out the prompt with a model. #[serde(default, skip_serializing_if = "Option::is_none")] pub assistant_session: Option, } impl JobSettings { pub fn new(name: &str) -> Self { Self { name: name.trim().to_string(), schedule: "0 3 * * *".into(), agent: default_agent(), model: None, permission_mode: default_mode(), timeout_minutes: default_timeout(), cwd: None, assistant_session: None, } } pub fn validate(&mut self) -> Result<()> { self.name = self.name.trim().chars().take(120).collect(); if self.name.is_empty() { bail!("the automation needs a name"); } self.schedule = self.schedule.trim().to_string(); check_schedule(&self.schedule)?; if !crate::permission::MODES.contains(&self.permission_mode.as_str()) { bail!("invalid permission_mode '{}'", self.permission_mode); } self.model = self.model.take().map(|m| m.trim().to_string()).filter(|m| !m.is_empty()); self.timeout_minutes = self.timeout_minutes.clamp(1, 24 * 60); Ok(()) } } /// Valid 5-field cron expression? pub fn check_schedule(schedule: &str) -> Result<()> { if schedule.split_whitespace().count() != 5 { bail!("invalid schedule '{schedule}': expected 5 fields (minute hour day month weekday)"); } croner::Cron::from_str(schedule).map_err(|e| anyhow!("invalid schedule '{schedule}': {e}"))?; Ok(()) } /// Parsed job file. #[derive(Debug, Clone, Serialize)] pub struct JobSpec { pub name: String, pub schedule: String, pub agent: String, pub model: Option, pub permission_mode: String, pub timeout_minutes: u64, pub cwd: Option, #[serde(skip_serializing_if = "Option::is_none")] pub assistant_session: Option, pub prompt: String, } impl JobSpec { fn from_settings(s: &JobSettings, prompt: String) -> Self { Self { name: s.name.clone(), schedule: s.schedule.clone(), agent: s.agent.clone(), model: s.model.clone(), permission_mode: s.permission_mode.clone(), timeout_minutes: s.timeout_minutes, cwd: s.cwd.clone(), assistant_session: s.assistant_session.clone(), prompt, } } fn settings(&self) -> JobSettings { JobSettings { name: self.name.clone(), schedule: self.schedule.clone(), agent: self.agent.clone(), model: self.model.clone(), permission_mode: self.permission_mode.clone(), timeout_minutes: self.timeout_minutes, cwd: self.cwd.clone(), assistant_session: self.assistant_session.clone(), } } } /// Settings and prompt of a job: settings from the database, the prompt from its file /// (a leftover frontmatter is ignored); not yet adopted jobs from the frontmatter. pub fn spec(job: &Job) -> Result { let path = Path::new(&job.path); let Some(settings) = &job.settings else { return parse(path) }; let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?; let (_, body) = crate::prompts::frontmatter(&text); if body.trim().is_empty() { bail!("{}: the instructions are empty", path.display()); } Ok(JobSpec::from_settings(settings, body.trim().to_string())) } /// Move the frontmatter of an older job file into the database and remove it from the /// file. Files without frontmatter get default settings (named after the file) and are /// switched off until the user sets a schedule. Returns the updated job. pub fn adopt(store: &crate::store::Store, job: Job) -> Result { if job.settings.is_some() { return Ok(job); } let path = Path::new(&job.path); let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?; let (meta, body) = crate::prompts::frontmatter(&text); let settings = if meta.is_empty() { let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("automation").replace(['-', '_'], " "); store.job_set_enabled(&job.id, false)?; JobSettings::new(&stem) } else { let spec = parse(path)?; // Prompt only from now on. std::fs::write(path, format!("{}\n", body.trim()))?; spec.settings() }; store.job_set_settings(&job.id, &settings)?; store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id)) } /// Settings from the frontmatter of an older job file. pub fn parse(path: &Path) -> Result { let text = std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?; let (meta, body) = crate::prompts::frontmatter(&text); // Values may carry a trailing comment (`auto # default`). let unquote = |s: &String| { let v = s.trim(); let v = if v.starts_with('"') || v.starts_with('\'') { v } else { v.split(" #").next().unwrap_or(v).trim() }; v.trim_matches('"').trim_matches('\'').to_string() }; let schedule = meta.get("schedule").map(unquote).ok_or_else(|| anyhow!("{}: frontmatter needs `schedule: \"\"`", path.display()))?; check_schedule(&schedule)?; let permission_mode = meta.get("permission_mode").map(unquote).unwrap_or_else(|| "auto".into()); if !crate::permission::MODES.contains(&permission_mode.as_str()) { bail!("invalid permission_mode '{permission_mode}'"); } if body.trim().is_empty() { bail!("{}: the job has no prompt (text after the frontmatter)", path.display()); } Ok(JobSpec { name: meta.get("name").map(unquote).unwrap_or_else(|| path.file_stem().and_then(|s| s.to_str()).unwrap_or("job").to_string()), schedule, agent: meta.get("agent").map(unquote).unwrap_or_else(|| "build".into()), model: meta.get("model").map(unquote).filter(|m| !m.is_empty()), permission_mode, timeout_minutes: meta.get("timeout_minutes").and_then(|v| unquote(v).parse().ok()).unwrap_or(60).clamp(1, 24 * 60), cwd: meta.get("cwd").map(unquote).filter(|c| !c.is_empty()), assistant_session: None, prompt: body.trim().to_string(), }) } /// Next run after `after` (ms) in the server's local time zone. pub fn next_run(schedule: &str, after_ms: i64) -> Option { let cron = croner::Cron::from_str(schedule).ok()?; let start = chrono::DateTime::from_timestamp_millis(after_ms)?.with_timezone(&chrono::Local); cron.find_next_occurrence(&start, false).ok().map(|t| t.timestamp_millis()) } /// Working directory of a job: `cwd` from the file, else the project containing `.codecraft/jobs/`. fn job_cwd(path: &Path, spec: &JobSpec) -> PathBuf { cwd_of(&path.display().to_string(), spec.cwd.as_deref()) } /// Working directory for a job file and its optional `cwd` setting. pub fn cwd_of(file: &str, cwd: Option<&str>) -> PathBuf { let path = Path::new(file); if let Some(c) = cwd { let p = PathBuf::from(c); return if p.is_absolute() { p } else { path.parent().unwrap_or(Path::new("/")).join(p) }; } let mut dir = path.parent(); while let Some(d) = dir { if d.file_name().and_then(|n| n.to_str()) == Some(".codecraft") { return d.parent().unwrap_or(d).to_path_buf(); } dir = d.parent(); } path.parent().unwrap_or(Path::new("/")).to_path_buf() } /// Job with its parsed file and next run, for the UI and the tool. pub fn describe(job: &Job) -> Value { let mut v = json!(job); match spec(job) { Ok(spec) => { v["spec"] = json!(spec); let base = job.last_run.unwrap_or(job.created_at).max(now_ms() - 60_000); v["next_run"] = json!(if job.enabled { next_run(&spec.schedule, base) } else { None }); } Err(e) => { v["error"] = json!(format!("{e:#}")); // The settings stay editable when the file is missing or empty. if let Some(st) = &job.settings { v["spec"] = json!(JobSpec::from_settings(st, String::new())); } } } v } /// Start a run now. Returns the new session id. pub async fn run(app: &Arc, job: &Job, trigger: &str) -> Result { run_with(app, job, trigger, None).await } /// Start a run; `payload` (webhook body) is appended to the instructions. pub async fn run_with(app: &Arc, job: &Job, trigger: &str, payload: Option) -> Result { let path = PathBuf::from(&job.path); let spec = match spec(job) { Ok(s) => s, Err(e) => { app.store.job_finished(&job.id, None, &format!("error: {e:#}"))?; return Err(e); } }; let owner = app.store.user(&job.owner_id)?.ok_or_else(|| anyhow!("the job's owner no longer exists"))?; if owner.disabled { bail!("the job's owner is disabled"); } let cwd = job_cwd(&path, &spec); let roots = app.user_roots(Some(&owner.id)); if !roots.iter().any(|r| cwd.starts_with(r)) { bail!("{} is outside the owner's folders", cwd.display()); } let cfg = app.config(); let model = spec.model.clone().or_else(|| Some(cfg.server.default_model.clone()).filter(|m| !m.is_empty())).or_else(|| app.registry().first_model()).ok_or_else(|| anyhow!("no model configured"))?; let now = now_ms(); let stamp = chrono::Local::now().format("%Y-%m-%d %H:%M"); let session = Session { id: new_id(), title: format!("{} – {stamp}", spec.name), cwd: cwd.display().to_string(), model, agent: spec.agent.clone(), permission_mode: spec.permission_mode.clone(), parent_id: None, owner_id: Some(owner.id.clone()), created_at: now, updated_at: now, todos: Value::Null, last_usage: None, clean_room: None, phase: None, worktree: None, pinned: false, archived: false, folder: None, instructions: String::new(), // New chats keep their approvals and questions out of Telegram (⋯ menu turns it on). telegram_quiet: true, }; let session = app.save_session(session)?; app.store.job_started(&job.id, &session.id, now)?; let text = format!( "\nThis is an unattended, scheduled run – nobody is watching live. Work autonomously, avoid questions unless you are truly blocked, and end with a short report of what you did and found.\n\n\n{}", spec.name, job.path, spec.prompt ); let text = match payload { Some(p) => format!("{text}\n\n\n{p}\n\nThe automation was triggered by this webhook call – use the payload as input."), None => text, }; crate::runner::submit(app.clone(), session.id.clone(), vec![Part::Text { text }], "user")?; // Watch for the end (or the timeout). let app2 = app.clone(); let job_id = job.id.clone(); let sid = session.id.clone(); let timeout = Duration::from_secs(spec.timeout_minutes * 60); tokio::spawn(async move { let started = std::time::Instant::now(); tokio::time::sleep(Duration::from_secs(2)).await; while app2.is_busy(&sid) { if started.elapsed() > timeout { crate::runner::abort(&app2, &sid); let _ = app2.store.job_finished(&job_id, Some(&sid), "timeout"); return; } tokio::time::sleep(Duration::from_secs(5)).await; } let _ = app2.store.job_finished(&job_id, Some(&sid), "done"); }); Ok(session.id) } /// Background scheduler: checks every 30 s which jobs are due. pub fn spawn_scheduler(app: Arc) { tokio::spawn(async move { // Older job files: settings from the frontmatter into the database. for job in app.store.jobs(None).unwrap_or_default().into_iter().filter(|j| j.settings.is_none()) { let path = job.path.clone(); match adopt(&app.store, job) { Ok(_) => tracing::info!("automation {path}: settings moved from the file into the database"), Err(e) => tracing::warn!("automation {path}: {e:#}"), } } loop { tokio::time::sleep(Duration::from_secs(30)).await; let Ok(jobs) = app.store.jobs(None) else { continue }; let now = now_ms(); for job in jobs.into_iter().filter(|j| j.enabled) { let Ok(spec) = spec(&job) else { continue }; // Due if a scheduled time passed since the last run (or since creation). let base = job.last_run.unwrap_or(job.created_at); let Some(next) = next_run(&spec.schedule, base) else { continue }; if next > now { continue; } // Missed runs while the server was down: run once, not for every slot. if job.last_status.as_deref() == Some("running") && job.last_session_id.as_deref().is_some_and(|s| app.is_busy(s)) { continue; } if let Err(e) = run(&app, &job, "schedule").await { tracing::warn!("job {} ({}): {e:#}", job.id, job.path); let _ = app.store.job_finished(&job.id, None, &format!("error: {e:#}")); // Avoid retrying every 30 s. let _ = app.store.job_touch(&job.id, now); } } } }); } // --- tool ------------------------------------------------------------------------- /// Tool: create, list, run and delete automations. pub struct Schedule; fn default_agent() -> String { "build".into() } fn default_mode() -> String { "auto".into() } fn default_timeout() -> u64 { 60 } /// Write the instructions file, register it and store the settings. pub fn create(store: &crate::store::Store, owner: &str, path: &Path, mut settings: JobSettings, prompt: &str, enabled: bool) -> Result { settings.validate()?; if path.exists() { bail!("{} already exists", path.display()); } if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } std::fs::write(path, format!("{}\n", prompt.trim()))?; let job = store.job_register(owner, &path.display().to_string())?; store.job_set_settings(&job.id, &settings)?; store.job_set_enabled(&job.id, enabled)?; store.job(&job.id)?.ok_or_else(|| anyhow!("automation {} vanished", job.id)) } /// Free file name for a new job in `/.codecraft/jobs/`. pub fn new_job_path(project: &Path, name: &str) -> PathBuf { let dir = project.join(".codecraft/jobs"); let base = slug(name); (1..100).map(|i| dir.join(if i == 1 { format!("{base}.md") } else { format!("{base}-{i}.md") })).find(|p| !p.exists()).unwrap_or_else(|| dir.join(format!("{base}-{}.md", new_id()))) } fn slug(s: &str) -> String { let lower = s.to_lowercase().replace('ä', "ae").replace('ö', "oe").replace('ü', "ue").replace('ß', "ss"); let out: String = lower.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect(); out.split('-').filter(|p| !p.is_empty()).collect::>().join("-").chars().take(50).collect() } #[async_trait] impl Tool for Schedule { fn name(&self) -> &str { "schedule" } fn description(&self) -> String { "Manage automations: agents that run on a cron schedule. The instructions are a Markdown file \ (.codecraft/jobs/.md in the working directory, only the prompt); name, schedule, mode, model and \ limits are settings of the automation. Actions: \ `create` (name, schedule, prompt, optional agent/model/permission_mode/timeout_minutes) writes the file and registers it; \ `register` (path) registers an existing Markdown file (switched off until a schedule is set in the UI); `list`; `run` (id) starts a run now; \ `enable`/`disable` (id); `delete` (id, delete_file). Schedules are 5-field cron expressions \ in the server's time zone, e.g. \"0 7 * * 1-5\" = weekdays 07:00, \"*/30 * * * *\" = every 30 minutes. \ The prompt must be self-contained: the run starts a fresh session without this conversation." .into() } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "action": {"type": "string", "enum": ["create", "register", "list", "run", "enable", "disable", "delete"]}, "name": {"type": "string"}, "schedule": {"type": "string", "description": "cron, e.g. \"0 3 * * *\""}, "prompt": {"type": "string", "description": "Complete instructions for the scheduled agent"}, "agent": {"type": "string", "description": "Mode, default build"}, "model": {"type": "string"}, "permission_mode": {"type": "string", "enum": crate::permission::MODES}, "timeout_minutes": {"type": "integer"}, "path": {"type": "string"}, "id": {"type": "string"}, "delete_file": {"type": "boolean"} }, "required": ["action"] }) } fn read_only(&self) -> bool { false } fn title(&self, input: &Value) -> String { format!("Automation: {} {}", input["action"].as_str().unwrap_or(""), input["name"].as_str().or(input["id"].as_str()).unwrap_or("")) } async fn run(&self, input: Value, ctx: &ToolCtx) -> Result { let owner = ctx.app.session(&ctx.session_id).ok().and_then(|s| s.owner_id).ok_or_else(|| anyhow!("session has no owner"))?; let own_job = |id: &str| -> Result { ctx.app.store.job(id)?.filter(|j| j.owner_id == owner).ok_or_else(|| anyhow!("no automation with id {id}")) }; match arg_str(&input, "action")? { "create" => { let name = arg_str(&input, "name")?; let schedule = arg_str(&input, "schedule")?; let prompt = arg_str(&input, "prompt")?; let rel = format!(".codecraft/jobs/{}.md", slug(name)); let path = ctx.resolve(&rel)?; let settings = JobSettings { schedule: schedule.into(), agent: input["agent"].as_str().unwrap_or("build").into(), model: input["model"].as_str().map(String::from), permission_mode: input["permission_mode"].as_str().unwrap_or("auto").into(), timeout_minutes: input["timeout_minutes"].as_u64().unwrap_or(60), ..JobSettings::new(name) }; let job = create(&ctx.app.store, &owner, &path, settings, prompt, true)?; let d = describe(&job); Ok(ToolOutput::text(format!( "Automation '{name}' created: {} (id {}). Schedule {schedule}, next run {}. The user can manage it on the Automations page.", rel, job.id, fmt_time(d["next_run"].as_i64()) )) .with_meta(d)) } "register" => { let path = ctx.resolve(arg_str(&input, "path")?)?; if !path.is_file() { bail!("{} is not a file", path.display()); } let job = adopt(&ctx.app.store, ctx.app.store.job_register(&owner, &path.display().to_string())?)?; Ok(ToolOutput::text(format!("Registered {} as automation {}.", path.display(), job.id)).with_meta(describe(&job))) } "list" => { let jobs = ctx.app.store.jobs(Some(&owner))?; if jobs.is_empty() { return Ok(ToolOutput::text("No automations.")); } let mut out = String::new(); for j in &jobs { let d = describe(j); out.push_str(&format!( "- {} [{}] {} – schedule {} – next {} – last {} ({})\n", j.id, if j.enabled { "on" } else { "off" }, d["spec"]["name"].as_str().unwrap_or("?"), d["spec"]["schedule"].as_str().unwrap_or("?"), fmt_time(d["next_run"].as_i64()), fmt_time(j.last_run), j.last_status.as_deref().unwrap_or("-"), )); } Ok(ToolOutput::text(out)) } "run" => { let job = own_job(arg_str(&input, "id")?)?; let sid = run(&ctx.app, &job, "manual").await?; Ok(ToolOutput::text(format!("Started run in session {sid}."))) } a @ ("enable" | "disable") => { let job = own_job(arg_str(&input, "id")?)?; ctx.app.store.job_set_enabled(&job.id, a == "enable")?; Ok(ToolOutput::text(format!("Automation {} {a}d.", job.id))) } "delete" => { let job = own_job(arg_str(&input, "id")?)?; ctx.app.store.job_delete(&job.id)?; if input["delete_file"].as_bool().unwrap_or(false) { let _ = std::fs::remove_file(&job.path); } Ok(ToolOutput::text(format!("Automation {} deleted.", job.id))) } other => bail!("unknown action '{other}'"), } } } fn fmt_time(ms: Option) -> String { ms.and_then(chrono::DateTime::from_timestamp_millis).map(|t| t.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M %Z").to_string()).unwrap_or_else(|| "-".into()) } #[cfg(test)] mod tests { use super::*; #[test] fn next_run_works() { let base = chrono::DateTime::parse_from_rfc3339("2026-11-08T10:15:00+00:00").unwrap().timestamp_millis(); let n = next_run("*/30 * * * *", base).unwrap(); assert_eq!(n - base, 15 * 60 * 1000); assert!(next_run("not a cron", base).is_none()); } #[test] fn parses_job_file() { let dir = std::env::temp_dir().join(format!("cc-job-{}", std::process::id())); std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap(); let p = dir.join(".codecraft/jobs/x.md"); std::fs::write(&p, "---\nname: \"Nightly\"\nschedule: \"0 3 * * *\"\n---\nDo things.\n").unwrap(); let s = parse(&p).unwrap(); assert_eq!((s.name.as_str(), s.schedule.as_str(), s.agent.as_str(), s.permission_mode.as_str()), ("Nightly", "0 3 * * *", "build", "auto")); assert_eq!(job_cwd(&p, &s), dir); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn adopts_frontmatter_and_creates_prompt_only_files() { let dir = std::env::temp_dir().join(format!("cc-job-adopt-{}", std::process::id())); std::fs::create_dir_all(dir.join(".codecraft/jobs")).unwrap(); let store = crate::store::Store::open(&dir.join("t.db")).unwrap(); // Older file: settings move into the database, the file keeps only the prompt. let p = dir.join(".codecraft/jobs/old.md"); std::fs::write(&p, "---\nname: \"Old\"\nschedule: \"0 4 * * *\"\npermission_mode: bypass # comment\n---\nDo old things.\n").unwrap(); let job = adopt(&store, store.job_register("u", &p.display().to_string()).unwrap()).unwrap(); let st = job.settings.clone().unwrap(); assert_eq!((st.name.as_str(), st.schedule.as_str(), st.permission_mode.as_str()), ("Old", "0 4 * * *", "bypass")); assert_eq!(std::fs::read_to_string(&p).unwrap(), "Do old things.\n"); assert_eq!(spec(&job).unwrap().prompt, "Do old things."); assert!(job.enabled); // Plain Markdown: default settings, switched off. let q = dir.join(".codecraft/jobs/weekly-report.md"); std::fs::write(&q, "Write a report.\n").unwrap(); let job = adopt(&store, store.job_register("u", &q.display().to_string()).unwrap()).unwrap(); assert_eq!(job.settings.as_ref().unwrap().name, "weekly report"); assert!(!job.enabled); // New automation. let r = dir.join(".codecraft/jobs/new.md"); assert!(create(&store, "u", &r, JobSettings { schedule: "nope".into(), ..JobSettings::new("N") }, "x", false).is_err()); let job = create(&store, "u", &r, JobSettings::new("New"), "# New\n\nGoal", false).unwrap(); assert_eq!(spec(&job).unwrap().prompt, "# New\n\nGoal"); assert!(check_schedule("0 3 * *").is_err() && check_schedule("*/15 8-18 * * 1-5").is_ok()); std::fs::remove_dir_all(&dir).unwrap(); } } // --- incoming webhooks ------------------------------------------------------------- fn constant_eq(a: &[u8], b: &[u8]) -> bool { a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } fn hmac_hex(secret: &str, body: &[u8]) -> String { use hmac::{KeyInit, Mac}; let mut mac = as KeyInit>::new_from_slice(secret.as_bytes()).expect("hmac key"); mac.update(body); mac.finalize().into_bytes().iter().map(|b| format!("{b:02x}")).collect() } /// Check a webhook call: shared secret (header/query) or Gitea/GitHub HMAC signature. pub fn verify_webhook(secret: &str, headers: &axum::http::HeaderMap, query_secret: Option<&str>, body: &[u8]) -> bool { let h = |k: &str| headers.get(k).and_then(|v| v.to_str().ok()).map(|v| v.trim().to_string()); if let Some(given) = h("x-codecraft-secret").or(query_secret.map(String::from)) { return constant_eq(given.as_bytes(), secret.as_bytes()); } let expected = hmac_hex(secret, body); if let Some(sig) = h("x-gitea-signature").or(h("x-gogs-signature")) { return constant_eq(sig.to_lowercase().as_bytes(), expected.as_bytes()); } if let Some(sig) = h("x-hub-signature-256") { return constant_eq(sig.trim_start_matches("sha256=").to_lowercase().as_bytes(), expected.as_bytes()); } false } /// The payload as text for the prompt (pretty JSON, event name, size-limited). pub fn payload_text(headers: &axum::http::HeaderMap, body: &[u8]) -> String { let event = ["x-gitea-event", "x-github-event", "x-gitlab-event", "x-event-key"] .iter() .find_map(|k| headers.get(*k).and_then(|v| v.to_str().ok())) .map(|e| format!("event: {e}\n")) .unwrap_or_default(); let text = match serde_json::from_slice::(body) { Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_default(), Err(_) => String::from_utf8_lossy(body).into_owned(), }; let mut text: String = text.chars().take(20_000).collect(); if body.len() > 20_000 { text.push_str("\n… (payload truncated)"); } format!("{event}{text}") } #[cfg(test)] mod webhook_tests { #[test] fn signatures() { let mut h = axum::http::HeaderMap::new(); let sig = super::hmac_hex("s3cret", b"{\"a\":1}"); h.insert("x-hub-signature-256", format!("sha256={sig}").parse().unwrap()); assert!(super::verify_webhook("s3cret", &h, None, b"{\"a\":1}")); assert!(!super::verify_webhook("other", &h, None, b"{\"a\":1}")); let mut h2 = axum::http::HeaderMap::new(); h2.insert("x-gitea-signature", sig.parse().unwrap()); assert!(super::verify_webhook("s3cret", &h2, None, b"{\"a\":1}")); assert!(super::verify_webhook("abc", &axum::http::HeaderMap::new(), Some("abc"), b"")); assert!(!super::verify_webhook("abc", &axum::http::HeaderMap::new(), None, b"")); } }