~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

An account can say it is a bot, and the feed leaves it out

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/profile-bot
.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(
crates/knotless-serve/tests/gateway.rs+14−0
docs/design/serve.md+6−2
docs/reference/api.md+4−3
docs/reference/cli.md+1−1
docs/reference/mcp.md+1−1
lexicons/dev/knotless/getOwnProfile.json+2−1
lexicons/dev/knotless/getProfile.json+1−0
lexicons/dev/knotless/profile.json+1−0
lexicons/dev/knotless/updateProfile.json+2−1
web/src/lib/organisms/ProfileCard.svelte+1−0
web/src/lib/organisms/ProfileForm.svelte+7−1
web/src/app.css+1−0