Adding Multi User & Role Tipping and Help/Warning Optimisation #13
4 changed files with 311 additions and 65 deletions
|
@ -2,18 +2,15 @@
|
|||
|
||||
const bitcoin = require('bitcoin');
|
||||
let config = require('config');
|
||||
let spamchannel = config.get('sandboxchannel');
|
||||
let regex = require('regex');
|
||||
let lbrycrdConfig = config.get('lbrycrd');
|
||||
let sandboxChannel = config.get('sandboxchannel');
|
||||
const lbry = new bitcoin.Client(lbrycrdConfig);
|
||||
|
||||
exports.commands = ['tip'];
|
||||
exports.commands = ['tip', 'multitip', 'roletip', 'tipcommands'];
|
||||
exports.tip = {
|
||||
usage: '<subcommand>',
|
||||
description: `balance: get your balance
|
||||
deposit: get address for your deposits
|
||||
withdraw ADDRESS AMOUNT: withdraw AMOUNT credits to ADDRESS
|
||||
[private] <user> <amount>: mention a user with @ and then the amount to tip them, or put private before the user to tip them privately.
|
||||
Key: [] : Optionally include contained keyword, <> : Replace with appropriate value.`,
|
||||
description: 'Tip a given user with an amount of LBC or perform wallet specific operations.',
|
||||
process: async function(bot, msg, suffix) {
|
||||
let tipper = msg.author.id.replace('!', ''),
|
||||
words = msg.content
|
||||
|
@ -22,30 +19,151 @@ exports.tip = {
|
|||
.filter(function(n) {
|
||||
return n !== '';
|
||||
}),
|
||||
subcommand = words.length >= 2 ? words[1] : 'help';
|
||||
subcommand = words.length >= 2 ? words[1] : 'help',
|
||||
helpmsgparts = [
|
||||
['[help]', 'Get this message.'],
|
||||
['balance', 'Get your balance.'],
|
||||
['deposit', 'Get address for your deposits.'],
|
||||
['withdraw ADDRESS AMOUNT', 'Withdraw AMOUNT credits to ADDRESS'],
|
||||
['[private] <user> <amount>', 'Mention a user with @ and then the amount to tip them, or put private before the user to tip them privately.']
|
||||
],
|
||||
helpmsg = {
|
||||
embed: {
|
||||
description: formatDescriptions(helpmsgparts) + '\nKey: [] : Optionally include contained keyword, <> : Replace with appropriate value.',
|
||||
color: 1109218,
|
||||
author: { name: '!tip' }
|
||||
}
|
||||
},
|
||||
channelwarning = 'Please use <' + spamchannel + '> or DMs to talk to bots.';
|
||||
switch (subcommand) {
|
||||
case 'help':
|
||||
doHelp(msg);
|
||||
privateOrSandboxOnly(msg, channelwarning, doHelp, [helpmsg]);
|
||||
break;
|
||||
case 'balance':
|
||||
doBalance(msg, tipper);
|
||||
break;
|
||||
case 'deposit':
|
||||
doDeposit(msg, tipper);
|
||||
privateOrSandboxOnly(msg, channelwarning, doDeposit, [tipper]);
|
||||
break;
|
||||
case 'withdraw':
|
||||
doWithdraw(msg, tipper, words);
|
||||
privateOrSandboxOnly(msg, channelwarning, doWithdraw, [tipper, words, helpmsg]);
|
||||
break;
|
||||
default:
|
||||
doTip(msg, tipper, words);
|
||||
doTip(msg, tipper, words, helpmsg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.multitip = {
|
||||
usage: '<subcommand>',
|
||||
description: 'Tip multiple users simultaneously for the same amount of LBC each.',
|
||||
process: async function(bot, msg, suffix) {
|
||||
let tipper = msg.author.id.replace('!', ''),
|
||||
words = msg.content
|
||||
.trim()
|
||||
.split(' ')
|
||||
.filter(function(n) {
|
||||
return n !== '';
|
||||
}),
|
||||
subcommand = words.length >= 2 ? words[1] : 'help',
|
||||
helpmsgparts = [
|
||||
['[help]', 'Get this message.'],
|
||||
['<user>+ <amount>', 'Mention one or more users in a row, seperated by spaces, then an amount that each mentioned user will receive.'],
|
||||
['private <user>+ <amount>', 'Put private before the user list to have each user tipped privately, without revealing other users tipped.']
|
||||
],
|
||||
helpmsg = {
|
||||
embed: {
|
||||
description: formatDescriptions(helpmsgparts) + '\nKey: [] : Optionally include contained keyword, <> : Replace with appropriate value, + : Value can be repeated for multiple entries.',
|
||||
color: 1109218,
|
||||
author: { name: '!multitip' }
|
||||
}
|
||||
},
|
||||
channelwarning = 'Please use <' + spamchannel + '> or DMs to talk to bots.';
|
||||
switch (subcommand) {
|
||||
case 'help':
|
||||
privateOrSandboxOnly(msg, channelwarning, doHelp, [helpmsg]);
|
||||
break;
|
||||
default:
|
||||
doMultiTip(msg, tipper, words, helpmsg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.roletip = {
|
||||
usage: '<subcommand>',
|
||||
description: 'Tip every user in a given role the same amount of LBC.',
|
||||
process: async function(bot, msg, suffix) {
|
||||
let tipper = msg.author.id.replace('!', ''),
|
||||
words = msg.content
|
||||
.trim()
|
||||
.split(' ')
|
||||
.filter(function(n) {
|
||||
return n !== '';
|
||||
}),
|
||||
subcommand = words.length >= 2 ? words[1] : 'help',
|
||||
helpmsgparts = [
|
||||
['[help]', 'Get this message'],
|
||||
['<role> <amount>', 'Mention a single role, then an amount that each user in that role will receive.'],
|
||||
['private <role> <amount>', 'Put private before the role to have each user tipped privately, without revealing other users tipped.']
|
||||
],
|
||||
helpmsg = {
|
||||
embed: {
|
||||
description: formatDescriptions(helpmsgparts) + '\nKey: [] : Optionally include contained keyword, <> : Replace with appropriate value.',
|
||||
color: 1109218,
|
||||
author: { name: '!roletip' }
|
||||
}
|
||||
},
|
||||
channelwarning = `Please use <${spamchannel}> or DMs to talk to bots.`;
|
||||
switch (subcommand) {
|
||||
case 'help':
|
||||
privateOrSandboxOnly(msg, channelwarning, doHelp, [helpmsg]);
|
||||
break;
|
||||
default:
|
||||
doRoleTip(msg, tipper, words, helpmsg);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.tipcommands = {
|
||||
usage: '',
|
||||
description: 'Lists all available tipbot commands with brief descriptions for each one.',
|
||||
process: async function(bot, msg, suffix) {
|
||||
let helpmsgparts = [
|
||||
['!tip', 'Tip a given user with an amount of LBC or perform wallet specific operations.'],
|
||||
['!multitip', 'Tip multiple users simultaneously for the same amount of LBC each.'],
|
||||
['!roletip', 'Tip every user in a given role the same amount of LBC.'],
|
||||
['!tipcommands', 'Lists all available tipbot commands with brief descriptions for each one.']
|
||||
],
|
||||
helpmsg = {
|
||||
embed: {
|
||||
description: `These are all the commands that TipBot currently supports. Use \`!<command> help\` for usage instructions.
|
||||
${formatDescriptions(helpmsgparts)}`,
|
||||
color: 1109218,
|
||||
author: { name: 'Tipbot Commands' }
|
||||
}
|
||||
};
|
||||
msg.reply(helpmsg);
|
||||
}
|
||||
};
|
||||
|
||||
function privateOrSandboxOnly(message, wrongchannelmsg, fn, args) {
|
||||
if (!inPrivateOrBotSandbox(message)) {
|
||||
message.reply(wrongchannelmsg);
|
||||
return;
|
||||
}
|
||||
fn.apply(null, [message, ...args]);
|
||||
}
|
||||
|
||||
function doHelp(message, helpmsg) {
|
||||
message.author.send(helpmsg);
|
||||
}
|
||||
|
||||
function doBalance(message, tipper) {
|
||||
lbry.getBalance(tipper, 1, function(err, balance) {
|
||||
if (err) {
|
||||
message.reply('Error getting balance');
|
||||
message.reply('Error getting balance.').then(message => message.delete(5000));
|
||||
} else {
|
||||
message.reply(`You have *${balance}* LBC`);
|
||||
}
|
||||
|
@ -53,58 +171,53 @@ function doBalance(message, tipper) {
|
|||
}
|
||||
|
||||
function doDeposit(message, tipper) {
|
||||
if (!inPrivateOrBotSandbox(message)) {
|
||||
return message.reply(`Please use <#${sandboxChannel}> or DMs to talk to bots.`);
|
||||
}
|
||||
getAddress(tipper, function(err, address) {
|
||||
if (err) {
|
||||
message.reply('Error getting deposit address');
|
||||
message.reply('Error getting your deposit address.').then(message => message.delete(5000));
|
||||
} else {
|
||||
message.reply(`Your address is ${address}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doWithdraw(message, tipper, words) {
|
||||
if (!inPrivateOrBotSandbox(message)) {
|
||||
return message.reply(`Please use <#${sandboxChannel}> or DMs to talk to bots.`);
|
||||
}
|
||||
function doWithdraw(message, tipper, words, helpmsg) {
|
||||
if (words.length < 4) {
|
||||
return doHelp(message);
|
||||
return doHelp(message, helpmsg);
|
||||
}
|
||||
|
||||
let address = words[2],
|
||||
amount = getValidatedAmount(words[3]);
|
||||
|
||||
if (amount === null) {
|
||||
message.reply("I don't know how to withdraw that many credits");
|
||||
message.reply("I don't know how to withdraw that many credits...").then(message => message.delete(5000));
|
||||
return;
|
||||
}
|
||||
|
||||
lbry.sendFrom(tipper, address, amount, function(err, txId) {
|
||||
if (err) {
|
||||
return message.reply(err.message);
|
||||
return message.reply(err.message).then(message => message.delete(5000));
|
||||
}
|
||||
message.reply(`You withdrew ${amount} to ${address} (${txLink(txId)})`);
|
||||
message.reply(`You withdrew ${amount} LBC to ${address}.
|
||||
${txLink(txId)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function doTip(message, tipper, words) {
|
||||
function doTip(message, tipper, words, helpmsg) {
|
||||
if (words.length < 3 || !words) {
|
||||
return doHelp(message);
|
||||
return doHelp(message, helpmsg);
|
||||
}
|
||||
|
||||
let prv = 0;
|
||||
let prv = false;
|
||||
let amountOffset = 2;
|
||||
if (words.length >= 4 && words[1] === 'private') {
|
||||
prv = 1;
|
||||
prv = true;
|
||||
amountOffset = 3;
|
||||
}
|
||||
|
||||
let amount = getValidatedAmount(words[amountOffset]);
|
||||
|
||||
if (amount === null) {
|
||||
return message.reply("I don't know how to tip that many credits");
|
||||
return message.reply("I don't know how to tip that many credits...").then(message => message.delete(5000));
|
||||
}
|
||||
|
||||
if (message.mentions.users.first().id) {
|
||||
|
@ -113,35 +226,108 @@ function doTip(message, tipper, words) {
|
|||
message.reply('Sorry, I could not find a user in your tip...');
|
||||
}
|
||||
|
||||
function doHelp(message) {
|
||||
if (!inPrivateOrBotSandbox(message)) {
|
||||
message.reply(`Sent you help via DM! Please use <#${sandboxChannel}> or DMs to talk to bots.`);
|
||||
function doMultiTip(message, tipper, words, helpmsg) {
|
||||
if (!words) {
|
||||
doHelp(message, helpmsg);
|
||||
return;
|
||||
}
|
||||
if (words.length < 4) {
|
||||
doTip(message, tipper, words, helpmsg);
|
||||
return;
|
||||
}
|
||||
let prv = false;
|
||||
if (words.length >= 5 && words[1] === 'private') {
|
||||
prv = true;
|
||||
}
|
||||
let [userIDs, amount] = findUserIDsAndAmount(message, words, prv);
|
||||
if (amount == null) {
|
||||
message.reply("I don't know how to tip that many credits...").then(message => message.delete(5000));
|
||||
return;
|
||||
}
|
||||
if (!userIDs) {
|
||||
message.reply('Sorry, I could not find a user in your tip...').then(message => message.delete(5000));
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < userIDs.length; i++) {
|
||||
sendLBC(message, tipper, userIDs[i].toString(), amount, prv);
|
||||
}
|
||||
message.author.send(`**!tip**
|
||||
balance: get your balance
|
||||
deposit: get address for your deposits
|
||||
withdraw ADDRESS AMOUNT: withdraw AMOUNT credits to ADDRESS
|
||||
[private] <user> <amount>: mention a user with @ and then the amount to tip them, or put private before the user to tip them privately.
|
||||
Key: [] : Optionally include contained keyword, <> : Replace with appropriate value.`);
|
||||
}
|
||||
|
||||
function sendLbc(message, tipper, recipient, amount, privacyFlag) {
|
||||
getAddress(recipient, function(err, address) {
|
||||
if (err) {
|
||||
message.reply(err.message);
|
||||
function doRoleTip(message, tipper, words, helpmsg) {
|
||||
if (!words || words.length < 3) {
|
||||
doHelp(message, helpmsg);
|
||||
return;
|
||||
}
|
||||
let prv = false;
|
||||
let amountOffset = 2;
|
||||
if (words.length >= 4 && words[1] === 'private') {
|
||||
prv = true;
|
||||
amountOffset = 3;
|
||||
}
|
||||
let amount = getValidatedAmount(words[amountOffset]);
|
||||
if (amount == null) {
|
||||
message.reply("I don't know how to tip that many credits...").then(message => message.delete(5000));
|
||||
return;
|
||||
}
|
||||
if (message.mentions.roles.first().id) {
|
||||
if (message.mentions.roles.first().members.first().id) {
|
||||
let userIDs = message.mentions.roles.first().members.map(member => member.user.id.replace('!', ''));
|
||||
for (let i = 0; i < userIDs; i++) {
|
||||
sendLBC(message, tipper, userIDs[i], amount, prv);
|
||||
}
|
||||
} else {
|
||||
lbry.sendFrom(tipper, address, amount, 1, null, null, function(err, txId) {
|
||||
message.reply('Sorry, I could not find any users to tip in that role...').then(message => message.delete(5000));
|
||||
}
|
||||
} else {
|
||||
message.reply('Sorry, I could not find any roles in your tip...').then(message => message.delete(5000));
|
||||
}
|
||||
}
|
||||
|
||||
function findUserIDsAndAmount(message, words, prv) {
|
||||
let idList = [];
|
||||
let amount = null;
|
||||
let count = 0;
|
||||
let startOffset = 1;
|
||||
if (prv) startOffset = 2;
|
||||
let regex = new RegExp(/<@!?[0-9]+>/);
|
||||
for (let i = startOffset; i < words.length; i++) {
|
||||
if (regex.test(words[i])) {
|
||||
count++;
|
||||
idList.push(words[i].match(/[0-9]+/));
|
||||
} else {
|
||||
amount = getValidatedAmount(words[Number(count) + 1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [idList, amount];
|
||||
}
|
||||
|
||||
function sendLBC(message, tipper, recipient, amount, privacyFlag) {
|
||||
getAddress(recipient.toString(), function(err, address) {
|
||||
if (err) {
|
||||
message.reply(err.message).then(message => message.delete(5000));
|
||||
} else {
|
||||
lbry.sendFrom(tipper, address, Number(amount), 1, null, null, function(err, txId) {
|
||||
if (err) {
|
||||
message.reply(err.message);
|
||||
message.reply(err.message).then(message => message.delete(5000));
|
||||
} else {
|
||||
let imessage = `Wubba lubba dub dub! <@${tipper}> tipped <@${recipient}> ${amount} LBC (${txLink(txId)}). DM me \`!tip\` for tipbot instructions.`;
|
||||
let tx = txLink(txId);
|
||||
let msgtail = `
|
||||
DM me with \`${message.content.split(' ', 1)[0]}\` for command specific instructions or with \`!tipcommands\` for all available commands`;
|
||||
if (privacyFlag) {
|
||||
message.author.send(imessage);
|
||||
let authmsg = `You have just privately tipped <@${recipient}> ${amount} LBC.
|
||||
${tx}${msgtail}`;
|
||||
message.author.send(authmsg);
|
||||
if (message.author.id !== message.mentions.users.first().id) {
|
||||
message.mentions.users.first().send(imessage);
|
||||
let usr = message.guild.members.find('id', recipient).user;
|
||||
let recipientmsg = `You have just been privately tipped ${amount} LBC by <@${tipper}>.
|
||||
${tx}${msgtail}`;
|
||||
usr.send(recipientmsg);
|
||||
}
|
||||
} else {
|
||||
message.reply(imessage);
|
||||
let generalmsg = `Wubba lubba dub dub! <@${tipper}> tipped <@${recipient}> ${amount} LBC.
|
||||
${tx}${msgtail}`;
|
||||
message.reply(generalmsg);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -168,7 +354,7 @@ function getAddress(userId, cb) {
|
|||
}
|
||||
|
||||
function inPrivateOrBotSandbox(msg) {
|
||||
return msg.channel.type === 'dm' || msg.channel.id === sandboxChannel;
|
||||
return msg.channel.type === 'dm' || msg.channel.id === spamchannel;
|
||||
}
|
||||
|
||||
function getValidatedAmount(amount) {
|
||||
|
@ -182,3 +368,13 @@ function getValidatedAmount(amount) {
|
|||
function txLink(txId) {
|
||||
return '<https://explorer.lbry.io/tx/' + txId + '>';
|
||||
}
|
||||
|
||||
function formatDescriptions(msgparts) {
|
||||
return msgparts
|
||||
.map(
|
||||
elem => `\t**${elem[0]}**
|
||||
\t\t${elem[1]}
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
|
|
@ -1,20 +1,13 @@
|
|||
{
|
||||
// Bot configuration
|
||||
"bot": {
|
||||
"token":"discordbottoken",
|
||||
"prefix": "!",
|
||||
"debug": false
|
||||
"token": "discordbottoken",
|
||||
"prefix": "!",
|
||||
"debug": false
|
||||
},
|
||||
"lbrycrd": {
|
||||
"port": 9245,
|
||||
"user": "lbry",
|
||||
"pass": "lbry"
|
||||
"port": 9245,
|
||||
"user": "lbry",
|
||||
"pass": "lbry"
|
||||
},
|
||||
"mongodb": {
|
||||
"url":"mongodb://localhost:27017/wunderbot"
|
||||
},
|
||||
"moderation":{
|
||||
"perms": ["LBRY MODS","LBRY TEAM"], // Roles that have access to all commands.
|
||||
"logchannel": "371620338263523328" // Channel to log the bots moderation..
|
||||
}
|
||||
}
|
||||
"sandboxchannel": "#369896313082478594"
|
||||
}
|
||||
|
|
56
package-lock.json
generated
56
package-lock.json
generated
|
@ -95,6 +95,11 @@
|
|||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
|
||||
},
|
||||
"augment": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/augment/-/augment-3.2.1.tgz",
|
||||
"integrity": "sha1-7F2elFYUDvqxEQ/ds1LL5U5/sxw="
|
||||
},
|
||||
"aws-sign2": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
|
||||
|
@ -1991,6 +1996,11 @@
|
|||
"is-buffer": "1.1.6"
|
||||
}
|
||||
},
|
||||
"lex": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/lex/-/lex-1.7.4.tgz",
|
||||
"integrity": "sha1-M91muYCjjXemxCTXFz5vQNYCwMU="
|
||||
},
|
||||
"lex-parser": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lex-parser/-/lex-parser-0.1.4.tgz",
|
||||
|
@ -2402,6 +2412,38 @@
|
|||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.0.tgz",
|
||||
"integrity": "sha512-/aA0kLeRb5N9K0d4fw7ooEbI+xDe+DKD499EQqygGqeS8N3xto15p09uY2xj7ixP81sNPXvRLnAQIqdVStgb1A=="
|
||||
},
|
||||
"regex": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-0.1.1.tgz",
|
||||
"integrity": "sha1-BYCZXRhGxTdqdiudNYuDBo0geu8=",
|
||||
"requires": {
|
||||
"augment": "3.2.1",
|
||||
"jison": "0.4.4",
|
||||
"lex": "1.7.4",
|
||||
"statemachines": "0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"esprima": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-1.0.4.tgz",
|
||||
"integrity": "sha1-n1V+CPw7TSbs6d00+Pv0drYlha0="
|
||||
},
|
||||
"jison": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/jison/-/jison-0.4.4.tgz",
|
||||
"integrity": "sha1-hrA7Ze/sAHqR9JPs6r5W+QqmtDk=",
|
||||
"requires": {
|
||||
"JSONSelect": "0.4.0",
|
||||
"ebnf-parser": "0.1.10",
|
||||
"escodegen": "0.0.21",
|
||||
"esprima": "1.0.4",
|
||||
"jison-lex": "0.2.1",
|
||||
"lex-parser": "0.1.4",
|
||||
"nomnom": "1.5.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"regex-cache": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/regex-cache/-/regex-cache-0.4.4.tgz",
|
||||
|
@ -2522,6 +2564,11 @@
|
|||
"hoek": "4.2.0"
|
||||
}
|
||||
},
|
||||
"sorted-array": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/sorted-array/-/sorted-array-1.1.0.tgz",
|
||||
"integrity": "sha1-YamDJeQSu5CZm0usGN/tXzVkEC0="
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
|
@ -2558,6 +2605,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"statemachines": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/statemachines/-/statemachines-0.1.0.tgz",
|
||||
"integrity": "sha1-tUX9PYpVKA8wK4aW8ZwKEcTjOt4=",
|
||||
"requires": {
|
||||
"augment": "3.2.1",
|
||||
"sorted-array": "1.1.0"
|
||||
}
|
||||
},
|
||||
"static-eval": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://registry.npmjs.org/static-eval/-/static-eval-0.2.3.tgz",
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
"mongoose": "^4.13.11",
|
||||
"node-config": "^0.0.2",
|
||||
"numeral": "^2.0.6",
|
||||
"regex": "^0.1.1",
|
||||
"request": "^2.83.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
Loading…
Add table
Reference in a new issue