25 Commits

Author SHA1 Message Date
34dbc91d1e Profile: Implement custom profile backgrounds 2023-08-07 16:23:18 +02:00
2d11fdfdad DB: Add fields for custom profile backgrounds 2023-08-07 16:22:39 +02:00
db98cc21b2 GeneralUtil: Add download file function 2023-08-07 16:21:29 +02:00
696d0f136d Profile: Fix showcase card being out of order.
The showcase cards were rendered based on the order in which
the render calls returned within the slots.map call.
Using the original slot keys fixes this issue.
2023-08-04 12:07:47 +02:00
24510dcc4c Bot: Change ENV to NODE_ENV in ready.js 2023-08-01 00:41:59 +02:00
ac54231ced Bot: Clear global commands when running as dev 2023-07-31 17:18:22 +02:00
b0311a3bb3 Rendering: Better card debug output
- Add timestamp
- Larger font
2023-07-31 17:17:30 +02:00
44eb1783ac Debug: Catch and print errors when fetching from Jose 2023-07-31 15:53:30 +02:00
e2dab416f2 GeneralUtils/Profile: Shorten numbers to K M B format
- Implemented for currencies on the profile
2023-07-31 15:52:42 +02:00
724621f8da Rendering: Add debug output in develop mode
- Burn node info into rendered card
- Log job def onto console
2023-07-31 14:49:00 +02:00
56ff2f96a5 Merge pull request #58 from JanGross/master
Merge master into dev-new-rendering
2023-07-31 11:38:40 +02:00
fe9b52c02d Editprofile: Properly defer updates on non-modal interactions
Also fixes interaction filtering by user id.
Also fixes collector filtering by modal customID
2023-06-12 22:31:28 +02:00
2d9f66acd4 Bump node versiom to 20
node:20-alpine
2023-06-12 21:56:45 +02:00
487ee866ba Cleanup legacy rendering code and files
also removes dockerfile and imagemagick.
2023-06-12 21:55:13 +02:00
14fbe1ab5d Rendering: Reposition labels for top-aligned default 2023-06-12 21:27:27 +02:00
7175271233 Rendering: Add placeholder frame
and fix profile card alignment
2023-06-12 16:40:11 +02:00
f84a1deddb Profile: Add stats bar to new rendering 2023-06-12 14:38:40 +02:00
5dd106354b Profile: Add userimage and status to new profile 2023-06-12 01:36:56 +02:00
9a79521a27 Profile: Add new rendering for username and showcase 2023-06-11 23:55:27 +02:00
b0d08f119e Editprofile: Remove top level defer which broke modal inputs 2023-06-11 21:55:30 +02:00
ce5cfafd1d Rendering: Hide card if it's unclaimed 2023-06-08 14:13:37 +02:00
8b35534517 Rendering: Replace card stack rendering with external API call 2023-06-08 13:47:49 +02:00
795d3b444e API: Serve static assets via express 2023-06-08 12:17:16 +02:00
46bc9ae711 Rendering: Add configurable Jose endpoint 2023-06-08 10:13:03 +02:00
a98f5e0ebc Rendering: WIP Replacing renderer with external API 2023-06-02 12:36:18 +02:00
17 changed files with 401 additions and 197 deletions

View File

@@ -9,4 +9,8 @@ DB_ROOTPW=
DB_PORT= DB_PORT=
API_PORT=3080 API_PORT=3080
API_ACCESS_TOKEN= API_ACCESS_TOKEN=
HOMEPAGE_URL= HOMEPAGE_URL=
#Rendering
JOSE_ENDPOINT=
ASSET_URL=

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@
assets/image_cache assets/image_cache
assets/cards assets/cards
assets/import assets/import
assets/userdata
### Visual Studio Code ### ### Visual Studio Code ###
.vscode .vscode

View File

@@ -1,3 +0,0 @@
# syntax=docker/dockerfile:1
FROM node:16.9.0-alpine
RUN apk add --no-cache imagemagick

View File

@@ -93,4 +93,6 @@ app.use(PREFIX, router);
app.use(PREFIX, groupRoutes); app.use(PREFIX, groupRoutes);
app.use(PREFIX, badgeRoutes); app.use(PREFIX, badgeRoutes);
app.use(PREFIX, characterRoutes); app.use(PREFIX, characterRoutes);
app.use('/assets', express.static('assets'));
module.exports = app; module.exports = app;

