Skip to content

Commit 195d947

Browse files
committed
Yet another big 'ol grab bag of changes, details below.
Additions: * User command history tracking and querying (`ucHistory`) * Count total users seen upon connect Improvements: * `help` command output, more help for individual commands * Channel search outputs link to proper Discord channels when available * Discord bot messaging improvements * Improve default /whois output * Start moving command implementations out of userCommands.js * `mode` command output improved (& fixed), especially for quiet lists Fixes: * Channel transform persistence mess has been cleaned up * Resolve issue #3: PM chan expiry * Resolve issue #19: Default command character sentinel changed and made configurable (app.allowedSpeakersCommandPrefixCharacter) * "Is user in channel?" check deferred to allow longer query time * Simplify live nick tracking and fallback to full query to eliminate false results
1 parent 695385d commit 195d947

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1021
-450
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ COPY discord ./discord/
4040
COPY irc/numerics.js ./irc/
4141
# zork is packaged in a snap, which does not run in containers :(
4242
RUN apt -y install bc gnuplot colossal-cave-adventure imagemagick-6.q16
43+
ENV PATH=/usr/games:$PATH
4344
USER drc
4445
CMD ["node", "discord"]

config/default.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ const _config = {
7575
// 'replace' (replace IRC nick with Discord ASers)
7676
// 'bracket' (turns into: IRCNick[DiscordASers])
7777
allowedSpeakersHighlightType: 'replace',
78+
allowedSpeakersCommandPrefixCharacter: ';',
7879
timeout: 30,
7980
statsTopChannelCount: 10,
8081
statsMaxNumQuits: 50,
@@ -155,7 +156,7 @@ const _config = {
155156
},
156157

157158
nmap: {
158-
defaultOptions: ['-v', '-Pn', '-O', '--traceroute']
159+
defaultOptions: ['-v', '-Pn']
159160
},
160161

161162
shodan: {

discord.js

Lines changed: 116 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,23 @@ const yargs = require('yargs');
99
const { fetch } = require('undici');
1010
const ipcMessageHandler = require('./discord/ipcMessage');
1111
const { banner, setDefaultFont } = require('./discord/figletBanners');
12-
const { PREFIX, replaceIrcEscapes, PrivmsgMappings, NetworkNotMatchedError, UserCommandNotFound, scopedRedisClient } = require('./util');
1312
const userCommands = require('./discord/userCommands');
1413
const { formatKVs, aliveKey, ticklePmChanExpiry } = require('./discord/common');
1514
const { plotMpmData } = require('./discord/plotting');
1615
const eventHandlers = require('./discord/events');
1716
const registerContextMenus = require('./discord/contextMenus');
1817
const parsers = require('./lib/parsers');
18+
const UCHistory = require('./discord/userCommandHistory');
19+
const {
20+
PREFIX,
21+
replaceIrcEscapes,
22+
PrivmsgMappings,
23+
NetworkNotMatchedError,
24+
AmbiguousMatchResultError,
25+
UserCommandNotFound,
26+
scopedRedisClient,
27+
fmtDuration
28+
} = require('./util');
1929

2030
require('./logger')('discord');
2131

@@ -198,7 +208,11 @@ async function alivenessCheck () {
198208
delete pendingAliveChecks[nick];
199209
}, 30 * 1000);
200210

201-
await msg(ctx, network, nick, ...messageComps);
211+
try {
212+
await msg(ctx, network, nick, ...messageComps);
213+
} catch (e) {
214+
console.error(`Aliveness check for ${network}/${nick} threw!`, e);
215+
}
202216
}
203217
}
204218

