NAME

Net::Nostr::Group - NIP-29 relay-based groups

SYNOPSIS

use Net::Nostr::Group;
use Net::Nostr::Key;

my $key = Net::Nostr::Key->new;

# Format and parse a group identifier (naddr for kind 39000 metadata)
my $group_naddr = Net::Nostr::Group->format_id(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    relay    => 'wss://groups.nostr.com',
);

my $parsed = Net::Nostr::Group->parse_id($group_naddr);
# { group_id => 'pizza', pubkey => $relay_pubkey, kind => 39000, ... }

# Validate a group_id
Net::Nostr::Group->validate_group_id('my-group_1');  # 1
Net::Nostr::Group->validate_group_id('Pizza Fans');   # 1
Net::Nostr::Group->validate_group_id('');             # 0

# Join a group (kind 9021)
my $join = Net::Nostr::Group->join_request(
    pubkey   => $key->pubkey_hex,
    group_id => 'pizza',
    reason   => 'I love pizza',
    code     => 'invite-abc',  # optional invite code
);
$key->sign_event($join);
$client->publish($join);

# Leave a group (kind 9022)
my $leave = Net::Nostr::Group->leave_request(
    pubkey   => $key->pubkey_hex,
    group_id => 'pizza',
);

# Add a user to a group (kind 9000, admin)
my $put = Net::Nostr::Group->put_user(
    pubkey   => $key->pubkey_hex,
    group_id => 'pizza',
    target   => $user_pubkey,
    roles    => ['moderator'],
    reason   => 'promoted',
);

# Remove a user (kind 9001, admin)
my $rm = Net::Nostr::Group->remove_user(
    pubkey   => $key->pubkey_hex,
    group_id => 'pizza',
    target   => $user_pubkey,
    reason   => 'spamming',
);

# Edit group metadata (kind 9002, admin)
my $edit = Net::Nostr::Group->edit_metadata(
    pubkey   => $key->pubkey_hex,
    group_id => 'pizza',
    name     => 'Pizza Lovers',
    about    => 'We love pizza',
    private  => 1,
    closed   => 1,
);

# Delete an event from the group (kind 9005, admin)
my $del = Net::Nostr::Group->delete_event(
    pubkey   => $key->pubkey_hex,
    group_id => 'pizza',
    event_id => $spam_event_id,
);

# Create a group (kind 9007)
my $create = Net::Nostr::Group->create_group(
    pubkey   => $key->pubkey_hex,
    group_id => 'new-group',
);

# Delete a group (kind 9008)
my $delete = Net::Nostr::Group->delete_group(
    pubkey   => $key->pubkey_hex,
    group_id => 'old-group',
);

# Create an invite code (kind 9009)
my $invite = Net::Nostr::Group->create_invite(
    pubkey   => $key->pubkey_hex,
    group_id => 'pizza',
    code     => 'secret-code-123',
);

# Generate group metadata (kind 39000, relay-generated)
my $meta = Net::Nostr::Group->metadata(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    name     => 'Pizza Lovers',
    picture  => 'https://pizza.com/pizza.png',
    about    => 'a group for pizza fans',
    private  => 1,
    closed   => 1,
    livekit  => 1,
    supported_kinds => [9, 11],
);

# Generate admin list (kind 39001, relay-generated)
my $admin_event = Net::Nostr::Group->admins(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    members  => [
        { pubkey => $admin_pk, roles => ['admin'] },
        { pubkey => $mod_pk,   roles => ['moderator'] },
    ],
);

# Generate member list (kind 39002, relay-generated)
my $member_event = Net::Nostr::Group->members(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    members  => [$pk1, $pk2, $pk3],
);

# Generate roles list (kind 39003, relay-generated)
my $role_event = Net::Nostr::Group->roles(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    roles    => [
        { name => 'admin', description => 'full control' },
        { name => 'moderator', description => 'can delete messages' },
    ],
);

# Generate LiveKit participant list (kind 39004, relay-generated)
my $participant_event = Net::Nostr::Group->participants(
    pubkey       => $relay_pubkey,
    group_id     => 'pizza',
    participants => [$pk1, $pk2],
);

# Parse received events
my $meta_info    = Net::Nostr::Group->metadata_from_event($event);
my $admin_info   = Net::Nostr::Group->admins_from_event($event);
my $member_info  = Net::Nostr::Group->members_from_event($event);
my $role_info    = Net::Nostr::Group->roles_from_event($event);
my $part_info    = Net::Nostr::Group->participants_from_event($event);
my $gid          = Net::Nostr::Group->group_id_from_event($event);