View File

View File

@@ -1,7 +1,7 @@
const { SlashCommandBuilder, ComponentType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); const { SlashCommandBuilder, ComponentType, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js");
const { customAlphabet } = require("nanoid"); const { customAlphabet } = require("nanoid");
const { Card, User, Wishlist, Character } = require("../models"); const { Card, User, Wishlist, Character } = require("../models");
const { UserUtils, CardUtils, GeneralUtils } = require("../util"); const { UserUtils, CardUtils, GeneralUtils, Rendering } = require("../util");
const { PATREON } = require("../config/constants"); const { PATREON } = require("../config/constants");
const stores = require("../stores"); const stores = require("../stores");
require('dotenv').config(); require('dotenv').config();
@@ -28,6 +28,7 @@ module.exports = {
{ name: 'toggle_maintenance', value: 'toggle_maintenance' }, { name: 'toggle_maintenance', value: 'toggle_maintenance' },
{ name: 'store', value: 'store' }, { name: 'store', value: 'store' },
{ name: 'wishlist', value: 'wishlist' }, { name: 'wishlist', value: 'wishlist' },
{ name: 'rendering', value: 'rendering' },
{ name: 'patreon', value: 'patreon' } { name: 'patreon', value: 'patreon' }
) )
) )
@@ -181,6 +182,55 @@ module.exports = {
let patreon = await UserUtils.getPatreonPerks(interaction.client, extUser ? extUser : user); let patreon = await UserUtils.getPatreonPerks(interaction.client, extUser ? extUser : user);
interaction.channel.send(JSON.stringify(patreon)); interaction.channel.send(JSON.stringify(patreon));
break;
case "rendering":
const row = new ActionRowBuilder();
row.addComponents(
new ButtonBuilder()
.setCustomId(`testbatch`)
.setLabel(`Render test batch`)
.setStyle(ButtonStyle.Primary),
);
interaction.editReply({
content: `Jose endpoint: ${process.env.JOSE_ENDPOINT}\n Asset URL: ${process.env.ASSET_URL}`,
components: [row],
ephemeral: false
});
const filter = (m) => m.user.id === interaction.user.id;
const collector = interaction.channel.createMessageComponentCollector({ filter: filter, componentType: ComponentType.Button, time: 60000 });
collector.on('collect', async (i) => {
switch (i.customId) {
case 'testbatch':
i.deferUpdate();
interaction.channel.send("Beep boop test batch of 5");
let testCard = await Card.build({
characterId: 0,
userId: 1,
identifier: "0xffff",
quality: 1,
printNr: 0,
});
let testCharacter = Character.build({
id: 0,
groupId: 0,
name: "test",
imageIdentifier: "azur-lane/akashi.png",
enabled: true
})
for (let index = 0; index < 5; index++) {
testCard.printNr = index;
let render = await Rendering.renderCard(testCard, testCharacter).catch(async function(error){
await interaction.channel.send(JSON.stringify(error));
await interaction.channel.send(JSON.stringify(error.response?.data));
return;
});
await interaction.channel.send(render);
}
break;
}
});
break; break;
default: default:
interaction.editReply({ interaction.editReply({

View File

@@ -1,6 +1,6 @@
const { SlashCommandBuilder, ComponentType, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder } = require("discord.js"); const { SlashCommandBuilder, ComponentType, TextInputBuilder, TextInputStyle, ActionRowBuilder, ButtonBuilder, ButtonStyle, ModalBuilder, Attachment } = require("discord.js");
const { Card, User, Character } = require("../models"); const { Card, User, Character } = require("../models");
const { UserUtils, ReplyUtils } = require("../util"); const { UserUtils, ReplyUtils, GeneralUtils } = require("../util");
const pageSize = 8; const pageSize = 8;
@@ -8,12 +8,18 @@ const pageSize = 8;
module.exports = { module.exports = {
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("editprofile") .setName("editprofile")
.setDescription("Edit your profile"), .setDescription("Edit your profile")
.addAttachmentOption((option) =>
option
.setName("attachement")
.setDescription("Attachement to be used")
.setRequired(false)
),
permissionLevel: 0, permissionLevel: 0,
async execute(interaction) { async execute(interaction) {
await interaction.deferReply(); await interaction.deferReply();
let user = await UserUtils.getUserByDiscordId(interaction.member.id); let user = await UserUtils.getUserByDiscordId(interaction.member.id);
let patreon = await UserUtils.getPatreonPerks(interaction.client, user);
let profile = await user.getProfile(); let profile = await user.getProfile();
@@ -31,6 +37,15 @@ module.exports = {
.setStyle(ButtonStyle.Primary) .setStyle(ButtonStyle.Primary)
); );
if (patreon.perks?.["custom_bg"] === true && interaction.options.getAttachment("attachement")) {
mainRow.addComponents(
new ButtonBuilder()
.setLabel('Set attachment as custom background')
.setCustomId('setCustomBg')
.setStyle(ButtonStyle.Primary)
);
}
const pingRow = new ActionRowBuilder(); const pingRow = new ActionRowBuilder();
pingRow.addComponents( pingRow.addComponents(
new ButtonBuilder() new ButtonBuilder()
@@ -51,11 +66,10 @@ module.exports = {
let message = await interaction.editReply({ content: "", components: [mainRow, pingRow], fetchReply: true }); let message = await interaction.editReply({ content: "", components: [mainRow, pingRow], fetchReply: true });
//filter only events from the user who triggered the command //filter only events from the user who triggered the command
const filter = (m) => m.author.id === interaction.author.id; const filter = (m) => m.user.id === interaction.user.id;
const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, time: 25000 }) const collector = message.createMessageComponentCollector({ filter: filter, componentType: ComponentType.Button, time: 300000 })
collector.on('collect', async (i) => { collector.on('collect', async (i) => {
await i.deferReply();
switch (i.customId) { switch (i.customId) {
case 'editStatus': case 'editStatus':
await this.openStatusModal(i, user, profile); await this.openStatusModal(i, user, profile);
@@ -63,19 +77,36 @@ module.exports = {
case 'editShowcase': case 'editShowcase':
await this.openShowcaseModal(i, user, profile); await this.openShowcaseModal(i, user, profile);
break; break;
case 'setCustomBg':
await i.deferReply();
let allowedContentTypes = [ "image/png", "image/jpeg" ];
let image = interaction.options.getAttachment("attachement");
if (!allowedContentTypes.includes(image.contentType)) {
await i.editReply({ content: "An invalid image has been attached. Allowed are .png and .jpeg", ephemeral: true });
return;
}
await GeneralUtils.downloadFile(image.url, `/app/assets/userdata/profiles/${image.id}_${image.name}`);
profile.customBackground = `${process.env.ASSET_URL}/userdata/profiles/${image.id}_${image.name}`;
await profile.save();
await i.editReply('Custom profile background has been set!');
break;
case 'toggle-wishlist-ping': case 'toggle-wishlist-ping':
await i.deferUpdate();
user.wishlistPing = !user.wishlistPing; user.wishlistPing = !user.wishlistPing;
user.save(); user.save();
break; break;
case 'toggle-drop-ping': case 'toggle-drop-ping':
await i.deferUpdate();
user.dropPing = !user.dropPing; user.dropPing = !user.dropPing;
user.save(); user.save();
break; break;
case 'toggle-daily-ping': case 'toggle-daily-ping':
await i.deferUpdate();
user.dailyPing = !user.dailyPing; user.dailyPing = !user.dailyPing;
user.save(); user.save();
break; break;
default: default:
await i.deferReply();
i.editReply({ content: "Invalid selection" }); i.editReply({ content: "Invalid selection" });
return; return;
break; break;
@@ -89,8 +120,6 @@ module.exports = {
} }
}); });
await message.edit({ components: newComponents }); await message.edit({ components: newComponents });
let msg = await i.editReply({content: '...'});
await msg.delete();
}); });
}, },
async openShowcaseModal(interaction, user, profile) { async openShowcaseModal(interaction, user, profile) {
@@ -118,13 +147,12 @@ module.exports = {
let submitted = await interaction.awaitModalSubmit({ let submitted = await interaction.awaitModalSubmit({
time: 60000, time: 60000,
filter: i => i.user.id === interaction.user.id, filter: i => i.user.id === interaction.user.id && i.customId === 'cardSlotModal',
}).catch(error => { }).catch(error => {
//Error includes timeout //Error includes timeout
console.error(error) console.error(error)
return null return null
}) })
if (submitted) { if (submitted) {
let updatePayload = {}; let updatePayload = {};
for (slot of slots) { for (slot of slots) {
@@ -144,7 +172,7 @@ module.exports = {
}, },
async openStatusModal(interaction, user, profile) { async openStatusModal(interaction, user, profile) {
const modal = new ModalBuilder() const modal = new ModalBuilder()
.setCustomId('descriptionModal') .setCustomId('statusModal')
.setTitle('Edit profile status/description'); .setTitle('Edit profile status/description');
let row = new ActionRowBuilder(); let row = new ActionRowBuilder();
@@ -162,7 +190,7 @@ module.exports = {
let submitted = await interaction.awaitModalSubmit({ let submitted = await interaction.awaitModalSubmit({
time: 300000, time: 300000,
filter: i => i.user.id === interaction.user.id, filter: i => i.user.id === interaction.user.id && i.customId === 'statusModal',
}).catch(error => { }).catch(error => {
//Error includes timeout //Error includes timeout
console.error(error) console.error(error)

View File

@@ -1,10 +1,9 @@
const { SlashCommandBuilder, MessageAttachment, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require("discord.js"); require("dotenv").config();
const { Card, User, Character } = require("../models"); const { SlashCommandBuilder } = require("discord.js");
const { UserUtils, Compositing, Rendering } = require("../util"); const { Card } = require("../models");
const { UserUtils, Rendering, GeneralUtils } = require("../util");
const axios = require("axios"); const axios = require("axios");
const sharp = require("sharp");
const { CURRENCY_NAMES } = require("../config/constants"); const { CURRENCY_NAMES } = require("../config/constants");
const fs = require('fs');
const pageSize = 8; const pageSize = 8;
@@ -25,56 +24,154 @@ module.exports = {
let discordUser = interaction.options.getUser("user") ? interaction.options.getUser("user") : interaction.member.user; let discordUser = interaction.options.getUser("user") ? interaction.options.getUser("user") : interaction.member.user;
let user = await UserUtils.getUserByDiscordId(discordUser.id); let user = await UserUtils.getUserByDiscordId(discordUser.id);
let patreon = await UserUtils.getPatreonPerks(interaction.client, user);
let profile = await user.getProfile(); let profile = await user.getProfile();
let customStatus = this.encodeStr(profile.customStatus); let customStatus = profile.customStatus;
customStatus = customStatus.replace(/(.{0,40}[\s])/g, '<tspan x="443" dy="1.2em">$1</tspan>');
let profileTemplate = fs.readFileSync('/app/assets/profile/profile.svg').toString(); let userImage = discordUser.displayAvatarURL({format: 'png', size: 128}).split('webp')[0] + 'png';
profileTemplate = profileTemplate.replace(/{{USERNAME}}/g, this.encodeStr(discordUser.username.substr(0,15)+(discordUser.username.length>15?'...':'')));
profileTemplate = profileTemplate.replace(/{{PROFILE_TEXT}}/g, customStatus );
profileTemplate = profileTemplate.replace(/{{HEADER_COLOR}}/g, '190,31,97');
profileTemplate = profileTemplate.replace(/{{CC}}/g, await Card.count({where: {userId: user.id}}));
profileTemplate = profileTemplate.replace(/{{LVL}}/g, await user.level().currentLevel);
profileTemplate = profileTemplate.replace(/{{CUR_1}}/g, `${await user.primaryCurrency} ${CURRENCY_NAMES[1]}`);
profileTemplate = profileTemplate.replace(/{{CUR_2}}/g, `${await user.secondaryCurrency} ${CURRENCY_NAMES[2]}`);
let userImageBuffer = await axios.get(discordUser.displayAvatarURL({format: 'png', size: 128}), { responseType: 'arraybuffer' });
userImage = await sharp(userImageBuffer.data);
const rect = new Buffer.from(
'<svg><rect x="0" y="0" width="128" height="128" rx="100%" ry="100%"/></svg>'
);
userImage = await userImage.composite([{input: rect, blend: 'dest-in' }]).png().toBuffer();
let background = await sharp(Buffer.from(profileTemplate, 'utf8'))
.composite([{ input: userImage, left: 360, top: 20 }]).png().toBuffer();
let slots = ['slotOne', 'slotTwo', 'slotThree', 'slotFour']; let slots = ['slotOne', 'slotTwo', 'slotThree', 'slotFour'];
let renderedCards = []; let renderedCards = [];
for (slot of slots) { await Promise.all(slots.map(async slot => {
let card = await Card.findOne({ where: { id: profile[slot], burned: false } }); let card = await Card.findOne({ where: { id: profile[slot], burned: false } });
if (card) { if (card) {
console.log(`Iterating card ${card.id}`);
let cardImage = await Rendering.renderCard(card); let cardImage = await Rendering.renderCard(card);
renderedCards.push(cardImage); renderedCards[slot] = cardImage;
} else { } else {
renderedCards.push('/app/assets/cards/missing_image.png'); renderedCards[slot] = `${process.env.ASSET_URL}/cards/card_cover.png`;
} }
}));
let job = {
"type": "profile",
"size": {
"width": 1200,
"height": 600
},
"elements": [
{
"type": "image",
"asset": `${renderedCards['slotOne']}`,
"x": 25,
"y": 85,
"width": 300,
"height": 500
},
{
"type": "image",
"asset": `${renderedCards['slotTwo']}`,
"x": 375,
"y": 310,
"width": 175,
"height": 275
},
{
"type": "image",
"asset": `${renderedCards['slotThree']}`,
"x": 560,
"y": 310,
"width": 175,
"height": 275
},
{
"type": "image",
"asset": `${renderedCards['slotFour']}`,
"x": 745,
"y": 310,
"width": 175,
"height": 275
},
{
"type": "image",
"asset": userImage,
"x": 350,
"y": 50,
"width": 150,
"height": 150
},
{
"type": "text",
"text": discordUser.username.substr(0,15)+(discordUser.username.length>15?'...':''),
"fontSize": 32,
"x": 25,
"y": 20,
"width": 300,
"height": 30,
"horizontalAlignment": "center"
},
{
"type": "text",
"text": `CC: ${GeneralUtils.formatNumber(await Card.count({where: {userId: user.id}}))}`,
"fontSize": 30,
"x": 550,
"y": 20,
"width": 150,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": `LVL: ${await user.level().currentLevel}`,
"fontSize": 30,
"x": 700,
"y": 20,
"width": 150,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": `${GeneralUtils.formatNumber(await user.primaryCurrency)} ${CURRENCY_NAMES[1]}`,
"fontSize": 30,
"x": 850,
"y": 20,
"width": 170,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": `${await GeneralUtils.formatNumber(user.secondaryCurrency)} ${CURRENCY_NAMES[2]}`,
"fontSize": 30,
"x": 1020,
"y": 20,
"width": 170,
"height": 30,
"horizontalAlignment": "left"
},
{
"type": "text",
"text": customStatus,
"fontSize": 25,
"x": 550,
"y": 65,
"width": 625,
"height": 300,
"horizontalAlignment": "left"
}
]
} }
let profileImage = await Compositing.renderProfile(profile, background, renderedCards); if (patreon.perks?.['custom_bg'] && profile.customBackground) {
await interaction.editReply({ files: [profileImage] }); job.elements.unshift(
}, {
encodeStr: function(str) { "type": "image",
let charMapping = { "asset": profile.customBackground,
'&': '&amp;', "x": 0,
'"': '&quot;', "y": 0,
'<': '&lt;', "width": 1200,
'>': '&gt;' "height": 600
}; }
return str.replace(/([\&"<>])/g, function(str, item) { );
return charMapping[item]; }
}); console.log("Fetching ", );
if(process.env.NODE_ENV === "development") {
await interaction.channel.send(`\`\`\`${JSON.stringify(job)}\`\`\``);
}
let { data } = await axios.post(`${process.env.JOSE_ENDPOINT}/jobs`, job);
console.log("Fetched ", data);
await interaction.editReply({ files: [data["path"]] });
} }
} }

View File

@@ -71,8 +71,6 @@ module.exports = {
return; return;
} }
let cardImage = await Rendering.renderCard(card); let cardImage = await Rendering.renderCard(card);
//get base filename
let filename = cardImage.split("/").pop();
let description = ""; let description = "";
//Add a new line after every 4th (long) word or after a full stop //Add a new line after every 4th (long) word or after a full stop
@@ -92,7 +90,7 @@ module.exports = {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle(`${card.Character.name}`) .setTitle(`${card.Character.name}`)
.setDescription(description) .setDescription(description)
.setImage(`attachment://${filename}`) .setImage(cardImage)
.setThumbnail(card.Character.Group.imageURL) .setThumbnail(card.Character.Group.imageURL)
.addFields( .addFields(
{ name: "Owned by", value: `<@${card.User.discordId}>` }, { name: "Owned by", value: `<@${card.User.discordId}>` },
@@ -108,7 +106,7 @@ module.exports = {
embed.setColor(0xff0000); embed.setColor(0xff0000);
embed.addFields({ name: "Burned", value: "This card has been burned" }); embed.addFields({ name: "Burned", value: "This card has been burned" });
} }
const message = await interaction.editReply({ embeds: [embed], files: [cardImage], fetchReply: true }); const message = await interaction.editReply({ embeds: [embed], fetchReply: true });
}, },
/** /**

View File

@@ -101,7 +101,8 @@ const PATREON = {
wishlist: 15, wishlist: 15,
currency: 1.25, currency: 1.25,
daily: 1.5 daily: 1.5
} },
custom_bg: true
}, },
4 : { 4 : {
modifiers: { modifiers: {
@@ -110,7 +111,8 @@ const PATREON = {
wishlist: 25, wishlist: 25,
currency: 1.75, currency: 1.75,
daily: 2 daily: 2
} },
custom_bg: true
}, },
5 : { 5 : {
modifiers: { modifiers: {
@@ -119,7 +121,8 @@ const PATREON = {
wishlist: 45, wishlist: 45,
currency: 2, currency: 2,
daily: 4 daily: 4
} },
custom_bg: true
} }
} }
} }

View File

@@ -2,7 +2,7 @@ version: "3.7"
services: services:
bot: bot:
build: . image: node:20-alpine
command: sh -c "npm config set cache /app/.npm_cache --global && npm install && npx sequelize db:migrate && node ." command: sh -c "npm config set cache /app/.npm_cache --global && npm install && npx sequelize db:migrate && node ."
restart: unless-stopped restart: unless-stopped
environment: environment:

View File

@@ -16,11 +16,12 @@ module.exports = {
(async () => { (async () => {
try { try {
console.log("Registering commands..."); console.log("Registering commands...");
if(process.env.ENV === "production") { if(process.env.NODE_ENV === "production") {
await rest.put(Routes.applicationCommands(CLIENT_ID), {body: commands }); await rest.put(Routes.applicationCommands(CLIENT_ID), {body: commands });
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, process.env.GUILD_ID), {body: [] }); //Clear Guild commands on prod await rest.put(Routes.applicationGuildCommands(CLIENT_ID, process.env.GUILD_ID), {body: [] }); //Clear Guild commands on prod
console.log("Global commands registered"); console.log("Global commands registered");
} else { } else {
await rest.put(Routes.applicationCommands(CLIENT_ID), {body: [] }); //Clear global commands on dev
await rest.put(Routes.applicationGuildCommands(CLIENT_ID, process.env.GUILD_ID), {body: commands }); await rest.put(Routes.applicationGuildCommands(CLIENT_ID, process.env.GUILD_ID), {body: commands });
console.log("Local commands registered"); console.log("Local commands registered");
} }

View File

@@ -0,0 +1,15 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn('Profiles', 'customBackground', {
type: Sequelize.STRING,
allowNull: true
});
},
async down (queryInterface, Sequelize) {
await queryInterface.removeColumn('Profiles', 'customBackground');
}
};

View File

@@ -20,6 +20,7 @@ module.exports = (sequelize, DataTypes) => {
Profile.init({ Profile.init({
userId: DataTypes.INTEGER, userId: DataTypes.INTEGER,
customStatus: DataTypes.STRING, customStatus: DataTypes.STRING,
customBackground: DataTypes.STRING,
slotOne: DataTypes.INTEGER, slotOne: DataTypes.INTEGER,
slotTwo: DataTypes.INTEGER, slotTwo: DataTypes.INTEGER,
slotThree: DataTypes.INTEGER, slotThree: DataTypes.INTEGER,

View File

@@ -1,42 +0,0 @@
const { spawn } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const { Card } = require('../models');
//TODO: Handle missing images
module.exports = {
name: "Compositing",
renderProfile: async function(profile, background, renderedCards) {
let hash = crypto.createHash('md5').update(JSON.stringify(profile) + background).digest('hex');
let outFile = `/app/assets/image_cache/profiles/${hash}.gif`;
console.log('Rendering profile to ' + outFile);
//composite {overlay} {background} [{mask}] [-compose {method}] {result}
let args = ['png:-', 'null:',
'\(', `${renderedCards[0]}`, '-coalesce', '\)',
'-geometry', '+25+85', '-compose', 'over', '-layers', 'composite',
'null:', '\(', `${renderedCards[1]}`, '-coalesce', '-resize', '170x283', '\)',
'-geometry', '+350+300', '-compose', 'over', '-layers', 'composite',
'null:', '\(', `${renderedCards[2]}`, '-coalesce', '-resize', '170x283', '\)',
'-geometry', '+535+300', '-compose', 'over', '-layers', 'composite',
'null:', '\(', `${renderedCards[3]}`, '-coalesce', '-resize', '170x283', '\)',
'-geometry', '+720+300', '-compose', 'over', '-layers', 'composite',
'-layers', 'optimize', outFile];
console.log('GM Args: ' + args);
const composite = spawn('convert', args);
composite.stdin.write(background);
composite.stdin.end();
composite.stderr.on('data', (data) => {
console.log(`stdout: ${data}`);
});
const exitCode = await new Promise( (resolve, reject) => {
composite.on('close', resolve);
})
return outFile;
}
}

View File

@@ -1,6 +1,8 @@
const { Bot } = require("../models"); const { Bot } = require("../models");
const crypto = require("crypto"); const crypto = require("crypto");
const { ReactionUserManager } = require("discord.js"); const { ReactionUserManager } = require("discord.js");
const axios = require("axios");
const fs = require("fs");
module.exports = { module.exports = {
name: "GeneralUtils", name: "GeneralUtils",
@@ -17,5 +19,28 @@ module.exports = {
generateLogID: async function() { generateLogID: async function() {
return crypto.randomBytes(4).toString("hex"); return crypto.randomBytes(4).toString("hex");
} },
formatNumber: function(num, precision = 1) {
const map = [
{ suffix: 'T', threshold: 1e12 },
{ suffix: 'B', threshold: 1e9 },
{ suffix: 'M', threshold: 1e6 },
{ suffix: 'K', threshold: 1e3 },
{ suffix: '', threshold: 1 },
];
const found = map.find((x) => Math.abs(num) >= x.threshold);
if (found) {
const formatted = (Math.floor((num / found.threshold)*10) / 10) + found.suffix;
return formatted;
}
return num;
},
downloadFile: async function(url, path) {
let imageBuffer = await axios.get(url, { responseType: 'arraybuffer' });
fs.writeFileSync(path, imageBuffer.data);
},
} }

View File

@@ -1,7 +1,6 @@
const sharp = require('sharp'); require("dotenv").config();
const crypto = require('crypto');
const fs = require('fs');
const { Character } = require('../models'); const { Character } = require('../models');
const axios = require('axios').default
const QualityColors = { const QualityColors = {
1: {r: 0, g: 0, b: 0}, //bad 1: {r: 0, g: 0, b: 0}, //bad
@@ -12,102 +11,127 @@ const QualityColors = {
6: {r: 255, g: 255, b: 0} //shiny 6: {r: 255, g: 255, b: 0} //shiny
} }
//TODO: Handle missing images
module.exports = { module.exports = {
name: "Rendering", name: "Rendering",
renderCardStack: async function(cards) { renderCardStack: async function(cards) {
for (let card of cards) { await Promise.all(cards.map(async card => {
console.log(`Iterating card ${card.id}`); console.log(`Iterating card ${card.id}`);
card['render'] = await this.renderCard(card); card['render'] = await this.renderCard(card);
}));
let job = {
"type": "stack",
"size": {
"width": 900,
"height": 500
},
"elements": [
{
"type": "image",
"asset": `${cards[0].render}`,
"x": 0,
"y": 0,
"width": 300,
"height": 500
},
{
"type": "image",
"asset": `${cards[1].render}`,
"x": 300,
"y": 0,
"width": 300,
"height": 500
},
{
"type": "image",
"asset": `${cards[2].render}`,
"x": 600,
"y": 0,
"width": 300,
"height": 500
}
]
} }
let image = await sharp({ console.log("Fetching ", );
create: { let { data } = await axios.post(`${process.env.JOSE_ENDPOINT}/jobs`, job);
width: 900, console.log("Fetched ", data);
height: 500, return data["path"];
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0.0 },
animated: true
}
}).composite([
{ input: cards[0].render, gravity: 'northwest' },
{ input: cards[1].render, gravity: 'centre' },
{ input: cards[2].render, gravity: 'northeast' },
]);
let hash = crypto.createHash('md5').update("CHANGEME").digest('hex');
try {
await image.gif({effort: 1}).toFile(`./assets/image_cache/${hash}.gif`);
} catch (error) {
console.log(error);
}
return `./assets/image_cache/${hash}.gif`;
}, },
renderCard: async function(card) { renderCard: async function(card, character=null) {
const character = await Character.findOne({ if(!character) {
where: { character = await Character.findOne({
id: card.characterId where: {
} id: card.characterId
}); }
if (!card.userId) {
return './assets/cards/card_cover.png';
}
let hash = crypto.createHash('md5').update(character.imageIdentifier + card.quality + (card.userId == 1 ? 'unclaimed' : 'claimed')).digest('hex');
//TODO: Add switch to turn off or bypass caching
if (fs.existsSync(`./assets/image_cache/${hash}.gif`)) {
return `./assets/image_cache/${hash}.gif`;
}
console.log(`Rendering card ${hash} for character ${character.name} ${character.imageIdentifier}`);
let filetype = character.imageIdentifier.split('.').pop();
let isAnimated = ['gif', 'webp'].includes(filetype);
let border = await sharp(`./assets/overlays/border.svg`).tint(QualityColors[card.quality]).toBuffer();
//BUGBUG: Custom fonts not loading
let label = Buffer.from(`
<svg width="300" height="500">
<text x="50%" y="95%" text-anchor="middle" style="font-size:28px;">${character.name}</text>
</svg>
`);
let cardImage;
try {
console.log("Loading character image");
cardImage = await sharp(`./assets/cards/${character.imageIdentifier}`,
{ animated: isAnimated, pages: (isAnimated ? -1 : 1) });
await cardImage.toBuffer();
} catch (error) {
console.log(`Missing character image: ${character.imageIdentifier}`);
cardImage = await sharp(`./assets/cards/missing_image.png`);
}
console.log("rendering");
await cardImage.resize(300, 500);
await cardImage.composite([
{input: border, top:0, left: 0, tile: true},
{input: label, top:0, left: 0, tile: true}]);
//BUGBUG: Grayscale does not apply to card border
if (card.userId === 1) {
await cardImage.grayscale()
.modulate({
brightness: 0.5
}); });
} }
if (isAnimated) { console.log(`Rendering card ${card.id} ${character.name} ${character.imageIdentifier}`);
await cardImage.gif({effort: 1})
} else { let characterImage = `${process.env.ASSET_URL}/cards/${character.imageIdentifier}`;
await cardImage.png();
//Hide character info if the card is unclaimed
if (!card.userId) {
characterImage = `${process.env.ASSET_URL}/cards/card_cover.png`;
character.name = ' ';
} }
let extension = isAnimated ? 'gif' : 'png'; let job = {
await cardImage.toFile(`./assets/image_cache/${hash}.${extension}`); "type": "card",
"size": {
"width": 600,
"height": 1000
},
"elements": [
{
"type": "image",
"asset": `${characterImage}`,
"x": 10,
"y": 10,
"width": 580,
"height": 980
},
{
"type": "image",
"asset": `${process.env.ASSET_URL}/overlays/default_frame.png`,
"x": 0,
"y": 0,
"width": 600,
"height": 1000
},
{
"type": "text",
"text": `${character.name}`,
"fontSize": 55,
"x": 0,
"y": 850,
"width": 600,
"height": 300,
"horizontalAlignment": "center"
}
]
}
return `./assets/image_cache/${hash}.${extension}`; if(process.env.NODE_ENV === "development") {
debugElement = {
"type": "text",
"text": `Jose-Endpoint: \n${process.env.JOSE_ENDPOINT}\nNode: \n%nodeid% \nPrint: ${card.printNr} uid: ${card.identifier}\n Serve-Mode: %servemode%\n TS:%timestamp%`,
"fontSize": 35,
"x": 0,
"y": 50,
"width": 600,
"height": 800,
"horizontalAlignment": "center"
}
job.elements.push(debugElement)
}
console.log("Fetching ", JSON.stringify(job));
let { data } = await axios.post(`${process.env.JOSE_ENDPOINT}/jobs`, job);
console.log("Fetched ", JSON.stringify(data));
return data["path"];
} }
} }