bot.js

/**
 * @module Seven
 */

/*** STARTUP | DECIDE ENV VAR SOURCE ***/

if (process.env.HEROKU) {
  console.log("SEVEN-SERVER: Started at " + new Date().toLocaleTimeString() + " on Heroku. Using cloud-configured env vars")
} else {
  console.log("SEVEN-SERVER: Started at " + new Date().toLocaleTimeString() + " on dev machine. Scanning ./config/env for vars")
  require('dotenv').config({ path: './config/.env' });
}

/*** HANDLE STANDARD IMPORTS ***/

require('./helpers/helpers.js')
// const winston = require('winston');
const flagEmoji = require('country-flag-emoji')
const Discord = require('discord.js')
const client = new Discord.Client()
const { JSDOM } = require("jsdom");
const rp = require('request-promise');
const csrfLogin = require('./helpers/csrf-login/csrf-login-override');
const fs = require('fs');
const jp = require('jsonpath');
const typoify = require('./modules/nlp/typoify')
const parseDate = require('date-fns/parse')
const formatRelative = require('date-fns/formatRelative')
const dialogflow = require('dialogflow').v2beta1;
const dflow = new dialogflow.SessionsClient({ credentials: JSON.parse(process.env.GOOGLE_APPLICATION_CREDENTIALS) });
const dFlowEnt = require('./helpers/update')
const strings = require('./static/strings')
const safeEval = require('safe-eval')
const { checkSelfName } = require('./helpers/nlp');
const { HtbMachine, HtbChallenge, TeamMember, HtbOwn, HtbMaker } = require('./helpers/classes');
const { HtbPusherEvent, HtbPusherSubscription } = require('./helpers/pusher-htb');
const pgp = require('pg-promise')({ capSQL: true });

/*** INIT GLOBAL VARIABLES ***/

var UPDATE_LOCK = false           // Simple way to prevent slow / lagging updates from clashing with new ones

var DISCORD_ANNOUNCE_CHAN = false // The Discord Channel object intended to recieve Pusher achievements.
var HTB_PUSHER_OWNS_SUBSCRIPTION = false         // The Pusher Client own channel subscription.
var CSRF_TOKEN = ""


/*** HANDLE DEVELOPMENT INSTANCE CASE ***/

var DEV_MODE_ON = false;

if (process.env.IS_DEV_INSTANCE) {
  DEV_MODE_ON = true;
  console.error("DEVELOPMENT MODE ON.\n  Only queries by the developer will be responded to by this instance.\n  (Avoids conflicts/ duplicate responses in production use)")
}


/* SETUP DB IMPORT TO RESTORE LAST GOOD STATE */
const cn = {
  connectionString: process.env.DATABASE_URL,
  ssl: {
    rejectUnauthorized: false
  }
};

const DB_FIELDNAMES_AUTO = ["MACHINES", "CHALLENGES", "TEAM_MEMBERS", "TEAM_STATS"]
const db = pgp(cn);

/** Imports globals from the cloud backup (Objects stored as raw, singular JSON columns in DB)
 * @returns {Promise}
*/
async function importDbBackup() {
  return new Promise(async resolve => {
    try {
      rows = await db.any('SELECT json FROM cache ORDER BY id ASC;', [true]);
      MACHINES = JSON.parse(rows[0].json)
      CHALLENGES = JSON.parse(rows[1].json)
      TEAM_MEMBERS = JSON.parse(rows[2].json)
      TEAM_MEMBERS_IGNORED = JSON.parse(rows[3].json)
      TEAM_STATS = JSON.parse(rows[4].json)
      DISCORD_LINKS = JSON.parse(rows[5].json)
      if (TEAM_STATS == {}) {
        TEAM_STATS = { "globalRanking": 5, "points": 0, "teamFounder": 7383, "topMembers": [], "name": "", "owns": { "users": 0, "roots": 0 }, "thumb": "https://www.hackthebox.com/storage/teams/8232e119d8f59aa83050a741631803a6.jpg" }
      }
      updateTeamStats()
      console.log("IMPORT: Restored from DB backup.")
      resolve()
    }
    catch (e) {
      console.error(e)
      resolve()
    }
  })
}


/** Updates the cloud backup, with options for selective update. 
 * @param {string[]} fields - The specific data types / buffers specified for the update operation, e.g. ["MACHINES","TEAM_MEMBERS"]
*/
async function updateCache(fields = DB_FIELDNAMES_AUTO) {
  var fieldData = []
  for (let i = 0; i < fields.length; i++) {
    var fieldName = fields[i];
    switch (fieldName.toLowerCase()) {
      case "machines": fieldData.push({ id: 1, json: MACHINES }); break;
      case "challenges": fieldData.push({ id: 2, json: CHALLENGES }); break;
      case "team_members": fieldData.push({ id: 3, json: TEAM_MEMBERS }); break;
      case "team_members_ignored": fieldData.push({ id: 4, json: TEAM_MEMBERS_IGNORED }); break; // These should only update manually
      case "team_stats": fieldData.push({ id: 5, json: TEAM_STATS }); break;
      case "discord_links": fieldData.push({ id: 6, json: DISCORD_LINKS }); break;               // These should only update manually
      default:
        break;
    }
  }
  const cs = new pgp.helpers.ColumnSet(['?id', { name: 'json', cast: 'json' }], { table: 'cache' });
  const update = pgp.helpers.update(fieldData, cs) + ' WHERE v.id = t.id';
  return await db.result(update)
    .then(() => {
      console.log("EXPORT: Updated database backup.")
      return true
    })
    .catch(error => {
      console.error(e)
      return false
    });
}



/**
 * Arrow function to generate a random hexadecimal nonce so images always get re-downloaded in Discord client.
 * @param {*} size - The length of the desired hex nonce.
 */
const genRanHex = size => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('');

/**
 * Returns one of the items passed as parameters, at random. Ex. usage: any("Hi","Hello","Hey")
 */
function any() {
  return arguments[arguments.length * Math.random() | 0]
}

/**
 * Returns either true or false based on the provided probabilty value (a number between 0-1, where 1 => 100% true and 0 => 100% false).
 * @param {number} probability 
 * @returns boolean
 */
function maybe(probability) {
  if (Math.random() < probability) return true;
  else return false;
}

/**
 * Promise-based sleep function (blocks only the caller (async) function)
 * @param {number} ms - The time in milliseconds to sleep.
 */
async function wait(ms) {
  return new Promise(resolve => {
    setTimeout(resolve, ms);
  });
}

/**
 * Returns the larger of the two objects, or -- alternatively -- the more real of the two (if one is falsey).
 * Returns 0 if both are falsey.
 * @param {Object} a1 
 * @param {Object} a2 
 */
function realMax(a1, a2) {
  if (a1) {
    if (a2) {
      return max(a1, a2)
    } else {
      return a1
    }
  } else {
    if (a2) {
      return a2
    } else {
      return 0
    }
  }
}

/** Returns the appropriate ordinal suffix (does NOT concatenate) for a given number.
 * @param {number} n - The number to get the ordinal suffix for.
 */
function nth(n) { return ["st", "nd", "rd"][((n + 90) % 100 - 10) % 10 - 1] || "th" }

/**
 * Returns the number of days that have elapsed since the provided date.
 * @param {Date} date - The Date object to compare against.
 * @returns {number}
 */
function elapsedDays(date) {
  var thisTime = new Date();
  var diff = thisTime.getTime() - date.getTime();  // Get the time elapsed
  return Math.round(diff / (1000 * 60 * 60 * 24)); // ... As a positive number of days 
}

/**
 * Returns the appropriate OS thumbnail for a specified OS name.
 * @param {string} osName
 * @returns {string}
 */
function getOsImage(osName) {
  switch (osName) {
    case "Linux": return 'https://i.ibb.co/mHXrhyC/linux.png'
    case "Windows": return 'https://i.ibb.co/61JG09j/windark.png'
    case "Solaris": return 'https://www.hackthebox.com/images/solaris.png'
    case "FreeBSD": return 'https://www.hackthebox.com/images/freebsd.png'
    case "Android": return 'https://www.hackthebox.com/images/android.png'
    default:
      return 'https://www.hackthebox.com/images/favicon.png'
  }
}

/**
 * Returns a fuzzy time estimate for a given date, relative to the present, that some date occurred (e.g. "4 months ago").
 * @param {Date} date 
 */
function timeSince(date) {
  var seconds = Math.floor((new Date() - date) / 1000);
  var interval = Math.floor(seconds / 31536000);
  if (interval < 0) {
    return "just now"
  }
  if (interval > 1) {
    return interval + " years ago";
  }
  interval = Math.floor(seconds / 2592000);
  if (interval > 1) {
    return interval + " months ago";
  }
  interval = Math.floor(seconds / 86400);
  if (interval > 1) {
    return interval + " days ago";
  }
  interval = Math.floor(seconds / 3600);
  if (interval > 1) {
    return interval + " hours ago";
  }
  interval = Math.floor(seconds / 60);
  if (interval > 1) {
    return interval + " minutes ago";
  }
  return Math.floor(seconds) + " seconds ago";
}

/**
 * Uses unicode special character sets to prettify text, despite all of Discord's precautions.
 * @param {string} str - The string to format
 * @param {string} inType - The style / alphabet to apply. One of: [bold sans-serif ('bs'), sans-serif ('s'), bold ('b'), monospaced ('m')]
 * @returns {string}
 */
function FMT(str, inType) { //Converts text to the unicode special math font equivalent specified in switch [ bs, s, b, m ]
  var out = ""
  type = "𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹𝖺𝖻𝖼𝖽𝖾𝖿𝗀𝗁𝗂𝗃𝗄𝗅𝗆𝗇𝗈𝗉𝗊𝗋𝗌𝗍𝗎𝗏𝗐𝗑𝗒𝗓"           // Default to math sans
  var normalSet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" // Normal alphabet
  if (inType == "bs") {       // If bold sans
    var type = "𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵𝗔𝗕𝗖𝗗𝗘𝗙𝗚𝗛𝗜𝗝𝗞𝗟𝗠𝗡𝗢𝗣𝗤𝗥𝗦𝗧𝗨𝗩𝗪𝗫𝗬𝗭𝗮𝗯𝗰𝗱𝗲𝗳𝗴𝗵𝗶𝗷𝗸𝗹𝗺𝗻𝗼𝗽𝗾𝗿𝘀𝘁𝘂𝘃𝘄𝘅𝘆𝘇"
  } else if (inType == "s") { // If sans
    var type = "𝟢𝟣𝟤𝟥𝟦𝟧𝟨𝟩𝟪𝟫𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹𝖺𝖻𝖼𝖽𝖾𝖿𝗀𝗁𝗂𝗃𝗄𝗅𝗆𝗇𝗈𝗉𝗊𝗋𝗌𝗍𝗎𝗏𝗐𝗑𝗒𝗓"
  } else if (inType == "b") { // If bold serif
    var type = "𝟎𝟏𝟐𝟑𝟒𝟓𝟔𝟕𝟖𝟗𝐀𝐁𝐂𝐃𝐄𝐅𝐆𝐇𝐈𝐉𝐊𝐋𝐌𝐍𝐎𝐏𝐐𝐑𝐒𝐓𝐔𝐕𝐖𝐗𝐘𝐙𝐚𝐛𝐜𝐝𝐞𝐟𝐠𝐡𝐢𝐣𝐤𝐥𝐦𝐧𝐨𝐩𝐪𝐫𝐬𝐭𝐮𝐯𝐰𝐱𝐲𝐳"
  } else if (inType == "m") { // If monospaced
    var type = "𝟶𝟷𝟸𝟹𝟺𝟻𝟼𝟽𝟾𝟿𝙰𝙱𝙲𝙳𝙴𝙵𝙶𝙷𝙸𝙹𝙺𝙻𝙼𝙽𝙾𝙿𝚀𝚁𝚂𝚃𝚄𝚅𝚆𝚇𝚈𝚉𝚊𝚋𝚌𝚍𝚎𝚏𝚐𝚑𝚒𝚓𝚔𝚕𝚖𝚗𝚘𝚙𝚚𝚛𝚜𝚝𝚞𝚟𝚠𝚡𝚢𝚣"
  }

  var fancySet = [...type]    // Convert to array with new ES6 string to byte array comprehension
  for (let i = 0; i < str.length; i++) {
    var char = str.charAt(i)
    if (normalSet.includes(char)) {
      var x = normalSet.indexOf(char)
      out += fancySet[x]
    } else {
      out += char
    }
  }
  // console.log('String in: ' + str + ' | String out: ' + out) // View conversion results
  return out
}

/**
 * Formats a given number in Unicode bold characters, for emphasis.
 * @param {string} str - The number to style in bold text.
 */
function numS(str) {
  str = str.toString()
  console.log(str)
  bold = "𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵"
  var bDig = [...bold]
  var o = ""
  for (let i = 0; i < str.length; i++) {
    switch (str.charAt(i)) {
      case '0': o += bDig[0]; break;
      case '1': o += bDig[1]; break;
      case '2': o += bDig[2]; break;
      case '3': o += bDig[3]; break;
      case '4': o += bDig[4]; break;
      case '5': o += bDig[5]; break;
      case '6': o += bDig[6]; break;
      case '7': o += bDig[7]; break;
      case '8': o += bDig[8]; break;
      case '9': o += bDig[9]; break;
      default:
        o += str.charAt(i)
        break;
    }
  }
  return o
}

/**
 * Calculates and returns the sub-integral symbol, used in 'ratingString' function
 * @param {number} num - the sub-integral value, between 0 - 1.
 */
function halfMoon(num) {
  divd = num % 1
  scale = Math.round(divd * 4);
  switch (scale) {
    case 0: return '🌑'
    case 1: return '🌘'
    case 2: return '🌗'
    case 3: return '🌖'
    case 4: return '🌕'
    default: return '🌝'
  }
}

