Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ The `kind` integer is the only dispatch switch. The relay routes, stores, and fa
| 45003 | KIND_FORUM_COMMENT | Forum thread reply |
| 46001–46012 | KIND_WORKFLOW_* | Workflow execution events |
| 20001 | KIND_PRESENCE_UPDATE | Ephemeral presence heartbeat |
| 20003 | KIND_AGENT_PRESENCE | Ephemeral presence heartbeat an owner sends for its agents |

`buzz-core` defines each event kind as a `pub const u32` and exports the full registry as `ALL_KINDS: &[u32]` (127 kinds at the time of writing); `crates/buzz-core/src/kind.rs` is the source of truth for the current list. Kinds are `u32` (NIP-01 specifies unsigned integer; `u32` covers the full range). Buzz uses both standard Nostr kinds (e.g., kind 7 for reactions) and custom ranges (40000+).

Expand Down Expand Up @@ -305,6 +306,8 @@ Ephemeral events bypass DB storage, audit, and search. Two sub-paths:
```
Presence events skip membership checks and use local-only fan-out. Multi-node presence fan-out would require Redis pub/sub (documented as future work).

**Agent presence (kind 20003):** an owner's one heartbeat for all its agents, so an app that runs agents without a socket per agent can still show them online. Content is the status (`online`, `away` or `offline`) and each `p` tag names an agent, at most 256. WebSocket only. The relay keeps the agents the signer owns (`users.agent_owner_pubkey`, through the owner cache), skips the rest, and sets or clears their presence exactly as their own kind 20001 would, so the presence snapshot reports them unchanged. It is not fanned out, since live presence is matched by author; viewers see it on their next snapshot read.

**Other ephemeral events (e.g., typing indicators):**
```
1. VERIFY — spawn_blocking(verify_event)
Expand Down
1 change: 1 addition & 0 deletions NOSTR.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
| **Group members (kind:39002)** | ✅ | Relay-signed; `d` tag + `p` tags for all members |
| **Membership notifications** | ✅ | kind:44100 (added) / kind:44101 (removed); relay-signed, community-global scope (`channel_id=None` inside the connected community) |
| **Presence (kind:20001)** | ✅ | Ephemeral; arbitrary status string (truncated to 128 chars); writes to Redis (`set_presence`/`clear_presence` on `"offline"`), then fan-out to local subscribers. In multi-community mode presence is scoped to the connected community. |
| **Agent presence (kind:20003)** | ✅ | Ephemeral; an owner's presence for the agents it owns, named by `p` tags (at most 256); `online`/`away` set and `offline` clears each agent's presence as its own kind:20001 would. Not fanned out. |
| **Typing indicators (kind:20002)** | ✅ | Ephemeral, not stored; published via Redis pub/sub (multi-node capable unlike presence fan-out) |
| **NIP-42 authentication** | ✅ | Proactive challenge; optional pubkey allowlist |
| **NIP-11 relay info** | ✅ | `GET /` with `Accept: application/nostr+json` |
Expand Down
4 changes: 4 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,9 @@ pub const KIND_PRESENCE_UPDATE: u32 = 20001;
pub const KIND_PAIRING: u32 = 24134;
/// Ephemeral: typing indicator for a channel.
pub const KIND_TYPING_INDICATOR: u32 = 20002;
/// Ephemeral: an owner's presence for its agents. Content is the status, each
/// `p` tag names one agent the signer owns. Never the signer's own presence.
pub const KIND_AGENT_PRESENCE: u32 = 20003;
/// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame.
pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200;
/// Ephemeral: huddle emoji reaction burst. Channel-scoped to the ephemeral
Expand Down Expand Up @@ -703,6 +706,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_THREAD_WINDOW_BOUNDS,
KIND_PRESENCE_UPDATE,
KIND_TYPING_INDICATOR,
KIND_AGENT_PRESENCE,
KIND_HUDDLE_REACTION,
KIND_BLOSSOM_AUTH,
KIND_PAIRING,
Expand Down
29 changes: 29 additions & 0 deletions crates/buzz-db/src/store/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,24 @@ pub async fn list_agents_for_owner(
.await?)
}

/// Of `candidates`, the agents whose `agent_owner_pubkey` is `owner_pubkey` in
/// one community.
pub async fn owned_agents_among(
pool: &PgPool,
community_id: CommunityId,
owner_pubkey: &[u8],
candidates: &[Vec<u8>],
) -> Result<Vec<Vec<u8>>> {
Ok(sqlx::query_scalar::<_, Vec<u8>>(
"SELECT pubkey FROM users WHERE community_id = $1 AND agent_owner_pubkey = $2 AND pubkey = ANY($3)",
)
.bind(community_id.as_uuid())
.bind(owner_pubkey)
.bind(candidates)
.fetch_all(pool)
.await?)
}

/// Check whether `actor_pubkey` is the `agent_owner_pubkey` of `target_pubkey`.
/// Queries `agent_owner_pubkey` directly rather than going through
/// `get_agent_channel_policy`, which would fetch unrelated fields.
Expand Down Expand Up @@ -593,6 +611,17 @@ impl Db {
crate::user::list_agents_for_owner(&self.pool, community_id, owner_pubkey).await
}

