import type { AttachmentAlreadyKept } from "../outgoing-attachment.ts"; export type ActorCredential = { kind: string; secret: string; }; // A person signing into the messenger app itself needs the address to sign in // at, which the company's server knows and the browser does not. export type PersonalIdentity = { externalID: string; name?: string; serverURL?: string; }; export type PersonalConversation = { id: string; name: string; description?: string; kind: "dm" | "group"; isHidden?: boolean; roleOfExternalID?: Record; isPrivate: boolean; avatarURL?: string; participantExternalIDs?: string[]; webURL?: string; unreadCount?: number; }; export type NewPersonalChannel = { name: string; description?: string; visibility: "open" | "private"; memberExternalIDs: string[]; }; export type CreatedPersonalChannel = PersonalConversation & { uninvitedExternalIDs: string[]; }; export type PersonalPerson = { externalID: string; name: string; avatarURL?: string; }; // A platform that keeps its custom emoji in a registry leaves the picture out // and answers for the name separately; one that puts it on the reaction says so // here. export type PersonalReaction = { emoji: string; imageURL?: string; byExternalIDs: string[]; }; // A store that addresses a file by the hash of its contents says so here, so a // reader that keeps its own copy can tell it already has these bytes without // fetching them again. Empty when the platform names files some other way. export type PersonalAttachment = { id: string; filename: string; contentType: string; sizeBytes: number; digest: string; widthPixels?: number; heightPixels?: number; }; export type PersonalMentions = { externalIDs: string[]; isEveryone: boolean; }; export type PersonalMessage = { id: string; conversationID: string; parentID?: string; authorExternalID: string; body: string; postedAt: string; editedAt?: string; mentions?: PersonalMentions; reactions: PersonalReaction[]; attachments: PersonalAttachment[]; }; export type PersonalMessageMatch = Pick< PersonalMessage, "id" | "conversationID" | "parentID" | "authorExternalID" | "body" | "postedAt" >; export type PersonalInboxItem = { reason: "mention" | "direct" | "thread_reply" | "channel"; message: PersonalMessageMatch; isUnread: boolean; }; export type PersonalHeldInboxItem = { id: string; body: string; isUnread: boolean; }; export type PersonalInbox = { items: PersonalInboxItem[]; held: PersonalHeldInboxItem[]; hasMoreBefore: boolean; }; export type PersonalInboxWindow = { since?: Date; until?: Date; heldIDs: string[]; }; export type PersonalMessagePage = { messages: PersonalMessage[]; hasMoreBefore: boolean; }; export type CredentialField = { name: string; label: string; isSecret: boolean; }; export type CredentialRequirement = { kind: "sign-in" | "secret" | "redirect"; credentialKind: string; fields: CredentialField[]; redirectURL?: string; }; export type IssuedCredential = { credential: ActorCredential; identity: PersonalIdentity; }; export class CredentialRefused extends Error { constructor(message: string) { super(message); this.name = "CredentialRefused"; } } export class ReadRefused extends Error { constructor( platform: string, url: string, readonly status: number, ) { super(`${platform} answered ${status} for ${url}`); this.name = "ReadRefused"; } } export type PersonalEmoji = { name: string; }; export type PersonalImage = { dataURL: string; }; export type KeptMedia = { address: string; digest: string; sizeBytes: number; contentType: string; }; export type MediaSource = { url: string; contentType: string; }; export class MediaRefused extends Error { constructor( readonly status: number, readonly reason: string, readonly digest: string, readonly sizeBytes: number, ) { super(`the messenger's file store refused this file (${status}: ${reason})`); this.name = "MediaRefused"; } } export interface PersonalGateway { readonly platform: string; readonly credentialKind: string; credentialRequirement(): CredentialRequirement; issueCredential(answers: Record): Promise; identity(actor: ActorCredential): Promise; listConversations(actor: ActorCredential): Promise; listPeople(actor: ActorCredential): Promise; ensureDirectConversation( actor: ActorCredential, counterpartExternalIDs: string[], ): Promise; hideDirectConversation(actor: ActorCredential, conversationID: string): Promise; revealDirectConversation(actor: ActorCredential, conversationID: string): Promise; createChannel(actor: ActorCredential, channel: NewPersonalChannel): Promise; listOpenChannels(actor: ActorCredential): Promise; joinChannel(actor: ActorCredential, conversationID: string): Promise; addChannelMembers( actor: ActorCredential, conversationID: string, memberExternalIDs: string[], ): Promise<{ uninvitedExternalIDs: string[] }>; removeChannelMember(actor: ActorCredential, conversationID: string, externalID: string): Promise; leaveChannel(actor: ActorCredential, conversationID: string): Promise; handOverChannel(actor: ActorCredential, conversationID: string, newOwnerExternalID: string): Promise; addChannelOwner( actor: ActorCredential, conversationID: string, externalID: string, ): Promise<{ newToChannel: boolean }>; deleteChannel(actor: ActorCredential, conversationID: string): Promise; markConversationRead( actor: ActorCredential, conversationID: string, readAt: Date, threadRootID?: string, ): Promise; listInbox(actor: ActorCredential, window: PersonalInboxWindow): Promise; listMessages( actor: ActorCredential, conversationID: string, before?: string, ): Promise; searchMessages(actor: ActorCredential, conversationID: string, query: string): Promise; sendMessage( actor: ActorCredential, conversationID: string, body: string, parentID?: string, attachments?: AttachmentAlreadyKept[], mentions?: PersonalMentions, timeZone?: string, ): Promise; readMedia(actor: ActorCredential, url: string, range: string): Promise; uploadMedia(actor: ActorCredential, source: MediaSource): Promise; editMessage( actor: ActorCredential, conversationID: string, messageID: string, body: string, mentions?: PersonalMentions, ): Promise; deleteMessage(actor: ActorCredential, conversationID: string, messageID: string): Promise; addReaction( actor: ActorCredential, conversationID: string, messageID: string, emoji: string, ): Promise; removeReaction( actor: ActorCredential, conversationID: string, messageID: string, emoji: string, ): Promise; listCustomEmoji(actor: ActorCredential): Promise; readCustomEmojiImage( actor: ActorCredential, name: string, largestBytes: number, ): Promise; readProfilePicture( actor: ActorCredential, externalID: string, largestBytes: number, ): Promise; watchArrivals( actor: ActorCredential, arrivalsURL: string, typingURL?: string, withdrawalsURL?: string, ): Promise; announceTyping(actor: ActorCredential, conversationID: string): Promise; } export class UnsupportedByPlatform extends Error { readonly reason = "unsupported-by-platform"; constructor( readonly platform: string, readonly operation: string, ) { super(`${platform} cannot ${operation} as a person`); this.name = "UnsupportedByPlatform"; } } export function requireMatchingCredential(gateway: PersonalGateway, actor: ActorCredential): void { if (actor.kind === gateway.credentialKind) return; throw new Error( `${gateway.platform} needs a ${gateway.credentialKind} credential, not ${actor.kind}`, ); }