/**
 * Converts a numeric rating (0.0 - 5.0) to a Unicode moon rating expression (e.g. '2.5' => 🌕🌕🌗🌑🌑)
 * @param {number} rating - The rating, a positive number from 0 - 5. 
 */
function ratingString(rating) {
  if (rating == 0) {
    return 'Unrated'
  } else {
    return '🌕'.repeat(Math.floor(rating)) + halfMoon(rating) + '🌑'.repeat(Math.max((5 - Math.floor(rating)) - 1, 0))
  }
}

/**
 * Returns whether the provided Discord user has admin privileges.
 * @param {Discord.User} author 
 */
function isAdmin(author) {
  return author.id == process.env.ADMIN_DISCORD_ID
}


/*** INITIALIZE HACKTHEBOX STATE VARIABLES ***/
const HTBROOT = "https://www.hackthebox.com/"
var LAST_UPDATE = new Date()
var MACHINES = {}
var MACHINES_BUFFER
var CHALLENGES
var DISCORD_LINKS = {}
var TEAM_MEMBERS = {}
var TEAM_MEMBERS_IGNORED = {}
var MACHINE_STATS = { "totalBoxes": 0, "activeBoxes": 0, "retiredBoxes": 0, "unreleasedBoxes": 0 }
var TEAM_STATS = { "globalRanking": 5, "points": 0, "teamFounder": "7383", "topMembers": [], "name": "", "owns": { "users": 0, "roots": 0 }, "thumb": "" }

/**
 * Moves a member to the 'ignored' set, meaning that their data will not be updated
 * or shared by the bot until the user undoes this (see unignoreMember()).
 * @param {number} uid 
 */
function ignoreMember(uid) {
  if (uid in TEAM_MEMBERS) {
    console.log(Object.keys(TEAM_MEMBERS).length)
    console.log(TEAM_MEMBERS_IGNORED)
    TEAM_MEMBERS_IGNORED[uid] = TEAM_MEMBERS[uid]
    delete TEAM_MEMBERS[uid]
    updateCache(["team_members", "team_members_ignored"])
    // exportData(TEAM_MEMBERS, "team_members.json");
    // exportData(TEAM_MEMBERS_IGNORED, "team_members_ignored.json")
    console.log(Object.keys(TEAM_MEMBERS).length)
    console.log(TEAM_MEMBERS_IGNORED)
    return TEAM_MEMBERS_IGNORED[uid].name
  } else {
    return false
  }
}

/**
 * Moves a member out of the 'ignored' set, meaning that their data will now be
 * updated and shared by the bot unless the user requests otherwise again (see ignoreMember()).
 * @param {number} uid
 * @returns {(string|false)} 
 */
function unignoreMember(uid) {
  console.log("Unignoring member " + uid)
  if (uid in TEAM_MEMBERS_IGNORED) {
    console.log(Object.keys(TEAM_MEMBERS).length)
    console.log(TEAM_MEMBERS_IGNORED)
    TEAM_MEMBERS[uid] = TEAM_MEMBERS_IGNORED[uid]
    delete TEAM_MEMBERS_IGNORED[uid]
    updateCache(["team_members", "team_members_ignored"])
    //exportData(TEAM_MEMBERS, "team_members.json");
    //exportData(TEAM_MEMBERS_IGNORED, "team_members_ignored.json")
    console.log(Object.keys(TEAM_MEMBERS).length)
    console.log(TEAM_MEMBERS_IGNORED)
    updateTeamStats()
    return TEAM_MEMBERS[uid].name
  } else {
    return false
  }
}



/**
 * Calculates the total machine count and the subcounts of active, retired and unreleased machines.
 * @returns {Object}
 */
function updateMachineStats() {
  var statBuffer = { "totalBoxes": 0, "activeBoxes": 0, "retiredBoxes": 0, "unreleasedBoxes": 0 }
  time = new Date().getTime()
  Object.values(MACHINES).forEach(machine => {
    if (machine.release > time) { // If not yet released
      statBuffer.unreleasedBoxes += 1
    } else if (machine.retired) {
      statBuffer.retiredBoxes += 1
    } else {
      statBuffer.activeBoxes += 1
    }
    statBuffer.totalBoxes += 1
  });
  return statBuffer
}

/**
 * Returns a Discord Markdown formatted list of member rankings.
 * @param {string[]} arr - A list of hyperlinked member names sorted by rank (descending) where index 0 is the highest ranking member.
 * @returns {string[]}
 */
function mdItemizeList(arr) {
  var out = []
  for (const [index, element] of arr.entries()) {
    if (index == 0) {
      out.push("` " + index.toString().padStart(2, '0') + " `⠀⟶⠀" + element + "⠀🔥")
    } else {
      out.push("` " + index.toString().padStart(2, '0') + " `⠀⟶⠀" + element)
    }
  }
  return out
}

/**
 * Returns a pretty emoji flag for a given country code. If not recognized, returns the Jolly Roger.
 * @param {string} countryCode - Example: "EU", "AU", "NZ"
 * @returns {string} - The emoji, e.g. 🏴‍☠️
 */
function getFlag(countryCode) {
  flag = "🏴‍☠️"
  try {
    flag = flagEmoji.data[countryCode].emoji
  } catch (error) {
    // console.error(error)
  }
  return flag
}

/** Updates the top team member tally. Should be done after every achievement update (or Pusher own receipt) */
async function updateTeamStats() {
  var teamMembersAll = { ...TEAM_MEMBERS, ...TEAM_MEMBERS_IGNORED }
  sortedByTPoints = Object.keys(teamMembersAll).sort(function (a, b) { return teamMembersAll[b].points - teamMembersAll[a].points })
  TEAM_STATS.topMembers = sortedByTPoints
}

/**
 * Returns true if the object has any properties (is not empty), else returns false.
 * @param {Object} obj 
 * @returns {boolean}
 */
function isEmpty(obj) {
  for (var key in obj) {
    if (obj.hasOwnProperty(key))
      return false;
  }
  return true;
}

/**
 * A local file-based method for restoring local backups, useful for early testing without a database instance.
 */
function importExistingData() { // Imports machine and team data from json files, enabling the bot to serve answers immediately.
  try {
    MACHINES = JSON.parse(fs.readFileSync('cache/machines.json', 'utf8'));
    CHALLENGES = JSON.parse(fs.readFileSync('cache/challenges.json', 'utf8'));
    TEAM_MEMBERS = JSON.parse(fs.readFileSync('cache/team_members.json', 'utf8'));
    TEAM_MEMBERS_IGNORED = JSON.parse(fs.readFileSync('cache/team_members_ignored.json', 'utf8'));
    DISCORD_LINKS = JSON.parse(fs.readFileSync('cache/discord_links.json', 'utf8'));
    TEAM_STATS = JSON.parse(fs.readFileSync('cache/team_stats.json', 'utf8'));
    MACHINE_STATS = updateMachineStats()
    updateTeamStats()
    console.info("Imported existing datafiles! Will update automatically every half-hour.")
  }
  catch (e) {
    console.warn('ERROR: couldn\'t import data. Moving on..')
    console.log(e)
  }
}

/**
 * A local file-based export method that serializes a specific HTB state data to a JSON file. Useful for pre-deployment testing.
 * @param {Object} object - The object to back up as JSON.
 * @param {*} filename - The name to use for the JSON file.
 */
function exportData(object, filename) { // Save JSON files of team and machine data.
  var objectName = varObj => Object.keys(varObj)[0]
  //if (!isEmpty(object)) {
  fs.writeFile("cache/" + filename, JSON.stringify(object, null, "\t"), 'utf8', function (err) {
    if (err) {
      console.log("An error occured while writing " + objectName + " settings to File.");
      return console.log(err);
    }
    console.log("JSON file " + filename + " has been saved.");
  });
  //}
}

/**
 * Converts a textual list (e.g. "Bob, Jane, Alice") to "Bob, Jane, and Alice".
 * @param {string} - A ", " separated textual list
 * @returns {string} The converted text.
 */
function andifyList(str) { // 
  if (str.includes(',')) {
    var n = str.lastIndexOf(",");
    return str.substring(0, n + 1) + ' and' + str.substring(n + 1, str.length);
  } else {
    return str
  }
}

/**
 * Returns the name of either an upcoming release (if announced) or the latest published box.
 * @returns {(string|null)}
 */
function getNewReleaseName() {
  const unreleasedBox = Object.values(MACHINES).find(machine => machine.unreleased)
  return unreleasedBox.name || null
}

/**
 * Get the ID of the machine whose name matches the parameter string.
 * @param {string} name - The machine name.
 * @returns {(number|null)}
 */
function getMachineIdFromName(name) {
  var machineArray = Object.values(MACHINES)
  const fetchedId = Number(Object.values(MACHINES).find(machine => machine.name.toLowerCase() == name.toLowerCase()).id)

  return fetchedId
}

/**
 * Get the machine object whose name matches the parameter string.
 * @param {string} name - The machine name.
 * @returns {(HtbMachine|null)}
 */
function getMachineByName(name) {
  if (name) {
    var machineArray = Object.values(MACHINES)
    return machineArray.find(machine => machine.name.toLowerCase() == name.toLowerCase())
  }
  return null
}

/**
 * Get the machine object whose id matches the parameter string.
 * @param {string} id - The machine id.
 * @returns {(HtbMachine|null)}
 */
function getMachineById(id) {
  try { return MACHINES[id] } catch (error) { console.error(error); return null }

}

/**
 * Get the challenge object whose name matches the parameter string.
 * @param {string} name - The challenge name.
 * @returns {(HtbChallenge|null)}
 */
function getChallengeByName(name) { // Return machine object with name matching parameter string
  //console.log(name)
  if (name && CHALLENGES) {
    var challengeArray = Object.values(CHALLENGES)
    for (let i = 0; i < challengeArray.length; i++) {
      for (let j = 0; j < challengeArray[i].length; j++) {
        // console.log(machineArray[i] + JSON.stringify(machineArray[i]))
        if (challengeArray[i][j].name == name) {
          //console.log('Challenge name ' + name + ' matched validated name ' + challengeArray[i][j].name)
          return challengeArray[i][j]
        }
      }
    }
  }
  return null
}

/**
 * Get the  current rank of a team member by ID.
 * @param {number} id - The member ID.
 * @returns {(number|"Unknown")}
 */
function getMemberTeamRankById(id) {
  try {
    return TEAM_STATS.topMembers.indexOf(id) + 1
  } catch (error) {
    console.log(error)
    return ("Unknown")
  }
}

/**
 * Get the member object whose name matches the parameter string.
 * @param {string} name - The member name.
 * @returns {(TeamMember|null)}
 */
function getMemberByName(name) {
  //console.log(name)
  if (name) {
    var match = (Object.values(TEAM_MEMBERS).find(member => member.name.toLowerCase() == name.toLowerCase()))
    if (match) {
      return match
    } else {
      match = getIdFromDiscordName(name)
      return (TEAM_MEMBERS[match] ? TEAM_MEMBERS[match] : null)
    }
  }
}

/**
 * Takes information about a new own achievement and adds it to our records.
 * @param {number} uid - The ID of the user associated with the achievement.
 * @param {string} type - A string value describing the thing / milestone owned, e.g. "root", "user", "challenge", "endgame", "akerva" etc.
 * @param {(number|string)} target - If a machine user/system own, use the numeric machine ID (e.g. 238). If a challenge or fortress own, use the title.
 * @param {string} flag - For pro labs and fortresses with multiple flags, use this field to specify the milestone title.
 */
function addOwn(uid, time, type, target, flag = "", isPusher = false) {
  if (uid in TEAM_MEMBERS) {
    try {
      switch (type) {
        case "user":
          var own = getMachineByName(target).userOwners.find(ownItem => ownItem.uid == uid)
          if (!own) {
            console.log((isPusher ? "user owns for this target: " + getMachineByName(target).userOwners.length : ""))
            getMachineByName(target).userOwners.push(new HtbOwn(uid, time))
            console.log((isPusher ? "user owns for this target: " + getMachineByName(target).userOwners.length : ""))
            TEAM_MEMBERS[uid].totalOwns.user++
            console.log((isPusher ? "Added user own for " + uidToUname(uid) : ""))
          } break;

        case "root":
          console.log((isPusher ? "root owns for this target:" + getMachineByName(target).userOwners.length : ""))
          var own = getMachineByName(target).rootOwners.find(ownItem => ownItem.uid == uid)
          console.log((isPusher ? "root owns for this target: " + getMachineByName(target).userOwners.length : ""))
          if (!own) {
            getMachineByName(target).rootOwners.push(new HtbOwn(uid, time))
            TEAM_MEMBERS[uid].totalOwns.user++
            console.log((isPusher ? "Added root own for " + uidToUname(uid) : "")); break;
          }

        case "challenge":
          var challenge = getChallengeByName(target)
          var own = challenge.owners.find(ownItem => ownItem.uid == uid)
          if (!own) {
            console.log((isPusher ? "root owns for this target: " + challenge.userOwners.length : ""))
            challenge.owners.push(new HtbOwn(uid, time))
            console.log((isPusher ? "root owns for this target: " + challenge.userOwners.length : ""))
            TEAM_MEMBERS[uid].totalOwns.challenge++
            console.log((isPusher ? "Added challenge own for " + uidToUname(uid) : ""))
          } break;
        default:
          break;
      }
    } catch (error) {
      console.error(error)
    }
  } else {
  }
}

/**
 * Returns a list of HtbOwn objects representing the members who have owned the specified challenge.
 * @param {string} challengeName - Name of the challenge.
 * @returns {(HtbOwn[]|null)}
 */
