diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Bonk.java b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Bonk.java new file mode 100644 index 000000000..feff46de1 --- /dev/null +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/command/Bonk.java @@ -0,0 +1,147 @@ +package dev.sheldan.abstracto.imagegeneration.command; + +import dev.sheldan.abstracto.core.command.UtilityModuleDefinition; +import dev.sheldan.abstracto.core.command.condition.AbstractConditionableCommand; +import dev.sheldan.abstracto.core.command.config.CommandConfiguration; +import dev.sheldan.abstracto.core.command.config.HelpInfo; +import dev.sheldan.abstracto.core.command.config.Parameter; +import dev.sheldan.abstracto.core.command.execution.CommandContext; +import dev.sheldan.abstracto.core.command.execution.CommandResult; +import dev.sheldan.abstracto.core.config.FeatureDefinition; +import dev.sheldan.abstracto.core.interaction.InteractionService; +import dev.sheldan.abstracto.core.interaction.slash.SlashCommandConfig; +import dev.sheldan.abstracto.core.interaction.slash.parameter.SlashCommandParameterService; +import dev.sheldan.abstracto.core.service.ChannelService; +import dev.sheldan.abstracto.core.templating.model.AttachedFile; +import dev.sheldan.abstracto.core.templating.model.MessageToSend; +import dev.sheldan.abstracto.core.templating.service.TemplateService; +import dev.sheldan.abstracto.core.utils.FileService; +import dev.sheldan.abstracto.core.utils.FutureUtils; +import dev.sheldan.abstracto.imagegeneration.config.ImageGenerationFeatureDefinition; +import dev.sheldan.abstracto.imagegeneration.config.ImageGenerationSlashCommandNames; +import dev.sheldan.abstracto.imagegeneration.service.ImageGenerationService; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +@Component +public class Bonk extends AbstractConditionableCommand { + public static final String MEMBER_PARAMETER_KEY = "member"; + + @Autowired + private ImageGenerationService imageGenerationService; + + @Autowired + private TemplateService templateService; + + @Autowired + private ChannelService channelService; + + @Autowired + private FileService fileService; + + @Autowired + private InteractionService interactionService; + + @Autowired + private SlashCommandParameterService slashCommandParameterService; + + private static final String BONK_EMBED_TEMPLATE_KEY = "bonk_response"; + + @Value("${abstracto.feature.imagegeneration.bonk.imagesize}") + private Integer imageSize; + + @Override + public CompletableFuture executeAsync(CommandContext commandContext) { + Member member; + List parameters = commandContext.getParameters().getParameters(); + if(parameters.isEmpty()) { + member = commandContext.getAuthor(); + } else { + member = (Member) parameters.get(0); + } + File bonkGifFile = imageGenerationService.getBonkGif(member.getEffectiveAvatar().getUrl(imageSize)); + MessageToSend messageToSend = templateService.renderEmbedTemplate(BONK_EMBED_TEMPLATE_KEY, new Object()); + // template support does not support binary files + AttachedFile file = AttachedFile + .builder() + .file(bonkGifFile) + .fileName("bonk.gif") + .build(); + messageToSend.getAttachedFiles().add(file); + return FutureUtils.toSingleFutureGeneric(channelService.sendMessageToSendToChannel(messageToSend, commandContext.getChannel())) + .thenAccept(unused -> fileService.safeDeleteIgnoreException(messageToSend.getAttachedFiles().get(0).getFile())) + .thenApply(unused -> CommandResult.fromIgnored()); + } + + @Override + public CompletableFuture executeSlash(SlashCommandInteractionEvent event) { + event.deferReply().queue(); + Member targetMember; + if(slashCommandParameterService.hasCommandOption(MEMBER_PARAMETER_KEY, event)) { + targetMember = slashCommandParameterService.getCommandOption(MEMBER_PARAMETER_KEY, event, Member.class); + } else { + targetMember = event.getMember(); + } + File bonkGifFile = imageGenerationService.getBonkGif(targetMember.getEffectiveAvatar().getUrl(imageSize)); + MessageToSend messageToSend = templateService.renderEmbedTemplate(BONK_EMBED_TEMPLATE_KEY, new Object()); + // template support does not support binary files + AttachedFile file = AttachedFile + .builder() + .file(bonkGifFile) + .fileName("bonk.gif") + .build(); + messageToSend.getAttachedFiles().add(file); + return FutureUtils.toSingleFutureGeneric(interactionService.sendMessageToInteraction(messageToSend, event.getHook())) + .thenAccept(unused -> fileService.safeDeleteIgnoreException(messageToSend.getAttachedFiles().get(0).getFile())) + .thenApply(unused -> CommandResult.fromIgnored()); + } + + @Override + public CommandConfiguration getConfiguration() { + List parameters = new ArrayList<>(); + Parameter memberParameter = Parameter + .builder() + .name(MEMBER_PARAMETER_KEY) + .type(Member.class) + .templated(true) + .optional(true) + .build(); + parameters.add(memberParameter); + HelpInfo helpInfo = HelpInfo + .builder() + .templated(true) + .build(); + + SlashCommandConfig slashCommandConfig = SlashCommandConfig + .builder() + .enabled(true) + .rootCommandName(ImageGenerationSlashCommandNames.IMAGE_GENERATION) + .groupName("memes") + .commandName("bonk") + .build(); + + return CommandConfiguration.builder() + .name("bonk") + .module(UtilityModuleDefinition.UTILITY) + .templated(true) + .supportsEmbedException(true) + .async(true) + .slashCommandConfig(slashCommandConfig) + .parameters(parameters) + .help(helpInfo) + .build(); + } + + @Override + public FeatureDefinition getFeature() { + return ImageGenerationFeatureDefinition.IMAGE_GENERATION; + } +} diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java index 20e67122d..148ed7580 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationServiceBean.java @@ -19,6 +19,9 @@ public class ImageGenerationServiceBean implements ImageGenerationService { @Value("${abstracto.feature.imagegeneration.pat.url}") private String patUrl; + @Value("${abstracto.feature.imagegeneration.bonk.url}") + private String bonkUrl; + @Autowired private HttpService httpService; @@ -40,4 +43,13 @@ public class ImageGenerationServiceBean implements ImageGenerationService { } } + @Override + public File getBonkGif(String imageUrl) { + try { + return httpService.downloadFileToTempFile(bonkUrl.replace("{1}", imageUrl)); + } catch (IOException e) { + throw new AbstractoRunTimeException(String.format("Failed to download bonk gif for url %s with error %s", imageUrl, e.getMessage())); + } + } + } diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties index 1d794bd8f..75f5ca648 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/image-generation-config.properties @@ -6,3 +6,6 @@ abstracto.feature.imagegeneration.triggered.imagesize=128 abstracto.feature.imagegeneration.pat.url=http://${PRIVATE_REST_API_HOST}:${PRIVATE_REST_API_PORT}/memes/pat/file.gif?url={1} abstracto.feature.imagegeneration.pat.imagesize=128 + +abstracto.feature.imagegeneration.bonk.url=http://${PRIVATE_REST_API_HOST}:${PRIVATE_REST_API_PORT}/memes/bonk/file.gif?url={1} +abstracto.feature.imagegeneration.bonk.imagesize=128 \ No newline at end of file diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml index 3bfa810e5..a6686b484 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-impl/src/main/resources/migrations/1.5.19/seedData/command.xml @@ -9,12 +9,17 @@ - + + + + + + \ No newline at end of file diff --git a/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java b/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java index e150e5582..654fed0d6 100644 --- a/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java +++ b/abstracto-application/abstracto-modules/image-generation/image-generation-int/src/main/java/dev/sheldan/abstracto/imagegeneration/service/ImageGenerationService.java @@ -5,4 +5,5 @@ import java.io.File; public interface ImageGenerationService { File getTriggeredGif(String imageUrl); File getPatGif(String imageUrl); + File getBonkGif(String imageUrl); } diff --git a/abstracto-application/abstracto-modules/twitch/twitch-impl/src/main/java/dev/sheldan/abstracto/twitch/service/StreamerServiceBean.java b/abstracto-application/abstracto-modules/twitch/twitch-impl/src/main/java/dev/sheldan/abstracto/twitch/service/StreamerServiceBean.java index 572e17003..9b219cf9d 100644 --- a/abstracto-application/abstracto-modules/twitch/twitch-impl/src/main/java/dev/sheldan/abstracto/twitch/service/StreamerServiceBean.java +++ b/abstracto-application/abstracto-modules/twitch/twitch-impl/src/main/java/dev/sheldan/abstracto/twitch/service/StreamerServiceBean.java @@ -314,7 +314,8 @@ public class StreamerServiceBean implements StreamerService { } log.info("Streamer {} went offline.", streamerId); if (deleteFlagValues.computeIfAbsent(streamer.getServer().getId(), - aLong -> featureModeService.featureModeActive(TwitchFeatureDefinition.TWITCH, aLong, TwitchFeatureMode.DELETE_NOTIFICATION))) { + aLong -> featureModeService.featureModeActive(TwitchFeatureDefinition.TWITCH, aLong, TwitchFeatureMode.DELETE_NOTIFICATION)) + && streamer.getCurrentSession() != null) { Long channelId = streamer.getCurrentSession().getChannel().getId(); Long messageId = streamer.getCurrentSession().getId(); messageService.deleteMessageInChannelInServer(streamer.getServer().getId(), channelId, messageId).thenAccept(unused -> { diff --git a/python/components/image-gen/python/endpoints/bonk.py b/python/components/image-gen/python/endpoints/bonk.py new file mode 100644 index 000000000..9cfd2eac0 --- /dev/null +++ b/python/components/image-gen/python/endpoints/bonk.py @@ -0,0 +1,99 @@ +from __main__ import app + +from PIL import Image, ImageOps +from flask import request +import requests +import validators +import logging +import io + +from utils import flask_utils + + +bonk_angles = [40, 45, -5, 0, 5, 45, -40, 45, -30, -5, 50, 40, 5, 0, -45, 15, 5, 40, 0, -45, 5, -40, 60, -50, -40] +allowed_content_length = 4_000_000 +allowed_image_formats = ['image/png', 'image/jpeg', 'image/jpg', 'image/gif'] +gif_speedup_factor = 2 + + +def get_avatar_height_factor(angle): + return max(0.1, angle * -1 / 45) + +@app.route('/memes/bonk/file.gif') # to directly embed, discord _requires_ this file ending, it seems +def bonk_animated(): + url = request.args.get('url') + if not validators.url(url): + return 'no valid url', 400 + session = requests.Session() + response = session.head(url) + content_type = response.headers['content-type'] + + if content_type not in allowed_image_formats: + return f'Incorrect image type {content_type}', 400 + + actual_content_length = int(response.headers['content-length']) + if actual_content_length > allowed_content_length: + return f'Image too large {actual_content_length}', 400 + + image_file = requests.get(url, stream=True) + input_image = Image.open(io.BytesIO(image_file.content)) + original_input_image = input_image + old_width, old_height = input_image.size + with Image.open('resources/img/newspaper.png') as newspaper_image: + newspaper_image = newspaper_image.convert('RGBA') + newspaper_width, newspaper_height = newspaper_image.size + newspaper_ratio = old_width / newspaper_width + desired_new_newspaper_width = int(newspaper_width * newspaper_ratio) + desired_newspaper_height = newspaper_height + if newspaper_ratio > 1: + newspaper_image = newspaper_image.resize((desired_new_newspaper_width, desired_newspaper_height)) + else: + newspaper_image = ImageOps.contain(newspaper_image, (desired_new_newspaper_width, desired_newspaper_height)) + new_newspaper_width, new_newspaper_height = newspaper_image.size + new_total_height = new_newspaper_height + if content_type == 'image/gif': + logging.info(f'Rendering bonk for gif.') + frame_count = original_input_image.n_frames + old_frames = [] + for frame_index in range(frame_count): + input_image.seek(frame_index) + frame = input_image.convert('RGBA') + old_frames.append(frame) + frames = [] + current_factor = 1 + for index, old_frame in enumerate(old_frames): + angle = bonk_angles[index % len(bonk_angles)] + frame = Image.new('RGBA', (old_width, new_total_height), (0, 0, 0, 0)) + current_factor *= (1 - get_avatar_height_factor(angle)) + current_factor += 0.2 + current_factor = min(1, current_factor) + avatar_height_factor = current_factor + target_height = int(max(1, old_height / 2 * avatar_height_factor)) + old_frame = old_frame.resize((int(old_width / 2), target_height)) + target_position = int(old_height / 2 + (1 - avatar_height_factor) * old_height / 2) + frame.paste(old_frame, (int(old_width / 2), target_position), old_frame) + rotated_news_paper = newspaper_image.rotate(angle, center=(0, new_newspaper_height)) + frame.paste(rotated_news_paper, (0, 0), rotated_news_paper) + frames.append(frame) + return flask_utils.serve_pil_gif_image(frames, (int(original_input_image.info['duration']) / gif_speedup_factor)) + else: + + frames = [] + logging.info(f'Rendering bonk for static image.') + input_image = input_image.convert('RGBA') + current_factor = 1 + for angle in bonk_angles: + frame = Image.new('RGBA', (old_width, old_height), (0, 0, 0, 0)) + current_factor *= (1 - get_avatar_height_factor(angle)) + current_factor += 0.2 + current_factor = min(1, current_factor) + avatar_height_factor = current_factor + target_height = int(max(1, old_height / 2 * avatar_height_factor)) + frame_input_image = input_image.resize((int(old_width / 2), target_height)) + target_position = int(old_height / 2 + (1 - avatar_height_factor) * old_height / 2) + frame.paste(frame_input_image, (int(old_width / 2), target_position), frame_input_image) + rotated_news_paper = newspaper_image.rotate(angle, center=(0, new_newspaper_height)) + frame.paste(rotated_news_paper, (0, 0), rotated_news_paper) + frames.append(frame) + return flask_utils.serve_pil_gif_image(frames, 50) + diff --git a/python/components/image-gen/python/endpoints/pat.py b/python/components/image-gen/python/endpoints/pat.py index 72eb8cb0f..b33c9d76e 100644 --- a/python/components/image-gen/python/endpoints/pat.py +++ b/python/components/image-gen/python/endpoints/pat.py @@ -54,7 +54,7 @@ def pat_animated(): resized_background = ImageOps.contain(background_image, (desired_new_pat_width, pat_height)) resized_background_images.append(resized_background) if content_type == 'image/gif': - logging.info(f'Rendering pet for gif.') + logging.info(f'Rendering pat for gif.') old_frames = [] for frame_index in range(frame_count): input_image.seek(frame_index) @@ -70,7 +70,7 @@ def pat_animated(): return flask_utils.serve_pil_gif_image(frames, int(original_input_image.info['duration'])) else: frames = [] - logging.info(f'Rendering pet for static image.') + logging.info(f'Rendering pat for static image.') input_image = input_image.convert('RGBA') for background_image in resized_background_images: frame = Image.new('RGBA', (old_width, old_height), (0, 0, 0, 0)) diff --git a/python/components/image-gen/resources/img/newspaper.png b/python/components/image-gen/resources/img/newspaper.png new file mode 100644 index 000000000..cd8afbaf1 Binary files /dev/null and b/python/components/image-gen/resources/img/newspaper.png differ