Parakeet is a Rust-based Bluesky AppServer aiming to implement most of the functionality required to support the Bluesky client
appview atproto bluesky rust appserver
66
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(parakeet): drafts

Mia b17cf503 1396b58e

+520 -49
+5 -4
crates/consumer/src/db/gates.rs
··· 1 1 use super::{PgExecResult, PgResult}; 2 - use crate::indexer::records::{ 3 - AppBskyFeedThreadgate, ThreadgateRule, THREADGATE_RULE_FOLLOWER, THREADGATE_RULE_FOLLOWING, 4 - THREADGATE_RULE_LIST, THREADGATE_RULE_MENTION, 5 - }; 2 + use crate::indexer::records::AppBskyFeedThreadgate; 6 3 use chrono::prelude::*; 7 4 use chrono::{DateTime, Utc}; 8 5 use deadpool_postgres::GenericClient; 6 + use lexica::app_bsky::feed::{ 7 + ThreadgateRule, THREADGATE_RULE_FOLLOWER, THREADGATE_RULE_FOLLOWING, THREADGATE_RULE_LIST, 8 + THREADGATE_RULE_MENTION, 9 + }; 9 10 use std::collections::HashSet; 10 11 11 12 pub async fn post_enforce_threadgate<C: GenericClient>(
+1
crates/consumer/src/db/record.rs
··· 4 4 use chrono::prelude::*; 5 5 use deadpool_postgres::GenericClient; 6 6 use ipld_core::cid::Cid; 7 + use lexica::app_bsky::feed::ThreadgateRule; 7 8 use lexica::community_lexicon::bookmarks::Bookmark; 8 9 use std::collections::HashSet; 9 10
+2 -1
crates/consumer/src/indexer/mod.rs
··· 15 15 use ipld_core::cid::Cid; 16 16 use jacquard_common::types::string::Handle; 17 17 use jacquard_identity::JacquardResolver; 18 + use lexica::app_bsky::feed::PostgateEmbeddingRules; 18 19 use metrics::counter; 19 20 use parakeet_db::types::{ActorStatus, ActorSyncState}; 20 21 use parakeet_index::AggregateType; ··· 630 631 631 632 let has_disable_rule = record 632 633 .embedding_rules 633 - .contains(&records::PostgateEmbeddingRules::Disable); 634 + .contains(&PostgateEmbeddingRules::Disable); 634 635 let disable_effective = has_disable_rule.then_some(record.created_at.naive_utc()); 635 636 636 637 db::postgate_upsert(conn, at_uri, cid, &record).await?;
+1 -44
crates/consumer/src/indexer/records.rs
··· 2 2 use chrono::{DateTime, Utc}; 3 3 use lexica::app_bsky::actor::{ChatAllowIncoming, ProfileAllowSubscriptions, Status}; 4 4 use lexica::app_bsky::embed::AspectRatio; 5 + use lexica::app_bsky::feed::{PostgateEmbeddingRules, ThreadgateRule}; 5 6 use lexica::app_bsky::labeler::LabelerPolicy; 6 7 use lexica::app_bsky::richtext::FacetMain; 7 8 use lexica::com_atproto::label::SelfLabels; ··· 237 238 pub embedding_rules: Vec<PostgateEmbeddingRules>, 238 239 } 239 240 240 - #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Deserialize, Serialize)] 241 - #[serde(tag = "$type")] 242 - pub enum PostgateEmbeddingRules { 243 - #[serde(rename = "app.bsky.feed.postgate#disableRule")] 244 - Disable, 245 - } 246 - 247 - impl PostgateEmbeddingRules { 248 - pub fn as_str(&self) -> &'static str { 249 - match self { 250 - PostgateEmbeddingRules::Disable => "app.bsky.feed.postgate#disableRule", 251 - } 252 - } 253 - } 254 - 255 241 #[derive(Debug, Deserialize, Serialize)] 256 242 #[serde(rename_all = "camelCase")] 257 243 pub struct AppBskyFeedRepost { ··· 270 256 pub allow: Option<Vec<ThreadgateRule>>, 271 257 #[serde(default)] 272 258 pub hidden_replies: Vec<String>, 273 - } 274 - 275 - pub const THREADGATE_RULE_MENTION: &str = "app.bsky.feed.threadgate#mentionRule"; 276 - pub const THREADGATE_RULE_FOLLOWER: &str = "app.bsky.feed.threadgate#followerRule"; 277 - pub const THREADGATE_RULE_FOLLOWING: &str = "app.bsky.feed.threadgate#followingRule"; 278 - pub const THREADGATE_RULE_LIST: &str = "app.bsky.feed.threadgate#listRule"; 279 - 280 - #[derive(Debug, Deserialize, Serialize)] 281 - #[serde(tag = "$type")] 282 - pub enum ThreadgateRule { 283 - #[serde(rename = "app.bsky.feed.threadgate#mentionRule")] 284 - Mention, 285 - #[serde(rename = "app.bsky.feed.threadgate#followerRule")] 286 - Follower, 287 - #[serde(rename = "app.bsky.feed.threadgate#followingRule")] 288 - Following, 289 - #[serde(rename = "app.bsky.feed.threadgate#listRule")] 290 - List { list: String }, 291 - } 292 - 293 - impl ThreadgateRule { 294 - pub fn as_str(&self) -> &'static str { 295 - match self { 296 - ThreadgateRule::Mention => THREADGATE_RULE_MENTION, 297 - ThreadgateRule::Follower => THREADGATE_RULE_FOLLOWER, 298 - ThreadgateRule::Following => THREADGATE_RULE_FOLLOWING, 299 - ThreadgateRule::List { .. } => THREADGATE_RULE_LIST, 300 - } 301 - } 302 259 } 303 260 304 261 #[derive(Debug, Deserialize, Serialize)]
+96
crates/lexica/src/app_bsky/draft.rs
··· 1 + use crate::{ 2 + app_bsky::feed::{PostgateEmbeddingRules, ThreadgateRule}, 3 + com_atproto::label::SelfLabels, 4 + StrongRef, 5 + }; 6 + use jacquard_common::types::string::Datetime; 7 + use serde::{Deserialize, Serialize}; 8 + 9 + #[derive(Clone, Debug, Deserialize, Serialize)] 10 + #[serde(rename_all = "camelCase")] 11 + pub struct DraftWithId { 12 + pub id: String, 13 + pub draft: Draft, 14 + } 15 + 16 + #[derive(Clone, Debug, Serialize)] 17 + #[serde(rename_all = "camelCase")] 18 + pub struct DraftView { 19 + pub id: String, 20 + pub draft: Draft, 21 + pub created_at: Datetime, 22 + pub updated_at: Datetime, 23 + } 24 + 25 + #[derive(Clone, Debug, Deserialize, Serialize)] 26 + #[serde(rename_all = "camelCase")] 27 + pub struct Draft { 28 + #[serde(skip_serializing_if = "Option::is_none")] 29 + pub device_id: Option<String>, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub device_name: Option<String>, 32 + 33 + pub posts: Vec<DraftPost>, 34 + 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + pub langs: Option<Vec<String>>, 37 + #[serde(skip_serializing_if = "Option::is_none")] 38 + pub postgate_embedding_rules: Option<Vec<PostgateEmbeddingRules>>, 39 + #[serde(skip_serializing_if = "Option::is_none")] 40 + pub threadgate_allow: Option<Vec<ThreadgateRule>>, 41 + } 42 + 43 + #[derive(Clone, Debug, Deserialize, Serialize)] 44 + #[serde(rename_all = "camelCase")] 45 + pub struct DraftPost { 46 + pub text: String, 47 + 48 + #[serde(skip_serializing_if = "Option::is_none")] 49 + pub labels: Option<SelfLabels>, 50 + 51 + #[serde(skip_serializing_if = "Option::is_none")] 52 + pub embed_images: Option<Vec<DraftEmbedImage>>, 53 + #[serde(skip_serializing_if = "Option::is_none")] 54 + pub embed_videos: Option<Vec<DraftEmbedVideo>>, 55 + #[serde(skip_serializing_if = "Option::is_none")] 56 + pub embed_externals: Option<Vec<DraftEmbedExternals>>, 57 + #[serde(skip_serializing_if = "Option::is_none")] 58 + pub embed_records: Option<Vec<DraftEmbedRecord>>, 59 + } 60 + 61 + #[derive(Clone, Debug, Deserialize, Serialize)] 62 + #[serde(rename_all = "camelCase")] 63 + pub struct DraftEmbedImage { 64 + pub local_ref: DraftEmbedLocalRef, 65 + pub alt: Option<String>, 66 + } 67 + 68 + #[derive(Clone, Debug, Deserialize, Serialize)] 69 + #[serde(rename_all = "camelCase")] 70 + pub struct DraftEmbedVideo { 71 + pub local_ref: DraftEmbedLocalRef, 72 + pub alt: Option<String>, 73 + pub captions: Option<Vec<DraftEmbedCaption>>, 74 + } 75 + 76 + #[derive(Clone, Debug, Deserialize, Serialize)] 77 + #[serde(rename_all = "camelCase")] 78 + pub struct DraftEmbedCaption { 79 + pub lang: String, 80 + pub content: String, 81 + } 82 + 83 + #[derive(Clone, Debug, Deserialize, Serialize)] 84 + pub struct DraftEmbedExternals { 85 + pub uri: String, 86 + } 87 + 88 + #[derive(Clone, Debug, Deserialize, Serialize)] 89 + pub struct DraftEmbedRecord { 90 + pub record: StrongRef, 91 + } 92 + 93 + #[derive(Clone, Debug, Deserialize, Serialize)] 94 + pub struct DraftEmbedLocalRef { 95 + pub path: String, 96 + }
+53
crates/lexica/src/app_bsky/feed.rs
··· 242 242 #[serde(rename = "app.bsky.feed.defs#skeletonReasonRepost")] 243 243 Repost { repost: String }, 244 244 } 245 + 246 + pub const POSTGATE_RULE_DISABLE: &str = "app.bsky.feed.postgate#disableRule"; 247 + 248 + #[derive(Copy, Clone, Debug, PartialEq, PartialOrd, Deserialize, Serialize)] 249 + #[serde(tag = "$type")] 250 + pub enum PostgateEmbeddingRules { 251 + #[serde(rename = "app.bsky.feed.postgate#disableRule")] 252 + Disable, 253 + } 254 + 255 + impl PostgateEmbeddingRules { 256 + pub fn as_str(&self) -> &'static str { 257 + match self { 258 + PostgateEmbeddingRules::Disable => POSTGATE_RULE_DISABLE, 259 + } 260 + } 261 + 262 + pub fn from_str(input: &str) -> Option<Self> { 263 + match input { 264 + POSTGATE_RULE_DISABLE => Some(PostgateEmbeddingRules::Disable), 265 + _ => None, 266 + } 267 + } 268 + } 269 + 270 + pub const THREADGATE_RULE_MENTION: &str = "app.bsky.feed.threadgate#mentionRule"; 271 + pub const THREADGATE_RULE_FOLLOWER: &str = "app.bsky.feed.threadgate#followerRule"; 272 + pub const THREADGATE_RULE_FOLLOWING: &str = "app.bsky.feed.threadgate#followingRule"; 273 + pub const THREADGATE_RULE_LIST: &str = "app.bsky.feed.threadgate#listRule"; 274 + 275 + #[derive(Clone, Debug, Deserialize, Serialize)] 276 + #[serde(tag = "$type")] 277 + pub enum ThreadgateRule { 278 + #[serde(rename = "app.bsky.feed.threadgate#mentionRule")] 279 + Mention, 280 + #[serde(rename = "app.bsky.feed.threadgate#followerRule")] 281 + Follower, 282 + #[serde(rename = "app.bsky.feed.threadgate#followingRule")] 283 + Following, 284 + #[serde(rename = "app.bsky.feed.threadgate#listRule")] 285 + List { list: String }, 286 + } 287 + 288 + impl ThreadgateRule { 289 + pub fn as_str(&self) -> &'static str { 290 + match self { 291 + ThreadgateRule::Mention => THREADGATE_RULE_MENTION, 292 + ThreadgateRule::Follower => THREADGATE_RULE_FOLLOWER, 293 + ThreadgateRule::Following => THREADGATE_RULE_FOLLOWING, 294 + ThreadgateRule::List { .. } => THREADGATE_RULE_LIST, 295 + } 296 + } 297 + }
+1
crates/lexica/src/app_bsky/mod.rs
··· 2 2 3 3 pub mod actor; 4 4 pub mod bookmark; 5 + pub mod draft; 5 6 pub mod embed; 6 7 pub mod feed; 7 8 pub mod graph;
+38
crates/parakeet-db/src/models.rs
··· 431 431 pub sort_at: DateTime<Utc>, 432 432 } 433 433 434 + #[derive(Debug, Queryable, Selectable, Identifiable)] 435 + #[diesel(table_name = crate::schema::drafts)] 436 + #[diesel(primary_key(did, draft_id))] 437 + #[diesel(check_for_backend(diesel::pg::Pg))] 438 + pub struct Draft { 439 + pub did: String, 440 + pub draft_id: String, 441 + 442 + pub device_id: Option<String>, 443 + pub device_name: Option<String>, 444 + 445 + pub posts: serde_json::Value, 446 + pub languages: Option<not_null_vec::TextArray>, 447 + pub postgate_embedding_rules: Option<not_null_vec::TextArray>, 448 + pub threadgate_allow: Option<not_null_vec::TextArray>, 449 + pub threadgate_allowed_lists: Option<not_null_vec::TextArray>, 450 + 451 + pub created_at: DateTime<Utc>, 452 + pub updated_at: DateTime<Utc>, 453 + } 454 + 455 + #[derive(Debug, Insertable, AsChangeset)] 456 + #[diesel(table_name = crate::schema::drafts)] 457 + #[diesel(check_for_backend(diesel::pg::Pg))] 458 + pub struct NewDraft<'a> { 459 + pub did: &'a str, 460 + pub draft_id: &'a str, 461 + 462 + pub device_id: Option<String>, 463 + pub device_name: Option<String>, 464 + 465 + pub posts: serde_json::Value, 466 + pub languages: Option<Vec<String>>, 467 + pub postgate_embedding_rules: Option<Vec<String>>, 468 + pub threadgate_allow: Option<Vec<String>>, 469 + pub threadgate_allowed_lists: Option<Vec<String>>, 470 + } 471 + 434 472 pub use not_null_vec::TextArray; 435 473 mod not_null_vec { 436 474 use diesel::deserialize::FromSql;
+18
crates/parakeet-db/src/schema.rs
··· 74 74 } 75 75 76 76 diesel::table! { 77 + drafts (did, draft_id) { 78 + did -> Text, 79 + draft_id -> Text, 80 + device_id -> Nullable<Text>, 81 + device_name -> Nullable<Text>, 82 + posts -> Jsonb, 83 + languages -> Nullable<Array<Nullable<Text>>>, 84 + postgate_embedding_rules -> Nullable<Array<Nullable<Text>>>, 85 + threadgate_allow -> Nullable<Array<Nullable<Text>>>, 86 + threadgate_allowed_lists -> Nullable<Array<Nullable<Text>>>, 87 + created_at -> Timestamptz, 88 + updated_at -> Timestamptz, 89 + } 90 + } 91 + 92 + diesel::table! { 77 93 feedgens (at_uri) { 78 94 at_uri -> Text, 79 95 cid -> Text, ··· 416 432 diesel::joinable!(blocks -> actors (did)); 417 433 diesel::joinable!(bookmarks -> actors (did)); 418 434 diesel::joinable!(chat_decls -> actors (did)); 435 + diesel::joinable!(drafts -> actors (did)); 419 436 diesel::joinable!(feedgens -> actors (owner)); 420 437 diesel::joinable!(follows -> actors (did)); 421 438 diesel::joinable!(labeler_defs -> labelers (labeler)); ··· 448 465 blocks, 449 466 bookmarks, 450 467 chat_decls, 468 + drafts, 451 469 feedgens, 452 470 follows, 453 471 labeler_defs,
+281
crates/parakeet/src/xrpc/app_bsky/draft.rs
··· 1 + use crate::utils::DateTimeExt; 2 + use crate::xrpc::error::{Error, XrpcResult}; 3 + use crate::xrpc::extract::AtpAuth; 4 + use crate::xrpc::CursorQuery; 5 + use crate::GlobalState; 6 + use axum::extract::{Query, State}; 7 + use axum::Json; 8 + use diesel::prelude::*; 9 + use diesel_async::RunQueryDsl; 10 + use jacquard_common::types::tid::Tid; 11 + use lexica::app_bsky::draft::{Draft, DraftView, DraftWithId}; 12 + use lexica::app_bsky::feed::{ 13 + PostgateEmbeddingRules, ThreadgateRule, THREADGATE_RULE_FOLLOWER, THREADGATE_RULE_FOLLOWING, 14 + THREADGATE_RULE_MENTION, 15 + }; 16 + use parakeet_db::{models, schema}; 17 + use reqwest::StatusCode; 18 + use serde::{Deserialize, Serialize}; 19 + use serde_json::{from_value, to_value}; 20 + 21 + const DRAFT_LIMIT: i64 = 100; 22 + 23 + #[derive(Debug, Deserialize)] 24 + pub struct CreateDraftReq { 25 + pub draft: Draft, 26 + } 27 + 28 + #[derive(Debug, Serialize)] 29 + pub struct CreateDraftRes { 30 + pub id: String, 31 + } 32 + 33 + pub async fn create_draft( 34 + State(state): State<GlobalState>, 35 + auth: AtpAuth, 36 + Json(form): Json<CreateDraftReq>, 37 + ) -> XrpcResult<Json<CreateDraftRes>> { 38 + let mut conn = state.pool.get().await?; 39 + 40 + let draft_count: i64 = schema::drafts::table 41 + .select(diesel::dsl::count_star()) 42 + .filter(schema::drafts::did.eq(&auth.0)) 43 + .get_result(&mut conn) 44 + .await?; 45 + if draft_count >= DRAFT_LIMIT { 46 + return Err(Error::new( 47 + StatusCode::BAD_REQUEST, 48 + "DraftLimitReached", 49 + None, 50 + )); 51 + } 52 + 53 + let draft_id = Tid::now_0(); 54 + 55 + let posts = to_value(form.draft.posts).map_err(|err| { 56 + tracing::error!("failed to serialize json {err}"); 57 + Error::server_error(None) 58 + })?; 59 + 60 + let postgate_embedding_rules = form 61 + .draft 62 + .postgate_embedding_rules 63 + .map(|v| v.iter().map(|v| v.as_str().to_string()).collect::<Vec<_>>()); 64 + 65 + let threadgate_allowed_lists = form.draft.threadgate_allow.as_ref().map(|allow| { 66 + allow 67 + .iter() 68 + .filter_map(|rule| match rule { 69 + ThreadgateRule::List { list } => Some(list.clone()), 70 + _ => None, 71 + }) 72 + .collect::<Vec<_>>() 73 + }); 74 + 75 + let threadgate_allow = form.draft.threadgate_allow.map(|allow| { 76 + allow 77 + .into_iter() 78 + .map(|v| v.as_str().to_string()) 79 + .collect::<Vec<_>>() 80 + }); 81 + 82 + let data = models::NewDraft { 83 + did: &auth.0, 84 + draft_id: &draft_id, 85 + device_id: form.draft.device_id, 86 + device_name: form.draft.device_name, 87 + posts, 88 + languages: form.draft.langs, 89 + postgate_embedding_rules, 90 + threadgate_allow, 91 + threadgate_allowed_lists, 92 + }; 93 + 94 + diesel::insert_into(schema::drafts::table) 95 + .values(&data) 96 + .execute(&mut conn) 97 + .await?; 98 + 99 + Ok(Json(CreateDraftRes { 100 + id: draft_id.to_string(), 101 + })) 102 + } 103 + 104 + #[derive(Debug, Deserialize)] 105 + pub struct DeleteDraftReq { 106 + pub id: String, 107 + } 108 + 109 + pub async fn delete_draft( 110 + State(state): State<GlobalState>, 111 + auth: AtpAuth, 112 + Json(form): Json<DeleteDraftReq>, 113 + ) -> XrpcResult<()> { 114 + let mut conn = state.pool.get().await?; 115 + 116 + diesel::delete(schema::drafts::table) 117 + .filter( 118 + schema::drafts::did 119 + .eq(&auth.0) 120 + .and(schema::drafts::draft_id.eq(form.id)), 121 + ) 122 + .execute(&mut conn) 123 + .await?; 124 + 125 + Ok(()) 126 + } 127 + 128 + #[derive(Debug, Serialize)] 129 + pub struct GetDraftRes { 130 + #[serde(skip_serializing_if = "Option::is_none")] 131 + cursor: Option<String>, 132 + drafts: Vec<DraftView>, 133 + } 134 + 135 + pub async fn get_drafts( 136 + State(state): State<GlobalState>, 137 + auth: AtpAuth, 138 + Query(query): Query<CursorQuery>, 139 + ) -> XrpcResult<Json<GetDraftRes>> { 140 + let mut conn = state.pool.get().await?; 141 + 142 + let limit = query.limit.unwrap_or(50).clamp(1, 100); 143 + 144 + let mut drafts_query = schema::drafts::table 145 + .select(models::Draft::as_select()) 146 + .filter(schema::drafts::did.eq(&auth.0)) 147 + .into_boxed(); 148 + 149 + if let Some(cursor) = query.cursor { 150 + drafts_query = drafts_query.filter(schema::drafts::draft_id.lt(cursor)); 151 + } 152 + 153 + let results = drafts_query 154 + .order(schema::drafts::draft_id.desc()) 155 + .limit(limit as i64) 156 + .load(&mut conn) 157 + .await?; 158 + 159 + let cursor = results.last().map(|v| v.draft_id.clone()); 160 + 161 + let drafts = results 162 + .into_iter() 163 + .filter_map(|draft| { 164 + let postgate_embedding_rules = draft.postgate_embedding_rules.map(|per| { 165 + per.iter() 166 + .filter_map(|r| PostgateEmbeddingRules::from_str(r)) 167 + .collect() 168 + }); 169 + 170 + let tg_allow_lists = draft.threadgate_allowed_lists.map(|lists| { 171 + lists 172 + .iter() 173 + .map(|list| ThreadgateRule::List { list: list.clone() }) 174 + .collect::<Vec<_>>() 175 + }); 176 + let tg_allow = draft.threadgate_allow.map(|rules| { 177 + rules 178 + .iter() 179 + .filter_map(|rule| match rule.as_str() { 180 + THREADGATE_RULE_MENTION => Some(ThreadgateRule::Mention), 181 + THREADGATE_RULE_FOLLOWER => Some(ThreadgateRule::Follower), 182 + THREADGATE_RULE_FOLLOWING => Some(ThreadgateRule::Following), 183 + _ => None, 184 + }) 185 + .collect::<Vec<_>>() 186 + }); 187 + 188 + let threadgate_allow = match (tg_allow, tg_allow_lists) { 189 + (Some(mut tg_allow), Some(tg_allow_lists)) => { 190 + tg_allow.extend(tg_allow_lists); 191 + Some(tg_allow) 192 + } 193 + (Some(tg_allow), None) => Some(tg_allow), 194 + (None, Some(tg_allow_lists)) => Some(tg_allow_lists), 195 + _ => None, 196 + }; 197 + 198 + let inner = Draft { 199 + device_id: draft.device_id, 200 + device_name: draft.device_name, 201 + posts: from_value(draft.posts).ok()?, 202 + langs: draft.languages.map(|v| v.into()), 203 + postgate_embedding_rules, 204 + threadgate_allow, 205 + }; 206 + 207 + Some(DraftView { 208 + id: draft.draft_id, 209 + draft: inner, 210 + created_at: draft.created_at.into_jacquard(), 211 + updated_at: draft.updated_at.into_jacquard(), 212 + }) 213 + }) 214 + .collect(); 215 + 216 + Ok(Json(GetDraftRes { cursor, drafts })) 217 + } 218 + 219 + #[derive(Debug, Deserialize)] 220 + pub struct UpdateDraftReq { 221 + pub draft: DraftWithId, 222 + } 223 + 224 + pub async fn update_draft( 225 + State(state): State<GlobalState>, 226 + auth: AtpAuth, 227 + Json(form): Json<UpdateDraftReq>, 228 + ) -> XrpcResult<()> { 229 + let mut conn = state.pool.get().await?; 230 + let draft = form.draft.draft; 231 + 232 + let posts = to_value(draft.posts).map_err(|err| { 233 + tracing::error!("failed to serialize json {err}"); 234 + Error::server_error(None) 235 + })?; 236 + 237 + let postgate_embedding_rules = draft 238 + .postgate_embedding_rules 239 + .map(|v| v.iter().map(|v| v.as_str().to_string()).collect::<Vec<_>>()); 240 + 241 + let threadgate_allowed_lists = draft.threadgate_allow.as_ref().map(|allow| { 242 + allow 243 + .iter() 244 + .filter_map(|rule| match rule { 245 + ThreadgateRule::List { list } => Some(list.clone()), 246 + _ => None, 247 + }) 248 + .collect::<Vec<_>>() 249 + }); 250 + 251 + let threadgate_allow = draft.threadgate_allow.map(|allow| { 252 + allow 253 + .into_iter() 254 + .map(|v| v.as_str().to_string()) 255 + .collect::<Vec<_>>() 256 + }); 257 + 258 + let data = models::NewDraft { 259 + did: &auth.0, 260 + draft_id: &form.draft.id, 261 + device_id: draft.device_id, 262 + device_name: draft.device_name, 263 + posts, 264 + languages: draft.langs, 265 + postgate_embedding_rules, 266 + threadgate_allow, 267 + threadgate_allowed_lists, 268 + }; 269 + 270 + diesel::update(schema::drafts::table) 271 + .set((&data, schema::drafts::updated_at.eq(diesel::dsl::now))) 272 + .filter( 273 + schema::drafts::did 274 + .eq(&auth.0) 275 + .and(schema::drafts::draft_id.eq(&form.draft.id)), 276 + ) 277 + .execute(&mut conn) 278 + .await?; 279 + 280 + Ok(()) 281 + }
+5
crates/parakeet/src/xrpc/app_bsky/mod.rs
··· 3 3 4 4 mod actor; 5 5 mod bookmark; 6 + mod draft; 6 7 mod feed; 7 8 mod graph; 8 9 mod labeler; ··· 21 22 .route("/app.bsky.bookmark.createBookmark", post(bookmark::create_bookmark)) 22 23 .route("/app.bsky.bookmark.deleteBookmark", post(bookmark::delete_bookmark)) 23 24 .route("/app.bsky.bookmark.getBookmarks", get(bookmark::get_bookmarks)) 25 + .route("/app.bsky.draft.createDraft", post(draft::create_draft)) 26 + .route("/app.bsky.draft.deleteDraft", post(draft::delete_draft)) 27 + .route("/app.bsky.draft.getDrafts", get(draft::get_drafts)) 28 + .route("/app.bsky.draft.updateDraft", post(draft::update_draft)) 24 29 .route("/app.bsky.feed.getActorFeeds", get(feed::feedgen::get_actor_feeds)) 25 30 .route("/app.bsky.feed.getActorLikes", get(feed::likes::get_actor_likes)) 26 31 .route("/app.bsky.feed.getAuthorFeed", get(feed::posts::get_author_feed))
+1
migrations/2026-03-18-175443-0000_drafts/down.sql
··· 1 + drop table drafts;
+18
migrations/2026-03-18-175443-0000_drafts/up.sql
··· 1 + create table drafts ( 2 + did text not null references actors (did), 3 + draft_id text not null, -- draft_id is an rkey 4 + 5 + device_id text, 6 + device_name text, 7 + 8 + posts jsonb not null, 9 + languages text [], 10 + postgate_embedding_rules text [], 11 + threadgate_allow text [], 12 + threadgate_allowed_lists text [], 13 + 14 + created_at timestamptz not null default now (), 15 + updated_at timestamptz not null default now (), 16 + 17 + primary key (did, draft_id) 18 + ) ;