function getOwnersByChallengeName(challengeName) {
  console.log("Getting owners by challenge name:", challengeName)
  if (challengeName) {
    return Array.from(getChallengeByName(challengeName).owners)
  } else {
    return null
  }
}

/**
 * Returns a list of HtbOwn objects representing the members who have rooted the machine with the specified ID.
 * @param {number} machineId - Name of the challenge.
 * @returns {(HtbOwn[]|null)}
 */
function getOwnersByMachineId(machineId) {
  console.log(machineId)
  if (machineId) {
    return Array.from(MACHINES[machineId].rootOwners)
  } else {
    return null
  }
}

/**
 * Gets the HTB user ID for a given Discord username, if such an association exists.
 * @param {string} username - The Discord username to lookup linked account for.
 * @returns {(number|null)}
 */
function getIdFromDiscordName(username) {
  var id = Object.keys(DISCORD_LINKS).find(link => DISCORD_LINKS[link].username.toLowerCase() == username.toLowerCase())
  return (id ? id : null)
}

/**
 * Returns a pretty-printable version of the Discord username and / or HTB username for a given HTB UID, in hyperlinked Discord markdown.
 * @param {number} uid
 * @returns {(string|"[Invalid ID]")}
 */
function tryDiscordifyUid(uid) {
  if (uid in TEAM_MEMBERS) {
    if (uid in DISCORD_LINKS) {
      discordName = DISCORD_LINKS[uid].username
      if (discordName != TEAM_MEMBERS[uid].name) {
        return TEAM_MEMBERS[uid].name + " (🌀" + FMT(discordName, "bs") + ")"
      } else {
        return "🌀" + FMT(discordName, "bs")
      }
    } else {
      return TEAM_MEMBERS[uid].name
    }
  } else {
    return "[Invalid ID]"
  }
}

/**
 * Returns a set of markdown-formatted username links for a given list of HTB ids.
 * @param {number[]} memberIds - An array of HTB UIDs 
 * @returns {(string[]|"[Invalid ID]")}
 */
function getMdLinksForUids(memberIds) { // Get markdown link to a HTB user's profile, based on UID.
  //console.log(memberIds)
  if (memberIds) {
    screenNames = []
    memberIds.forEach(uid => {
      //console.log("UID: " + uid)
      if (uid in TEAM_MEMBERS) {
        screenNames.push('[' + tryDiscordifyUid(uid) + ']' + '(' + 'https://www.hackthebox.com/home/users/profile/' + uid + ' ' + "'Goto HTB page for " + TEAM_MEMBERS[uid].name + (uid in DISCORD_LINKS ? " / @" + DISCORD_LINKS[uid].tag : "") + '\')')
      } else {
        console.log("UID opted out of data collection.")
        screenNames.push('[٩(͡๏̯͡๏)۶](https://www.hackthebox.com/ \'This user has disabled data collection by Seven.\')')
      }
    });
    if (screenNames.length == 0) {
      return null
    } else {
      return screenNames
    }
  } else {
    return null
  }
}

function getMdLinksForBoxIds(boxIds) { // Get markdown link to a HTB user's profile, based on UID.
  // console.log(boxIds)
  if (boxIds) {
    boxLinks = []
    boxIds.forEach(boxId => {
      if (boxId in MACHINES) {
        var box = MACHINES[boxId]
        boxLinks.push('**[' + box.name + "](" + 'https://www.hackthebox.com/home/machines/profile/' + box.id + " 'Goto HTB page')**")
      }
    });
    // console.log(boxLinks)
    if (boxLinks.length == 0) {
      return null
    } else {
      return boxLinks
    }
  } else {
    return null
  }
}

function getMdLinksForChallengeCategoriesByChallengeNames(challengeNames) { // Get markdown link to a HTB user's profile, based on UID.
  categories = {}
  Object.keys(CHALLENGES).forEach(challengeCategoryName => {
    categories[challengeCategoryName] = []
  })
  // console.log(challengeNames)
  if (challengeNames) {
    challengeNames.forEach(challengeName => {
      // console.log(challengeName)
      var challenge = getChallengeByName(challengeName)
      if (challenge) {
        categories[challenge.category].push('**[' + challenge.name + "](http://0)**")
      }

    });
    console.log(categories)
    return categories

  } else {
    return null
  }
}

function getMdLinksForOwnList(memberOwnList) { // Get markdown link to a HTB user's profile, based on UID.
  // console.log(memberOwnList)
  if (memberOwnList) {
    screenNames = []
    memberOwnList.forEach(memberOwn => {
      if (memberOwn.uid in TEAM_MEMBERS) {
        if (memberOwn.uid in DISCORD_LINKS) {
          console.log(DISCORD_LINKS[memberOwn.uid].username + " / " + TEAM_MEMBERS[memberOwn.uid].name)
        }
        screenNames.push('[' + tryDiscordifyUid(memberOwn.uid) + ']' + '(' + 'https://www.hackthebox.com/home/users/profile/' + memberOwn.uid + ' ' + "'HTB Profile for " + TEAM_MEMBERS[memberOwn.uid].name + (memberOwn.uid in DISCORD_LINKS ? " / @" + DISCORD_LINKS[memberOwn.uid].tag : "") + "')")
      } else {
        console.log("UID opted out of data collection.")
        screenNames.push('[٩(͡๏̯͡๏)۶](https://www.hackthebox.com/ \'This user has disabled data collection by Seven.\')')
      }
    });
    if (screenNames.length == 0) {
      return null
    } else {
      return screenNames
    }
  } else {
    return null
  }
}

function parseSingleDate(date,) { // Parse date to timestamp (millis) for various formats used on HTB site, based on length
  if (date) {
    switch (date.length) {
      case 10:
        try {
          return new Date(parseDate(date, 'y-MM-dd', new Date(0)).setUTCHours(19)).getTime()
        } catch (error) {
          console.log(error)
          return 0
        }
      case 23:
        try {
          return new Date(date.replace(' UTC', 'Z')).getTime()
        } catch (error) {
          console.log(error)
          return 0
        }
      default:
        try {
          return new Date(parseDate(date, 'MMMM do, yyyy', new Date(0)).setUTCHours(19)).getTime()
        } catch (error) {
          console.log(error)
          return 0
        }
    }
  } else {
    return 0
  }
}

function parseDateArray(dates) { // Parse array of dates (see parseSingleDate)
  newDates = []
  dates.forEach(dateString => {
    newDates.push(parseSingleDate(dateString))
  });
  return newDates
}

function getMachines() {
  return new Promise(resolve => {
    rp('https://www.hackthebox.com/api/machines/get/all?api_token=' + process.env.HTB_TOKEN, { json: true })
      .then(function (machines) {
        machineSet = {}
        console.log('Got', machines.length, 'machines...');
        //console.log(value)
        var machineIds = jp.query(machines, '$.*.id');
        var machineNames = jp.query(machines, '$.*.name');
        var machineThumbs = jp.query(machines, '$.*.avatar_thumb')
        var machineIsRetireds = jp.query(machines, '$.*.retired')
        var machineMakers = jp.query(machines, '$.*.maker')
        var machineMaker2s = jp.query(machines, '$.*.maker2')
        var machineOses = jp.query(machines, '$.*.os')
        var machineIps = jp.query(machines, '$.*.ip')
        var machineRatings = jp.query(machines, '$.*.rating')
        var machineReleases = jp.query(machines, '$.*.release')
        var machineRetireDates = jp.query(machines, '$.*.retired_date')
        var machinePoints = jp.query(machines, '$.*.points')
        machineReleases = parseDateArray(machineReleases)
        machineRetireDates = parseDateArray(machineRetireDates)
        for (let i = 0; i < machineIds.length; i++) {
          machineSet[machineIds[i].toString()] = new HtbMachine(machineNames[i],
            Number(machineIds[i]),
            machineThumbs[i],
            machineIsRetireds[i],
            machineMakers[i],
            machineMaker2s[i],
            machineOses[i],
            machineIps[i],
            machineRatings[i],
            machineReleases[i],
            machineRetireDates[i],
            machinePoints[i],
            false
          )
        }
        // console.log(machineSet)
        resolve(machineSet);
      })
      .catch(function (err) {
        resolve(err);
      });
  });
}

async function getChallenges(session) {
  console.log("Getting challenges...")
  challengeBuffer = {}
  var categories = ["Reversing", "Crypto", "Stego", "Pwn", "Web", "Misc", "Forensics", "Mobile", "OSINT", "Hardware"]
  return new Promise(async resolve => {
    for (let i = 0; i < categories.length; i++) {
      category = categories[i]
      response = await getChallengesCategory(category, session)
      var thisCategoryChallenges = []
      var $ = require('jquery')(new JSDOM(response.body).window);

      $('.panel-heading').each(function () {
        var description = this.nextSibling.nextSibling.firstChild.nextSibling.nextSibling.nextSibling.nextSibling.data.trim()
        var points = 0
        var isActive = false
        //console.log(description)
        var dateString = $($(this).children('.panel-tools')[0]).text().trim()
        var releaseDate = new Date(parseDate(dateString, 'dd/MM/yyyy', new Date(0)).setUTCHours(19)).getTime()
        var spans = $(this).children('span')
        var activeChecker = $(spans[0]).text().trim()
        //console.log(activeChecker.trim())
        if (activeChecker.trim().startsWith("[")) {
          isActive = true
          //console.log(spans.html())
          points = Number($(spans[0]).html().match(/[\d]*(\d+)/g)[0])

        } else {
          // console.log("Not Active.")
          // console.log(spans[0].innerText)
        }
        var makers = $(this).find("a[href^='https://www.hackthebox.com/home/users/profile/']")
        maker = { "id": makers[0].href.substring(45), "name": makers[0].innerHTML }
        var maker2 = null
        try {
          maker2 = { "id": makers[1].href.substring(45), "name": makers[1].innerHTML }
        } catch (error) {
          //console.log('\nNo 2nd maker ...')
        }
        var tex = $(this).text()
        //console.log("TEX",tex)
        var name = tex.substring(tex.indexOf((isActive ? '] ' : '     ')) + 1, tex.indexOf('[by') - 1).trim()
        var solverCount = Number($(spans[1]).text().match(/[\d]*(\d+)/g))
        var ratePro = Number($(spans[2]).text())
        var rateSucks = Number($(spans[3]).text())
        // console.log("GOT CHALLENGE. Datestring:", dateString, "|", "name:", name, "| maker:", maker.name, "| maker2:", (maker2 ? maker2.name : "None"), "| points:", points, "| active:", isActive, "| solvercount:", solverCount, "| ratings:", ratePro, rateSucks * -1)
        // console.log("Got challenge", name + " ...")
        var thisChallenge = new HtbChallenge(name, category, releaseDate, description, isActive, points, maker, maker2, solverCount, ratePro, rateSucks)
        thisCategoryChallenges.push(thisChallenge)
        if (!getChallengeByName(thisChallenge.name)) {
          // dFlowEnt.updateEntity('challenge', thisChallenge.name)
        }
      })
      console.log("Got", thisCategoryChallenges.length, "challenges in the '" + category + "' category")
      challengeBuffer[category] = thisCategoryChallenges
    };
    resolve(challengeBuffer)
  })
}

function getTeamData(session) {
  return new Promise(resolve => {
    console.log("Getting team details...")
    session.request('/home/teams/profile/2102', function (error, response, body) {

      teamData = {}
      teamUsers = {}
      var $ = require('jquery')(new JSDOM(body).window);
      //console.log("Team page body length:" + body)
      // Parse Team Stats
      try {
        var teamName = $('.row-selected').children()[1].innerHTML
        var thumb = $($('.header-icon').find('.image-lg')[0]).attr('data-cfsrc')
        var globalRanking = Number($('.row-selected').children()[0].innerHTML)
        var totalPoints = Number($('.row-selected').children()[2].innerHTML)
        var totalRoots = Number($('.row-selected').children()[3].innerHTML)
        var totalUsers = Number($('.row-selected').children()[4].innerHTML)
        TEAM_STATS.name = teamName
        TEAM_STATS.thumb = thumb // Not working. Needs fixing
        TEAM_STATS.globalRanking = globalRanking
        TEAM_STATS.points = totalPoints
        TEAM_STATS.owns.roots = totalRoots
        TEAM_STATS.owns.users = totalUsers
        console.log("Team Stats Updated:", TEAM_STATS)
      } catch (error) {
        console.error(error)
        console.error('ERROR: Could not parse team Data. Failing gracefully...')
      }
      console.log("Getting team members...")
      // Parse Team Members
      try {

        var jq = $($('#membersTable').children()[1]).children().each(function () {
          var stats = $(this).children()
          var siterank = 99999999;
          userpoints = 0;
          if (stats[0].innerHTML && stats[0].innerHTML != 'unranked') {
            siterank = Number(stats[0].innerHTML)
            userpoints = Number(stats[2].innerHTML)
          }
          var userCol = $(stats[1]).children()[0]
          var uName = userCol.innerHTML
          var uid = userCol.href.substring(45)
          user = new TeamMember(uName, uid, { 'user': Number(stats[4].innerHTML), "root": Number(stats[3].innerHTML) }, siterank, userpoints)

          if (!(uid in Object.keys(TEAM_MEMBERS_IGNORED))) {
            //console.log("got here")
            teamUsers[uid] = user
          }
          // console.log(user)
          //console.log('username: ' + uName + ' uid: ' + uid + ' uOwns: ' + uOwns)
        })
      } catch (error) {
        console.error(error)
      }
      console.log('SUCCESS: Got team members... ' + Object.keys(teamUsers).length)
      resolve(teamUsers)
    })
  })
}

function getSession() {
  return csrfLogin({
    username: process.env.HTB_EMAIL,
    password: process.env.HTB_PASS
  })
}

