UNPKG

@agree-able/room

Version:

A small scale chat room intended for ai agents to chat with each other

445 lines (413 loc) 16.4 kB
import { loadAgreement, host } from '@agree-able/rpc' import { signText, verifySignedText, generateChallengeText, getKeybaseProofChain } from '@agree-able/invite' import Autobase from 'autobase' import BlindPairing from 'blind-pairing' import Corestore from 'corestore' import Hyperswarm from 'hyperswarm' import RAM from 'random-access-memory' import z32 from 'z32' import { EventEmitter } from 'events' import fs from 'fs' import path from 'path' import os from 'os' /** * @typedef {Object} RoomManagerOptions * @property {Corestore} [corestore] - Optional preconfigured Corestore instance * @property {string} [storageDir] - Optional storage directory path * @property {Hyperswarm} [swarm] - Optional preconfigured Hyperswarm instance * @property {BlindPairing} [pairing] - Optional preconfigured BlindPairing instance */ /** * Manages multiple breakout rooms and their resources * @extends EventEmitter */ export class RoomManager extends EventEmitter { /** * Creates a new RoomManager instance * @param {RoomManagerOptions} [opts={}] - Configuration options */ constructor (opts = {}) { super() this.internalManaged = { corestore: false, swarm: false, pairing: false } if (opts.corestore) this.corestore = opts.corestore else { this.internalManaged.corestore = true if (opts.storageDir) this.corestore = new Corestore(opts.storageDir) else this.corestore = new Corestore(RAM.reusable()) } this.swarm = opts.swarm ? opts.swarm : (this.internalManaged.swarm = true, new Hyperswarm()) this.pairing = opts.pairing ? opts.pairing : (this.internalManaged.pairing = true, new BlindPairing(this.swarm)) this.rooms = {} this.challengeTexts = {} // move to a hyper thing later } /** * Gets configuration options for a new room * @param {string} roomId - Unique room identifier * @returns {Object} Room configuration options */ getRoomOptions (roomId) { const corestore = roomId ? this.corestore.namespace(roomId) : this.corestore return { corestore, swarm: this.swarm, pairing: this.pairing } } /** * Creates a new breakout room * @param {Object} [opts={}] - Room configuration options * @param {string} [opts.invite] - Optional invite code * @param {Object} [opts.metadata] - Optional room metadata * @param {string} [opts.keybaseUsername] - Optional keybase username * @param {boolean} [opts.signMessages] - Optional flag to sign each sent message * @param {string} [opts.keybaseUsername] - Optional keybase username * @param {string} [opts.privateKeyArmored] - Optional private key needed to sign messages * @returns {BreakoutRoom} New room instance */ createRoom (opts = {}) { const roomId = generateRoomId() const baseOpts = this.getRoomOptions(roomId) if (opts.invite) baseOpts.invite = opts.invite baseOpts.metadata = opts.metadata || {} baseOpts.roomId = roomId // allow for message signing if (opts.signMessages) baseOpts.signMessages = opts.signMessages if (opts.privateKeyArmored) baseOpts.privateKeyArmored = opts.privateKeyArmored if (opts.keybaseUsername) baseOpts.keybaseUsername = opts.keybaseUsername const room = new BreakoutRoom(baseOpts) this.rooms[roomId] = room room.on('roomClosed', () => { delete this.rooms[roomId] if (this.closingDown) return if (Object.keys(this.rooms).length > 0) return process.nextTick(() => this.emit('lastRoomClosed')) }) process.nextTick(() => this.emit('newRoom', room)) return room } async createReadyRoom (opts = {}) { const room = this.createRoom(opts) const invite = await room.ready() process.nextTick(() => this.emit('readyRoom', room)) return invite } async startAgreeable (config, expectations, validateParticipant) { if (config.privateKeyArmoredFile) { config.privateKeyArmored = fs.readFileSync(resolvePath(config.privateKeyArmoredFile), 'utf8') } /** @type { z.infer<NewRoom> } newRoom */ const newRoom = async (agreement, { remotePublicKey }) => { const participantDetails = { remotePublicKey } if (expectations.whoamiRequired) { if (!agreement.whoami) return { ok: false, reason: 'whoami requested and not provided' } if (agreement.whoami.keybase) { if (!agreement.whoami.keybase.username) throw new Error('participant username was not returned') const expectectChallengeText = this.challengeTexts[remotePublicKey] if (expectectChallengeText !== agreement.whoami.keybase.challengeResponse.text) return { ok: false, reason: 'challengeText was modified' } participantDetails.whoami = { keybase: { username: agreement.whoami.keybase.username } } participantDetails.whoami.keybase.verified = await verifySignedText(agreement.whoami.keybase.challengeResponse, agreement.whoami.keybase.username) // external participantDetails.whoami.keybase.chain = await getKeybaseProofChain(agreement.whoami.keybase.username) // external } if (!participantDetails.whoami) return { ok: false, reason: 'participant did not provide any known whoami response' } } if (validateParticipant) { const results = await validateParticipant(agreement.accept, participantDetails) if (!results.ok) return { ok: false, reason: results.reason } } // if bad things, return { ok: false, reason: 'did not like it' } // we need to do lots of work here to verify the new room const invite = await this.createReadyRoom(config) return { ok: true, invite } } const roomExpectations = async (query, { remotePublicKey }) => { const _expectations = structuredClone(expectations) if (query.challengeText) { try { // we have been asked to sign the challengeText _expectations.whoami = { keybase: { username: config.keybaseUsername, challengeResponse: await signText(query.challengeText, config.privateKeyArmored) } } } catch (e) { // failed to generate challengeResponse console.log(e) } } if (!expectations.whoamiRequired) return _expectations const challengeText = await generateChallengeText() this.challengeTexts[remotePublicKey] = challengeText _expectations.challengeText = challengeText return _expectations } const api = { newRoom, roomExpectations } const opts = { seed: config.seed, dht: this.swarm.dht } const results = await host(await loadAgreement('./agreement.mjs', import.meta.url), api, opts) results.agreeableKey = z32.encode(results.keyPair.publicKey) return results } async cleanup () { const exitPromises = Object.values(this.rooms).map(room => room.exit()) await Promise.all(exitPromises) this.rooms = {} // Clean up other resources if (this.internalManaged.pairing) await this.pairing.close() if (this.internalManaged.swarm) await this.swarm.destroy() if (this.internalManaged.corestore) await this.corestore.close() } async installSIGHandlers () { this.closingDown = false const cleanup = async () => { this.closingDown = true await this.cleanup() process.exit(0) } process.on('SIGINT', cleanup) process.on('SIGTERM', cleanup) } isClosingDown () { return this.closingDown } } /** * @typedef {Object} BreakoutRoomOptions * @property {string} [roomId] - Optional room identifier * @property {Corestore} [corestore] - Optional Corestore instance * @property {string} [storageDir] - Optional storage directory * @property {Hyperswarm} [swarm] - Optional Hyperswarm instance * @property {BlindPairing} [pairing] - Optional BlindPairing instance * @property {string} [invite] - Optional invite code * @property {Object} [metadata] - Optional room metadata * @param {boolean} [signMessages] - Optional flag to sign each sent message * @param {string} [keybaseUsername] - Optional keybase username * @param {string} [privateKeyArmored] - Optional private key needed to sign messages */ /** * Represents a single breakout room for peer-to-peer communication * @extends EventEmitter */ export class BreakoutRoom extends EventEmitter { /** * Creates a new BreakoutRoom instance * @param {BreakoutRoomOptions} [opts={}] - Room configuration options */ constructor (opts = {}) { super() this.roomId = opts.roomId || generateRoomId() this.internalManaged = { corestore: false, swarm: false, pairing: false } if (opts.corestore) this.corestore = opts.corestore else { this.internalManaged.corestore = true if (opts.storageDir) this.corestore = new Corestore(opts.storageDir) else this.corestore = new Corestore(RAM.reusable()) } this.swarm = opts.swarm ? opts.swarm : (this.internalManaged.swarm = true, new Hyperswarm()) this.pairing = opts.pairing ? opts.pairing : (this.internalManaged.pairing = true, new BlindPairing(this.swarm)) this.autobase = new Autobase(this.corestore, null, { apply, open, valueEncoding: 'json' }) if (opts.invite) this.invite = z32.decode(opts.invite) this.metadata = opts.metadata || {} this.signMessages = opts.signMessages this.privateKeyArmored = opts.privateKeyArmored this.keybaseUsername = opts.keybaseUsername this.initialized = false } /** * Initializes the room and sets up event handlers * @returns {Promise<string|void>} Returns invite code if room is host */ async ready () { if (this.initialized) return this.invite this.initialized = true await this.autobase.ready() this.metadata.who = z32.encode(this.autobase.local.key) // some hacky stuff to only emit remote messages, and only emit once this.lastEmitMessageLength = 0 this.autobase.view.on('append', async () => { const entry = await this.autobase.view.get(this.autobase.view.length - 1) if (entry.who === this.metadata.who) return if (entry.event === 'leftChat') return this.emit('peerLeft', entry.who) if (entry.event === 'joinedChat') return this.emit('peerEntered', entry) if (this.lastEmitMessageLength === this.autobase.view.length) return this.lastEmitMessageLength = this.autobase.view.length process.nextTick(() => this.emit('message', entry)) }) const welcomeMessage = { when: Date.now(), who: this.metadata.who, event: 'joinedChat' } if (this.keybaseUsername) welcomeMessage.keybaseUsername = this.keybaseUsername await this.autobase.append(welcomeMessage) this.swarm.join(this.autobase.local.discoveryKey) this.swarm.on('connection', conn => this.corestore.replicate(conn)) if (this.invite) { const candidate = this.pairing.addCandidate({ invite: this.invite, userData: this.autobase.local.key, onadd: (result) => this._onHostInvite(result) }) await candidate.paring } else { const { invite, publicKey, discoveryKey } = BlindPairing.createInvite(this.autobase.local.key) this.metadata.host = { publicKey: z32.encode(publicKey), discoveryKey: z32.encode(discoveryKey) } const member = this.pairing.addMember({ discoveryKey, onadd: (candidate) => this._onAddMember(publicKey, candidate) }) await member.flushed() this.invite = invite return z32.encode(invite) } } getRoomInfo () { return { invite: z32.encode(this.invite), roomId: this.roomId, metadata: this.metadata } } /** * Sends a message to the room * @param {string} data - Message content * @returns {Promise<void>} */ async message (data) { const message = { when: Date.now(), who: this.metadata.who, data } if (this.signMessages && this.privateKeyArmored && typeof data === 'string') { const signature = await signText(data, this.privateKeyArmored) message.signature = signature.armoredSignature } await this.autobase.append(message) return message } async _onHostInvite (result) { if (result.key) { // can read result.additional data here this._connectOtherCore(result.key) this.metadata.host = { publicKey: z32.encode(result.key) // should add the discovery key here } } } async _onAddMember (publicKey, candidate) { candidate.open(publicKey) candidate.confirm({ key: this.autobase.local.key }) // can add additional https://github.com/holepunchto/blind-pairing-core/blob/main/index.js#L190 this._connectOtherCore(candidate.userData) } async _connectOtherCore (key) { await this.autobase.append({ addWriter: key }) } /** * Retrieves the complete room message history * @returns {Promise<Array>} Array of message entries */ async getTranscript () { const transcript = [] await this.autobase.update() for (let i = 0; i < this.autobase.view.length; i++) { transcript.push(await this.autobase.view.get(i)) } return transcript } async exit () { await this.autobase.append({ when: Date.now(), who: this.metadata.who, event: 'leftChat' }) await this.autobase.update() this.swarm.leave(this.autobase.local.discoveryKey) await this.autobase.close() if (this.internalManaged.pairing) await this.pairing.close() if (this.internalManaged.swarm) await this.swarm.destroy() if (this.internalManaged.corestore) await this.corestore.close() this.emit('roomClosed') this.removeAllListeners() // clean up listeners } async installSIGHandlers () { this.closingDown = false const cleanup = async () => { if (this.closingDown) return this.closingDown = true await this.exit() process.exit(0) } process.on('SIGINT', cleanup) process.on('SIGTERM', cleanup) } isClosingDown () { return this.closingDown } } // create the view /** * Opens the view store * @param {Object} store - Storage instance * @returns {Promise<Object>} View store instance */ function open (store) { return store.get({ name: 'view', valueEncoding: 'json' }) } // use apply to handle to updates /** * Applies updates to the view * @param {Array} nodes - Array of nodes to process * @param {Object} view - View instance * @param {Object} base - Base instance * @returns {Promise<void>} */ async function apply (nodes, view, base) { for (const { value } of nodes) { if (value.addWriter) { if (value.addWriter.type) continue // weird cycle have to figure out await base.addWriter(value.addWriter, { isIndexer: true }) continue } await view.append(value) } } /** * Generates a unique room identifier * @returns {string} Unique room ID combining timestamp and random string */ function generateRoomId () { const timestamp = Date.now().toString(36) // Base36 timestamp const random = Math.random().toString(36).substr(2, 5) // 5 random chars return `room-${timestamp}-${random}` } // Function to resolve a path with ~ function resolvePath (inputPath) { if (inputPath.startsWith('~')) { return path.join(os.homedir(), inputPath.slice(1)) } return path.resolve(inputPath) } export const autoValidatedConfirmRoomEnter = async (config, expectations, hostInfo) => { if (config.hostProveWhoami) { const verified = hostInfo?.whoami?.keybase?.verified if (!verified) return { rules: false, reason: false } const connectedDomain = config.domain const verifiedDomains = hostInfo?.whoami?.keybase?.chain?.dns || [] const domainVerified = connectedDomain && verifiedDomains.some(d => d.username === connectedDomain && d.state === 1 ) if (!domainVerified) return { rules: false, reason: false } } if (config.agree === false) return { rules: false, reason: false } return { rules: true, reason: true } } export const autoValidateParticipant = async (config, acceptance, extraInfo) => { if (!acceptance.reason || !acceptance.rules) { return { ok: false } } // Auto-reject if whoami required but verification failed if (config.whoamiRequired && extraInfo?.whoami?.keybase && !extraInfo.whoami.keybase.verified) { return { ok: false } } return { ok: true } }