Something went wrong. Try again.
ATProto-based AI Assistant Chat interface.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236import { DidNotFoundError, PoorlyFormattedDidError, UnsupportedDidMethodError,} from '@atproto/identity'import { isValidDid } from '@atproto/syntax'import { atproto, nsid } from '@scn-chat/lexicons'import type { Account } from '../auth/accounts.ts'import { IdentityResolutionError, type IdentityResolver } from '../auth/identity.ts'import type { PdsClientFactory } from '../auth/pds.ts'import { lexErrorCode } from '../lex-errors.ts'import type { Logger } from '../logger.ts'import type { Loose } from '../loose.ts'import { SpaceNotFound } from '../storage/record-store.ts'import { type JsonRecord, spaceUri, toJson } from '../storage/records.ts'import { MEMBER_LIST, PUBLIC } from '../storage/space-policies.ts'import { assertValidStored, keepValid } from '../storage/validate.ts'import { CredentialError } from '../sync/credentials.ts'
export type ShareMode = 'private' | 'people' | 'public'export type ShareSettings = { mode: ShareMode; members: { did: string; handle: string | null }[] }
export class ShareInputError extends Error { constructor(message: string) { super(message) this.name = 'ShareInputError' }}
/** The conversation is missing, private, deleted, or the viewer is not allowed: all look the same. */export class SharedNotFound extends Error { constructor() { super('Conversation not found') this.name = 'SharedNotFound' }}
type CredentialClient = { client(service: string, audience: string): Loose }
export type SharingDeps = { getPdsClient: PdsClientFactory identity: IdentityResolver /** Get a fresh, uncached space credential acting for the viewer. */ mintCredential: (viewerDid: string, space: string) => Promise<CredentialClient> logger: Logger}
const DENIED = new Set([ 'UserNotAuthorized', 'AppNotAuthorized', 'NotAuthorized', 'SpaceNotFound', 'SpaceDeleted', 'RecordNotFound', 'BlobNotFound', 'RepoNotFound',])
/** Share settings for an owner's conversations, and shared views read with the viewer's credential. */export class SharingService { private readonly deps: SharingDeps
constructor(deps: SharingDeps) { this.deps = deps }
private async owner(account: Account): Promise<Loose> { return this.deps.getPdsClient(account.did) }
private async members(client: Loose, space: string): Promise<string[]> { const dids: string[] = [] let cursor: string | undefined do { const out = await client.call(atproto.simplespace.listMembers, { space, cursor, limit: 100 }) dids.push( ...out.members.filter((m: { read: boolean }) => m.read).map((m: { did: string }) => m.did), ) cursor = out.cursor } while (cursor) return dids }
/** Run an owner's space management call, reporting a missing space as SpaceNotFound. */ private async managing<T>(space: string, fn: () => Promise<T>): Promise<T> { try { return await fn() } catch (err) { if (lexErrorCode(err) === 'SpaceNotFound') throw new SpaceNotFound(space) throw err } }
async getSettings(account: Account, skey: string): Promise<ShareSettings> { const space = spaceUri(account.did, nsid.conversation, skey) return this.managing(space, () => this.readSettings(account, space)) }
private async readSettings(account: Account, space: string): Promise<ShareSettings> { const client = await this.owner(account) const [config, dids] = await Promise.all([ client.call(atproto.simplespace.getSpace, { space }), this.members(client, space), ]) const isPublic = (config.readPolicy.$type as string).endsWith('#publicPolicy') const members = await Promise.all( dids.map(async (did) => ({ did, handle: await this.deps.identity.resolve(did).then( (identity) => identity.handle, (err) => { this.deps.logger.warn({ err, did }, 'could not resolve a shared member handle') return null }, ), })), ) return { mode: isPublic ? 'public' : members.length ? 'people' : 'private', members } }
private async toDids(members: string[]): Promise<string[]> { return Promise.all( members.map(async (member) => { if (member.startsWith('did:')) { if (!isValidDid(member)) throw new ShareInputError(`"${member}" is not a valid DID`) return member } const did = await this.deps.identity.resolveHandle(member.replace(/^@/, '')) if (!did) throw new ShareInputError(`Cannot find the handle "${member}"`) return did }), ) }
async setSettings( account: Account, skey: string, mode: ShareMode, members: string[] = [], ): Promise<void> { const wanted = mode === 'people' ? await this.toDids(members) : [] const space = spaceUri(account.did, nsid.conversation, skey) await this.managing(space, () => this.applySettings(account, space, mode, wanted)) }
private async applySettings(account: Account, space: string, mode: ShareMode, wanted: string[]) { const client = await this.owner(account) const current = await this.members(client, space) if (mode === 'public') { await client.call(atproto.simplespace.updateSpace, { space, readPolicy: PUBLIC }) return } await client.call(atproto.simplespace.updateSpace, { space, readPolicy: MEMBER_LIST }) for (const did of current.filter((d) => !wanted.includes(d))) { await client.call(atproto.simplespace.removeMember, { space, did }) } for (const did of wanted.filter((d) => !current.includes(d))) { await client.call(atproto.simplespace.putMember, { space, did, read: true, write: false }) } }
private async viewerClient(viewer: Account, ownerDid: string, skey: string) { const space = spaceUri(ownerDid, nsid.conversation, skey) try { const [credential, owner] = await Promise.all([ this.deps.mintCredential(viewer.did, space), this.deps.identity.resolve(ownerDid), ]) return { space, client: credential.client(owner.pdsUrl, ownerDid), ownerHandle: owner.handle } } catch (err) { if ( (err instanceof CredentialError && DENIED.has(err.code)) || DENIED.has(lexErrorCode(err) ?? '') || err instanceof IdentityResolutionError || err instanceof DidNotFoundError || err instanceof PoorlyFormattedDidError || err instanceof UnsupportedDidMethodError ) { throw new SharedNotFound() } throw err } }
private async read(fn: () => Promise<Loose>): Promise<Loose> { try { return await fn() } catch (err) { if (DENIED.has(lexErrorCode(err) ?? '')) throw new SharedNotFound() throw err } }
/** A conversation shared with the viewer, without its system prompt or the owner's preferences. */ async view(viewer: Account, ownerDid: string, skey: string) { const { space, client, ownerHandle } = await this.viewerClient(viewer, ownerDid, skey) const info = await this.read(() => client.call(atproto.space.getRecord, { space, repo: ownerDid, collection: nsid.info, rkey: 'self', }), ) const messages: { rkey: string; value: JsonRecord; cid: string }[] = [] let cursor: string | undefined do { const out = await this.read(() => client.call(atproto.space.listRecords, { space, repo: ownerDid, collection: nsid.message, cursor, limit: 100, }), ) for (const record of out.records) { messages.push({ rkey: record.rkey, value: toJson(record.value), cid: record.cid }) } cursor = out.cursor } while (cursor) const infoValue = toJson(info.value) assertValidStored(space, nsid.info, 'self', infoValue) return { owner: { did: ownerDid, handle: ownerHandle }, title: (infoValue.title as string | undefined) ?? null, messages: keepValid(space, nsid.message, messages, this.deps.logger), } }
async blob(viewer: Account, ownerDid: string, skey: string, cid: string): Promise<Uint8Array> { const { space, client } = await this.viewerClient(viewer, ownerDid, skey) return this.read(() => client.call(atproto.space.getBlob, { space, repo: ownerDid, cid })) }}