Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,10 @@ export {
isProfileUpdateMessage,
isProfileSnapshotMessage,
ProfileUpdateCodec,
ProfileUpdateV2Codec,
ProfileSnapshotCodec,
ContentTypeProfileUpdate,
ContentTypeProfileUpdateV2,
ContentTypeProfileSnapshot,
MemberKind,
type ProfileUpdateContent,
Expand Down
7 changes: 6 additions & 1 deletion src/utils/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@ import {
} from "@xmtp/content-type-remote-attachment";
import { Client, IdentifierKind, LogLevel } from "@xmtp/node-sdk";
import type { ClientOptions } from "@xmtp/node-sdk";
import { ProfileUpdateCodec, ProfileSnapshotCodec } from "./profileMessages.js";
import {
ProfileUpdateCodec,
ProfileUpdateV2Codec,
ProfileSnapshotCodec,
} from "./profileMessages.js";
import { JoinRequestCodec } from "./joinRequest.js";
import { TypingIndicatorCodec } from "./typingIndicator.js";
import { ExplodeSettingsCodec } from "./explodeSettings.js";
Expand Down Expand Up @@ -139,6 +143,7 @@ async function buildClient(
new AttachmentCodec(),
new RemoteAttachmentCodec(),
new ProfileUpdateCodec() as any,
new ProfileUpdateV2Codec() as any,
new ProfileSnapshotCodec() as any,
new JoinRequestCodec() as any,
new TypingIndicatorCodec() as any,
Expand Down
65 changes: 64 additions & 1 deletion src/utils/profileMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ const ProfileUpdateType = new protobuf.Type("ProfileUpdate")
.add(new protobuf.Field("name", 1, "string", "optional"))
.add(new protobuf.Field("encrypted_image", 2, "EncryptedProfileImageRef", "optional"))
.add(new protobuf.Field("member_kind", 3, "MemberKind", "optional"))
.add(new protobuf.MapField("metadata", 4, "string", "MetadataValue"));
.add(new protobuf.MapField("metadata", 4, "string", "MetadataValue"))
// v2. Field 2 stays declared rather than reserved: senders that have not
// upgraded still populate it, and reserving it would make their avatars
// decode as absent instead of failing loudly.
.add(new protobuf.Field("avatar_url", 5, "string", "optional"))
.add(new protobuf.Field("version", 6, "uint64", "optional"));

const MemberProfileType = new protobuf.Type("MemberProfile")
.add(new protobuf.Field("inbox_id", 1, "bytes"))
Expand Down Expand Up @@ -75,6 +80,24 @@ export const ContentTypeProfileUpdate = {
versionMinor: 0,
};

/**
* What the iOS client sends once profiles live on the backend: a plain avatar
* URL and a version instead of an encrypted image. Registered for reading,
* because the XMTP codec registry keys on the full version string - without
* this, a v2 message finds no codec and arrives as raw EncodedContent.
*
* The CLI keeps sending v1. The wire format is a superset, so v1 carries the
* new fields fine, and a client that predates v2 would ignore a v2-typed
* message entirely - which for an agent means its name silently stops
* updating on every un-upgraded client, in exchange for nothing.
*/
export const ContentTypeProfileUpdateV2 = {
authorityId: "convos.org",
typeId: "profile_update",
versionMajor: 2,
versionMinor: 0,
};

export const ContentTypeProfileSnapshot = {
authorityId: "convos.org",
typeId: "profile_snapshot",
Expand Down Expand Up @@ -112,9 +135,14 @@ export type ProfileMetadata = Record<string, ProfileMetadataValue>;

export interface ProfileUpdateContent {
name?: string;
/** Pre-v2 senders only. New senders carry `avatarUrl` instead. */
encryptedImage?: EncryptedProfileImageRef;
memberKind?: MemberKind;
metadata?: ProfileMetadata;
/** Plain CDN URL of the backend-hosted avatar. */
avatarUrl?: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium utils/profileMessages.ts:143

resolveProfilesFromMessages drops avatarUrl and version from every decoded ProfileUpdate, so v2 profile updates resolve without the backend avatar or version. Add these fields to ResolvedProfile and propagate them through both update and snapshot resolution (mapping avatarUrl to image if that is the intended API).

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/utils/profileMessages.ts around line 143:

`resolveProfilesFromMessages` drops `avatarUrl` and `version` from every decoded `ProfileUpdate`, so v2 profile updates resolve without the backend avatar or version. Add these fields to `ResolvedProfile` and propagate them through both update and snapshot resolution (mapping `avatarUrl` to `image` if that is the intended API).

/** The backend's monotonic profile version, so a reader can skip a fetch. */
version?: number;
}

export interface MemberProfileEntry {
Expand Down Expand Up @@ -274,6 +302,14 @@ export function encodeProfileUpdate(update: ProfileUpdateContent): EncodedConten
obj.metadata = metadataToProto(update.metadata);
}

if (update.avatarUrl !== undefined) {
obj.avatar_url = update.avatarUrl;
}

if (update.version !== undefined) {
obj.version = update.version;
}

const errMsg = ProfileUpdateType.verify(obj);
if (errMsg) throw new Error(`Invalid ProfileUpdate: ${errMsg}`);

Expand Down Expand Up @@ -344,6 +380,8 @@ interface RawProfileUpdateMsg {
encrypted_image?: { url: string; salt: Uint8Array; nonce: Uint8Array } | null;
member_kind?: number;
metadata?: Record<string, RawMetadataValue>;
avatar_url?: string;
version?: number | { toNumber(): number };
}

/**
Expand Down Expand Up @@ -377,6 +415,21 @@ export function decodeProfileUpdate(encoded: EncodedContent): ProfileUpdateConte
result.memberKind = msg.member_kind as MemberKind;
}

if (msg.avatar_url) {
result.avatarUrl = msg.avatar_url;
}

if (msg.version !== undefined && msg.version !== null) {
// protobufjs hands back a Long for uint64 unless configured otherwise, and
// decodes an unset field as 0 rather than leaving it out. Backend versions
// start at 1, so 0 means the sender did not set one.
const version =
typeof msg.version === "number" ? msg.version : msg.version.toNumber();
if (version > 0) {
result.version = version;
}
}

const meta = metadataFromProto(msg.metadata);
if (meta) {
result.metadata = meta;
Expand Down Expand Up @@ -470,6 +523,16 @@ export class ProfileUpdateCodec implements ContentCodec<ProfileUpdateContent> {
}
}

/**
* Reads v2 profile updates. Decoding is identical - the proto is a superset
* and only the content type differs, which is what the registry keys on.
*/
export class ProfileUpdateV2Codec extends ProfileUpdateCodec {
get contentType(): ContentTypeId {
return ContentTypeProfileUpdateV2;
}
}

/**
* XMTP ContentCodec for ProfileSnapshot messages.
* Register this with the XMTP client so it can decode profile messages.
Expand Down
68 changes: 68 additions & 0 deletions test/utils/profileMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
type ProfileSnapshotContent,
type ProfileMetadata,
type ProfileMetadataValue,
ProfileUpdateV2Codec,
ContentTypeProfileUpdateV2,
} from "../../src/utils/profileMessages.js";
import {
isDisplayableMessage,
Expand Down Expand Up @@ -402,6 +404,72 @@ describe("appData corruption prevention", () => {
});
});

// ─── v2 Wire Format ───

describe("ProfileUpdate v2 fields", () => {
it("round-trips the avatar URL and version", () => {
const encoded = encodeProfileUpdate({
name: "Ada",
avatarUrl: "https://cdn.convos.org/profiles/abc.jpg",
version: 7,
});
const decoded = decodeProfileUpdate(encoded);
expect(decoded.name).toBe("Ada");
expect(decoded.avatarUrl).toBe("https://cdn.convos.org/profiles/abc.jpg");
expect(decoded.version).toBe(7);
});

it("omits the new fields when they are not set", () => {
const decoded = decodeProfileUpdate(encodeProfileUpdate({ name: "Ada" }));
expect(decoded.avatarUrl).toBeUndefined();
expect(decoded.version).toBeUndefined();
});

// The reason field 2 is still declared rather than reserved: a v1 sender
// populates it, and reserving it would decode their avatar as absent.
it("still decodes a v1 payload's encrypted image", () => {
const encoded = encodeProfileUpdate({
name: "Grace",
encryptedImage: {
url: "https://cdn.convos.org/old.enc",
salt: new Uint8Array(32).fill(1),
nonce: new Uint8Array(12).fill(2),
},
});
const decoded = decodeProfileUpdate(encoded);
expect(decoded.encryptedImage?.url).toBe("https://cdn.convos.org/old.enc");
expect(decoded.name).toBe("Grace");
});

// The XMTP codec registry keys on the full version string, so reading v2
// needs its own registered codec - not just a tolerant matcher.
it("registers a v2 codec that decodes the same payload", () => {
const v2 = new ProfileUpdateV2Codec();
expect(v2.contentType).toEqual(ContentTypeProfileUpdateV2);
expect(v2.contentType.versionMajor).toBe(2);
const decoded = v2.decode(encodeProfileUpdate({ name: "Alan" }));
expect(decoded.name).toBe("Alan");
});

// Sending stays on v1 deliberately: the proto is a superset, so the new
// fields ride along, and a client that predates v2 keeps decoding it.
it("keeps sending v1 while carrying the v2 fields", () => {
const encoded = encodeProfileUpdate({ name: "Ada", version: 3 });
expect(encoded.type?.versionMajor).toBe(1);
expect(decodeProfileUpdate(encoded).version).toBe(3);
});

it("recognizes a v2 message as a profile update", () => {
const message = mockMessage({
authorityId: "convos.org",
typeId: "profile_update",
versionMajor: 2,
versionMinor: 0,
});
expect(isProfileUpdateMessage(message)).toBe(true);
});
});

// ─── Encoded Content Type ───

describe("encoded content type", () => {
Expand Down
Loading