Fix agent timeline identity and avatars (#46)

This commit is contained in:
Wes
2026-03-12 18:43:56 -07:00
committed by GitHub
parent 039d4eb2e5
commit ec6a5404fd
8 changed files with 102 additions and 25 deletions
+4 -1
View File
@@ -680,6 +680,8 @@ pub struct UserRecord {
pub pubkey: Vec<u8>,
/// Optional display name.
pub display_name: Option<String>,
/// Optional avatar image URL.
pub avatar_url: Option<String>,
/// Optional NIP-05 identifier (e.g. `user@example.com`).
pub nip05_handle: Option<String>,
}
@@ -794,7 +796,7 @@ pub async fn get_users_bulk(pool: &MySqlPool, pubkeys: &[Vec<u8>]) -> Result<Vec
// `.bind()` below. No user input is interpolated into the SQL string.
let placeholders = pubkeys.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
let sql = format!(
"SELECT pubkey, display_name, nip05_handle FROM users WHERE pubkey IN ({placeholders})"
"SELECT pubkey, display_name, avatar_url, nip05_handle FROM users WHERE pubkey IN ({placeholders})"
);
let mut q = sqlx::query(&sql);
@@ -809,6 +811,7 @@ pub async fn get_users_bulk(pool: &MySqlPool, pubkeys: &[Vec<u8>]) -> Result<Vec
out.push(UserRecord {
pubkey: row.try_get("pubkey")?,
display_name: row.try_get("display_name")?,
avatar_url: row.try_get("avatar_url")?,
nip05_handle: row.try_get("nip05_handle")?,
});
}
+29 -2
View File
@@ -1,3 +1,4 @@
use nostr::{EventBuilder, Kind, Tag};
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{ServerCapabilities, ServerInfo},
@@ -634,8 +635,34 @@ impl SproutMcpServer {
);
}
// Route all messages through REST — avoids WebSocket timeout (~5 min).
// The relay determines kind from channel_type; parent_event_id is optional.
// Use a user-signed WebSocket event for top-level messages so downstream
// clients see the agent pubkey directly rather than the relay pubkey.
// Threaded replies still go through REST because that path handles the
// reply ancestry tags and DB bookkeeping for us.
if p.parent_event_id.is_none() {
let kind = Kind::from(p.kind.unwrap_or(40001));
let tags = vec![match Tag::parse(&["h", &p.channel_id]) {
Ok(tag) => tag,
Err(e) => return format!("Error: failed to build channel tag: {e}"),
}];
let event =
match EventBuilder::new(kind, p.content, tags).sign_with_keys(self.client.keys()) {
Ok(event) => event,
Err(e) => return format!("Error: failed to sign message event: {e}"),
};
return match self.client.send_event(event).await {
Ok(ok) => serde_json::json!({
"event_id": ok.event_id,
"accepted": ok.accepted,
"message": ok.message,
})
.to_string(),
Err(e) => format!("Error: {e}"),
};
}
let mut body = serde_json::json!({
"content": p.content,
});
+2 -1
View File
@@ -173,7 +173,7 @@ pub struct BatchProfilesRequest {
pub pubkeys: Vec<String>,
}
/// `POST /api/users/batch` — resolve display names for multiple pubkeys.
/// `POST /api/users/batch` — resolve profile summaries for multiple pubkeys.
pub async fn get_users_batch(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
@@ -237,6 +237,7 @@ pub async fn get_users_batch(
hex,
serde_json::json!({
"display_name": r.display_name,
"avatar_url": r.avatar_url,
"nip05_handle": r.nip05_handle,
}),
);
+1
View File
@@ -33,6 +33,7 @@ pub struct ProfileInfo {
#[derive(Serialize, Deserialize)]
pub struct UserProfileSummaryInfo {
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
}
@@ -6,18 +6,31 @@ import {
type UserProfileLookup,
} from "@/features/profile/lib/identity";
function getEffectiveAuthorPubkey(event: RelayEvent) {
const [firstTag] = event.tags;
if (
firstTag?.[0] === "p" &&
firstTag[1] &&
event.tags.some((tag) => tag[0] === "h")
) {
return firstTag[1];
}
return event.pubkey;
}
function formatMessageAuthor(
event: RelayEvent,
channel: Channel | null,
currentPubkey: string | undefined,
profiles: UserProfileLookup | undefined,
) {
const authorPubkey = getEffectiveAuthorPubkey(event);
const fallbackName =
channel?.channelType === "dm"
? (() => {
const participantIndex = channel.participantPubkeys.indexOf(
event.pubkey,
);
const participantIndex =
channel.participantPubkeys.indexOf(authorPubkey);
if (participantIndex < 0) {
return null;
}
@@ -27,7 +40,7 @@ function formatMessageAuthor(
: null;
return resolveUserLabel({
pubkey: event.pubkey,
pubkey: authorPubkey,
currentPubkey,
fallbackName,
profiles,
@@ -35,6 +48,21 @@ function formatMessageAuthor(
});
}
function getAuthorAvatarUrl(input: {
authorPubkey: string;
currentPubkey: string | undefined;
currentUserAvatarUrl: string | null;
profiles: UserProfileLookup | undefined;
}) {
const { authorPubkey, currentPubkey, currentUserAvatarUrl, profiles } = input;
if (currentPubkey === authorPubkey) {
return currentUserAvatarUrl ?? null;
}
return profiles?.[authorPubkey.toLowerCase()]?.avatarUrl ?? null;
}
export function formatTimelineMessages(
events: RelayEvent[],
channel: Channel | null,
@@ -42,24 +70,36 @@ export function formatTimelineMessages(
currentUserAvatarUrl: string | null,
profiles?: UserProfileLookup,
): TimelineMessage[] {
return events.map((event) => ({
id: event.id,
pubkey: event.pubkey,
author: formatMessageAuthor(event, channel, currentPubkey, profiles),
avatarUrl:
currentPubkey === event.pubkey ? (currentUserAvatarUrl ?? null) : null,
time: new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(new Date(event.created_at * 1_000)),
body: event.content,
accent: currentPubkey === event.pubkey,
pending: event.pending,
kind: event.kind,
tags: event.tags,
}));
return events.map((event) => {
const authorPubkey = getEffectiveAuthorPubkey(event);
return {
id: event.id,
pubkey: authorPubkey,
author: formatMessageAuthor(event, channel, currentPubkey, profiles),
avatarUrl: getAuthorAvatarUrl({
authorPubkey,
currentPubkey,
currentUserAvatarUrl,
profiles,
}),
time: new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
}).format(new Date(event.created_at * 1_000)),
body: event.content,
accent: currentPubkey === authorPubkey,
pending: event.pending,
kind: event.kind,
tags: event.tags,
};
});
}
export function collectMessageAuthorPubkeys(events: RelayEvent[]) {
return [...new Set(events.map((event) => event.pubkey.toLowerCase()))];
return [
...new Set(
events.map((event) => getEffectiveAuthorPubkey(event).toLowerCase()),
),
];
}
+2
View File
@@ -45,6 +45,7 @@ type RawProfile = {
type RawUserProfileSummary = {
display_name: string | null;
avatar_url: string | null;
nip05_handle: string | null;
};
@@ -297,6 +298,7 @@ function fromRawUserProfileSummary(
): UserProfileSummary {
return {
displayName: profile.display_name,
avatarUrl: profile.avatar_url,
nip05Handle: profile.nip05_handle,
};
}
+1
View File
@@ -90,6 +90,7 @@ export type Profile = {
export type UserProfileSummary = {
displayName: string | null;
avatarUrl: string | null;
nip05Handle: string | null;
};
+2
View File
@@ -43,6 +43,7 @@ type RawProfile = {
type RawUserProfileSummary = {
display_name: string | null;
avatar_url: string | null;
nip05_handle: string | null;
};
@@ -1098,6 +1099,7 @@ async function handleGetUsersBatch(
profiles[normalizedPubkey] = {
display_name: profile.display_name,
avatar_url: profile.avatar_url,
nip05_handle: profile.nip05_handle,
};
}