/// Of `candidates`, the agents owned by `owner_pubkey` in one community.
#[datastore_span(name = "owned_agents_among", system = "postgresql")]
pub async fn owned_agents_among(
&self,
community_id: CommunityId,
owner_pubkey: &[u8],
candidates: &[Vec<u8>],
) -> Result<Vec<Vec<u8>>> {
crate::user::owned_agents_among(&self.pool, community_id, owner_pubkey, candidates).await
}

/// Check whether `actor_pubkey` is the agent owner of `target_pubkey`.
#[datastore_span(name = "is_agent_owner", system = "postgresql")]
pub async fn is_agent_owner(
Expand Down
19 changes: 19 additions & 0 deletions crates/buzz-pubsub/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,25 @@ impl PubSubManager {
presence::clear_presence(&self.pool, ctx, pubkey).await
}

/// Set one status with the 180s TTL for each of `pubkeys`, in one round trip.
pub async fn set_presence_many(
&self,
ctx: &TenantContext,
pubkeys: &[PublicKey],
status: &str,
) -> Result<(), PubSubError> {
presence::set_presence_many(&self.pool, ctx, pubkeys, status).await
}

/// Remove presence for each of `pubkeys`.
pub async fn clear_presence_many(
&self,
ctx: &TenantContext,
pubkeys: &[PublicKey],
) -> Result<(), PubSubError> {
presence::clear_presence_many(&self.pool, ctx, pubkeys).await
}

/// Returns the current presence status for `pubkey`, or `None` if not set.
pub async fn get_presence(
&self,
Expand Down
91 changes: 91 additions & 0 deletions crates/buzz-pubsub/src/presence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,53 @@ pub async fn clear_presence(
Ok(())
}

/// Sets one status for every pubkey in `pubkeys`, each with the
/// [`PRESENCE_TTL_SECS`] TTL, in one atomic round trip.
pub async fn set_presence_many(
pool: &Pool,
ctx: &TenantContext,
pubkeys: &[PublicKey],
status: &str,
) -> Result<(), PubSubError> {
if pubkeys.is_empty() {
return Ok(());
}
let mut conn = pool.get().await?;
let mut pipe = redis::pipe();
pipe.atomic();
for pubkey in pubkeys {
pipe.cmd("SET")
Comment on lines +73 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the batched presence update atomic

A normal Redis pipeline is only a round-trip optimization, not an atomic transaction. If the connection fails after Redis processes a prefix of these SET commands, query_async returns an error and the relay rejects the heartbeat even though some named agents were updated and others were not; use an atomic pipeline or Lua operation so this single batch cannot leave a torn presence snapshot.

AGENTS.md reference: AGENTS.md:L250-L255

Useful? React with 👍 / 👎.

.arg(presence_key(ctx, pubkey))
.arg(status)
.arg("EX")
.arg(PRESENCE_TTL_SECS)
.ignore();
}
pipe.query_async::<()>(&mut conn).await?;
Ok(())
}

/// Removes the presence entries for every pubkey in `pubkeys`.
pub async fn clear_presence_many(
pool: &Pool,
ctx: &TenantContext,
pubkeys: &[PublicKey],
) -> Result<(), PubSubError> {
if pubkeys.is_empty() {
return Ok(());
}
let mut conn = pool.get().await?;
let keys: Vec<String> = pubkeys
.iter()
.map(|pubkey| presence_key(ctx, pubkey))
.collect();
redis::cmd("DEL")
.arg(&keys)
.query_async::<()>(&mut conn)
.await?;
Ok(())
}

/// Returns the current presence status for `pubkey`, or `None` if not set or expired.
pub async fn get_presence(
pool: &Pool,
Expand Down Expand Up @@ -209,6 +256,50 @@ mod tests {
clear_presence(&pool, &ctx, &pk2).await.unwrap();
}

#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_many_sets_with_ttl_and_clears() {
let pool = make_test_pool();
let pk1 = make_pubkey();
let pk2 = make_pubkey();
let untouched = make_pubkey();
let ctx = ctx(0xaaaa, "a.example");
set_presence(&pool, &ctx, &untouched, "away").await.unwrap();

set_presence_many(&pool, &ctx, &[pk1, pk2], "online")
.await
.unwrap();
let result = get_presence_bulk(&pool, &ctx, &[pk1, pk2, untouched])
.await
.unwrap();
assert_eq!(
result.get(&pk1.to_hex()).map(String::as_str),
Some("online")
);
assert_eq!(
result.get(&pk2.to_hex()).map(String::as_str),
Some("online")
);
assert_eq!(
result.get(&untouched.to_hex()).map(String::as_str),
Some("away")
);
let mut conn = pool.get().await.unwrap();
let ttl: i64 = redis::cmd("TTL")
.arg(presence_key(&ctx, &pk2))
.query_async(&mut conn)
.await
.unwrap();
assert!(ttl > 0 && ttl <= PRESENCE_TTL_SECS as i64, "got TTL {ttl}");

clear_presence_many(&pool, &ctx, &[pk1, pk2]).await.unwrap();
let result = get_presence_bulk(&pool, &ctx, &[pk1, pk2, untouched])
.await
.unwrap();
assert_eq!(result.len(), 1, "only the untouched key remains");
clear_presence(&pool, &ctx, &untouched).await.unwrap();
}

#[tokio::test]
#[ignore = "requires Redis"]
async fn test_presence_ttl() {
Expand Down
Loading
Loading