tangled
alpha
login
or
join now
parakeet.at
/
parakeet
63
fork
atom
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
63
fork
atom
overview
issues
12
pulls
pipelines
feat: app.bsky.notification.declaration
mia.omg.lol
7 months ago
973e70b6
045771c2
+119
-10
10 changed files
expand all
collapse all
unified
split
consumer
src
db
record.rs
indexer
mod.rs
records.rs
types.rs
lexica
src
app_bsky
actor.rs
migrations
2025-07-21-173906_notif-decl
down.sql
up.sql
parakeet
src
hydration
profile.rs
loaders.rs
parakeet-db
src
schema.rs
+16
consumer/src/db/record.rs
···
256
.await
257
}
258
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
259
pub async fn post_insert<C: GenericClient>(
260
conn: &mut C,
261
at_uri: &str,
···
256
.await
257
}
258
259
+
pub async fn notif_decl_upsert<C: GenericClient>(
260
+
conn: &mut C,
261
+
repo: &str,
262
+
rec: AppBskyNotificationDeclaration,
263
+
) -> PgExecResult {
264
+
conn.execute(
265
+
"INSERT INTO notif_decl (did, allow_subscriptions) VALUES ($1, $2) ON CONFLICT (did) DO UPDATE SET allow_subscriptions=EXCLUDED.allow_subscriptions",
266
+
&[&repo, &rec.allow_subscriptions.to_string()],
267
+
).await
268
+
}
269
+
270
+
pub async fn notif_decl_delete<C: GenericClient>(conn: &mut C, repo: &str) -> PgExecResult {
271
+
conn.execute("DELETE FROM notif_decl WHERE did=$1", &[&repo])
272
+
.await
273
+
}
274
+
275
pub async fn post_insert<C: GenericClient>(
276
conn: &mut C,
277
at_uri: &str,
+6
consumer/src/indexer/mod.rs
···
688
}
689
}
690
}
0
0
0
0
0
691
RecordTypes::ChatBskyActorDeclaration(record) => {
692
if rkey == "self" {
693
db::chat_decl_upsert(conn, repo, record).await?;
···
775
}
776
CollectionType::BskyVerification => db::verification_delete(conn, at_uri).await?,
777
CollectionType::BskyLabelerService => db::labeler_delete(conn, at_uri).await?,
0
778
CollectionType::ChatActorDecl => db::chat_decl_delete(conn, repo).await?,
779
_ => unreachable!(),
780
};
···
688
}
689
}
690
}
691
+
RecordTypes::AppBskyNotificationDeclaration(record) => {
692
+
if rkey == "self" {
693
+
db::notif_decl_upsert(conn, repo, record).await?;
694
+
}
695
+
}
696
RecordTypes::ChatBskyActorDeclaration(record) => {
697
if rkey == "self" {
698
db::chat_decl_upsert(conn, repo, record).await?;
···
780
}
781
CollectionType::BskyVerification => db::verification_delete(conn, at_uri).await?,
782
CollectionType::BskyLabelerService => db::labeler_delete(conn, at_uri).await?,
783
+
CollectionType::BskyNotificationDeclaration => db::notif_decl_delete(conn, repo).await?,
784
CollectionType::ChatActorDecl => db::chat_decl_delete(conn, repo).await?,
785
_ => unreachable!(),
786
};
+7
-1
consumer/src/indexer/records.rs
···
1
use crate::utils;
2
use chrono::{DateTime, Utc};
3
use ipld_core::cid::Cid;
4
-
use lexica::app_bsky::actor::{ChatAllowIncoming, Status};
5
use lexica::app_bsky::embed::AspectRatio;
6
use lexica::app_bsky::labeler::LabelerPolicy;
7
use lexica::app_bsky::richtext::FacetMain;
···
405
pub subject_types: Option<Vec<SubjectType>>,
406
pub subject_collections: Option<Vec<String>>,
407
pub created_at: DateTime<Utc>,
0
0
0
0
0
0
408
}
409
410
#[derive(Debug, Deserialize, Serialize)]
···
1
use crate::utils;
2
use chrono::{DateTime, Utc};
3
use ipld_core::cid::Cid;
4
+
use lexica::app_bsky::actor::{ChatAllowIncoming, ProfileAllowSubscriptions, Status};
5
use lexica::app_bsky::embed::AspectRatio;
6
use lexica::app_bsky::labeler::LabelerPolicy;
7
use lexica::app_bsky::richtext::FacetMain;
···
405
pub subject_types: Option<Vec<SubjectType>>,
406
pub subject_collections: Option<Vec<String>>,
407
pub created_at: DateTime<Utc>,
408
+
}
409
+
410
+
#[derive(Debug, Deserialize, Serialize)]
411
+
#[serde(rename_all = "camelCase")]
412
+
pub struct AppBskyNotificationDeclaration {
413
+
pub allow_subscriptions: ProfileAllowSubscriptions,
414
}
415
416
#[derive(Debug, Deserialize, Serialize)]
+5
consumer/src/indexer/types.rs
···
37
AppBskyGraphVerification(records::AppBskyGraphVerification),
38
#[serde(rename = "app.bsky.labeler.service")]
39
AppBskyLabelerService(records::AppBskyLabelerService),
0
0
40
#[serde(rename = "chat.bsky.actor.declaration")]
41
ChatBskyActorDeclaration(records::ChatBskyActorDeclaration),
42
}
···
59
BskyStarterPack,
60
BskyVerification,
61
BskyLabelerService,
0
62
ChatActorDecl,
63
Unsupported,
64
}
···
82
"app.bsky.graph.starterpack" => CollectionType::BskyStarterPack,
83
"app.bsky.graph.verification" => CollectionType::BskyVerification,
84
"app.bsky.labeler.service" => CollectionType::BskyLabelerService,
0
85
"chat.bsky.actor.declaration" => CollectionType::ChatActorDecl,
86
_ => CollectionType::Unsupported,
87
}
···
106
CollectionType::BskyStarterPack => true,
107
CollectionType::BskyVerification => false,
108
CollectionType::BskyLabelerService => true,
0
109
CollectionType::Unsupported => false,
110
}
111
}
···
37
AppBskyGraphVerification(records::AppBskyGraphVerification),
38
#[serde(rename = "app.bsky.labeler.service")]
39
AppBskyLabelerService(records::AppBskyLabelerService),
40
+
#[serde(rename = "app.bsky.notification.declaration")]
41
+
AppBskyNotificationDeclaration(records::AppBskyNotificationDeclaration),
42
#[serde(rename = "chat.bsky.actor.declaration")]
43
ChatBskyActorDeclaration(records::ChatBskyActorDeclaration),
44
}
···
61
BskyStarterPack,
62
BskyVerification,
63
BskyLabelerService,
64
+
BskyNotificationDeclaration,
65
ChatActorDecl,
66
Unsupported,
67
}
···
85
"app.bsky.graph.starterpack" => CollectionType::BskyStarterPack,
86
"app.bsky.graph.verification" => CollectionType::BskyVerification,
87
"app.bsky.labeler.service" => CollectionType::BskyLabelerService,
88
+
"app.bsky.notification.declaration" => CollectionType::BskyNotificationDeclaration,
89
"chat.bsky.actor.declaration" => CollectionType::ChatActorDecl,
90
_ => CollectionType::Unsupported,
91
}
···
110
CollectionType::BskyStarterPack => true,
111
CollectionType::BskyVerification => false,
112
CollectionType::BskyLabelerService => true,
113
+
CollectionType::BskyNotificationDeclaration => true,
114
CollectionType::Unsupported => false,
115
}
116
}
+39
lexica/src/app_bsky/actor.rs
···
14
pub labeler: bool,
15
#[serde(skip_serializing_if = "Option::is_none")]
16
pub chat: Option<ProfileAssociatedChat>,
0
0
17
}
18
19
#[derive(Clone, Debug, Serialize)]
···
48
"all" => Ok(ChatAllowIncoming::All),
49
"none" => Ok(ChatAllowIncoming::None),
50
"following" => Ok(ChatAllowIncoming::Following),
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
51
x => Err(format!("Unrecognized variant {x}")),
52
}
53
}
···
14
pub labeler: bool,
15
#[serde(skip_serializing_if = "Option::is_none")]
16
pub chat: Option<ProfileAssociatedChat>,
17
+
#[serde(skip_serializing_if = "Option::is_none")]
18
+
pub activity_subscription: Option<ProfileAssociatedActivitySubscription>,
19
}
20
21
#[derive(Clone, Debug, Serialize)]
···
50
"all" => Ok(ChatAllowIncoming::All),
51
"none" => Ok(ChatAllowIncoming::None),
52
"following" => Ok(ChatAllowIncoming::Following),
53
+
x => Err(format!("Unrecognized variant {x}")),
54
+
}
55
+
}
56
+
}
57
+
58
+
#[derive(Clone, Debug, Serialize)]
59
+
#[serde(rename_all = "camelCase")]
60
+
pub struct ProfileAssociatedActivitySubscription {
61
+
pub allow_subscriptions: ProfileAllowSubscriptions,
62
+
}
63
+
64
+
#[derive(Copy, Clone, Debug, Deserialize, Serialize)]
65
+
#[serde(rename_all = "lowercase")]
66
+
pub enum ProfileAllowSubscriptions {
67
+
Followers,
68
+
Mutuals,
69
+
None,
70
+
}
71
+
72
+
impl Display for ProfileAllowSubscriptions {
73
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74
+
match self {
75
+
ProfileAllowSubscriptions::Followers => write!(f, "followers"),
76
+
ProfileAllowSubscriptions::Mutuals => write!(f, "mutuals"),
77
+
ProfileAllowSubscriptions::None => write!(f, "none"),
78
+
}
79
+
}
80
+
}
81
+
82
+
impl FromStr for ProfileAllowSubscriptions {
83
+
type Err = String;
84
+
85
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
86
+
match s {
87
+
"none" => Ok(ProfileAllowSubscriptions::None),
88
+
"mutuals" => Ok(ProfileAllowSubscriptions::Mutuals),
89
+
"following" => Ok(ProfileAllowSubscriptions::Followers),
90
x => Err(format!("Unrecognized variant {x}")),
91
}
92
}
+1
migrations/2025-07-21-173906_notif-decl/down.sql
···
0
···
1
+
drop table notif_decl;
+6
migrations/2025-07-21-173906_notif-decl/up.sql
···
0
0
0
0
0
0
···
1
+
create table notif_decl
2
+
(
3
+
did text primary key references actors (did),
4
+
allow_subscriptions text,
5
+
indexed_at timestamp not null default now()
6
+
);
+10
parakeet-db/src/schema.rs
···
166
}
167
168
diesel::table! {
0
0
0
0
0
0
0
0
169
post_embed_ext (post_uri) {
170
post_uri -> Text,
171
uri -> Text,
···
359
diesel::joinable!(likes -> actors (did));
360
diesel::joinable!(list_blocks -> actors (did));
361
diesel::joinable!(lists -> actors (owner));
0
362
diesel::joinable!(post_embed_ext -> posts (post_uri));
363
diesel::joinable!(post_embed_images -> posts (post_uri));
364
diesel::joinable!(post_embed_record -> posts (post_uri));
···
388
list_blocks,
389
list_items,
390
lists,
0
391
post_embed_ext,
392
post_embed_images,
393
post_embed_record,
···
166
}
167
168
diesel::table! {
169
+
notif_decl (did) {
170
+
did -> Text,
171
+
allow_subscriptions -> Nullable<Text>,
172
+
indexed_at -> Timestamp,
173
+
}
174
+
}
175
+
176
+
diesel::table! {
177
post_embed_ext (post_uri) {
178
post_uri -> Text,
179
uri -> Text,
···
367
diesel::joinable!(likes -> actors (did));
368
diesel::joinable!(list_blocks -> actors (did));
369
diesel::joinable!(lists -> actors (owner));
370
+
diesel::joinable!(notif_decl -> actors (did));
371
diesel::joinable!(post_embed_ext -> posts (post_uri));
372
diesel::joinable!(post_embed_images -> posts (post_uri));
373
diesel::joinable!(post_embed_record -> posts (post_uri));
···
397
list_blocks,
398
list_items,
399
lists,
400
+
notif_decl,
401
post_embed_ext,
402
post_embed_images,
403
post_embed_record,
+10
-6
parakeet/src/hydration/profile.rs
···
17
chat: Option<ChatAllowIncoming>,
18
labeler: bool,
19
stats: Option<ProfileStats>,
0
20
) -> Option<ProfileAssociated> {
21
if chat.is_some() || labeler || stats.is_some() {
22
let stats = stats.unwrap_or_default();
···
27
starter_packs: stats.starterpacks as i64,
28
labeler,
29
chat: chat.map(|v| ProfileAssociatedChat { allow_incoming: v }),
0
0
0
30
})
31
} else {
32
None
···
148
}
149
150
fn build_basic(
151
-
(handle, profile, chat_decl, is_labeler, stats, status): ProfileLoaderRet,
152
labels: Vec<models::Label>,
153
verifications: Option<Vec<models::VerificationEntry>>,
154
cdn: &BskyCdn,
155
) -> ProfileViewBasic {
156
-
let associated = build_associated(chat_decl, is_labeler, stats);
157
let verification = build_verification(&profile, &handle, verifications);
158
let status = status.and_then(|status| build_status(status, cdn));
159
let avatar = profile.avatar_cid.map(|cid| cdn.avatar(&profile.did, &cid));
···
172
}
173
174
fn build_profile(
175
-
(handle, profile, chat_decl, is_labeler, stats, status): ProfileLoaderRet,
176
labels: Vec<models::Label>,
177
verifications: Option<Vec<models::VerificationEntry>>,
178
cdn: &BskyCdn,
179
) -> ProfileView {
180
-
let associated = build_associated(chat_decl, is_labeler, stats);
181
let verification = build_verification(&profile, &handle, verifications);
182
let status = status.and_then(|status| build_status(status, cdn));
183
let avatar = profile.avatar_cid.map(|cid| cdn.avatar(&profile.did, &cid));
···
198
}
199
200
fn build_detailed(
201
-
(handle, profile, chat_decl, is_labeler, stats, status): ProfileLoaderRet,
202
labels: Vec<models::Label>,
203
verifications: Option<Vec<models::VerificationEntry>>,
204
cdn: &BskyCdn,
205
) -> ProfileViewDetailed {
206
-
let associated = build_associated(chat_decl, is_labeler, stats);
207
let verification = build_verification(&profile, &handle, verifications);
208
let status = status.and_then(|status| build_status(status, cdn));
209
let avatar = profile.avatar_cid.map(|cid| cdn.avatar(&profile.did, &cid));
···
17
chat: Option<ChatAllowIncoming>,
18
labeler: bool,
19
stats: Option<ProfileStats>,
20
+
notif: Option<ProfileAllowSubscriptions>,
21
) -> Option<ProfileAssociated> {
22
if chat.is_some() || labeler || stats.is_some() {
23
let stats = stats.unwrap_or_default();
···
28
starter_packs: stats.starterpacks as i64,
29
labeler,
30
chat: chat.map(|v| ProfileAssociatedChat { allow_incoming: v }),
31
+
activity_subscription: notif.map(|v| ProfileAssociatedActivitySubscription {
32
+
allow_subscriptions: v,
33
+
}),
34
})
35
} else {
36
None
···
152
}
153
154
fn build_basic(
155
+
(handle, profile, chat_decl, is_labeler, stats, status, notif_decl): ProfileLoaderRet,
156
labels: Vec<models::Label>,
157
verifications: Option<Vec<models::VerificationEntry>>,
158
cdn: &BskyCdn,
159
) -> ProfileViewBasic {
160
+
let associated = build_associated(chat_decl, is_labeler, stats, notif_decl);
161
let verification = build_verification(&profile, &handle, verifications);
162
let status = status.and_then(|status| build_status(status, cdn));
163
let avatar = profile.avatar_cid.map(|cid| cdn.avatar(&profile.did, &cid));
···
176
}
177
178
fn build_profile(
179
+
(handle, profile, chat_decl, is_labeler, stats, status, notif_decl): ProfileLoaderRet,
180
labels: Vec<models::Label>,
181
verifications: Option<Vec<models::VerificationEntry>>,
182
cdn: &BskyCdn,
183
) -> ProfileView {
184
+
let associated = build_associated(chat_decl, is_labeler, stats, notif_decl);
185
let verification = build_verification(&profile, &handle, verifications);
186
let status = status.and_then(|status| build_status(status, cdn));
187
let avatar = profile.avatar_cid.map(|cid| cdn.avatar(&profile.did, &cid));
···
202
}
203
204
fn build_detailed(
205
+
(handle, profile, chat_decl, is_labeler, stats, status, notif_decl): ProfileLoaderRet,
206
labels: Vec<models::Label>,
207
verifications: Option<Vec<models::VerificationEntry>>,
208
cdn: &BskyCdn,
209
) -> ProfileViewDetailed {
210
+
let associated = build_associated(chat_decl, is_labeler, stats, notif_decl);
211
let verification = build_verification(&profile, &handle, verifications);
212
let status = status.and_then(|status| build_status(status, cdn));
213
let avatar = profile.avatar_cid.map(|cid| cdn.avatar(&profile.did, &cid));
+19
-3
parakeet/src/loaders.rs
···
5
use diesel_async::pooled_connection::deadpool::Pool;
6
use diesel_async::{AsyncPgConnection, RunQueryDsl};
7
use itertools::Itertools;
8
-
use lexica::app_bsky::actor::ChatAllowIncoming;
9
use parakeet_db::{models, schema};
10
use std::collections::HashMap;
11
use std::str::FromStr;
···
74
bool,
75
Option<parakeet_index::ProfileStats>,
76
Option<models::Status>,
0
77
);
78
impl BatchFn<String, ProfileLoaderRet> for ProfileLoader {
79
async fn load(&mut self, keys: &[String]) -> HashMap<String, ProfileLoaderRet> {
···
86
)
87
.left_join(schema::labelers::table.on(schema::labelers::did.eq(schema::actors::did)))
88
.left_join(schema::statuses::table.on(schema::statuses::did.eq(schema::actors::did)))
0
0
0
89
.select((
90
schema::actors::did,
91
schema::actors::handle,
···
93
schema::chat_decls::allow_incoming.nullable(),
94
schema::labelers::cid.nullable(),
95
Option::<models::Status>::as_select(),
0
96
))
97
.filter(
98
schema::actors::did
···
106
Option<String>,
107
Option<String>,
108
Option<models::Status>,
0
109
)>(&mut conn)
110
.await;
111
···
122
123
match res {
124
Ok(res) => HashMap::from_iter(res.into_iter().map(
125
-
|(did, handle, profile, chat_decl, labeler_cid, status)| {
126
let chat_decl = chat_decl.and_then(|v| ChatAllowIncoming::from_str(&v).ok());
0
0
127
let is_labeler = labeler_cid.is_some();
128
let maybe_stats = stats.remove(&did);
129
130
-
let val = (handle, profile, chat_decl, is_labeler, maybe_stats, status);
0
0
0
0
0
0
0
0
131
132
(did, val)
133
},
···
5
use diesel_async::pooled_connection::deadpool::Pool;
6
use diesel_async::{AsyncPgConnection, RunQueryDsl};
7
use itertools::Itertools;
8
+
use lexica::app_bsky::actor::{ChatAllowIncoming, ProfileAllowSubscriptions};
9
use parakeet_db::{models, schema};
10
use std::collections::HashMap;
11
use std::str::FromStr;
···
74
bool,
75
Option<parakeet_index::ProfileStats>,
76
Option<models::Status>,
77
+
Option<ProfileAllowSubscriptions>,
78
);
79
impl BatchFn<String, ProfileLoaderRet> for ProfileLoader {
80
async fn load(&mut self, keys: &[String]) -> HashMap<String, ProfileLoaderRet> {
···
87
)
88
.left_join(schema::labelers::table.on(schema::labelers::did.eq(schema::actors::did)))
89
.left_join(schema::statuses::table.on(schema::statuses::did.eq(schema::actors::did)))
90
+
.left_join(
91
+
schema::notif_decl::table.on(schema::notif_decl::did.eq(schema::actors::did)),
92
+
)
93
.select((
94
schema::actors::did,
95
schema::actors::handle,
···
97
schema::chat_decls::allow_incoming.nullable(),
98
schema::labelers::cid.nullable(),
99
Option::<models::Status>::as_select(),
100
+
schema::notif_decl::allow_subscriptions.nullable(),
101
))
102
.filter(
103
schema::actors::did
···
111
Option<String>,
112
Option<String>,
113
Option<models::Status>,
114
+
Option<String>,
115
)>(&mut conn)
116
.await;
117
···
128
129
match res {
130
Ok(res) => HashMap::from_iter(res.into_iter().map(
131
+
|(did, handle, profile, chat_decl, labeler_cid, status, notif_decl)| {
132
let chat_decl = chat_decl.and_then(|v| ChatAllowIncoming::from_str(&v).ok());
133
+
let notif_decl =
134
+
notif_decl.and_then(|v| ProfileAllowSubscriptions::from_str(&v).ok());
135
let is_labeler = labeler_cid.is_some();
136
let maybe_stats = stats.remove(&did);
137
138
+
let val = (
139
+
handle,
140
+
profile,
141
+
chat_decl,
142
+
is_labeler,
143
+
maybe_stats,
144
+
status,
145
+
notif_decl,
146
+
);
147
148
(did, val)
149
},