Serve the feed, according to all the Atproto endpoints
This commit is contained in:
parent
c2899951f6
commit
b4250e12cd
|
@ -1,2 +1,3 @@
|
|||
CHAT_GPT_API_KEY="fake-chat-gpt-key"
|
||||
DATABASE_URL="postgres://postgres:password@localhost/nederlandskie"
|
||||
HOSTNAME="..."
|
|
@ -132,6 +132,55 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.6.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b829e4e32b91e643de6eafe82b1d90675f5874230191a4ffbc1b336dec4d6bf"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bitflags 1.3.2",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "759fa577a247914fd3f7f76d62972792636412fbfd634cd452f6a385a74d2d2c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"mime",
|
||||
"rustversion",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backtrace"
|
||||
version = "0.3.69"
|
||||
|
@ -1125,6 +1174,12 @@ version = "0.4.20"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed1202b2a6f884ae56f04cff409ab315c5ce26b5e58d7412e484f01fd52f52ef"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.5"
|
||||
|
@ -1242,6 +1297,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"atrium-api",
|
||||
"atrium-xrpc",
|
||||
"axum",
|
||||
"chat-gpt-lib-rs",
|
||||
"chrono",
|
||||
"ciborium",
|
||||
|
@ -1250,6 +1306,7 @@ dependencies = [
|
|||
"libipld-core",
|
||||
"rs-car",
|
||||
"scooby",
|
||||
"serde",
|
||||
"serde_ipld_dagcbor",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
|
@ -1702,6 +1759,12 @@ dependencies = [
|
|||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.15"
|
||||
|
@ -1813,6 +1876,16 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4beec8bce849d58d06238cb50db2e1c417cfeafa4c63f692b15c82b7c80f8335"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_qs"
|
||||
version = "0.12.0"
|
||||
|
@ -2196,6 +2269,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.12.6"
|
||||
|
@ -2364,6 +2443,28 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.2"
|
||||
|
|
|
@ -10,6 +10,7 @@ anyhow = "1.0.75"
|
|||
async-trait = "0.1.73"
|
||||
atrium-api = "0.6.0"
|
||||
atrium-xrpc = "0.4.0"
|
||||
axum = "0.6.20"
|
||||
chat-gpt-lib-rs = "0.2.1"
|
||||
chrono = "0.4.29"
|
||||
ciborium = "0.2.1"
|
||||
|
@ -18,6 +19,7 @@ futures = "0.3.28"
|
|||
libipld-core = { version = "0.16.0", features = ["serde-codec"] }
|
||||
rs-car = "0.4.1"
|
||||
scooby = "0.5.0"
|
||||
serde = "1.0.188"
|
||||
serde_ipld_dagcbor = "0.4.1"
|
||||
sqlx = { version = "0.7.1", default-features = false, features = ["postgres", "runtime-tokio-native-tls", "chrono"] }
|
||||
tokio = { version = "1.32.0", features = ["full"] }
|
||||
|
|
|
@ -21,6 +21,7 @@ Copy `.env.example` into `.env` and set up the environment variables within:
|
|||
|
||||
- `CHAT_GPT_API_KEY` for your ChatGPT key
|
||||
- `DATABASE_URL` for PostgreSQL credentials
|
||||
- `HOSTNAME` to the hostname of where you intend to host the feed
|
||||
|
||||
## Running
|
||||
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
use anyhow::Result;
|
||||
use dotenv::dotenv;
|
||||
use std::env;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub chat_gpt_api_key: String,
|
||||
pub database_url: String,
|
||||
pub service_did: String,
|
||||
pub publisher_did: String,
|
||||
pub hostname: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
dotenv()?;
|
||||
|
||||
Ok(Self {
|
||||
chat_gpt_api_key: env::var("CHAT_GPT_API_KEY")?,
|
||||
database_url: env::var("DATABASE_URL")?,
|
||||
hostname: env::var("HOSTNAME")?,
|
||||
service_did: format!("did:web:{}", env::var("HOSTNAME")?),
|
||||
publisher_did: "".to_owned(), // TODO
|
||||
})
|
||||
}
|
||||
}
|
29
src/main.rs
29
src/main.rs
|
@ -1,33 +1,17 @@
|
|||
mod config;
|
||||
mod processes;
|
||||
mod services;
|
||||
|
||||
use std::env;
|
||||
|
||||
use anyhow::Result;
|
||||
use dotenv::dotenv;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::processes::feed_server::FeedServer;
|
||||
use crate::processes::post_saver::PostSaver;
|
||||
use crate::processes::profile_classifier::ProfileClassifier;
|
||||
use crate::services::ai::AI;
|
||||
use crate::services::bluesky::Bluesky;
|
||||
use crate::services::database::Database;
|
||||
|
||||
struct Config {
|
||||
chat_gpt_api_key: String,
|
||||
database_url: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn load() -> Result<Self> {
|
||||
dotenv()?;
|
||||
|
||||
Ok(Self {
|
||||
chat_gpt_api_key: env::var("CHAT_GPT_API_KEY")?,
|
||||
database_url: env::var("DATABASE_URL")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let config = Config::load()?;
|
||||
|
@ -38,8 +22,13 @@ async fn main() -> Result<()> {
|
|||
|
||||
let post_saver = PostSaver::new(&database, &bluesky);
|
||||
let profile_classifier = ProfileClassifier::new(&database, &ai, &bluesky);
|
||||
let feed_server = FeedServer::new(&database, &config);
|
||||
|
||||
tokio::try_join!(post_saver.start(), profile_classifier.start())?;
|
||||
tokio::try_join!(
|
||||
post_saver.start(),
|
||||
profile_classifier.start(),
|
||||
feed_server.serve(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
mod endpoints;
|
||||
mod server;
|
||||
mod state;
|
||||
|
||||
pub use server::FeedServer;
|
|
@ -0,0 +1,9 @@
|
|||
mod describe_feed_generator;
|
||||
mod did_json;
|
||||
mod get_feed_skeleton;
|
||||
mod root;
|
||||
|
||||
pub use describe_feed_generator::describe_feed_generator;
|
||||
pub use did_json::did_json;
|
||||
pub use get_feed_skeleton::get_feed_skeleton;
|
||||
pub use root::root;
|
|
@ -0,0 +1,18 @@
|
|||
use atrium_api::app::bsky::feed::describe_feed_generator::{
|
||||
Feed, Output as FeedGeneratorDescription,
|
||||
};
|
||||
use axum::{extract::State, Json};
|
||||
|
||||
use crate::processes::feed_server::state::FeedServerState;
|
||||
|
||||
pub async fn describe_feed_generator(
|
||||
State(state): State<FeedServerState>,
|
||||
) -> Json<FeedGeneratorDescription> {
|
||||
Json(FeedGeneratorDescription {
|
||||
did: state.config.service_did.clone(),
|
||||
feeds: vec![Feed {
|
||||
uri: format!("at://{}/app.bsky.feed.generator/{}", state.config.publisher_did, "nederlandskie"),
|
||||
}],
|
||||
links: None,
|
||||
})
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
use axum::{extract::State, Json};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::processes::feed_server::state::FeedServerState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Did {
|
||||
#[serde(rename = "@context")]
|
||||
context: Vec<String>,
|
||||
id: String,
|
||||
service: Vec<Service>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Service {
|
||||
id: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
service_endpoint: String,
|
||||
}
|
||||
|
||||
pub async fn did_json(State(state): State<FeedServerState>) -> Json<Did> {
|
||||
Json(Did {
|
||||
context: vec!["https://www.w3.org/ns/did/v1".to_owned()],
|
||||
id: state.config.service_did.clone(),
|
||||
service: vec![Service {
|
||||
id: "#bsky_fg".to_owned(),
|
||||
type_: "BskyFeedGenerator".to_owned(),
|
||||
service_endpoint: format!("https://{}", state.config.hostname),
|
||||
}],
|
||||
})
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
use anyhow::{anyhow, Result};
|
||||
use atrium_api::app::bsky::feed::defs::SkeletonFeedPost;
|
||||
use atrium_api::app::bsky::feed::get_feed_skeleton::{
|
||||
Output as FeedSkeleton, Parameters as FeedSkeletonQuery,
|
||||
};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use chrono::{DateTime, TimeZone, Utc};
|
||||
|
||||
use crate::processes::feed_server::state::FeedServerState;
|
||||
|
||||
pub async fn get_feed_skeleton(
|
||||
State(state): State<FeedServerState>,
|
||||
query: Query<FeedSkeletonQuery>,
|
||||
) -> Json<FeedSkeleton> {
|
||||
let limit = query.limit.unwrap_or(20) as usize;
|
||||
let earlier_than = query
|
||||
.cursor
|
||||
.as_deref()
|
||||
.map(parse_cursor)
|
||||
.transpose()
|
||||
.unwrap(); // TODO: handle error
|
||||
|
||||
let posts = state
|
||||
.database
|
||||
.fetch_posts_by_authors_country("ru", limit, earlier_than)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let feed = posts
|
||||
.iter()
|
||||
.map(|p| SkeletonFeedPost {
|
||||
post: p.uri.clone(),
|
||||
reason: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cursor = posts.last().map(|p| make_cursor(&p.indexed_at, &p.cid));
|
||||
|
||||
Json(FeedSkeleton { cursor, feed })
|
||||
}
|
||||
|
||||
fn make_cursor(date: &DateTime<Utc>, cid: &str) -> String {
|
||||
format!("{}::{}", date.timestamp() * 1000, cid)
|
||||
}
|
||||
|
||||
fn parse_cursor(cursor: &str) -> Result<(DateTime<Utc>, &str)> {
|
||||
let mut parts = cursor.split("::");
|
||||
|
||||
let indexed_at = parts.next().ok_or_else(|| anyhow!("Malformed cursor"))?;
|
||||
let cid = parts.next().ok_or_else(|| anyhow!("Malformed cursor"))?;
|
||||
|
||||
if parts.next().is_some() {
|
||||
return Err(anyhow!("Malformed cursor"));
|
||||
}
|
||||
|
||||
let indexed_at: i64 = indexed_at.parse()?;
|
||||
let indexed_at = Utc.timestamp_opt(indexed_at / 1000, 0).unwrap(); // TODO: handle error
|
||||
|
||||
Ok((indexed_at, cid))
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
pub async fn root() -> &'static str {
|
||||
"Hello, World!"
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
use std::net::SocketAddr;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::routing::get;
|
||||
use axum::{Router, Server};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::services::database::Database;
|
||||
|
||||
use super::endpoints::{describe_feed_generator, did_json, get_feed_skeleton, root};
|
||||
use super::state::FeedServerState;
|
||||
|
||||
pub struct FeedServer<'a> {
|
||||
database: &'a Database,
|
||||
config: &'a Config,
|
||||
}
|
||||
|
||||
impl<'a> FeedServer<'a> {
|
||||
pub fn new(database: &'a Database, config: &'a Config) -> Self {
|
||||
Self { database, config }
|
||||
}
|
||||
|
||||
pub async fn serve(self) -> Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/", get(root))
|
||||
.route("/.well-known/did.json", get(did_json))
|
||||
.route(
|
||||
"/xrpc/app.bsky.feed.describeFeedGenerator",
|
||||
get(describe_feed_generator),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.feed.getFeedSkeleton",
|
||||
get(get_feed_skeleton),
|
||||
)
|
||||
.with_state(FeedServerState {
|
||||
database: self.database.clone(),
|
||||
config: self.config.clone(),
|
||||
});
|
||||
|
||||
let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
|
||||
Server::bind(&addr).serve(app.into_make_service()).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
use crate::config::Config;
|
||||
use crate::services::database::Database;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FeedServerState {
|
||||
pub database: Database,
|
||||
pub config: Config,
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
pub mod feed_server;
|
||||
pub mod post_saver;
|
||||
pub mod profile_classifier;
|
||||
|
|
|
@ -23,7 +23,7 @@ impl AI {
|
|||
Message {
|
||||
role: Role::System,
|
||||
// TODO: Lol, prompt injection much?
|
||||
content: "You are a tool that attempts to guess where a person is likely to be from based on their name and short bio. Please respond with two-letter country code only. Use lowercase letters.".to_string(),
|
||||
content: "You are a tool that attempts to guess where a person is likely to be from based on their name and short bio. Please respond with two-letter country code only. If unable to determine, say xx.".to_string(),
|
||||
},
|
||||
Message {
|
||||
role: Role::User,
|
||||
|
@ -36,6 +36,6 @@ impl AI {
|
|||
let response = self.chat_gpt_client.chat(chat_input).await?;
|
||||
|
||||
// TODO: Error handling?
|
||||
return Ok(response.choices[0].message.content.clone());
|
||||
return Ok(response.choices[0].message.content.to_lowercase());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use scooby::postgres::{insert_into, select, update, Parameters};
|
||||
use scooby::postgres::{insert_into, select, update, Joinable, Orderable, Parameters, Aliasable};
|
||||
use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
|
||||
use sqlx::query;
|
||||
use sqlx::Row;
|
||||
|
||||
pub struct Post {
|
||||
indexed_at: DateTime<Utc>,
|
||||
author_did: String,
|
||||
cid: String,
|
||||
uri: String,
|
||||
pub indexed_at: DateTime<Utc>,
|
||||
pub author_did: String,
|
||||
pub cid: String,
|
||||
pub uri: String,
|
||||
}
|
||||
|
||||
pub struct Profile {
|
||||
|
@ -24,6 +24,7 @@ pub struct SubscriptionState {
|
|||
cursor: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
connection_pool: PgPool,
|
||||
}
|
||||
|
@ -52,6 +53,49 @@ impl Database {
|
|||
.map(|_| ())?)
|
||||
}
|
||||
|
||||
pub async fn fetch_posts_by_authors_country(
|
||||
&self,
|
||||
author_country: &str,
|
||||
limit: usize,
|
||||
earlier_than: Option<(DateTime<Utc>, &str)>,
|
||||
) -> Result<Vec<Post>> {
|
||||
let mut params = Parameters::new();
|
||||
let mut sql_builder = select(("p.indexed_at", "p.author_did", "p.cid", "p.uri"))
|
||||
.from(
|
||||
"Post".as_("p")
|
||||
.inner_join("Profile".as_("pr"))
|
||||
.on("pr.did = p.author_did"),
|
||||
)
|
||||
.where_(format!("pr.likely_country_of_living = {}", params.next()))
|
||||
.order_by(("p.indexed_at".desc(), "p.cid".desc()))
|
||||
.limit(limit);
|
||||
|
||||
if earlier_than.is_some() {
|
||||
sql_builder = sql_builder
|
||||
.where_(format!("p.indexed_at <= {}", params.next()))
|
||||
.where_(format!("p.cid < {}", params.next()));
|
||||
}
|
||||
|
||||
let sql_string = sql_builder.to_string();
|
||||
|
||||
let mut query_object = query(&sql_string)
|
||||
.bind(author_country);
|
||||
|
||||
if let Some((last_indexed_at, last_cid)) = earlier_than {
|
||||
query_object = query_object.bind(last_indexed_at).bind(last_cid);
|
||||
}
|
||||
|
||||
Ok(query_object
|
||||
.map(|r: PgRow| Post {
|
||||
indexed_at: r.get("indexed_at"),
|
||||
author_did: r.get("author_did"),
|
||||
cid: r.get("cid"),
|
||||
uri: r.get("uri"),
|
||||
})
|
||||
.fetch_all(&self.connection_pool)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn insert_profile_if_it_doesnt_exist(&self, did: &str) -> Result<bool> {
|
||||
let mut params = Parameters::new();
|
||||
|
||||
|
|
Loading…
Reference in New Issue