DESCRIPTION

Implements NIP-29 relay-based groups. Groups have arbitrary non-empty group_id strings in event h and d tags. Public group identifiers are naddr references to the group's kind 39000 metadata event. Group state is managed through moderation events (kinds 9000-9010) and user events (kinds 9021-9022). Relay-generated group state is published as addressable events (kinds 39000-39005) signed by the relay.

This module supplies event and identifier helpers. Net::Nostr::RelayGroups provides an optional relay policy for moderation, subgroup trees, and pins. Net::Nostr::GroupDiscovery coordinates cached-admin migration and fork lookups; applications supply its discovery transport and user interaction.

All user and moderation events MUST include an h tag with the group id. Group metadata events use a d tag instead.

To store a user's list of groups, use a kind 10009 Net::Nostr::List with group and r tags per NIP-51:

use Net::Nostr::List;

my $groups = Net::Nostr::List->new(kind => 10009);
my $group_id = Net::Nostr::Group->format_id(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    relay    => 'wss://groups.nostr.com',
);
$groups->add('group', $group_id, 'wss://groups.nostr.com', 'Pizza Lovers');
$groups->add('r', 'wss://groups.nostr.com');
my $event = $groups->to_event(pubkey => $key->pubkey_hex);

CLASS METHODS

parse_id

Accepts the optional ?invite=... suffix. The result includes invite when present; pass it as code to "join_request". Validates the bech32 metadata reference, suffix shape, percent encoding, and UTF-8. Empty codes, repeated invite parameters, fragments, and control characters are rejected. An ordinary identifier continues to parse without an invite key.

my $parsed = Net::Nostr::Group->parse_id($naddr);
# { group_id => 'pizza', pubkey => $relay_pubkey, kind => 39000, ... }

Parses a group identifier naddr that references a kind 39000 metadata event. Returns the raw group_id, relay pubkey, kind, relay hints, and the first relay hint as relay. Croaks for legacy host-based identifiers, invalid bech32 data, empty group IDs, or naddr values that do not reference kind 39000. The returned reference is structurally validated; it does not prove that the relay hosts the group.

format_id

Optional invite accepts a non-empty string without control characters and appends a percent-encoded UTF-8 query value. The bech32 prefix remains a valid standalone group reference. Relay hints and the referenced public key retain the validation performed by "encode_naddr" in Net::Nostr::Bech32.

my $id = Net::Nostr::Group->format_id(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    relay    => 'wss://groups.nostr.com',
);
# naddr1...

Formats a public group identifier as an naddr referencing the group's kind 39000 metadata event. pubkey is the relay's self pubkey. relay or relays may be supplied as relay hints. relays must be an arrayref. This strict builder rejects empty group IDs and unknown options.

validate_group_id

Net::Nostr::Group->validate_group_id('my-group');  # 1
Net::Nostr::Group->validate_group_id('Pizza Fans'); # 1
Net::Nostr::Group->validate_group_id('');           # 0

Returns true if the group id is a defined non-empty scalar. Current NIP-29 does not restrict group ids to a specific character set.

put_user

my $event = Net::Nostr::Group->put_user(
    pubkey   => $hex_pubkey,
    group_id => 'pizza',
    target   => $user_pubkey,
    roles    => ['admin', 'moderator'],  # optional
    reason   => 'promoted',              # optional
    previous => ['abcd1234'],            # optional timeline refs
);

Creates a kind 9000 moderation event to add a user to the group or update their roles. The p tag contains the target pubkey followed by any role strings.

remove_user

my $event = Net::Nostr::Group->remove_user(
    pubkey   => $hex_pubkey,
    group_id => 'pizza',
    target   => $user_pubkey,
    reason   => 'spamming',  # optional
);

Creates a kind 9001 moderation event to remove a user from the group.

All user and moderation event builders (put_user, remove_user, edit_metadata, delete_event, create_group, delete_group, create_invite, join_request, leave_request) accept an optional previous parameter for timeline references. When defined it must be an array of eight-character lowercase hex strings; an empty array adds no tag. The relay must check that referenced events exist. See put_user for an example.

edit_metadata

Also accepts banner, parent, and children. banner is a display string. parent is a non-empty group ID distinct from this group; omission requests promotion to a root. children is an array of distinct non-empty group IDs in display order, excluding this group itself. The builder validates these structural rules. Relay authorization, existence of the parent, cycles, and completeness of the child list require the relay's group state. Also accepts livekit and supported_kinds, as described under "metadata". The Core builder can describe AV support even when the chosen relay does not provide it; Net::Nostr::RelayGroups rejects AV edits.

