feat(buzz): auto-invite mentioned non-members into a workspace channel

Mentioning someone who isn't yet a member of a Buzz workspace channel now
adds them (kind-9000 put-user) before the message is published, so the
`@`-mention resolves to a real member — mirroring Buzz's own composer, which
is how you pull a bot into a channel by naming it.

Gated on the sender being able to moderate (only a moderator can issue
kind-9000; it's a silent no-op otherwise), skips self and already-present
members, and is best-effort so a failed add never blocks the message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-23 03:29:27 +00:00
parent 7ae96ae3ce
commit bfc6862be4
@@ -39,6 +39,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.geohashChat.GeohashChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState
import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiSuggestionState
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
@@ -126,6 +127,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
@Stable
open class ChannelNewMessageViewModel :
@@ -530,6 +532,37 @@ open class ChannelNewMessageViewModel :
}
}
/**
* Buzz auto-invite: mentioning someone who isn't yet a member of a Buzz workspace channel adds
* them (kind-9000 put-user) before the message goes out, so the `@`-mention resolves to a real
* member — mirroring Buzz's own composer, which is how you pull a bot into a channel by naming it.
*
* Only a moderator can issue kind-9000, so this is a no-op for a plain member (the relay would
* reject it anyway); self and already-present members are skipped. Best-effort — a failed add
* must never block the message, so each is guarded.
*/
private suspend fun autoInviteMentionedBuzzMembers(
channel: Channel,
mentioned: List<User>?,
) {
if (mentioned.isNullOrEmpty()) return
if (channel !is RelayGroupChannel || !BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) return
val me = accountViewModel.account.userProfile().pubkeyHex
if (!channel.membershipOf(me).canModerate()) return
mentioned.forEach { user ->
val pk = user.pubkeyHex
if (pk != me && channel.membershipOf(pk) == RelayGroupMembership.NONE) {
try {
accountViewModel.account.putRelayGroupUser(channel, pk, emptyList())
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("BuzzAutoInvite", "Failed to add mentioned member ${pk.take(8)}: ${e.message}")
}
}
}
}
// `protected open` so a specialized composer (e.g. the Buzz forum reply) can reuse this whole
// rich EditFieldRow but swap only the event it builds for the composed text.
protected open suspend fun createTemplate(): EventTemplate<out Event>? {
@@ -545,6 +578,8 @@ open class ChannelNewMessageViewModel :
)
tagger.run()
autoInviteMentionedBuzzMembers(channel, tagger.pTags)
val urls = findURLs(messageText)
val usedAttachments = iMetaAttachments.filterIsIn(urls.toSet())
val emojis = accountViewModel.account.emoji.findEmojiTags(messageText)