synops-notify: send varsel via epost, WebSocket-push, eller begge (oppgave 28.4)
Nytt CLI-verktøy som sender varsler til brukere via to kanaler: - Email: Slår opp epost fra auth_identities, kaller synops-mail - WebSocket: Oppretter notification-node i PG med node_access for mottaker, PG NOTIFY-trigger leverer via portvokteren til brukerens WS-tilkobling Støtter --channel email|ws|both (default: ws) og --payload-json for jobbkø-dispatch. Validerer at mottaker er person/agent. JSON-output til stdout.
This commit is contained in:
parent
e0afec848b
commit
bdb4a697f5
4 changed files with 294 additions and 2 deletions
3
tasks.md
3
tasks.md
|
|
@ -378,8 +378,7 @@ modell som brukes til hva.
|
|||
|
||||
### Øvrige manglende verktøy
|
||||
|
||||
- [~] 28.4 `synops-notify`: send varsel via epost (synops-mail), WebSocket-push, eller begge. Input: `--to <node_id> --message <tekst> [--channel email|ws|both]`. Brukes av orkestreringer og vaktmesteren.
|
||||
> Påbegynt: 2026-03-18T20:20
|
||||
- [x] 28.4 `synops-notify`: send varsel via epost (synops-mail), WebSocket-push, eller begge. Input: `--to <node_id> --message <tekst> [--channel email|ws|both]`. Brukes av orkestreringer og vaktmesteren.
|
||||
- [ ] 28.5 `synops-validate`: sjekk at en node matcher forventet skjema for sin node_kind. Input: `--node-id <uuid>`. Output: liste av avvik. Brukes av valideringsfasen og som pre-commit sjekk.
|
||||
- [ ] 28.6 `synops-backup`: PG-dump + CAS-filiste + metadata-snapshot. Input: `[--full | --incremental]`. Output: backup-sti. Erstatter cron-scriptet fra 12.2.
|
||||
- [ ] 28.7 `synops-health`: sjekk status for alle tjenester (PG, Caddy, vaktmesteren, LiteLLM, Whisper, LiveKit). Output: JSON med status per tjeneste. Brukes av admin-dashboard og overvåking.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|
|||
| `synops-ai` | LLM-verktøy: `prompt` (direkte LLM-kall) + `script` (orkestreringsscript fra fritekst) | Ferdig |
|
||||
| `synops-clip` | Hent og parse webartikler (Readability + Playwright-fallback, paywall-deteksjon) | Ferdig |
|
||||
| `synops-mail` | Send epost via msmtp (vaktmester@synops.no) | Ferdig (venter SMTP-credentials) |
|
||||
| `synops-notify` | Send varsel via epost, WebSocket-push, eller begge | Ferdig |
|
||||
|
||||
## Delt bibliotek
|
||||
|
||||
|
|
|
|||
19
tools/synops-notify/Cargo.toml
Normal file
19
tools/synops-notify/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "synops-notify"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "synops-notify"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
synops-common = { path = "../synops-common" }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
|
||||
uuid = { version = "1", features = ["v7", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
273
tools/synops-notify/src/main.rs
Normal file
273
tools/synops-notify/src/main.rs
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
// synops-notify — Send varsel via epost, WebSocket-push, eller begge.
|
||||
//
|
||||
// Løser opp mottaker fra node_id, og sender varsel via valgt kanal:
|
||||
// - email: Slår opp epost fra auth_identities, kaller synops-mail
|
||||
// - ws: Oppretter notification-node i PG med tilgang for mottaker.
|
||||
// PG NOTIFY-trigger sender sanntidsoppdatering via portvokteren.
|
||||
// - both: Begge kanaler
|
||||
//
|
||||
// Bruk:
|
||||
// synops-notify --to <node_id> --message "Tekst" --channel email
|
||||
// synops-notify --to <node_id> --message "Tekst" --channel ws
|
||||
// synops-notify --to <node_id> --message "Tekst" --channel both
|
||||
// synops-notify --to <node_id> --message "Tekst" (default: ws)
|
||||
//
|
||||
// Brukes av orkestreringer og vaktmesteren.
|
||||
//
|
||||
// Output: JSON til stdout med resultat per kanal.
|
||||
// Feil: stderr + exit code != 0.
|
||||
//
|
||||
// Ref: docs/retninger/unix_filosofi.md, tools/README.md
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use serde::Serialize;
|
||||
use sqlx::PgPool;
|
||||
use std::process::{Command, Stdio};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Varslingskanal.
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
enum Channel {
|
||||
Email,
|
||||
Ws,
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Send varsel via epost, WebSocket-push, eller begge.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "synops-notify", about = "Send varsel til bruker via epost og/eller WebSocket")]
|
||||
struct Cli {
|
||||
/// Mottaker (node_id for bruker)
|
||||
#[arg(long)]
|
||||
to: Uuid,
|
||||
|
||||
/// Varseltekst
|
||||
#[arg(long)]
|
||||
message: String,
|
||||
|
||||
/// Emne for epost (valgfritt, default: "Varsel fra Synops")
|
||||
#[arg(long, default_value = "Varsel fra Synops")]
|
||||
subject: String,
|
||||
|
||||
/// Kanal: email, ws, both (default: ws)
|
||||
#[arg(long, value_enum, default_value = "ws")]
|
||||
channel: Channel,
|
||||
|
||||
/// Payload fra jobbkø (JSON). Overstyrer andre argumenter.
|
||||
#[arg(long)]
|
||||
payload_json: Option<String>,
|
||||
}
|
||||
|
||||
/// Resultat fra notifikasjonen.
|
||||
#[derive(Serialize)]
|
||||
struct NotifyResult {
|
||||
ok: bool,
|
||||
to: String,
|
||||
channels: Vec<ChannelResult>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChannelResult {
|
||||
channel: String,
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
detail: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
synops_common::logging::init("synops_notify");
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Støtte for --payload-json (jobbkø-dispatch)
|
||||
let (to, message, subject, channel) = if let Some(ref json_str) = cli.payload_json {
|
||||
let payload: serde_json::Value = serde_json::from_str(json_str).unwrap_or_else(|e| {
|
||||
eprintln!("Ugyldig --payload-json: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let to = payload["to"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse::<Uuid>().ok())
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Mangler 'to' (UUID) i payload");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let message = payload["message"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Mangler 'message' i payload");
|
||||
std::process::exit(1);
|
||||
})
|
||||
.to_string();
|
||||
let subject = payload["subject"]
|
||||
.as_str()
|
||||
.unwrap_or("Varsel fra Synops")
|
||||
.to_string();
|
||||
let channel = match payload["channel"].as_str().unwrap_or("ws") {
|
||||
"email" => Channel::Email,
|
||||
"both" => Channel::Both,
|
||||
_ => Channel::Ws,
|
||||
};
|
||||
(to, message, subject, channel)
|
||||
} else {
|
||||
(cli.to, cli.message, cli.subject, cli.channel)
|
||||
};
|
||||
|
||||
let db = synops_common::db::connect().await.unwrap_or_else(|e| {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
// Verifiser at mottaker-noden eksisterer og er en bruker/agent
|
||||
let node_kind: Option<String> = sqlx::query_scalar(
|
||||
"SELECT node_kind::text FROM nodes WHERE id = $1",
|
||||
)
|
||||
.bind(to)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("DB-feil ved oppslag av mottaker: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
let node_kind = node_kind.unwrap_or_else(|| {
|
||||
eprintln!("Mottaker-node {to} finnes ikke");
|
||||
std::process::exit(1);
|
||||
});
|
||||
|
||||
if !["person", "agent"].contains(&node_kind.as_str()) {
|
||||
eprintln!("Mottaker {to} er {node_kind}, ikke person/agent — kan ikke sende varsel");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
let mut channels = Vec::new();
|
||||
let mut all_ok = true;
|
||||
|
||||
// E-post
|
||||
if matches!(channel, Channel::Email | Channel::Both) {
|
||||
let result = send_email(&db, to, &subject, &message).await;
|
||||
let ok = result.is_ok();
|
||||
if !ok {
|
||||
all_ok = false;
|
||||
}
|
||||
channels.push(ChannelResult {
|
||||
channel: "email".to_string(),
|
||||
ok,
|
||||
detail: result.as_ref().ok().cloned(),
|
||||
error: result.err(),
|
||||
});
|
||||
}
|
||||
|
||||
// WebSocket (via notification-node i PG)
|
||||
if matches!(channel, Channel::Ws | Channel::Both) {
|
||||
let result = send_ws(&db, to, &subject, &message).await;
|
||||
let ok = result.is_ok();
|
||||
if !ok {
|
||||
all_ok = false;
|
||||
}
|
||||
channels.push(ChannelResult {
|
||||
channel: "ws".to_string(),
|
||||
ok,
|
||||
detail: result.as_ref().ok().map(|id| format!("notification_node={id}")),
|
||||
error: result.err(),
|
||||
});
|
||||
}
|
||||
|
||||
let output = NotifyResult {
|
||||
ok: all_ok,
|
||||
to: to.to_string(),
|
||||
channels,
|
||||
};
|
||||
|
||||
println!("{}", serde_json::to_string_pretty(&output).unwrap());
|
||||
|
||||
if !all_ok {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send epost via synops-mail. Slår opp mottakers epostadresse fra auth_identities.
|
||||
async fn send_email(db: &PgPool, to: Uuid, subject: &str, message: &str) -> Result<String, String> {
|
||||
let email: Option<String> = sqlx::query_scalar(
|
||||
"SELECT email FROM auth_identities WHERE node_id = $1",
|
||||
)
|
||||
.bind(to)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| format!("DB-feil ved e-post-oppslag: {e}"))?;
|
||||
|
||||
let email = email.ok_or_else(|| format!("Ingen epost registrert for node {to}"))?;
|
||||
|
||||
tracing::info!(to = %email, subject, "Sender epost via synops-mail");
|
||||
|
||||
let output = Command::new("synops-mail")
|
||||
.args(["--send", "--to", &email, "--subject", subject, "--body", message])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|e| format!("Kunne ikke starte synops-mail: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(format!("Sendt til {email}"))
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("synops-mail feilet: {stderr}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Send WebSocket-varsel ved å opprette en notification-node i PG.
|
||||
///
|
||||
/// Steg:
|
||||
/// 1. Opprett node med node_kind='notification', visibility='private'
|
||||
/// 2. Opprett node_access-rad for mottaker → noden blir synlig via portvokteren
|
||||
///
|
||||
/// PG NOTIFY-triggere (fra migrasjon 018) sender sanntidsoppdatering
|
||||
/// til portvokteren, som leverer til mottakerens WebSocket-tilkobling.
|
||||
async fn send_ws(db: &PgPool, to: Uuid, subject: &str, message: &str) -> Result<Uuid, String> {
|
||||
let node_id = Uuid::now_v7();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"notification_type": "general",
|
||||
"recipient": to.to_string(),
|
||||
});
|
||||
|
||||
// Opprett notification-node og tilgang i én transaksjon
|
||||
let mut tx = db.begin().await.map_err(|e| format!("Transaksjon feilet: {e}"))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata)
|
||||
VALUES ($1, 'notification', $2, $3, 'hidden', $4)"#,
|
||||
)
|
||||
.bind(node_id)
|
||||
.bind(subject)
|
||||
.bind(message)
|
||||
.bind(&metadata)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("Kunne ikke opprette notification-node: {e}"))?;
|
||||
|
||||
// Gi mottaker lesetilgang — dette trigger access_changed NOTIFY
|
||||
sqlx::query(
|
||||
r#"INSERT INTO node_access (subject_id, object_id, access)
|
||||
VALUES ($1, $2, 'reader')"#,
|
||||
)
|
||||
.bind(to)
|
||||
.bind(node_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("Kunne ikke gi mottaker tilgang: {e}"))?;
|
||||
|
||||
tx.commit().await.map_err(|e| format!("Commit feilet: {e}"))?;
|
||||
|
||||
tracing::info!(
|
||||
notification_id = %node_id,
|
||||
to = %to,
|
||||
"Notification-node opprettet — WebSocket-push via PG NOTIFY"
|
||||
);
|
||||
|
||||
Ok(node_id)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue