9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
275 lines
9.5 KiB
Dart
275 lines
9.5 KiB
Dart
import 'dart:collection';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/crypto/nip_oa.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
|
|
/// A relay agent parsed from its kind:10100 agent-profile event.
|
|
///
|
|
/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility
|
|
/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which
|
|
/// channels it sits in.
|
|
class AgentDirectoryEntry {
|
|
final String pubkey;
|
|
final String? displayName;
|
|
final String? respondTo;
|
|
final List<String> respondToAllowlist;
|
|
final List<String> channelIds;
|
|
|
|
const AgentDirectoryEntry({
|
|
required this.pubkey,
|
|
this.displayName,
|
|
this.respondTo,
|
|
this.respondToAllowlist = const [],
|
|
this.channelIds = const [],
|
|
});
|
|
|
|
factory AgentDirectoryEntry.fromEvent(NostrEvent event) {
|
|
final content = _tryDecodeJsonMap(event.content);
|
|
return AgentDirectoryEntry(
|
|
pubkey: event.pubkey.toLowerCase(),
|
|
displayName:
|
|
(content?['display_name'] as String?) ??
|
|
(content?['name'] as String?),
|
|
respondTo: content?['respond_to'] as String?,
|
|
respondToAllowlist: [
|
|
for (final value in (content?['respond_to_allowlist'] as List?) ?? [])
|
|
if (value is String) value.toLowerCase(),
|
|
],
|
|
channelIds: [
|
|
for (final value in (content?['channel_ids'] as List?) ?? [])
|
|
if (value is String) value,
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic>? _tryDecodeJsonMap(String content) {
|
|
try {
|
|
final decoded = jsonDecode(content);
|
|
return decoded is Map<String, dynamic> ? decoded : null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Relay agent directory from kind:10100 agent-profile events.
|
|
///
|
|
/// Watches the session and only fetches after the WebSocket connects.
|
|
final agentDirectoryProvider = FutureProvider<List<AgentDirectoryEntry>>((
|
|
ref,
|
|
) async {
|
|
final sessionState = ref.watch(relaySessionProvider);
|
|
if (sessionState.status != SessionStatus.connected) return const [];
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
final events = await session.fetchHistory(NostrFilters.agentProfiles());
|
|
return [for (final event in events) AgentDirectoryEntry.fromEvent(event)];
|
|
});
|
|
|
|
/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0
|
|
/// profiles. An entry exists only when the `auth` tag verifies — mirrors
|
|
/// desktop's `profile_valid_oa_owner_pubkey`.
|
|
final agentOwnersProvider = FutureProvider<Map<String, String>>((ref) async {
|
|
final agents = await ref.watch(agentDirectoryProvider.future);
|
|
if (agents.isEmpty) return const {};
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
final events = await session.fetchHistory(
|
|
NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]),
|
|
);
|
|
final owners = <String, String>{};
|
|
for (final event in events) {
|
|
final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey);
|
|
if (owner != null) owners[event.pubkey.toLowerCase()] = owner;
|
|
}
|
|
return owners;
|
|
});
|
|
|
|
/// Pubkeys currently known to represent agents across the active relay.
|
|
///
|
|
/// Message surfaces that do not own channel membership can use this shared
|
|
/// identity source; channel features add their bot roles separately.
|
|
final knownAgentPubkeysProvider = Provider<Set<String>>((ref) {
|
|
final relayAgents =
|
|
ref.watch(agentDirectoryProvider).asData?.value ??
|
|
const <AgentDirectoryEntry>[];
|
|
final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {};
|
|
return _AgentPubkeySet({
|
|
for (final agent in relayAgents) agent.pubkey.toLowerCase(),
|
|
...owners.keys.map((pubkey) => pubkey.toLowerCase()),
|
|
});
|
|
});
|
|
|
|
/// Directory display names keyed by agent pubkey for mention presentation.
|
|
final agentDirectoryDisplayNamesProvider = Provider<Map<String, String>>((ref) {
|
|
final agents =
|
|
ref.watch(agentDirectoryProvider).asData?.value ??
|
|
const <AgentDirectoryEntry>[];
|
|
return Map.unmodifiable({
|
|
for (final agent in agents)
|
|
if (agent.displayName?.trim().isNotEmpty == true)
|
|
agent.pubkey.toLowerCase(): agent.displayName!.trim(),
|
|
});
|
|
});
|
|
|
|
/// Adds channel bot roles to relay-wide agent identities.
|
|
Set<String> agentPubkeysWithChannelBots({
|
|
required Set<String> knownAgentPubkeys,
|
|
required Iterable<String> channelBotPubkeys,
|
|
}) => _AgentPubkeySet({
|
|
...knownAgentPubkeys,
|
|
...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
|
});
|
|
|
|
/// Adds agent identities derived from locally cached verified profiles.
|
|
Set<String> agentPubkeysWithProfileOwners({
|
|
required Set<String> knownAgentPubkeys,
|
|
required Iterable<String> profileOwnedAgentPubkeys,
|
|
}) => _AgentPubkeySet({
|
|
...knownAgentPubkeys,
|
|
...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()),
|
|
});
|
|
|
|
/// Preserves profile labels while filling missing agent mentions from the
|
|
/// relay's agent directory.
|
|
Map<String, String> mentionNamesWithDirectoryLabels({
|
|
required Iterable<String> mentionPubkeys,
|
|
required Map<String, String> profileMentionNames,
|
|
required Map<String, String> directoryDisplayNames,
|
|
required Set<String> agentMentionPubkeys,
|
|
}) {
|
|
final names = Map<String, String>.from(profileMentionNames);
|
|
for (final pubkey in mentionPubkeys) {
|
|
final normalizedPubkey = pubkey.toLowerCase();
|
|
if (names[normalizedPubkey]?.trim().isEmpty == true) {
|
|
names.remove(normalizedPubkey);
|
|
}
|
|
final directoryName = directoryDisplayNames[normalizedPubkey];
|
|
if (!names.containsKey(normalizedPubkey) && directoryName != null) {
|
|
names[normalizedPubkey] = directoryName;
|
|
}
|
|
if (!names.containsKey(normalizedPubkey) &&
|
|
agentMentionPubkeys.contains(normalizedPubkey)) {
|
|
names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey);
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
String _agentFallbackLabel(String pubkey) =>
|
|
pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey;
|
|
|
|
/// Keeps the role feed alive for consumers that render mentions outside the
|
|
/// channel timeline, such as search results. A membership change refreshes the
|
|
/// shared bot-role lookup below, regardless of which surface owns the channel.
|
|
class _ChannelBotRoleSubscription extends Notifier<int> {
|
|
final String channelId;
|
|
void Function()? _unsubscribe;
|
|
int _subscriptionVersion = 0;
|
|
|
|
_ChannelBotRoleSubscription(this.channelId);
|
|
|
|
@override
|
|
int build() {
|
|
final sessionState = ref.watch(relaySessionProvider);
|
|
final subscriptionVersion = ++_subscriptionVersion;
|
|
_clearSubscription();
|
|
ref.onDispose(() {
|
|
_subscriptionVersion++;
|
|
_clearSubscription();
|
|
});
|
|
|
|
if (sessionState.status != SessionStatus.connected) return 0;
|
|
Future.microtask(() => _subscribe(channelId, subscriptionVersion));
|
|
return 0;
|
|
}
|
|
|
|
Future<void> _subscribe(String channelId, int subscriptionVersion) async {
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
try {
|
|
final unsubscribe = await session.subscribe(
|
|
NostrFilter(
|
|
kinds: const [39002],
|
|
tags: {
|
|
'#h': [channelId],
|
|
},
|
|
).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000),
|
|
(_) {
|
|
if (_isCurrent(subscriptionVersion)) {
|
|
state++;
|
|
}
|
|
},
|
|
);
|
|
if (!_isCurrent(subscriptionVersion)) {
|
|
unsubscribe();
|
|
return;
|
|
}
|
|
_unsubscribe = unsubscribe;
|
|
} catch (error) {
|
|
if (_isCurrent(subscriptionVersion)) {
|
|
debugPrint(
|
|
'[ChannelBotRoleSubscription] failed for $channelId: $error',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isCurrent(int subscriptionVersion) =>
|
|
subscriptionVersion == _subscriptionVersion;
|
|
|
|
void _clearSubscription() {
|
|
_unsubscribe?.call();
|
|
_unsubscribe = null;
|
|
}
|
|
}
|
|
|
|
/// Monotonically increments when the channel's kind:39002 membership snapshot
|
|
/// changes. Channel-member and agent-role views share this source so remote
|
|
/// membership updates refresh both snapshots together.
|
|
final channelMembershipUpdateProvider = NotifierProvider.autoDispose
|
|
.family<_ChannelBotRoleSubscription, int, String>(
|
|
_ChannelBotRoleSubscription.new,
|
|
);
|
|
|
|
/// Bot pubkeys currently assigned a channel bot role.
|
|
final channelBotPubkeysProvider = FutureProvider.autoDispose
|
|
.family<Set<String>, String>((ref, channelId) async {
|
|
ref.watch(channelMembershipUpdateProvider(channelId));
|
|
final sessionState = ref.watch(relaySessionProvider);
|
|
if (sessionState.status != SessionStatus.connected) return const {};
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
final events = await session.fetchHistory(
|
|
NostrFilters.channelMembers(channelId),
|
|
);
|
|
if (events.isEmpty) return const {};
|
|
return _AgentPubkeySet({
|
|
for (final member in membersFromEvent(events.first))
|
|
if (member.role == 'bot') member.pubkey.toLowerCase(),
|
|
});
|
|
});
|
|
|
|
/// Pubkeys currently known to represent agents in a channel.
|
|
final agentMentionPubkeysProvider = Provider.autoDispose
|
|
.family<Set<String>, String>((ref, channelId) {
|
|
final channelBotPubkeys =
|
|
ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ??
|
|
const <String>{};
|
|
return agentPubkeysWithChannelBots(
|
|
knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider),
|
|
channelBotPubkeys: channelBotPubkeys,
|
|
);
|
|
});
|
|
|
|
class _AgentPubkeySet extends UnmodifiableSetView<String> {
|
|
_AgentPubkeySet(Iterable<String> pubkeys) : super(Set.unmodifiable(pubkeys));
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is Set && length == other.length && every(other.contains);
|
|
|
|
@override
|
|
int get hashCode => Object.hashAllUnordered(this);
|
|
}
|