Oak

Artifact [42d9c551e7]
Login

Artifact [42d9c551e7]

Artifact 42d9c551e716eb4d2ad4bacbee1102402b98fb67e2a28f18f545d2c13fd08cd6:


use ::hex::ToHex;
use deltachat::context::Context;
use deltachat::securejoin;
use nostr::prelude::*;
use std::collections::HashSet;
use std::str::FromStr;

use crate::{bot::delta, db, Db, DeltaBotStatus, NostrBotStateTransitions, NostrBotStatus};
use rocket::{serde::json::Json, State};
use rocket_db_pools::Connection;

use crate::model::bot::delta::*;
use crate::model::bot::nostr::*;

use crate::model::api::{ApiError, ApiErrorLogged, ValidationError};
use crate::params::StartupParamsOptional;
use crate::util::oak::{CONFIG_KEY_NOSTR_BOT_OWNER_PUB_KEY, CONFIG_KEY_NOSTR_WS_ENDPOINT};

use crate::bot::ons::nip47::{Nip47InnerConfig, SecretConfig};
use crate::model::api::ApiError::GenericError;
use utoipa::OpenApi;

#[derive(OpenApi)]
#[openapi(
    paths(
        setup_email,
        get_pairing_code,
        enable_disable_ons,
        ons3_get_amount,
        ons3_update_amount,
        nip47_create,
        nip47_list,
        nip47_delete,
        nostr_setup_owner_pubkey_and_relay,
    ),
    components(
        schemas(
            VerifiedEmailInfo, DeltaBotSetupInfo,
            SecretConfig,
        )
    ),
    tags(
        (name = "bot", description = "Bot-related APIs"),
    ),
)]
pub(crate) struct ApiDoc;

/// Configure the bot's email account
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    request_body = DeltaBotSetupInfo,
    responses(
        (status = 200, description = "Bot was successfully configured to use this email account"),
        (status = 500, description = "Setup error")
    )
)]
#[post("/email", data = "<setup_info>")]
pub(crate) async fn setup_email(
    setup_info: Json<DeltaBotSetupInfo>,
    bot_ctx: &State<Context>,
    bot_status: &State<DeltaBotStatus>,
) -> Result<(), ApiErrorLogged> {
    delta::configure(bot_ctx.inner(), setup_info.into_inner())
        .await
        .map(|_| {
            info!("Configured email bot");
            bot_status.inner().to_state_pending_restart()?;
            Ok(())
        })?
}

/// Get a pairing code
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    responses(
        (status = 200, description = "Pairing code", body = String),
        (status = 400, description = "When trying to get a pairing code for a bot that is already paired"),
        (status = 500, description = "General error")
    )
)]
#[get("/email")]
pub(crate) async fn get_pairing_code(
    bot_ctx: &State<Context>,
    bot_status: &State<DeltaBotStatus>,
) -> Result<String, ApiErrorLogged> {
    if *bot_status.state.lock()? != DeltaBotState::PendingOwnerPairing {
        return Err(ValidationError::of(
            "Pairing code cannot be generated for this bot state",
        ))?;
    }

    securejoin::get_securejoin_qr(bot_ctx, None)
        .await
        .map(Ok)
        .map_err(|e| GenericError(format!("Failed to generate pairing code: {e}")))?
}

/// Enable or disable Nostr bot modules
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    responses(
        (status = 200, description = "Bot module was successfully enabled or disabled"),
        (status = 500, description = "Setup error")
    )
)]
#[post("/nostr/<ons_id>/<flag>")]
pub(crate) async fn enable_disable_ons(
    ons_id: String,
    flag: bool,
    nostr_bot_status: &State<NostrBotStatus>,
    mut db: Connection<Db>,
) -> Result<(), ApiErrorLogged> {
    let ons_id_exists = nostr_bot_status
        .ons
        .lock()?
        .iter()
        .any(|ons_meta| ons_meta.id == ons_id);

    match ons_id_exists {
        true => {
            nostr_bot_status.to_state_pending_restart()?;
            crate::bot::ons::set_enabled(&mut db, ons_id, flag).await?;

            Ok(())
        }
        false => Err(ValidationError::of("Unknown ons_id"))?,
    }
}

/// Get the ons3 tipping amount
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    responses(
        (status = 200, description = "The current tipping amount, in sats"),
    )
)]
#[get("/nostr/ons3/tip_amount_sat")]
pub(crate) async fn ons3_get_amount(
    composite_params: &State<StartupParamsOptional>,
) -> Result<String, ApiErrorLogged> {
    Ok(composite_params.ons3.tip_amount_sat.to_string())
}

/// Update the ons3 tipping amount
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    request_body = Integer,
    responses(
        (status = 200, description = "Bot module was successfully updated"),
        (status = 500, description = "Setup error")
    )
)]
#[post("/nostr/ons3/tip_amount_sat", data = "<tip_amount_sat>")]
pub(crate) async fn ons3_update_amount(
    tip_amount_sat: String,
    nostr_bot_status: &State<NostrBotStatus>,
    mut db: Connection<Db>,
) -> Result<(), ApiErrorLogged> {
    let tip_amount_sat = u64::from_str(&tip_amount_sat).map_err(|e| {
        ApiError::InvalidInput(ValidationError::of("Invalid input")).to_logged_with_debug(&e)
    })?;

    match tip_amount_sat > 0 {
        true => {
            nostr_bot_status.to_state_pending_restart()?;

            let key = "ons3.tip_amount_sat";
            db::config::config_write_or_update(&mut db, key, &tip_amount_sat.to_string()).await?;

            Ok(())
        }
        false => Err(ValidationError::of("Amount must be bigger than zero"))?,
    }
}

