use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use trust_tasks_rs::Payload;
use trust_tasks_rs::specs::rooms::keys::profile::{
read::v0_1 as profile_read, write::v0_1 as profile_write,
};
use vti_common::error::AppError;
use super::community::{CommunityRooms, HolderError, KeyHolder, OwnedRoom};
use crate::server::AppState;
pub const PROFILE_TIMEOUT: Duration = Duration::from_secs(60);
const TICK: Duration = Duration::from_secs(60);
const FIRST_PASS: Duration = Duration::from_secs(90);
const RETRY_BASE: Duration = Duration::from_secs(60);
const RETRY_CAP: Duration = Duration::from_secs(6 * 60 * 60);
const PROFILE_READ: &str = <profile_read::Payload as Payload>::TYPE_URI;
const PROFILE_WRITE: &str = <profile_write::Payload as Payload>::TYPE_URI;
const MEMBER_EXT: &str = "org.openvtc.rooms";
fn record_key(room_id: &str) -> Vec<u8> {
format!("room-profile:{room_id}").into_bytes()
}
fn rfc3339(t: DateTime<Utc>) -> String {
t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
fn parse_time(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.with_timezone(&Utc))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProfileOutcome {
Written,
Pending,
Unchanged,
}
impl ProfileOutcome {
pub fn as_str(self) -> &'static str {
match self {
Self::Written => "written",
Self::Pending => "pending",
Self::Unchanged => "unchanged",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRecord {
pub room_id: String,
#[serde(default)]
pub pending: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub written_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub written_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub written_at: Option<String>,
#[serde(default)]
pub attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_attempt_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_attempt_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
impl ProfileRecord {
fn new(room_id: &str) -> Self {
Self {
room_id: room_id.to_string(),
..Default::default()
}
}
pub fn ever_written(&self) -> bool {
self.written_at.is_some()
}
pub fn status(&self) -> Value {
let state = if self.pending {
"pending"
} else if self.ever_written() {
"written"
} else {
"none"
};
let mut v = json!({ "state": state });
if self.attempts > 0 {
v["attempts"] = json!(self.attempts);
}
for (name, field) in [
("writtenAt", &self.written_at),
("lastAttemptAt", &self.last_attempt_at),
("nextAttemptAt", &self.next_attempt_at),
("lastError", &self.last_error),
] {
if let Some(f) = field {
v[name] = json!(f);
}
}
v
}
}
pub async fn record(
rooms: &CommunityRooms,
room_id: &str,
) -> Result<Option<ProfileRecord>, AppError> {
rooms.keyspace().get(record_key(room_id)).await
}
async fn put_record(rooms: &CommunityRooms, rec: &ProfileRecord) -> Result<(), AppError> {
rooms.keyspace().insert(record_key(&rec.room_id), rec).await
}
pub async fn room_ext(state: &AppState, room_id: &str) -> Result<Value, AppError> {
Ok(record(&state.community_rooms, room_id)
.await?
.unwrap_or_else(|| ProfileRecord::new(room_id))
.status())
}
pub async fn list_ext(state: &AppState, room_ids: &[String]) -> Result<Value, AppError> {
let mut rooms = serde_json::Map::new();
for id in room_ids {
let status = record(&state.community_rooms, id)
.await?
.unwrap_or_else(|| ProfileRecord::new(id))
.status();
rooms.insert(id.clone(), status);
}
Ok(Value::Object(rooms))
}
pub async fn update_needs_write(
state: &AppState,
room_id: &str,
label_or_description_changed: bool,
) -> Result<bool, AppError> {
if label_or_description_changed {
return Ok(true);
}
let rec = record(&state.community_rooms, room_id).await?;
Ok(rec.is_none_or(|r| r.pending || !r.ever_written()))
}
pub async fn write_now(state: &AppState, room: &OwnedRoom, now: DateTime<Utc>) -> ProfileOutcome {
match attempt(state, room, now).await {
Ok(outcome) => outcome,
Err(e) => {
tracing::warn!(room = %room.room_id, error = %e,
"could not record a community room's profile state; the sweeper retries it");
ProfileOutcome::Pending
}
}
}
async fn attempt(
state: &AppState,
room: &OwnedRoom,
now: DateTime<Utc>,
) -> Result<ProfileOutcome, AppError> {
let mut rec = record(&state.community_rooms, &room.room_id)
.await?
.unwrap_or_else(|| ProfileRecord::new(&room.room_id));
rec.pending = true;
rec.last_attempt_at = Some(rfc3339(now));
put_record(&state.community_rooms, &rec).await?;
let vtc_did = state.config.read().await.vtc_did.clone();
let result = match (state.community_rooms.key_holder(), vtc_did) {
(Some(holder), Some(vtc_did)) => write_at_vta(&holder, &vtc_did, room).await,
(None, _) => Err("this community has no VTA link to write the profile with".to_string()),
(_, None) => Err("this community has no DID".to_string()),
};
match result {
Ok(()) => {
rec.pending = false;
rec.written_name = Some(room.label.clone());
rec.written_description = room.description.clone();
rec.written_at = Some(rfc3339(now));
rec.attempts = 0;
rec.next_attempt_at = None;
rec.last_error = None;
put_record(&state.community_rooms, &rec).await?;
tracing::info!(room = %room.room_id, "wrote a community room's profile into the room");
Ok(ProfileOutcome::Written)
}
Err(why) => {
rec.attempts = rec.attempts.saturating_add(1);
rec.next_attempt_at = Some(rfc3339(now + backoff(rec.attempts)));
rec.last_error = Some(why.clone());
put_record(&state.community_rooms, &rec).await?;
tracing::warn!(room = %room.room_id, attempts = rec.attempts, error = %why,
"could not write a community room's profile; will retry");
Ok(ProfileOutcome::Pending)
}
}
}
async fn write_at_vta(holder: &KeyHolder, vtc_did: &str, room: &OwnedRoom) -> Result<(), String> {
let as_community = json!({ MEMBER_EXT: { "member": vtc_did } });
let mut profile = serde_json::Map::new();
let read = holder
.ask(
PROFILE_READ,
json!({ "roomId": room.room_id, "host": vtc_did, "ext": as_community }),
PROFILE_TIMEOUT,
)
.await;
match read {
Ok(current) if current["status"] == "found" => {
for carried in ["tags", "emoji", "ext"] {
if let Some(v) = current["profile"].get(carried) {
profile.insert(carried.into(), v.clone());
}
}
}
Ok(_) => {}
Err(e) => tracing::debug!(room = %room.room_id, error = %holder_error(&e),
"could not read a community room's profile before re-writing it"),
}
profile.insert("name".into(), json!(room.label));
if let Some(d) = &room.description {
profile.insert("description".into(), json!(d));
}
let request: profile_write::Payload = serde_json::from_value(json!({
"roomId": room.room_id,
"host": vtc_did,
"profile": profile,
"ext": as_community,
}))
.map_err(|e| format!("the room's profile is not one the specification admits: {e}"))?;
let answer = holder
.ask(
PROFILE_WRITE,
serde_json::to_value(request).map_err(|e| e.to_string())?,
PROFILE_TIMEOUT,
)
.await
.map_err(|e| holder_error(&e))?;
let answer: profile_write::Response = serde_json::from_value(answer).map_err(|e| {
format!("the community's VTA answered rooms/keys/profile/write in a shape its specification does not define: {e}")
})?;
match answer.outcome {
profile_write::ResponseOutcome::Written => Ok(()),
other => Err(format!(
"the community's VTA answered rooms/keys/profile/write with outcome `{other}`"
)),
}
}
fn holder_error(e: &HolderError) -> String {
match e {
HolderError::Unavailable(m) => format!("rooms/keys/profile/write: {m}"),
HolderError::Refused { code, message } => {
format!("rooms/keys/profile/write: the community's VTA refused: {code} {message}")
}
}
}
fn backoff(attempts: u32) -> chrono::Duration {
use rand::RngExt as _;
let factor = 1u32
.checked_shl(attempts.saturating_sub(1))
.unwrap_or(u32::MAX);
let ceiling = RETRY_BASE.saturating_mul(factor).min(RETRY_CAP).as_secs();
let half = ceiling / 2;
let secs = half + rand::rng().random_range(0..=ceiling - half);
chrono::Duration::seconds(secs as i64)
}
pub fn spawn(state: AppState, mut shutdown: tokio::sync::watch::Receiver<bool>) {
tokio::spawn(async move {
let mut wait = FIRST_PASS;
loop {
tokio::select! {
_ = shutdown.changed() => return,
_ = tokio::time::sleep(wait) => {}
}
wait = TICK;
if let Err(e) = sweep(&state, Utc::now()).await {
tracing::warn!(error = %e, "community room profile pass failed");
}
}
});
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SweepReport {
pub written: Vec<String>,
pub failed: Vec<String>,
}
pub async fn sweep(state: &AppState, now: DateTime<Utc>) -> Result<SweepReport, AppError> {
let mut report = SweepReport::default();
let vtc_did = state.config.read().await.vtc_did.clone();
let Some(vtc_did) = vtc_did else {
return Ok(report);
};
if state.community_rooms.key_holder().is_none() {
return Ok(report);
}
for room in state.community_rooms.rooms().await? {
let Some(rec) = record(&state.community_rooms, &room.room_id).await? else {
continue;
};
if !rec.pending
|| rec
.next_attempt_at
.as_deref()
.and_then(parse_time)
.is_some_and(|next| next > now)
{
continue;
}
if !still_ours(state, &room, &vtc_did).await {
continue;
}
match attempt(state, &room, now).await? {
ProfileOutcome::Written => report.written.push(room.room_id.clone()),
_ => report.failed.push(room.room_id.clone()),
}
}
Ok(report)
}
async fn still_ours(state: &AppState, room: &OwnedRoom, vtc_did: &str) -> bool {
match vti_rooms::storage::get_room(&state.rooms_ks, &room.room_id).await {
Ok(hosted) => hosted.owner_did == vtc_did,
Err(AppError::NotFound(_)) => false,
Err(e) => {
tracing::warn!(room = %room.room_id, error = %e,
"could not read a community room; not writing its profile now");
false
}
}
}