|
| 1 | +package org.comroid.api.config; |
| 2 | + |
| 3 | +import lombok.SneakyThrows; |
| 4 | +import lombok.Value; |
| 5 | +import lombok.experimental.NonFinal; |
| 6 | +import net.dv8tion.jda.api.EmbedBuilder; |
| 7 | +import net.dv8tion.jda.api.entities.IMentionable; |
| 8 | +import net.dv8tion.jda.api.entities.Role; |
| 9 | +import net.dv8tion.jda.api.entities.User; |
| 10 | +import net.dv8tion.jda.api.entities.channel.Channel; |
| 11 | +import net.dv8tion.jda.api.entities.channel.ChannelType; |
| 12 | +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; |
| 13 | +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; |
| 14 | +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; |
| 15 | +import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent; |
| 16 | +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; |
| 17 | +import net.dv8tion.jda.api.hooks.ListenerAdapter; |
| 18 | +import net.dv8tion.jda.api.interactions.components.buttons.Button; |
| 19 | +import net.dv8tion.jda.api.interactions.components.selections.EntitySelectMenu; |
| 20 | +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; |
| 21 | +import net.dv8tion.jda.api.interactions.components.text.TextInput; |
| 22 | +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; |
| 23 | +import net.dv8tion.jda.api.interactions.modals.Modal; |
| 24 | +import org.comroid.annotations.internal.Annotations; |
| 25 | +import org.comroid.api.attr.Aliased; |
| 26 | +import org.comroid.api.attr.Named; |
| 27 | +import org.comroid.api.config.adapter.TypeAdapter; |
| 28 | +import org.comroid.api.data.bind.DataStructure; |
| 29 | +import org.comroid.api.data.seri.DataNode; |
| 30 | +import org.comroid.api.data.seri.MimeType; |
| 31 | +import org.comroid.api.func.exc.ThrowingFunction; |
| 32 | +import org.comroid.api.func.exc.ThrowingSupplier; |
| 33 | +import org.comroid.api.func.ext.Context; |
| 34 | +import org.comroid.api.io.FileHandle; |
| 35 | +import org.comroid.api.java.Activator; |
| 36 | +import org.comroid.api.java.JITAssistant; |
| 37 | +import org.comroid.api.text.Capitalization; |
| 38 | +import org.jetbrains.annotations.NotNull; |
| 39 | + |
| 40 | +import java.awt.*; |
| 41 | +import java.io.File; |
| 42 | +import java.io.FileOutputStream; |
| 43 | +import java.lang.reflect.Field; |
| 44 | +import java.nio.charset.StandardCharsets; |
| 45 | +import java.time.Instant; |
| 46 | +import java.util.Arrays; |
| 47 | +import java.util.Optional; |
| 48 | +import java.util.UUID; |
| 49 | +import java.util.stream.Collectors; |
| 50 | +import java.util.stream.IntStream; |
| 51 | + |
| 52 | +import static org.comroid.api.Polyfill.*; |
| 53 | +import static org.comroid.api.text.Markdown.*; |
| 54 | + |
| 55 | +@Value |
| 56 | +public class ConfigurationManager<T extends DataNode> { |
| 57 | + UUID uuid = UUID.randomUUID(); |
| 58 | + Context context; |
| 59 | + DataStructure<T> struct; |
| 60 | + File file; |
| 61 | + MimeType dataType; |
| 62 | + T config; |
| 63 | + @NonFinal Instant timestamp = Instant.EPOCH; |
| 64 | + |
| 65 | + public ConfigurationManager(Context context, Class<T> type, String filePath) { |
| 66 | + this(context, type, filePath, MimeType.JSON); |
| 67 | + } |
| 68 | + |
| 69 | + public ConfigurationManager(Context context, Class<T> type, String filePath, MimeType dataType) { |
| 70 | + this.context = context; |
| 71 | + this.struct = DataStructure.of(type); |
| 72 | + this.file = new FileHandle(filePath); |
| 73 | + this.dataType = dataType; |
| 74 | + this.config = ctor(); |
| 75 | + |
| 76 | + invokeAdapters(); |
| 77 | + } |
| 78 | + |
| 79 | + public T initialize() { |
| 80 | + if (!file.exists()) save(); // save default config |
| 81 | + reload(); |
| 82 | + return config; |
| 83 | + } |
| 84 | + |
| 85 | + public void reload() { |
| 86 | + if (ftime().isBefore(timestamp)) return; // reload is not necessary |
| 87 | + this.timestamp = Instant.now(); |
| 88 | + } |
| 89 | + |
| 90 | + @SneakyThrows |
| 91 | + public void save() { |
| 92 | + DataNode data = config; |
| 93 | + var prefix = dataType.getSerializerPrefix(); |
| 94 | + if (prefix != null) data = prefix.apply(data); |
| 95 | + try (var fos = new FileOutputStream(file)) { |
| 96 | + fos.write(data.toSerializedString().getBytes(StandardCharsets.UTF_8)); |
| 97 | + } |
| 98 | + timestamp = ftime(); |
| 99 | + } |
| 100 | + |
| 101 | + private void invokeAdapters() { |
| 102 | + invokePropertyAdaptersRecursive(context, config, struct, config); |
| 103 | + } |
| 104 | + |
| 105 | + private Instant ftime() { |
| 106 | + return Instant.ofEpochMilli(file.lastModified()); |
| 107 | + } |
| 108 | + |
| 109 | + private T ctor() { |
| 110 | + return uncheckedCast(Activator.get(struct.getType()).createInstance(DataNode.Value.NULL)); |
| 111 | + } |
| 112 | + |
| 113 | + private void invokePropertyAdaptersRecursive(Context context, DataNode node, DataStructure<?> struct, Object it) { |
| 114 | + if (node.isNull()) return; |
| 115 | + for (var property : struct.getProperties()) { |
| 116 | + var propType = property.getType(); |
| 117 | + if (propType.isStandard()) continue; |
| 118 | + var propClass = propType.getTargetClass(); |
| 119 | + var propStruct = DataStructure.of(propClass); |
| 120 | + |
| 121 | + // try recurse |
| 122 | + invokePropertyAdaptersRecursive(context, node.get(property.getName()), propStruct, property.getFrom(it)); |
| 123 | + |
| 124 | + // initialize dependencies |
| 125 | + if (!property.isAnnotationPresent(Adapt.class)) continue; |
| 126 | + JITAssistant.prepare(property.getAnnotation(Adapt.class).value()).join(); |
| 127 | + |
| 128 | + // find & apply adapter |
| 129 | + Optional.ofNullable(TypeAdapter.CACHE.getOrDefault(propClass, null)).map(adp -> { |
| 130 | + var key = property.getName() + Capitalization.Title_Case.convert(adp.getNameSuffix()); |
| 131 | + var value = node.get(key).as(adp.getSerialized()).orElseGet(() -> Annotations.defaultValue(property)); |
| 132 | + return adp.deserialize(context, uncheckedCast(value)); |
| 133 | + }).ifPresent(value -> property.setFor(it, uncheckedCast(value))); |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + public interface Presentation { |
| 138 | + void clear(); |
| 139 | + |
| 140 | + void refresh(); |
| 141 | + |
| 142 | + default void resend() { |
| 143 | + clear(); |
| 144 | + refresh(); |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + @Value |
| 149 | + public class Presentation$JDA implements Presentation { |
| 150 | + TextChannel channel; |
| 151 | + |
| 152 | + @Override |
| 153 | + public void clear() { |
| 154 | + channel.getHistory().retrievePast(100).flatMap(channel::deleteMessages).queue(); |
| 155 | + } |
| 156 | + |
| 157 | + @Override |
| 158 | + public void refresh() { |
| 159 | + for (var property : struct.getProperties()) |
| 160 | + sendAttributeMessageRecursive(property.getName(), property, config, 1); |
| 161 | + } |
| 162 | + |
| 163 | + private void sendAttributeMessageRecursive(String fullName, DataStructure<?>.Property<?> property, Object it, int level) { |
| 164 | + var title = IntStream.range(0, level).mapToObj($ -> "#").collect(Collectors.joining()) + " Config Value " + Code.apply(fullName); |
| 165 | + var menuId = uuid.toString() + ':' + fullName; |
| 166 | + var propType = property.getType(); |
| 167 | + var propClass = propType.getTargetClass(); |
| 168 | + |
| 169 | + ifs: |
| 170 | + { |
| 171 | + if (!propType.isStandard()) { |
| 172 | + var from = property.getFrom(it); |
| 173 | + var struct = DataStructure.of(propClass); |
| 174 | + for (var subProperty : struct.getProperties()) |
| 175 | + sendAttributeMessageRecursive(fullName + '.' + subProperty.getName(), subProperty, from, level + 1); |
| 176 | + } else if (property.isAnnotationPresent(Adapt.class)) { |
| 177 | + // prepare dependencies |
| 178 | + JITAssistant.prepare(property.getAnnotation(Adapt.class).value()).join(); |
| 179 | + |
| 180 | + // try send mentionable selection box |
| 181 | + if (IMentionable.class.isAssignableFrom(propClass)) { |
| 182 | + // choose SelectTarget |
| 183 | + var target = getSelectTarget(property); |
| 184 | + var builder = EntitySelectMenu.create(menuId, target).setRequiredRange(1, 1); |
| 185 | + |
| 186 | + // set default value |
| 187 | + EntitySelectMenu.DefaultValue def; |
| 188 | + var currentId = (long) property.getFrom(it); |
| 189 | + if (Channel.class.isAssignableFrom(propClass)) def = EntitySelectMenu.DefaultValue.channel(currentId); |
| 190 | + else if (User.class.isAssignableFrom(propClass)) def = EntitySelectMenu.DefaultValue.user(currentId); |
| 191 | + else if (Role.class.isAssignableFrom(propClass)) def = EntitySelectMenu.DefaultValue.role(currentId); |
| 192 | + else throw new IllegalArgumentException("Invalid mentionable: " + propClass.getCanonicalName()); |
| 193 | + builder.setDefaultValues(def); |
| 194 | + |
| 195 | + // choose ChannelType if necessary |
| 196 | + if (target == EntitySelectMenu.SelectTarget.CHANNEL) builder.setChannelTypes(Arrays.stream(ChannelType.values()) |
| 197 | + .filter(type -> type.getInterface().isAssignableFrom(propClass)) |
| 198 | + .toList()); |
| 199 | + |
| 200 | + // send mentionable selection box |
| 201 | + var listener = new ListenerAdapter[1]; |
| 202 | + listener[0] = new ListenerAdapter() { |
| 203 | + @Override |
| 204 | + public void onEntitySelectInteraction(@NotNull EntitySelectInteractionEvent event) { |
| 205 | + if (!menuId.equals(event.getComponentId())) return; |
| 206 | + event.getValues().stream().findAny().ifPresent(mentionable -> property.setFor(it, uncheckedCast(mentionable))); |
| 207 | + //channel.getJDA().removeEventListener(listener[0]); |
| 208 | + } |
| 209 | + }; |
| 210 | + channel.getJDA().addEventListener(listener[0]); |
| 211 | + channel.sendMessage(title).addActionRow(builder.build()).queue(); |
| 212 | + } else break ifs; |
| 213 | + } else if (propClass.isEnum()) { |
| 214 | + // prepare enum selection box |
| 215 | + var menu = StringSelectMenu.create(menuId); |
| 216 | + Arrays.stream(propClass.getFields()) |
| 217 | + .filter(Field::isEnumConstant) |
| 218 | + .forEach(field -> menu.addOption(Aliased.$(field) |
| 219 | + .findAny() |
| 220 | + .or(() -> Optional.ofNullable(ThrowingSupplier.sneaky(() -> Named.$(field.get(null))).get())) |
| 221 | + .orElseGet(field::getName), field.getName(), Annotations.descriptionText(field))); |
| 222 | + |
| 223 | + // send enum selection box |
| 224 | + var listener = new ListenerAdapter[1]; |
| 225 | + listener[0] = new ListenerAdapter() { |
| 226 | + @Override |
| 227 | + public void onStringSelectInteraction(@NotNull StringSelectInteractionEvent event) { |
| 228 | + if (!menuId.equals(event.getComponentId())) return; |
| 229 | + event.getValues() |
| 230 | + .stream() |
| 231 | + .flatMap(value -> Arrays.stream(propClass.getFields()) |
| 232 | + .filter(Field::isEnumConstant) |
| 233 | + .filter(field -> value.equals(Aliased.$(field) |
| 234 | + .findAny() |
| 235 | + .or(() -> Optional.ofNullable(ThrowingSupplier.sneaky(() -> Named.$(field.get(null))).get())) |
| 236 | + .orElseGet(field::getName)))) |
| 237 | + .findAny() |
| 238 | + .map(ThrowingFunction.sneaky(field -> field.get(null))) |
| 239 | + .ifPresent(value -> property.setFor(it, uncheckedCast(value))); |
| 240 | + //channel.getJDA().removeEventListener(listener[0]); |
| 241 | + } |
| 242 | + }; |
| 243 | + channel.getJDA().addEventListener(listener[0]); |
| 244 | + channel.sendMessage(title).addActionRow(menu.build()).queue(); |
| 245 | + } |
| 246 | + return; |
| 247 | + } |
| 248 | + |
| 249 | + // just send a simple textbox-based editor message |
| 250 | + var listener = new ListenerAdapter[1]; |
| 251 | + listener[0] = new ListenerAdapter() { |
| 252 | + @Override |
| 253 | + public void onButtonInteraction(@NotNull ButtonInteractionEvent event) { |
| 254 | + if (!(menuId + ":button").equals(event.getComponentId())) return; |
| 255 | + event.replyModal(Modal.create(menuId + ":modal", title) |
| 256 | + .addActionRow(TextInput.create("newValue", "New Value", TextInputStyle.SHORT).build()) |
| 257 | + .build()).queue(); |
| 258 | + } |
| 259 | + |
| 260 | + @Override |
| 261 | + public void onModalInteraction(@NotNull ModalInteractionEvent event) { |
| 262 | + if (!(menuId + ":modal").equals(event.getModalId())) return; |
| 263 | + var newValue = event.getValue("newValue"); |
| 264 | + var value = propType.parse(String.valueOf(newValue)); |
| 265 | + property.setFor(it, uncheckedCast(value)); |
| 266 | + //channel.getJDA().removeEventListener(listener[0]); |
| 267 | + } |
| 268 | + }; |
| 269 | + channel.getJDA().addEventListener(listener[0]); |
| 270 | + channel.sendMessageEmbeds(new EmbedBuilder().setTitle(title) |
| 271 | + .setColor(new Color(86, 98, 246)) |
| 272 | + .setDescription(String.join("\n", property.getDescription())) |
| 273 | + .addField("Current Value [" + Code.apply(propType.getName()) + "]", CodeBlock.apply(String.valueOf(property.getFrom(it))), false) |
| 274 | + .build()).addActionRow(Button.primary(menuId + ":button", "Change Value...")).queue(); |
| 275 | + } |
| 276 | + |
| 277 | + private static EntitySelectMenu.@NotNull SelectTarget getSelectTarget(DataStructure<?>.Property<?> property) { |
| 278 | + EntitySelectMenu.SelectTarget target; |
| 279 | + if (Channel.class.isAssignableFrom(property.getType().getTargetClass())) target = EntitySelectMenu.SelectTarget.CHANNEL; |
| 280 | + else if (User.class.isAssignableFrom(property.getType().getTargetClass())) target = EntitySelectMenu.SelectTarget.USER; |
| 281 | + else if (Role.class.isAssignableFrom(property.getType().getTargetClass())) target = EntitySelectMenu.SelectTarget.ROLE; |
| 282 | + else throw new IllegalArgumentException("Invalid mentionable: " + property.getType().getTargetClass().getCanonicalName()); |
| 283 | + return target; |
| 284 | + } |
| 285 | + } |
| 286 | +} |
0 commit comments