@@ -366,36 +380,41 @@ client.once('ready', async () => {
366380

367381
siteCheck();
368382

383+
const getDiscordChannelById = (id) => client.channels.cache.get(id);
369384
let allowedSpeakerCommandHandler = () => {
370385
throw new Error('allowedSpeakerCommandHandler not initialized! not configured?');
371386
};
372387

373388
if (config.app.allowedSpeakers.length) {
374-
allowedSpeakerCommandHandler = async (data, toChanId) => {
389+
allowedSpeakerCommandHandler = async (data, toChanId, {
390+
autoPrefixCurrentCommandChar = false
391+
} = {}) => {
375392
const trimContent = data.content.replace(/^\s+/, '');
376-
if (trimContent[0] !== '!') {
393+
394+
if (!autoPrefixCurrentCommandChar && trimContent[0] !== config.app.allowedSpeakersCommandPrefixCharacter) {
377395
return;
378396
}
379397

380398
const pipedHandler = parsers.parseMessageStringForPipes(trimContent, (content) =>
381-
allowedSpeakerCommandHandler(Object.assign({}, data, { content }), toChanId));
399+
allowedSpeakerCommandHandler(Object.assign({}, data, { content }), toChanId, { autoPrefixCurrentCommandChar }));
382400

383401
if (pipedHandler) {
384402
return pipedHandler();
385403
}
386404

387-
let { command, args } = parsers.parseCommandAndArgs(trimContent);
405+
let { command, args } = parsers.parseCommandAndArgs(trimContent, { autoPrefixCurrentCommandChar });
406+
console.log(trimContent, '-> user command parsed ->', { command, args });
388407
args = parsers.parseArgsForQuotes(args);
408+
console.debug('args parsed for quotes', args);
389409

390410
const fmtedCmdStr = '`' + `${command} ${args.join(' ')}` + '`';
391-
console.log(trimContent, 'USER CMD PARSED', command, fmtedCmdStr, args);
392411
const redis = new Redis(config.redis.url);
393412

394413
const zEvents = [];
395414
let resolvedName;
396415
try {
397416
const cmdFunc = userCommands(command);
398-
resolvedName = cmdFunc?.__resolvedFullCommandName;
417+
resolvedName = cmdFunc?.__resolvedFullCommandName ?? command;
399418

400419
// this should be removed ASAP in favor of scopedRedisClient,
401420
// but need to find all uses of it first...
@@ -444,7 +463,7 @@ client.once('ready', async () => {
444463
throw new UserCommandNotFound();
445464
}
446465

447-
const result = await cmdFunc({
466+
const context = {
448467
stats,
449468
redis,
450469
publish, // TODO: replace all uses of `redis` with `publish` (and others if needed)
@@ -462,11 +481,18 @@ client.once('ready', async () => {
462481
channelsById,
463482
categoriesByName,
464483
toChanId,
465-
getDiscordChannelById: (id) => client.channels.cache.get(id),
484+
getDiscordChannelById,
466485
discordMessage: data
467-
}, ...args);
486+
};
487+
488+
if (argObj.help || argObj.h) {
489+
context.options._ = [resolvedName];
490+
zEvents.push('commandSuccess');
491+
return userCommands('help')(context);
492+
}
468493

469-
console.log(`Exec'ed user command ${command} with args [${args.join(', ')}]`, argObj, '-->', result);
494+
const result = await cmdFunc(context, ...args);
495+
console.log(`Executed user command "${command}" (${resolvedName}) with result -->\n`, result);
470496

471497
let toBotChan;
472498
if (result && result.__drcFormatter) {
@@ -494,19 +520,40 @@ client.once('ready', async () => {
494520
if (ucErr instanceof NetworkNotMatchedError) {
495521
sendToBotChan(`Unable to find a matching network for "${ucErr.message}"`);
496522
zEvents.push('netNotMatched');
523+
} else if (ucErr instanceof AmbiguousMatchResultError) {
524+
sendToBotChan({
525+
embeds: [
526+
new MessageEmbed()
527+
.setTitle(`Ambiguous command name "\`${fmtedCmdStr.trim()}\`"`)
528+
.setColor('RED')
529+
.setDescription(ucErr.message)
530+
]
531+
}, true);
497532
} else {
498533
sendToBotChan(fmtedCmdStr + `threw an error! (${ucErr.name}):` +
499534
' `' + ucErr.message + '`');
500535
zEvents.push('otherError');
501536
}
502537
} finally {
503538
redis.disconnect();
539+
540+
const chan = await getDiscordChannelById(data.channelId);
541+
const { hashKey } = await UCHistory.push(trimContent, {
542+
zEvents,
543+
sentBy: data.author?.tag ?? '<system>',
544+
sentIn: {
545+
channel: chan?.name ?? '<system>',
546+
guild: chan?.guild?.name ?? '<system>'
547+
}
548+
});
549+
504550
scopedRedisClient(async (client, prefix) => {
505551
const keyArr = [prefix, 'userCommandCalls'];
506552
const zNewCounts = await Promise.all(
507553
zEvents.map((evName) => client.zincrby([...keyArr, evName].join(':'), '1', resolvedName ?? command))
508554
);
509-
console.log('Logging UC zEvents!', command, 'is', resolvedName, '||', zEvents, '->>', zNewCounts);
555+
console.log(`User command logged as ${hashKey}:`,
556+
command, 'is', resolvedName, '/ events:', zEvents, '->>', zNewCounts);
510557
});
511558
}
512559
};
@@ -549,13 +596,29 @@ client.once('ready', async () => {
549596
const uCfg = await scopedRedisClient(async (redis) => userCommands('config')({ redis }, 'load'));
550597

551598
if (uCfg.error) {
552-
sendToBotChan(`\nReloading user configuration failed:\n\n**${uCfg.error.message}**\n`);
553-
sendToBotChan('\nUsing default user configuration:\n\n' + formatKVs(config.user));
599+
sendToBotChan({
600+
embeds: [
601+
new MessageEmbed()
602+
.setTitle('Reloading configuration failed: using defaults!')
603+
.setDescription(formatKVs(config.user)).setColor('RED')
604+
]
605+
}, true);
554606
console.warn('Reloading user config failed', uCfg.error);
555607
} else {
556-
sendToBotChan('\nUser configuration:\n\n' + formatKVs(uCfg));
608+
sendToBotChan({
609+
embeds: [
610+
new MessageEmbed().setTitle('User configuration').setDescription(formatKVs(uCfg)).setColor('AQUA')
611+
]
612+
}, true);
557613
}
558614

615+
sendToBotChan({
616+
embeds: [
617+
new MessageEmbed().setTitle(config.app.allowedSpeakersCommandPrefixCharacter)
618+
.setDescription('is the user command prefix character.').setColor('ORANGE')
619+
]
620+
}, true);
621+
559622
console.log('Discovered private messaging category:', config.discord.privMsgCategoryId, channelsById[config.discord.privMsgCategoryId]);
560623
if (!config.discord.privMsgCategoryId || !channelsById[config.discord.privMsgCategoryId]) {
561624
const potentials = Object.keys(categoriesByName).filter(x => x.match(/priv(?:ate)?\s*me?s(?:sa)?ge?s?/ig) || x === 'PMs');
@@ -592,10 +655,25 @@ client.once('ready', async () => {
592655
const realDel = await scopedRedisClient(async (aliveClient) => {
593656
const realDel = [];
594657
for (const [chanId, o] of toDel) {
595-
const network = PrivmsgMappings.findNetworkForKey(chanId);
658+
const network = await PrivmsgMappings.findNetworkForKey(chanId);
659+
if (!network) {
660+
console.warn(`No network found for PM channel ${chanId}! Removing.`);
661+
realDel.push([chanId, o]);
662+
continue;
663+
}
664+
665+
const aKey = aliveKey(network, chanId);
666+
const pmChanTTL = await aliveClient.ttl(aKey);
667+
if (pmChanTTL === -1) {
668+
continue;
669+
}
670+
596671
// only delete channels that have expired in Redis (assuming here that we've missed the keyspace notification for some reason)
597-
if (!(await aliveClient.get(aliveKey(network, chanId)))) {
672+
if (!(await aliveClient.get(aKey))) {
598673
realDel.push([chanId, o]);
674+
} else {
675+
const chanObj = await PrivmsgMappings.get(network, chanId);
676+
console.info(`PM channel for ${chanObj.target} on ${network} (${chanId}) still has ${fmtDuration(0, true, pmChanTTL * 1000)} to live.`);
599677
}
600678
}
601679
return realDel;
@@ -618,9 +696,14 @@ client.once('ready', async () => {
618696

619697
const privMsgExpiryListener = new Redis(config.redis.url);
620698
privMsgExpiryListener.on('pmessage', async (_chan, key, event) => {
621-
console.log('EXPIRY MSG', key, event);
699+
console.log('Expiry message', key, event);
622700
const [, prefix, type, trackingType, id, network] = key.split(':');
623701

702+
if (!(await client.channels.cache.get(id))) {
703+
console.error(`Expiry message for unknown channel ID ${id}!`, prefix, type, trackingType, network);
704+
return;
705+
}
706+
624707
if (prefix !== PREFIX) {
625708
stats.errors++;
626709
console.error(`bad prefix for keyspace notification! ${prefix}`, key, event);
@@ -631,9 +714,9 @@ client.once('ready', async () => {
631714
if (type === 'pmchan') {
632715
if (trackingType === 'aliveness') {
633716
console.log(`PM channel ${id}:${network} expired! Removing...`);
634-
const chInfo = Object.entries(PrivmsgMappings.forNetwork(network)).find(([chId]) => chId == id)?.[1]; // eslint-disable-line eqeqeq
717+
const chInfo = Object.entries(await PrivmsgMappings.forNetwork(network)).find(([chId]) => chId == id)?.[1]; // eslint-disable-line eqeqeq
635718
if (!chInfo || !chInfo.target || !channelsById[id]) {
636-
console.error('bad chinfo?!', key, event, chInfo, channelsById[id], PrivmsgMappings.forNetwork(network));
719+
console.error('bad chinfo?!', key, event, chInfo, channelsById[id], await PrivmsgMappings.forNetwork(network));
637720
return;
638721
}
639722

@@ -643,13 +726,14 @@ client.once('ready', async () => {
643726
}
644727

645728
const toTime = Number(new Date());
646-
const queryArgs = [network, 'get', chInfo.target, `--from=${chInfo.created}`, `--to=${toTime}`];
729+
const queryArgs = [network, chInfo.target, `--from=${chInfo.created}`, `--to=${toTime}`, '--everything'];
647730

648731
const rmEmbed = new MessageEmbed()
649732
.setColor(config.app.stats.embedColors.irc.privMsg)
650733
.setTitle('Private Message channel cleanup')
651734
.setDescription('I removed the following channel due to inactivity:')
652-
.addField(channelsById[id].name, 'Query logs for this session with:\n`' + `!logs ${queryArgs.join(' ')}` + '`');
735+
.addField(channelsById[id].name, 'Query logs for this session with:\n`' +
736+
config.app.allowedSpeakersCommandPrefixCharacter + `logs ${queryArgs.join(' ')}` + '`');
653737

654738
const buttonId = [id, crypto.randomBytes(8).toString('hex')].join('-');
655739
const actRow = new MessageActionRow()
@@ -664,27 +748,30 @@ client.once('ready', async () => {
664748
sendToBotChan,
665749
channelsById,
666750
network,
751+
categoriesByName,
667752
publish: eventHandlerContext.publish,
668753
argObj: {
669754
_: queryArgs
670755
},
671756
options: {
672757
from: chInfo.created,
673-
to: toTime
758+
to: toTime,
759+
everything: true,
760+
_: queryArgs.slice(0, 2)
674761
}
675762
}, ...queryArgs);
676763

677764
interaction.update({ embeds: [rmEmbed], components: [] });
678765
sendToBotChan(logs);
679766
});
680767

681-
sendToBotChan(`:arrow_down: :rotating_light: :mega: ${allowedSpeakersMentionString(['', ''])}`);
682768
client.channels.cache.get(config.irc.quitMsgChanId).send({
683769
embeds: [new MessageEmbed()
684770
.setColor(config.app.stats.embedColors.irc.privMsg)
685771
.setTitle('Private Message channel cleanup')
686772
.setDescription('I removed the following channel due to inactivity:')
687-
.addField(channelsById[id].name, 'Query logs for this session with the button below or:\n`' + `!logs ${queryArgs.join(' ')}` + '`')],
773+
.addField(channelsById[id].name, 'Query logs for this session with the button below or:\n`' +
774+
config.app.allowedSpeakersCommandPrefixCharacter + `logs ${queryArgs.join(' ')}` + '`')],
688775
components: [actRow]
689776
});
690777

@@ -722,7 +809,7 @@ client.once('ready', async () => {
722809
});
723810
} catch (err) {
724811
stats.errors++;
725-
console.error('removalWarning KS notification threw', err);
812+
console.error(`removalWarning KS notification threw (chan ${id})`, err);
726813
}
727814
}
728815
}
@@ -785,7 +872,7 @@ client.once('ready', async () => {
785872

786873
for (const connectCmd of onConnectCmds) {
787874
console.log(await sendToBotChan(`Running connect command for \`${network}\`: \`${connectCmd}\``));
788-
await allowedSpeakerCommandHandler({ content: connectCmd });
875+
await allowedSpeakerCommandHandler({ content: connectCmd }, null, { autoPrefixCurrentCommandChar: true });
789876
}
790877
}
791878
});
@@ -821,7 +908,7 @@ client.once('ready', async () => {
821908
console.log('Re-connected!');
822909
}
823910

824-
const HB_FUDGE_FACTOR = 1.01;
911+
const HB_FUDGE_FACTOR = 1.05;
825912
let ircLastHeartbeat = Number(new Date());
826913
ircHeartbeatListener = new Redis(config.redis.url);
827914
ircHeartbeatListener.on('message', (...a) => {
@@ -902,7 +989,6 @@ client.once('ready', async () => {
902989
}));
903990

904991
console.log('Waiting for irc:ready...');
905-
sendToBotChan('Waiting for IRC bridge...');
906992
const readyRes = await ircReady.promise;
907993
_isReconnect = readyRes?.isReconnect;
908994

0 commit comments

Comments
 (0)