fix: make WakeUp events actually deliver notifications

The WakeUp notification path had three latent bugs that caused pushed
WakeUps to silently not deliver anything to the user:

- WakeUpEvent.build() tagged the about event's AUTHOR as the recipient
  (via EventHintBundle.toPTag()), so a WakeUp about a zap from Alice to
  Bob would p-tag Alice. The consumer matches recipients by p-tag, so
  Bob — the intended recipient — was filtered out. Forward the about
  event's audience (its own p-tags) instead.

- wakeUpFor() passed the WakeUp's own note to EventFinderQueryState. The
  finder's filterMissingEvents only queries for the note itself (already
  in cache) or its replyTo (empty because computeReplyTo had no WakeUp
  case). The referenced events were never REQ'd on relays. Link the
  referenced events via computeReplyTo and subscribe the finder on each
  referenced note directly so filterMissingEvents picks them up.

- UserFinder was subscribed against the WakeUp's own pubKey (typically a
  push bot), not the author of the event the notification is about.
  Pull author pubkeys from the e-tag author hint, falling back to the
  WakeUp signer only when the hint is absent.

Also: bound e-tag count per WakeUp to 16 to prevent subscription
flooding, log the 30s timeout, extract WAKEUP_WINDOW_MS constant, drop
the now-unused Note parameter from wakeUpFor.
This commit is contained in:
Claude
2026-04-24 01:26:50 +00:00
parent 81819b6fbe
commit 3c6b2bec9f
3 changed files with 66 additions and 17 deletions
@@ -923,6 +923,12 @@ object LocalCache : ILocalCache, ICacheProvider {
event.taggedAddresses().map { getOrCreateAddressableNote(it) } event.taggedAddresses().map { getOrCreateAddressableNote(it) }
} }
is WakeUpEvent -> {
// Link the referenced events so filterMissingEvents will query
// for them when the WakeUp note is in an EventFinder subscription.
event.eventIds().mapNotNull { checkGetOrCreateNote(it) }
}
is ChannelMessageEvent -> { is ChannelMessageEvent -> {
event.tagsWithoutCitations().filter { it != event.channelId() }.mapNotNull { checkGetOrCreateNote(it) } event.tagsWithoutCitations().filter { it != event.channelId() }.mapNotNull { checkGetOrCreateNote(it) }
} }
@@ -89,6 +89,11 @@ class EventNotificationConsumer(
) { ) {
companion object { companion object {
private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes private const val WAKELOCK_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes
private const val WAKEUP_WINDOW_MS = 30_000L
// Upper bound on referenced events we'll chase per WakeUp. Guards against
// a malicious sender opening hundreds of subscriptions per wake-up.
private const val MAX_WAKEUP_REFS = 16
} }
/** /**
@@ -162,7 +167,7 @@ class EventNotificationConsumer(
} }
is WakeUpEvent -> { is WakeUpEvent -> {
wakeUpFor(event, LocalCache.getOrCreateNote(event.id), account) wakeUpFor(event, account)
return return
} }
} }
@@ -185,40 +190,74 @@ class EventNotificationConsumer(
suspend fun wakeUpFor( suspend fun wakeUpFor(
event: WakeUpEvent, event: WakeUpEvent,
note: Note,
account: Account, account: Account,
) { ) {
// A WakeUp's whole purpose is the events it references. If it carries
// none, there's nothing to fetch — skip the 30s subscription window.
val referencedTags =
event
.events()
.distinctBy { it.eventId }
.take(MAX_WAKEUP_REFS)
if (referencedTags.isEmpty()) {
Log.d(TAG) { "WakeUp ${event.id} has no referenced events — skipping" }
return
}
// The referenced event's author is who the user will see in the final
// notification ("Alice zapped you"). The WakeUp's own pubKey is typically
// a push bot and not useful. Fall back to it only when the `e` tag omits
// the author hint.
val referencedNotes = referencedTags.map { LocalCache.getOrCreateNote(it.eventId) }
val authorCandidates =
referencedTags
.mapNotNull { it.author }
.distinct()
.ifEmpty { listOf(event.pubKey) }
.map { LocalCache.getOrCreateUser(it) }
coroutineScope { coroutineScope {
// keeps the relay connection active for 30 seconds. // keeps the relay connection active for 30 seconds.
launch { launch {
try { try {
withTimeout(30_000L) { withTimeout(WAKEUP_WINDOW_MS) {
Amethyst.instance.relayProxyClientConnector.relayServices Amethyst.instance.relayProxyClientConnector.relayServices
.collect() .collect()
} }
} catch (_: TimeoutCancellationException) { } catch (_: TimeoutCancellationException) {
Log.d(TAG) { "WakeUp ${event.id}${WAKEUP_WINDOW_MS}ms relay window elapsed" }
} }
} }
// keeps the subscription to download this event active for 30 seconds. // keeps subscriptions active for 30 seconds so EventFinder can pull
// the referenced events from relays and UserFinder can resolve the
// referenced authors' metadata.
launch { launch {
val accountState = ScreenAuthAccount(account) val accountState = ScreenAuthAccount(account)
val eventState = EventFinderQueryState(note, account) val eventStates = referencedNotes.map { EventFinderQueryState(it, account) }
val authorState = UserFinderQueryState(note.author ?: LocalCache.getOrCreateUser(event.pubKey), account) val authorStates = authorCandidates.map { UserFinderQueryState(it, account) }
try { try {
Amethyst.instance.authCoordinator.subscribe(accountState) Amethyst.instance.authCoordinator.subscribe(accountState)
Amethyst.instance.sources.eventFinder eventStates.forEach {
.subscribe(eventState) Amethyst.instance.sources.eventFinder
Amethyst.instance.sources.userFinder .subscribe(it)
.subscribe(authorState) }
delay(30_000) authorStates.forEach {
Amethyst.instance.sources.userFinder
.subscribe(it)
}
delay(WAKEUP_WINDOW_MS)
} finally { } finally {
Amethyst.instance.authCoordinator.unsubscribe(accountState) Amethyst.instance.authCoordinator.unsubscribe(accountState)
Amethyst.instance.sources.eventFinder eventStates.forEach {
.unsubscribe(eventState) Amethyst.instance.sources.eventFinder
Amethyst.instance.sources.userFinder .unsubscribe(it)
.unsubscribe(authorState) }
authorStates.forEach {
Amethyst.instance.sources.userFinder
.unsubscribe(it)
}
} }
} }
} }
@@ -35,7 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parse import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parse
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey
import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.json.JsonNull.content import kotlinx.serialization.json.JsonNull.content
@@ -68,6 +68,10 @@ class WakeUpEvent(
const val KIND = 23903 const val KIND = 23903
const val ALT_DESCRIPTION = "WakeUp" const val ALT_DESCRIPTION = "WakeUp"
// Tags the audience of [about] (its `p` tags) as the recipients to wake up.
// The about event's author is NOT tagged: a WakeUp about a zap from Alice
// to Bob should notify Bob, not Alice. Callers can add more recipients via
// [initializer].
fun build( fun build(
about: EventHintBundle<Event>, about: EventHintBundle<Event>,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
@@ -75,7 +79,7 @@ class WakeUpEvent(
) = eventTemplate(KIND, content, createdAt) { ) = eventTemplate(KIND, content, createdAt) {
alt(ALT_DESCRIPTION) alt(ALT_DESCRIPTION)
about(about) about(about)
notify(about.toPTag()) notify(about.event.tags.taggedUsers())
kind(about.event.kind) kind(about.event.kind)
initializer() initializer()
} }