mirror of
https://github.com/block/buzz.git
synced 2026-11-06 01:11:33 +00:00
fix(mobile): recognize agents without NIP-OA in Huddles
Mobile Huddles decided "is this an agent" from the channel role `bot`. The relay now stores `bot` only for keys with a recorded NIP-OA owner (#8494), so an agent without an owner attestation joins as `member`. Mobile then left it out of the participant list after joining, labeled it as a person, did not show its "preparing a response" state, and counted it as a person when deciding whether leaving ends the Huddle. Add one Huddle agent owner, `huddle_agents_provider.dart`. A member is an agent when it has the `bot` role or when a trusted author published a validly signed kind:30177 managed-agent record for it. Trusted authors are the current user and the Huddle channel's owners; a record from anyone else is only a claim. This is the same rule as the Desktop fix (#8544). Signature checks run off the UI isolate. If the records are still loading or cannot be read, the `bot` role still works, and the last-person count fails so leave keeps the Huddle open. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -30,13 +30,17 @@ final _huddleLogicalParticipantPubkeysProvider = Provider.autoDispose
|
||||
final backingMembers =
|
||||
ref.watch(channelMembersProvider(channelId)).value ??
|
||||
const <ChannelMember>[];
|
||||
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 <ChannelMember>[];
|
||||
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 = <String>{
|
||||
for (final member
|
||||
in ref
|
||||
.watch(channelMembersProvider(invite.ephemeralChannelId))
|
||||
.value ??
|
||||
const <ChannelMember>[])
|
||||
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 = <String>{
|
||||
for (final entry in huddleTypingEntries)
|
||||
if (parentAgentPubkeys.contains(entry.pubkey.toLowerCase()) ||
|
||||
|
||||
168
mobile/lib/features/channels/huddle_agents_provider.dart
Normal file
168
mobile/lib/features/channels/huddle_agents_provider.dart
Normal file
@@ -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<Set<String>, String>((ref, channelId) {
|
||||
final members =
|
||||
ref.watch(channelMembersProvider(channelId)).value ??
|
||||
const <ChannelMember>[];
|
||||
final recordAgents =
|
||||
ref.watch(huddleManagedAgentRecordPubkeysProvider(channelId)).value ??
|
||||
const <String>{};
|
||||
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<Set<String>, 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<Set<String>> fetchTrustedManagedAgentPubkeys(
|
||||
RelaySessionNotifier session, {
|
||||
required Iterable<MemberEntry> 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<String> trustedManagedAgentRecordAuthors({
|
||||
required Iterable<MemberEntry> members,
|
||||
required String? viewerPubkey,
|
||||
}) {
|
||||
final authors = <String>{};
|
||||
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<String> managedAgentRecordCandidates(Iterable<MemberEntry> 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<String> managedAgentPubkeysFromRecords({
|
||||
required Iterable<NostrEvent> events,
|
||||
required Iterable<String> trustedAuthors,
|
||||
required Iterable<String> candidates,
|
||||
}) {
|
||||
final trusted = {for (final author in trustedAuthors) author.toLowerCase()};
|
||||
final members = {for (final pubkey in candidates) pubkey.toLowerCase()};
|
||||
final agents = <String>{};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<int> Function(String channelId);
|
||||
|
||||
@@ -65,9 +66,24 @@ final huddleHumanCountProvider = Provider<HuddleHumanCountLoader>((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;
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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<ChannelMember> members = const [],
|
||||
List<ChannelMember> huddleMembers = const [],
|
||||
_MutableHuddleMembersNotifier? huddleMembersNotifier,
|
||||
Set<String>? huddleRecordAgentPubkeys,
|
||||
Channel? channel,
|
||||
ChannelDetails Function()? channelDetails,
|
||||
List<Channel>? 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 <String>{},
|
||||
@@ -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 {
|
||||
|
||||
309
mobile/test/features/channels/huddle_agents_provider_test.dart
Normal file
309
mobile/test/features/channels/huddle_agents_provider_test.dart
Normal file
@@ -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<void>();
|
||||
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<Set<String>> _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<Set<String>> _agentsNow(ProviderContainer container) async {
|
||||
await _pump();
|
||||
return container.read(huddleAgentPubkeysProvider(_channelId));
|
||||
}
|
||||
|
||||
Future<void> _pump() async {
|
||||
for (var i = 0; i < 10; i++) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
}
|
||||
|
||||
MemberEntry _member(String pubkey, String role) =>
|
||||
MemberEntry(pubkey: pubkey, role: role);
|
||||
|
||||
class _HuddleRelaySession extends RelaySessionNotifier {
|
||||
Map<String, String> members;
|
||||
final List<NostrEvent> records;
|
||||
Object? recordError;
|
||||
Completer<void>? recordGate;
|
||||
final List<NostrFilter> recordFilters = [];
|
||||
|
||||
_HuddleRelaySession({
|
||||
required this.members,
|
||||
this.records = const [],
|
||||
this.recordError,
|
||||
});
|
||||
|
||||
@override
|
||||
SessionState build() => const SessionState(status: SessionStatus.connected);
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> 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<void Function()> 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;
|
||||
Reference in New Issue
Block a user