my $event = Net::Nostr::Group->edit_metadata(
    pubkey       => $hex_pubkey,
    group_id     => 'pizza',
    name         => 'Pizza Lovers',       # optional
    picture      => 'https://pic.url',    # optional
    about        => 'description',        # optional
    private      => 1,                    # optional flag
    restricted   => 1,                    # optional flag
    hidden       => 1,                    # optional flag
    closed       => 1,                    # optional flag
    supported_kinds => [9, 11],           # optional text event kinds
);

Creates a kind 9002 moderation event to update group metadata. Metadata fields become tags. Supplied flags must be 0 or 1; true flags become single-element tags. To reverse flags, use public, unrestricted, visible, or open. Supplying both members of an opposing pair as true croaks. Display fields must be defined scalar strings. This is a strict structural builder returning an unsigned event; it does not authorize edits.

delete_event

my $event = Net::Nostr::Group->delete_event(
    pubkey   => $hex_pubkey,
    group_id => 'pizza',
    event_id => $event_id_hex,
    reason   => 'spam content',  # optional
);

Creates a kind 9005 moderation event to delete an event from the group.

create_group

my $event = Net::Nostr::Group->create_group(
    pubkey   => $hex_pubkey,
    group_id => 'new-group',
);

Creates a kind 9007 event requesting the relay to create a new group.

delete_group

my $event = Net::Nostr::Group->delete_group(
    pubkey   => $hex_pubkey,
    group_id => 'old-group',
    reason   => 'inactive',  # optional
);

Creates a kind 9008 event requesting the relay to delete a group.

create_invite

my $event = Net::Nostr::Group->create_invite(
    pubkey   => $hex_pubkey,
    group_id => 'pizza',
    code     => 'secret-code-123',
);

Creates a kind 9009 event with an invite code for the group.

join_request

my $event = Net::Nostr::Group->join_request(
    pubkey   => $hex_pubkey,
    group_id => 'pizza',
    reason   => 'I love pizza',   # optional
    code     => 'invite-abc',     # optional invite code
);

Creates a kind 9021 join request event. The optional code tag can be used with invite codes created by create_invite.

leave_request

my $event = Net::Nostr::Group->leave_request(
    pubkey   => $hex_pubkey,
    group_id => 'pizza',
    reason   => 'moving on',  # optional
);

Creates a kind 9022 leave request event.

metadata

Supports the same banner, parent, and ordered children fields and structural checks as "edit_metadata". It builds an unsigned metadata event; only the hosting relay's key should sign the result.

my $event = Net::Nostr::Group->metadata(
    pubkey     => $relay_pubkey,
    group_id   => 'pizza',
    name       => 'Pizza Lovers',
    picture    => 'https://pizza.com/pizza.png',
    about      => 'a group for pizza fans',
    private    => 1,    # only members can read
    restricted => 1,    # only members can write
    hidden     => 1,    # hide metadata from non-members
    closed     => 1,    # ignore join requests
    livekit    => 1,    # group supports LiveKit A/V rooms
    supported_kinds => [9, 11], # text event kinds supported by the group
);

Creates a kind 39000 addressable event describing group metadata. This event should be signed by the relay's master key. Uses a d tag (not h) with the group id. supported_kinds, when supplied, must be an arrayref of integer kinds from 0 through 65535. An empty array emits an empty tag (no supported text kinds); omission leaves kinds unlimited. Display fields must be defined scalar strings and flags must be 0 or 1. Group IDs must be non-empty. This builder checks structure, including subgroup references, but does not verify relay ownership or other groups' state.

admins

my $event = Net::Nostr::Group->admins(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    content  => 'admin list',  # optional
    members  => [
        { pubkey => $pk, roles => ['admin'] },
    ],
);

Creates a kind 39001 addressable event listing group admins with roles.

members

my $event = Net::Nostr::Group->members(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    content  => 'member list',  # optional
    members  => [$pk1, $pk2],
);

Creates a kind 39002 addressable event listing group members.

roles

my $event = Net::Nostr::Group->roles(
    pubkey   => $relay_pubkey,
    group_id => 'pizza',
    content  => 'role definitions',  # optional
    roles    => [
        { name => 'admin', description => 'full control' },
        { name => 'moderator' },
    ],
);

Creates a kind 39003 addressable event listing supported roles.

participants

my $event = Net::Nostr::Group->participants(
    pubkey       => $relay_pubkey,
    group_id     => 'pizza',
    content      => 'participant list',  # optional
    participants => [$pk1, $pk2],
);

