import type { BuzzAdapter } from "../adapters/buzz/adapter.ts"; import { deleteThread } from "../adapters/buzz/thread-deletion.ts"; import type { BuzzEvent } from "../adapters/buzz/types.ts"; import { isElevatedIn, signingKeyring } from "../message-ownership.ts"; import { BlobRefused, isServedByTheRelay, putBlob, readAuthorizationHeader } from "../adapters/buzz/blossom.ts"; import { fetchFromRelay } from "../adapters/buzz/relay-trust.ts"; import type { AttachmentAlreadyKept } from "../outgoing-attachment.ts"; import { withSpooledMedia } from "./media-spool.ts"; import { withCanonicalMedia } from "./media-canonical.ts"; import { rangesOf } from "./ranged-read.ts"; import { MalformedRequest } from "./parse.ts"; import { addReactionAsUser, removeReactionAsUser } from "../adapters/buzz/user-reactions.ts"; import { mentionTags } from "../adapters/buzz/user-mentions.ts"; import { emojiTagsFor, listCustomEmojiAsUser, normalizedShortcode, shortcodesIn, type CustomEmoji } from "../adapters/buzz/user-emoji.ts"; import { markConversationReadAsUser, unreadCountsAsUser } from "../adapters/buzz/user-read-state.ts"; import { announceTypingAsUser } from "../adapters/buzz/user-typing.ts"; import { searchChannelMessagesAsUser } from "../adapters/buzz/user-message-search.ts"; import { hideDirectMessageAsUser, revealDirectMessageAsUser } from "../adapters/buzz/user-dm-visibility.ts"; import { listInboxAsUser } from "../adapters/buzz/user-inbox.ts"; import { deleteChannelMessageAsUser, editChannelMessageAsUser, ensureUserDirectMessageChannel, listChannelMessagesAsUser, listUserConversations, profilePictureURLAsUser, pubkeyFromSecret, sendChannelMessageAsUser, timeZoneTags, } from "../adapters/buzz/user-session.ts"; import { addChannelMembersAsUser, addChannelOwnerAsUser, createChannelAsUser, deleteChannelAsUser, handOverOwnershipAsUser, joinChannelAsUser, leaveChannelAsUser, listOpenChannelsAsUser, removeChannelMemberAsUser, } from "../adapters/buzz/user-channels.ts"; import { CredentialRefused, UnsupportedByPlatform, requireMatchingCredential, type ActorCredential, type CreatedPersonalChannel, type CredentialRequirement, type PersonalConversation, type PersonalEmoji, type PersonalGateway, type IssuedCredential, type NewPersonalChannel, type PersonalIdentity, type KeptMedia, type MediaSource, MediaRefused, type PersonalMentions, type PersonalImage, type PersonalInbox, type PersonalInboxWindow, type PersonalMessage, type PersonalMessageMatch, type PersonalMessagePage, type PersonalPerson, ReadRefused, } from "./gateway.ts"; import { createBuzzArrivalWatch, type BuzzArrivalWatch } from "./buzz-arrival-watch.ts"; export type BuzzPersonalSettings = { relayURL: string; authTagJSON?: string; admindBaseURL?: string; }; export function createBuzzPersonalGateway( adapter: BuzzAdapter, settings: BuzzPersonalSettings, ): PersonalGateway { return new BuzzPersonalGateway(adapter, settings); } class BuzzPersonalGateway implements PersonalGateway { readonly platform = "buzz"; readonly credentialKind = "buzz-secret"; private readonly arrivals: BuzzArrivalWatch; constructor( private readonly adapter: BuzzAdapter, private readonly settings: BuzzPersonalSettings, ) { this.arrivals = createBuzzArrivalWatch(settings.relayURL, settings.authTagJSON); } async watchArrivals( actor: ActorCredential, arrivalsURL: string, typingURL?: string, withdrawalsURL?: string, ): Promise { this.require(actor); await this.arrivals.watch(actor.secret, arrivalsURL, typingURL, withdrawalsURL); } async announceTyping(actor: ActorCredential, conversationID: string): Promise { this.require(actor); await announceTypingAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, authTagJSON: this.settings.authTagJSON, }); } credentialRequirement(): CredentialRequirement { return { kind: "secret", credentialKind: this.credentialKind, fields: [{ name: "secret", label: "Your Buzz secret key", isSecret: true }], }; } async issueCredential(answers: Record): Promise { const secret = answers.secret ?? ""; if (!secret) throw new CredentialRefused("a Buzz identity needs its secret key"); const credential = { kind: this.credentialKind, secret }; return { credential, identity: await this.identity(credential) }; } async identity(actor: ActorCredential): Promise { this.require(actor); const externalID = pubkeyFromSecret(actor.secret); const user = await this.adapter.getUser(externalID).catch(() => null); return { externalID, name: user?.fullName, serverURL: this.settings.relayURL }; } async listConversations(actor: ActorCredential): Promise { this.require(actor); const conversations = await listUserConversations(this.settings.relayURL, actor.secret); const unreadCounts = await unreadCountsAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelIDs: conversations.map((conversation) => conversation.channelID), authTagJSON: this.settings.authTagJSON, }); return conversations.map((conversation) => ({ id: conversation.channelID, name: conversation.name, kind: conversation.isDM ? "dm" : "group", isHidden: conversation.isHidden, isPrivate: conversation.isPrivate, avatarURL: conversation.avatarURL, participantExternalIDs: conversation.participantPubkeyHexes, description: conversation.description, roleOfExternalID: conversation.roleOfPubkeyHex, unreadCount: unreadCounts.get(conversation.channelID) ?? 0, })); } async listPeople(actor: ActorCredential): Promise { this.require(actor); const people = await this.adapter.listPeople(pubkeyFromSecret(actor.secret)); return people.map((person) => ({ externalID: person.id, name: person.name, avatarURL: person.avatarURL, })); } async ensureDirectConversation( actor: ActorCredential, counterpartExternalIDs: string[], ): Promise { this.require(actor); const counterpart = counterpartExternalIDs[0] ?? this.adapter.botPubkey; if (counterpartExternalIDs.length > 1) { throw new UnsupportedByPlatform(this.platform, "open a group direct conversation"); } const channel = await ensureUserDirectMessageChannel( this.settings.relayURL, actor.secret, counterpart, ); await this.revealDirectConversation(actor, channel.channelID); return { id: channel.channelID, name: "", kind: "dm", isPrivate: true }; } async hideDirectConversation(actor: ActorCredential, conversationID: string): Promise { this.require(actor); await hideDirectMessageAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID }); } async revealDirectConversation(actor: ActorCredential, conversationID: string): Promise { this.require(actor); await revealDirectMessageAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID }); } async createChannel(actor: ActorCredential, channel: NewPersonalChannel): Promise { this.require(actor); const created = await createChannelAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, spec: { name: channel.name, description: channel.description, visibility: channel.visibility, memberPubkeyHexes: this.membersOfNewChannel(channel), }, }); return { id: created.channelID, name: created.name, description: channel.description, kind: "group", isPrivate: channel.visibility === "private", uninvitedExternalIDs: created.uninvitedPubkeyHexes, }; } private membersOfNewChannel(channel: NewPersonalChannel): string[] { if (channel.visibility !== "open") return channel.memberExternalIDs; return [...new Set([...channel.memberExternalIDs, this.adapter.botPubkey])]; } async listOpenChannels(actor: ActorCredential): Promise { this.require(actor); const channels = await listOpenChannelsAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret }); return channels.map((channel) => ({ id: channel.channelID, name: channel.name, description: channel.description, kind: "group", isPrivate: false, })); } async joinChannel(actor: ActorCredential, conversationID: string): Promise { this.require(actor); await joinChannelAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID }); } async addChannelMembers( actor: ActorCredential, conversationID: string, memberExternalIDs: string[], ): Promise<{ uninvitedExternalIDs: string[] }> { this.require(actor); const added = await addChannelMembersAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, memberPubkeyHexes: memberExternalIDs, }); return { uninvitedExternalIDs: added.uninvitedPubkeyHexes }; } async removeChannelMember( actor: ActorCredential, conversationID: string, externalID: string, ): Promise { this.require(actor); await removeChannelMemberAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, memberPubkeyHex: externalID, }); } async leaveChannel(actor: ActorCredential, conversationID: string): Promise { this.require(actor); await leaveChannelAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID }); } async handOverChannel( actor: ActorCredential, conversationID: string, newOwnerExternalID: string, ): Promise { this.require(actor); await handOverOwnershipAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, newOwnerPubkeyHex: newOwnerExternalID, }); } async addChannelOwner( actor: ActorCredential, conversationID: string, externalID: string, ): Promise<{ newToChannel: boolean }> { this.require(actor); return addChannelOwnerAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, newOwnerPubkeyHex: externalID, }); } async deleteChannel(actor: ActorCredential, conversationID: string): Promise { this.require(actor); await deleteChannelAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID }); } async markConversationRead( actor: ActorCredential, conversationID: string, readAt: Date, threadRootID?: string, ): Promise { this.require(actor); await markConversationReadAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, threadRootID, readAtSeconds: Math.floor(readAt.getTime() / 1000), authTagJSON: this.settings.authTagJSON, }); } async listInbox(actor: ActorCredential, window: PersonalInboxWindow): Promise { this.require(actor); const conversations = await listUserConversations(this.settings.relayURL, actor.secret, { withProfiles: false }); const inbox = await listInboxAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, directChannelIDs: conversations.filter((conversation) => conversation.isDM).map((conversation) => conversation.channelID), groupChannelIDs: conversations.filter((conversation) => !conversation.isDM).map((conversation) => conversation.channelID), sinceSeconds: window.since ? Math.floor(window.since.getTime() / 1000) : undefined, untilSeconds: window.until ? Math.floor(window.until.getTime() / 1000) : undefined, heldIDs: window.heldIDs, authTagJSON: this.settings.authTagJSON, }); const items = inbox.items.map((item) => ({ reason: item.reason, isUnread: item.isUnread, message: { id: item.message.id, conversationID: item.message.conversationID, parentID: item.message.parentID, authorExternalID: item.message.authorPubkeyHex, body: item.message.body, postedAt: item.message.postedAt, }, })); return { items, held: inbox.held, hasMoreBefore: inbox.hasMoreBefore }; } async listMessages( actor: ActorCredential, conversationID: string, before?: string, ): Promise { this.require(actor); const wanted = 50; const page = await listChannelMessagesAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, limit: wanted, before, authTagJSON: this.settings.authTagJSON, }); return { messages: page.messages.map((message) => ({ id: message.id, conversationID: message.conversationID, parentID: message.parentID, authorExternalID: message.authorPubkeyHex, body: message.body, postedAt: message.postedAt, mentions: { externalIDs: message.mentions.pubkeyHexes, isEveryone: message.mentions.isEveryone }, reactions: message.reactions.map((reaction) => ({ emoji: reaction.emoji, imageURL: reaction.imageURL, byExternalIDs: reaction.byPubkeyHexes, })), attachments: message.attachments.map((attachment) => ({ id: attachment.url, filename: attachment.filename, contentType: attachment.contentType, sizeBytes: attachment.sizeBytes, digest: attachment.digest, })), })), hasMoreBefore: page.hasMoreBefore, }; } async searchMessages( actor: ActorCredential, conversationID: string, query: string, ): Promise { this.require(actor); const matches = await searchChannelMessagesAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, query, authTagJSON: this.settings.authTagJSON, }); return matches.map((match) => ({ id: match.id, conversationID: match.conversationID, parentID: match.parentID, authorExternalID: match.authorPubkeyHex, body: match.body, postedAt: match.postedAt, })); } async sendMessage( actor: ActorCredential, conversationID: string, body: string, parentID?: string, attachments: AttachmentAlreadyKept[] = [], mentions?: PersonalMentions, timeZone?: string, ): Promise { this.require(actor); const sent = await sendChannelMessageAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, message: body, attachments, replyToRootId: parentID, extraTags: [ ...mentionTags(mentions && { pubkeyHexes: mentions.externalIDs, isEveryone: mentions.isEveryone }), ...(await this.customEmojiTagsFor(actor, body)), ...timeZoneTags(timeZone), ], authTagJSON: this.settings.authTagJSON, }); return { id: sent.id, conversationID, parentID, authorExternalID: pubkeyFromSecret(actor.secret), body: sent.body, postedAt: new Date().toISOString(), mentions, reactions: [], attachments: sent.attachments.map((attachment) => ({ id: attachment.url, filename: attachment.filename, contentType: attachment.contentType, sizeBytes: attachment.sizeBytes, digest: attachment.digest, })), }; } async editMessage( actor: ActorCredential, conversationID: string, messageID: string, body: string, mentions?: PersonalMentions, ): Promise { this.require(actor); if (await this.isWrittenByTheAgent(messageID)) { const edited = await this.adapter.editMessage( this.adapter.encodeThreadId({ channelId: conversationID }), messageID, body, ); return this.agentMessage(edited.id, conversationID, body); } const editID = await editChannelMessageAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, targetEventId: messageID, message: body, extraTags: [ ...mentionTags(mentions && { pubkeyHexes: mentions.externalIDs, isEveryone: mentions.isEveryone }), ...(await this.customEmojiTagsFor(actor, body)), ], authTagJSON: this.settings.authTagJSON, }); return { id: editID, conversationID, authorExternalID: pubkeyFromSecret(actor.secret), body, postedAt: new Date().toISOString(), editedAt: new Date().toISOString(), mentions, reactions: [], attachments: [], }; } async deleteMessage( actor: ActorCredential, conversationID: string, messageID: string, ): Promise { this.require(actor); const root = await this.adapter.readMessageEvent(messageID); const channelID = await this.adapter.channelOwningMessage( this.adapter.encodeThreadId({ channelId: conversationID }), messageID, ); const keyring = signingKeyring(this.settings.admindBaseURL); await deleteThread({ relay: this.adapter, channelID, rootEventId: messageID, mayClearReplies: () => this.mayClearReplies(actor, root, channelID), deleteRoot: () => this.deleteOneMessage(actor, channelID, messageID), deleteReply: (reply) => this.deleteSomebodyElses(channelID, reply, keyring), }); } private async mayClearReplies( actor: ActorCredential, root: BuzzEvent | undefined, channelID: string, ): Promise { if (!root) return false; if (root.pubkey !== this.adapter.botPubkey) return true; return isElevatedIn(this.adapter, channelID, pubkeyFromSecret(actor.secret), true); } private async deleteOneMessage( actor: ActorCredential, conversationID: string, messageID: string, ): Promise { if (await this.isWrittenByTheAgent(messageID)) { await this.adapter.deleteMessage(this.adapter.encodeThreadId({ channelId: conversationID }), messageID); return; } await deleteChannelMessageAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, targetEventId: messageID, authTagJSON: this.settings.authTagJSON, }); } private async deleteSomebodyElses( channelID: string, reply: BuzzEvent, keyring: (author: string, id: string) => Promise, ): Promise { if (reply.pubkey === this.adapter.botPubkey) { await this.adapter.deleteMessage(this.adapter.encodeThreadId({ channelId: channelID }), reply.id); return; } await deleteChannelMessageAsUser({ relayURL: this.settings.relayURL, userSecretHex: await keyring(reply.pubkey, reply.id), channelID, targetEventId: reply.id, authTagJSON: this.settings.authTagJSON, }); } async addReaction( actor: ActorCredential, conversationID: string, messageID: string, emoji: string, ): Promise { this.require(actor); const custom = await this.customEmojiNamed(actor, emoji); await addReactionAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, targetEventId: messageID, emoji: custom ? `:${custom.shortcode}:` : emoji, extraTags: custom && [["emoji", custom.shortcode, custom.url]], authTagJSON: this.settings.authTagJSON, }); } async removeReaction( actor: ActorCredential, conversationID: string, messageID: string, emoji: string, ): Promise { this.require(actor); await removeReactionAsUser({ relayURL: this.settings.relayURL, userSecretHex: actor.secret, channelID: conversationID, targetEventId: messageID, emoji, authTagJSON: this.settings.authTagJSON, }); } async listCustomEmoji(actor: ActorCredential): Promise { this.require(actor); const community = await this.communityEmoji(actor); return community.map((emoji) => ({ name: emoji.shortcode })); } async readCustomEmojiImage( actor: ActorCredential, name: string, largestBytes: number, ): Promise { this.require(actor); const emoji = await this.customEmojiNamed(actor, name); if (!emoji) return null; const held = await readWithinLimit(emoji.url, largestBytes, "image/png", this.relayReadHeaders(actor, emoji.url)); if (!held) return null; return { dataURL: `data:${held.contentType};base64,${held.contentBase64}` }; } // A buzz profile names a picture by url rather than carrying it, and the // caller wants the bytes. async readProfilePicture( actor: ActorCredential, externalID: string, largestBytes: number, ): Promise { this.require(actor); const pictureURL = await profilePictureURLAsUser(this.settings.relayURL, actor.secret, externalID); if (!pictureURL) return null; const held = await readWithinLimit(pictureURL, largestBytes, "image/png", this.relayReadHeaders(actor, pictureURL)); if (!held) return null; return { dataURL: `data:${held.contentType};base64,${held.contentBase64}` }; } async readMedia(actor: ActorCredential, url: string, range: string): Promise { this.require(actor); if (!isServedByTheRelay(url, this.settings.relayURL)) { throw new MalformedRequest(`${url} is not held by this messenger's file store`); } const response = await fetchFromRelay(url, { headers: { ...this.relayReadHeaders(actor, url), Range: range } }); if (response.status !== 206 && response.status !== 200 && response.status !== 416) { throw new ReadRefused("buzz", url, response.status); } const headers = new Headers({ "Content-Type": response.headers.get("content-type") ?? "application/octet-stream", }); for (const name of ["content-length", "content-range"]) { const value = response.headers.get(name); if (value) headers.set(name, value); } return new Response(response.body, { status: response.status, headers }); } async uploadMedia(actor: ActorCredential, source: MediaSource): Promise { this.require(actor); const ranges = rangesOf((rangeHeader) => fetch(source.url, { headers: { Range: rangeHeader } })); return withSpooledMedia(ranges, (spooled) => withCanonicalMedia(spooled, source.contentType, async (media) => { const body = { body: Bun.file(media.path).stream(), digestHex: media.digestHex, sizeBytes: media.sizeBytes }; const blob = await putBlob(this.settings.relayURL, actor.secret, body, media.contentType).catch((error: unknown) => { if (error instanceof BlobRefused && error.willRefuseAgain) { throw new MediaRefused(error.status, error.reason, media.digestHex, media.sizeBytes); } throw error; }); return { address: blob.url, digest: blob.sha256, sizeBytes: blob.size, contentType: blob.mimeType }; })); } private communityEmoji(actor: ActorCredential): Promise { return listCustomEmojiAsUser(this.settings.relayURL, actor.secret, this.settings.authTagJSON); } private async customEmojiNamed(actor: ActorCredential, name: string): Promise { const shortcode = normalizedShortcode(name); if (!shortcode) return undefined; const community = await this.communityEmoji(actor); return community.find((emoji) => emoji.shortcode === shortcode); } private async customEmojiTagsFor(actor: ActorCredential, text: string): Promise { if (shortcodesIn(text).length === 0) return []; return emojiTagsFor(text, await this.communityEmoji(actor)); } private relayReadHeaders(actor: ActorCredential, url: string): Record { if (!isServedByTheRelay(url, this.settings.relayURL)) return {}; return { Authorization: readAuthorizationHeader(actor.secret, url) }; } // A person's own key cannot sign away the agent's message, so a change to // one is published by the agent. Everything else stays the person's own // event and the relay decides whether they may make it. private async isWrittenByTheAgent(messageID: string): Promise { const target = await this.adapter.readMessageEvent(messageID); return target?.pubkey === this.adapter.botPubkey; } private agentMessage(messageID: string, conversationID: string, body: string): PersonalMessage { const now = new Date().toISOString(); return { id: messageID, conversationID, authorExternalID: this.adapter.botPubkey, body, postedAt: now, editedAt: now, reactions: [], attachments: [], }; } private require(actor: ActorCredential): void { requireMatchingCredential(this, actor); } } // A file too big for the caller is not an error to raise, it is a file they do // not get. async function readWithinLimit( url: string, largestBytes: number, fallbackContentType: string, headers: Record, ): Promise<{ contentType: string; contentBase64: string } | null> { const response = await fetchFromRelay(url, { headers }); if (!response.ok) throw new ReadRefused("buzz", url, response.status); const bytes = new Uint8Array(await response.arrayBuffer()); if (bytes.byteLength > largestBytes) return null; return { contentType: response.headers.get("content-type") ?? fallbackContentType, contentBase64: Buffer.from(bytes).toString("base64"), }; }