Skip to content

Commit

Permalink
Add user scripting (w/ editor in http); bump to v0.7
Browse files Browse the repository at this point in the history
  • Loading branch information
edfletcher committed Jul 16, 2023
1 parent 2105af6 commit adf9e43
Show file tree
Hide file tree
Showing 19 changed files with 1,969 additions and 559 deletions.
10 changes: 8 additions & 2 deletions config/default.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,12 @@ const _config = {
plotBackupsAndGifGenerationEnabled: false,
mpmPlotOutputPath: path.join(HTTP_STATIC_DIR, MPM_PLOT_FILE_NAME),
mpmPlotTimeLimitHours: 24
}
},
userScriptsEnabledAtStartup: false
},

discord: {
userScriptOutputChannelId: '',
privMsgChannelStalenessTimeMinutes: 720,
privMsgChannelStalenessRemovalAlert: 0.1, // remaining of privMsgChannelStalenessTimeMinutes
privMsgCategoryId: null,
Expand Down Expand Up @@ -176,7 +178,11 @@ const _config = {
ttlSecs: 30 * 60,
staticDir: HTTP_STATIC_DIR,
attachmentsDir: HTTP_ATTACHMENTS_DIR,
rootRedirectUrl: 'https://discordrc.com'
rootRedirectUrl: 'https://discordrc.com',
editor: {
defaultTheme: 'vs-dark', // valid options are those in the "Theme" drop down in the editor
defaultFontSizePt: 14
}
},

ipinfo: {
Expand Down
44 changes: 35 additions & 9 deletions discord.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ client.once('ready', async () => {
console.debug('categoriesByName', categoriesByName);

if (config.irc.quitMsgChanId) {
const __sendToBotChan = async (s, raw = false) => {
const __sendToBotChan = async (s, raw = false, fromUserScript = false) => {
if (!s || (typeof s === 'string' && !s?.length)) {
console.error('sendToBotChan called without message!', s, raw);
return;
Expand Down Expand Up @@ -338,7 +338,12 @@ client.once('ready', async () => {
}

toSend = formatForAllowedSpeakerResponse(s, raw);
await client.channels.cache.get(config.irc.quitMsgChanId).send(toSend);
let chanId = config.irc.quitMsgChanId;
if (fromUserScript && config.discord.userScriptOutputChannelId) {
chanId = config.discord.userScriptOutputChannelId;
}

await client.channels.cache.get(chanId).send(toSend);
} catch (e) {
console.error('sendToBotChan .send() threw!', e);
console.debug(s);
Expand All @@ -350,18 +355,18 @@ client.once('ready', async () => {
return (truncTail ? sendToBotChan(truncTail, raw) : toSend);
};

// serialize bot-chan sends to a cadence of 1Hz, to avoid rate limits
// serialize bot-chan sends to a cadence of 2Hz, to avoid rate limits
const stbcQueue = [];
const stbcServicer = async () => {
if (stbcQueue.length > 0) {
const [s, raw] = stbcQueue.shift();
await __sendToBotChan(s, raw);
const [s, raw, fUC] = stbcQueue.shift();
await __sendToBotChan(s, raw, fUC);
}

setTimeout(stbcServicer, 1000);
setTimeout(stbcServicer, 500);
};

sendToBotChan = async (s, raw = false) => stbcQueue.push([s, raw]);
sendToBotChan = async (s, raw = false, fromUserCommand = false) => stbcQueue.push([s, raw, fromUserCommand]);

stbcServicer();
}
Expand Down Expand Up @@ -964,7 +969,7 @@ client.once('ready', async () => {
}
};

mainSubClient.on('message', ipcMessageHandler.bind(null, {
const mainContext = {
pendingAliveChecks,
allowedSpeakersAvatars,
stats,
Expand All @@ -984,7 +989,26 @@ client.once('ready', async () => {
allowedSpeakerCommandHandler,
isReconnect,
setIsReconnect: (s) => (_isReconnect = s)
}));
};
mainSubClient.on('message', ipcMessageHandler.bind(null, mainContext));

const userScriptsSubClient = new Redis(config.redis.url);
await userScriptsSubClient.psubscribe('*');
userScriptsSubClient.on('pmessage', async (_pattern, channel, msgJson) => {
let msg = {
type: channel,
data: msgJson
};

try {
msg = JSON.parse(msgJson);
} catch (err) {
console.error(`Failed to parse message data as json for user script channel=${channel}, msgJson:\n${msgJson}`);
console.error(err);
}

await userCommands('scripts').runScriptsForEvent(mainContext, msg.type, msg.data, channel);
});

try {
redisClient.publish(PREFIX, JSON.stringify({
Expand Down Expand Up @@ -1097,6 +1121,8 @@ async function main () {
setTimeout(process.exit, 2000);
});

await userCommands.init();

registerContextMenus();
client.login(token);
}
Expand Down
30 changes: 20 additions & 10 deletions discord/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@ function formatKVsWithOpts (obj, opts) {
const typeFmt = (v, k) => {
switch (typeof v) {
case 'object':
return ['...'];
return ['⤵️\n' + formatKVsWithOpts(v, {
...opts,
recLevel: (opts?.recLevel ?? 0) + 1,
recMaxPropLen: Math.max(maxPropLen, (opts?.recMaxPropLen ?? 0))
}), null, true];

case 'boolean':
return [v ? ':white_check_mark:' : ':x:'];
Expand All @@ -139,8 +143,9 @@ function formatKVsWithOpts (obj, opts) {
};

const vFmt = (v, k) => {
const [primary, secondary] = typeFmt(v, k);
return `**${primary}**${secondary ? ` (_${secondary}_)` : ''}`;
const [primary, secondary, noFormat] = typeFmt(v, k);
const pFmt = noFormat ? '' : '**';
return `${pFmt}${primary}${pFmt}${secondary ? ` (_${secondary}_)` : ''}`;
};

const sorter = (a, b) => {
Expand All @@ -155,13 +160,18 @@ function formatKVsWithOpts (obj, opts) {
return a[0].localeCompare(b[0]);
};

const maxPropLen = Object.keys(obj).reduce((a, k) => a > k.length ? a : k.length, 0) + 1;
return Object.entries(obj).sort(sorter)
.filter(([, v]) => typeof (v) !== 'function')
.map(([k, v]) =>
`${nameBoundary}${k.padStart(maxPropLen, ' ')}${nameBoundary}` +
`${delim}${vFmt(v, k)}`
).join('\n');
const maxPropLen = Math.max(
obj ? Object.keys(obj).reduce((a, k) => a > k.length ? a : k.length, 0) + 1 : 0,
(opts?.recMaxPropLen ?? 0)
);
return !obj
? 'null'
: Object.entries(obj).sort(sorter)
.filter(([, v]) => typeof (v) !== 'function')
.map(([k, v]) =>
`${nameBoundary}${'→'.repeat(opts?.recLevel ?? 0)}${k.padStart(maxPropLen - (opts?.recLevel ?? 0), ' ')}${nameBoundary}` +
`${delim}${vFmt(v, k, maxPropLen)}`
).join('\n');
}

module.exports = {
Expand Down
3 changes: 2 additions & 1 deletion discord/lib/serveMessages.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ async function serveMessages (context, data, opts = {}) {
context.sendToBotChan({ embeds: [embed] }, true);
}

async function servePage (context, data, renderType, callback) {
async function servePage (context, data, renderType, callback, allowPut = false) {
if (!context || !data || !renderType) {
throw new Error('not enough args');
}
Expand Down Expand Up @@ -126,6 +126,7 @@ async function servePage (context, data, renderType, callback) {
data: {
name,
renderType,
allowPut,
options
}
});
Expand Down
50 changes: 50 additions & 0 deletions discord/lib/vm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const vm = require('vm');
const config = require('config');
const { fetch } = require('undici');
const logger = require('../../logger')('discord');
const { PREFIX, scopedRedisClient } = require('../../util');

const AllowedGlobals = ['Object', 'Function', 'Array', 'Number', 'parseFloat', 'parseInt', 'Infinity', 'NaN', 'undefined',
'Boolean', 'String', 'Symbol', 'Date', 'Promise', 'RegExp', 'Error', 'AggregateError', 'EvalError', 'RangeError',
'ReferenceError', 'SyntaxError', 'TypeError', 'URIError', 'globalThis', 'JSON', 'Math', 'Intl', 'ArrayBuffer',
'Uint8Array', 'Int8Array', 'Uint16Array', 'Int16Array', 'Uint32Array', 'Int32Array', 'Float32Array', 'Float64Array',
'Uint8ClampedArray', 'BigUint64Array', 'BigInt64Array', 'DataView', 'Map', 'BigInt', 'Set', 'WeakMap', 'WeakSet',
'Proxy', 'Reflect', 'FinalizationRegistry', 'WeakRef', 'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent',
'escape', 'unescape', 'isFinite', 'isNaN', 'Buffer', 'atob', 'btoa', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder',
'clearInterval', 'clearTimeout', 'setInterval', 'setTimeout', 'queueMicrotask', 'performance', 'clearImmediate', 'setImmediate',
'SharedArrayBuffer', 'Atomics', 'buffer', 'constants', 'crypto', 'dgram', 'dns', 'domain', 'fs', 'http', 'http2', 'https',
'net', 'os', 'path', 'querystring', 'readline', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'zlib', 'util']
.reduce((a, x) => ({ [x]: global[x], ...a }), {});

async function runStringInContext (runStr, context) {
let wasAwaited = false;
let result = vm.runInNewContext(runStr, {
...context,
...AllowedGlobals,
logger,
config,
PREFIX,
common: require('../common'),
util: require('../../util'),
fetch,
scopedRedisClient,
src: scopedRedisClient,
stbc: context.sendToBotChan,
sendToBotChan: context.sendToBotChan,
console
});

if (result instanceof Promise) {
result = await result;
wasAwaited = true;
}

return {
result,
wasAwaited
};
}

module.exports = { runStringInContext };
17 changes: 17 additions & 0 deletions discord/userCommands.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,21 @@ resolver.__functions = {
}
};

resolver.init = async function () {
return Promise.all(Object.entries(resolver.__functions).map(async ([name, f]) => {
if (f.__init) {
try {
await f.__init();
console.info(`userCommand "${name}" initialized`);
} catch (e) {
console.error(`userCommand "${name}" __init failed!`, e);
} finally {
// only let it run once with the (usually correct) assumption that
// it will continue to fail if called repeatedly
delete f.__init();
}
}
}));
};

module.exports = resolver;
7 changes: 6 additions & 1 deletion discord/userCommands/alpaca.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,12 @@ async function f (context, ...a) {
}
context.sendToBotChan(respStr);
}
})); // eslint-disable-line camelcase
})
.catch((err) => {
console.error('Alpaca failed: ', err);
context.sendToBotChan(`Alpaca request failed: ${err.message}`);
})
); // eslint-disable-line camelcase

return `🦙 Sending your prompt to ${activeEps.length} models. They may take awhile to respond: when they do, the responses will be posted here.`;
}
Expand Down
71 changes: 30 additions & 41 deletions discord/userCommands/js.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,19 @@
'use strict';

const vm = require('vm');
const vm = require('../lib/vm');
const config = require('config');
const logger = require('../../logger')('discord');
const { PREFIX, scopedRedisClient } = require('../../util');
const { MessageEmbed } = require('discord.js');
const { servePage } = require('../common');

const RKEY = `${PREFIX}:jssaved`;

const AllowedGlobals = ['Object', 'Function', 'Array', 'Number', 'parseFloat', 'parseInt', 'Infinity', 'NaN', 'undefined',
'Boolean', 'String', 'Symbol', 'Date', 'Promise', 'RegExp', 'Error', 'AggregateError', 'EvalError', 'RangeError',
'ReferenceError', 'SyntaxError', 'TypeError', 'URIError', 'globalThis', 'JSON', 'Math', 'Intl', 'ArrayBuffer',
'Uint8Array', 'Int8Array', 'Uint16Array', 'Int16Array', 'Uint32Array', 'Int32Array', 'Float32Array', 'Float64Array',
'Uint8ClampedArray', 'BigUint64Array', 'BigInt64Array', 'DataView', 'Map', 'BigInt', 'Set', 'WeakMap', 'WeakSet',
'Proxy', 'Reflect', 'FinalizationRegistry', 'WeakRef', 'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent',
'escape', 'unescape', 'isFinite', 'isNaN', 'Buffer', 'atob', 'btoa', 'URL', 'URLSearchParams', 'TextEncoder', 'TextDecoder',
'clearInterval', 'clearTimeout', 'setInterval', 'setTimeout', 'queueMicrotask', 'performance', 'clearImmediate', 'setImmediate',
'SharedArrayBuffer', 'Atomics', 'buffer', 'constants', 'crypto', 'dgram', 'dns', 'domain', 'fs', 'http', 'http2', 'https',
'net', 'os', 'path', 'querystring', 'readline', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'zlib', 'util']
.reduce((a, x) => ({ [x]: global[x], ...a }), {});

async function _run (context, runStr) {
try {
console.log(`runStr> ${runStr}`);
let res = vm.runInNewContext(runStr, {
...context,
...AllowedGlobals,
logger,
config,
PREFIX,
common: require('../common'),
util: require('../../util'),
src: scopedRedisClient,
stbc: context.sendToBotChan,
console: {
debug: context.sendToBotChan,
log: context.sendToBotChan,
warn: context.sendToBotChan,
error: context.sendToBotChan
}
});

let wasAwaited = false;
if (res instanceof Promise) {
res = await res;
wasAwaited = true;
}

console.log(`res ${wasAwaited ? '(awaited)' : ''}> ${res}`);
context.sendToBotChan('```\n' + JSON.stringify(res, null, 2) + '\n```\n');
const { result, wasAwaited } = await vm.runStringInContext(runStr, context);
console.log(`res ${wasAwaited ? '(awaited)' : ''}> ${result}`);
context.sendToBotChan('```\n' + JSON.stringify(result, null, 2) + '\n```\n');
} catch (e) {
console.error('runInNewContext threw>', e);
context.sendToBotChan('Compilation failed:\n\n```\n' + e.message + '\n' + e.stack + '\n```');
Expand Down Expand Up @@ -87,12 +53,34 @@ async function delSnippet (context, ...a) {
return scopedRedisClient((r) => r.hdel(RKEY, name));
}

async function edit (context, ...a) {
const [name] = a;
const snippetText = await scopedRedisClient((r) => r.hget(RKEY, name));

if (!snippetText) {
return 'Nothing found';
}

const pageName = await servePage(context, {
snippetText: snippetText.trim(),
snippetTextBase64: Buffer.from(snippetText.trim(), 'utf8').toString('base64'),
name,
keyComponent: 'jssaved'
}, 'editor', null, true);
const embed = new MessageEmbed()
.setColor(config.app.stats.embedColors.main)
.setTitle(`Click here to edit "${name}"...`)
.setURL(`https://${config.http.fqdn}/${pageName}`);
await context.sendToBotChan({ embeds: [embed] }, true);
}

const subCommands = {
exec: run,
save: saveSnippet,
list: listSnippets,
run: runSnippet,
del: delSnippet,
edit,
get: async function (context, ...a) {
const name = a.shift();
return '```javascript\n' + await scopedRedisClient((r) => r.hget(RKEY, name)) + '\n```';
Expand All @@ -115,7 +103,8 @@ const helpText = {
list: 'List all saved snippets',
run: 'Run a snippet saved with "name" (first arugment)',
del: 'Delete the snippet named "name" (first arugment)',
get: 'Get a saved snippet named "name" (first arugment)\'s source without executing it'
get: 'Get a saved snippet named "name" (first arugment)\'s source without executing it',
edit: 'Edit a snippet or user script'
};

f.__drcHelp = () => ({
Expand Down
6 changes: 5 additions & 1 deletion discord/userCommands/reload.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ module.exports = function (context, ...a) {
context.sendToBotChan('Reloading user commands...');
try {
require('../userCommands').__unresolve();
context.sendToBotChan(`Reloaded ${Object.keys(require('../userCommands').__functions).length} user commands`);
require('../userCommands').init()
.then(() =>
context.sendToBotChan(`Reloaded ${Object.keys(require('../userCommands').__functions).length} user commands`)
)
.catch((e) => console.error('userCommands init failed', e));
} catch (e) {
console.error('Reload failed!', e);
context.sendToBotChan(`Reload failed! ${e}\n\n` + '```\n' + e.stack + '\n```\n');
Expand Down
Loading

0 comments on commit adf9e43

Please sign in to comment.