import request from 'superagent' import { v1 as uuidV1 } from 'uuid' import { io } from 'socket.io-client' import { createHash, hmacHex } from './crypto.js' import { isObject } from './util.js' import { aoEnv } from './settings.js' // The AO API server endpoint this ao-cli client will attempt to connect to export const AO_DEFAULT_HOSTNAME = 'localhost:8003' const HOSTNAME = aoEnv('AO_CLI_TARGET_HOSTNAME') || AO_DEFAULT_HOSTNAME const [HOST, PORT] = HOSTNAME.split(':') // The AO API server websocket endpoint this ao-cli client will attempt to connect to const AO_SOCKET_URL = 'http://' + AO_DEFAULT_HOSTNAME // was process.env.NODE_ENV === 'development' ? 'http://' + AO_DEFAULT_HOSTNAME : '/' export const socket = io(AO_SOCKET_URL, { autoConnect: false }) // The current connected state of the websocket, can be 'attemptingAuthentication' | 'authenticationSuccess' | 'authenticationFailed' export let socketStatus // Load the current session cookies from the AO .env file let currentMemberId = aoEnv('AO_CLI_SESSION_MEMBERID') let currentSessionId = aoEnv('AO_CLI_SESSION_ID') let currentSessionToken = aoEnv('AO_CLI_SESSION_TOKEN') // Performs a GET request to the specified endpoint, sending the given payload export async function getRequest(endpoint, payload = null, alternateHost = null, verbose = true) { const target = alternateHost || HOSTNAME try { if(payload) { return await request .get(target + endpoint) .send(payload) } else { return await request.get(target + endpoint) } } catch (err) { if(verbose) console.log('request failed', err) return null } } // Performs a POST request to the specified endpoint, sending the given payload export async function postRequest(endpoint, payload = null, verbose = true) { if (!currentSessionToken) { if(verbose) console.log('Session token not set, API not ready.') return new Promise(() => null) } try { if(payload) { return await request .post(HOSTNAME + endpoint) .send(payload) .set('authorization', currentSessionToken) .set('session', currentSessionId) } else { return await request.post(HOSTNAME + endpoint) .set('authorization', currentSessionToken) .set('session', currentSessionId) } } catch (err) { if(verbose) console.log('request failed', err) return null } } // Performs a post request to the /event endpoint, sending the given JSON object as the event export async function postEvent(event, verbose) { event = { ...event, blame: currentMemberId } return await postRequest('/events', event, verbose) } // Attempts login with the given username and password combo. If successful, returns the generated session and token (login cookies). export async function createSession(user, pass) { const session = uuidV1() let sessionKey = createHash(session + createHash(pass)) const token = hmacHex(session, sessionKey) const result = await request .post(HOSTNAME + '/session') .set('authorization', token) .set('session', session) .set('name', user) .on('error', () => false) currentMemberId = result.body.memberId currentSessionToken = token currentSessionId = session return { session, token, memberId: currentMemberId } } export async function logout() { return await postRequest('/logout') } // AO p2p over tor features export async function nameAo(newName) { return await postEvent({ type: 'ao-named', alias: newName }) } // When you call startSocketListeners, it will either attempt to connect and authenticate on a web socket, or fail and return. // onAuthenticated is a function that will be called when the client authenticates on the web socket (logs in/connects). // In your onAuthenticated function, you should trigger fetchState or other initial fetching of state from server. // eventCallback is a function (ev) => {} that will be called whenever an event is received on the socket. // In this way, initial state can be fetched and then updates received after that can be used to update the local state model. export async function startSocketListeners(onAuthenticated, onEvent, verbose = true) { if(typeof onAuthenticated !== 'function' || typeof onEvent !== 'function') { console.log('startSocketListeners requires two callback functions as arguments.') return } socket.connect() socket.on('connect', () => { if(verbose) console.log('websocket connected') socketStatus = 'attemptingAuthentication' if(!currentSessionId || !currentSessionToken) { if(verbose) console.log('No current session, must log in to authenticate and use socket.') return } socket.emit('authentication', { session: currentSessionId, token: currentSessionToken, }) }) socket.on('authenticated', () => { if(verbose) console.log('websocket authenticated') socketStatus = 'authenticationSuccess' socket.on('eventstream', onEvent) onAuthenticated() }) socket.on('disconnect', reason => { if(verbose) console.log('websocket disconnected') socketStatus = 'authenticationFailed' socket.connect() }) } // Requests the public bootstrap list by making a public (not logged in) GET request to this or the specified server export async function getAoBootstrapList(serverOnion = null) { const result = await getRequest('/bootstrap', undefined, serverOnion, false) if(!result || !result.ok || result.body.addresses.length < 1) { return null } return result.body.addresses } // Gets the bootsrap list from the specified or our server, then recursively bootstraps from each address on the list // The AO network is small right now so this shouldn't cause any problems for a while export async function bootstrap(serverOnion = null) { if(!serverOnion) serverOnion = HOSTNAME let alreadyQueried = [ serverOnion ] let onionList = await getAoBootstrapList(serverOnion) if(!onionList) { return null } for(let i = 0; i < onionList.length; i++) { const onion = onionList[i] let more = await bootstrap(onion) if(!more) continue more = more.filter(onion => !onionList.concat(alreadyQueried).includes(onion)) onionList.concat(more) } return onionList } /*export async function shadowchat(room, message, username) { return await postEvent({ type: 'shadowchat', room: room, name: username, message: message, }) } export async function connectToAo(address, secret) { return await postEvent({ type: 'ao-outbound-connected', address: address, secret: secret }) } export async function deleteAoConnection(address) { return await postEvent({ type: 'ao-disconnected', address: address }) } export async function relayEventToOtherAo(address, event) { return await postEvent({ type: 'ao-relay', address: address, ev: event }) } export async function linkCardOnAo(taskId, address) { return await postEvent({ type: 'ao-linked', address: address, taskId: taskId }) } // Avatar and presence features export async function bark() { return await postEvent({ type: 'doge-barked', memberId: currentMemberId }) } export async function hopped(taskId) { return await postEvent({ type: 'doge-hopped', memberId: currentMemberId, taskId: taskId }) } export async function mute() { return await updateMemberField('muted', true) } export async function unmute() { return await updateMemberField('muted', false) } */ // Memes feature export async function fetchMeme(memeHash, progressCallback) { return request .get(HOSTNAME + '/meme/' + memeHash) .responseType('blob') .set('Authorization', currentSessionToken) .on('progress', function (e) { progressCallback(e.percent) }) .then(res => { console.log('got meme! res is ', res) return res.body }) } export async function downloadMeme(memeHash) { return request .get(HOSTNAME + '/download/' + memeHash) .set('Authorization', currentSessionToken) .then(res => { // console.log('got meme! res is ', res) return res }) } export async function uploadMemes(formData, progressCallback) { return postRequest('/upload', formData) .on('progress', function (e) { console.log('Percentage done: ', e) if (e && e.hasOwnProperty('percent') && e.percent >= 0) { progressCallback(e.percent) } }) .on('error', err => { console.log('Upload failed with error:', err) return false }) .then(res => { console.log('sent files. res is', res) return res }) } /*export async function cacheMeme(taskId) { return await postEvent({ type: 'meme-cached', taskId }) }*/ // Cards feature // Returns the card and other cards as specified by the alsoGetRelevant arg // If multiple cards are returned, they will be returned in their global deck order (global creation order on server) export async function getCard(taskId, alsoGetRelevant = 'subcards') { taskId = taskId.trim().toLowerCase() let payload = { taskIds: [ taskId ] } const result = await postRequest('/fetchTasks', payload, false) if(!result || !result.body) { //console.log('Error fetching task.') return null } let fetchedCard = result.body[0] if(alsoGetRelevant) { let relevantCards = await getAllRelevantCards(fetchedCard, alsoGetRelevant) return [fetchedCard, ...relevantCards] } return [fetchedCard] } // Cards feature export async function getCardByName(taskName, alsoGetRelevant = 'subcards') { taskName = taskName.trim() let payload = { taskName: taskName } const result = await postRequest('/fetchTaskByName_exact', payload, false) // todo: change to flat text, not JSON (?) if(!result || !result.body || result.statusCode === 204 || result.statusCode === 400) { //console.log('Error fetching task.') return null } if(alsoGetRelevant) { let relevantCards = await getAllRelevantCards(result.body, alsoGetRelevant) return [result.body, ...relevantCards] } return [result.body] } // Fetches all cards related to the given card object, i.e., cards that could be seen or navigated to immediately from that card // scope = 'priority' returns only the first/top priority card within the specified card // scope = 'priorities' returns only the priorities within the specified card // scope = 'subcards' returns all subcards (priorities, pinned, subTasks, completed) // Further scopes are not currently needed because the server also includes some related cards with each send // If existingTasks: Map is provided, those cards will be skipped // Returns the new cards that were fetched (not any existingTasks), plus cards the server chooses to also include export async function getAllRelevantCards( seedTask, scope = 'priorities', existingTasks ) { if(existingTasks === undefined) { existingTasks = new Map() } let taskIdsToFetch = [] // Choose which taskIds we are going to request from the server switch (scope) { case 'priority': if(seedTask.hasOwnProperty('priorities') && seedTask.priorities?.length >= 1) { taskIdsToFetch = new Set([seedTask.priorities.at(-1)]) } break case 'priorities': taskIdsToFetch = new Set(seedTask.priorities) break case 'subcards': taskIdsToFetch = new Set( seedTask.priorities.concat(seedTask.subTasks, seedTask.completed) ) if (seedTask.pins && seedTask.pins.length >= 1) { seedTask.pins.forEach(pin => { taskIdsToFetch.add(pin.taskId) }) } } // Filter out the taskIds for tasks we already have taskIdsToFetch = [...taskIdsToFetch].filter(taskId => { if (!taskId) { return false } const existingTask = existingTasks.get(taskId) return !existingTask }) if(taskIdsToFetch.length < 1) { return [] } // Fetch the cards try { const result = await postRequest('/fetchTasks', { taskIds: taskIdsToFetch }) // Filter again (overlapping queries or intelligent server can cause duplicates to be returned) const newTasksOnly = result.body.filter( fetchedTask => !existingTasks.get(fetchedTask.taskId) ) return newTasksOnly } catch (error) { console.log('Error fetching relevant tasks:', { taskIdsToFetch, error }) return null } } /*export async function createCard( name, anonymous = false, prioritized = false ) { return await postEvent({ type: 'task-created', name: name, color: 'blue', deck: (anonymous || !currentMemberId) ? [] : [currentMemberId], inId: anonymous ? null : currentMemberId || null, prioritized: prioritized, }, false) } // This is different from only setting a card property because when a card's color changes, it is bumped to the top of the .subTasks color pile export async function colorCard(taskId, color) { return await postEvent({ type: 'task-colored', taskId: taskId, color: color, inId: null, // add this when we have context, mutation works on server blame: currentMemberId }) } // Set arbitrary metadata on a card export async function setCardProperty(taskId, property, value) { return await postEvent({ type: 'task-property-set', taskId: taskId, property: property, value: value, blame: currentMemberId }) } // Card send feature export async function passCard(taskId, toMemberId) { return await postEvent({ type: 'task-passed', taskId: taskId, toMemberId: toMemberId, fromMemberId: currentMemberId }) } // Send an immediate bark and notification to the member if possible, reminding them that they have an unread message from you (no automated pings) export async function remindMember(memberId) { return await postEvent({ type: 'member-reminded', toMemberId: memberId, fromMemberId: currentMemberId }) } // Cards-in-cards feature export async function playCard(from = null, to) { return await postEvent({ type: 'task-played', from: from, to: to, memberId: currentMemberId }) } export async function discardCardFromCard(taskId, inId) { return await postEvent({ type: 'task-de-sub-tasked', taskId: inId, subTask: taskId, blame: currentMemberId }) } // Empties a card's priorities and subtasks export async function emptyCard(taskId) { return await postEvent({ type: 'task-emptied', taskId: taskId, blame: currentMemberId }) } export async function swapCard(inId, taskId1, taskId2) { return await postEvent({ type: 'task-swapped', taskId: inId, swapId1: taskId1, swapId2: taskId2, blame: currentMemberId }) } export async function bumpCard(taskId, inId, direction) { return await postEvent({ type: 'task-bumped', taskId: inId, bumpId: taskId, direction: direction, blame: currentMemberId }) } // Deck features export async function grabCard(taskId) { return await postEvent({ type: 'task-grabbed', taskId: taskId, memberId: currentMemberId }) } export async function grabPile(taskId) { return await postEvent({ type: 'pile-grabbed', taskId: taskId, memberId: currentMemberId }) } export async function dropCard(taskId) { return await postEvent({ type: 'task-dropped', taskId: taskId, memberId: currentMemberId }) } export async function removeCards(taskIds) { return await postEvent({ type: 'tasks-removed', taskIds: taskIds, memberId: currentMemberId }) } export async function dropPile(taskId) { return await postEvent({ type: 'pile-dropped', taskId: taskId, memberId: currentMemberId }) } // Priority feature export async function prioritizeCard(taskId, inId, position = 0, echelon = null) { return await postEvent({ type: 'task-prioritized', taskId: taskId, inId: inId, position: position, ...(echelon && { echelon: echelon }), blame: currentMemberId }) } export async function prioritizePile(inId) { return await postEvent({ type: 'task-prioritized', inId: inId }) } export async function refocusCard(taskId, inId) { return await postEvent({ type: 'task-refocused', taskId: taskId, inId: inId, blame: currentMemberId }) } export async function refocusPile(inId) { return await postEvent({ type: 'pile-refocused', inId: inId }) } export async function allocatePriority(inId, taskId, points = 1) { return await postEvent({ type: 'task-allocated', taskId: inId, allocatedId: taskId, amount: points, blame: currentMemberId //inId: inId, }) } // Guilds feature export async function tagCard(taskId, newTitle) { return await postEvent({ type: 'task-guilded', taskId: taskId, guild: newTitle, blame: currentMemberId }) }*/ export async function getCardsForTag(tag) { return (await postRequest('/fetchTasksByGuild', { guild: tag })).body.cards } // Checkmarks feature /*export async function completeCard(taskId) { return await postEvent({ type: 'task-claimed', taskId: taskId, memberId: currentMemberId }) } export async function uncheckCard(taskId) { return await postEvent({ type: 'task-unclaimed', taskId: taskId, memberId: currentMemberId }) } export async function setClaimInterval(taskId, newClaimInterval) { return await setCardProperty(taskId, claimInterval, newClaimInterval) } // Hardware resources feature export async function createResource( resourceId, name, charged, secret, trackStock ) { return await postEvent({ type: 'resource-created', resourceId: resourceId, name: name, charged: charged, secret: secret, trackStock: trackStock, blame: currentMemberId }) } export async function useResource(resourceId, amount, charged, notes = '') { return await postEvent({ type: 'resource-used', resourceId: resourceId, memberId: currentMemberId, amount: amount, charged: charged, notes: notes }) } export async function stockResource(resourceId, amount, paid, notes = '') { return await postEvent({ type: 'resource-stocked', resourceId: resourceId, memberId: currentMemberId, amount: amount, paid: paid, notes: notes }) } export async function purgeResource(resourceId) { return await postEvent({ type: 'resource-purged', resourceId: resourceId, blame: currentMemberId }) } export async function bookResource(taskId, startTime, endTime) { return await postEvent({ type: 'resource-booked', resourceId: taskId, memberId: currentMemberId, startTs: startTime, endTs: endTime }) }*/ // Member account features export async function updateMemberField(field, newValue) { if (field === 'secret') { newValue = createHash(newValue) } return await postEvent({ type: 'member-field-updated', memberId: currentMemberId, field: field, newfield: newValue }) } // Member admin features export async function createMember(name, fob = '') { const secret = createHash(name) return await postEvent({ type: 'member-created', name, secret, fob }) } /*export async function activateMember(memberId) { return await postEvent({ type: 'member-activated', memberId: memberId }) } export async function deactivateMember(memberId) { return await postEvent({ type: 'member-deactivated', memberId: memberId }) } // senpai function export async function resetPassword(memberId) { return await postEvent({ type: 'member-secret-reset', kohaiId: memberId, senpaiId: currentMemberId }) } // senpai function export async function promoteMember(memberId) { return await postEvent({ type: 'member-promoted', kohaiId: memberId, senpaiId: currentMemberId }) } // senpai function export async function banMember(memberId) { return await postEvent({ type: 'member-banned', kohaiId: memberId, senpaiId: currentMemberId }) } // senpai function export async function unbanMember(memberId) { return await postEvent({ type: 'member-unbanned', kohaiId: memberId, senpaiId: currentMemberId }) } // senpai function export async function purgeMember(memberId) { return await postEvent({ type: 'member-purged', memberId: memberId, blame: currentMemberId }) } // Each member has a list of tickers. Each ticker is a string. // Sets the ticker at position tickerListIndex to symbol coinSymbol. export async function setTicker(fromCoin, toCoin, tickerListIndex) { return await postEvent({ type: 'member-ticker-set', memberId: currentMemberId, fromCoin: fromCoin, toCoin: toCoin, index: tickerListIndex }) } // Timeclock features export async function clockTime(seconds, taskId, date) { return await postEvent({ type: 'task-time-clocked', taskId: taskId, memberId: currentMemberId, seconds: seconds, date: date }) } export async function startTimeClock(taskId, inId) { return await postEvent({ type: 'task-started', taskId: taskId, inId: inId, memberId: currentMemberId }) } export async function stopTimeClock(taskId) { return await postEvent({ type: 'task-stopped', taskId: taskId, memberId: currentMemberId }) } // Group membership features export async function assignMembership(taskId, memberId, level) { return await postEvent({ type: 'task-membership', taskId: taskId, memberId: memberId, level: level, blame: currentMemberId }) } export async function stashCard(taskId, inId, level) { return await postEvent({ type: 'task-stashed', taskId: taskId, inId: inId, level: level, blame: currentMemberId }) } export async function unstashCard(taskId, inId, level) { return await postEvent({ type: 'task-unstashed', taskId: taskId, inId: inId, level: level, blame: currentMemberId }) } // Unreads feature export async function visitCard(taskId, inChat = false, notify = false) { return await postEvent({ type: 'task-visited', taskId: taskId, memberId: currentMemberId, area: inChat ? 1 : 0, notify: notify }) }*/ /* export async function markSeen(taskId) { const task = aoStore.hashMap.get(taskId) const act = { type: 'task-seen', taskId: taskId, memberId: currentMemberId, } // console.log('card marked seen') return await postEvent(act) } */ // Pinboard feature /*export async function resizeGrid(taskId, newHeight, newWidth, newSize) { return await postEvent({ type: 'grid-resized', taskId: taskId, height: newHeight, width: newWidth, size: newSize || 9 }) } export async function createCardWithGrid(name, height, width) { return await postEvent({ type: 'grid-created', name: name, height: height, width: width, color: 'blue', deck: [currentMemberId] }) } export async function addGridToCard(taskId, height, width, spread = 'pyramid') { return await postEvent({ type: 'grid-added', taskId: taskId, spread: spread, height: height, width: width }) } export async function removeGridFromCard(taskId) { return await postEvent({ type: 'grid-removed', taskId: taskId }) }*/ // This function encodes whatever is passed by the search box as a URIComponent and passes it to a search endpoint, returning the response when supplied export async function search(querystring, take = 10, skip = 0) { const qs = encodeURIComponent(querystring) const params = `?take=${take}&skip=${skip}` return await postRequest('/search/' + qs + params) } /*export async function requestBtcQr(taskId) { return await postEvent({ type: 'address-updated', taskId }) } export async function requestLightningInvoice(taskId, amount = 0) { return await postEvent({ type: 'invoice-created', taskId, amount: amount }) } // Proposals features export async function signCard(taskId, opinion = 1) { return await postEvent({ type: 'task-signed', taskId: taskId, memberId: currentMemberId, opinion: opinion }) } export async function setQuorum(quorum) { return await postEvent({ type: 'quorum-set', quorum: quorum }) }*/ /*reaction( () => { //return aoStore.state.socketState }, socketState => console.log('AO: client/api.ts: socketState: ' + socketState) ) console.log("NODE_ENV is", process.env.NODE_ENV)*/ //const api = new AoApi(socket) //export default api /*async fetchState() { const session = window.localStorage.getItem('session') const token = window.localStorage.getItem('token') const user = window.localStorage.getItem('user') if (session && token && user) { return request .post('/state') .set('Authorization', token) .then(res => { aoStore.state.user = user // console.log( // 'AO: client/api.ts: fetchState: initial state: ', // res.body // ) let dataPackageToSendToClient = res.body // Get the memberId let memberId dataPackageToSendToClient.stateToSend.sessions.forEach(sessionItem => { if (session === sessionItem.session) { memberId = sessionItem.ownerId } }) // See if we got our member card let foundMemberCard if(!memberId) { console.log("memberId missing when loading state") } else { dataPackageToSendToClient.stateToSend.tasks.forEach(task => { if(task.taskId === memberId) { foundMemberCard = task } }) } if(foundMemberCard) { console.log("State includes member card:", foundMemberCard) } else { console.log("State does not include member card") } aoStore.initializeState(dataPackageToSendToClient.stateToSend) let metaData = dataPackageToSendToClient.metaData aoStore.memberDeckSize = metaData.memberDeckSize aoStore.bookmarksTaskId = metaData.bookmarksTaskId return true }) .catch(() => false) } return Promise.resolve(false) }*/ /* export async createCardIfDoesNotExist( name, color, anonymous ) { return new Promise((resolve, reject) => { aoStore.getTaskByName_async(name, (task) => { if (isObject(task)) { console.log("task exists!") resolve(task) } else { const act = { type: 'task-created', name: name, color: color || 'blue', deck: [currentMemberId], inId: null, prioritized: false, } postEvent(act).then((res) => { aoStore.getTaskByName_async(name, (task) => { resolve(task) }) }) } }) }) } */ /* export async createAndPlayCard(name, color, anonymous, to) { return new Promise((resolve, reject) => { this.createCardIfDoesNotExist(name, color, anonymous).then(success => { aoStore.getTaskByName_async(name, found => { to.taskId = found.taskId resolve(this.playCard(null, to)) }) }) }) } */ /*async findOrCreateCardInCard( name, inId, prioritizedpostEvent(act) = false, color = 'blue', anonymous ) { return new Promise((resolve, reject) => { aoStore.getTaskByName_async(name, found => { console.log('gotTaskByName name was ', name, 'and found is ', found) let act if (found) { if (prioritized) { resolve(this.prioritizeCard(found.taskId, inId)) return } else { act = { type: 'task-sub-tasked', taskId: inId, subTask: found.taskId, memberId: anonymous ? null : currentMemberId, } } } else { act = { type: 'task-created', name: name, color: color, deck: anonymous ? [] : [currentMemberId], inId: inId, prioritized: prioritized, } } resolve( postEvent(act).then(res => return res) ) }) }) }*/ /*async function pinCardToGrid( x, y, name, inId ) { return new Promise((resolve, reject) => { aoStore.getTaskByName_async(name, (task) => { console.log('gotTaskByName name was ', name, 'and found is ', task) // console.log("AO: client/api.ts: pinCardToGrid: ", {x, y, name, inId, task}) if (isObject(task)) { const fromLocation const toLocation: CardLocation = { taskId.taskId, inId: inId, coords: { y, x } } playCard() } else { const act = { type: 'task-created', name: name, color: 'blue', deck: [currentMemberId], inId: inId, prioritized: false, } postEvent(act).then(res => { const taskId = JSON.parse(res.text).event.taskId const gridAct = { type: 'grid-pin', inId: inId, taskId: taskId, x: x, y: y, memberId: currentMemberId, } resolve( request .post('/events') .set('Authorization', currentSessionToken) .send(gridAct) ) }) } }) }) }*/ /*async function unpinCardFromGrid( x, y, inId ) { return await postEvent({ type: 'grid-unpin', x, y, inId, }) }*/