function getUserProfile(id, session) {
  //console.log('getting user profile #' + id)
  return session.requestAsync('/home/users/profile/' + id)
}

function getChallengesCategory(category, session) {
  //console.log('getting user profile #' + id)
  return session.requestAsync('/home/challenges/' + category)
}

async function getOwnageData(session, mbrs) {
  var members = Object.keys(mbrs)
  console.log("Collecting ownage data for " + members.length + " team members...")
  return new Promise(async resolve => {
    for (var i = 0; i < members.length; i++) {
      //for (var i = 0; i < 5; i++) {
      //console.log("#" + i.toString().padStart(2, '0') + ": Parsing owns for member " + members[i])
      response = await getUserProfile(members[i], session)
      await parseUserOwns(response.body, members[i])
    }
    console.log('FINISHED PARSING OWNS...')
    resolve()
  })
}

function uidToUname(uid) {
  // console.log("uidToUname(uid):", uid)
  try {
    if (uid in TEAM_MEMBERS) {
      return TEAM_MEMBERS[uid].name
    } else return null
  } catch (error) {
    console.error(error)
    return null
  }
}

function unameToUid(username) {
  id = false
  //console.log(Object.values(TEAM_MEMBERS))
  Object.values(TEAM_MEMBERS).forEach(member => {
    if (member.name.toLowerCase() == username.toLowerCase()) {
      id = member.id
    }
  });
  return id
}

async function getUnreleasedMachine(session) {
  return new Promise(async resolve => {
    //for (var i = 0; i < members.length; i++) {
    response = await session.requestAsync('/home/machines/unreleased')
    var $ = require('jquery')(new JSDOM(response.body).window);
    if ($('.table tr').length > 1) {
      var trs = $($('.table tr')[1])
      var mids = trs.find("a[href^='https://www.hackthebox.com/home/machines/profile/']")
      var urmid = mids[0].href.substring(48)
      var oldmid = mids[1].href.substring(48)
      var name = mids[0].innerHTML
      var rname = mids[1].innerHTML
      var thumb = trs.find("img[src^='https://www.hackthebox.com/storage/avatars']")[0].src
      var releaseDate = parseSingleDate($($('.table tr')[1]).find(":contains('UTC')")[0].innerHTML)
      var makers = trs.find("a[href^='https://www.hackthebox.com/home/users/profile/']")
      var maker = makers[0].innerHTML
      var makerId = makers[0].href.substring(45)
      var maker2 = null
      try {
        maker2 = { "id": makers[1].innerHTML, "name": makers[1].href.substring(45) }
      } catch (error) {
        console.log('\nNo 2nd maker ...')
      }
      response = await session.requestAsync('/home/machines/profile/' + urmid)
      var $ = require('jquery')(new JSDOM(response.body).window);
      var ip = $('td')[9].innerHTML
      var points = Number($('td span')[1].innerHTML)
      var difficulty = $('td span')[0].innerHTML
      var os = $('td')[1].innerHTML.substring($('td')[1].innerHTML.lastIndexOf('>') + 1).replace(' ', '')
      var unreleasedBox = new HtbMachine(name,
        urmid,
        thumb,
        false,
        { "id": makerId, "name": maker },
        maker2,
        os,
        ip,
        0,
        releaseDate,
        null,
        points,
        { "replaces": rname, "mid": oldmid }
      )
      dFlowEnt.updateEntity('Machines', unreleasedBox.name)
      resolve(unreleasedBox)
    } else {
      resolve(null)
    }
  })
}

function grabCsrfFromJar(session) {
  try {
    return session.jar._jar.store.idx['www.hackthebox.com']['/']['csrftoken'].value
  } catch (error) {
    console.error(error)
    return ""
  }
}

async function updateData(client) {
  if (!UPDATE_LOCK) {
    UPDATE_LOCK = true
    console.log("Update lock engaged. Beginning update attempt.")
    return new Promise(async resolve => {
      try {
        await updateDiscordIds(client, "655499722454335488")
        var SESH = await getSession()
        CSRF_TOKEN = grabCsrfFromJar(SESH)
        console.log("Got CSRF TOKEN:", CSRF_TOKEN)
        HTB_PUSHER_OWNS_SUBSCRIPTION.auth = CSRF_TOKEN
        console.log("Got a logged in session.")
        console.log("BEGINNING DATA COLLECTION [HTML Parse]")
        MACHINES_BUFFER = await getMachines()
        CHALLENGES = await getChallenges(SESH)
        TEAM_MEMBERS_TEMP = await getTeamData(SESH)
        if (Object.keys(TEAM_MEMBERS_TEMP).length > Object.keys(TEAM_MEMBERS).length) {
          TEAM_MEMBERS = TEAM_MEMBERS_TEMP
        }
        urmachine = await getUnreleasedMachine(SESH)
        console.warn(urmachine ? "INFO: Got unreleased machine " + urmachine.name + "..." : "INFO: There are currently no machines in unreleased section.")
        if (urmachine) {
          MACHINES[urmachine.id.toString()] = urmachine
          MACHINES_BUFFER[urmachine.id.toString()] = urmachine
        }
        await getOwnageData(SESH, TEAM_MEMBERS)
        MACHINES = MACHINES_BUFFER
        await cleanTargetSets()
        console.log("UPDATED DATA. Total machines : " + Object.values(MACHINES).length)
        console.log("               Total members : " + Object.values(TEAM_MEMBERS).length)
        updateCacheSuccessful = await updateCache()
        console.log(updateCacheSuccessful ? "All data backed up to the cloud for a rainy day..." : "Export failed...")
        /* TO HANDLE EXPORTS WITHOUT DB (USING LOCAL JSON FILES ( useful for dev )):::
        |  exportData(MACHINES, "machines.json")
        |  exportData(CHALLENGES, "challenges.json")
        |  exportData(TEAM_MEMBERS, "team_members.json");
        |  exportData(TEAM_MEMBERS_IGNORED, "team_members_ignored.json")
        |  exportData(DISCORD_LINKS, "discord_links.json")
        \  exportData(TEAM_STATS, "team_stats.json")  */
        LAST_UPDATE = new Date()
        UPDATE_LOCK = false
        console.log("Update lock released.")
        resolve()
      } catch (error) {
        console.error("UPDATE FAILED.")
        console.error(error, "\nUPDATE LOCK HAS BEEN RESET AS A PRECAUTION.")
        UPDATE_LOCK = false
        resolve()
      }
    })
  } else {
    console.warn("WARN: DATA UPDATE NOT STARTED, AS ONE IS ALREADY IN PROGRESS.")
  }
}


async function parseUserOwns(body, id) {
  return new Promise(resolve => {
    //console.log("Parsing owns for uid: " + uid)
    try {
      var $ = require('jquery')(new JSDOM(body).window);
      var thumb = $($('.header-icon').find('.image-lg')[0]).attr('data-cfsrc') || "https://www.hackthebox.com/images/favicon.png"
      rank = $($('.header-title')[0]).find('.c-white').text() || "Noob"
      joinDate = parseSingleDate($('div[title^="Joined on"]').attr("title").substring(10)) || 0
      countryName = $('.flag')[0].parentNode.attributes['title'].value || "Pangea"
      countryCode = $('.flag').attr("class").split(/\s+/)[1].split('-')[1].toUpperCase() || ""
      //console.log($('div[title^="Joined on"]').attr("title").substring(10))
      //console.log(joinDate)
      try {
        TEAM_MEMBERS[id].thumb = thumb
        TEAM_MEMBERS[id].countryName = countryName
        TEAM_MEMBERS[id].countryCode = countryCode
        TEAM_MEMBERS[id].rank = rank
        TEAM_MEMBERS[id].joinDate = joinDate
        // console.log(TEAM_MEMBERS[id])
      } catch (error) {
        console.error(error)
      }
      try {
        charts = $('script:contains("var globalOptions = {")').html()
        var chartData = safeEval(charts.substring(charts.indexOf("var lineData = ") + 15, charts.indexOf(";", charts.indexOf("var lineData = ")))).datasets
        var chart = { users: 0, roots: 0, challenges: 0, respects: 0, bloods: 0 }
        chart.users = realMax(chartData[0].data.slice(-1)[0], 0)      // user owns
        chart.roots = realMax(chartData[1].data.slice(-1)[0], 0)      // root owns
        chart.challenges = realMax(chartData[2].data.slice(-1)[0], 0) // challenge owns
        chart.respects = realMax(chartData[3].data.slice(-1)[0], 0)   // respects
        chart.bloods = realMax(chartData[4].data.slice(-1)[0], 0)     // bloods
        if (chart.users || chart.roots || chart.challenges || chart.respects || chart.bloods) {
          TEAM_MEMBERS[id].stats = chart
        } else {
          TEAM_MEMBERS[id].stats = { users: 0, roots: 0, challenges: 0, respects: 0, bloods: 0 }
        }
        console.log("Parsing owns for " + TEAM_MEMBERS[id].name + "...")
      } catch (error) {
        // There was a problem getting stats for the user based on profile timechart
        console.log(error)
      }
      var up2DateStats = { user: 0, root: 0, challenge: 0 } // Let's actually count these ourselves when parsing.
      $('.p-xs').each(function () { // Go through the user profile ownage rows (on right of page)
        var t = $($(this).children()[1]).text().trim().split(/\s+/);
        var parsedTime = $($(this).children('span.pull-right')[0]).attr('title')
        var timestamp = parseDate(parsedTime, 'MMMM do, yyyy h:mm a', new Date()).getTime() / 1000
        var machineId = ''
        var machineLitmus = $(this).find('[href*="/machines/"]')
        if (machineLitmus.length > 0) { machineId = machineLitmus[0].href.substring(48) }
        if (t[2].includes('user')) { // It is a machine user own
          up2DateStats.user++
          MACHINES_BUFFER[machineId].userOwners.push({ "uid": id, "timestamp": timestamp })
        } else if (t[2].includes('root')) { // It is a machine root own
          up2DateStats.root++
          MACHINES_BUFFER[machineId].rootOwners.push({ "uid": id, "timestamp": timestamp })
        } else if (t[2].includes('challenge')) {
          if ($(this).find('i.far.fa-cog.text-warning').length) {// It is a valid challenge
            up2DateStats.challenge++
            var challengeName = $(this).find('i.far.fa-cog.text-warning')[0].nextSibling.data.trim()
            getChallengeByName(challengeName).owners.push({ "uid": id, "timestamp": timestamp })
          } else {
            // It is not a challenge
          }
        }
        else { /*console.log('Something unparsable (e.g. special challenge, fortress OR a site update has screwed our parsing (PLS GIV US API, PLSSS HTB)...')*/ }
      })
      try {
        TEAM_MEMBERS[id].totalOwns = up2DateStats
      } catch (error) {
        console.error(error)
      }
    } catch (error) {
      //console.log(error + " - Could not parse page. (Likely user has no rank / XP)")
    }
    resolve('Done..')
  })
}

function cleanTargetSets() {
  return new Promise(resolve => {
    Object.values(MACHINES).forEach(machine => {
      machine.userOwners = [... new Set(machine.userOwners)]
      machine.rootOwners = [... new Set(machine.rootOwners)]
      machine.userOwners.sort((a, b) => (a.timestamp < b.timestamp) ? 1 : (a.timestamp === b.timestamp) ? ((a.uid < b.uid) ? 1 : -1) : -1)
      machine.rootOwners.sort((a, b) => (a.timestamp < b.timestamp) ? 1 : (a.timestamp === b.timestamp) ? ((a.uid < b.uid) ? 1 : -1) : -1)
    });

    Object.values(CHALLENGES).forEach(category => {
      category.forEach(challenge => {
        challenge.owners = [...new Map(challenge.owners.map(item => [item.uid, item])).values()]
        challenge.owners.sort((a, b) => (a.timestamp < b.timestamp) ? 1 : (a.timestamp === b.timestamp) ? ((a.uid < b.uid) ? 1 : -1) : -1)
      })
    });
    resolve('done')
  })
}


function between(str, oTag, cTag) {
  if (oTag == 'XXX') {
    var first = 0
  } else {
    var first = str.indexOf(oTag) + 1
  }
  return str.substring(
    first,
    str.substring(first).indexOf(cTag)
  );
}

function sendFileMsg() {
  try {
    contents = JSON.parse(fs.readFileSync('config/sendable.json', 'utf8'));
    client.channels.cache.get(contents.channel.toString()).send(contents.message)
  } catch (error) {
    console.error(error)
  }
}

async function setStatus(client, statusType, activityVerb, activityName) {
  await client.user.setPresence({ activity: { name: activityName, type: activityVerb }, status: statusType })
    .then(console.log)
    .catch(console.error);
  await client.user.setStatus(statusType)
    .then(console.log)
    .catch(console.error);
}

async function updateDiscordIds(client, guildIdString) {
  keys = Object.keys(DISCORD_LINKS)
  guild = await client.guilds.resolve(guildIdString)

  for (let i = 0; i < keys.length; i++) {
    var link = DISCORD_LINKS[keys[i]];
    guildMember = await guild.members.resolve(link.id);
    member = guildMember.user
    // console.log(DISCORD_LINKS[keys[i]], member)
    DISCORD_LINKS[keys[i]] = member || DISCORD_LINKS[i]
  }
  updateCache(['DISCORD_LINKS'])
}

