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) }
}
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 -> {
event.tagsWithoutCitations().filter { it != event.channelId() }.mapNotNull { checkGetOrCreateNote(it) }
}
@@ -89,6 +89,11 @@ class EventNotificationConsumer(
) {
companion object {
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 -> {
wakeUpFor(event, LocalCache.getOrCreateNote(event.id), account)
wakeUpFor(event, account)
return
}
}
@@ -185,40 +190,74 @@ class EventNotificationConsumer(
suspend fun wakeUpFor(
event: WakeUpEvent,
note: Note,
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 {
// keeps the relay connection active for 30 seconds.
launch {
try {
withTimeout(30_000L) {
withTimeout(WAKEUP_WINDOW_MS) {
Amethyst.instance.relayProxyClientConnector.relayServices
.collect()
}
} 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 {
val accountState = ScreenAuthAccount(account)
val eventState = EventFinderQueryState(note, account)
val authorState = UserFinderQueryState(note.author ?: LocalCache.getOrCreateUser(event.pubKey), account)
val eventStates = referencedNotes.map { EventFinderQueryState(it, account) }
val authorStates = authorCandidates.map { UserFinderQueryState(it, account) }
try {
Amethyst.instance.authCoordinator.subscribe(accountState)
Amethyst.instance.sources.eventFinder
.subscribe(eventState)
Amethyst.instance.sources.userFinder
.subscribe(authorState)
delay(30_000)
eventStates.forEach {
Amethyst.instance.sources.eventFinder
.subscribe(it)
}
authorStates.forEach {
Amethyst.instance.sources.userFinder
.subscribe(it)
}
delay(WAKEUP_WINDOW_MS)
} finally {
Amethyst.instance.authCoordinator.unsubscribe(accountState)
Amethyst.instance.sources.eventFinder
.unsubscribe(eventState)
Amethyst.instance.sources.userFinder
.unsubscribe(authorState)
eventStates.forEach {
Amethyst.instance.sources.eventFinder
.unsubscribe(it)
}
authorStates.forEach {
Amethyst.instance.sources.userFinder
.unsubscribe(it)
}
}
}
}