mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
Show the Huddle action in owned Codex agent DMs and invite the agent before connecting audio. Revalidate eligibility on starts/recovery, ask before inviting into an existing call, and distinguish pending invitations from connected audio peers with actionable timeout feedback. DM hangup leaves shared room cleanup to the relay so another owner device stays connected. Pairs with https://github.com/block/buzz-app/pull/3048. Desktop and the Codex agent must remain running. Validated: 4,559 mobile tests, analysis, formatting, push hooks, and independent source review. Live phone-to-desktop audio and human trial remain pending. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Co-authored-by: Tom Brow <tomb@block.xyz> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.2 KiB
Dart
114 lines
4.2 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../../shared/agents/owned_agent_directory.dart';
|
|
import '../../shared/me/me_archived_agents.dart';
|
|
import '../../shared/me/me_sync.dart';
|
|
import '../../shared/relay/relay.dart';
|
|
import '../../shared/mentions/agent_identity_provider.dart';
|
|
import '../../shared/profile/user_cache_provider.dart';
|
|
import 'channel.dart';
|
|
import 'channel_management_provider.dart';
|
|
import 'channels_provider.dart';
|
|
|
|
/// Runtime is owner-signed discovery evidence, not permission to run an agent.
|
|
/// The hosting desktop rechecks its live runtime, protection and room access.
|
|
/// Derived from the owned-agent directory an agent DM already loads, so voice
|
|
/// adds no inventory read.
|
|
final ownedCodexVoiceAgentsProvider = FutureProvider<Set<String>>((ref) async {
|
|
ref.watch(_voiceInventoryChangesProvider);
|
|
final archived = ref.watch(meArchivedAgentsProvider.future);
|
|
final owned = await ref.watch(ownedAgentDirectoryProvider.future);
|
|
final hidden = await archived;
|
|
return {
|
|
for (final agent in owned)
|
|
if (agent.runtime == 'buzz.codex/codex' && !hidden.contains(agent.pubkey))
|
|
agent.pubkey,
|
|
};
|
|
});
|
|
|
|
/// Only the viewer's Codex peer in a writable one-to-one DM is offered voice.
|
|
String? dmVoiceAgent(Channel channel, String? viewer, Set<String> ownedCodex) {
|
|
final members = channel.participantPubkeys.toSet();
|
|
if (!channel.isDm ||
|
|
channel.isArchived ||
|
|
!channel.isMember ||
|
|
members.length != 2 ||
|
|
!members.contains(viewer)) {
|
|
return null;
|
|
}
|
|
final peer = members.firstWhere((key) => key != viewer);
|
|
return ownedCodex.contains(peer) ? peer : null;
|
|
}
|
|
|
|
// Subscribe separately from the snapshot so refreshing it does not replay the
|
|
// subscription and cause an invalidation loop. Reconnects resubscribe. A
|
|
// desktop inventory change rereads the directory, which mentions share.
|
|
final _voiceInventoryChangesProvider = Provider.autoDispose<void>((ref) {
|
|
final sync = ref.watch(meSyncProvider);
|
|
ref.watch(relaySessionProvider.select((state) => state.status));
|
|
var disposed = false;
|
|
void Function()? unsubscribe;
|
|
unawaited(() async {
|
|
try {
|
|
unsubscribe = await sync.session.subscribe(
|
|
NostrFilter(kinds: const [30177], authors: [sync.pubkey], limit: 0),
|
|
(_) {
|
|
if (!disposed) ref.invalidate(ownedAgentDirectoryProvider);
|
|
ref.invalidate(meArchivedAgentsProvider);
|
|
},
|
|
);
|
|
if (disposed) unsubscribe?.call();
|
|
} catch (error, stack) {
|
|
if (!disposed) {
|
|
FlutterError.reportError(
|
|
FlutterErrorDetails(exception: error, stack: stack),
|
|
);
|
|
}
|
|
}
|
|
}());
|
|
ref.onDispose(() {
|
|
disposed = true;
|
|
unsubscribe?.call();
|
|
});
|
|
});
|
|
|
|
/// Classification only: ordinary human/group DMs never depend on inventory
|
|
/// network availability. Admission still requires fresh signed evidence below.
|
|
final dmVoiceCandidateProvider = Provider.autoDispose.family<String?, String>((
|
|
ref,
|
|
channelId,
|
|
) {
|
|
final channel = ref.watch(channelByIdProvider(channelId));
|
|
if (channel == null) return null;
|
|
final known = ref.watch(agentMentionPubkeysProvider(channelId));
|
|
final profiles = ref.watch(userCacheProvider);
|
|
final owned =
|
|
ref.watch(ownedCodexVoiceAgentsProvider).value ?? const <String>{};
|
|
return dmVoiceAgent(channel, ref.watch(currentPubkeyProvider), {
|
|
...known,
|
|
...owned,
|
|
for (final profile in profiles.values)
|
|
if (profile.ownerPubkey != null) profile.pubkey,
|
|
});
|
|
});
|
|
|
|
/// All new-call paths, including recovery, resolve fresh signed evidence.
|
|
Future<String?> resolveDmVoiceAgent(Ref ref, String parentChannelId) async {
|
|
final channel = ref.read(channelByIdProvider(parentChannelId));
|
|
if (channel == null ||
|
|
ref.read(dmVoiceCandidateProvider(parentChannelId)) == null) {
|
|
return null;
|
|
}
|
|
ref.invalidate(ownedAgentDirectoryProvider);
|
|
ref.invalidate(meArchivedAgentsProvider);
|
|
final agents = await ref.read(ownedCodexVoiceAgentsProvider.future);
|
|
final agent = dmVoiceAgent(channel, ref.read(currentPubkeyProvider), agents);
|
|
if (agent == null) {
|
|
throw StateError('This agent is no longer available for voice.');
|
|
}
|
|
return agent;
|
|
}
|