async function main() {
  await importDbBackup()
  client.login(process.env.BOT_TOKEN)               // BOT_TOKEN is the Discord client secret
  client.on('ready', async () => {
    var SESH = await getSession()
    CSRF_TOKEN = grabCsrfFromJar(SESH)
    HTB_PUSHER_OWNS_SUBSCRIPTION = new HtbPusherSubscription('97608bf7532e6f0fe898', 'owns-channel', 'display-info', CSRF_TOKEN)
    HTB_PUSHER_OWNS_SUBSCRIPTION.on("pusherevent", message => {
      if (uidToUname(message.uid)){
        console.log("PUSHER: " + message.debug)
        addOwn(message.uid, message.time, message.type, message.target, true)
      }
    })
    DISCORD_ANNOUNCE_CHAN = await client.users.cache.get("679986418466029568").createDM()
    // console.log(DISCORD_ANNOUNCE_CHAN)
    console.log("DISCORD LINKS:", Object.values(DISCORD_LINKS).map(link => link.username).join(", "))
    updateData(client)
    setInterval(() => updateData(client), 30 * 60 * 1000);   // UPDATE OWNAGE DATA BY PARSING, EVERY 30 MINUTES
    console.log("Updated Discord User Objects...")
    console.warn('INFO: Discord connection established...')
    client.on('message', message => {
      if (!DEV_MODE_ON) {
        try {
          handleMessage(message)
        } catch (error) {
          console.log(error)
          message.channel.stopTyping()
        }
      } else if (isAdmin(message.author)) {
        console.log("Message is from dev admin, responding...")
        if (message.content.includes("📤")) {
          console.log("Sending file msg...")
          sendFileMsg()
        } else {
          try {
            handleMessage(message)
          } catch (error) {
            console.log(error)
            message.channel.stopTyping()
          }
        }
      } else if (!message.author.bot) {
        console.log("Dev mode enabled but received message not from admin. Not responding from this instance...")
      }
    })
  })

}
main()






