emojisToNumber(String input) {
75 | try {
76 | return Optional.of(Integer.parseInt(parse(input)));
77 | } catch (NumberFormatException e) {
78 | return Optional.empty();
79 | }
80 | }
81 |
82 | private String parse(String input) {
83 | if (input.isEmpty()) return "";
84 | EmojiMapping found = startsWithNumberEmoji(input);
85 | String shortened = input.replaceFirst(found.emoji, "");
86 | return found.digit + parse(shortened);
87 | }
88 |
89 | private EmojiMapping startsWithNumberEmoji(String input) {
90 | return EMOJI_TO_DIGIT.entrySet().stream()
91 | .filter(entry -> input.startsWith(entry.getKey()))
92 | .findAny()
93 | .map(entry -> new EmojiMapping(entry.getKey(), entry.getValue()))
94 | .orElseThrow(() -> new NumberFormatException("Input " + input + " does not start with a known emoji."));
95 | }
96 |
97 | private static class EmojiMapping {
98 | public final String emoji;
99 | public final char digit;
100 |
101 | public EmojiMapping(String emoji, char digit) {
102 | this.emoji = emoji;
103 | this.digit = digit;
104 | }
105 | }
106 |
107 | }
108 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/EventWaiter.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax;
19 |
20 | import net.dv8tion.jda.api.events.GenericEvent;
21 | import net.dv8tion.jda.api.hooks.EventListener;
22 | import org.springframework.stereotype.Component;
23 |
24 | import java.util.ArrayList;
25 | import java.util.HashMap;
26 | import java.util.HashSet;
27 | import java.util.List;
28 | import java.util.Set;
29 | import java.util.concurrent.ScheduledExecutorService;
30 | import java.util.concurrent.ScheduledThreadPoolExecutor;
31 | import java.util.concurrent.TimeUnit;
32 | import java.util.function.Consumer;
33 | import java.util.function.Predicate;
34 |
35 | /**
36 | * Created by napster on 05.09.18.
37 | *
38 | * Just like the JDA-Utils EventWaiter (Apache v2) but a lot less crappy aka
39 | * - threadsafe
40 | * - doesn't block the main JDA threads
41 | * - efficient
42 | * - stricter types
43 | * - doesn't have to wait 6 months (and counting) for fixes
44 | */
45 | @Component
46 | public class EventWaiter implements EventListener {
47 |
48 | //this thread pool runs the actions as well as the timeout actions
49 | private final ScheduledExecutorService pool;
50 | //modifications to the hash map and sets have to go through this single threaded pool
51 | private final ScheduledExecutorService single;
52 |
53 | //These stateful collections are only threadsafe when modified though the single executor
54 | private final List> toRemove = new ArrayList<>(); //reused object
55 | private final HashMap, Set>> waitingEvents;
56 |
57 | public EventWaiter(ScheduledThreadPoolExecutor jdaThreadPool) {
58 | this.waitingEvents = new HashMap<>();
59 | this.pool = jdaThreadPool;
60 | this.single = new ScheduledThreadPoolExecutor(1);
61 | }
62 |
63 | public EventWaiter.WaitingEvent waitForEvent(
64 | Class classType, Predicate condition, Consumer action, long timeout, TimeUnit unit,
65 | Runnable timeoutAction
66 | ) {
67 |
68 | EventWaiter.WaitingEvent we = new EventWaiter.WaitingEvent<>(condition, action);
69 |
70 | this.single.execute(() -> {
71 | this.waitingEvents.computeIfAbsent(classType, c -> new HashSet<>())
72 | .add(we);
73 | this.single.schedule(() -> {
74 | var set = this.waitingEvents.get(classType);
75 | if (set == null) {
76 | return;
77 | }
78 | if (set.remove(we)) {
79 | this.pool.execute(timeoutAction);
80 | }
81 |
82 | if (set.isEmpty()) {
83 | this.waitingEvents.remove(classType);
84 | }
85 | }, timeout, unit);
86 | });
87 | return we;
88 | }
89 |
90 | @Override
91 | public final void onEvent(GenericEvent event) {
92 | Class> cc = event.getClass();
93 |
94 | // Runs at least once for the fired Event, at most
95 | // once for each superclass (excluding Object) because
96 | // Class#getSuperclass() returns null when the superclass
97 | // is primitive, void, or (in this case) Object.
98 | while (cc != null && cc != Object.class) {
99 | Class> clazz = cc;
100 | if (this.waitingEvents.get(clazz) != null) {
101 | this.single.execute(() -> {
102 | Set> set = this.waitingEvents.get(clazz);
103 | @SuppressWarnings({"unchecked", "rawtypes", "java:S3740"}) Predicate filter = we -> we.attempt(event);
104 | set.stream().filter(filter).forEach(this.toRemove::add);
105 | set.removeAll(this.toRemove);
106 | this.toRemove.clear();
107 |
108 | if (set.isEmpty()) {
109 | this.waitingEvents.remove(clazz);
110 | }
111 | });
112 | }
113 |
114 | cc = cc.getSuperclass();
115 | }
116 | }
117 |
118 | public class WaitingEvent {
119 | final Predicate condition;
120 | final Consumer action;
121 |
122 | WaitingEvent(Predicate condition, Consumer action) {
123 | this.condition = condition;
124 | this.action = action;
125 | }
126 |
127 | private boolean attempt(T event) {
128 | if (this.condition.test(event)) {
129 | EventWaiter.this.pool.execute(() -> this.action.accept(event));
130 | return true;
131 | }
132 | return false;
133 | }
134 |
135 | public void cancel() {
136 | EventWaiter.this.single.execute(
137 | () -> EventWaiter.this.waitingEvents.values().forEach(set -> set.remove(this))
138 | );
139 | }
140 | }
141 |
142 | }
143 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/HelpDeskListener.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018-2022 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax;
19 |
20 | import com.github.benmanes.caffeine.cache.Cache;
21 | import com.github.benmanes.caffeine.cache.Caffeine;
22 | import com.github.benmanes.caffeine.cache.RemovalCause;
23 | import java.net.URI;
24 | import java.time.Duration;
25 | import java.util.Map;
26 | import java.util.Objects;
27 | import java.util.Optional;
28 | import java.util.concurrent.ConcurrentHashMap;
29 | import java.util.concurrent.TimeUnit;
30 | import net.dv8tion.jda.api.entities.Member;
31 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
32 | import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
33 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
34 | import net.dv8tion.jda.api.events.session.ReadyEvent;
35 | import net.dv8tion.jda.api.hooks.ListenerAdapter;
36 | import net.dv8tion.jda.api.sharding.ShardManager;
37 | import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
38 | import net.dv8tion.jda.api.utils.messages.MessageCreateData;
39 | import org.springframework.lang.Nullable;
40 | import org.springframework.stereotype.Component;
41 | import org.yaml.snakeyaml.error.YAMLException;
42 | import space.npstr.baymax.config.properties.BaymaxConfig;
43 | import space.npstr.baymax.db.TemporaryRoleService;
44 | import space.npstr.baymax.helpdesk.Node;
45 | import space.npstr.baymax.helpdesk.exception.MalformedModelException;
46 |
47 | /**
48 | * Created by napster on 05.09.18.
49 | */
50 | @Component
51 | public class HelpDeskListener extends ListenerAdapter {
52 |
53 | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(HelpDeskListener.class);
54 |
55 | public static final int EXPIRE_MINUTES = 2;
56 |
57 | private final EventWaiter eventWaiter;
58 | private final ModelLoader modelLoader;
59 | private final BaymaxConfig baymaxConfig;
60 | private final RestActions restActions;
61 | private final TemporaryRoleService temporaryRoleService;
62 |
63 | //channel id of the helpdesk <-> user id <-> ongoing dialogue
64 | private final Map> helpDesksDialogues = new ConcurrentHashMap<>();
65 |
66 | public HelpDeskListener(EventWaiter eventWaiter, ModelLoader modelLoader, BaymaxConfig baymaxConfig,
67 | RestActions restActions, TemporaryRoleService temporaryRoleService) {
68 |
69 | this.eventWaiter = eventWaiter;
70 | this.modelLoader = modelLoader;
71 | this.baymaxConfig = baymaxConfig;
72 | this.restActions = restActions;
73 | this.temporaryRoleService = temporaryRoleService;
74 | }
75 |
76 | @Override
77 | public void onMessageReceived(MessageReceivedEvent event) {
78 | MessageChannel messageChannel = event.getChannel();
79 | if (!(messageChannel instanceof TextChannel channel) || !messageChannel.canTalk()) {
80 | return;
81 | }
82 |
83 | var helpDeskOpt = this.baymaxConfig.helpDesks().stream()
84 | .filter(helpDesk -> helpDesk.channelId() == channel.getIdLong())
85 | .findAny();
86 |
87 | if (helpDeskOpt.isEmpty()) {
88 | return;
89 | }
90 | if (event.getAuthor().isBot()) {
91 | if (event.getAuthor().getIdLong() == event.getJDA().getSelfUser().getIdLong()) {
92 | return;
93 | }
94 |
95 | restActions.deleteMessageAfter(event.getMessage(), Duration.ofSeconds(5))
96 | .whenComplete((__, t) -> {
97 | if (t != null) {
98 | log.error("Failed to delete bot message in channel {}", channel, t);
99 | }
100 | });
101 | return;
102 | }
103 |
104 | var helpDesk = helpDeskOpt.get();
105 | var userDialogues = this.helpDesksDialogues.computeIfAbsent(
106 | helpDesk.channelId(), channelId -> this.createUserDialogueCache()
107 | );
108 | Member member = event.getMember();
109 | if (member == null) {
110 | return;
111 | }
112 | if (isStaff(member)) {
113 | if (event.getMessage().getMentions().isMentioned(event.getJDA().getSelfUser())) {
114 | String content = event.getMessage().getContentRaw().toLowerCase();
115 | if (content.contains("init")) {
116 | userDialogues.invalidateAll();
117 | userDialogues.cleanUp();
118 | init(channel, helpDesk.modelName(), helpDesk.modelUri());
119 | return;
120 | } else if (content.contains("reload")) {
121 | try {
122 | var reloadedModel = this.modelLoader.attemptReload(helpDesk.modelName(), helpDesk.modelUri());
123 | userDialogues.invalidateAll();
124 | userDialogues.cleanUp();
125 | init(channel, reloadedModel);
126 | } catch (MalformedModelException | YAMLException e) {
127 | MessageCreateData message = new MessageCreateBuilder().addContent("Failed to load model due to: **")
128 | .addContent(e.getMessage())
129 | .addContent("**")
130 | .build();
131 | this.restActions.sendMessage(channel, message)
132 | .whenComplete((__, t) -> {
133 | if (t != null) {
134 | log.error("Failed to reply in channel {}", channel, t);
135 | }
136 | });
137 | }
138 | return;
139 | }
140 | }
141 | }
142 |
143 | userDialogues.get(event.getAuthor().getIdLong(),
144 | userId -> {
145 | var model = this.modelLoader.getModel(helpDesk.modelName(), helpDesk.modelUri());
146 | return new UserDialogue(this.eventWaiter, model, event, this.restActions, this.temporaryRoleService);
147 | });
148 | }
149 |
150 | // revisit for when there is more than one shard
151 | @Override
152 | public void onReady(ReadyEvent event) {
153 | //1. Clean up the channel
154 | //2. Post the root message
155 |
156 | ShardManager shardManager = Objects.requireNonNull(event.getJDA().getShardManager(), "Shard manager required");
157 | for (BaymaxConfig.HelpDesk helpDesk : this.baymaxConfig.helpDesks()) {
158 | TextChannel channel = shardManager.getTextChannelById(helpDesk.channelId());
159 | if (channel == null) {
160 | log.warn("Failed to find and setup configured help desk channel {}", helpDesk.channelId());
161 | return;
162 | }
163 | init(channel, helpDesk.modelName(), helpDesk.modelUri());
164 | }
165 | }
166 |
167 | private void init(TextChannel channel, String modelName, @Nullable URI modelUri) {
168 | init(channel, this.modelLoader.getModel(modelName, modelUri));
169 | }
170 |
171 | private void init(TextChannel channel, Map model) {
172 | try {
173 | this.restActions.purgeChannel(channel)
174 | .exceptionally(t -> {
175 | log.error("Failed to purge messages for init in channel {}", channel, t);
176 | return null; //Void
177 | })
178 | .thenCompose(__ -> {
179 | NodeContext nodeContext = new NodeContext(model.get("root"), Optional.empty());
180 | MessageCreateData message = UserDialogue.asMessage(nodeContext);
181 | return this.restActions.sendMessage(channel, message);
182 | })
183 | .whenComplete((__, t) -> {
184 | if (t != null) {
185 | log.error("Failed to send init message in channel {}", channel, t);
186 | }
187 | });
188 | } catch (Exception e) {
189 | log.error("Failed to purge channel {}", channel, e);
190 | }
191 | }
192 |
193 | private boolean isStaff(Member member) {
194 | return member.getRoles().stream()
195 | .anyMatch(role -> this.baymaxConfig.staffRoleIds().contains(role.getIdLong()));
196 | }
197 |
198 | private Cache createUserDialogueCache() {
199 | return Caffeine.newBuilder()
200 | .expireAfterAccess(EXPIRE_MINUTES, TimeUnit.MINUTES)
201 | .removalListener((@Nullable Long userId, @Nullable UserDialogue userDialogue, RemovalCause cause) -> {
202 | if (userDialogue != null) {
203 | userDialogue.done();
204 | }
205 | })
206 | .build();
207 | }
208 | }
209 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/Launcher.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax;
19 |
20 | import jakarta.annotation.PreDestroy;
21 | import java.time.Instant;
22 | import java.time.ZoneId;
23 | import java.time.format.DateTimeFormatter;
24 | import java.util.List;
25 | import java.util.Optional;
26 | import java.util.concurrent.ScheduledThreadPoolExecutor;
27 | import java.util.concurrent.TimeUnit;
28 | import net.dv8tion.jda.api.JDAInfo;
29 | import net.dv8tion.jda.api.sharding.ShardManager;
30 | import org.springframework.beans.factory.ObjectProvider;
31 | import org.springframework.boot.ApplicationArguments;
32 | import org.springframework.boot.ApplicationRunner;
33 | import org.springframework.boot.SpringApplication;
34 | import org.springframework.boot.autoconfigure.SpringBootApplication;
35 | import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
36 | import org.springframework.boot.context.event.ApplicationFailedEvent;
37 | import org.springframework.boot.context.properties.EnableConfigurationProperties;
38 | import space.npstr.baymax.config.properties.BaymaxConfig;
39 | import space.npstr.baymax.info.AppInfo;
40 | import space.npstr.baymax.info.GitRepoState;
41 |
42 | /**
43 | * Created by napster on 05.09.18.
44 | */
45 | @SpringBootApplication
46 | @EnableConfigurationProperties({
47 | BaymaxConfig.class,
48 | })
49 | public class Launcher implements ApplicationRunner {
50 |
51 | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(Launcher.class);
52 |
53 | private final Thread shutdownHook;
54 | private volatile boolean shutdownHookAdded = false;
55 | private volatile boolean shutdownHookExecuted = false;
56 |
57 | @SuppressWarnings("squid:S106") // CLI usage intended
58 | public static void main(String[] args) {
59 | //just post the info to the console
60 | if (args.length > 0 &&
61 | (args[0].equalsIgnoreCase("-v")
62 | || args[0].equalsIgnoreCase("--version")
63 | || args[0].equalsIgnoreCase("-version"))) {
64 | System.out.println("Version flag detected. Printing version info, then exiting.");
65 | System.out.println(getVersionInfo());
66 | System.out.println("Version info printed, exiting.");
67 | return;
68 | }
69 |
70 | System.setProperty("spring.config.name", "baymax");
71 | SpringApplication app = new SpringApplication(Launcher.class);
72 | app.setAdditionalProfiles("secrets");
73 |
74 | app.addListeners(
75 | event -> {
76 | if (event instanceof ApplicationEnvironmentPreparedEvent) {
77 | log.info(getVersionInfo());
78 | }
79 | },
80 | event -> {
81 | if (event instanceof ApplicationFailedEvent failed) {
82 | log.error("Application failed", failed.getException());
83 | }
84 | }
85 | );
86 | app.run(args);
87 | }
88 |
89 | public Launcher(ObjectProvider shardManager, ScheduledThreadPoolExecutor jdaThreadPool) {
90 | this.shutdownHook = new Thread(() -> {
91 | try {
92 | shutdown(shardManager, jdaThreadPool);
93 | } catch (Exception e) {
94 | log.error("Uncaught exception in shutdown hook", e);
95 | } finally {
96 | this.shutdownHookExecuted = true;
97 | }
98 | }, "shutdown-hook");
99 | }
100 |
101 | @Override
102 | public void run(ApplicationArguments args) {
103 | Runtime.getRuntime().addShutdownHook(this.shutdownHook);
104 | this.shutdownHookAdded = true;
105 | }
106 |
107 | @PreDestroy
108 | public void waitOnShutdownHook() {
109 |
110 | // This condition can happen when spring encountered an exception during start up and is tearing itself down,
111 | // but did not call System.exit, so out shutdown hooks are not being executed.
112 | // If spring is tearing itself down, we always want to exit the JVM, so we call System.exit manually here, so
113 | // our shutdown hooks will be run, and the loop below does not hang forever.
114 | if (!isShuttingDown()) {
115 | System.exit(1);
116 | }
117 |
118 | while (this.shutdownHookAdded && !this.shutdownHookExecuted) {
119 | log.info("Waiting on main shutdown hook to be done...");
120 | try {
121 | Thread.sleep(5000);
122 | } catch (InterruptedException ignored) {
123 | Thread.currentThread().interrupt();
124 | }
125 | }
126 |
127 | log.info("Main shutdown hook done! Proceeding.");
128 | }
129 |
130 | private static final Thread DUMMY_HOOK = new Thread();
131 |
132 | public static boolean isShuttingDown() {
133 | try {
134 | Runtime.getRuntime().addShutdownHook(DUMMY_HOOK);
135 | Runtime.getRuntime().removeShutdownHook(DUMMY_HOOK);
136 | } catch (IllegalStateException ignored) {
137 | return true;
138 | }
139 | return false;
140 | }
141 |
142 | private void shutdown(ObjectProvider shardManager, ScheduledThreadPoolExecutor jdaThreadPool) {
143 | //okHttpClient claims that a shutdown isn't necessary
144 |
145 | //shutdown JDA
146 | log.info("Shutting down shards");
147 | Optional.ofNullable(shardManager.getIfAvailable()).ifPresent(ShardManager::shutdown);
148 |
149 | //shutdown executors
150 | log.info("Shutting down jda thread pool");
151 | final List jdaThreadPoolRunnables = jdaThreadPool.shutdownNow();
152 | log.info("{} jda thread pool runnables cancelled", jdaThreadPoolRunnables.size());
153 |
154 | try {
155 | jdaThreadPool.awaitTermination(30, TimeUnit.SECONDS);
156 | log.info("Jda thread pool terminated");
157 | } catch (final InterruptedException e) {
158 | log.warn("Interrupted while awaiting executors termination", e);
159 | Thread.currentThread().interrupt();
160 | }
161 | }
162 |
163 | private static String getVersionInfo() {
164 | // deduplication of individual lines doesnt make any sense for ascii art
165 | @SuppressWarnings("squid:S1192")
166 | String baymax
167 | //copypasta'd from http://textart4u.blogspot.com/2014/10/disney-baymax-face-text-art-copy-paste.html
168 | = "\t¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶\n"
169 | + "\t¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶\n"
170 | + "\t¶¶¶¶¶¶¶¶¶___________________¶¶¶¶¶¶¶¶¶\n"
171 | + "\t¶¶¶¶¶¶_________________________¶¶¶¶¶¶\n"
172 | + "\t¶¶¶¶_____________________________¶¶¶¶\n"
173 | + "\t¶¶¶______¶¶¶_____________¶¶¶______¶¶¶\n"
174 | + "\t¶¶______¶¶¶¶¶___________¶¶¶¶¶______¶¶\n"
175 | + "\t¶¶______¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶______¶¶\n"
176 | + "\t¶¶_______¶¶¶_____________¶¶¶_______¶¶\n"
177 | + "\t¶¶¶_______________________________¶¶¶\n"
178 | + "\t¶¶¶¶_____________________________¶¶¶¶\n"
179 | + "\t¶¶¶¶¶¶_________________________¶¶¶¶¶¶\n"
180 | + "\t¶¶¶¶¶¶¶¶_____________________¶¶¶¶¶¶¶¶\n"
181 | + "\t¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶\n"
182 | + "\t¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶¶\n";
183 |
184 | return "\n\n" + baymax
185 | + "\n"
186 | + "\n\tVersion: " + AppInfo.getAppInfo().getVersion()
187 | + "\n\tBuild: " + AppInfo.getAppInfo().getBuildNumber()
188 | + "\n\tBuild time: " + asTimeInCentralEurope(AppInfo.getAppInfo().getBuildTime())
189 | + "\n\tCommit: " + GitRepoState.getGitRepositoryState().commitIdAbbrev + " (" + GitRepoState.getGitRepositoryState().branch + ")"
190 | + "\n\tCommit time: " + asTimeInCentralEurope(GitRepoState.getGitRepositoryState().commitTime * 1000)
191 | + "\n\tJVM: " + System.getProperty("java.version")
192 | + "\n\tJDA: " + JDAInfo.VERSION
193 | + "\n";
194 | }
195 |
196 | private static String asTimeInCentralEurope(final long epochMillis) {
197 | return timeInCentralEuropeFormatter().format(Instant.ofEpochMilli(epochMillis));
198 | }
199 |
200 | //DateTimeFormatter is not threadsafe
201 | private static DateTimeFormatter timeInCentralEuropeFormatter() {
202 | return DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss z")
203 | .withZone(ZoneId.of("Europe/Berlin"));
204 | }
205 | }
206 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/ModelLoader.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax;
19 |
20 | import org.slf4j.Logger;
21 | import org.slf4j.LoggerFactory;
22 | import org.springframework.lang.Nullable;
23 | import org.springframework.stereotype.Component;
24 | import space.npstr.baymax.helpdesk.ModelParser;
25 | import space.npstr.baymax.helpdesk.Node;
26 | import space.npstr.baymax.helpdesk.exception.MalformedModelException;
27 |
28 | import java.io.File;
29 | import java.io.FileInputStream;
30 | import java.io.IOException;
31 | import java.io.InputStream;
32 | import java.net.URI;
33 | import java.net.http.HttpClient;
34 | import java.net.http.HttpRequest;
35 | import java.net.http.HttpResponse;
36 | import java.nio.file.Files;
37 | import java.nio.file.Path;
38 | import java.util.Collections;
39 | import java.util.Map;
40 | import java.util.concurrent.ConcurrentHashMap;
41 |
42 | /**
43 | * Created by napster on 05.09.18.
44 | */
45 | @Component
46 | public class ModelLoader {
47 |
48 | private static final Logger log = LoggerFactory.getLogger(ModelLoader.class);
49 |
50 | private final Map> models = new ConcurrentHashMap<>();
51 |
52 | private final ModelParser modelParser = new ModelParser();
53 | private final HttpClient httpClient = HttpClient.newHttpClient();
54 | private final Path tempDir;
55 |
56 | public ModelLoader() throws IOException {
57 | this.tempDir = Files.createTempDirectory("baymax_models");
58 | this.tempDir.toFile().deleteOnExit();
59 | }
60 |
61 | public Map getModel(String name, @Nullable URI uri) {
62 | return this.models.computeIfAbsent(name, __ -> loadModel(name, uri));
63 | }
64 |
65 | /**
66 | * @throws RuntimeException if there is a general problem loading the model
67 | * @throws MalformedModelException if there is a problem parsing the model
68 | */
69 | public Map attemptReload(String name, @Nullable URI uri) {
70 | Map model = loadModel(name, uri);
71 | this.models.put(name, model);
72 | return model;
73 | }
74 |
75 | private Map loadModel(String name, @Nullable URI uri) {
76 | String rawModel;
77 | if (uri != null) {
78 | try {
79 | rawModel = loadModelFromUrl(name, uri);
80 | } catch (Exception e) {
81 | throw new RuntimeException("Failed to load model" + name + " from url " + uri, e);
82 | }
83 | } else {
84 | String filePath = "models/" + name + ".yaml";
85 | rawModel = loadModelAsYamlString(filePath);
86 | }
87 |
88 | return Collections.unmodifiableMap(
89 | this.modelParser.parse(rawModel)
90 | );
91 | }
92 |
93 | public String loadModelFromUrl(String name, URI uri) throws IOException, InterruptedException {
94 | Path tempFile = Files.createTempFile(tempDir, name, ".yaml");
95 | tempFile.toFile().deleteOnExit();
96 |
97 | HttpResponse.BodyHandler bodyHandler = HttpResponse.BodyHandlers.ofFile(tempFile);
98 |
99 | HttpRequest request = HttpRequest.newBuilder()
100 | .GET()
101 | .uri(uri)
102 | .build();
103 | HttpResponse response = httpClient.send(request, bodyHandler);
104 |
105 | log.debug("Fetched model {} from {} with status {} and saved to {}", name, uri, response.statusCode(), tempFile);
106 |
107 | return loadModelAsYamlString(tempFile.toFile());
108 | }
109 |
110 | private String loadModelAsYamlString(String fileName) {
111 | return loadModelAsYamlString(new File(fileName));
112 | }
113 |
114 | private String loadModelAsYamlString(File modelFile) {
115 | if (!modelFile.exists() || !modelFile.canRead()) {
116 | throw new RuntimeException("Failed to find or read model file " + modelFile.getName());
117 | }
118 | try (InputStream fileStream = new FileInputStream(modelFile)) {
119 | return new String(fileStream.readAllBytes());
120 | } catch (IOException e) {
121 | throw new RuntimeException("Failed to load model " + modelFile.getName(), e);
122 | }
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/NodeContext.java:
--------------------------------------------------------------------------------
1 | package space.npstr.baymax;
2 |
3 | import space.npstr.baymax.helpdesk.Node;
4 |
5 | import java.util.Optional;
6 |
7 | /**
8 | * Provide some context around a node, for example the node that was previously visited.
9 | */
10 | public class NodeContext {
11 |
12 | private final Node node;
13 | private final Optional previousNodeContext;
14 |
15 | public NodeContext(Node node, Optional previousNodeContext) {
16 | this.node = node;
17 | this.previousNodeContext = previousNodeContext;
18 | }
19 |
20 | public Node getNode() {
21 | return this.node;
22 | }
23 |
24 | public Optional getPreviousNodeContext() {
25 | return this.previousNodeContext;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/RestActions.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018-2022 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax;
19 |
20 | import java.time.Duration;
21 | import java.util.List;
22 | import java.util.concurrent.CompletableFuture;
23 | import java.util.concurrent.CompletionStage;
24 | import java.util.concurrent.TimeUnit;
25 | import java.util.function.Function;
26 | import net.dv8tion.jda.api.entities.Guild;
27 | import net.dv8tion.jda.api.entities.Member;
28 | import net.dv8tion.jda.api.entities.Message;
29 | import net.dv8tion.jda.api.entities.MessageHistory;
30 | import net.dv8tion.jda.api.entities.Role;
31 | import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
32 | import net.dv8tion.jda.api.exceptions.HierarchyException;
33 | import net.dv8tion.jda.api.exceptions.InsufficientPermissionException;
34 | import net.dv8tion.jda.api.utils.messages.MessageCreateData;
35 | import org.springframework.stereotype.Component;
36 |
37 | /**
38 | * Created by napster on 21.09.18.
39 | */
40 | @Component
41 | public class RestActions {
42 |
43 | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RestActions.class);
44 |
45 | public void assignRole(Guild guild, Member member, Role role) {
46 | try {
47 | guild.addRoleToMember(member, role).queue();
48 | } catch (InsufficientPermissionException e) {
49 | log.error("Can't assign role {} due to missing permission {}", role, e.getPermission(), e);
50 | } catch (HierarchyException e) {
51 | log.error("Can't assign role {} due to hierarchy issue", role, e);
52 | }
53 | }
54 |
55 | public void removeRole(Guild guild, Member member, Role role) {
56 | try {
57 | guild.removeRoleFromMember(member, role).queue();
58 | } catch (InsufficientPermissionException e) {
59 | log.error("Can't remove role {} due to missing permission {}", role, e.getPermission(), e);
60 | } catch (HierarchyException e) {
61 | log.error("Can't remove role {} due to hierarchy issue", role, e);
62 | }
63 | }
64 |
65 | public CompletionStage sendMessage(MessageChannel channel, MessageCreateData message) {
66 | return channel.sendMessage(message).submit()
67 | .thenApply(Function.identity()); //avoid JDA's Promise#toCompletableFuture's UnsupportedOperationException
68 | }
69 |
70 | public CompletionStage deleteMessageAfter(Message message, Duration after) {
71 | return message.delete()
72 | .submitAfter(after.toMillis(), TimeUnit.MILLISECONDS);
73 | }
74 |
75 | public CompletionStage purgeChannel(MessageChannel channel) {
76 | return fetchAllMessages(channel.getHistory())
77 | .thenApply(channel::purgeMessages)
78 | .thenCompose(requestFutures -> CompletableFuture.allOf(requestFutures.toArray(new CompletableFuture[0])));
79 | }
80 |
81 | private CompletionStage> fetchAllMessages(MessageHistory history) {
82 | return history.retrievePast(100).submit()
83 | .thenApply(Function.identity()) //avoid JDA's Promise#toCompletableFuture's UnsupportedOperationException
84 | .thenCompose(
85 | messages -> {
86 | if (!messages.isEmpty()) {
87 | return fetchAllMessages(history);
88 | }
89 | return CompletableFuture.completedStage(history.getRetrievedHistory());
90 | });
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/UserDialogue.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax;
19 |
20 | import java.util.ArrayList;
21 | import java.util.List;
22 | import java.util.Map;
23 | import java.util.Objects;
24 | import java.util.Optional;
25 | import java.util.concurrent.CompletableFuture;
26 | import java.util.concurrent.TimeUnit;
27 | import java.util.concurrent.atomic.AtomicReference;
28 | import java.util.function.Predicate;
29 | import java.util.stream.Collectors;
30 | import net.dv8tion.jda.api.entities.Guild;
31 | import net.dv8tion.jda.api.entities.Member;
32 | import net.dv8tion.jda.api.entities.Role;
33 | import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
34 | import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
35 | import net.dv8tion.jda.api.sharding.ShardManager;
36 | import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
37 | import net.dv8tion.jda.api.utils.messages.MessageCreateData;
38 | import space.npstr.baymax.db.TemporaryRoleService;
39 | import space.npstr.baymax.helpdesk.Branch;
40 | import space.npstr.baymax.helpdesk.Node;
41 |
42 | /**
43 | * Created by napster on 05.09.18.
44 | */
45 | public class UserDialogue {
46 |
47 | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(UserDialogue.class);
48 |
49 | private final EventWaiter eventWaiter;
50 | private final ShardManager shardManager;
51 | private final Map model;
52 | private final long userId;
53 | private final long channelId;
54 | private final RestActions restActions;
55 | private final TemporaryRoleService temporaryRoleService;
56 | private final EmojisNumbersParser emojisNumbersParser = new EmojisNumbersParser();
57 | private final List messagesToCleanUp = new ArrayList<>();
58 | private final AtomicReference> waitingEvent = new AtomicReference<>();
59 | private boolean done = false;
60 |
61 | public UserDialogue(EventWaiter eventWaiter, Map model, MessageReceivedEvent event,
62 | RestActions restActions, TemporaryRoleService temporaryRoleService) {
63 |
64 | this.eventWaiter = eventWaiter;
65 | this.shardManager = Objects.requireNonNull(event.getJDA().getShardManager(), "Shard Manager required");
66 | this.model = model;
67 | this.userId = event.getAuthor().getIdLong();
68 | this.channelId = event.getChannel().getIdLong();
69 | this.restActions = restActions;
70 | this.temporaryRoleService = temporaryRoleService;
71 |
72 | this.messagesToCleanUp.add(event.getMessageIdLong());
73 |
74 | NodeContext nodeContext = new NodeContext(model.get("root"), Optional.empty());
75 | parseUserInput(event, nodeContext);
76 | }
77 |
78 | public synchronized void done() {
79 | var we = this.waitingEvent.get();
80 | if (we != null) {
81 | we.cancel();
82 | }
83 |
84 | if (this.done) {
85 | return;
86 | }
87 | this.done = true;
88 |
89 | getTextChannel().ifPresent(textChannel -> {
90 | List messageIdsAsStrings = this.messagesToCleanUp.stream()
91 | .map(Number::toString)
92 | .collect(Collectors.toList());
93 | List> requestFutures = textChannel.purgeMessagesById(messageIdsAsStrings);
94 | requestFutures.forEach(f -> f.whenComplete((__, t) -> {
95 | if (t != null) {
96 | log.error("Failed to purge messages for user {} in channel {}", this.userId, this.channelId, t);
97 | }
98 | }));
99 | });
100 | }
101 |
102 | private Optional getTextChannel() {
103 | return Optional.ofNullable(this.shardManager.getTextChannelById(this.channelId));
104 | }
105 |
106 | private void assignRole(TextChannel textChannel, long roleId) {
107 | Guild guild = textChannel.getGuild();
108 | Role role = guild.getRoleById(roleId);
109 | if (role == null) {
110 | log.warn("Where did the role {} go?", roleId);
111 | return;
112 | }
113 |
114 | Member member = guild.getMemberById(this.userId);
115 | if (member == null) {
116 | log.warn("No member found for user {}", this.userId);
117 | return;
118 | }
119 |
120 | this.restActions.assignRole(guild, member, role);
121 | this.temporaryRoleService.setTemporaryRole(member.getUser(), role);
122 | }
123 |
124 | private void sendNode(NodeContext nodeContext) {
125 | Optional textChannelOpt = getTextChannel();
126 | if (textChannelOpt.isPresent()) {
127 | TextChannel textChannel = textChannelOpt.get();
128 | this.restActions.sendMessage(textChannel, asMessage(nodeContext))
129 | .thenAccept(message -> this.messagesToCleanUp.add(message.getIdLong()))
130 | .whenComplete((__, t) -> {
131 | if (t != null) {
132 | log.error("Failed to send message", t);
133 | }
134 | });
135 |
136 | Optional.ofNullable(nodeContext.getNode().getRoleId()).ifPresent(roleId -> assignRole(textChannel, roleId));
137 | } else {
138 | log.warn("Where did the channel {} go?", this.channelId);
139 | }
140 |
141 | this.waitingEvent.set(this.eventWaiter.waitForEvent(
142 | MessageReceivedEvent.class,
143 | messageOfThisUser(),
144 | event -> this.parseUserInput(event, nodeContext),
145 | HelpDeskListener.EXPIRE_MINUTES, TimeUnit.MINUTES,
146 | this::done
147 | ));
148 | }
149 |
150 | private void parseUserInput(MessageReceivedEvent event, NodeContext currentNodeContext) {
151 | this.messagesToCleanUp.add(event.getMessageIdLong());
152 | String contentRaw = event.getMessage().getContentRaw();
153 | Node currentNode = currentNodeContext.getNode();
154 | Optional previousNodeContext = currentNodeContext.getPreviousNodeContext();
155 |
156 | int numberPicked;
157 | try {
158 | numberPicked = Integer.parseInt(contentRaw);
159 | } catch (NumberFormatException e) {
160 | Optional numberOpt = this.emojisNumbersParser.emojisToNumber(contentRaw);
161 | if (numberOpt.isEmpty()) {
162 | sendNode(currentNodeContext); //todo better message?
163 | return;
164 | }
165 | numberPicked = numberOpt.get();
166 | }
167 |
168 | numberPicked--; //correct for shown index starting at 1 instead of 0
169 |
170 | int goBack = -1; //actually user entered 0
171 |
172 | if (numberPicked < goBack || numberPicked >= currentNode.getBranches().size()) {
173 | sendNode(currentNodeContext); //todo better message?
174 | return;
175 | }
176 |
177 | NodeContext nextNodeContext;
178 | if (numberPicked == goBack) {
179 | if (previousNodeContext.isPresent()) {
180 | nextNodeContext = previousNodeContext.get();
181 | } else {
182 | Node rootNode = this.model.get("root");
183 | nextNodeContext = new NodeContext(rootNode, Optional.empty());
184 | }
185 | } else {
186 | Branch branch = currentNode.getBranches().get(numberPicked);
187 | Node nextNode = this.model.get(branch.getTargetId());
188 | nextNodeContext = new NodeContext(nextNode, Optional.of(currentNodeContext));
189 | }
190 | sendNode(nextNodeContext);
191 | }
192 |
193 | private Predicate messageOfThisUser() {
194 | return event ->
195 | event.getAuthor().getIdLong() == this.userId
196 | && event.getChannel().getIdLong() == this.channelId;
197 | }
198 |
199 | public static MessageCreateData asMessage(NodeContext nodeContext) {
200 | MessageCreateBuilder mb = new MessageCreateBuilder();
201 | EmojisNumbersParser emojisNumbersParser = new EmojisNumbersParser();
202 | Node node = nodeContext.getNode();
203 |
204 | mb.addContent("**").addContent(node.getTitle()).addContent("**\n\n");
205 | int bb = 1;
206 | for (Branch branch : node.getBranches()) {
207 | mb
208 | .addContent(emojisNumbersParser.numberAsEmojis(bb++))
209 | .addContent(" ")
210 | .addContent(branch.getMessage())
211 | .addContent("\n");
212 | }
213 | mb.addContent("\n");
214 | if ("root".equals(node.getId())) {
215 | mb.addContent("Say a number to start.\n");
216 | } else {
217 | if (nodeContext.getPreviousNodeContext().isPresent()) {
218 | mb.addContent(emojisNumbersParser.numberAsEmojis(0)).addContent(" Go back.\n");
219 | }
220 | }
221 |
222 | return mb.build();
223 | }
224 | }
225 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/config/OkHttpConfiguration.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.config;
19 |
20 | import okhttp3.OkHttpClient;
21 | import org.springframework.beans.factory.config.ConfigurableBeanFactory;
22 | import org.springframework.context.annotation.Bean;
23 | import org.springframework.context.annotation.Configuration;
24 | import org.springframework.context.annotation.Scope;
25 |
26 | import java.util.concurrent.TimeUnit;
27 |
28 | /**
29 | * Created by napster on 05.09.18.
30 | */
31 | @Configuration
32 | public class OkHttpConfiguration {
33 |
34 | //a general purpose http client builder
35 | @Bean
36 | @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) //do not reuse the builders
37 | public static OkHttpClient.Builder httpClientBuilder() {
38 | return new OkHttpClient.Builder()
39 | .connectTimeout(30, TimeUnit.SECONDS)
40 | .writeTimeout(30, TimeUnit.SECONDS)
41 | .readTimeout(30, TimeUnit.SECONDS)
42 | .retryOnConnectionFailure(true);
43 | }
44 |
45 | // default http client that can be used for anything
46 | @Bean
47 | public OkHttpClient defaultHttpClient(OkHttpClient.Builder httpClientBuilder) {
48 | return httpClientBuilder.build();
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/config/ShardManagerConfiguration.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.config;
19 |
20 | import net.dv8tion.jda.api.entities.Activity;
21 | import net.dv8tion.jda.api.requests.GatewayIntent;
22 | import net.dv8tion.jda.api.sharding.DefaultShardManagerBuilder;
23 | import net.dv8tion.jda.api.sharding.ShardManager;
24 | import net.dv8tion.jda.api.utils.ChunkingFilter;
25 | import net.dv8tion.jda.api.utils.MemberCachePolicy;
26 | import net.dv8tion.jda.api.utils.cache.CacheFlag;
27 | import okhttp3.OkHttpClient;
28 | import org.springframework.context.annotation.Bean;
29 | import org.springframework.context.annotation.Configuration;
30 | import org.springframework.util.ObjectUtils;
31 | import space.npstr.baymax.EventWaiter;
32 | import space.npstr.baymax.HelpDeskListener;
33 | import space.npstr.baymax.config.properties.BaymaxConfig;
34 |
35 | import java.util.EnumSet;
36 | import java.util.concurrent.ScheduledThreadPoolExecutor;
37 | import java.util.concurrent.atomic.AtomicInteger;
38 |
39 | /**
40 | * Created by napster on 05.09.18.
41 | */
42 | @Configuration
43 | public class ShardManagerConfiguration {
44 |
45 | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(ShardManagerConfiguration.class);
46 |
47 | private static final Thread.UncaughtExceptionHandler UNCAUGHT_EXCEPTION_HANDLER
48 | = (thread, throwable) -> log.error("Uncaught exception in thread {}", thread.getName(), throwable);
49 |
50 | @Bean
51 | public ScheduledThreadPoolExecutor jdaThreadPool() {
52 | AtomicInteger threadNumber = new AtomicInteger(0);
53 | return new ScheduledThreadPoolExecutor(50, r -> {
54 | Thread thread = new Thread(r, "jda-pool-t" + threadNumber.getAndIncrement());
55 | thread.setUncaughtExceptionHandler(UNCAUGHT_EXCEPTION_HANDLER);
56 | return thread;
57 | });
58 | }
59 |
60 | @Bean(destroyMethod = "") //we manage the lifecycle ourselves tyvm, see shutdown hook in the launcher
61 | public ShardManager shardManager(BaymaxConfig baymaxConfig, OkHttpClient.Builder httpClientBuilder,
62 | ScheduledThreadPoolExecutor jdaThreadPool, EventWaiter eventWaiter,
63 | HelpDeskListener helpDeskListener) {
64 |
65 | DefaultShardManagerBuilder shardBuilder = DefaultShardManagerBuilder
66 | .createDefault(baymaxConfig.discordToken())
67 | .setChunkingFilter(ChunkingFilter.ALL) //we need to fetch members from the cache at several places
68 | .setMemberCachePolicy(MemberCachePolicy.ALL)
69 | .enableIntents(
70 | GatewayIntent.GUILD_MEMBERS, //required for chunking
71 | GatewayIntent.MESSAGE_CONTENT // parsing numbers
72 | )
73 | .addEventListeners(eventWaiter)
74 | .addEventListeners(helpDeskListener)
75 | .setHttpClientBuilder(httpClientBuilder
76 | .retryOnConnectionFailure(false))
77 | .setEnableShutdownHook(false)
78 | .setRateLimitScheduler(jdaThreadPool, false)
79 | .setRateLimitElastic(jdaThreadPool, false)
80 | .setCallbackPool(jdaThreadPool, false)
81 | .disableCache(EnumSet.allOf(CacheFlag.class));
82 |
83 | String statusMessage = baymaxConfig.statusMessage();
84 | if (!ObjectUtils.isEmpty(statusMessage)) {
85 | Activity.ActivityType activityType = Activity.ActivityType.fromKey(baymaxConfig.statusType());
86 | Activity discordStatus = Activity.of(activityType, statusMessage);
87 | shardBuilder.setActivity(discordStatus);
88 | }
89 |
90 | return shardBuilder.build();
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/config/package-info.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018-2023 the original author or authors
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | @NonNullApi
19 | @NonNullFields
20 | package space.npstr.baymax.config;
21 |
22 | import org.springframework.lang.NonNullApi;
23 | import org.springframework.lang.NonNullFields;
24 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/config/properties/BaymaxConfig.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.config.properties;
19 |
20 | import java.net.URI;
21 | import java.util.List;
22 | import java.util.Set;
23 | import org.springframework.boot.context.properties.ConfigurationProperties;
24 | import org.springframework.lang.Nullable;
25 |
26 | /**
27 | * Created by napster on 05.09.18.
28 | */
29 | @ConfigurationProperties("baymax")
30 | public record BaymaxConfig(
31 | String discordToken,
32 | int statusType,
33 | @Nullable String statusMessage,
34 | Set staffRoleIds,
35 | List helpDesks
36 | ) {
37 |
38 | @SuppressWarnings("ConstantValue")
39 | public BaymaxConfig {
40 | if (discordToken == null || discordToken.isBlank()) {
41 | throw new IllegalArgumentException("Discord token must not be blank");
42 | }
43 | if (staffRoleIds == null) {
44 | staffRoleIds = Set.of();
45 | }
46 | if (helpDesks == null) {
47 | helpDesks = List.of();
48 | }
49 | }
50 |
51 | public record HelpDesk(
52 | long channelId,
53 | String modelName,
54 | @Nullable URI modelUri
55 | ) {
56 |
57 | @SuppressWarnings("ConstantValue")
58 | public HelpDesk {
59 | if (channelId <= 0) {
60 | throw new IllegalArgumentException("Channel id must be positive");
61 | }
62 | if (modelName == null || modelName.isBlank()) {
63 | throw new IllegalArgumentException("Model name must not be blank");
64 | }
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/config/properties/package-info.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018-2023 the original author or authors
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | @NonNullApi
19 | @NonNullFields
20 | package space.npstr.baymax.config.properties;
21 |
22 | import org.springframework.lang.NonNullApi;
23 | import org.springframework.lang.NonNullFields;
24 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/db/Database.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.db;
19 |
20 | import org.flywaydb.core.Flyway;
21 | import org.flywaydb.core.api.MigrationVersion;
22 | import org.springframework.stereotype.Component;
23 | import org.sqlite.SQLiteConfig;
24 | import org.sqlite.SQLiteDataSource;
25 |
26 | import javax.sql.DataSource;
27 | import java.sql.Connection;
28 | import java.sql.SQLException;
29 |
30 | /**
31 | * Created by napster on 21.09.18.
32 | */
33 | @Component
34 | public class Database {
35 |
36 | private static final String JDBC_URL = "jdbc:sqlite:baymax.sqlite";
37 |
38 | private final DataSource dataSource;
39 |
40 | public Database() {
41 | SQLiteConfig sqliteConfig = new SQLiteConfig();
42 | SQLiteDataSource sqliteDataSource = new SQLiteDataSource(sqliteConfig);
43 | sqliteDataSource.setUrl(JDBC_URL);
44 | migrate(sqliteDataSource);
45 |
46 | this.dataSource = sqliteDataSource;
47 | }
48 |
49 | public Connection getConnection() throws SQLException {
50 | return this.dataSource.getConnection();
51 | }
52 |
53 | private void migrate(DataSource dataSource) {
54 | Flyway flyway = new Flyway(Flyway.configure()
55 | .baselineOnMigrate(true)
56 | .baselineVersion(MigrationVersion.fromVersion("0"))
57 | .baselineDescription("Base Migration")
58 | .locations("db/migrations")
59 | .dataSource(dataSource)
60 | );
61 | flyway.migrate();
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/db/TemporaryRoleService.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.db;
19 |
20 | import net.dv8tion.jda.api.JDA;
21 | import net.dv8tion.jda.api.entities.Guild;
22 | import net.dv8tion.jda.api.entities.Member;
23 | import net.dv8tion.jda.api.entities.Role;
24 | import net.dv8tion.jda.api.entities.User;
25 | import net.dv8tion.jda.api.sharding.ShardManager;
26 | import org.springframework.beans.factory.ObjectProvider;
27 | import org.springframework.stereotype.Component;
28 | import space.npstr.baymax.RestActions;
29 |
30 | import java.sql.Connection;
31 | import java.sql.PreparedStatement;
32 | import java.sql.ResultSet;
33 | import java.sql.SQLException;
34 | import java.sql.Statement;
35 | import java.time.OffsetDateTime;
36 | import java.util.ArrayList;
37 | import java.util.List;
38 | import java.util.concurrent.Executors;
39 | import java.util.concurrent.TimeUnit;
40 |
41 | /**
42 | * Created by napster on 21.09.18.
43 | */
44 | @Component
45 | public class TemporaryRoleService {
46 |
47 | private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(TemporaryRoleService.class);
48 |
49 | private static final int KEEP_ROLE_FOR_HOURS = 3;
50 |
51 | private final Database database;
52 | private final ObjectProvider shardManager;
53 | private final RestActions restActions;
54 |
55 | public TemporaryRoleService(Database database, ObjectProvider shardManager, RestActions restActions) {
56 | this.database = database;
57 | this.shardManager = shardManager;
58 | this.restActions = restActions;
59 | var cleaner = Executors.newSingleThreadScheduledExecutor(r -> new Thread(r, "temporary-role-service"));
60 |
61 | cleaner.scheduleAtFixedRate(this::cleanUp, 1, 1, TimeUnit.MINUTES);
62 | }
63 |
64 | public void setTemporaryRole(User user, Role role) {
65 | long userId = user.getIdLong();
66 | long roleId = role.getIdLong();
67 | long guildId = role.getGuild().getIdLong();
68 | long until = OffsetDateTime.now()
69 | .plusHours(KEEP_ROLE_FOR_HOURS)
70 | .toInstant().toEpochMilli();
71 |
72 | //language=SQLite
73 | String existsSql = "SELECT EXISTS(SELECT * FROM temporary_role WHERE user_id = ? AND role_id = ?);";
74 |
75 | //language=SQLite
76 | String insertSql = "INSERT INTO temporary_role VALUES(?, ?, ?, ?);";
77 | //language=SQLite
78 | String updateSql = "UPDATE temporary_role SET guild_id = ?, until = ? WHERE user_id = ? AND role_id = ?;";
79 |
80 | try (Connection connection = this.database.getConnection()) {
81 | boolean exists;
82 | try (PreparedStatement statement = connection.prepareStatement(existsSql)) {
83 | statement.setLong(1, userId);
84 | statement.setLong(2, roleId);
85 | try (ResultSet resultSet = statement.executeQuery()) {
86 | exists = resultSet.getBoolean(1);
87 | }
88 |
89 | }
90 | String query = exists ? updateSql : insertSql;
91 |
92 | try (PreparedStatement statement = connection.prepareStatement(query)) {
93 | if (exists) {
94 | statement.setLong(1, guildId);
95 | statement.setLong(2, until);
96 | statement.setLong(3, userId);
97 | statement.setLong(4, roleId);
98 | } else {
99 | statement.setLong(1, userId);
100 | statement.setLong(2, roleId);
101 | statement.setLong(3, guildId);
102 | statement.setLong(4, until);
103 | }
104 | statement.execute();
105 | }
106 | } catch (SQLException e) {
107 | log.error("Failed to set temporary role. User: {} Role: {}", user, role, e);
108 | }
109 | }
110 |
111 | @SuppressWarnings("squid:S135") // the while loop is perfectly readable
112 | private void cleanUp() {
113 | //avoid runnign this when were not ready
114 | if (this.shardManager.getObject().getShardCache().stream()
115 | .anyMatch(shard -> shard.getStatus() != JDA.Status.CONNECTED)) {
116 | return;
117 | }
118 |
119 | //language=SQLite
120 | String query = "SELECT * FROM temporary_role;";
121 |
122 | try (Connection connection = this.database.getConnection()) {
123 | List toDelete = new ArrayList<>();
124 | try (Statement statement = connection.createStatement()) {
125 | try (ResultSet resultSet = statement.executeQuery(query)) {
126 | while (resultSet.next()) {
127 | long until = resultSet.getLong(4);
128 | if (System.currentTimeMillis() < until) {
129 | continue;
130 | }
131 | long userId = resultSet.getLong(1);
132 | long roleId = resultSet.getLong(2);
133 | long guildId = resultSet.getLong(3);
134 | Guild guild = this.shardManager.getObject().getGuildById(guildId);
135 | if (guild == null) { //we left the guild. dont do anything, we might get readded
136 | continue;
137 | }
138 | Role role = guild.getRoleById(roleId);
139 | Member member = guild.getMemberById(userId);
140 | if (role == null || member == null) { //role or member is gone. no point in keeping records on it
141 | toDelete.add(new TemporaryRoleId(userId, roleId));
142 | continue;
143 | }
144 |
145 | log.debug("Removing role {} from member {}", role, member);
146 | this.restActions.removeRole(guild, member, role);
147 |
148 | //remove the row
149 | toDelete.add(new TemporaryRoleId(userId, roleId));
150 | }
151 | }
152 | }
153 | //language=SQLite
154 | String deleteQuery = "DELETE FROM temporary_role WHERE user_id = ? AND role_id = ?;";
155 | try (PreparedStatement statement = connection.prepareStatement(deleteQuery)) {
156 | for (TemporaryRoleId id : toDelete) {
157 | log.debug("Deleting temporary role entry for user {} and role {}", id.userId, id.roleId);
158 | statement.setLong(1, id.userId);
159 | statement.setLong(2, id.roleId);
160 | statement.execute();
161 | }
162 | }
163 | } catch (SQLException e) {
164 | log.error("Failed to clean up", e);
165 | }
166 | }
167 |
168 | //todo clean guild members of the roles?
169 |
170 | private static class TemporaryRoleId {
171 | private final long userId;
172 | private final long roleId;
173 |
174 | public TemporaryRoleId(long userId, long roleId) {
175 | this.userId = userId;
176 | this.roleId = roleId;
177 | }
178 | }
179 | }
180 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/db/package-info.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018-2023 the original author or authors
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | @NonNullApi
19 | @NonNullFields
20 | package space.npstr.baymax.db;
21 |
22 | import org.springframework.lang.NonNullApi;
23 | import org.springframework.lang.NonNullFields;
24 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/helpdesk/Branch.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.helpdesk;
19 |
20 | /**
21 | * Created by napster on 05.09.18.
22 | */
23 | public interface Branch {
24 |
25 | /**
26 | * @return The message describing this branch.
27 | */
28 | String getMessage();
29 |
30 | /**
31 | * @return The id of the node which this branch is pointing at.
32 | */
33 | String getTargetId();
34 | }
35 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/helpdesk/BranchModel.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.helpdesk;
19 |
20 | /**
21 | * Created by napster on 05.09.18.
22 | */
23 | public class BranchModel implements Branch {
24 |
25 | private String message = "";
26 |
27 | private String targetId = "";
28 |
29 | @Override
30 | public String getMessage() {
31 | return this.message;
32 | }
33 |
34 | public void setMessage(String message) {
35 | this.message = message;
36 | }
37 |
38 | @Override
39 | public String getTargetId() {
40 | return this.targetId;
41 | }
42 |
43 | public void setTargetId(String targetId) {
44 | this.targetId = targetId;
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/main/java/space/npstr/baymax/helpdesk/ModelParser.java:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (C) 2018 Dennis Neufeld
3 | *
4 | * This program is free software: you can redistribute it and/or modify
5 | * it under the terms of the GNU Affero General Public License as published
6 | * by the Free Software Foundation, either version 3 of the License, or
7 | * (at your option) any later version.
8 | *
9 | * This program is distributed in the hope that it will be useful,
10 | * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 | * GNU Affero General Public License for more details.
13 | *
14 | * You should have received a copy of the GNU Affero General Public License
15 | * along with this program. If not, see .
16 | */
17 |
18 | package space.npstr.baymax.helpdesk;
19 |
20 | import org.yaml.snakeyaml.LoaderOptions;
21 | import org.yaml.snakeyaml.Yaml;
22 | import org.yaml.snakeyaml.constructor.Constructor;
23 | import space.npstr.baymax.helpdesk.exception.MissingTargetNodeException;
24 | import space.npstr.baymax.helpdesk.exception.NoRootNodeException;
25 | import space.npstr.baymax.helpdesk.exception.UnreferencedNodesException;
26 |
27 | import java.util.HashMap;
28 | import java.util.List;
29 | import java.util.Map;
30 |
31 | /**
32 | * Created by napster on 05.09.18.
33 | */
34 | public class ModelParser {
35 |
36 | public Map parse(String yaml) {
37 | Yaml snakeYaml = new Yaml(new Constructor(NodeModel.class, new LoaderOptions()));
38 | Iterable