mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
feat(relay): auto-join new community members to default channels (#8594)
## Summary Adds **default channels**: relay owners and admins can flag channels that every new community member is automatically joined into. **Setting (contract)** - New `channels.auto_join` column (migration `0080`, constant default `false`, so no table rewrite). - kind:9002 accepts `["auto_join", "true"|"false"]`. Authorization comes from the actor's **relay** roster role (owner/admin), not a channel role, so the actor doesn't need to be a channel member. API tokens need `AdminChannels`. - Only open, unarchived `stream` channels qualify. This is checked under the channel row lock, so one event can open and flag a channel in one step, and a concurrent archive can't slip past the check. - kind:39000 carries a bare `auto_join` tag only while the setting is effective (on, open and unarchived). Every change posts one `auto_join_changed` kind:40099 notice. - SDK `build_update_channel(.., auto_join)`, `buzz channels update --auto-join <true|false>`, a NOSTR.md "Default channels" section, and a NIP-CL tag-table row. **Behaviour** - kind:9021's body is pulled out into `join_channel_as_self`, so auto-join uses the same path as a self-join: the channel's `join_role` applies, and it emits `member_joined` 40099, 39000/39002 and 44100. - `auto_join_default_channels` runs inline right after a member is added at three sites: the v2 invite claim, the v1 invite claim, and kind:9030 add. It does not run on login or reconnect, so a later kind:9022 leave stands. - Best effort: each failure is logged and counted (`buzz_auto_join_failures_total`) but never retried, because the roster membership has already committed. This deliberately departs from Review-Proven Rule 1 and is documented in NOSTR.md. `buzz-admin add-member` writes the roster row only. **Communities without a membership roster** (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=false`) - These have no invite claim or kind:9030 to hook, so the three sites above join nobody there. Instead, after the AUTH `OK`, the relay joins a person to every effective default channel they have **never had a membership row in** (`auto_join_never_joined_channels`, spawned so admission never waits on it). - The channel's own membership history is the once-only record, so this adds no new table, column or migration. A kind:9022 leave or a kind:9001 removal keeps the row with `removed_at`, so the person stays out. A new device or another relay pod finds the row and does nothing. `put_first_member` checks the row again under the `put_member` locks, so two admissions racing each other join once. - A channel flagged later is joined on each person's next admission. A failed membership write leaves no row, so the next admission retries it. - Each channel's join holds the session's NIP-FI effect permit, so once the socket closes or its admission expires, no further channels are joined. - Agents are skipped: any socket with an admitted owner (a NIP-OA tag, a NIP-FI agent token, or a stored `agent_owner_pubkey`). - The kind:9021 side effects moved into `announce_self_join`, so both join paths emit the same `member_joined`, 39000/39002 and 44100. **Review follow-ups** - `get_accessible_channels` now selects `auto_join`. Without it, every call failed with `ColumnNotFound`. - The final authorization branch of the 9002 validator now refuses by default. `list_auto_join_channels` filters to `stream` channels itself. ## Testing - Postgres-lane journeys: - `edit_metadata_postgres_tests`: who may set `auto_join`, the 40099 notice, the effective-state tag, and the refusals. - `claim_auto_joins_default_channels_once`: v2 and v1 claims, and leave then re-claim. - `adding_a_member_auto_joins_default_channels`: kind:9030 add. - Removing the auto-join call at any one of the three sites fails the matching test (checked by mutation). - `root_auth_joins_people_to_default_channels_without_a_roster`: through `handle_auth` on a roster-less community. A person is joined, a NIP-OA agent is not, and a leave holds when the person is admitted again. Mutations checked: removing the spawn, and dropping the agent check. Each check waits for that admission's own auto-join outcome (a skip reports through the same test hook as a finished task) and fails if none arrives. A third mutation drops the agent check and delays the task 3s; it still fails. - `admission_auto_join_stops_when_the_session_ends`: closing the socket before the first or second channel stops the remaining joins; a later admission joins what was skipped. Mutation checked: dropping the effect permit. - `first_member_put_joins_once_and_never_undoes_a_leave` (buzz-db): the person is joined once with the channel's `join_role` (`guest` on an announce channel), a second put does nothing, after a leave the channel is neither listed nor joined, and two racing puts write exactly once. Mutations checked: removing the row check, removing the channel lock, and removing the `NOT EXISTS`. - e2e `test_invite_claim_auto_joins_default_channel` against a local relay. - Unit coverage in the SDK and CLI builders and `group_metadata_tags`. ## What got simpler The roster-less path adds no schema and no new concept: it reuses the channel's membership history as its record. It also shares the kind:9021 join code. It replaces the per-device client workarounds, [buzz-app#2029](https://github.com/block/buzz-app/pull/2029) and [buzz#8795](https://github.com/block/buzz/pull/8795), which are now closed. ## Related - Client half (buzz-app): https://github.com/block/buzz-app/pull/1624 ## Demo Recorded against a local relay built from this branch with throwaway identities (Alice = owner, Bob = new member); no real accounts or hosted relays involved. Relay at 141aa01d4, client at buzz-app bb0d97d86 (browser dev build). **Owner turns on "Starter channel for new members" on two channels.** Alice creates `#general` and `#welcome`, then flips the switch on each (~0:25 and ~0:41). The switch greys out while the kind 9002 is in flight and only turns on once the republished 39000 carries the `auto_join` tag. https://github.com/user-attachments/assets/e590d343-11d3-4385-af43-2cfaf19f8468 **A brand-new member joins and lands in a starter channel.** Bob claims Alice's invite with empty storage; from ~0:18 he is reading `#welcome` with both `#general` and `#welcome` in the sidebar, and at 0:27 opens `#general` as a member with no Join prompt. The relay logged one `auto-joined default channel` line per flagged channel. https://github.com/user-attachments/assets/037a0aaa-b25d-474d-a316-8d25e5e54f89 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: tornquist <tornquist@squareup.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1640,6 +1640,7 @@ fn validate_update_channel_fields(
|
||||
ttl_change: Option<Option<i32>>,
|
||||
join_role: Option<buzz_sdk::ChannelJoinRole>,
|
||||
unlisted: Option<bool>,
|
||||
auto_join: Option<bool>,
|
||||
) -> Result<(), CliError> {
|
||||
if name.is_none()
|
||||
&& description.is_none()
|
||||
@@ -1647,9 +1648,10 @@ fn validate_update_channel_fields(
|
||||
&& ttl_change.is_none()
|
||||
&& join_role.is_none()
|
||||
&& unlisted.is_none()
|
||||
&& auto_join.is_none()
|
||||
{
|
||||
return Err(CliError::Usage(
|
||||
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role, --unlisted)"
|
||||
"at least one field required (--name, --description, --visibility, --ttl, --no-ttl, --join-role, --unlisted, --auto-join)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
@@ -1667,6 +1669,7 @@ pub async fn cmd_update_channel(
|
||||
no_ttl: bool,
|
||||
join_role: Option<buzz_sdk::ChannelJoinRole>,
|
||||
unlisted: Option<bool>,
|
||||
auto_join: Option<bool>,
|
||||
) -> Result<(), CliError> {
|
||||
// Outer Option: None leaves TTL unchanged. Inner: Some(secs) sets it,
|
||||
// None (from --no-ttl) clears it, making the channel permanent.
|
||||
@@ -1683,6 +1686,7 @@ pub async fn cmd_update_channel(
|
||||
ttl_change,
|
||||
join_role,
|
||||
unlisted,
|
||||
auto_join,
|
||||
)?;
|
||||
let channel_uuid = parse_uuid(channel_id)?;
|
||||
// `--unlisted false` needs no check: a relay without the extension lists
|
||||
@@ -1699,6 +1703,7 @@ pub async fn cmd_update_channel(
|
||||
ttl_change,
|
||||
join_role,
|
||||
unlisted,
|
||||
auto_join,
|
||||
)
|
||||
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
|
||||
|
||||
@@ -2006,6 +2011,7 @@ pub async fn dispatch(
|
||||
no_ttl,
|
||||
join_role,
|
||||
unlisted,
|
||||
auto_join,
|
||||
} => {
|
||||
let visibility = visibility.as_ref().map(|v| v.to_string());
|
||||
cmd_update_channel(
|
||||
@@ -2018,6 +2024,7 @@ pub async fn dispatch(
|
||||
no_ttl,
|
||||
join_role.map(Into::into),
|
||||
unlisted,
|
||||
auto_join,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2207,7 +2214,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_channel_fields_rejects_empty_update() {
|
||||
let result = validate_update_channel_fields(None, None, None, None, None, None);
|
||||
let result = validate_update_channel_fields(None, None, None, None, None, None, None);
|
||||
assert!(matches!(result, Err(CliError::Usage(_))));
|
||||
let msg = result.unwrap_err().to_string();
|
||||
assert!(msg.contains("at least one field required"));
|
||||
@@ -2216,7 +2223,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn update_channel_fields_accepts_visibility_only_update() {
|
||||
let result = validate_update_channel_fields(None, None, Some("open"), None, None, None);
|
||||
let result =
|
||||
validate_update_channel_fields(None, None, Some("open"), None, None, None, None);
|
||||
assert!(result.is_ok(), "visibility-only update should be accepted");
|
||||
}
|
||||
|
||||
@@ -4823,6 +4831,7 @@ mod unlisted_support_tests {
|
||||
false,
|
||||
None,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
@@ -4849,6 +4858,7 @@ mod unlisted_support_tests {
|
||||
false,
|
||||
None,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -4877,6 +4887,7 @@ mod unlisted_support_tests {
|
||||
false,
|
||||
None,
|
||||
Some(false),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -699,9 +699,9 @@ pub enum ChannelsCmd {
|
||||
#[arg(long, value_name = "PATH")]
|
||||
templates_file: Option<String>,
|
||||
},
|
||||
/// Update channel name, description, visibility, join role, unlisted, or ephemeral TTL
|
||||
/// Update channel name, description, visibility, join role, unlisted, auto-join, or ephemeral TTL
|
||||
#[command(
|
||||
after_help = "Examples:\n buzz channels update --channel <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private\n buzz channels update --channel <uuid> --join-role guest\n buzz channels update --channel <uuid> --visibility open --unlisted true"
|
||||
after_help = "Examples:\n buzz channels update --channel <uuid> --name general\n buzz channels update --channel <uuid> --visibility open\n buzz channels update --channel <uuid> --visibility private\n buzz channels update --channel <uuid> --join-role guest\n buzz channels update --channel <uuid> --visibility open --unlisted true\n buzz channels update --channel <uuid> --auto-join true"
|
||||
)]
|
||||
Update {
|
||||
/// Channel UUID
|
||||
@@ -735,6 +735,11 @@ pub enum ChannelsCmd {
|
||||
/// does not advertise `buzz-unlisted-channels`.
|
||||
#[arg(long, value_name = "BOOL", action = clap::ArgAction::Set)]
|
||||
unlisted: Option<bool>,
|
||||
/// Whether the relay joins every new community member into this
|
||||
/// channel (a default channel). Relay owner/admin only; accepted only
|
||||
/// on an open, unarchived stream channel.
|
||||
#[arg(long, value_name = "true|false")]
|
||||
auto_join: Option<bool>,
|
||||
},
|
||||
/// Set the channel topic
|
||||
Topic {
|
||||
|
||||
Reference in New Issue
Block a user