/// Create a new NIP-47 NWC secret
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    responses(
        (status = 200, description = "Secret successfully created"),
        (status = 500, description = "Setup error")
    )
)]
#[post("/nostr/nip47")]
pub(crate) async fn nip47_create(
    nostr_bot_status: &State<NostrBotStatus>,
    spo: &State<StartupParamsOptional>,
    db: Connection<Db>,
) -> Result<(), ApiErrorLogged> {
    nostr_bot_status.ensure_nip47_module_enabled()?;

    let new_secret = SecretConfig {
        secret: SecretKey::new(&mut rand::thread_rng())
            .secret_bytes()
            .encode_hex(),
    };
    info!("Created new NIP-47 NWC secret: {new_secret:?}");

    nostr_bot_status.to_state_pending_restart()?;

    let mut inner_config: Nip47InnerConfig = crate::bot::ons::nip47::get_inner_config(spo)?;
    inner_config.secrets.insert(new_secret);

    crate::bot::ons::nip47::persist_inner_config(db, inner_config).await
}

/// List the NIP-47 NWC secrets
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    responses(
        (status = 200, description = "Secrets successfully listed"),
        (status = 500, description = "Setup error")
    )
)]
#[get("/nostr/nip47")]
pub(crate) async fn nip47_list(
    nostr_bot_status: &State<NostrBotStatus>,
    spo: &State<StartupParamsOptional>,
) -> Result<Json<HashSet<SecretConfig>>, ApiErrorLogged> {
    nostr_bot_status.ensure_nip47_module_enabled()?;

    let inner_config: Nip47InnerConfig = crate::bot::ons::nip47::get_inner_config(spo)?;
    Ok(Json::from(inner_config.secrets))
}

/// Delete a NWC secret
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    params(
        ("secret", description = "Secret to delete")
    ),
    responses(
        (status = 200, description = "Secret was successfully deleted"),
        (status = 500, description = "Setup error")
    )
)]
#[delete("/nostr/nip47/<secret>")]
pub(crate) async fn nip47_delete(
    secret: String,
    nostr_bot_status: &State<NostrBotStatus>,
    spo: &State<StartupParamsOptional>,
    db: Connection<Db>,
) -> Result<(), ApiErrorLogged> {
    nostr_bot_status.ensure_nip47_module_enabled()?;

    info!("Deleting NIP-47 NWC secret: {secret:?}");
    let mut inner_config: Nip47InnerConfig = crate::bot::ons::nip47::get_inner_config(spo)?;
    match inner_config
        .secrets
        .iter()
        .find(|&s| s.secret == secret)
        .cloned()
    {
        None => Err(GenericError("Secret does not exist".into()).into()),
        Some(s) => {
            nostr_bot_status.to_state_pending_restart()?;

            inner_config.secrets.remove(&s);
            crate::bot::ons::nip47::persist_inner_config(db, inner_config).await
        }
    }
}

/// Configure the owner's pubkey
#[utoipa::path(
    tag = "bot",
    context_path = "/bot",
    request_body = NostrBotSetupInfo,
    responses(
        (status = 200, description = "The owner pubkey and the relay URL are valid and were successfully saved"),
        (status = 400, description = "Invalid input"),
        (status = 500, description = "Setup error")
    )
)]
#[post("/nostr", data = "<setup_info>")]
pub(crate) async fn nostr_setup_owner_pubkey_and_relay(
    setup_info: Json<NostrBotSetupInfo>,
    nostr_bot_status: &State<NostrBotStatus>,
    mut db: Connection<Db>,
) -> Result<(), ApiErrorLogged> {
    match *nostr_bot_status.state.lock()? {
        NostrBotState::PendingOwnerPairing | NostrBotState::Running => {}
        _ => {
            return Err(ValidationError::of(
                "Owner pubkey and relay cannot be configured for this bot state",
            ))?
        }
    }

    let info = setup_info.into_inner();

    // Validate key
    let pk = match XOnlyPublicKey::from_bech32(&info.pk_hex_or_npub) {
        Ok(npub) => npub,
        Err(_) => {
            info!("Owner pk is not an npub, trying hex");
            XOnlyPublicKey::from_str(&info.pk_hex_or_npub).map_err(|e| {
                ApiError::InvalidInput(ValidationError::of(
                    "Invalid pubkey, it is neither a hex nor an npub",
                ))
                .to_logged_with_debug(&e)
            })?
        }
    };

    // Validate relay
    let relay = Url::parse(&info.relay).map_err(|e| {
        ApiError::InvalidInput(ValidationError::of("Invalid relay URL")).to_logged_with_debug(&e)
    })?;

    // Update bot status
    // We call this before persisting to DB, because this may not be allowed for some bot states,
    // in which case this will fail
    nostr_bot_status.to_state_pending_restart()?;

    // Persist hex pk to DB
    let pk_hex: String = pk.serialize().encode_hex();
    db::config::config_write_or_update(&mut db, CONFIG_KEY_NOSTR_BOT_OWNER_PUB_KEY, &pk_hex)
        .await?;

    // Persist relay URL to DB
    db::config::config_write_or_update(&mut db, CONFIG_KEY_NOSTR_WS_ENDPOINT, relay.as_str())
        .await?;

    info!("Persisted owner pubkey and relay");
    Ok(())
}