From c1472344b8fc05fdffd1652316bcd0454e818725 Mon Sep 17 00:00:00 2001 From: TMC Date: Wed, 29 Jul 2026 16:52:47 -0500 Subject: [PATCH 1/2] fix(mobile): wake agents on direct messages Co-authored-by: TMC Signed-off-by: TMC --- .../channels/channel_detail_page.dart | 1 + .../channels/send_message_provider.dart | 17 ++- .../features/channels/thread_detail_page.dart | 5 + .../channels/send_message_provider_test.dart | 130 ++++++++++++++++++ 4 files changed, 149 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index bee9639825d..e55c51c93ce 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -523,6 +523,7 @@ class ChannelDetailPage extends HookConsumerWidget { channelId: channel.id, content: content, mentionPubkeys: mentionPubkeys, + isDirectMessage: resolvedChannel.isDm, mediaTags: mediaTags, ), ), diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 730546807f8..f828bdeb6f5 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -48,6 +48,7 @@ class SendMessage { String? parentEventId, String? rootEventId, List? mentionPubkeys, + bool isDirectMessage = false, List> mediaTags = const [], }) async { _ensureDeliveryValid(); @@ -55,15 +56,23 @@ class SendMessage { // channel members to avoid matching the wrong user. final resolvedMentions = mentionPubkeys ?? await _resolveMentions(content, channelId); + final recipients = [...resolvedMentions]; + if (isDirectMessage) { + // A DM addresses every other participant even when its text contains no + // explicit @mention. Agent harnesses and human notification + // subscriptions both rely on these p-tags to wake reliably. + final members = await _fetchMembers(channelId); + recipients.addAll(members.map((member) => member.pubkey)); + } final authorPubkey = _signedEventRelay.pubkey; - // Normalize mentions: lowercase, deduplicate, exclude self (matching - // the desktop's normalizeMentionPubkeys). + // Normalize recipients: lowercase, deduplicate, exclude self (matching + // the desktop's messageMentionPubkeys). final selfLower = authorPubkey?.toLowerCase(); final seenMentions = {?selfLower}; final normalizedMentions = [ - for (final pk in resolvedMentions) - if (seenMentions.add(pk.toLowerCase())) pk, + for (final pk in recipients) + if (seenMentions.add(pk.toLowerCase())) pk.toLowerCase(), ]; final tags = >[ diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index fff7f5834ba..f04e53c2ad6 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -330,9 +330,13 @@ class ThreadDetailPage extends HookConsumerWidget { // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channelNamesMap = {}; + var isDirectMessage = false; channelsAsync.whenData((channels) { for (final ch in channels) { channelNamesMap[ch.name.toLowerCase()] = ch.id; + if (ch.id == channelId) { + isDirectMessage = ch.isDm; + } } }); @@ -540,6 +544,7 @@ class ThreadDetailPage extends HookConsumerWidget { mentionPubkeys: mentionPubkeys, parentEventId: threadHead.id, rootEventId: effectiveRootId, + isDirectMessage: isDirectMessage, mediaTags: mediaTags, ), ), diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index 273336f2833..b24d633dcfa 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/send_message_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -91,6 +92,135 @@ void main() { ), ); }); + + test('plain DM messages p-tag every participant except the sender', () async { + final session = _PendingPublishRelaySession(); + final senderKeys = nostr.Keys.generate(); + final agentPubkey = nostr.Keys.generate().public; + final humanPubkey = nostr.Keys.generate().public; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: senderKeys.nsec, + ), + fetchMembers: (_) async => [ + ChannelMember( + pubkey: senderKeys.public, + role: 'owner', + joinedAt: DateTime.utc(2026), + ), + ChannelMember( + pubkey: agentPubkey.toUpperCase(), + role: 'bot', + joinedAt: DateTime.utc(2026), + ), + ChannelMember( + pubkey: humanPubkey, + role: 'member', + joinedAt: DateTime.utc(2026), + ), + ], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello', + isDirectMessage: true, + ); + await session.published; + + final pTags = session.event.tags + .where((tag) => tag.length >= 2 && tag[0] == 'p') + .map((tag) => tag[1]) + .toList(); + expect(pTags, [agentPubkey, humanPubkey]); + + session.accept(); + await result; + }); + + test('DM thread replies p-tag every participant', () async { + final session = _PendingPublishRelaySession(); + final senderKeys = nostr.Keys.generate(); + final recipientPubkey = nostr.Keys.generate().public; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: senderKeys.nsec, + ), + fetchMembers: (_) async => [ + ChannelMember( + pubkey: senderKeys.public, + role: 'owner', + joinedAt: DateTime.utc(2026), + ), + ChannelMember( + pubkey: recipientPubkey, + role: 'bot', + joinedAt: DateTime.utc(2026), + ), + ], + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'thread reply', + parentEventId: 'a' * 64, + rootEventId: 'b' * 64, + isDirectMessage: true, + ); + await session.published; + + expect(session.event.tags.where((tag) => tag.firstOrNull == 'p').toList(), [ + ['p', recipientPubkey], + ]); + + session.accept(); + await result; + }); + + test('stream messages preserve explicit-mention behavior', () async { + final session = _PendingPublishRelaySession(); + final recipientPubkey = nostr.Keys.generate().public; + var fetchedMembers = false; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async { + fetchedMembers = true; + return const []; + }, + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello agent', + mentionPubkeys: [recipientPubkey.toUpperCase(), recipientPubkey], + ); + await session.published; + + expect(fetchedMembers, isFalse); + expect(session.event.tags.where((tag) => tag.firstOrNull == 'p').toList(), [ + ['p', recipientPubkey], + ]); + + session.accept(); + await result; + }); } const _channelId = '11111111-1111-4111-8111-111111111111'; From 01b59b7bf24244b1b40fa41945e966698692547a Mon Sep 17 00:00:00 2001 From: TMC Date: Fri, 7 Aug 2026 07:40:28 -0300 Subject: [PATCH 2/2] fix(mobile): resolve DM type before sending Fail closed when channel metadata is unavailable so thread replies cannot silently omit DM participant tags. Co-authored-by: TMC Signed-off-by: TMC --- .../channels/channel_detail_page.dart | 1 - .../channels/send_message_provider.dart | 13 +++++- .../features/channels/thread_detail_page.dart | 5 --- .../channels/send_message_provider_test.dart | 41 ++++++++++++++++--- 4 files changed, 47 insertions(+), 13 deletions(-) diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index e55c51c93ce..bee9639825d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -523,7 +523,6 @@ class ChannelDetailPage extends HookConsumerWidget { channelId: channel.id, content: content, mentionPubkeys: mentionPubkeys, - isDirectMessage: resolvedChannel.isDm, mediaTags: mediaTags, ), ), diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index f828bdeb6f5..4a4762b2863 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; +import '../channels/channels_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_messages_provider.dart'; @@ -11,6 +12,7 @@ import 'channel_messages_provider.dart'; class SendMessage { final SignedEventRelay _signedEventRelay; final Future> Function(String channelId) _fetchMembers; + final Future Function(String channelId) _isDirectMessage; final Map Function() _readUserCache; final void Function(String channelId, NostrEvent event) _addLocalMessage; final void Function(String channelId, String eventId) _completeLocalMessage; @@ -21,6 +23,7 @@ class SendMessage { required SignedEventRelay signedEventRelay, required Future> Function(String channelId) fetchMembers, + required Future Function(String channelId) isDirectMessage, required Map Function() readUserCache, required void Function(String channelId, NostrEvent event) addLocalMessage, required void Function(String channelId, String eventId) @@ -29,6 +32,7 @@ class SendMessage { bool Function()? isDeliveryValid, }) : _signedEventRelay = signedEventRelay, _fetchMembers = fetchMembers, + _isDirectMessage = isDirectMessage, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, _completeLocalMessage = completeLocalMessage, @@ -48,10 +52,10 @@ class SendMessage { String? parentEventId, String? rootEventId, List? mentionPubkeys, - bool isDirectMessage = false, List> mediaTags = const [], }) async { _ensureDeliveryValid(); + final isDirectMessage = await _isDirectMessage(channelId); // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. final resolvedMentions = @@ -191,6 +195,13 @@ final sendMessageProvider = Provider((ref) { ), fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), + isDirectMessage: (channelId) async { + final channels = await ref.read(channelsProvider.future); + for (final channel in channels) { + if (channel.id == channelId) return channel.isDm; + } + throw StateError('Cannot determine channel type for $channelId'); + }, readUserCache: () => ref.read(userCacheProvider), addLocalMessage: (channelId, event) => ref .read(channelMessagesProvider(channelId).notifier) diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index f04e53c2ad6..fff7f5834ba 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -330,13 +330,9 @@ class ThreadDetailPage extends HookConsumerWidget { // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channelNamesMap = {}; - var isDirectMessage = false; channelsAsync.whenData((channels) { for (final ch in channels) { channelNamesMap[ch.name.toLowerCase()] = ch.id; - if (ch.id == channelId) { - isDirectMessage = ch.isDm; - } } }); @@ -544,7 +540,6 @@ class ThreadDetailPage extends HookConsumerWidget { mentionPubkeys: mentionPubkeys, parentEventId: threadHead.id, rootEventId: effectiveRootId, - isDirectMessage: isDirectMessage, mediaTags: mediaTags, ), ), diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index b24d633dcfa..572eb82ae77 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -21,6 +21,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + isDirectMessage: (_) async => false, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -54,6 +55,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + isDirectMessage: (_) async => false, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -120,17 +122,14 @@ void main() { joinedAt: DateTime.utc(2026), ), ], + isDirectMessage: (_) async => true, readUserCache: () => const {}, addLocalMessage: (_, _) {}, completeLocalMessage: (_, _) {}, removeLocalMessage: (_, _) {}, ); - final result = send( - channelId: _channelId, - content: 'hello', - isDirectMessage: true, - ); + final result = send(channelId: _channelId, content: 'hello'); await session.published; final pTags = session.event.tags @@ -164,6 +163,7 @@ void main() { joinedAt: DateTime.utc(2026), ), ], + isDirectMessage: (_) async => true, readUserCache: () => const {}, addLocalMessage: (_, _) {}, completeLocalMessage: (_, _) {}, @@ -175,7 +175,6 @@ void main() { content: 'thread reply', parentEventId: 'a' * 64, rootEventId: 'b' * 64, - isDirectMessage: true, ); await session.published; @@ -200,6 +199,7 @@ void main() { fetchedMembers = true; return const []; }, + isDirectMessage: (_) async => false, readUserCache: () => const {}, addLocalMessage: (_, _) {}, completeLocalMessage: (_, _) {}, @@ -221,6 +221,35 @@ void main() { session.accept(); await result; }); + + test('fails closed when the channel type cannot be determined', () async { + final session = _PendingPublishRelaySession(); + var fetchedMembers = false; + var addedLocalMessage = false; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async { + fetchedMembers = true; + return const []; + }, + isDirectMessage: (_) async => throw StateError('unknown channel'), + readUserCache: () => const {}, + addLocalMessage: (_, _) => addedLocalMessage = true, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + await expectLater( + send(channelId: _channelId, content: 'hello'), + throwsA(isA()), + ); + + expect(fetchedMembers, isFalse); + expect(addedLocalMessage, isFalse); + }); } const _channelId = '11111111-1111-4111-8111-111111111111';