diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 6dab26f294..a625a5db6d 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -64,6 +64,7 @@ import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'inline_agent_typing.dart'; import 'typing_motion_preview.dart'; +import 'huddle_agents_provider.dart'; import 'channel_ids_by_name_provider.dart'; import 'channels_provider.dart'; import 'unread_badge/observed_unread_event.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart index 79ddbd9bac..a8940d29c9 100644 --- a/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart +++ b/mobile/lib/features/channels/channel_detail_page/huddle_sheet.dart @@ -30,13 +30,17 @@ final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose final backingMembers = ref.watch(channelMembersProvider(channelId)).value ?? const []; + final agentPubkeys = ref.watch(huddleAgentPubkeysProvider(channelId)); return _huddleParticipantPubkeys( sessionParticipantPubkeys: session.ephemeralChannelId == channelId ? session.participantPubkeys : const [], currentPubkey: session.currentPubkey, members: session.wasAdmitted - ? backingMembers.where((member) => member.isBot) + ? backingMembers.where( + (member) => + agentPubkeys.contains(member.pubkey.trim().toLowerCase()), + ) : backingMembers, ); }); @@ -51,10 +55,7 @@ final _huddleIdentityNamesProvider = Provider.autoDispose const []; final names = sources.scope( ref.watch(_huddleLogicalParticipantPubkeysProvider(channelId)), - agentPubkeys: { - for (final member in members) - if (member.isBot) member.pubkey, - }, + agentPubkeys: ref.watch(huddleAgentPubkeysProvider(channelId)), fallbackNames: { for (final member in members) member.pubkey: ?member.displayName, }, @@ -619,17 +620,13 @@ class _MobileHuddleCallPage extends ConsumerWidget { final parentAgentPubkeys = ref.watch( agentMentionPubkeysProvider(invite.parentChannelId), ); - // Ephemeral Huddle bot membership is authoritative for native enrollment; - // parent classification is only best-effort and may miss a valid Huddle - // bot. Union both so a Huddle-only agent still enters the preparing state. - final huddleBotPubkeys = { - for (final member - in ref - .watch(channelMembersProvider(invite.ephemeralChannelId)) - .value ?? - const []) - if (member.isBot) member.pubkey.trim().toLowerCase(), - }; + // Ephemeral Huddle agent membership is authoritative for native + // enrollment; parent classification is only best-effort and may miss a + // valid Huddle agent. Union both so a Huddle-only agent still enters the + // preparing state. + final huddleBotPubkeys = ref.watch( + huddleAgentPubkeysProvider(invite.ephemeralChannelId), + ); final workingAgentPubkeys = { for (final entry in huddleTypingEntries) if (parentAgentPubkeys.contains(entry.pubkey.toLowerCase()) || diff --git a/mobile/lib/features/channels/huddle_agents_provider.dart b/mobile/lib/features/channels/huddle_agents_provider.dart new file mode 100644 index 0000000000..f86d41aa8d --- /dev/null +++ b/mobile/lib/features/channels/huddle_agents_provider.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../shared/relay/relay.dart'; +import 'channel_management_provider.dart'; + +/// Kind of the managed-agent record that Desktop publishes, signed by the +/// owner, for every agent it runs. Its `d` tag is the agent's pubkey. +const managedAgentRecordKind = 30177; + +/// Which members of a Huddle channel are agents. +/// +/// The channel role `bot` is the first signal. The relay stores it only for +/// keys with a recorded NIP-OA owner, so an agent without an owner +/// attestation joins as `member`. Such an agent is still an agent here when +/// a trusted author published a kind:30177 managed-agent record for it. +/// +/// Trusted record authors are the current user and the Huddle channel's +/// owners. A record from anyone else is only that author's claim. This is a +/// Huddle compatibility policy, not identity proof: it mints no owner. It +/// lets an owner mark a member as an agent for the participant list, labels, +/// the "preparing a response" state, and the last-person count that decides +/// whether leaving ends and archives the Huddle. An owner already had that +/// power when the client chose the `bot` role. Desktop uses the same rule +/// for its Huddle roster (block/buzz#8544). +/// +/// The result holds only current members. Until the records first load, +/// only the `bot` role counts. While the records reload after a membership +/// change, or if that reload fails, the last loaded records still apply to +/// the members who remain; a failed read never removes a `bot` agent. +final huddleAgentPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final members = + ref.watch(channelMembersProvider(channelId)).value ?? + const []; + final recordAgents = + ref.watch(huddleManagedAgentRecordPubkeysProvider(channelId)).value ?? + const {}; + return Set.unmodifiable({ + for (final member in members) + if (member.isBot || + recordAgents.contains(member.pubkey.trim().toLowerCase())) + member.pubkey.trim().toLowerCase(), + }); + }); + +final huddleManagedAgentRecordPubkeysProvider = FutureProvider.autoDispose + .family, String>((ref, channelId) async { + final connected = ref.watch( + relaySessionProvider.select( + (state) => state.status == SessionStatus.connected, + ), + ); + final viewer = ref.watch(myPubkeyProvider); + final members = await ref.watch(channelMembersProvider(channelId).future); + if (!connected) return const {}; + try { + return await fetchTrustedManagedAgentPubkeys( + ref.read(relaySessionProvider.notifier), + members: [ + for (final member in members) + MemberEntry(pubkey: member.pubkey, role: member.role), + ], + viewerPubkey: viewer, + ); + } catch (error) { + debugPrint('[HuddleAgents] managed-agent query failed: $error'); + rethrow; + } + }); + +/// Members of a Huddle channel, other than `bot` members, that a trusted +/// author recorded as a managed agent. Throws when the records cannot be read. +Future> fetchTrustedManagedAgentPubkeys( + RelaySessionNotifier session, { + required Iterable members, + required String? viewerPubkey, +}) async { + final authors = trustedManagedAgentRecordAuthors( + members: members, + viewerPubkey: viewerPubkey, + ); + final candidates = managedAgentRecordCandidates(members); + if (authors.isEmpty || candidates.isEmpty) return const {}; + final events = await session.fetchHistory( + NostrFilter( + kinds: const [managedAgentRecordKind], + authors: authors, + tags: {'#d': candidates}, + limit: authors.length * candidates.length, + ), + ); + // Signature checks are CPU work; keep them off the UI isolate. + return Isolate.run( + () => managedAgentPubkeysFromRecords( + events: events, + trustedAuthors: authors, + candidates: candidates, + ), + ); +} + +/// The current user and the channel's owners, normalized and deduplicated. +@visibleForTesting +List trustedManagedAgentRecordAuthors({ + required Iterable members, + required String? viewerPubkey, +}) { + final authors = {}; + final viewer = viewerPubkey?.trim().toLowerCase(); + if (viewer != null && viewer.isNotEmpty) authors.add(viewer); + for (final member in members) { + if (member.role == 'owner') authors.add(member.pubkey.trim().toLowerCase()); + } + return List.unmodifiable(authors); +} + +/// Members without the `bot` role. Only these need a managed-agent record. +@visibleForTesting +List managedAgentRecordCandidates(Iterable members) => + List.unmodifiable({ + for (final member in members) + if (member.role != 'bot') member.pubkey.trim().toLowerCase(), + }); + +/// Candidates named by a validly signed managed-agent record from a trusted +/// author. +@visibleForTesting +Set managedAgentPubkeysFromRecords({ + required Iterable events, + required Iterable trustedAuthors, + required Iterable candidates, +}) { + final trusted = {for (final author in trustedAuthors) author.toLowerCase()}; + final members = {for (final pubkey in candidates) pubkey.toLowerCase()}; + final agents = {}; + for (final event in events) { + if (event.kind != managedAgentRecordKind) continue; + if (!trusted.contains(event.pubkey.toLowerCase())) continue; + final agent = _firstTagValue(event, 'd')?.toLowerCase(); + if (agent == null || !members.contains(agent)) continue; + if (!_hasValidSignature(event)) continue; + agents.add(agent); + } + return Set.unmodifiable(agents); +} + +String? _firstTagValue(NostrEvent event, String name) { + for (final tag in event.tags) { + if (tag.length >= 2 && tag[0] == name) return tag[1]; + } + return null; +} + +/// `nostr.Event.fromJson` checks the event id and the signature, and throws +/// when either is wrong. +bool _hasValidSignature(NostrEvent event) { + try { + nostr.Event.fromJson(jsonEncode(event.toJson())); + return true; + } catch (_) { + return false; + } +} diff --git a/mobile/lib/features/channels/mobile_huddle_controller.dart b/mobile/lib/features/channels/mobile_huddle_controller.dart index 4cb856f381..45b89fdae4 100644 --- a/mobile/lib/features/channels/mobile_huddle_controller.dart +++ b/mobile/lib/features/channels/mobile_huddle_controller.dart @@ -7,6 +7,7 @@ import '../../shared/community/community_provider.dart'; import '../../shared/huddle/huddle.dart'; import '../../shared/relay/relay.dart'; import 'channel_management_provider.dart'; +import 'huddle_agents_provider.dart'; typedef HuddleHumanCountLoader = Future Function(String channelId); @@ -65,9 +66,24 @@ final huddleHumanCountProvider = Provider((ref) { throw StateError('Relay disconnected during Huddle member lookup.'); } if (events.isEmpty) return 0; - return membersFromEvent( - events.first, - ).where((member) => member.role != 'bot').length; + final members = membersFromEvent(events.first); + // An agent without NIP-OA has role `member`. A failed record read throws, + // so leave keeps the Huddle open rather than count an agent as a person. + final recordAgents = await fetchTrustedManagedAgentPubkeys( + ref.read(relaySessionProvider.notifier), + members: members, + viewerPubkey: ref.read(myPubkeyProvider), + ); + if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + throw StateError('Relay disconnected during Huddle member lookup.'); + } + return members + .where( + (member) => + member.role != 'bot' && + !recordAgents.contains(member.pubkey.trim().toLowerCase()), + ) + .length; }; }); diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index d1ca0e2d53..e760a1def0 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -54,6 +54,7 @@ import 'package:buzz/features/channels/composer_dock_size_reporter.dart'; import 'package:buzz/features/channels/date_formatters.dart'; import 'package:buzz/features/channels/day_divider.dart'; import 'package:buzz/features/channels/emoji_picker.dart'; +import 'package:buzz/features/channels/huddle_agents_provider.dart'; import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; import 'package:buzz/features/channels/local_message_send_animation_provider.dart'; import 'package:buzz/features/channels/local_send_bubble_surface.dart'; @@ -259,6 +260,7 @@ Widget _buildTestable({ List members = const [], List huddleMembers = const [], _MutableHuddleMembersNotifier? huddleMembersNotifier, + Set? huddleRecordAgentPubkeys, Channel? channel, ChannelDetails Function()? channelDetails, List? channels, @@ -392,6 +394,10 @@ Widget _buildTestable({ ), if (huddleMembersNotifier != null) _mutableHuddleMembersProvider.overrideWith(() => huddleMembersNotifier), + if (huddleRecordAgentPubkeys != null) + huddleManagedAgentRecordPubkeysProvider( + _huddleChannelId, + ).overrideWith((ref) async => huddleRecordAgentPubkeys), if (!watchChannelMembershipUpdates) channelBotPubkeysProvider(_channelId).overrideWith( (ref) async => loadChannelBotPubkeys?.call() ?? const {}, @@ -7538,6 +7544,135 @@ void main() { }, ); + testWidgets( + 'keeps a record-only Huddle agent in the roster without audio', + (tester) async { + // After relay PR #8494 an agent without NIP-OA joins with role + // `member`. After admission the roster shows audio peers plus agents, + // so a record-only agent that is not on audio must still appear. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final transport = _HuddleTestTransport( + peers: const {1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0)}, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'record-only-quiet-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'person': UserProfile(pubkey: 'person', displayName: 'Away'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ChannelMember( + pubkey: 'person', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + huddleRecordAgentPubkeys: const {'agent'}, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('huddle-participant-avatar-agent')), + findsOneWidget, + ); + // A person who is not on audio is not in the roster after admission. + expect( + find.byKey(const ValueKey('huddle-participant-avatar-person')), + findsNothing, + ); + }, + ); + + testWidgets( + 'shows preparing indicator for a Huddle agent known only by its record', + (tester) async { + // After relay PR #8494 an agent without NIP-OA joins with role + // `member`. A managed-agent record from a trusted author still makes + // it an agent in the Huddle. + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final typing = _FakeTypingNotifier([ + TypingEntry( + pubkey: 'agent', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], channelId: _huddleChannelId); + final transport = _HuddleTestTransport( + peers: const { + 1: HuddlePeer(pubkey: 'self', peerIndex: 1, epoch: 0), + 2: HuddlePeer(pubkey: 'agent', peerIndex: 2, epoch: 0), + }, + ); + + await tester.pumpWidget( + _buildTestable( + messages: [ + _huddleMsg( + id: 'record-only-working-agent-call', + kind: EventKind.huddleStarted, + pubkey: 'self', + createdAt: now, + ), + ], + users: const { + 'agent': UserProfile(pubkey: 'agent', displayName: 'Pollen'), + 'self': UserProfile(pubkey: 'self', displayName: 'Self'), + }, + huddleMembers: [ + ChannelMember( + pubkey: 'agent', + role: 'member', + joinedAt: DateTime(2025), + ), + ], + huddleRecordAgentPubkeys: const {'agent'}, + huddleTypingNotifier: typing, + relayConfigNotifier: _HuddleRelayConfigNotifier(), + huddleCurrentPubkey: 'self', + huddleMediaFactory: _HuddleTestMedia.new, + huddleTransportFactory: (_) => transport, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(FilledButton, 'Join')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pump(const Duration(milliseconds: 200)); + + expect( + find.byKey(const ValueKey('huddle-agent-preparing-response-agent')), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Pollen, preparing a response'), + findsOneWidget, + ); + }, + ); + testWidgets('does not revive response dots when typing follows audio', ( tester, ) async { diff --git a/mobile/test/features/channels/huddle_agents_provider_test.dart b/mobile/test/features/channels/huddle_agents_provider_test.dart new file mode 100644 index 0000000000..513a9bb38c --- /dev/null +++ b/mobile/test/features/channels/huddle_agents_provider_test.dart @@ -0,0 +1,309 @@ +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/huddle_agents_provider.dart'; +import 'package:buzz/features/channels/mobile_huddle_controller.dart'; +import 'package:buzz/shared/relay/relay.dart'; + +void main() { + test('role bot and trusted managed-agent records both make agents', () async { + final relay = _HuddleRelaySession( + members: { + _owner.public: 'owner', + _viewer.public: 'member', + _botAgent: 'bot', + _ownerAgent: 'member', + _viewerAgent: 'member', + _strangerAgent: 'member', + _person: 'member', + }, + records: [ + _record(_owner, _ownerAgent), + _record(_viewer, _viewerAgent), + // A member who owns nothing in this Huddle cannot mark an agent. + _record(_stranger, _strangerAgent), + ], + ); + final container = _container(relay); + + expect(await _settledAgents(container), { + _botAgent, + _ownerAgent, + _viewerAgent, + }); + final query = relay.recordFilters.single; + expect(query.authors, unorderedEquals([_viewer.public, _owner.public])); + expect(query.tags['#d'], isNot(contains(_botAgent))); + expect(query.tags['#d'], contains(_ownerAgent)); + }); + + test('a failed record read keeps role bot agents only', () async { + final relay = _HuddleRelaySession( + members: { + _owner.public: 'owner', + _botAgent: 'bot', + _ownerAgent: 'member', + }, + recordError: Exception('relay timeout'), + ); + final container = _container(relay); + + expect(await _settledAgents(container), {_botAgent}); + expect(relay.recordFilters, hasLength(1)); + }); + + test('a reload keeps record agents only while they are members', () async { + final relay = _HuddleRelaySession( + members: { + _owner.public: 'owner', + _botAgent: 'bot', + _ownerAgent: 'member', + _viewerAgent: 'member', + }, + records: [_record(_owner, _ownerAgent), _record(_owner, _viewerAgent)], + ); + final container = _container(relay); + expect(await _settledAgents(container), { + _botAgent, + _ownerAgent, + _viewerAgent, + }); + + // _ownerAgent leaves; the record reload is slow and then fails. + relay.members = { + _owner.public: 'owner', + _botAgent: 'bot', + _viewerAgent: 'member', + }; + relay.recordGate = Completer(); + relay.recordError = Exception('relay timeout'); + container.invalidate(channelMembersProvider(_channelId)); + expect(await _agentsNow(container), {_botAgent, _viewerAgent}); + expect(relay.recordFilters, hasLength(2)); + + relay.recordGate!.complete(); + expect(await _settledAgents(container), {_botAgent, _viewerAgent}); + + // The next successful read is authoritative again. + relay.recordError = null; + relay.recordGate = null; + relay.members = {_owner.public: 'owner', _botAgent: 'bot'}; + container.invalidate(channelMembersProvider(_channelId)); + expect(await _settledAgents(container), {_botAgent}); + }); + + test('last-human count leaves out agents known by their record', () async { + final relay = _HuddleRelaySession( + members: { + _owner.public: 'owner', + _viewer.public: 'member', + _botAgent: 'bot', + _ownerAgent: 'member', + _strangerAgent: 'member', + }, + records: [ + _record(_owner, _ownerAgent), + _record(_stranger, _strangerAgent), + ], + ); + final container = _container(relay); + + // The owner, the viewer and the agent that only a stranger vouched for. + expect(await container.read(huddleHumanCountProvider)(_channelId), 3); + }); + + test('last-human count fails when the records cannot be read', () async { + final relay = _HuddleRelaySession( + members: {_owner.public: 'owner', _ownerAgent: 'member'}, + recordError: Exception('relay timeout'), + ); + final container = _container(relay); + + // Leave treats a failed count as "another person remains". + await expectLater( + container.read(huddleHumanCountProvider)(_channelId), + throwsException, + ); + }); + + test('records need a valid signature, the d tag and kind', () { + final valid = _record(_owner, _ownerAgent); + final forged = NostrEvent( + id: valid.id, + pubkey: valid.pubkey, + createdAt: valid.createdAt, + kind: valid.kind, + tags: [ + ['d', _viewerAgent], + ], + content: valid.content, + sig: valid.sig, + ); + + expect( + managedAgentPubkeysFromRecords( + events: [valid, forged, _record(_owner, _person, kind: 30078)], + trustedAuthors: [_owner.public], + candidates: [_ownerAgent, _viewerAgent, _person], + ), + {_ownerAgent}, + ); + }); + + test('trusted authors are the viewer and the channel owners', () { + final members = [ + _member(_owner.public, 'owner'), + _member(_stranger.public, 'admin'), + _member(_botAgent, 'bot'), + ]; + + expect( + trustedManagedAgentRecordAuthors( + members: members, + viewerPubkey: _viewer.public.toUpperCase(), + ), + [_viewer.public, _owner.public], + ); + expect(managedAgentRecordCandidates(members), [ + _owner.public, + _stranger.public, + ]); + }); +} + +ProviderContainer _container(_HuddleRelaySession relay) { + final container = ProviderContainer( + retry: (_, _) => null, + overrides: [ + relaySessionProvider.overrideWith(() => relay), + myPubkeyProvider.overrideWithValue(_viewer.public), + ], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + huddleAgentPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + return container; +} + +/// The agents once the record read has finished, including its isolate. +Future> _settledAgents(ProviderContainer container) async { + await _pump(); + try { + await container.read( + huddleManagedAgentRecordPubkeysProvider(_channelId).future, + ); + } catch (_) {} + return _agentsNow(container); +} + +/// The agents while a record read may still be in flight. +Future> _agentsNow(ProviderContainer container) async { + await _pump(); + return container.read(huddleAgentPubkeysProvider(_channelId)); +} + +Future _pump() async { + for (var i = 0; i < 10; i++) { + await Future.delayed(Duration.zero); + } +} + +MemberEntry _member(String pubkey, String role) => + MemberEntry(pubkey: pubkey, role: role); + +class _HuddleRelaySession extends RelaySessionNotifier { + Map members; + final List records; + Object? recordError; + Completer? recordGate; + final List recordFilters = []; + + _HuddleRelaySession({ + required this.members, + this.records = const [], + this.recordError, + }); + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + if (filter.kinds.contains(39002)) { + return [ + NostrEvent( + id: 'membership', + pubkey: 'relay', + createdAt: 1, + kind: 39002, + tags: [ + ['d', _channelId], + for (final entry in members.entries) + ['p', entry.key, 'wss://relay.example', entry.value], + ], + content: '', + sig: 'sig', + ), + ]; + } + if (filter.kinds.contains(managedAgentRecordKind)) { + recordFilters.add(filter); + if (recordGate case final gate?) await gate.future; + if (recordError case final error?) throw error; + return records; + } + return const []; + } + + @override + Future subscribeWithStatus( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + required void Function(RelaySubscriptionStatus status) onStatusChanged, + }) async { + onStatusChanged(RelaySubscriptionStatus.ready); + return () {}; + } +} + +NostrEvent _record(nostr.Keys author, String agent, {int kind = 30177}) { + final event = nostr.Event.from( + kind: kind, + content: '{}', + tags: [ + ['d', agent], + ], + secretKey: author.secret, + createdAt: 1792680000, + ); + return NostrEvent( + id: event.id, + pubkey: event.pubkey, + createdAt: event.createdAt, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + ); +} + +const _channelId = '22222222-2222-4222-8222-222222222222'; +final _owner = nostr.Keys('1' * 64); +final _viewer = nostr.Keys('2' * 64); +final _stranger = nostr.Keys('3' * 64); +final _botAgent = 'b' * 64; +final _ownerAgent = 'c' * 64; +final _viewerAgent = 'd' * 64; +final _strangerAgent = 'e' * 64; +final _person = 'f' * 64;