function sendBoxOwnersMsg(message, machineName) {
  if (!machineName) {
    machineName == ""
  } else { console.log("machinename: " + machineName) }
  twentyPlus = false
  console.log("Constructing a box info message for " + machineName + "...")
  ownerList = getMdLinksForOwnList(getOwnersByMachineId(getMachineIdFromName(machineName)))
  if (ownerList) {
    if (ownerList.length > 20) {
      ownerList = ownerList.slice(0, 20)
      twentyPlus = true
    }
    if (ownerList.length == 1) {
      message.channel.send({
        embed: {
          color: 3447003,
          author: {
            name: getMachineByName(machineName).name,
            icon_url: getMachineByName(machineName).thumb,
            url: 'https://www.hackthebox.com/home/machines/profile/' + getMachineByName(machineName).id,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Owned by** ' + andifyList(ownerList.join(', ')) + (twentyPlus ? ' […]' : '') + ' :woman_detective:').substring(0, 2040)
        }
      }
      )
    } else {
      message.channel.send({
        embed: {
          color: 3447003,
          author: {
            name: getMachineByName(machineName).name,
            icon_url: getMachineByName(machineName).thumb,
            url: 'https://www.hackthebox.com/home/machines/profile/' + getMachineByName(machineName).id,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Most recent team owns:** ' + andifyList(ownerList.join(', ')) + (twentyPlus ? ' […]' : '') + ' :woman_detective:').substring(0, 2040)
        }
      })
    }
  } else {
    message.channel.send((getMachineByName(machineName) ? 'Looks like nobody in the team has done this box yet :tired_face:' : 'That isn\'t even a box!! Trying to trick me … silly Hu-man. :wheelchair:\nAccording to Wikipedia, u can\'t fool me... :woman_mage: '))
  }
}


function sendUserOwnersMsg(message, machineName) {
  if (!machineName) {
    machineName == ""
  } else { console.log("machinename: " + machineName) }
  twentyPlus = false
  console.log("Constructing a user owner info message for " + machineName + "...")
  ownerList = getMdLinksForOwnList(getOwnersByMachineId(getMachineIdFromName(machineName)))
  if (ownerList) {
    if (ownerList.length > 20) {
      ownerList = ownerList.slice(0, 20)
      twentyPlus = true
    }
    if (ownerList.length == 1) {
      message.channel.send({
        embed: {
          color: 3447003,
          author: {
            name: getMachineByName(machineName).name,
            icon_url: getMachineByName(machineName).thumb,
            url: 'https://www.hackthebox.com/home/machines/profile/' + getMachineByName(machineName).id,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Owned by** ' + andifyList(ownerList.join(', ')) + (twentyPlus ? ' […]' : '') + ' :woman_detective:').substring(0, 2040)
        }
      }
      )
    } else {
      message.channel.send({
        embed: {
          color: 3447003,
          author: {
            name: getMachineByName(machineName).name,
            icon_url: getMachineByName(machineName).thumb,
            url: 'https://www.hackthebox.com/home/machines/profile/' + getMachineByName(machineName).id,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Most recent team owns:** ' + andifyList(ownerList.join(', ')) + (twentyPlus ? ' […]' : '') + ' :woman_detective:').substring(0, 2040)
        }
      })
    }
  } else {
    message.channel.send((getMachineByName(machineName) ? 'Looks like nobody in the team has done this box yet :tired_face:' : 'That isn\'t even a box!! Trying to trick me … silly Hu-man. :wheelchair:\nAccording to Wikipedia, u can\'t fool me... :woman_mage: '))
  }
}

function sendLastBoxOwnerMsg(message, machineName) {
  if (!machineName) {
    machineName == ""
  } else { console.log("machinename: " + machineName) }
  console.log(machineName)
  ownerList = getMdLinksForOwnList(getOwnersByMachineId(getMachineIdFromName(machineName)))
  if (ownerList) {
    // console.log(ownerList)
    lastOwner = ownerList[0]
    // console.log(lastOwner)
    message.channel.send({
      embed: {
        color: 16580705,
        author: {
          name: getMachineByName(machineName).name,
          icon_url: getMachineByName(machineName).thumb,
          url: 'https://www.hackthebox.com/home/machines/profile/' + getMachineByName(machineName).id,
        },
        footer: {
          text: "ℹ️  Data last updated " + timeSince(LAST_UPDATE)
        },
        description: ('**Last owned by: ** ' + lastOwner + ' :woman_detective:').substring(0, 2040)
      }
    }
    )
  } else {
    message.channel.send((getMachineByName(machineName) ? 'Looks like nobody in the team has done this box yet :tired_face:' : 'Not a box... hmm.'))
  }
}


function sendChallengeOwnersMsg(message, challengeName) {
  if (!challengeName) {
    challengeName == ""
  } else { console.log("Challenge name: " + challengeName) }
  var twentyPlus = false
  var challenge = getChallengeByName(challengeName)
  console.log("Constructing a challenge owner info message for " + challengeName + "...")
  var ownerList = getMdLinksForOwnList(getOwnersByChallengeName(challengeName))
  if (ownerList) {
    if (ownerList.length > 20) {
      ownerList = ownerList.slice(0, 20)
      twentyPlus = true
    }
    if (ownerList.length == 1) {
      message.channel.send({
        embed: {
          color: 3447003,
          author: {
            name: challenge.name,
            icon_url: "https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/white/png/24/cogs.png",
            url: 'https://www.hackthebox.com/home/challenges/' + challenge.category,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Owned by** ' + andifyList(ownerList.join(', ')) + (twentyPlus ? ' […]' : '') + ' :woman_detective:').substring(0, 2040)
        }
      }
      )
    } else {
      message.channel.send({
        embed: {
          color: 3447003,
          author: {
            name: challenge.name,
            icon_url: "https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/white/png/24/cogs.png",
            url: 'https://www.hackthebox.com/home/challenges/' + challenge.category,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Most recent team owns:** ' + andifyList(ownerList.join(', ')) + (twentyPlus ? ' […]' : '') + ' :woman_detective:').substring(0, 2040)
        }
      })
    }
  } else {
    message.channel.send((challenge ? 'Looks like nobody in the team has done this challenge yet :tired_face:' : 'That challenge doesn\'t even exist! Trying to trick me … silly Hu-man. :wheelchair:\nAccording to Wikipedia, u can\'t fool me... :woman_mage: '))
  }
}

function sendLastChallengeOwnerMsg(message, challengeName) {
  if (!challengeName) {
    challengeName == ""
  } else { console.log("Challenge name: " + challengeName) }
  var challenge = getChallengeByName(challengeName)
  var ownerList = getMdLinksForOwnList(getOwnersByChallengeName(challengeName))
  if (ownerList) {
    console.log(ownerList)
    lastOwner = ownerList[0]
    console.log(lastOwner)
    message.channel.send({
      embed: {
        color: 16580705,
        author: {
          name: challenge.name,
          icon_url: "https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/white/png/24/cogs.png",
          url: 'https://www.hackthebox.com/home/challenges/' + challenge.category,
        },
        footer: {
          text: "ℹ️  Challenge data last updated " + timeSince(LAST_UPDATE)
        },
        description: ('**Most recently completed by: ** ' + lastOwner + ' 🧙').substring(0, 2040)
      }
    }
    )
  } else {
    message.channel.send((challenge.name ? 'Looks like nobody in the team has done this challenge yet :tired_face:' : 'Not a challenge... hmm.'))
  }
}



async function sendTeamRankingMsg(message, note) {

  message.channel.send({
    embed: {
      title: "🌍⠀Team Global Rank",
      color: 3447003,
      author: {
        icon_url: TEAM_STATS.thumb,
        url: 'https://www.hackthebox.com/home/teams/profile/2102',
      },
      footer: {
        text: "ℹ️  Accurate as of " + timeSince(LAST_UPDATE)
      },
      description: "Global Rank: **[# " + TEAM_STATS.globalRanking + "](http://0)**"
    }
  })

  if (Math.random() > 0.5) {
    await humanSend(message, note, true)
    console.log("NOTE: " + note)
  }

}


function sp(size, arr) { //size - child_array.length
  var out = [], i = 0, n = Math.ceil((arr.length) / size);
  while (i < n) { out.push(arr.splice(0, (i == n - 1) && size < arr.length ? arr.length : size)); i++; }
  return out;
}

async function sendFlagboardMsg(message) {
  // var flags = []
  flags = Object.values(TEAM_MEMBERS).map(member => getFlag(member.countryCode));
  flagsSorted = flags.slice().sort();
  var flagString = ""
  var sortedFlagString = ""
  // TEAM_STATS.topMembers.forEach(memberId => {
  //   member = TEAM_MEMBERS[memberId]
  //   var flagLink = "["+getFlag(member.countryCode)+"](http://" + member.id + ")"
  //   flags.push(flagLink)
  // });
  sortSpliced = sp(9, flagsSorted)
  spliced = sp(9, flags)
  spliced.forEach(flagRow => {
    flagString += "\n" + flagRow.join(" ")
  });
  sortSpliced.forEach(flagRow => {
    sortedFlagString += "\n" + flagRow.join(" ")
  });

  await message.channel.send({
    embed: {
      color: "LUMINOUS_VIVID_PINK",
      author: {
        name: any("🗺️", "🌎", "🌏", "🌍", "🌐", "🚩", "⛳", "🛂", "🛫", "✈️", "🛩️") + " Team Locales",
        url: 'https://www.hackthebox.com/home/teams/profile/2102',
      },
      thumbnail: {
        url: TEAM_STATS.thumb,
      },
      footer: {
        text: "ℹ️  Accurate as of " + timeSince(LAST_UPDATE)
      },
      description: any(sortedFlagString, flagString)
    }
  })
  if (maybe(0.20)) await humanSend(message, any("Globalization is a form of artificial intelligence. 🍉", "Teamwork makes the dream work 👑"), true)
}

async function sendTeamLeaderMsg(message, note) {
  member = TEAM_MEMBERS[TEAM_STATS.topMembers[0]]
  await message.channel.send({
    embed: {
      title: tryDiscordifyUid(member.id),
      color: "GREEN",
      author: {
        name: any("💯", "🏆", "🎖️", "🔮", "💠", "💎", "👑") + " Team Leader",
        icon_url: TEAM_STATS.thumb,
        url: 'https://www.hackthebox.com/home/teams/profile/2102',
      },
      thumbnail: {
        url: member.thumb,
      },
      footer: {
        text: "ℹ️  Accurate as of " + timeSince(LAST_UPDATE)
      },
      description: "Global Rank: **[# " + member.siterank + "](http://0)**\nTeam Rank:  **[# " + getMemberTeamRankById(member.id) + "](http://0)**"
    }
  })
  if (maybe(0.6)) await humanSend(message, "Let's give a round of applause!", true)
  if (maybe(0.4)) await humanSend(message, "Pain is the heart of success. No one knows that like " + member.name + "! 🎉", true)
}

async function sendMemberRankMsg(message, username) {
  if (!username) {
    username == ""
  } else { console.log("challengeName: " + username) }
  var member = tryGetValidMember(username, message.author.username)

  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }

  if (member) {
    teamRank = getMemberTeamRankById(member.id)
    if (teamRank == 1) {
      sendTeamLeaderMsg(message)
      return
    }
    message.channel.send({
      embed: {
        title: tryDiscordifyUid(member.id),
        color: 3447003,
        author: {
          name: "Member Rank",
          icon_url: TEAM_STATS.thumb,
          url: 'https://www.hackthebox.com/home/teams/profile/2102',
        },
        thumbnail: {
          url: member.thumb,
        },
        footer: {
          text: "ℹ️  Accurate as of " + timeSince(LAST_UPDATE)
        },
        description: "Global Rank: **[# " + member.siterank + "](http://0)**\nTeam Rank:  **[# " + teamRank + "](http://0)**"
      }
    })
  } else {
    message.channel.send({
      embed: {
        color: 3447003,
        author: {
          name: "Unrecognized User",
          icon_url: 'https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/white/png/32/question.png',
          url: ""
        },
        description: "Member not found."
      }
    })
  }
}

async function sendCheckMemberOwnedChallengeMsg(message, challengename, username) {
  var challenge = getChallengeByName(challengename)
  var member = tryGetValidMember(username, message.author.username)

  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }

  console.log(challenge, member)
  if (member && challenge) {
    var own = (challenge.owners.find(own => own.uid == member.id))
    if (own) {
      await message.channel.send({
        embed: {
          color: "AQUA",
          author: {
            name: challenge.name,
            icon_url: "https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/white/png/24/cogs.png",
            url: 'https://www.hackthebox.com/home/challenges/' + challenge.category,
          },
          thumbnail: {
            url: member.thumb,
          },
          footer: {
            text: "ℹ️  Accurate as of " + timeSince(new Date(Math.max(LAST_UPDATE.getTime(),own.timestamp)))
          },
          description: "🥳 W00t! " + (checkSelfName(username) ? " **[You](https://www.hackthebox.com/home/users/profile/" + member.id + ")**" : getMdLinksForUids([member.id])) + " completed challenge **[" + challenge.name + "](https://www.hackthebox.com/home/challenges/" + challenge.category + ") " + timeSince(new Date(own.timestamp * 1000)) + "**."
        }
      })
    } else {
      await message.channel.send({
        embed: {
          color: "GOLD",
          author: {
            name: challenge.name,
            icon_url: "https://raw.githubusercontent.com/encharm/Font-Awesome-SVG-PNG/master/white/png/24/cogs.png",
            url: 'https://www.hackthebox.com/home/challenges/' + challenge.category,
          },
          thumbnail: {
            url: member.thumb,
          },
          footer: {
            text: "ℹ️  Accurate as of " + timeSince(new Date(Math.max(LAST_UPDATE.getTime(),own.timestamp)))
          },
          description: "Looks like " + getMdLinksForUids([member.id]) + " hasn't owned challenge **[" + challenge.name + "](https://www.hackthebox.com/home/challenges/" + challenge.category + ")** yet. 🐳"
        }
      })
    }
  } else {
    await humanSend(message, "Sorry, username and / or challenge name was invalid. 🍔", true)
  }
}

function tryGetValidMember(username, discordName) {
  var member = getMemberByName(username)
  if (!member && checkSelfName(username)) {
    member = getMemberByName(discordName)
  }
  return (member ? member : null)
}

async function sendCheckMemberOwnedBoxMsg(message, boxname, username) {
  var machine = getMachineByName(boxname)
  var member = tryGetValidMember(username, message.author.username)

  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }

  if (member && machine) {
    var own = {}
    if (machine.rootOwners.some(own => own.uid == member.id)) {
      own = machine.rootOwners.find(own => own.uid == member.id)
    }
    else if (machine.userOwners.some(own => own.uid == member.id)) {
      own = machine.userOwners.find(own => own.uid == member.id)
      own['userOnly'] = true
    } else {
      own['userOnly'] = false
    }
    if ("uid" in own) {
      await message.channel.send({
        embed: {
          color: "AQUA",
          author: {
            name: machine.name,
            icon_url: machine.thumb,
            url: 'https://www.hackthebox.com/home/machines/profile/' + machine.id,
          },
          thumbnail: {
            url: member.thumb,
          },
          footer: {
            text: "ℹ️  Accurate as of " + timeSince(new Date(Math.max(LAST_UPDATE.getTime(),own.timestamp)))
          },
          description: (own.userOnly == true ? "🥳 Looks like " : "👑 W00t! ") + (checkSelfName(username) ? " **[You](https://www.hackthebox.com/home/users/profile/" + member.id + ")**" : getMdLinksForUids([member.id])) + (own.userOnly == true ? " got " : " owned ") + (own.userOnly == true ? "user" : "root") + " on " + getMdLinksForBoxIds([machine.id]) + (own.userOnly == true ? " **" : " ") + timeSince(new Date(own.timestamp * 1000)) + (own.userOnly == true ? "**." : ".")
        }
      })
    } else {
      await message.channel.send({
        embed: {
          color: "GOLD",
          author: {
            name: machine.name,
            icon_url: machine.thumb,
            url: 'https://www.hackthebox.com/home/machines/profile/' + machine.id,
          },
          thumbnail: {
            url: member.thumb,
          },
          footer: {
            text: "ℹ️  Accurate as of " + timeSince(new Date(Math.max(LAST_UPDATE.getTime(),own.timestamp)))
          },
          description: "Looks like " + getMdLinksForUids([member.id]) + " hasn't got user or root on " + getMdLinksForBoxIds([machine.id]) + " yet. 🍟"
        }
      })
    }
  } else {
    await humanSend(message, "Sorry, username and / or machine name was invalid. 🍔", true)
  }
}

async function sendTeamLeadersMsg(message, note) {
  leaderList = mdItemizeList(getMdLinksForUids(TEAM_STATS.topMembers).slice(0, 10))

  if (note & Math.random() > 0.5) {
    console.log("NOTE: " + note)
    await humanSend(message, note, true)
  }

  message.channel.send({
    embed: {
      title: ":bar_chart:⠀Team Leaderboard",
      color: "AQUA",
      author: {
        icon_url: 'https://www.hackthebox.com/storage/teams/8232e119d8f59aa83050a741631803a6.jpg',
        url: 'https://www.hackthebox.com/home/teams/profile/2102',
      },
      footer: {
        text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
      },
      description: ("**" + leaderList.join("**\n**") + "**").substring(0, 2040)
    }
  })
}

async function sendTeamInfoMsg(message, note) {
  console.log(TEAM_STATS)
  leaderList = getMdLinksForUids(TEAM_STATS.topMembers)
  message.channel.send({
    embed: {
      color: 15844367,
      title: TEAM_STATS.name,
      author: {
        name: "Group by " + TEAM_MEMBERS[TEAM_STATS.teamFounder].name,
        icon_url: TEAM_MEMBERS[TEAM_STATS.teamFounder].thumb,
        url: 'https://www.hackthebox.com/home/users/profile/7383',
      },
      "footer": {
        //"icon_url": "https://cdn.discordapp.com/embed/avatars/0.png",
        "text": "ℹ️  Team info last updated " + timeSince(LAST_UPDATE)
      },
      thumbnail: {
        url: TEAM_STATS.thumb,
      },
      description: (":flag_nl:⠀Hacking for the fun of it! Learning it as a bonus").substring(0, 2040),
      image: {
        url: genBadgeUrl(),
      },
      fields: [
        {
          name: 'R4NK1ng', inline: true,
          value: "Global: **[# " + TEAM_STATS.globalRanking + "](http://0)**"
        },
        {
          name: 'P01NTz', inline: true,
          value: "**[" + TEAM_STATS.points + "](http://0)**"
        },
        {
          name: '0Wn4g3', inline: true,
          value: "Roots:** [" + TEAM_STATS.owns.roots + "](http://0)\n**Users:** [" + TEAM_STATS.owns.users + "](http://0)**"
        },
        {
          name: 'L34d3Rbo4rd', inline: true,
          value: "**" + leaderList.slice(0, 5).join("**\n**") + "**"
        },
        {
          name: '...', inline: true,
          value: "**" + leaderList.slice(5, 10).join("**\n**") + "**"
        },
        {
          name: '...', inline: true,
          value: "**" + leaderList.slice(10, 15).join("**\n**") + "**"
        },
      ]
    }
  })

  if (note & Math.random() > 0.5) {
    console.log("NOTE: " + note)
    await humanSend(message, note, true)
  }

}



function sendBoxInfoMsg(message, machineName) {
  if (!machineName) {
    machineName == ""
  } else { console.log("machinename: " + machineName) }
  box = getMachineByName(machineName)
  if (box) {
    message.channel.send({
      embed: {
        color: 1146986,
        author: {
          name: getMachineByName(machineName).name,
          icon_url: getOsImage(box.os),
          url: 'https://www.hackthebox.com/home/machines/profile/' + getMachineByName(machineName).id,
        },
        description: ("AEIOU".includes(box.os.charAt(0)) ? 'An ' : 'A ') +
          box.os + ' box by **['
          + box.maker.name
          + '](https://www.hackthebox.com/home/users/profile/'
          + box.maker.id.toString()
          + ')'
          + (box.maker2 ? '** & **[' + box.maker2.name + '](https://www.hackthebox.com/home/users/profile/' + box.maker2.id.toString() + ')' : '')
          + '**.\nIP Address: **' + (box.ip ? '[' + box.ip + '](http://' + box.ip + '/)**' : "Unknown**"),
        thumbnail: {
          url: getMachineByName(machineName).thumb.replace('_thumb', ''),
        },
        footer: {
          text: "ℹ️  Machines last updated " + timeSince(LAST_UPDATE)
        },
        fields: [
          {
            name: '` ' + FMT('  ' + difficultySymbol(box.difficulty) + ' ', 's') + FMT(box.difficulty, 'bs') + '     `', inline: false,
            value: '```diff\n'
              + FMT((box.retired ? '- Status   : ' : '  Status   : '), 's') + (box.retired ? '🧟 Retired' : (box.release > (new Date()).getTime() ? '🔥 𝗨𝗡𝗥𝗘𝗟𝗘𝗔𝗦𝗘𝗗' : '👾 Active')) + '\n'
              + '+' + FMT(' Points   : ', 's') + box.points + '\n'
              + '+' + FMT(' Released : ', 's') + new Date(box.release).toDateString() + '\n'
              + '+' + FMT(' Rating   : ', 's') + ratingString(box.rating) + '\n'
              + '+' + FMT(' Age     : ', 's') + elapsedDays(new Date(box.release)) + ' days\n'
              + (box.retired ? FMT('+ Retired  : ') + (new Date(box.retiredate).toDateString()) : '')
              + (box.unreleased ? FMT('- Replaces : ') + box.unreleased.replaces : '')
              + '\n```'
          },
        ]
      }
    }
    )
  } else {
    message.reply('ጠﻉ乇የ ᙏØɾየ')
  }
}

function sendMemberInfoMsg(message, username) {
  if (!username) {
    username == ""
  } else { console.log("memberName: " + username) }
  var member = tryGetValidMember(username, message.author.username)

  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }

  if (member) {
    message.channel.send({
      embed: {
        color: 3066993,
        author: {
          name: tryDiscordifyUid(member.id) + (checkSelfName(username) ? "  [You]" : ""),
          icon_url: TEAM_STATS.thumb,
          url: 'https://www.hackthebox.com/home/users/profile/' + member.id,
        },
        description: getFlag(member.countryCode) + "⠀**[" + member.rank + ".](http://0)** | HTB member since **" + formatRelative(new Date(member.joinDate), new Date()) + "**",
        thumbnail: {
          url: member.thumb,
        },
        footer: {
          text: "ℹ️  Achievements last updated " + timeSince(LAST_UPDATE)
        },
        fields: [
          {
            name: '` ' + FMT(rankSymbol(member.rank) + ' Site Rank : ', 's') + FMT((member.siterank == 99999999 ? "Unranked" : member.siterank + nth(member.siterank)), 'bs') + '     `', inline: false,
            value: '```diff\n'
              + '+ 🧡 𝖱𝖾𝗌𝗉𝖾𝖼𝗍    : ' + member.stats.respects + '\n'
              + '+ 👨‍💻 𝖱𝗈𝗈𝗍𝗌      : ' + member.totalOwns.root + '\n'
              + '  💻 𝖴𝗌𝖾𝗋𝗌      : ' + member.totalOwns.user + '\n'
              + '  ⚙️ 𝖢𝗁𝖺𝗅𝗅𝖾𝗇𝗀𝖾𝗌  : ' + member.totalOwns.challenge + '\n'
              + '- 🔴 𝟣𝗌𝗍 𝖡𝗅𝗈𝗈𝖽𝗌  : ' + member.stats.bloods + '\n'
              // + (challenge.unreleased ? FMT('- Replaces : ') + challenge.unreleased.replaces : '')
              + '\n```'
          },
        ]
      }
    }
    )
  } else {
    message.reply("Sorry, I don't know a '" + username + "'. 🤔")
  }
}

function sendChallengeInfoMsg(message, challengeName) {
  if (!challengeName) {
    challengeName == ""
  } else { console.log("challengeName: " + challengeName) }
  var challenge = getChallengeByName(challengeName)
  difficulty = challengeDifficulty(challenge)
  if (challenge) {
    message.channel.send({
      embed: {
        color: 3066993,
        author: {
          name: challenge.name,
          // icon_url: getChallengeImage(challenge.category),
          icon_url: "",
          url: 'https://www.hackthebox.com/home/challenges/' + challenge.category,
        },
        description: ("AEIOU".includes(challenge.category.toUpperCase().charAt(0)) ? 'An ' : 'A ') +
          challenge.category.toLowerCase() + ' challenge by **['
          + challenge.maker.name
          + '](https://www.hackthebox.com/home/users/profile/'
          + challenge.maker.id.toString()
          + ')'
          + (challenge.maker2 ? '** & **[' + challenge.maker2.name + '](https://www.hackthebox.com/home/users/profile/' + challenge.maker2.id.toString() + ')' : '')
          + '**.\n> _' + challenge.description + '_\n',
        thumbnail: {
          url: "",
        },
        footer: {
          text: "ℹ️  Challenges last updated " + timeSince(LAST_UPDATE)
        },
        fields: [
          {
            name: '` ' + FMT('  ' + difficultySymbol(difficulty) + ' ', 's') + FMT(difficulty, 'bs') + '     `', inline: false,
            value: '```diff\n'
              + FMT((!challenge.isActive ? '- Status   : ' : '  Status   : '), 's') + (!challenge.isActive ? '🧟 Retired' : (challenge.releaseDate > (new Date()).getTime() ? '🔥 𝗨𝗡𝗥𝗘𝗟𝗘𝗔𝗦𝗘𝗗' : '👾 Active')) + '\n'
              + '+' + FMT(' Points   : ', 's') + challenge.points + '\n'
              + '+' + FMT(' Solved   : ', 's') + challenge.solverCount + ' times.\n'
              + '+' + FMT(' Released : ', 's') + new Date(challenge.releaseDate).toDateString() + '\n'
              + '+' + FMT(' Rating   : ', 's') + '🍏: ' + challenge.upvotes + ' 🍎: ' + challenge.downvotes + '\n'
              + '+' + FMT(' Age     : ', 's') + elapsedDays(new Date(challenge.releaseDate)) + ' days\n'
              // + (challenge.unreleased ? FMT('- Replaces : ') + challenge.unreleased.replaces : '')
              + '\n```'
          },
        ]
      }
    }
    )
  } else {
    message.reply('ጠﻉ乇የ ᙏØɾየ')
  }
}

function challengeDifficulty(challenge) {
  var p = challenge.points
  if (p <= 25) {
    return "Easy"
  } else if (p <= 50) {
    return "Medium"
  } else if (p <= 75) {
    return "Hard"
  } else {
    return "Insane"
  }
}

function difficultySymbol(difficulty) {
  switch (difficulty) {
    case "Unknown": return "💩"
    case "Easy": return "📗";
    case "Medium": return "📘";
    case "Hard": return "📙";
    case "Insane": return "📕";
    default: return "📓"
  }
}

function challengeSymbol(category) {
  switch (category.toLowerCase()) {
    case "reversing": return "↩️"
    case "crypto": return "👩‍💻️";
    case "stego": return "🖼️";
    case "pwn": return "🧊";
    case "forensics": return "🔎";
    case "misc": return "🎲";
    case "mobile": return "☎️";
    case "osint": return "🌐";
    case "hardware": return "🧰";
    default: return "❓"
  }
}

function boxOsSymbol(category) {
  switch (category.toLowerCase()) {
    case "Linux": return '🐧'
    case "Windows": return any('🔷', '🔶', "💠")
    case "Solaris": return '☀️'
    case "FreeBSD": return '😈'
    case "Android": return '🍈'
    default: return "❓"
  }
}

function rankSymbol(rankText) {
  switch (rankText) {
    case "Noob": return "👽"
    case "Script Kiddie": return "🐍";
    case "Hacker": return "🤖";
    case "Pro Hacker": return "👩‍💻";
    case "Elite Hacker": return "👾";
    case "Guru": return "🔮";
    case "Omniscient": return "🧙";
    case "Admin": return "🤺";
    default: return "💯"
  }
}








/**
 * Send a query to the dialogflow agent, and return the query result.
 * @param {Object} message A Discord Message object.
 */
async function understand(message) {
  var sessionPath = dflow.sessionPath(process.env.GOOGLE_CLOUD_PROJECT, message.author.id);
  var sessionId = message.author.id
  console.log("Sending message to DialogFlow for comprehension. Session ID:", sessionPath)
  return new Promise(async resolve => {
    const request = {
      session: sessionPath,
      queryInput: {
        text: {
          // The query to send to the dialogflow agent
          text: message.content,
          // The language used by the client (en-US)
          languageCode: 'en',
        },
      },
    };

    const responses = await dflow.detectIntent(request);
    console.log('Detected intent');
    const result = responses[0].queryResult;
    //console.log(`  Full response: ${JSON.stringify(result)}`);
    console.log(`  Query: ${result.queryText}`);
    console.log(`  Response: ${result.fulfillmentText}`);

    if (result.intent) {
      console.log(`  Intent: ${result.intent.displayName}`);
    } else {
      console.log(`  No intent matched.`);
    }
    resolve(result)
  })
}

async function asyncForEach(array, callback) {
  for (let index = 0; index < array.length; index++) {
    await callback(array[index], index, array);
  }
}

var KEYSTROKE = 7
async function humanSend(message, msg, noMention) {
  return new Promise(async resolve => {
    if (!msg || msg.length == 0 || msg == undefined) {
      msg = " "
    }
    var msgLines = msg.split('\\n')
    if (noMention) {
      var firstline = false
    } else {
      var firstline = true
    }
    await asyncForEach(msgLines, async ln => {
      message.channel.startTyping()
      //console.log(ln)
      if (Math.random() < 0.1) { // random chance we'll try to generate a typo
        var typoData = typoify.typo(ln)
        if (typoData) {
          console.log(typoData)
          message.channel.startTyping()
          await wait(Math.min(ln.length * ((Math.random() * 50) + KEYSTROKE), 600))
          await message.reply(ln.replace(typoData[0], typoData[1]))
          message.channel.stopTyping()
          await wait(150)
          message.channel.startTyping()
          await wait(150)
          await wait(200 + (Math.min(typoData[0].length * KEYSTROKE), 600))
          if (firstline) {
            await message.reply((Math.random() > 0.5 ? "*" : "") + typoData[0]);
          } else {
            await message.channel.send((Math.random() > 0.5 ? "*" : "") + typoData[0]);
          }


        } else {
          message.channel.startTyping()
          await wait(Math.min(500 + (ln.length * KEYSTROKE), 800))
          if (firstline) {
            await message.reply(ln)
          } else {
            await message.channel.send(ln)
          }
          message.channel.stopTyping()
        }
      } else {
        await wait(Math.min(500 + (ln.length * ((Math.random() * 50) + KEYSTROKE)), 800))
        if (firstline) {
          await message.reply(ln)
        } else {
          await message.channel.send(ln)
        }
        firstline = false
      }
      message.channel.stopTyping()
      resolve()
    });
  })
  //console.log('finished sending message')
}

async function sendTotalBoxCountMsg(message, note) {
  msg = "There are currently " + MACHINE_STATS.totalBoxes + " machines in total. (__" +
    (MACHINE_STATS.unreleasedBoxes > 0 ? MACHINE_STATS.unreleasedBoxes + " unreleased, " : "") +
    MACHINE_STATS.activeBoxes + " active, " + MACHINE_STATS.retiredBoxes + " retired.__)"
  if (note) {
    console.log("NOTE: " + note)
    await humanSend(message, note, true)
  }
  await humanSend(message, msg)
}

async function sendHelpMsg(message, note) {
  if (note) {
    console.log("NOTE: " + note)
    await humanSend(message, note, true)
  }
  await wait(300)
  await message.channel.send(strings.manual)
}

async function sendIncompleteBoxesByMemberMsg(message, note, username) {
  boxIds = []
  var member = tryGetValidMember(username, message.author.username)
  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }
  if (member) {
    uid = member.id
    console.log("Constructing incomplete boxes by member message... [", "UID:", uid, "Username: ", username, "]")
    Object.values(MACHINES).sort((a, b) => (Number(a.id) < Number(b.id)) ? 1 : -1).forEach(machine => {
      var match = machine.rootOwners.find(user => user.uid === uid);
      if (!match) {
        boxIds.push(machine.id)
      }
    });
    console.log

    twentyPlus = false
    console.log("Constructing an incomplete box tally message for " + username + "...")
    var ownedBoxList = getMdLinksForBoxIds(boxIds)
    if (ownedBoxList) {
      if (ownedBoxList.length > 20) {
        ownedBoxList = ownedBoxList.slice(0, 20)
        twentyPlus = true
      }
      message.channel.send({
        embed: {
          color: 15105570,
          author: {
            name: (uid in TEAM_MEMBERS ? tryDiscordifyUid(member.id) + ": Incomplete machines" : '٩(͡๏̯͡๏)۶'),
            icon_url: member.thumb,
            url: 'https://www.hackthebox.com/home/users/profile/' + uid,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ((twentyPlus ? ownedBoxList.join(', ') : (ownedBoxList.length < 10 ? andifyList(ownedBoxList.join('\n')) : andifyList(ownedBoxList.join(', ')))) + (twentyPlus ? ' […]' : '')).substring(0, 2040)
        }
      }
      )
    } else {
      message.channel.send('Looks like ' + tryDiscordifyUid(member.id) + " has done them all!!! 👑\n(Is that even possible???)")
    }

  } else {
    message.channel.send("Sorry, I don't know a '" + username + "'. 🤔")
  }
}

async function sendOwnedBoxesByMemberMsg(message, note, username) {
  boxIds = []
  var member = tryGetValidMember(username, message.author.username)

  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }

  if (member) {
    var uid = member.id
    console.log("Constructing owned boxes by member message... [", "UID:", uid, "Username: ", username, "]")
    Object.values(MACHINES).sort((a, b) => (Number(a.id) < Number(b.id)) ? 1 : -1).forEach(machine => {
      var match = machine.rootOwners.find(user => user.uid === uid);
      if (match) {
        boxIds.push(machine.id)
      } else {
        // console.error("User w/ ID of '" + uid + "' not found.")
      }
    });
    console.log

    twentyPlus = false
    console.log("Constructing a box ownage tally message for " + username + "...")
    var ownedBoxList = getMdLinksForBoxIds(boxIds)
    if (ownedBoxList) {
      if (ownedBoxList.length > 20) {
        ownedBoxList = ownedBoxList.slice(0, 20)
        twentyPlus = true
      }
      message.channel.send({
        embed: {
          color: "AQUA",
          author: {
            name: (uid in TEAM_MEMBERS ? 'Root ownage for ' + tryDiscordifyUid(member.id) : '٩(͡๏̯͡๏)۶'),
            icon_url: member.thumb,
            url: 'https://www.hackthebox.com/home/users/profile/' + uid,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ((twentyPlus ? ownedBoxList.join(', ') : (ownedBoxList.length < 10 ? andifyList(ownedBoxList.join('\n')) : andifyList(ownedBoxList.join(', ')))) + (twentyPlus ? ' […] 👑' : '')).substring(0, 2040)
        }
      }
      )
    } else {
      message.channel.send('Looks like ' + tryDiscordifyUid(member.id) + " hasn't completed any boxes yet.")
    }

  } else {
    message.channel.send("Sorry, I don't know a '" + username + "'. 🤔")
  }
}

async function sendOwnedChallengesByMemberMsg(message, note, username) {
  console.log(username)
  var challengeNames = []
  var member = tryGetValidMember(username, message.author.username)

  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }

  if (member) {
    uid = member.id
    console.log("UID: ", uid)
    Object.values(CHALLENGES).forEach(challengecategory => {
      challengecategory.forEach(challenge => {
        var match = challenge.owners.find(user => user.uid === uid);
        if (match) {
          console.log(challenge.name + " completed by " + tryDiscordifyUid(member.id) + ": YES");
          challengeNames.push(challenge.name)
        } else {
          // console.error("User w/ ID of '" + uid + "' not found.")
        }
      })
    });
    console.log
    console.log("Constructing a box ownage tally message for " + tryDiscordifyUid(member.id) + "...")
    var ownedChallengeLinks = getMdLinksForChallengeCategoriesByChallengeNames(challengeNames)
    if (ownedChallengeLinks) {
      categoryOverflows = {}
      Object.keys(CHALLENGES).forEach(challengeCategoryName => {
        categoryOverflows[challengeCategoryName] = false
      })


      for (let i = 0; i < Object.keys(ownedChallengeLinks).length; i++) {
        const catKey = Object.keys(ownedChallengeLinks)[i];
        const catList = ownedChallengeLinks[catKey];

        if (catList.length > 20) {
          categoryOverflows[catKey] = true
          ownedChallengeLinks[catKey] = ownedChallengeLinks[catKey].slice(0, 20)
        }
      }

      catFields = []

      Object.keys(ownedChallengeLinks).forEach(challengeCategory => {
        // console.log(ownedChallengeLinks[challengeCategory])
        catFields.push(
          {
            name: challengeCategory, inline: true,
            value: (ownedChallengeLinks[challengeCategory].length == 0 ? "∅" : (categoryOverflows[challengeCategory] ? ownedChallengeLinks[challengeCategory].join(', ') : (ownedChallengeLinks[challengeCategory].length < 10 && ownedChallengeLinks[challengeCategory].length > 0 ? andifyList(ownedChallengeLinks[challengeCategory].join('\n')) : andifyList(ownedChallengeLinks[challengeCategory].join(', ')))) + (categoryOverflows[challengeCategory] ? ' […] 👑' : ''))
          })
      })
      console.log(catFields)
      message.channel.send({
        embed: {
          color: "AQUA",
          author: {
            name: 'Challenge ownage for ' + tryDiscordifyUid(member.id),
            icon_url: member.thumb,
            url: 'https://www.hackthebox.com/home/users/profile/' + uid,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Challenge owns by category:**'),
          fields: catFields
        }
      }
      )
    } else {
      message.channel.send('Looks like ' + tryDiscordifyUid(member.id) + " hasn't completed any challenges yet.")
    }

  } else {
    message.channel.send("Sorry, I don't know a '" + username + "'. 🤔")
  }
}


async function sendIncompleteChallengesByMemberMsg(message, note, username) {
  console.log(username)
  var challengeNames = []
  var member = tryGetValidMember(username, message.author.username)

  if (checkSelfName(username) && !member) {
    humanSend(message, (maybe(0.4) ? "" : "Hmm... ") + "I don't know who you are yet! Try saying `I am (username) on HTB` to associate your HTB account with your Discord username.\n(You can find my man page with the `help` command anytime.)")
    return
  }

  if (member) {
    var uid = member.id
    console.log("UID: ", uid)
    Object.values(CHALLENGES).forEach(challengecategory => {
      challengecategory.forEach(challenge => {
        var match = challenge.owners.find(user => user.uid === uid);
        if (!match) {
          console.log(challenge.name + " not completed by " + tryDiscordifyUid(member.id) + ": TRUE");
          challengeNames.push(challenge.name)
        } else {
          // console.error("User w/ ID of '" + uid + "' not found.")
        }
      })
    });
    console.log
    console.log("Constructing an incomplete challenge tally message for " + tryDiscordifyUid(member.id) + "...")
    var ownedChallengeLinks = getMdLinksForChallengeCategoriesByChallengeNames(challengeNames)
    if (ownedChallengeLinks) {
      categoryOverflows = {}
      Object.keys(CHALLENGES).forEach(challengeCategoryName => {
        categoryOverflows[challengeCategoryName] = false
      })


      for (let i = 0; i < Object.keys(ownedChallengeLinks).length; i++) {
        const catKey = Object.keys(ownedChallengeLinks)[i];
        const catList = ownedChallengeLinks[catKey];

        if (catList.length > 20) {
          categoryOverflows[catKey] = true
          ownedChallengeLinks[catKey] = ownedChallengeLinks[catKey].slice(0, 20)
        }
      }

      catFields = []

      Object.keys(ownedChallengeLinks).forEach(challengeCategory => {
        // console.log(ownedChallengeLinks[challengeCategory])
        catFields.push(
          {
            name: challengeCategory, inline: true,
            value: (ownedChallengeLinks[challengeCategory].length == 0 ? "👑 All done!" : (categoryOverflows[challengeCategory] ? ownedChallengeLinks[challengeCategory].join(', ') : (ownedChallengeLinks[challengeCategory].length < 10 && ownedChallengeLinks[challengeCategory].length > 0 ? andifyList(ownedChallengeLinks[challengeCategory].join('\n')) : andifyList(ownedChallengeLinks[challengeCategory].join(', ')))) + (categoryOverflows[challengeCategory] ? ' […]' : ''))
          })
      })
      console.log(catFields)
      message.channel.send({
        embed: {
          color: 16737095,
          author: {
            name: tryDiscordifyUid(member.id) + ": Incomplete challenges",
            icon_url: member.thumb,
            url: 'https://www.hackthebox.com/home/users/profile/' + uid,
          },
          footer: {
            text: "ℹ️  Ownage data last updated " + timeSince(LAST_UPDATE)
          },
          description: ('**Challenges, incomplete, by category:**'),
          fields: catFields
        }
      }
      )
    } else {
      message.channel.send('Looks like ' + tryDiscordifyUid(member.id) + " has done them all!!! 👑\n(Is that even possible???)")
    }

  } else {
    message.channel.send("Sorry, I don't know a '" + username + "'. 🤔")
  }
}

async function sendActiveBoxCountMsg(message, note) {
  msg = "There are " + MACHINE_STATS.activeBoxes + (Math.random() > 0.5 ? " non-retired " : " active ") + "machines."
  if (note) {
    console.log("NOTE: " + note)
    await humanSend(message, note, true)
  }
  await humanSend(message, msg)
}

async function sendRetiredBoxCountMsg(message, note) {
  msg = "There are " + MACHINE_STATS.retiredBoxes + (Math.random() > 0.5 ? " retired " : " inactive ") + "machines."
  if (note) {
    console.log("NOTE: " + note)
    await humanSend(message, note, true)
  }
  await humanSend(message, msg)
}

async function linkDiscord(message, idType, id) {
  switch (idType) {
    case "uid":
      try {
        DISCORD_LINKS[id] = message.author
        await humanSend(message, any("Associated HTB user " + uidToUname(id) + " (" + id + ")", "HTB user " + uidToUname(id) + " (" + id + ") has been linked") + " to your Discord account (" + message.author.tag + ")", true)
        updateCache(["discord_links"])
        //exportData(DISCORD_LINKS, "discord_links.json")
      } catch (error) { console.log(error) }
      break;

    case "uname": try {
      console.log("ID:", id)
      DISCORD_LINKS[unameToUid(id)] = message.author
      await humanSend(message, "Associated HTB user " + uidToUname(unameToUid(id)) + " (" + unameToUid(id) + ") to your Discord account (" + message.author.tag + ")", true)
      updateCache(["discord_links"])
      // exportData(DISCORD_LINKS, "discord_links.json")
    } catch (error) { console.log(error) }
      break;
    default:
      break;
  }

}

async function unlinkDiscord(message, id) {
  if (id in DISCORD_LINKS) {
    try {
      delete DISCORD_LINKS[id]
      await humanSend(message, "[Discord Unlink] Dissociated HTB user " + uidToUname(id) + " (" + id + ") from Discord account (" + message.author.tag + ")", true)
      updateCache(["discord_links"])
      // exportData(DISCORD_LINKS, "discord_links.json")
      return true
    } catch (error) {
      await humanSend(message, "[Discord Unlink] There was an issue dissociating '" + uidToUname(id) + "' (" + id + ") from Discord account (" + message.author.tag + "). \nMaybe the id is wrong, or perhaps I wasn't tracking that in the first place.", true)
      console.log(error)
      return true
    }
  } else {
    await humanSend(message, "[Discord Unlink] It looks like no Discord association exists for this user (make sure the ID is correct).\nNo changes were made.", true)
    return false
  }

}


async function forgetHtbDataFlow(message, identifier, uid) {
  switch (identifier) {
    case "htb":
      try {
        deletedUname = ignoreMember(uid)
        if (deletedUname) {
          await humanSend(message, "Blacklisted user " + uid + " (" + deletedUname + ") from future scans.", true)
        } else {
          await humanSend(message, "It looks like no HTB user data is actually being collected for this user (make sure the ID is correct).", true)
        }
      } catch (error) { console.log(error) }
      ; break;
    case "discord":
      unlinkDiscord(message, uid)
      break;
    case "all":
      try {
        ignoreMember(uid)
      } catch (error) {
        console.error(error)
      }
      unlinkDiscord(message, uid)
      await humanSend(message, "Forgetting any HTB data and Discord association for this user and ignoring future achievements.", true); break;
    default:
      break;
  }

}

function genBadgeUrl() {
  return (HTBROOT + 'badge/team/image/2102?nonce=' + genRanHex(4))
}



async function doFakeReboot(message, note) {
  await humanSend(message, note, true)
  await client.user.setStatus('idle')
    .then(console.log)
    .catch(console.error);
  await wait(3500)
  await client.user.setStatus('online')
    .then(console.log)
    .catch(console.error);
}

async function admin_setStatus(message, params) {
  if (message.author.id == process.env.ADMIN_DISCORD_ID) {
    status = params.discordStatusType.stringValue
    activity = params.discordStatusActivity.stringValue
    actverb = params.discordStatusVerb.stringValue
    humanSend(message, any("You're the boss!\nsetting the status 😊",
      "Ok " + message.author.username + ", you got it!",
      "you got it, " + message.author.username + " 😁",
      "no prob, i'm on it 🍉",
      "ok, on it! 🍉"), false)
    await setStatus(message.client, (status ? status : "online"), (actverb ? actverb : ""), (activity ? activity : ""))
  } else {
    humanSend(message, "You're not the boss. 👔\nTry asking __@Propolis__!")
  }
}


async function admin_forceUpdate(message) {
  if (message.author.id == process.env.ADMIN_DISCORD_ID) {
    humanSend(message, any("You're the boss!\nupdating the DB 😊",
      "Ok " + message.author.username + ", you got it!",
      "you got it, boss! 😁",
      "no prob, i'm on it 🍉",
      "ok, on it! 🍉"), false)
    await updateData(message.client)
    humanSend(message, any("hey I finished updating the DB! 😊",
      "Heyo, the DB update is finished!",
      "The data has been updated!",
      "DB update complete!",
      "Achivement data has been updated. 😊"), false)
  } else {
    humanSend(message, "You're not my boss! 🤔\nno can do.\nAsk __@Propolis__!")
  }
}

const checkIsSevenMsg = /[\t ]?seven\W?/g;

async function handleMessage(message) {
  if (message.channel.type == "dm" || message.content.toLowerCase().includes('seven')) {
    if (!message.author.bot) {
      if (message.content.toLowerCase().match(checkIsSevenMsg)) {
        message.content = message.content.toLowerCase().replace(checkIsSevenMsg, "")
      }
      console.log('got dm')
      result = await understand(message)
      isRipe = result.allRequiredParamsPresent
      console.log('result.intent: ' + result.intent.displayName + "  |  isRipe(hasParams): " + isRipe)
      if (result.intent && isRipe) {
        var job = result.intent.displayName
        var inf = result.parameters.fields
        console.log('jobinf: ' + job + ' | ' + JSON.stringify(inf))
        message.channel.startTyping()
        switch (job) {
          // case "removeLastXMessages": break;
          case "admin.forceUpdateData": try { admin_forceUpdate(message) } catch (e) { console.log(e) }; break;
          case "admin.setStatus": try { admin_setStatus(message, inf) } catch (e) { console.log(e) }; break;
          case "help": try { sendHelpMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "forgetMe.htbIgnore.getUserID": try { forgetHtbDataFlow(message, "htb", inf.uid.numberValue) } catch (e) { console.log(e) }; break;
          case "forgetMe.discordUnlink.getUserID": try { forgetHtbDataFlow(message, "discord", inf.uid.numberValue) } catch (e) { console.log(e) }; break;
          case "forgetMe.all.getUserID": try { forgetHtbDataFlow(message, "all", inf.uid.numberValue) } catch (e) { console.log(e) }; break;
          case "linkDiscord": try { linkDiscord(message, ("numberValue" in Object.keys(inf.uid) ? "uid" : "uname"), ("numberValue" in Object.keys(inf.uid) ? inf.uid.numberValue : inf.username.stringValue)) } catch (e) { console.log(e) }; break;
          case "unforgetMe": try { unignoreMember(inf.uid.numberValue); humanSend(message, result.fulfillmentText, true) } catch (e) { console.log(e) }; break;
          case "getTeamBadge": try { humanSend(message, genBadgeUrl() + '\n' + result.fulfillmentText, true) } catch (e) { console.log(e) }; break;
          case "getTeamInfo": try { sendTeamInfoMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "getTeamLeaders": try { sendTeamLeadersMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "getTeamLeader": try { sendTeamLeaderMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "getTeamRanking": try { sendTeamRankingMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "getFlagboard": try { sendFlagboardMsg(message) } catch (e) { console.log(e) }; break;
          case "getBoxInfo": try { sendBoxInfoMsg(message, inf.machines.stringValue) } catch (e) { console.log(e) }; break;
          case "getBoxOwners": try { sendBoxOwnersMsg(message, inf.machines.stringValue) } catch (e) { console.log(e) }; break;
          case "getOwnedBoxesByMember": try { sendOwnedBoxesByMemberMsg(message, result.fulfillmentText, inf.username.stringValue) } catch (e) { console.log(e) }; break;
          case "checkMemberOwnedBox": try { sendCheckMemberOwnedBoxMsg(message, inf.boxname.stringValue, inf.username.stringValue) } catch (e) { console.log(e) }; break;
          case "checkMemberOwnedChallenge": try { sendCheckMemberOwnedChallengeMsg(message, inf.challengename.stringValue, inf.username.stringValue) } catch (e) { console.log(e) }; break;
          case "getIncompleteBoxesByMember": try { sendIncompleteBoxesByMemberMsg(message, result.fulfillmentText, inf.username.stringValue) } catch (e) { console.log(e) }; break;
          case "getOwnedChallengesByMember": try { sendOwnedChallengesByMemberMsg(message, result.fulfillmentText, inf.username.stringValue) } catch (e) { console.log(e) }; break;
          case "getIncompleteChallengesByMember": try { sendIncompleteChallengesByMemberMsg(message, result.fulfillmentText, inf.username.stringValue) } catch (e) { console.log(e) }; break;
          case "getLastBoxOwner": try { sendLastBoxOwnerMsg(message, inf.machines.stringValue) } catch (e) { console.log(e) }; break;
          case "getBoxLaunchDate": try { sendBoxInfoMsg(message, inf.machines.stringValue) } catch (e) { console.log(e) }; break;
          case "getBoxRetireDate": try { sendBoxInfoMsg(message, inf.machines.stringValue) } catch (e) { console.log(e) }; break;
          case "getTotalBoxCount": try { sendTotalBoxCountMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "getActiveBoxCount": try { sendActiveBoxCountMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "getRetiredBoxCount": try { sendRetiredBoxCountMsg(message, result.fulfillmentText) } catch (e) { console.log(e) }; break;
          case "getFirstBox": try { sendBoxInfoMsg(message, "Lame") } catch (e) { console.log(e) };
          case "agent.doReboot": doFakeReboot(message, result.fulfillmentText); break;
          case "getNewBox": console.log("got here6.."); sendBoxInfoMsg(message, getNewReleaseName()); break;
          case "getChallengeInfo": try { sendChallengeInfoMsg(message, inf.challengeName.stringValue); } catch (e) { console.log(e) }; break;
          case "getChallengeOwners": try { sendChallengeOwnersMsg(message, inf.challengeName.stringValue); } catch (e) { console.log(e) }; break;
          case "getLastChallengeOwner": try { sendLastChallengeOwnerMsg(message, inf.challengeName.stringValue) } catch (e) { console.log(e) }; break;
          case "getMemberInfo": try { sendMemberInfoMsg(message, inf.username.stringValue); } catch (e) { message.channel.stopTyping(true); console.log(e) }; break;
          case "getMemberRank": try { sendMemberRankMsg(message, inf.username.stringValue); } catch (e) { message.channel.stopTyping(true); console.log(e) }; break;
          default:
            message.channel.stopTyping(true)
            if (result.fulfillmentText) {
              message.channel.startTyping()
              await humanSend(message, result.fulfillmentText)
              message.channel.stopTyping(true)
            }


        }
        message.channel.stopTyping(true)
      } else {
        if (result.fulfillmentText) {
          message.channel.startTyping()
          await humanSend(message, result.fulfillmentText)
        }
      }
    }
  }
}