Creates a kind 39004 addressable event listing current LiveKit participants. participants must be an arrayref. This event should be signed by the relay's master key.

update_pin_list

my $pins = [['e', $event_id], ['a', "30023:$alice_pk:post"]];
my $update = Net::Nostr::Group->update_pin_list(
    pubkey => $alice_pk, group_id => 'pizza', pins => $pins,
);
my $announcement = Net::Nostr::Group->pinned_events(
    pubkey => $relay_pk, group_id => 'pizza', pins => $pins,
);
my $decoded = Net::Nostr::Group->pins_from_event($announcement);

Builds an unsigned kind 9010 moderation event. Requires pubkey, group_id, and pins, an array of two-element tags. Each tag must be ['e', $id] with a 64-character lowercase hex event ID or ['a', $coordinate] referencing an addressable kind (30000 through 39999). The order is preserved; an empty array clears the pins. Accepts the usual reason, previous, and event fields. Invalid references, tag shapes, and missing fields croak. Authorization and a relay's optional pin count limit are enforced by the relay. previous must be an array of eight-character lowercase hex timeline references. The relay checks whether those references belong to its history.

pinned_events

Builds unsigned relay metadata of kind 39005. Requires pubkey, group_id, and pins, validated exactly as in "update_pin_list". Emits a d tag and the ordered full pin list with empty content. Sign with the hosting relay key.

pins_from_event

Parses kind 9010 or 39005 into { group_id => $id, pins => \@tags }. Validates the kind, exactly one non-empty h or d group tag as appropriate, and every pin reference and tag shape. A previous tag is permitted and its values must be eight-character lowercase hex strings; other tag names are rejected. Returns copied pin arrays. It does not authenticate the signature or check group administration; verify the event before trusting it. Parsing then rebuilding preserves the pin order, including an empty list.

metadata_from_event

The result includes banner, parent, and ordered children when present. Requires exactly one non-empty d tag. Recognized display, parent, flag, and supported-kind tags must have the correct shape and occur at most once. Self references, duplicate children, and invalid supported kinds croak. Unknown named tags are ignored. These are structural checks; validating a complete relay tree requires its other metadata and administrator lists.

my $meta = Net::Nostr::Group->metadata_from_event($event);
# { group_id => '...', name => '...', picture => '...',
#   about => '...', private => 1, closed => 1 }

Parses a kind 39000 event. Returns a hashref with group metadata. Boolean flags (private, restricted, hidden, closed, livekit) are set to 1 when present. supported_kinds is returned as an arrayref when present. Requires a Net::Nostr::Event of kind 39000; does not authenticate its ID or signature. Authenticate remote metadata before trusting it. The returned fields require no later structural validation.

admins_from_event

my $result = Net::Nostr::Group->admins_from_event($event);
# { group_id => '...', admins => [{ pubkey => '...', roles => [...] }] }

Parses a kind 39001 event. Returns a hashref with group_id and an arrayref of admin entries. Croaks if the event is not kind 39001.

for my $admin (@{$result->{admins}}) {
    say "$admin->{pubkey}: " . join(', ', @{$admin->{roles}});
}

members_from_event

my $result = Net::Nostr::Group->members_from_event($event);
# { group_id => '...', members => [$pk1, $pk2] }

Parses a kind 39002 event. Returns a hashref with group_id and an arrayref of member pubkeys. Croaks if the event is not kind 39002.

roles_from_event

my $result = Net::Nostr::Group->roles_from_event($event);
# { group_id => '...', roles => [{ name => '...', description => '...' }] }

Parses a kind 39003 event. Returns a hashref with group_id and an arrayref of role definitions. Croaks if the event is not kind 39003.

for my $role (@{$result->{roles}}) {
    say "$role->{name}: $role->{description}";
}

participants_from_event

my $result = Net::Nostr::Group->participants_from_event($event);
# { group_id => '...', participants => [$pk1, $pk2] }

Parses a kind 39004 event. Returns a hashref with group_id and an arrayref of participant pubkeys. Croaks if the event is not kind 39004.

group_id_from_event

my $gid = Net::Nostr::Group->group_id_from_event($event);

Extracts the group id from an event's h tag (user/moderation events) or d tag (metadata events). If both tags are present, the h tag takes priority. Returns undef if neither is found.

my $gid = Net::Nostr::Group->group_id_from_event($join_event);
say "Group: $gid";

SEE ALSO

NIP-29, Net::Nostr, Net::Nostr::Event, Net::Nostr::List (kind 10009 group storage)