Merge branch '0.0.9-alpha' of github.com:NuSkooler/enigma-bbs into 0.0.9-alpha

This commit is contained in:
Bryan Ashby
2019-01-26 15:30:41 -07:00
59 changed files with 6274 additions and 4173 deletions

View File

@@ -6,8 +6,11 @@ const DropFile = require('./dropfile.js');
const Door = require('./door.js');
const theme = require('./theme.js');
const ansi = require('./ansi_term.js');
const Events = require('./events.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
@@ -149,8 +152,6 @@ exports.getModule = class AbracadabraModule extends MenuModule {
}
runDoor() {
Events.emit(Events.getSystemEvents().UserRunDoor, { user : this.client.user } );
this.client.term.write(ansi.resetScreen());
const exeInfo = {
@@ -164,7 +165,11 @@ exports.getModule = class AbracadabraModule extends MenuModule {
node : this.client.node,
};
const doorTracking = trackDoorRunBegin(this.client, this.config.name);
this.doorInstance.run(exeInfo, () => {
trackDoorRunEnd(doorTracking);
//
// Try to clean up various settings such as scroll regions that may
// have been set within the door

724
core/achievement.js Normal file
View File

@@ -0,0 +1,724 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const Events = require('./events.js');
const Config = require('./config.js').get;
const {
getConfigPath,
getFullConfig,
} = require('./config_util.js');
const UserDb = require('./database.js').dbs.user;
const {
getISOTimestampString
} = require('./database.js');
const UserInterruptQueue = require('./user_interrupt_queue.js');
const {
getConnectionByUserId
} = require('./client_connections.js');
const UserProps = require('./user_property.js');
const {
Errors,
ErrorReasons
} = require('./enig_error.js');
const { getThemeArt } = require('./theme.js');
const {
pipeToAnsi,
stripMciColorCodes
} = require('./color_codes.js');
const stringFormat = require('./string_format.js');
const StatLog = require('./stat_log.js');
const Log = require('./logger.js').log;
const ConfigCache = require('./config_cache.js');
// deps
const _ = require('lodash');
const async = require('async');
const moment = require('moment');
const paths = require('path');
exports.getAchievementsEarnedByUser = getAchievementsEarnedByUser;
class Achievement {
constructor(data) {
this.data = data;
// achievements are retroactive by default
this.data.retroactive = _.get(this.data, 'retroactive', true);
}
static factory(data) {
if(!data) {
return;
}
let achievement;
switch(data.type) {
case Achievement.Types.UserStatSet :
case Achievement.Types.UserStatInc :
case Achievement.Types.UserStatIncNewVal :
achievement = new UserStatAchievement(data);
break;
default : return;
}
if(achievement.isValid()) {
return achievement;
}
}
static get Types() {
return {
UserStatSet : 'userStatSet',
UserStatInc : 'userStatInc',
UserStatIncNewVal : 'userStatIncNewVal',
};
}
isValid() {
switch(this.data.type) {
case Achievement.Types.UserStatSet :
case Achievement.Types.UserStatInc :
case Achievement.Types.UserStatIncNewVal :
if(!_.isString(this.data.statName)) {
return false;
}
if(!_.isObject(this.data.match)) {
return false;
}
break;
default : return false;
}
return true;
}
getMatchDetails(/*matchAgainst*/) {
}
isValidMatchDetails(details) {
if(!details || !_.isString(details.title) || !_.isString(details.text) || !_.isNumber(details.points)) {
return false;
}
return (_.isString(details.globalText) || !details.globalText);
}
}
class UserStatAchievement extends Achievement {
constructor(data) {
super(data);
// sort match keys for quick match lookup
this.matchKeys = Object.keys(this.data.match || {}).map(k => parseInt(k)).sort( (a, b) => b - a);
}
isValid() {
if(!super.isValid()) {
return false;
}
return !Object.keys(this.data.match).some(k => !parseInt(k));
}
getMatchDetails(matchValue) {
let ret = [];
let matchField = this.matchKeys.find(v => matchValue >= v);
if(matchField) {
const match = this.data.match[matchField];
matchField = parseInt(matchField);
if(this.isValidMatchDetails(match) && !isNaN(matchField)) {
ret = [ match, matchField, matchValue ];
}
}
return ret;
}
}
class Achievements {
constructor(events) {
this.events = events;
}
getAchievementByTag(tag) {
return this.achievementConfig.achievements[tag];
}
isEnabled() {
return !_.isUndefined(this.achievementConfig);
}
init(cb) {
let achievementConfigPath = _.get(Config(), 'general.achievementFile');
if(!achievementConfigPath) {
Log.info('Achievements are not configured');
return cb(null);
}
achievementConfigPath = getConfigPath(achievementConfigPath); // qualify
const configLoaded = (achievementConfig) => {
if(true !== achievementConfig.enabled) {
Log.info('Achievements are not enabled');
this.stopMonitoringUserStatEvents();
delete this.achievementConfig;
} else {
Log.info('Achievements are enabled');
this.achievementConfig = achievementConfig;
this.monitorUserStatEvents();
}
};
const changed = ( { fileName, fileRoot } ) => {
const reCachedPath = paths.join(fileRoot, fileName);
if(reCachedPath === achievementConfigPath) {
getFullConfig(achievementConfigPath, (err, achievementConfig) => {
if(err) {
return Log.error( { error : err.message }, 'Failed to reload achievement config from cache');
}
configLoaded(achievementConfig);
});
}
};
ConfigCache.getConfigWithOptions(
{
filePath : achievementConfigPath,
forceReCache : true,
callback : changed,
},
(err, achievementConfig) => {
if(err) {
return cb(err);
}
configLoaded(achievementConfig);
return cb(null);
}
);
}
loadAchievementHitCount(user, achievementTag, field, cb) {
UserDb.get(
`SELECT COUNT() AS count
FROM user_achievement
WHERE user_id = ? AND achievement_tag = ? AND match = ?;`,
[ user.userId, achievementTag, field],
(err, row) => {
return cb(err, row ? row.count : 0);
}
);
}
record(info, localInterruptItem, cb) {
StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalCount, 1);
StatLog.incrementUserStat(info.client.user, UserProps.AchievementTotalPoints, info.details.points);
const recordData = [
info.client.user.userId, info.achievementTag, getISOTimestampString(info.timestamp), info.matchField,
stripMciColorCodes(localInterruptItem.title), stripMciColorCodes(localInterruptItem.achievText), info.details.points,
];
UserDb.run(
`INSERT OR IGNORE INTO user_achievement (user_id, achievement_tag, timestamp, match, title, text, points)
VALUES (?, ?, ?, ?, ?, ?, ?);`,
recordData,
err => {
if(err) {
return cb(err);
}
this.events.emit(
Events.getSystemEvents().UserAchievementEarned,
{
user : info.client.user,
achievementTag : info.achievementTag,
points : info.details.points,
}
);
return cb(null);
}
);
}
display(info, interruptItems, cb) {
if(interruptItems.local) {
UserInterruptQueue.queue(interruptItems.local, { clients : info.client } );
}
if(interruptItems.global) {
UserInterruptQueue.queue(interruptItems.global, { omit : info.client } );
}
return cb(null);
}
recordAndDisplayAchievement(info, cb) {
async.waterfall(
[
(callback) => {
return this.createAchievementInterruptItems(info, callback);
},
(interruptItems, callback) => {
this.record(info, interruptItems.local, err => {
return callback(err, interruptItems);
});
},
(interruptItems, callback) => {
return this.display(info, interruptItems, callback);
}
],
err => {
return cb(err);
}
);
}
monitorUserStatEvents() {
if(this.userStatEventListeners) {
return; // already listening
}
const listenEvents = [
Events.getSystemEvents().UserStatSet,
Events.getSystemEvents().UserStatIncrement
];
this.userStatEventListeners = this.events.addMultipleEventListener(listenEvents, userStatEvent => {
if([ UserProps.AchievementTotalCount, UserProps.AchievementTotalPoints ].includes(userStatEvent.statName)) {
return;
}
if(!_.isNumber(userStatEvent.statValue) && !_.isNumber(userStatEvent.statIncrementBy)) {
return;
}
// :TODO: Make this code generic - find + return factory created object
const achievementTags = Object.keys(_.pickBy(
_.get(this.achievementConfig, 'achievements', {}),
achievement => {
if(false === achievement.enabled) {
return false;
}
const acceptedTypes = [
Achievement.Types.UserStatSet,
Achievement.Types.UserStatInc,
Achievement.Types.UserStatIncNewVal,
];
return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName;
}
));
if(0 === achievementTags.length) {
return;
}
async.eachSeries(achievementTags, (achievementTag, nextAchievementTag) => {
const achievement = Achievement.factory(this.getAchievementByTag(achievementTag));
if(!achievement) {
return nextAchievementTag(null);
}
const statValue = parseInt(
[ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ?
userStatEvent.statValue :
userStatEvent.statIncrementBy
);
if(isNaN(statValue)) {
return nextAchievementTag(null);
}
const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue);
if(!details) {
return nextAchievementTag(null);
}
async.series(
[
(callback) => {
this.loadAchievementHitCount(userStatEvent.user, achievementTag, matchField, (err, count) => {
if(err) {
return callback(err);
}
return callback(count > 0 ? Errors.General('Achievement already acquired', ErrorReasons.TooMany) : null);
});
},
(callback) => {
const client = getConnectionByUserId(userStatEvent.user.userId);
if(!client) {
return callback(Errors.UnexpectedState('Failed to get client for user ID'));
}
const info = {
achievementTag,
achievement,
details,
client,
matchField, // match - may be in odd format
matchValue, // actual value
achievedValue : matchField, // achievement value met
user : userStatEvent.user,
timestamp : moment(),
};
const achievementsInfo = [ info ];
if(true === achievement.data.retroactive) {
// For userStat, any lesser match keys(values) are also met. Example:
// matchKeys: [ 500, 200, 100, 20, 10, 2 ]
// ^---- we met here
// ^------------^ retroactive range
//
const index = achievement.matchKeys.findIndex(v => v < matchField);
if(index > -1 && Array.isArray(achievement.matchKeys)) {
achievement.matchKeys.slice(index).forEach(k => {
const [ det, fld, val ] = achievement.getMatchDetails(k);
if(det) {
achievementsInfo.push(Object.assign(
{},
info,
{
details : det,
matchField : fld,
achievedValue : fld,
matchValue : val,
}
));
}
});
}
}
// reverse achievementsInfo so we display smallest > largest
achievementsInfo.reverse();
async.eachSeries(achievementsInfo, (achInfo, nextAchInfo) => {
return this.recordAndDisplayAchievement(achInfo, err => {
return nextAchInfo(err);
});
},
err => {
return callback(err);
});
}
],
err => {
if(err && ErrorReasons.TooMany !== err.reasonCode) {
Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event');
}
return nextAchievementTag(null); // always try the next, regardless
}
);
});
/*
const achievementTag = _.findKey(
_.get(this.achievementConfig, 'achievements', {}),
achievement => {
if(false === achievement.enabled) {
return false;
}
const acceptedTypes = [
Achievement.Types.UserStatSet,
Achievement.Types.UserStatInc,
Achievement.Types.UserStatIncNewVal,
];
return acceptedTypes.includes(achievement.type) && achievement.statName === userStatEvent.statName;
}
);
if(!achievementTag) {
return;
}
const achievement = Achievement.factory(this.getAchievementByTag(achievementTag));
if(!achievement) {
return;
}
const statValue = parseInt(
[ Achievement.Types.UserStatSet, Achievement.Types.UserStatIncNewVal ].includes(achievement.data.type) ?
userStatEvent.statValue :
userStatEvent.statIncrementBy
);
if(isNaN(statValue)) {
return;
}
const [ details, matchField, matchValue ] = achievement.getMatchDetails(statValue);
if(!details) {
return;
}
async.series(
[
(callback) => {
this.loadAchievementHitCount(userStatEvent.user, achievementTag, matchField, (err, count) => {
if(err) {
return callback(err);
}
return callback(count > 0 ? Errors.General('Achievement already acquired', ErrorReasons.TooMany) : null);
});
},
(callback) => {
const client = getConnectionByUserId(userStatEvent.user.userId);
if(!client) {
return callback(Errors.UnexpectedState('Failed to get client for user ID'));
}
const info = {
achievementTag,
achievement,
details,
client,
matchField, // match - may be in odd format
matchValue, // actual value
achievedValue : matchField, // achievement value met
user : userStatEvent.user,
timestamp : moment(),
};
const achievementsInfo = [ info ];
if(true === achievement.data.retroactive) {
// For userStat, any lesser match keys(values) are also met. Example:
// matchKeys: [ 500, 200, 100, 20, 10, 2 ]
// ^---- we met here
// ^------------^ retroactive range
//
const index = achievement.matchKeys.findIndex(v => v < matchField);
if(index > -1 && Array.isArray(achievement.matchKeys)) {
achievement.matchKeys.slice(index).forEach(k => {
const [ det, fld, val ] = achievement.getMatchDetails(k);
if(det) {
achievementsInfo.push(Object.assign(
{},
info,
{
details : det,
matchField : fld,
achievedValue : fld,
matchValue : val,
}
));
}
});
}
}
// reverse achievementsInfo so we display smallest > largest
achievementsInfo.reverse();
async.eachSeries(achievementsInfo, (achInfo, nextAchInfo) => {
return this.recordAndDisplayAchievement(achInfo, err => {
return nextAchInfo(err);
});
},
err => {
return callback(err);
});
}
],
err => {
if(err && ErrorReasons.TooMany !== err.reasonCode) {
Log.warn( { error : err.message, userStatEvent }, 'Error handling achievement for user stat event');
}
}
);*/
});
}
stopMonitoringUserStatEvents() {
if(this.userStatEventListeners) {
this.events.removeMultipleEventListener(this.userStatEventListeners);
delete this.userStatEventListeners;
}
}
getFormatObject(info) {
return {
userName : info.user.username,
userRealName : info.user.properties[UserProps.RealName],
userLocation : info.user.properties[UserProps.Location],
userAffils : info.user.properties[UserProps.Affiliations],
nodeId : info.client.node,
title : info.details.title,
//text : info.global ? info.details.globalText : info.details.text,
points : info.details.points,
achievedValue : info.achievedValue,
matchField : info.matchField,
matchValue : info.matchValue,
timestamp : moment(info.timestamp).format(info.dateTimeFormat),
boardName : Config().general.boardName,
};
}
getFormattedTextFor(info, textType, defaultSgr = '|07') {
const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {});
const textTypeSgr = themeDefaults[`${textType}SGR`] || defaultSgr;
const formatObj = this.getFormatObject(info);
const wrap = (input) => {
const re = new RegExp(`{(${Object.keys(formatObj).join('|')})([^}]*)}`, 'g');
return input.replace(re, (m, formatVar, formatOpts) => {
const varSgr = themeDefaults[`${formatVar}SGR`] || textTypeSgr;
let r = `${varSgr}{${formatVar}`;
if(formatOpts) {
r += formatOpts;
}
return `${r}}${textTypeSgr}`;
});
};
return stringFormat(`${textTypeSgr}${wrap(info.details[textType])}`, formatObj);
}
createAchievementInterruptItems(info, cb) {
info.dateTimeFormat =
info.details.dateTimeFormat ||
info.achievement.dateTimeFormat ||
info.client.currentTheme.helpers.getDateTimeFormat();
const title = this.getFormattedTextFor(info, 'title');
const text = this.getFormattedTextFor(info, 'text');
let globalText;
if(info.details.globalText) {
globalText = this.getFormattedTextFor(info, 'globalText');
}
const getArt = (name, callback) => {
const spec =
_.get(info.details, `art.${name}`) ||
_.get(info.achievement, `art.${name}`) ||
_.get(this.achievementConfig, `art.${name}`);
if(!spec) {
return callback(null);
}
const getArtOpts = {
name : spec,
client : this.client,
random : false,
};
getThemeArt(getArtOpts, (err, artInfo) => {
// ignore errors
return callback(artInfo ? artInfo.data : null);
});
};
const interruptItems = {};
let itemTypes = [ 'local' ];
if(globalText) {
itemTypes.push('global');
}
async.each(itemTypes, (itemType, nextItemType) => {
async.waterfall(
[
(callback) => {
getArt(`${itemType}Header`, headerArt => {
return callback(null, headerArt);
});
},
(headerArt, callback) => {
getArt(`${itemType}Footer`, footerArt => {
return callback(null, headerArt, footerArt);
});
},
(headerArt, footerArt, callback) => {
const itemText = 'global' === itemType ? globalText : text;
interruptItems[itemType] = {
title,
achievText : itemText,
text : `${title}\r\n${itemText}`,
pause : true,
};
if(headerArt || footerArt) {
const themeDefaults = _.get(info.client.currentTheme, 'achievements.defaults', {});
const defaultContentsFormat = '{title}\r\n{message}';
const contentsFormat = 'global' === itemType ?
themeDefaults.globalFormat || defaultContentsFormat :
themeDefaults.format || defaultContentsFormat;
const formatObj = Object.assign(this.getFormatObject(info), {
title : this.getFormattedTextFor(info, 'title', ''), // ''=defaultSgr
message : itemText,
});
const contents = pipeToAnsi(stringFormat(contentsFormat, formatObj));
interruptItems[itemType].contents =
`${headerArt || ''}\r\n${contents}\r\n${footerArt || ''}`;
}
return callback(null);
}
],
err => {
return nextItemType(err);
}
);
},
err => {
return cb(err, interruptItems);
});
}
}
let achievementsInstance;
function getAchievementsEarnedByUser(userId, cb) {
if(!achievementsInstance) {
return cb(Errors.UnexpectedState('Achievements not initialized'));
}
UserDb.all(
`SELECT achievement_tag, timestamp, match, title, text, points
FROM user_achievement
WHERE user_id = ?
ORDER BY DATETIME(timestamp);`,
[ userId ],
(err, rows) => {
if(err) {
return cb(err);
}
const earned = rows.map(row => {
const achievement = Achievement.factory(achievementsInstance.getAchievementByTag(row.achievement_tag));
if(!achievement) {
return;
}
const earnedInfo = {
achievementTag : row.achievement_tag,
type : achievement.data.type,
retroactive : achievement.data.retroactive,
title : row.title,
text : row.text,
points : row.points,
timestamp : moment(row.timestamp),
};
switch(earnedInfo.type) {
case [ Achievement.Types.UserStatSet ] :
case [ Achievement.Types.UserStatInc ] :
case [ Achievement.Types.UserStatIncNewVal ] :
earnedInfo.statName = achievement.data.statName;
break;
}
return earnedInfo;
}).filter(a => a); // remove any empty records (ie: no achievement.hjson entry exists anymore).
return cb(null, earned);
}
);
}
exports.moduleInitialize = (initInfo, cb) => {
achievementsInstance = new Achievements(initInfo.events);
achievementsInstance.init( err => {
if(err) {
return cb(err);
}
return cb(null);
});
};

View File

@@ -1004,7 +1004,7 @@ function peg$parse(input, options) {
TW : function termWidth() {
return !isNaN(value) && _.get(client, 'term.termWidth', 0) >= value;
},
ID : function isUserId(value) {
ID : function isUserId() {
if(!user) {
return false;
}
@@ -1024,6 +1024,20 @@ function peg$parse(input, options) {
const midnight = now.clone().startOf('day')
const minutesPastMidnight = now.diff(midnight, 'minutes');
return !isNaN(value) && minutesPastMidnight >= value;
},
AC : function achievementCount() {
if(!user) {
return false;
}
const count = user.getPropertyAsNumber(UserProps.AchievementTotalCount) || 0;
return !isNan(value) && points >= value;
},
AP : function achievementPoints() {
if(!user) {
return false;
}
const points = user.getPropertyAsNumber(UserProps.AchievementTotalPoints) || 0;
return !isNan(value) && points >= value;
}
}[acsCode](value);
} catch (e) {

View File

@@ -23,6 +23,8 @@
// General
// * http://en.wikipedia.org/wiki/ANSI_escape_code
// * http://www.inwap.com/pdp10/ansicode.txt
// * Excellent information with many standards covered (for hterm):
// https://chromium.googlesource.com/apps/libapps/+/master/hterm/doc/ControlSequences.md
//
// Other Implementations
// * https://github.com/chjj/term.js/blob/master/src/term.js

View File

@@ -4,6 +4,10 @@
const { MenuModule } = require('./menu_module.js');
const { resetScreen } = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
@@ -98,7 +102,7 @@ exports.getModule = class BBSLinkModule extends MenuModule {
//
// Authenticate the token we acquired previously
//
var headers = {
const headers = {
'X-User' : self.client.user.userId.toString(),
'X-System' : self.config.sysCode,
'X-Auth' : crypto.createHash('md5').update(self.config.authCode + token).digest('hex'),
@@ -125,17 +129,19 @@ exports.getModule = class BBSLinkModule extends MenuModule {
// Authentication with BBSLink successful. Now, we need to create a telnet
// bridge from us to them
//
var connectOpts = {
const connectOpts = {
port : self.config.port,
host : self.config.host,
};
var clientTerminated;
let clientTerminated;
self.client.term.write(resetScreen());
self.client.term.write(' Connecting to BBSLink.net, please wait...\n');
var bridgeConnection = net.createConnection(connectOpts, function connected() {
const doorTracking = trackDoorRunBegin(self.client, `bbslink_${self.config.door}`);
const bridgeConnection = net.createConnection(connectOpts, function connected() {
self.client.log.info(connectOpts, 'BBSLink bridge connection established');
self.client.term.output.pipe(bridgeConnection);
@@ -143,13 +149,15 @@ exports.getModule = class BBSLinkModule extends MenuModule {
self.client.once('end', function clientEnd() {
self.client.log.info('Connection ended. Terminating BBSLink connection');
clientTerminated = true;
bridgeConnection.end();
bridgeConnection.end();
});
});
var restorePipe = function() {
const restorePipe = function() {
self.client.term.output.unpipe(bridgeConnection);
self.client.term.output.resume();
trackDoorRunEnd(doorTracking);
};
bridgeConnection.on('data', function incomingData(data) {

View File

@@ -40,6 +40,7 @@ const MenuStack = require('./menu_stack.js');
const ACS = require('./acs.js');
const Events = require('./events.js');
const UserInterruptQueue = require('./user_interrupt_queue.js');
const UserProps = require('./user_property.js');
// deps
const stream = require('stream');
@@ -442,13 +443,36 @@ Client.prototype.startIdleMonitor = function() {
//
// Every 1m, check for idle.
// We also update minutes spent online the system here,
// if we have a authenticated user.
//
this.idleCheck = setInterval( () => {
const nowMs = Date.now();
const idleLogoutSeconds = this.user.isAuthenticated() ?
Config().users.idleLogoutSeconds :
Config().users.preAuthIdleLogoutSeconds;
let idleLogoutSeconds;
if(this.user.isAuthenticated()) {
idleLogoutSeconds = Config().users.idleLogoutSeconds;
//
// We don't really want to be firing off an event every 1m for
// every user, but want at least some updates for various things
// such as achievements. Send off every 5m.
//
const minOnline = this.user.incrementProperty(UserProps.MinutesOnlineTotalCount, 1);
if(0 === (minOnline % 5)) {
Events.emit(
Events.getSystemEvents().UserStatIncrement,
{
user : this.user,
statName : UserProps.MinutesOnlineTotalCount,
statIncrementBy : 1,
statValue : minOnline
}
);
}
} else {
idleLogoutSeconds = Config().users.preAuthIdleLogoutSeconds;
}
if(nowMs - this.lastKeyPressMs >= (idleLogoutSeconds * 1000)) {
this.emit('idle timeout');
@@ -473,6 +497,14 @@ Client.prototype.end = function () {
currentModule.leave();
}
// persist time online for authenticated users
if(this.user.isAuthenticated()) {
this.user.persistProperty(
UserProps.MinutesOnlineTotalCount,
this.user.getProperty(UserProps.MinutesOnlineTotalCount)
);
}
this.stopIdleMonitor();
try {

View File

@@ -60,12 +60,25 @@ function getActiveConnectionList(authUsersOnly) {
}
function addNewClient(client, clientSock) {
const id = client.session.id = clientConnections.push(client) - 1;
const remoteAddress = client.remoteAddress = clientSock.remoteAddress;
//
// Assign ID/client ID to next lowest & available #
//
let id = 0;
for(let i = 0; i < clientConnections.length; ++i) {
if(clientConnections[i].id > id) {
break;
}
id++;
}
client.session.id = id;
const remoteAddress = client.remoteAddress = clientSock.remoteAddress;
// create a unique identifier one-time ID for this session
client.session.uniqueId = new hashids('ENiGMA½ClientSession').encode([ id, moment().valueOf() ]);
clientConnections.push(client);
clientConnections.sort( (c1, c2) => c1.session.id - c2.session.id);
// Create a client specific logger
// Note that this will be updated @ login with additional information
client.log = logger.log.child( { clientId : id, sessionId : client.session.uniqueId } );
@@ -107,7 +120,8 @@ function removeClient(client) {
);
if(client.user && client.user.isValid()) {
Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user } );
const minutesOnline = moment().diff(moment(client.user.properties[UserProps.LastLoginTs]), 'minutes');
Events.emit(Events.getSystemEvents().UserLogoff, { user : client.user, minutesOnline } );
}
Events.emit(

View File

@@ -131,7 +131,7 @@ function renegadeToAnsi(s, client) {
//
// Supported control code formats:
// * Renegade : |##
// * PCBoard : @X## where the first number/char is FG color, and second is BG
// * PCBoard : @X## where the first number/char is BG color, and second is FG
// * WildCat! : @##@ the same as PCBoard without the X prefix, but with a @ suffix
// * WWIV : ^#
// * CNET Y-Style : 0x19## where ## is a specific set of codes -- this is the older format
@@ -179,26 +179,6 @@ function controlCodesToAnsi(s, client) {
v = m[4];
}
fg = {
0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ],
8 : [ 'blink', 'black' ],
9 : [ 'blink', 'blue' ],
A : [ 'blink', 'green' ],
B : [ 'blink', 'cyan' ],
C : [ 'blink', 'red' ],
D : [ 'blink', 'magenta' ],
E : [ 'blink', 'yellow' ],
F : [ 'blink', 'white' ],
}[v.charAt(0)] || ['normal'];
bg = {
0 : [ 'blackBG' ],
1 : [ 'blueBG' ],
@@ -217,7 +197,27 @@ function controlCodesToAnsi(s, client) {
D : [ 'bold', 'magentaBG' ],
E : [ 'bold', 'yellowBG' ],
F : [ 'bold', 'whiteBG' ],
}[v.charAt(1)] || [ 'normal' ];
}[v.charAt(0)] || [ 'normal' ];
fg = {
0 : [ 'reset', 'black' ],
1 : [ 'reset', 'blue' ],
2 : [ 'reset', 'green' ],
3 : [ 'reset', 'cyan' ],
4 : [ 'reset', 'red' ],
5 : [ 'reset', 'magenta' ],
6 : [ 'reset', 'yellow' ],
7 : [ 'reset', 'white' ],
8 : [ 'blink', 'black' ],
9 : [ 'blink', 'blue' ],
A : [ 'blink', 'green' ],
B : [ 'blink', 'cyan' ],
C : [ 'blink', 'red' ],
D : [ 'blink', 'magenta' ],
E : [ 'blink', 'yellow' ],
F : [ 'blink', 'white' ],
}[v.charAt(1)] || ['normal'];
v = ANSI.sgr(fg.concat(bg));
result += s.substr(lastIndex, m.index - lastIndex) + v;

View File

@@ -5,6 +5,10 @@
const { MenuModule } = require('../core/menu_module.js');
const { resetScreen } = require('../core/ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
@@ -46,9 +50,15 @@ exports.getModule = class CombatNetModule extends MenuModule {
self.client.term.write(resetScreen());
self.client.term.write('Connecting to CombatNet, please wait...\n');
let doorTracking;
const restorePipeToNormal = function() {
if(self.client.term.output) {
self.client.term.output.removeListener('data', sendToRloginBuffer);
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
}
};
@@ -90,6 +100,7 @@ exports.getModule = class CombatNetModule extends MenuModule {
self.client.log.info('Connected to CombatNet');
self.client.term.output.on('data', sendToRloginBuffer);
doorTracking = trackDoorRunBegin(self.client);
} else {
return callback(Errors.General('Failed to establish establish CombatNet connection'));
}

View File

@@ -175,6 +175,7 @@ function getDefaultConfig() {
menuFile : 'menu.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
promptFile : 'prompt.hjson', // 'oputil.js config new' will set this appropriately in config.hjson; may be full path
achievementFile : 'achievements.hjson',
},
users : {
@@ -1003,6 +1004,6 @@ function getDefaultConfig() {
systemEvents : {
loginHistoryMax: -1, // set to -1 for forever
}
}
},
};
}

View File

@@ -10,6 +10,7 @@ const paths = require('path');
const async = require('async');
exports.init = init;
exports.getConfigPath = getConfigPath;
exports.getFullConfig = getFullConfig;
function getConfigPath(filePath) {

View File

@@ -200,7 +200,7 @@ function displayBanner(term) {
// note: intentional formatting:
term.pipeWrite(`
|06Connected to |02EN|10i|02GMA|10½ |06BBS version |12|VN
|06Copyright (c) 2014-2018 Bryan Ashby |14- |12http://l33t.codes/
|06Copyright (c) 2014-2019 Bryan Ashby |14- |12http://l33t.codes/
|06Updates & source |14- |12https://github.com/NuSkooler/enigma-bbs/
|00`
);

View File

@@ -189,6 +189,20 @@ const DB_INIT_TABLE = {
);`
);
dbs.user.run(
`CREATE TABLE IF NOT EXISTS user_achievement (
user_id INTEGER NOT NULL,
achievement_tag VARCHAR NOT NULL,
timestamp DATETIME NOT NULL,
match VARCHAR NOT NULL,
title VARCHAR NOT NULL,
text VARCHAR NOT NULL,
points INTEGER NOT NULL,
UNIQUE(user_id, achievement_tag, match),
FOREIGN KEY(user_id) REFERENCES user(id) ON DELETE CASCADE
);`
);
return cb(null);
},

View File

@@ -5,6 +5,10 @@
const { MenuModule } = require('./menu_module.js');
const { resetScreen } = require('./ansi_term.js');
const { Errors } = require('./enig_error.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
@@ -54,10 +58,16 @@ exports.getModule = class DoorPartyModule extends MenuModule {
let pipeRestored = false;
let pipedStream;
let doorTracking;
const restorePipe = function() {
if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream);
self.client.term.output.resume();
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
}
};
@@ -75,6 +85,8 @@ exports.getModule = class DoorPartyModule extends MenuModule {
return callback(Errors.General('Failed to establish tunnel'));
}
doorTracking = trackDoorRunBegin(self.client);
//
// Send rlogin
// DoorParty wants the "server username" portion to be in the format of [BBS_TAG]USERNAME, e.g.
@@ -100,6 +112,7 @@ exports.getModule = class DoorPartyModule extends MenuModule {
sshClient.on('error', err => {
self.client.log.info(`DoorParty SSH client error: ${err.message}`);
trackDoorRunEnd(doorTracking);
});
sshClient.on('close', () => {
@@ -122,7 +135,7 @@ exports.getModule = class DoorPartyModule extends MenuModule {
self.client.log.warn( { error : err.message }, 'DoorParty error');
}
// if the client is stil here, go to previous
// if the client is still here, go to previous
if(!clientTerminated) {
self.prevMenu();
}

38
core/door_util.js Normal file
View File

@@ -0,0 +1,38 @@
/* jslint node: true */
'use strict';
const UserProps = require('./user_property.js');
const Events = require('./events.js');
const StatLog = require('./stat_log.js');
const moment = require('moment');
exports.trackDoorRunBegin = trackDoorRunBegin;
exports.trackDoorRunEnd = trackDoorRunEnd;
function trackDoorRunBegin(client, doorTag) {
const startTime = moment();
return { startTime, client, doorTag };
}
function trackDoorRunEnd(trackInfo) {
const { startTime, client, doorTag } = trackInfo;
const diff = moment.duration(moment().diff(startTime));
if(diff.asSeconds() >= 45) {
StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalCount, 1);
}
const runTimeMinutes = Math.floor(diff.asMinutes());
if(runTimeMinutes > 0) {
StatLog.incrementUserStat(client.user, UserProps.DoorRunTotalMinutes, runTimeMinutes);
const eventInfo = {
runTimeMinutes,
user : client.user,
doorTag : doorTag || 'unknown',
};
Events.emit(Events.getSystemEvents().UserRunDoor, eventInfo);
}
}

View File

@@ -5,6 +5,9 @@ const events = require('events');
const Log = require('./logger.js').log;
const SystemEvents = require('./system_events.js');
// deps
const _ = require('lodash');
module.exports = new class Events extends events.EventEmitter {
constructor() {
super();
@@ -35,12 +38,30 @@ module.exports = new class Events extends events.EventEmitter {
return super.once(event, listener);
}
addListenerMultipleEvents(events, listener) {
Log.trace( { events }, 'Registring event listeners');
//
// Listen to multiple events for a single listener.
// Called with: listener(event, eventName)
//
// The returned object must be used with removeMultipleEventListener()
//
addMultipleEventListener(events, listener) {
Log.trace( { events }, 'Registering event listeners');
const listeners = [];
events.forEach(eventName => {
this.on(eventName, event => {
listener(eventName, event);
});
const listenWrapper = _.partial(listener, _, eventName);
this.on(eventName, listenWrapper);
listeners.push( { eventName, listenWrapper } );
});
return listeners;
}
removeMultipleEventListener(listeners) {
Log.trace( { events }, 'Removing listeners');
listeners.forEach(listener => {
this.removeListener(listener.eventName, listener.listenWrapper);
});
}

View File

@@ -2,12 +2,18 @@
'use strict';
// ENiGMA½
const MenuModule = require('./menu_module.js').MenuModule;
const resetScreen = require('./ansi_term.js').resetScreen;
const Config = require('./config.js').get;
const Errors = require('./enig_error.js').Errors;
const Log = require('./logger.js').log;
const getEnigmaUserAgent = require('./misc_util.js').getEnigmaUserAgent;
const { MenuModule } = require('./menu_module.js');
const { resetScreen } = require('./ansi_term.js');
const Config = require('./config.js').get;
const { Errors } = require('./enig_error.js');
const Log = require('./logger.js').log;
const {
getEnigmaUserAgent
} = require('./misc_util.js');
const {
trackDoorRunBegin,
trackDoorRunEnd
} = require('./door_util.js');
// deps
const async = require('async');
@@ -151,11 +157,16 @@ exports.getModule = class ExodusModule extends MenuModule {
let pipeRestored = false;
let pipedStream;
let doorTracking;
function restorePipe() {
if(pipedStream && !pipeRestored && !clientTerminated) {
self.client.term.output.unpipe(pipedStream);
self.client.term.output.resume();
if(doorTracking) {
trackDoorRunEnd(doorTracking);
}
}
}
@@ -186,6 +197,8 @@ exports.getModule = class ExodusModule extends MenuModule {
});
sshClient.shell(window, options, (err, stream) => {
doorTracking = trackDoorRunBegin(self.client, `exodus_${self.config.door}`);
pipedStream = stream; // :TODO: ewwwwwwwww hack
self.client.term.output.pipe(stream);

View File

@@ -174,7 +174,7 @@ exports.getModule = class LastCallersModule extends MenuModule {
let indicatorSumsSql;
if(actionIndicatorNames.length > 0) {
indicatorSumsSql = actionIndicatorNames.map(i => {
return `SUM(CASE WHEN log_value='${_.snakeCase(i)}' THEN 1 ELSE 0 END) AS ${i}`;
return `SUM(CASE WHEN log_name='${_.snakeCase(i)}' THEN 1 ELSE 0 END) AS ${i}`;
});
}

View File

@@ -37,8 +37,8 @@ module.exports = class LoginServerModule extends ServerModule {
handleNewClient(client, clientSock, modInfo) {
//
// Start tracking the client. We'll assign it an ID which is
// just the index in our connections array.
// Start tracking the client. A session ID aka client ID
// will be established in addNewClient() below.
//
if(_.isUndefined(client.session)) {
client.session = {};

View File

@@ -30,6 +30,10 @@ exports.MenuModule = class MenuModule extends PluginModule {
this.cls = _.get(this.menuConfig.config, 'cls', Config().menus.cls);
this.viewControllers = {};
this.interrupt = (_.get(this.menuConfig.config, 'interrupt', MenuModule.InterruptTypes.Queued)).toLowerCase();
if(MenuModule.InterruptTypes.Realtime === this.interrupt) {
this.realTimeInterrupt = 'blocked';
}
}
static get InterruptTypes() {
@@ -137,6 +141,7 @@ exports.MenuModule = class MenuModule extends PluginModule {
},
function finishAndNext(callback) {
self.finishedLoading();
self.realTimeInterrupt = 'allowed';
return self.autoNextMenu(callback);
}
],
@@ -194,21 +199,28 @@ exports.MenuModule = class MenuModule extends PluginModule {
}
attemptInterruptNow(interruptItem, cb) {
if(MenuModule.InterruptTypes.Realtime !== this.interrupt) {
if(this.realTimeInterrupt !== 'allowed' || MenuModule.InterruptTypes.Realtime !== this.interrupt) {
return cb(null, false); // don't eat up the item; queue for later
}
this.realTimeInterrupt = 'blocked';
//
// Default impl: clear screen -> standard display -> reload menu
//
const done = (err, removeFromQueue) => {
this.realTimeInterrupt = 'allowed';
return cb(err, removeFromQueue);
};
this.client.interruptQueue.displayWithItem(
Object.assign({}, interruptItem, { cls : true }),
err => {
if(err) {
return cb(err, false);
return done(err, false);
}
this.reload(err => {
return cb(err, err ? false : true);
return done(err, err ? false : true);
});
});
}
@@ -317,7 +329,7 @@ exports.MenuModule = class MenuModule extends PluginModule {
// A quick rundown:
// * We may have mciData.menu, mciData.prompt, or both.
// * Prompt form is favored over menu form if both are present.
// * Standard/prefdefined MCI entries must load both (e.g. %BN is expected to resolve)
// * Standard/predefined MCI entries must load both (e.g. %BN is expected to resolve)
//
const self = this;

View File

@@ -65,7 +65,7 @@ exports.getModule = class NodeMessageModule extends MenuModule {
}
}
Events.emit(Events.getSystemEvents().UserSendNodeMsg, { user : this.client.user } );
Events.emit(Events.getSystemEvents().UserSendNodeMsg, { user : this.client.user, global : -1 === nodeId } );
return this.prevMenu(cb);
});

View File

@@ -11,7 +11,9 @@ const {
const clientConnections = require('./client_connections.js');
const StatLog = require('./stat_log.js');
const FileBaseFilters = require('./file_base_filter.js');
const { formatByteSize } = require('./string_util.js');
const {
formatByteSize,
} = require('./string_util.js');
const ANSI = require('./ansi_term.js');
const UserProps = require('./user_property.js');
const SysProps = require('./system_property.js');
@@ -54,6 +56,15 @@ function userStatAsString(client, statName, defaultValue) {
return (StatLog.getUserStat(client.user, statName) || defaultValue).toLocaleString();
}
function toNumberWithCommas(x) {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function userStatAsCountString(client, statName, defaultValue) {
const value = StatLog.getUserStatNum(client.user, statName) || defaultValue;
return toNumberWithCommas(value);
}
function sysStatAsString(statName, defaultValue) {
return (StatLog.getSystemStat(statName) || defaultValue).toLocaleString();
}
@@ -90,14 +101,14 @@ const PREDEFINED_MCI_GENERATORS = {
return moment(client.user.properties[UserProps.Birthdate]).format(client.currentTheme.helpers.getDateFormat());
},
US : function sex(client) { return userStatAsString(client, UserProps.Sex, ''); },
UE : function emailAddres(client) { return userStatAsString(client, UserProps.EmailAddress, ''); },
UE : function emailAddress(client) { return userStatAsString(client, UserProps.EmailAddress, ''); },
UW : function webAddress(client) { return userStatAsString(client, UserProps.WebAddress, ''); },
UF : function affils(client) { return userStatAsString(client, UserProps.Affiliations, ''); },
UT : function themeName(client) {
return _.get(client, 'currentTheme.info.name', userStatAsString(client, UserProps.ThemeId, ''));
},
UD : function themeId(client) { return userStatAsString(client, UserProps.ThemeId, ''); },
UC : function loginCount(client) { return userStatAsString(client, UserProps.LoginCount, 0); },
UC : function loginCount(client) { return userStatAsCountString(client, UserProps.LoginCount, 0); },
ND : function connectedNode(client) { return client.node.toString(); },
IP : function clientIpAddress(client) { return client.remoteAddress.replace(/^::ffff:/, ''); }, // convert any :ffff: IPv4's to 32bit version
ST : function serverName(client) { return client.session.serverName; },
@@ -105,12 +116,12 @@ const PREDEFINED_MCI_GENERATORS = {
const activeFilter = FileBaseFilters.getActiveFilter(client);
return activeFilter ? activeFilter.name : '(Unknown)';
},
DN : function userNumDownloads(client) { return userStatAsString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2
DN : function userNumDownloads(client) { return userStatAsCountString(client, UserProps.FileDlTotalCount, 0); }, // Obv/2
DK : function userByteDownload(client) { // Obv/2 uses DK=downloaded Kbytes
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileDlTotalBytes);
return formatByteSize(byteSize, true); // true=withAbbr
},
UP : function userNumUploads(client) { return userStatAsString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2
UP : function userNumUploads(client) { return userStatAsCountString(client, UserProps.FileUlTotalCount, 0); }, // Obv/2
UK : function userByteUpload(client) { // Obv/2 uses UK=uploaded Kbytes
const byteSize = StatLog.getUserStatNum(client.user, UserProps.FileUlTotalBytes);
return formatByteSize(byteSize, true); // true=withAbbr
@@ -122,10 +133,10 @@ const PREDEFINED_MCI_GENERATORS = {
return getUserRatio(client, UserProps.FileUlTotalBytes, UserProps.FileDlTotalBytes);
},
MS : function accountCreatedclient(client) {
MS : function accountCreated(client) {
return moment(client.user.properties[UserProps.AccountCreated]).format(client.currentTheme.helpers.getDateFormat());
},
PS : function userPostCount(client) { return userStatAsString(client, UserProps.MessagePostCount, 0); },
PS : function userPostCount(client) { return userStatAsCountString(client, UserProps.MessagePostCount, 0); },
PC : function userPostCallRatio(client) { return getUserRatio(client, UserProps.MessagePostCount, UserProps.LoginCount); },
MD : function currentMenuDescription(client) {
@@ -152,6 +163,19 @@ const PREDEFINED_MCI_GENERATORS = {
SH : function termHeight(client) { return client.term.termHeight.toString(); },
SW : function termWidth(client) { return client.term.termWidth.toString(); },
AC : function achievementCount(client) { return userStatAsCountString(client, UserProps.AchievementTotalCount, 0); },
AP : function achievementPoints(client) { return userStatAsCountString(client, UserProps.AchievementTotalPoints, 0); },
DR : function doorRuns(client) { return userStatAsCountString(client, UserProps.DoorRunTotalCount, 0); },
DM : function doorFriendlyRunTime(client) {
const minutes = client.user.properties[UserProps.DoorRunTotalMinutes] || 0;
return moment.duration(minutes, 'minutes').humanize();
},
TO : function friendlyTotalTimeOnSystem(client) {
const minutes = client.user.properties[UserProps.MinutesOnlineTotalCount] || 0;
return moment.duration(minutes, 'minutes').humanize();
},
//
// Date/Time
//
@@ -166,7 +190,7 @@ const PREDEFINED_MCI_GENERATORS = {
OS : function operatingSystem() {
return {
linux : 'Linux',
darwin : 'Mac OS X',
darwin : 'OS X',
win32 : 'Windows',
sunos : 'SunOS',
freebsd : 'FreeBSD',

View File

@@ -120,11 +120,20 @@ class StatLog {
//
// User specific stats
// These are simply convience methods to the user's properties
// These are simply convenience methods to the user's properties
//
setUserStat(user, statName, statValue, cb) {
setUserStatWithOptions(user, statName, statValue, options, cb) {
// note: cb is optional in PersistUserProperty
return user.persistProperty(statName, statValue, cb);
user.persistProperty(statName, statValue, cb);
if(!options.noEvent) {
const Events = require('./events.js'); // we need to late load currently
Events.emit(Events.getSystemEvents().UserStatSet, { user, statName, statValue } );
}
}
setUserStat(user, statName, statValue, cb) {
return this.setUserStatWithOptions(user, statName, statValue, {}, cb);
}
getUserStat(user, statName) {
@@ -138,18 +147,34 @@ class StatLog {
incrementUserStat(user, statName, incrementBy, cb) {
incrementBy = incrementBy || 1;
let newValue = parseInt(user.properties[statName]);
if(newValue) {
if(!_.isNumber(newValue)) {
return cb(new Error(`Value for ${statName} is not a number!`));
const oldValue = user.getPropertyAsNumber(statName) || 0;
const newValue = oldValue + incrementBy;
this.setUserStatWithOptions(
user,
statName,
newValue,
{ noEvent : true },
err => {
if(!err) {
const Events = require('./events.js'); // we need to late load currently
Events.emit(
Events.getSystemEvents().UserStatIncrement,
{
user,
statName,
oldValue,
statIncrementBy : incrementBy,
statValue : newValue
}
);
}
if(cb) {
return cb(err);
}
}
newValue += incrementBy;
} else {
newValue = incrementBy;
}
return this.setUserStat(user, statName, newValue, cb);
);
}
// the time "now" in the ISO format we use and love :)
@@ -344,29 +369,8 @@ class StatLog {
}
initUserEvents(cb) {
//
// We map some user events directly to user stat log entries such that they
// are persisted for a time.
//
const Events = require('./events.js');
const systemEvents = Events.getSystemEvents();
const interestedEvents = [
systemEvents.NewUser,
systemEvents.UserUpload, systemEvents.UserDownload,
systemEvents.UserPostMessage, systemEvents.UserSendMail,
systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg,
];
Events.addListenerMultipleEvents(interestedEvents, (eventName, event) => {
this.appendUserLogEntry(
event.user,
'system_event',
eventName.replace(/^codes\.l33t\.enigma\.system\./, ''), // strip package name prefix
90
);
});
const systemEventUserLogInit = require('./sys_event_user_log.js');
systemEventUserLogInit(this);
return cb(null);
}
}

View File

@@ -14,6 +14,7 @@ const {
// deps
const _ = require('lodash');
const moment = require('moment');
/*
String formatting HEAVILY inspired by David Chambers string-format library
@@ -281,6 +282,10 @@ const transformers = {
countWithAbbr : (n) => formatCount(n, true, 0),
countWithoutAbbr : (n) => formatCount(n, false, 0),
countAbbr : (n) => formatCountAbbr(n),
durationHours : (h) => moment.duration(h, 'hours').humanize(),
durationMinutes : (m) => moment.duration(m, 'minutes').humanize(),
durationSeconds : (s) => moment.duration(s, 'seconds').humanize(),
};
function transformValue(transformerName, value) {

View File

@@ -0,0 +1,73 @@
/* jslint node: true */
'use strict';
const Events = require('./events.js');
const LogNames = require('./user_log_name.js');
const DefaultKeepForDays = 365;
module.exports = function systemEventUserLogInit(statLog) {
const systemEvents = Events.getSystemEvents();
const interestedEvents = [
systemEvents.NewUser,
systemEvents.UserLogin, systemEvents.UserLogoff,
systemEvents.UserUpload, systemEvents.UserDownload,
systemEvents.UserPostMessage, systemEvents.UserSendMail,
systemEvents.UserRunDoor, systemEvents.UserSendNodeMsg,
systemEvents.UserAchievementEarned,
];
const append = (e, n, v) => {
statLog.appendUserLogEntry(e.user, n, v, DefaultKeepForDays);
};
Events.addMultipleEventListener(interestedEvents, (event, eventName) => {
const detailHandler = {
[ systemEvents.NewUser ] : (e) => {
append(e, LogNames.NewUser, 1);
},
[ systemEvents.UserLogin ] : (e) => {
append(e, LogNames.Login, 1);
},
[ systemEvents.UserLogoff ] : (e) => {
append(e, LogNames.Logoff, e.minutesOnline);
},
[ systemEvents.UserUpload ] : (e) => {
if(e.files.length) { // we can get here for dupe uploads
append(e, LogNames.UlFiles, e.files.length);
const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.meta.byte_size, 0);
append(e, LogNames.UlFileBytes, totalBytes);
}
},
[ systemEvents.UserDownload ] : (e) => {
if(e.files.length) {
append(e, LogNames.DlFiles, e.files.length);
const totalBytes = e.files.reduce( (bytes, fileEntry) => bytes + fileEntry.byteSize, 0);
append(e, LogNames.DlFileBytes, totalBytes);
}
},
[ systemEvents.UserPostMessage ] : (e) => {
append(e, LogNames.PostMessage, e.areaTag);
},
[ systemEvents.UserSendMail ] : (e) => {
append(e, LogNames.SendMail, 1);
},
[ systemEvents.UserRunDoor ] : (e) => {
append(e, LogNames.RunDoor, e.doorTag);
append(e, LogNames.RunDoorMinutes, e.runTimeMinutes);
},
[ systemEvents.UserSendNodeMsg ] : (e) => {
append(e, LogNames.SendNodeMsg, e.global ? 'global' : 'direct');
},
[ systemEvents.UserAchievementEarned ] : (e) => {
append(e, LogNames.AchievementEarned, e.achievementTag);
append(e, LogNames.AchievementPointsEarned, e.points);
}
}[eventName];
if(detailHandler) {
detailHandler(event);
}
});
};

View File

@@ -2,23 +2,26 @@
'use strict';
module.exports = {
ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount }
ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount }
TermDetected : 'codes.l33t.enigma.system.term_detected', // { client }
ClientConnected : 'codes.l33t.enigma.system.connected', // { client, connectionCount }
ClientDisconnected : 'codes.l33t.enigma.system.disconnected', // { client, connectionCount }
TermDetected : 'codes.l33t.enigma.system.term_detected', // { client }
ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId }
ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson)
MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson)
PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson)
ThemeChanged : 'codes.l33t.enigma.system.theme_changed', // (theme.hjson): { themeId }
ConfigChanged : 'codes.l33t.enigma.system.config_changed', // (config.hjson)
MenusChanged : 'codes.l33t.enigma.system.menus_changed', // (menu.hjson)
PromptsChanged : 'codes.l33t.enigma.system.prompts_changed', // (prompt.hjson)
// User - includes { user, ...}
NewUser : 'codes.l33t.enigma.system.user_new',
UserLogin : 'codes.l33t.enigma.system.user_login',
UserLogoff : 'codes.l33t.enigma.system.user_logoff',
UserUpload : 'codes.l33t.enigma.system.user_upload', // {..., files[ fileEntry, ...] }
UserDownload : 'codes.l33t.enigma.system.user_download', // {..., files[ fileEntry, ...] }
UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { areaTag }
UserSendMail : 'codes.l33t.enigma.system.user_send_mail',
UserRunDoor : 'codes.l33t.enigma.system.user_run_door',
UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg',
NewUser : 'codes.l33t.enigma.system.user_new', // { ... }
UserLogin : 'codes.l33t.enigma.system.user_login', // { ... }
UserLogoff : 'codes.l33t.enigma.system.user_logoff', // { ... }
UserUpload : 'codes.l33t.enigma.system.user_upload', // { ..., files[ fileEntry, ...] }
UserDownload : 'codes.l33t.enigma.system.user_download', // { ..., files[ fileEntry, ...] }
UserPostMessage : 'codes.l33t.enigma.system.user_post_msg', // { ..., areaTag }
UserSendMail : 'codes.l33t.enigma.system.user_send_mail', // { ... }
UserRunDoor : 'codes.l33t.enigma.system.user_run_door', // { ..., runTimeMinutes, doorTag|unknown }
UserSendNodeMsg : 'codes.l33t.enigma.system.user_send_node_msg', // { ..., global }
UserStatSet : 'codes.l33t.enigma.system.user_stat_set', // { ..., statName, statValue }
UserStatIncrement : 'codes.l33t.enigma.system.user_stat_increment', // { ..., statName, statIncrementBy, statValue }
UserAchievementEarned : 'codes.l33t.enigma.system.user_achievement_earned', // { ..., achievementTag, points }
};

View File

@@ -96,7 +96,7 @@ function loadTheme(themeId, cb) {
}
if(false === _.get(theme, 'info.enabled')) {
return cb(Errors.General('Theme is not enalbed', ErrorReasons.ErrNotEnabled));
return cb(Errors.General('Theme is not enabled', ErrorReasons.ErrNotEnabled));
}
refreshThemeHelpers(theme);
@@ -131,8 +131,9 @@ function getMergedTheme(menuConfig, promptConfig, theme) {
//
// Add in data we won't be altering directly from the theme
//
mergedTheme.info = theme.info;
mergedTheme.helpers = theme.helpers;
mergedTheme.info = theme.info;
mergedTheme.helpers = theme.helpers;
mergedTheme.achievements = _.get(theme, 'customization.achievements');
//
// merge customizer to disallow immutable MCI properties

235
core/top_x.js Normal file
View File

@@ -0,0 +1,235 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const { MenuModule } = require('./menu_module.js');
const UserProps = require('./user_property.js');
const UserLogNames = require('./user_log_name.js');
const { Errors } = require('./enig_error.js');
const UserDb = require('./database.js').dbs.user;
const SysDb = require('./database.js').dbs.system;
const User = require('./user.js');
// deps
const _ = require('lodash');
const async = require('async');
exports.moduleInfo = {
name : 'TopX',
desc : 'Displays users top X stats',
author : 'NuSkooler',
packageName : 'codes.l33t.enigma.topx',
};
const FormIds = {
menu : 0,
};
exports.getModule = class TopXModule extends MenuModule {
constructor(options) {
super(options);
this.config = Object.assign({}, _.get(options, 'menuConfig.config'), { extraArgs : options.extraArgs });
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
async.series(
[
(callback) => {
const userPropValues = _.values(UserProps);
const userLogValues = _.values(UserLogNames);
const hasMci = (c, t) => {
if(!Array.isArray(t)) {
t = [ t ];
}
return t.some(t => _.isObject(mciData, [ 'menu', `${t}${c}` ]));
};
return this.validateConfigFields(
{
mciMap : (key, config) => {
const mciCodes = Object.keys(config.mciMap).map(mci => {
return parseInt(mci);
}).filter(mci => !isNaN(mci));
if(0 === mciCodes.length) {
return false;
}
return mciCodes.every(mci => {
const o = config.mciMap[mci];
if(!_.isObject(o)) {
return false;
}
const type = o.type;
switch(type) {
case 'userProp' :
if(!userPropValues.includes(o.value)) {
return false;
}
// VM# must exist for this mci
if(!_.isObject(mciData, [ 'menu', `VM${mci}` ])) {
return false;
}
break;
case 'userEventLog' :
if(!userLogValues.includes(o.value)) {
return false;
}
// VM# must exist for this mci
if(!hasMci(mci, ['VM'])) {
return false;
}
break;
default :
return false;
}
return true;
});
}
},
callback
);
},
(callback) => {
return this.prepViewController('menu', FormIds.menu, mciData.menu, callback);
},
(callback) => {
async.forEachSeries(Object.keys(this.config.mciMap), (mciCode, nextMciCode) => {
return this.populateTopXList(mciCode, nextMciCode);
},
err => {
return callback(err);
});
}
],
err => {
return cb(err);
}
);
});
}
populateTopXList(mciCode, cb) {
const listView = this.viewControllers.menu.getView(mciCode);
if(!listView) {
return cb(Errors.UnexpectedState(`Failed to get view for MCI ${mciCode}`));
}
const type = this.config.mciMap[mciCode].type;
switch(type) {
case 'userProp' : return this.populateTopXUserProp(listView, mciCode, cb);
case 'userEventLog' : return this.populateTopXUserEventLog(listView, mciCode, cb);
// we should not hit here; validation happens up front
default : return cb(Errors.UnexpectedState(`Unexpected type: ${type}`));
}
}
rowsToItems(rows, cb) {
let position = 1;
async.mapSeries(rows, (row, nextRow) => {
this.loadUserInfo(row.user_id, (err, userInfo) => {
if(err) {
return nextRow(err);
}
return nextRow(null, Object.assign(userInfo, { position : position++, value : row.value }));
});
},
(err, items) => {
return cb(err, items);
});
}
populateTopXUserEventLog(listView, mciCode, cb) {
const mciMap = this.config.mciMap[mciCode];
const count = listView.dimens.height || 1;
const daysBack = mciMap.daysBack;
const shouldSum = _.get(mciMap, 'sum', true);
const valueSql = shouldSum ? 'SUM(CAST(log_value AS INTEGER))' : 'COUNT()';
const dateSql = daysBack ? `AND DATETIME(timestamp) >= DATETIME('now', '-${daysBack} days')` : '';
SysDb.all(
`SELECT user_id, ${valueSql} AS value
FROM user_event_log
WHERE log_name = ? ${dateSql}
GROUP BY user_id
ORDER BY value DESC
LIMIT ${count};`,
[ mciMap.value ],
(err, rows) => {
if(err) {
return cb(err);
}
this.rowsToItems(rows, (err, items) => {
if(err) {
return cb(err);
}
listView.setItems(items);
listView.redraw();
return cb(null);
});
}
);
}
populateTopXUserProp(listView, mciCode, cb) {
const count = listView.dimens.height || 1;
UserDb.all(
`SELECT user_id, CAST(prop_value AS INTEGER) AS value
FROM user_property
WHERE prop_name = ?
ORDER BY value DESC
LIMIT ${count};`,
[ this.config.mciMap[mciCode].value ],
(err, rows) => {
if(err) {
return cb(err);
}
this.rowsToItems(rows, (err, items) => {
if(err) {
return cb(err);
}
listView.setItems(items);
listView.redraw();
return cb(null);
});
}
);
}
loadUserInfo(userId, cb) {
const getPropOpts = {
names : [ UserProps.RealName, UserProps.Location, UserProps.Affiliations ]
};
const userInfo = { userId };
User.getUserName(userId, (err, userName) => {
if(err) {
return cb(err);
}
userInfo.userName = userName;
User.loadProperties(userId, getPropOpts, (err, props) => {
if(err) {
return cb(err);
}
userInfo.location = props[UserProps.Location] || '';
userInfo.affils = userInfo.affiliation = props[UserProps.Affiliations] || '';
userInfo.realName = props[UserProps.RealName] || '';
return cb(null, userInfo);
});
});
}
};

View File

@@ -73,6 +73,8 @@ exports.getModule = class UploadModule extends MenuModule {
constructor(options) {
super(options);
this.interrupt = MenuModule.InterruptTypes.Never;
if(_.has(options, 'lastMenuResult.recvFilePaths')) {
this.recvFilePaths = options.lastMenuResult.recvFilePaths;
}

View File

@@ -443,6 +443,22 @@ module.exports = class User {
);
}
setProperty(propName, propValue) {
this.properties[propName] = propValue;
}
incrementProperty(propName, incrementBy) {
incrementBy = incrementBy || 1;
let newValue = parseInt(this.getProperty(propName));
if(newValue) {
newValue += incrementBy;
} else {
newValue = incrementBy;
}
this.setProperty(propName, newValue);
return newValue;
}
getProperty(propName) {
return this.properties[propName];
}

View File

@@ -0,0 +1,102 @@
/* jslint node: true */
'use strict';
// ENiGMA½
const { MenuModule } = require('./menu_module.js');
const {
getAchievementsEarnedByUser
} = require('./achievement.js');
const UserProps = require('./user_property.js');
// deps
const async = require('async');
const _ = require('lodash');
exports.moduleInfo = {
name : 'User Achievements Earned',
desc : 'Lists achievements earned by a user',
author : 'NuSkooler',
};
const MciViewIds = {
achievementList : 1,
customRangeStart : 10, // updated @ index update
};
exports.getModule = class UserAchievementsEarned extends MenuModule {
constructor(options) {
super(options);
}
mciReady(mciData, cb) {
super.mciReady(mciData, err => {
if(err) {
return cb(err);
}
async.waterfall(
[
(callback) => {
this.prepViewController('achievements', 0, mciData.menu, err => {
return callback(err);
});
},
(callback) => {
return this.validateMCIByViewIds('achievements', MciViewIds.achievementList, callback);
},
(callback) => {
return getAchievementsEarnedByUser(this.client.user.userId, callback);
},
(achievementsEarned, callback) => {
this.achievementsEarned = achievementsEarned;
const achievementListView = this.viewControllers.achievements.getView(MciViewIds.achievementList);
achievementListView.on('index update', idx => {
this.selectionIndexUpdate(idx);
});
const dateTimeFormat = _.get(
this, 'menuConfig.config.dateTimeFormat', this.client.currentTheme.helpers.getDateFormat('short'));
achievementListView.setItems(achievementsEarned.map(achiev => Object.assign(
achiev,
this.getUserInfo(),
{
ts : achiev.timestamp.format(dateTimeFormat),
}
)));
achievementListView.redraw();
this.selectionIndexUpdate(0);
return callback(null);
}
],
err => {
return cb(err);
}
);
});
}
getUserInfo() {
// :TODO: allow args to pass in a different user - ie from user list -> press A for achievs, so on...
return {
userId : this.client.user.userId,
userName : this.client.user.username,
realName : this.client.user.getProperty(UserProps.RealName),
location : this.client.user.getProperty(UserProps.Location),
affils : this.client.user.getProperty(UserProps.Affiliations),
totalCount : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalCount),
totalPoints : this.client.user.getPropertyAsNumber(UserProps.AchievementTotalPoints),
};
}
selectionIndexUpdate(index) {
const achiev = this.achievementsEarned[index];
if(!achiev) {
return;
}
this.updateCustomViewTextsWithFilter('achievements', MciViewIds.customRangeStart, achiev);
}
};

View File

@@ -76,23 +76,33 @@ module.exports = class UserInterruptQueue
displayWithItem(interruptItem, cb) {
if(interruptItem.cls) {
this.client.term.rawWrite(ANSI.clearScreen());
this.client.term.rawWrite(ANSI.resetScreen());
} else {
this.client.term.rawWrite('\r\n\r\n');
}
const maybePauseAndFinish = () => {
if(interruptItem.pause) {
this.client.currentMenuModule.pausePrompt( () => {
return cb(null);
});
} else {
return cb(null);
}
};
if(interruptItem.contents) {
Art.display(this.client, interruptItem.contents, err => {
if(err) {
return cb(err);
}
//this.client.term.rawWrite('\r\n\r\n'); // :TODO: Prob optional based on contents vs text
this.client.currentMenuModule.pausePrompt( () => {
return cb(null);
});
maybePauseAndFinish();
});
} else {
return this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), cb);
this.client.term.write(pipeToAnsi(`${interruptItem.text}\r\n\r\n`, this.client), true, () => {
maybePauseAndFinish();
});
}
}
};

22
core/user_log_name.js Normal file
View File

@@ -0,0 +1,22 @@
/* jslint node: true */
'use strict';
//
// Common (but not all!) user log names
//
module.exports = {
NewUser : 'new_user',
Login : 'login',
Logoff : 'logoff',
UlFiles : 'ul_files', // value=count
UlFileBytes : 'ul_file_bytes', // value=total bytes
DlFiles : 'dl_files', // value=count
DlFileBytes : 'dl_file_bytes', // value=total bytes
PostMessage : 'post_msg', // value=areaTag
SendMail : 'send_mail',
RunDoor : 'run_door', // value=doorTag|unknown
RunDoorMinutes : 'run_door_minutes', // value=minutes ran
SendNodeMsg : 'send_node_msg', // value=global|direct
AchievementEarned : 'achievement_earned', // value=achievementTag
AchievementPointsEarned : 'achievement_pts_earned', // value=points earned
};

View File

@@ -49,5 +49,13 @@ module.exports = {
MessageConfTag : 'message_conf_tag',
MessageAreaTag : 'message_area_tag',
MessagePostCount : 'post_count',
DoorRunTotalCount : 'door_run_total_count',
DoorRunTotalMinutes : 'door_run_total_minutes',
AchievementTotalCount : 'achievement_total_count',
AchievementTotalPoints : 'achievement_total_points',
MinutesOnlineTotalCount : 'minutes_online_total_count',
};