From 7bf6e482711bdc685a71641f5919cb7410098265 Mon Sep 17 00:00:00 2001 From: Taksh Date: Wed, 29 Jul 2026 22:05:43 +0300 Subject: [PATCH 1/2] fix(mobile): p-tag DM recipients so agents receive plain messages Desktop already tags every DM participant; mobile only added explicit @mentions, so agent DMs from the phone never woke a turn. Signed-off-by: Taksh Co-authored-by: Cursor --- .../channels/message_mention_pubkeys.dart | 27 +++++ .../channels/send_message_provider.dart | 43 +++++-- .../message_mention_pubkeys_test.dart | 58 ++++++++++ .../channels/send_message_provider_test.dart | 107 +++++++++++++++++- 4 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 mobile/lib/features/channels/message_mention_pubkeys.dart create mode 100644 mobile/test/features/channels/message_mention_pubkeys_test.dart diff --git a/mobile/lib/features/channels/message_mention_pubkeys.dart b/mobile/lib/features/channels/message_mention_pubkeys.dart new file mode 100644 index 0000000000..59793b514c --- /dev/null +++ b/mobile/lib/features/channels/message_mention_pubkeys.dart @@ -0,0 +1,27 @@ +/// Return the semantic recipients for an outgoing message. +/// +/// Stream messages notify only explicit mentions. A DM addresses every other +/// participant, so it must carry recipient `p` tags even when the composer text +/// contains no `@mention`. Agent harnesses and human notification subscriptions +/// both rely on those tags. +/// +/// Mirrors desktop `messageMentionPubkeys` in +/// `desktop/src/features/messages/lib/messageMentionPubkeys.ts`. +List messageMentionPubkeys({ + required bool isDm, + required String? senderPubkey, + required Iterable explicitMentions, + required Iterable memberPubkeys, + required Iterable participantPubkeys, +}) { + final candidates = isDm + ? [...explicitMentions, ...memberPubkeys, ...participantPubkeys] + : explicitMentions; + + final selfLower = senderPubkey?.toLowerCase(); + final seen = {?selfLower}; + return [ + for (final pk in candidates) + if (pk.isNotEmpty && seen.add(pk.toLowerCase())) pk, + ]; +} diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 3659bda4bf..ba47e2b566 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -4,13 +4,17 @@ import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; +import 'channel.dart'; import 'channel_messages_provider.dart'; +import 'channels_provider.dart'; +import 'message_mention_pubkeys.dart'; /// Sends messages by signing an event with the user's nsec and publishing it /// over the relay's NIP-42-authenticated WebSocket session. class SendMessage { final SignedEventRelay _signedEventRelay; final Future> Function(String channelId) _fetchMembers; + final Future Function(String channelId) _fetchChannel; final Map Function() _readUserCache; final void Function(String channelId, NostrEvent event) _addLocalMessage; final void Function(String channelId, String eventId) _completeLocalMessage; @@ -20,6 +24,7 @@ class SendMessage { required SignedEventRelay signedEventRelay, required Future> Function(String channelId) fetchMembers, + required Future Function(String channelId) fetchChannel, required Map Function() readUserCache, required void Function(String channelId, NostrEvent event) addLocalMessage, required void Function(String channelId, String eventId) @@ -27,6 +32,7 @@ class SendMessage { required void Function(String channelId, String eventId) removeLocalMessage, }) : _signedEventRelay = signedEventRelay, _fetchMembers = fetchMembers, + _fetchChannel = fetchChannel, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, _completeLocalMessage = completeLocalMessage, @@ -53,14 +59,28 @@ class SendMessage { mentionPubkeys ?? await _resolveMentions(content, channelId); final authorPubkey = _signedEventRelay.pubkey; - // Normalize mentions: lowercase, deduplicate, exclude self (matching - // the desktop's normalizeMentionPubkeys). - final selfLower = authorPubkey?.toLowerCase(); - final seenMentions = {?selfLower}; - final normalizedMentions = [ - for (final pk in resolvedMentions) - if (seenMentions.add(pk.toLowerCase())) pk, - ]; + // DMs must p-tag every other participant even without @mentions — agent + // harnesses and human notification subscriptions both rely on those tags. + // Mirrors desktop `messageMentionPubkeys`. + final channel = await _fetchChannel(channelId); + final isDm = channel?.isDm ?? false; + var memberPubkeys = const []; + if (isDm) { + try { + memberPubkeys = [ + for (final member in await _fetchMembers(channelId)) member.pubkey, + ]; + } catch (_) { + // Non-fatal — participantPubkeys alone still cover typical 1:1 DMs. + } + } + final normalizedMentions = messageMentionPubkeys( + isDm: isDm, + senderPubkey: authorPubkey, + explicitMentions: resolvedMentions, + memberPubkeys: memberPubkeys, + participantPubkeys: channel?.participantPubkeys ?? const [], + ); final tags = >[ ['h', channelId], @@ -169,6 +189,13 @@ final sendMessageProvider = Provider((ref) { ), fetchMembers: (channelId) => ref.read(channelMembersProvider(channelId).future), + fetchChannel: (channelId) async { + final channels = await ref.read(channelsProvider.future); + for (final channel in channels) { + if (channel.id == channelId) return channel; + } + return null; + }, readUserCache: () => ref.read(userCacheProvider), addLocalMessage: (channelId, event) => ref .read(channelMessagesProvider(channelId).notifier) diff --git a/mobile/test/features/channels/message_mention_pubkeys_test.dart b/mobile/test/features/channels/message_mention_pubkeys_test.dart new file mode 100644 index 0000000000..b5610c9088 --- /dev/null +++ b/mobile/test/features/channels/message_mention_pubkeys_test.dart @@ -0,0 +1,58 @@ +import 'package:buzz/features/channels/message_mention_pubkeys.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('messageMentionPubkeys', () { + test('plain DM messages p-tag every recipient except the sender', () { + expect( + messageMentionPubkeys( + isDm: true, + senderPubkey: 'owner', + explicitMentions: const [], + memberPubkeys: const ['OWNER', 'AGENT'], + participantPubkeys: const ['owner', 'agent'], + ), + ['AGENT'], + ); + }); + + test('DM messages keep explicit mentions and dedupe case variants', () { + expect( + messageMentionPubkeys( + isDm: true, + senderPubkey: 'OWNER', + explicitMentions: const ['AGENT', 'third'], + memberPubkeys: const ['owner', 'agent'], + participantPubkeys: const ['Owner', 'Agent', 'guest'], + ), + ['AGENT', 'third', 'guest'], + ); + }); + + test('stream messages keep only explicit mentions', () { + expect( + messageMentionPubkeys( + isDm: false, + senderPubkey: 'owner', + explicitMentions: const ['agent'], + memberPubkeys: const ['owner', 'agent', 'other'], + participantPubkeys: const ['someone'], + ), + ['agent'], + ); + }); + + test('empty and self pubkeys are dropped', () { + expect( + messageMentionPubkeys( + isDm: true, + senderPubkey: 'me', + explicitMentions: const ['', 'me'], + memberPubkeys: const ['me', ''], + participantPubkeys: const ['you'], + ), + ['you'], + ); + }); + }); +} diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index f91ce87b2b..62fd6e7ab4 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -1,9 +1,11 @@ import 'dart:async'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; +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'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:nostr/nostr.dart' as nostr; void main() { test( @@ -19,6 +21,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + fetchChannel: (_) async => null, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -52,6 +55,7 @@ void main() { nsec: nostr.Keys.generate().nsec, ), fetchMembers: (_) async => const [], + fetchChannel: (_) async => null, readUserCache: () => const {}, addLocalMessage: (_, event) => localMessages.add(event), completeLocalMessage: (_, eventId) => completedIds.add(eventId), @@ -66,9 +70,108 @@ void main() { expect(completedIds, isEmpty); expect(removedIds, [localMessages.single.id]); }); + + test('plain DM messages p-tag every recipient except the sender', () async { + final keys = nostr.Keys.generate(); + final agentPubkey = nostr.Keys.generate().public; + final session = _PendingPublishRelaySession(); + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec), + fetchMembers: (_) async => [ + ChannelMember( + pubkey: keys.public, + role: 'member', + joinedAt: DateTime.utc(2026), + ), + ChannelMember( + pubkey: agentPubkey, + role: 'bot', + joinedAt: DateTime.utc(2026), + ), + ], + fetchChannel: (_) async => Channel( + id: _dmChannelId, + name: 'DM', + channelType: 'dm', + visibility: 'private', + description: '', + createdBy: keys.public, + createdAt: DateTime.utc(2026), + memberCount: 2, + participantPubkeys: [keys.public, agentPubkey], + isMember: true, + ), + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _dmChannelId, + content: 'hello', + mentionPubkeys: const [], + ); + await session.published; + session.accept(); + await result; + + final pTags = [ + for (final tag in session.event.tags) + if (tag.isNotEmpty && tag.first == 'p') tag[1].toLowerCase(), + ]; + expect(pTags, [agentPubkey.toLowerCase()]); + }); + + test('stream messages do not invent DM recipient p-tags', () async { + final keys = nostr.Keys.generate(); + final otherPubkey = nostr.Keys.generate().public; + final session = _PendingPublishRelaySession(); + final send = SendMessage( + signedEventRelay: SignedEventRelay(session: session, nsec: keys.nsec), + fetchMembers: (_) async => [ + ChannelMember( + pubkey: otherPubkey, + role: 'member', + joinedAt: DateTime.utc(2026), + ), + ], + fetchChannel: (_) async => Channel( + id: _channelId, + name: 'general', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: keys.public, + createdAt: DateTime.utc(2026), + memberCount: 2, + participantPubkeys: [otherPubkey], + isMember: true, + ), + readUserCache: () => const {}, + addLocalMessage: (_, _) {}, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + ); + + final result = send( + channelId: _channelId, + content: 'hello', + mentionPubkeys: const [], + ); + await session.published; + session.accept(); + await result; + + expect( + session.event.tags.any((tag) => tag.isNotEmpty && tag.first == 'p'), + isFalse, + ); + }); } const _channelId = '11111111-1111-4111-8111-111111111111'; +const _dmChannelId = '22222222-2222-4222-8222-222222222222'; class _PendingPublishRelaySession extends RelaySessionNotifier { final Completer _result = Completer(); From f3df62d7b60baab82aa5d1fdf3a2d25ff0a22445 Mon Sep 17 00:00:00 2001 From: ScaleLeanChris Date: Wed, 29 Jul 2026 23:43:07 -0600 Subject: [PATCH 2/2] test: trigger mobile CI