chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
An account can say it is a bot, and the feed leaves it out
.changeset/profile-bot.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Say an account is a bot | ||
| ||
`knotless profile --bot` says your account is a bot, such as a runner, a triager or a reviewer, and `--no-bot` takes that back. The MCP server's `set_profile` takes `bot`, and the profile settings page has a box for it. A bot's profile shows a "Bot" tag, and the front page's feed leaves out what it says and pushes. | ||
crates/knotless/src/cli.rs+21−1
| @@ -286,9 +286,12 @@ const USAGE: &str = "usage: knotless <command> | ||
your first push adds the one git uses | ||
profile [--name <text>] [--bio <text>] [--pronouns <text>] | ||
[--link <url> [--title <text>]]... [--avatar <picture file>] | ||
[--bot | --no-bot] | ||
how you show on knotless, over your | ||
Bluesky profile; '' shows its again, | ||
and --link '' its links | ||
and --link '' its links; --bot says | ||
the account is a bot, which the feed | ||
leaves out | ||
report <handle, url or at-uri> <reason> [-m <text>] | ||
report to the moderation service; reasons: | ||
spam violation misleading sexual rude other | ||
| @@ -1820,6 +1823,10 @@ async fn command( | ||
"profile" => { | ||
let mut change = profile::Change::default(); | ||
while let Some(flag) = args.rest.next() { | ||
if let Some(bot) = bot_flag(flag) { | ||
change.bot = Some(bot); | ||
continue; | ||
} | ||
let value = args.next("<value>")?.to_string(); | ||
match flag.as_str() { | ||
"--name" => change.name = Some(value), | ||
| @@ -1848,6 +1855,9 @@ async fn command( | ||
let value = value.unwrap_or_else(|| "(as on Bluesky)".into()); | ||
writeln!(out, "{field}: {value}")?; | ||
} | ||
if now.bot { | ||
writeln!(out, "bot: yes")?; | ||
} | ||
if now.links.is_empty() { | ||
writeln!(out, "links: (as on Bluesky)")?; | ||
} | ||
| @@ -2226,6 +2236,16 @@ async fn command( | ||
Ok(()) | ||
} | ||
| ||
/// What `--bot` or `--no-bot` says of `knotless profile`'s account, if | ||
/// `flag` is either. | ||
fn bot_flag(flag: &str) -> Option<bool> { | ||
match flag { | ||
"--bot" => Some(true), | ||
"--no-bot" => Some(false), | ||
_ => None, | ||
} | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
crates/knotless/src/profile.rs+5−0
| @@ -37,6 +37,8 @@ pub struct Change { | ||
pub pronouns: Option<String>, | ||
pub links: Option<Vec<ProfileLink>>, | ||
pub avatar: Option<Vec<u8>>, | ||
/// Whether the account says it is a bot. | ||
pub bot: Option<bool>, | ||
} | ||
| ||
/// `text`, trimmed, unless it is empty or longer than the lexicon allows: | ||
| @@ -100,6 +102,9 @@ pub async fn set(ctx: &Ctx, me: &Session, change: Change) -> Result<ProfileRecor | ||
let checked = links.iter().map(|l| link(&l.url, l.title.as_deref())); | ||
profile.links = checked.collect::<Result<_>>()?; | ||
} | ||
if let Some(bot) = change.bot { | ||
profile.bot = bot; | ||
} | ||
if let Some(bytes) = change.avatar { | ||
profile.avatar = None; | ||
if !bytes.is_empty() { | ||
crates/knotless/src/records.rs+4−0
| @@ -500,6 +500,10 @@ pub struct ProfileRecord { | ||
pub links: Vec<ProfileLink>, | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub avatar: Option<BlobRef>, | ||
/// Whether the account says it is a bot: the feed leaves out what it | ||
/// says and pushes. | ||
#[serde(default, skip_serializing_if = "std::ops::Not::not")] | ||
pub bot: bool, | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub updated_at: Option<String>, | ||
} | ||
crates/knotless/tests/cli.rs+6−0
| @@ -170,6 +170,12 @@ fn setting_a_profile() { | ||
assert!(out.contains("avatar: (as on Bluesky)\nlink: "), "{out}"); | ||
let (_, out) = profile(&["--link", ""]); | ||
assert!(out.ends_with("links: (as on Bluesky)"), "{out}"); | ||
// Said to be a bot, and then not. | ||
let (_, out) = profile(&["--bot"]); | ||
assert!(out.contains("avatar: (as on Bluesky)\nbot: yes\n"), "{out}"); | ||
assert_eq!(s.record("dev.knotless.profile", "self")["bot"], true); | ||
let (_, out) = profile(&["--no-bot"]); | ||
assert!(!out.contains("bot:"), "{out}"); | ||
profile(&["--bio", "", "--pronouns", ""]); | ||
assert_eq!( | ||
s.record("dev.knotless.profile", "self"), | ||
crates/knotless-mcp/src/lib.rs+4−0
| @@ -744,6 +744,9 @@ pub struct SetProfile { | ||
/// Your picture, a PNG, JPEG, WebP or GIF, as base64: one over 1 MB is | ||
/// made smaller. | ||
pub avatar: Option<String>, | ||
/// Whether the account is a bot, such as a runner, a triager or a | ||
/// reviewer: the feed leaves out what it says and pushes. | ||
pub bot: Option<bool>, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
| @@ -2587,6 +2590,7 @@ impl Server { | ||
pronouns: p.pronouns, | ||
links: p.links.map(recorded), | ||
avatar: avatar.transpose()?, | ||
bot: p.bot, | ||
}; | ||
let me = knotless::session::me(&self.ctx).await?; | ||
let now = profile::set(&self.ctx, &me, change).await?; | ||
crates/knotless-serve/src/web/account_settings.rs+5−0
| @@ -99,6 +99,8 @@ pub struct OwnProfile { | ||
links: Vec<knotless::records::ProfileLink>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
avatar: Option<String>, | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
bot: bool, | ||
} | ||
| ||
fn own(did: &str, profile: ProfileRecord) -> OwnProfile { | ||
| @@ -109,6 +111,7 @@ fn own(did: &str, profile: ProfileRecord) -> OwnProfile { | ||
pronouns: profile.pronouns, | ||
links: profile.links, | ||
avatar: avatar.map(absolute), | ||
bot: profile.bot, | ||
} | ||
} | ||
| ||
| @@ -133,6 +136,7 @@ pub struct ProfileInput { | ||
links: Option<Vec<knotless::records::ProfileLink>>, | ||
#[serde(default)] | ||
remove_avatar: bool, | ||
bot: Option<bool>, | ||
} | ||
| ||
/// `dev.knotless.updateProfile`. | ||
| @@ -149,6 +153,7 @@ pub async fn update_profile( | ||
pronouns: input.pronouns, | ||
links: input.links, | ||
avatar: input.remove_avatar.then(Vec::new), | ||
bot: input.bot, | ||
}; | ||
profiled(&app, &did, change).await | ||
} | ||
crates/knotless-serve/src/index.rs+49−4
| @@ -468,6 +468,7 @@ pub(crate) fn connect(path: &Path) -> rusqlite::Result<(Connection, bool)> { | ||
subject TEXT PRIMARY KEY, value TEXT NOT NULL, at TEXT NOT NULL); | ||
CREATE TABLE IF NOT EXISTS emitted (seq INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL); | ||
CREATE TABLE IF NOT EXISTS profile_kept (did TEXT PRIMARY KEY); | ||
CREATE TABLE IF NOT EXISTS bot (did TEXT PRIMARY KEY); | ||
CREATE TABLE IF NOT EXISTS profile_record ( | ||
did TEXT NOT NULL, collection TEXT NOT NULL, rkey TEXT NOT NULL, | ||
value TEXT NOT NULL, PRIMARY KEY (did, collection, rkey)); | ||
| @@ -568,7 +569,7 @@ const CACHE: &str = "DROP TABLE IF EXISTS repo; DROP TABLE IF EXISTS forge; | ||
DROP TABLE IF EXISTS labeler; DROP TABLE IF EXISTS stream; DROP TABLE IF EXISTS check_run; | ||
DROP TABLE IF EXISTS space; DROP TABLE IF EXISTS space_syncer; | ||
DROP TABLE IF EXISTS space_writer; DROP TABLE IF EXISTS space_record; | ||
DROP TABLE IF EXISTS profile_kept; | ||
DROP TABLE IF EXISTS profile_kept; DROP TABLE IF EXISTS bot; | ||
DROP TABLE IF EXISTS profile_record; DROP TABLE IF EXISTS handle; | ||
DROP TABLE IF EXISTS home; | ||
DROP TABLE IF EXISTS star; DROP TABLE IF EXISTS follow; DROP TABLE IF EXISTS comment; | ||
| @@ -681,6 +682,7 @@ const PURGE: &[&str] = &[ | ||
"DELETE FROM seen WHERE did = ?1", | ||
"DELETE FROM pref WHERE did = ?1", | ||
"DELETE FROM profile_kept WHERE did = ?1", | ||
"DELETE FROM bot WHERE did = ?1", | ||
"DELETE FROM profile_record WHERE did = ?1", | ||
"DELETE FROM handle WHERE did = ?1", | ||
"DELETE FROM invite WHERE did = ?1 OR runner = ?1", | ||
| @@ -746,6 +748,15 @@ impl Index { | ||
let uri = format!("at://{did}/{collection}/{rkey}"); | ||
let db = self.db.lock().unwrap(); | ||
let text = value.to_string(); | ||
// Whether an account says it is a bot, of every account, for the | ||
// feed to leave out what bots say and push. | ||
if collection == nsid::PROFILE { | ||
let sql = match value["bot"] == true { | ||
true => "INSERT OR IGNORE INTO bot VALUES (?1)", | ||
false => "DELETE FROM bot WHERE did = ?1", | ||
}; | ||
db.execute(sql, [did])?; | ||
} | ||
// A profile's records, kept only for the accounts whose profiles | ||
// are: those of every other account on the network pass by. | ||
if nsid::PROFILES.contains(&collection) { | ||
| @@ -1099,6 +1110,9 @@ impl Index { | ||
"DELETE FROM profile_record WHERE did = ?1 AND collection = ?2 AND rkey = ?3", | ||
[did, collection, rkey], | ||
)?; | ||
if collection == nsid::PROFILE { | ||
db.execute("DELETE FROM bot WHERE did = ?1", [did])?; | ||
} | ||
if moved > 0 { | ||
self.stamps.mark_all(); | ||
} | ||
| @@ -2962,7 +2976,8 @@ impl Index { | ||
/// it follows wrote, and anything on its repositories and those it | ||
/// starred. With `only`, `pull` or `issue`, those and changes of their | ||
/// state alone; `comment`, comments alone. With `quiet`, of a | ||
/// repository's bots ([`bot_on`]) changes of state alone. | ||
/// repository's bots ([`bot_on`]), and of accounts that say they are | ||
/// bots, changes of state alone. | ||
pub fn recent( | ||
&self, | ||
limit: usize, | ||
| @@ -3011,7 +3026,8 @@ impl Index { | ||
AND json_extract(f.value, '$.subject') LIKE '%/dev.knotless.' || ?6 || '/%')))) | ||
AND coalesce(json_extract(f.value, '$.subject'), '') NOT IN down | ||
AND f.author NOT IN (SELECT subject FROM block WHERE did = f.repo_did) | ||
AND (NOT ?7 OR f.uri LIKE '%/dev.knotless.state/%' OR NOT {BOT}) | ||
AND (NOT ?7 OR f.uri LIKE '%/dev.knotless.state/%' | ||
OR NOT ({BOT} OR EXISTS (SELECT 1 FROM bot WHERE did = f.author))) | ||
AND NOT EXISTS (SELECT 1 FROM owned o | ||
WHERE o.repo_did = f.repo_did AND o.repo_key = f.repo_key | ||
AND ((o.blocks AND o.subject = f.author) | ||
| @@ -3028,7 +3044,8 @@ impl Index { | ||
/// Refs that moved on repositories that stand, newest first; after | ||
/// `cursor`, as [`cursor_at`] makes one, if given: `pusher`'s alone, | ||
/// or those of `followed_by` and whom it follows, if named; with | ||
/// `quiet`, none by the repository's bots ([`bot_on`]). | ||
/// `quiet`, none by the repository's bots ([`bot_on`]), nor by an | ||
/// account that says it is a bot. | ||
pub fn pushes( | ||
&self, | ||
limit: usize, | ||
| @@ -3059,6 +3076,7 @@ impl Index { | ||
AND (NOT ?6 OR NOT {bot})) | ||
WHERE by NOT IN down AND (?4 IS NULL OR by = ?4) | ||
AND (?5 IS NULL OR by = ?5 OR by IN (SELECT subject FROM follow WHERE did = ?5)) | ||
AND (NOT ?6 OR NOT EXISTS (SELECT 1 FROM bot WHERE did = by)) | ||
ORDER BY created_at DESC, uri DESC LIMIT ?1" | ||
))?; | ||
let found = params![limit as i64, at, uri, pusher, followed_by, quiet]; | ||
| @@ -4769,6 +4787,17 @@ mod tests { | ||
.map(|p| p.by) | ||
.collect(); | ||
assert_eq!(by, ["did:a", "did:a", "did:b", "did:b"]); | ||
// Nor of an account that says it is a bot. | ||
let bot = json!({"bot": true}); | ||
index.put("did:b", nsid::PROFILE, "self", &bot).unwrap(); | ||
let by: Vec<String> = index | ||
.pushes(10, None, None, None, true) | ||
.unwrap() | ||
.into_iter() | ||
.map(|p| p.by) | ||
.collect(); | ||
assert_eq!(by, ["did:a", "did:a"]); | ||
index.delete("did:b", nsid::PROFILE, "self").unwrap(); | ||
let of = |did| { | ||
index | ||
.pushes(10, None, Some(did), None, false) | ||
| @@ -5091,6 +5120,22 @@ mod tests { | ||
.collect(); | ||
assert!(kept.len() < recent.len()); | ||
assert_eq!(quiet, kept); | ||
// As of an account that says it is a bot, wherever it writes, | ||
// until it says so no more. | ||
let plain = json!({"name": "r", "key": "k", "createdAt": "2026-01-01T00:00:00Z"}); | ||
index.put("did:a", nsid::REPO, "r", &plain).unwrap(); | ||
let says = |bot: bool| { | ||
let profile = json!({"bot": bot}); | ||
index.put("did:b", nsid::PROFILE, "self", &profile).unwrap(); | ||
let quiet = index.recent(10, None, None, None, None, true).unwrap(); | ||
quiet.into_iter().map(|(e, ..)| e.uri).collect::<Vec<_>>() | ||
}; | ||
assert_eq!(says(true), kept); | ||
assert_eq!(says(false).len(), recent.len()); | ||
says(true); | ||
index.delete("did:b", nsid::PROFILE, "self").unwrap(); | ||
let quiet = index.recent(10, None, None, None, None, true).unwrap(); | ||
assert_eq!(quiet.len(), recent.len()); | ||
| ||
// Gone with the repository. | ||
index.delete("did:a", nsid::REPO, "r").unwrap(); | ||
crates/knotless-serve/src/lib.rs+7−2
| @@ -335,8 +335,12 @@ impl App { | ||
} | ||
// Profiles change all over the network: an edit to one is taken in | ||
// only for an account whose profile is kept, and is no sign that | ||
// its repositories moved. | ||
let profile = |op: &firehose::Op| PROFILES.contains(&op.collection.as_str()); | ||
// its repositories moved. A knotless profile is always taken in, | ||
// for whether it says the account is a bot. | ||
let profile = |op: &firehose::Op| { | ||
let bluesky = op.collection != knotless::records::nsid::PROFILE; | ||
bluesky && PROFILES.contains(&op.collection.as_str()) | ||
}; | ||
let kept = |index: &Arc<index::Index>| index.profile_kept(&did).unwrap_or(false); | ||
let unkept = event.ops.iter().any(profile) && !self.index.as_ref().is_some_and(kept); | ||
if unkept { | ||
| @@ -650,6 +654,7 @@ fn indexed_collections() -> impl Iterator<Item = &'static str> { | ||
knotless::records::nsid::ORG, | ||
knotless::records::nsid::ORG_MEMBER, | ||
knotless::records::nsid::ORG_ACCEPTANCE, | ||
knotless::records::nsid::PROFILE, | ||
] | ||
.into_iter() | ||
.chain(knotless::records::nsid::FORGE) | ||
crates/knotless-serve/src/web.rs+6−0
| @@ -685,6 +685,8 @@ struct About { | ||
pronouns: Option<String>, | ||
links: Vec<ProfileLink>, | ||
avatar: Option<String>, | ||
/// Whether its knotless profile says it is a bot. | ||
bot: bool, | ||
/// Whether both profiles were read: an account whose PDS did not | ||
/// answer shows as having none, but is not kept so. | ||
answered: bool, | ||
| @@ -755,6 +757,7 @@ fn about(did: &str, records: Option<Records>) -> About { | ||
pronouns: own.pronouns.or_else(|| text("pronouns")), | ||
links, | ||
avatar, | ||
bot: own.bot, | ||
answered, | ||
} | ||
} | ||
| @@ -789,6 +792,8 @@ pub struct ProfileView { | ||
viewer: Option<ActorViewerState>, | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
organization: bool, | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
bot: bool, | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
orgs: Vec<orgs::OrgView>, | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
| @@ -906,6 +911,7 @@ pub async fn get_profile( | ||
follows_count: counts.map(|c| c.1), | ||
viewer: viewer.0.as_ref().map(state), | ||
organization: orgs::is_org(&app, remote.did()), | ||
bot: about.bot, | ||
orgs: orgs::orgs_of(&app, remote.did(), viewer.0.as_deref()).await, | ||
badges: match index { | ||
Some(i) => badges::badges_of( | ||