Compare commits
2 commits
Author | SHA1 | Date | |
---|---|---|---|
33aeb356a4 | |||
18ee28daee |
102 changed files with 520 additions and 8125 deletions
|
@ -1,10 +1,7 @@
|
|||
matrix:
|
||||
MODULE:
|
||||
- db
|
||||
- bot
|
||||
- web
|
||||
- migration
|
||||
- monolith
|
||||
- discord
|
||||
|
||||
when:
|
||||
event: [tag, push, manual]
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
when:
|
||||
event: [tag, push, manual]
|
||||
|
||||
steps:
|
||||
build:
|
||||
image: docker.io/node:18-alpine
|
||||
directory: ui
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run build
|
|
@ -1,10 +1,6 @@
|
|||
matrix:
|
||||
TARGET:
|
||||
- bot
|
||||
- web
|
||||
- migration
|
||||
- ui
|
||||
- monolith
|
||||
PLATFORM:
|
||||
- linux/amd64
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ matrix:
|
|||
|
||||
depends_on:
|
||||
- java
|
||||
- nodejs
|
||||
|
||||
when:
|
||||
event: [tag, push, manual]
|
||||
|
|
48
Dockerfile
48
Dockerfile
|
@ -13,57 +13,9 @@ RUN --mount=type=cache,target=/root/.m2/ \
|
|||
|
||||
FROM ${SETUP_IMAGE} as setup
|
||||
|
||||
# Create final monolith
|
||||
FROM docker.io/eclipse-temurin:${TEMURIN_IMAGE_TAG} AS monolith
|
||||
WORKDIR /app
|
||||
COPY --from=setup /app/monolith/target/monolith-*-fat.jar /app/monolith.jar
|
||||
CMD ["java", "-jar", "/app/monolith.jar"]
|
||||
|
||||
# Create final web
|
||||
FROM docker.io/eclipse-temurin:${TEMURIN_IMAGE_TAG} AS web
|
||||
WORKDIR /app
|
||||
COPY --from=setup /app/web/target/web-*-fat.jar /app/web.jar
|
||||
EXPOSE 8080
|
||||
CMD ["java", "-jar", "/app/web.jar"]
|
||||
|
||||
# Create final migration
|
||||
FROM docker.io/eclipse-temurin:${TEMURIN_IMAGE_TAG} AS migration
|
||||
WORKDIR /app
|
||||
COPY --from=setup /app/migration/target/migration-*-fat.jar /app/migration.jar
|
||||
CMD ["java", "-jar", "/app/migration.jar"]
|
||||
|
||||
# Create final bot
|
||||
FROM docker.io/eclipse-temurin:${TEMURIN_IMAGE_TAG} AS bot
|
||||
WORKDIR /app
|
||||
COPY --from=setup /app/bot/target/bot-*-fat.jar /app/bot.jar
|
||||
CMD ["java", "-jar", "/app/bot.jar"]
|
||||
|
||||
# Build the ui
|
||||
FROM docker.io/node:18-alpine AS ui-build
|
||||
WORKDIR /ui
|
||||
COPY ui/package.json ui/package-lock.json .
|
||||
RUN npm ci
|
||||
COPY ui .
|
||||
RUN npm run build
|
||||
|
||||
# Create final ui
|
||||
FROM docker.io/nginx AS ui
|
||||
COPY --from=ui-build ui/dist /usr/share/nginx/html
|
||||
|
||||
# Create caddy reverse proxy
|
||||
FROM docker.io/caddy AS caddy
|
||||
RUN <<EOF cat > /etc/caddy/Caddyfile
|
||||
{
|
||||
debug
|
||||
auto_https off
|
||||
}
|
||||
|
||||
:8080 {
|
||||
route {
|
||||
reverse_proxy /api/* monolith:8080
|
||||
reverse_proxy ui:80
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
EXPOSE 8080
|
||||
|
|
59
bot/pom.xml
59
bot/pom.xml
|
@ -15,65 +15,14 @@
|
|||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<artifactId>db</artifactId>
|
||||
<artifactId>discord</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.javacord</groupId>
|
||||
<artifactId>javacord</artifactId>
|
||||
<type>pom</type>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-to-slf4j</artifactId>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<configuration>
|
||||
<shadedArtifactAttached>true</shadedArtifactAttached>
|
||||
<shadedClassifierName>fat</shadedClassifierName>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Main-Class>de.hhhammer.dchat.bot.App</Main-Class>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
|
|
@ -1,51 +1,12 @@
|
|||
package de.hhhammer.dchat.bot;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import de.hhhammer.dchat.bot.openai.ChatGPTService;
|
||||
import de.hhhammer.dchat.db.PostgresServerDBService;
|
||||
import de.hhhammer.dchat.db.PostgresUserDBService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
|
||||
public final class App {
|
||||
private static final Logger logger = LoggerFactory.getLogger(App.class);
|
||||
|
||||
public static void main(final String[] args) {
|
||||
final String discordApiKey = System.getenv("DISCORD_API_KEY");
|
||||
if (discordApiKey == null) {
|
||||
logger.error("Missing environment variables: DISCORD_API_KEY");
|
||||
System.exit(1);
|
||||
}
|
||||
final String openaiApiKey = System.getenv("OPENAI_API_KEY");
|
||||
if (openaiApiKey == null) {
|
||||
logger.error("Missing environment variables: OPENAI_API_KEY");
|
||||
System.exit(1);
|
||||
}
|
||||
final String postgresUser = System.getenv("POSTGRES_USER");
|
||||
final String postgresPassword = System.getenv("POSTGRES_PASSWORD");
|
||||
final String postgresUrl = System.getenv("POSTGRES_URL");
|
||||
if (postgresUser == null || postgresPassword == null || postgresUrl == null) {
|
||||
logger.error("Missing environment variables: POSTGRES_USER and/or POSTGRES_PASSWORD and/or POSTGRES_URL");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
final var chatGPTService = new ChatGPTService(openaiApiKey, HttpClient.newHttpClient(), new ObjectMapper());
|
||||
|
||||
final var config = new HikariConfig();
|
||||
config.setJdbcUrl(postgresUrl);
|
||||
config.setUsername(postgresUser);
|
||||
config.setPassword(postgresPassword);
|
||||
|
||||
try (var ds = new HikariDataSource(config)) {
|
||||
final var serverDBService = new PostgresServerDBService(ds);
|
||||
final var userDBService = new PostgresUserDBService(ds);
|
||||
|
||||
final var discordBot = new DiscordBot(serverDBService, userDBService, chatGPTService, discordApiKey);
|
||||
discordBot.run();
|
||||
}
|
||||
logger.error("Currently not implemented!");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,82 +0,0 @@
|
|||
package de.hhhammer.dchat.bot;
|
||||
|
||||
import de.hhhammer.dchat.bot.discord.MessageCreateHandler;
|
||||
import de.hhhammer.dchat.bot.discord.ServerMessageHandler;
|
||||
import de.hhhammer.dchat.bot.discord.UserMessageHandler;
|
||||
import de.hhhammer.dchat.bot.openai.ChatGPTService;
|
||||
import de.hhhammer.dchat.db.ServerDBService;
|
||||
import de.hhhammer.dchat.db.UserDBService;
|
||||
import org.javacord.api.DiscordApi;
|
||||
import org.javacord.api.DiscordApiBuilder;
|
||||
import org.javacord.api.interaction.SlashCommand;
|
||||
import org.javacord.api.interaction.SlashCommandInteraction;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class DiscordBot implements Runnable {
|
||||
private static final Logger logger = LoggerFactory.getLogger(DiscordBot.class);
|
||||
|
||||
private final ServerDBService serverDBService;
|
||||
private final UserDBService userDBService;
|
||||
private final ChatGPTService chatGPTService;
|
||||
private final String discordApiKey;
|
||||
|
||||
public DiscordBot(final ServerDBService serverDBService, final UserDBService userDBService, final ChatGPTService chatGPTService, final String discordApiKey) {
|
||||
this.serverDBService = serverDBService;
|
||||
this.userDBService = userDBService;
|
||||
this.chatGPTService = chatGPTService;
|
||||
this.discordApiKey = discordApiKey;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
logger.info("Starting Discord application");
|
||||
final DiscordApi discordApi = new DiscordApiBuilder()
|
||||
.setToken(discordApiKey)
|
||||
.login()
|
||||
.join();
|
||||
discordApi.setMessageCacheSize(10, 60 * 60);
|
||||
final var future = new CompletableFuture<Void>();
|
||||
Runtime.getRuntime().addShutdownHook(Thread.ofVirtual().unstarted(() -> {
|
||||
logger.info("Shutting down Discord application");
|
||||
discordApi.disconnect().thenAccept(future::complete);
|
||||
}));
|
||||
final SlashCommand token = SlashCommand.with("tokens", "Check how many tokens where spend on this server")
|
||||
.createGlobal(discordApi)
|
||||
.join();
|
||||
|
||||
discordApi.addSlashCommandCreateListener(event -> {
|
||||
logger.debug("Event? " + event.getSlashCommandInteraction().getFullCommandName());
|
||||
final SlashCommandInteraction command = event.getSlashCommandInteraction();
|
||||
if (token.getFullCommandNames().contains(command.getFullCommandName())) {
|
||||
event.getInteraction()
|
||||
.respondLater()
|
||||
.orTimeout(30, TimeUnit.SECONDS)
|
||||
.thenAccept((interactionOriginalResponseUpdater) -> {
|
||||
final long tokens = event.getInteraction().getServer().isPresent() ?
|
||||
this.serverDBService.tokensOfLast30Days(String.valueOf(event.getInteraction().getServer().get().getId())) :
|
||||
this.userDBService.tokensOfLast30Days(String.valueOf(event.getInteraction().getUser().getId()));
|
||||
interactionOriginalResponseUpdater.setContent("" + tokens).update();
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
discordApi.addMessageCreateListener(new MessageCreateHandler(new ServerMessageHandler(serverDBService, chatGPTService)));
|
||||
discordApi.addMessageCreateListener(new MessageCreateHandler(new UserMessageHandler(userDBService, chatGPTService)));
|
||||
|
||||
// Print the invite url of your bot
|
||||
logger.info("You can invite the bot by using the following url: " + discordApi.createBotInvite());
|
||||
|
||||
try {
|
||||
future.get();
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.discord;
|
||||
|
||||
import de.hhhammer.dchat.bot.openai.ResponseException;
|
||||
import org.javacord.api.entity.message.MessageType;
|
||||
import org.javacord.api.event.message.MessageCreateEvent;
|
||||
import org.javacord.api.listener.message.MessageCreateListener;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class MessageCreateHandler implements MessageCreateListener {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MessageCreateHandler.class);
|
||||
|
||||
private final MessageHandler messageHandler;
|
||||
|
||||
public MessageCreateHandler(final MessageHandler messageHandler) {
|
||||
this.messageHandler = messageHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageCreate(final MessageCreateEvent event) {
|
||||
Thread.ofVirtual().start(() -> {
|
||||
if (!event.canYouReadContent() || event.getMessageAuthor().isBotUser() || !isNormalOrReplyMessageType(event)) {
|
||||
return;
|
||||
}
|
||||
if (!this.messageHandler.canHandle(event)) {
|
||||
return;
|
||||
}
|
||||
if (!this.messageHandler.isAllowed(event)) {
|
||||
return;
|
||||
}
|
||||
if (this.messageHandler.exceedsRate(event)) {
|
||||
event.getChannel().sendMessage("Rate limit hit - cooling down...");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.messageHandler.handle(event);
|
||||
} catch (final ResponseException | IOException | InterruptedException e) {
|
||||
logger.error("Reading a message from the listener", e);
|
||||
event.getMessage().reply("Sorry but something went wrong :(");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean isNormalOrReplyMessageType(final MessageCreateEvent event) {
|
||||
final MessageType type = event.getMessage().getType();
|
||||
return type == MessageType.NORMAL || type == MessageType.REPLY;
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.discord;
|
||||
|
||||
import de.hhhammer.dchat.bot.openai.ResponseException;
|
||||
import org.javacord.api.event.message.MessageCreateEvent;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public interface MessageHandler {
|
||||
void handle(MessageCreateEvent event) throws ResponseException, IOException, InterruptedException;
|
||||
|
||||
boolean isAllowed(MessageCreateEvent event);
|
||||
|
||||
boolean exceedsRate(MessageCreateEvent event);
|
||||
|
||||
boolean canHandle(MessageCreateEvent event);
|
||||
}
|
|
@ -1,112 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.discord;
|
||||
|
||||
import de.hhhammer.dchat.bot.openai.ChatGPTRequestBuilder;
|
||||
import de.hhhammer.dchat.bot.openai.ChatGPTService;
|
||||
import de.hhhammer.dchat.bot.openai.MessageContext.ReplyInteraction;
|
||||
import de.hhhammer.dchat.bot.openai.ResponseException;
|
||||
import de.hhhammer.dchat.bot.openai.models.ChatGPTRequest;
|
||||
import de.hhhammer.dchat.bot.openai.models.ChatGPTResponse;
|
||||
import de.hhhammer.dchat.db.ServerDBService;
|
||||
import de.hhhammer.dchat.db.models.server.ServerConfig;
|
||||
import de.hhhammer.dchat.db.models.server.ServerMessage;
|
||||
import org.javacord.api.entity.DiscordEntity;
|
||||
import org.javacord.api.entity.message.Message;
|
||||
import org.javacord.api.entity.message.MessageReference;
|
||||
import org.javacord.api.entity.message.MessageType;
|
||||
import org.javacord.api.event.message.MessageCreateEvent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class ServerMessageHandler implements MessageHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(ServerMessageHandler.class);
|
||||
private final ServerDBService serverDBService;
|
||||
private final ChatGPTService chatGPTService;
|
||||
|
||||
public ServerMessageHandler(final ServerDBService serverDBService, final ChatGPTService chatGPTService) {
|
||||
this.serverDBService = serverDBService;
|
||||
this.chatGPTService = chatGPTService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(final MessageCreateEvent event) throws ResponseException, IOException, InterruptedException {
|
||||
logger.debug("Handling server event");
|
||||
final String content = extractContent(event);
|
||||
final long serverId = event.getServer().get().getId();
|
||||
final String systemMessage = this.serverDBService.getConfig(String.valueOf(serverId)).get().systemMessage();
|
||||
final List<ReplyInteraction> messageContext = event.getMessage().getType() == MessageType.REPLY ? getContextMessages(event) : List.of();
|
||||
final ChatGPTRequest request = new ChatGPTRequestBuilder().contextRequest(messageContext, content, systemMessage);
|
||||
final ChatGPTResponse response = this.chatGPTService.submit(request);
|
||||
if (response.choices().isEmpty()) {
|
||||
event.getMessage().reply("No response available");
|
||||
return;
|
||||
}
|
||||
final String answer = response.choices().get(0).message().content();
|
||||
logServerMessage(event, response.usage().totalTokens());
|
||||
event.getMessage().reply(answer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAllowed(final MessageCreateEvent event) {
|
||||
if (event.getServer().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final long serverId = event.getServer().get().getId();
|
||||
final Optional<ServerConfig> config = this.serverDBService.getConfig(String.valueOf(serverId));
|
||||
if (config.isEmpty()) {
|
||||
logger.debug("Not allowed with id: " + serverId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exceedsRate(final MessageCreateEvent event) {
|
||||
final String serverId = String.valueOf(event.getServer().get().getId());
|
||||
final Optional<ServerConfig> config = this.serverDBService.getConfig(serverId);
|
||||
if (config.isEmpty()) {
|
||||
logger.error("Missing configuration for server with id: " + serverId);
|
||||
return true;
|
||||
}
|
||||
final int rateLimit = config.get().rateLimit();
|
||||
final int countMessagesInLastMinute = this.serverDBService.countMessagesInLastMinute(serverId);
|
||||
|
||||
return countMessagesInLastMinute >= rateLimit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canHandle(final MessageCreateEvent event) {
|
||||
return event.isServerMessage();
|
||||
}
|
||||
|
||||
private void logServerMessage(final MessageCreateEvent event, final int tokens) {
|
||||
final long serverId = event.getServer().map(DiscordEntity::getId).get();
|
||||
final long userId = event.getMessageAuthor().getId();
|
||||
|
||||
final var serverMessage = new ServerMessage.NewServerMessage(String.valueOf(serverId), userId, tokens);
|
||||
this.serverDBService.addMessage(serverMessage);
|
||||
}
|
||||
|
||||
private String extractContent(final MessageCreateEvent event) {
|
||||
final long ownId = event.getApi().getYourself().getId();
|
||||
return event.getMessageContent().replaceFirst("<" + ownId + "> ", "");
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private List<ReplyInteraction> getContextMessages(final MessageCreateEvent event) {
|
||||
return event.getMessage()
|
||||
.getMessageReference()
|
||||
.map(MessageReference::getMessage)
|
||||
.flatMap(m -> m)
|
||||
.map(Message::getReadableContent)
|
||||
.map(ReplyInteraction::new)
|
||||
.stream()
|
||||
.toList();
|
||||
}
|
||||
}
|
|
@ -1,92 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.discord;
|
||||
|
||||
import de.hhhammer.dchat.bot.openai.ChatGPTRequestBuilder;
|
||||
import de.hhhammer.dchat.bot.openai.ChatGPTService;
|
||||
import de.hhhammer.dchat.bot.openai.MessageContext.PreviousInteraction;
|
||||
import de.hhhammer.dchat.bot.openai.ResponseException;
|
||||
import de.hhhammer.dchat.bot.openai.models.ChatGPTRequest;
|
||||
import de.hhhammer.dchat.bot.openai.models.ChatGPTResponse;
|
||||
import de.hhhammer.dchat.db.UserDBService;
|
||||
import de.hhhammer.dchat.db.models.user.UserConfig;
|
||||
import de.hhhammer.dchat.db.models.user.UserMessage;
|
||||
import org.javacord.api.event.message.MessageCreateEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class UserMessageHandler implements MessageHandler {
|
||||
private static final Logger logger = LoggerFactory.getLogger(UserMessageHandler.class);
|
||||
private final UserDBService userDBService;
|
||||
private final ChatGPTService chatGPTService;
|
||||
|
||||
public UserMessageHandler(final UserDBService userDBService, final ChatGPTService chatGPTService) {
|
||||
this.userDBService = userDBService;
|
||||
this.chatGPTService = chatGPTService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(final MessageCreateEvent event) throws ResponseException, IOException, InterruptedException {
|
||||
logger.debug("Handling user event");
|
||||
final String content = event.getReadableMessageContent();
|
||||
final String userId = String.valueOf(event.getMessageAuthor().getId());
|
||||
final UserConfig config = this.userDBService.getConfig(userId).get();
|
||||
final String systemMessage = config.systemMessage();
|
||||
final List<PreviousInteraction> context = this.userDBService.getLastMessages(userId, config.contextLength())
|
||||
.stream()
|
||||
.map(userMessage -> new PreviousInteraction(userMessage.question(), userMessage.answer()))
|
||||
.toList();
|
||||
final ChatGPTRequest request = new ChatGPTRequestBuilder().contextRequest(context, content, systemMessage);
|
||||
final ChatGPTResponse response = this.chatGPTService.submit(request);
|
||||
if (response.choices().isEmpty()) {
|
||||
event.getMessage().reply("No response available");
|
||||
return;
|
||||
}
|
||||
final String answer = response.choices().get(0).message().content();
|
||||
logUserMessage(event, content, answer, response.usage().totalTokens());
|
||||
event.getChannel().sendMessage(answer);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAllowed(final MessageCreateEvent event) {
|
||||
if (event.getServer().isPresent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final long userId = event.getMessageAuthor().getId();
|
||||
final Optional<UserConfig> config = this.userDBService.getConfig(String.valueOf(userId));
|
||||
if (config.isEmpty()) {
|
||||
logger.debug("Not allowed with id: " + userId);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean exceedsRate(final MessageCreateEvent event) {
|
||||
final String userId = String.valueOf(event.getMessageAuthor().getId());
|
||||
final Optional<UserConfig> config = this.userDBService.getConfig(userId);
|
||||
if (config.isEmpty()) {
|
||||
logger.error("Missing configuration for userId with id: " + userId);
|
||||
return true;
|
||||
}
|
||||
final int rateLimit = config.get().rateLimit();
|
||||
final int countMessagesInLastMinute = this.userDBService.countMessagesInLastMinute(userId);
|
||||
|
||||
return countMessagesInLastMinute >= rateLimit;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canHandle(final MessageCreateEvent event) {
|
||||
return event.isPrivateMessage();
|
||||
}
|
||||
|
||||
private void logUserMessage(final MessageCreateEvent event, final String question, final String answer, final int tokens) {
|
||||
final long userId = event.getMessageAuthor().getId();
|
||||
|
||||
final UserMessage.NewUserMessage userMessage = new UserMessage.NewUserMessage(String.valueOf(userId), question, answer, tokens);
|
||||
this.userDBService.addMessage(userMessage);
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.openai;
|
||||
|
||||
import de.hhhammer.dchat.bot.openai.MessageContext.PreviousInteraction;
|
||||
import de.hhhammer.dchat.bot.openai.MessageContext.ReplyInteraction;
|
||||
import de.hhhammer.dchat.bot.openai.models.ChatGPTRequest;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public final class ChatGPTRequestBuilder {
|
||||
private static final String contextModel = "gpt-4o-mini";
|
||||
|
||||
public ChatGPTRequestBuilder() {
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static List<ChatGPTRequest.Message> getMessages(final List<? extends MessageContext> contextMessages, final String message, final String systemMessage) {
|
||||
final ChatGPTRequest.Message systemMsg = new ChatGPTRequest.Message("system", systemMessage);
|
||||
final List<ChatGPTRequest.Message> contextMsgs = getContextMessages(contextMessages);
|
||||
final ChatGPTRequest.Message userMessage = new ChatGPTRequest.Message("user", message);
|
||||
final List<ChatGPTRequest.Message> messages = new ArrayList<>();
|
||||
messages.add(systemMsg);
|
||||
messages.addAll(contextMsgs);
|
||||
messages.add(userMessage);
|
||||
return messages;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static List<ChatGPTRequest.Message> getContextMessages(final List<? extends MessageContext> contextMessages) {
|
||||
return contextMessages.stream()
|
||||
.map(ChatGPTRequestBuilder::mapContextMessages)
|
||||
.flatMap(List::stream)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static List<ChatGPTRequest.Message> mapContextMessages(final MessageContext contextMessage) {
|
||||
return switch (contextMessage) {
|
||||
case PreviousInteraction previousInteractions -> List.of(
|
||||
new ChatGPTRequest.Message("user", previousInteractions.question()),
|
||||
new ChatGPTRequest.Message("assistant", previousInteractions.answer())
|
||||
);
|
||||
case ReplyInteraction replyInteractions ->
|
||||
List.of(new ChatGPTRequest.Message("assistant", replyInteractions.answer()));
|
||||
};
|
||||
}
|
||||
|
||||
public ChatGPTRequest contextRequest(final List<? extends MessageContext> contextMessages, final String message, final String systemMessage) {
|
||||
final List<ChatGPTRequest.Message> messages = getMessages(contextMessages, message, systemMessage);
|
||||
return new ChatGPTRequest(
|
||||
contextModel,
|
||||
messages,
|
||||
0.7f
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.openai;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import de.hhhammer.dchat.bot.openai.models.ChatGPTRequest;
|
||||
import de.hhhammer.dchat.bot.openai.models.ChatGPTResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
|
||||
public final class ChatGPTService {
|
||||
private static final String url = "https://api.openai.com/v1/chat/completions";
|
||||
private final String apiKey;
|
||||
private final HttpClient httpClient;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public ChatGPTService(final String apiKey, final HttpClient httpClient, final ObjectMapper mapper) {
|
||||
this.apiKey = apiKey;
|
||||
this.httpClient = httpClient;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
public ChatGPTResponse submit(final ChatGPTRequest chatGPTRequest) throws IOException, InterruptedException, ResponseException {
|
||||
final byte[] data = mapper.writeValueAsBytes(chatGPTRequest);
|
||||
final HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(data))
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setHeader("Authorization", "Bearer " + this.apiKey)
|
||||
.timeout(Duration.ofMinutes(5))
|
||||
.build();
|
||||
|
||||
final HttpResponse<InputStream> responseStream = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
|
||||
if (responseStream.statusCode() != 200) {
|
||||
throw new ResponseException("Response status code was not 200: " + responseStream.statusCode());
|
||||
}
|
||||
|
||||
return mapper.readValue(responseStream.body(), ChatGPTResponse.class);
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.openai;
|
||||
|
||||
public sealed interface MessageContext {
|
||||
record ReplyInteraction(String answer) implements MessageContext {
|
||||
}
|
||||
|
||||
record PreviousInteraction(String question, String answer) implements MessageContext {
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.openai;
|
||||
|
||||
public final class ResponseException extends Exception {
|
||||
public ResponseException(final String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.openai.models;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ChatGPTRequest(String model, List<Message> messages, float temperature) {
|
||||
|
||||
public record Message(String role, String content) {
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
package de.hhhammer.dchat.bot.openai.models;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record ChatGPTResponse(Usage usage,
|
||||
List<Choice> choices) {
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Usage(@JsonProperty("prompt_tokens") int promptTokens,
|
||||
@JsonProperty("completion_tokens") int completionTokens,
|
||||
@JsonProperty("total_tokens") int totalTokens) {
|
||||
}
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Choice(Message message) {
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Message(String role, String content) {
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="de.hhhammer.dchat" level="DEBUG"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
</configuration>
|
|
@ -1,7 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
public final class DBException extends Exception {
|
||||
public DBException(final String message, final Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
|
@ -1,202 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
import de.hhhammer.dchat.db.models.server.ServerConfig;
|
||||
import de.hhhammer.dchat.db.models.server.ServerMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
public final class PostgresServerDBService implements ServerDBService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(PostgresServerDBService.class);
|
||||
private final DataSource dataSource;
|
||||
|
||||
public PostgresServerDBService(final DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ServerConfig> getConfig(final String serverId) {
|
||||
final var getServerConfig = """
|
||||
SELECT * FROM server_configs WHERE server_id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, serverId);
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
final Iterable<ServerConfig> iterable = () -> new ResultSetIterator<>(resultSet, new ServerConfig.ServerConfigResultSetTransformer());
|
||||
return StreamSupport.stream(iterable.spliterator(), false).findFirst();
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Getting configuration for server with id: " + serverId, e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
logger.error("Iterating over ServerConfig ResultSet for server with id: " + serverId, e);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ServerConfig> getAllConfigs() throws DBException {
|
||||
final var getAllowedServerSql = """
|
||||
SELECT * FROM server_configs
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getAllowedServerSql)
|
||||
) {
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
final Iterable<ServerConfig> iterable = () -> new ResultSetIterator<>(resultSet, new ServerConfig.ServerConfigResultSetTransformer());
|
||||
return StreamSupport.stream(iterable.spliterator(), false).toList();
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Loading all configs", e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
throw new DBException("Iterating over configs", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<ServerConfig> getConfigBy(final long id) throws DBException {
|
||||
final var getServerConfig = """
|
||||
SELECT * FROM server_configs WHERE id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setLong(1, id);
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
final Iterable<ServerConfig> iterable = () -> new ResultSetIterator<>(resultSet, new ServerConfig.ServerConfigResultSetTransformer());
|
||||
return StreamSupport.stream(iterable.spliterator(), false).findFirst();
|
||||
} catch (SQLException e) {
|
||||
throw new DBException("Getting configuration with id: " + id, e);
|
||||
} catch (ResultSetIteratorException e) {
|
||||
throw new DBException("Iterating over ServerConfig ResultSet for id: " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addConfig(final ServerConfig.NewServerConfig newServerConfig) throws DBException {
|
||||
final var getServerConfig = """
|
||||
INSERT INTO server_configs (server_id, system_message, rate_limit) VALUES (?,?,?)
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, newServerConfig.serverId());
|
||||
pstmt.setString(2, newServerConfig.systemMessage());
|
||||
pstmt.setInt(3, newServerConfig.rateLimit());
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No config added for server with id: " + newServerConfig.serverId());
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Adding configuration to server with id: " + newServerConfig.serverId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateConfig(final long id, final ServerConfig.NewServerConfig newServerConfig) throws DBException {
|
||||
final var getServerConfig = """
|
||||
UPDATE server_configs SET system_message = ?, rate_limit = ?, server_id = ? WHERE id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, newServerConfig.systemMessage());
|
||||
pstmt.setInt(2, newServerConfig.rateLimit());
|
||||
pstmt.setString(3, newServerConfig.serverId());
|
||||
pstmt.setLong(4, id);
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No config update for server with id: " + newServerConfig.serverId());
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Adding configuration to server with id: " + newServerConfig.serverId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteConfig(final long id) throws DBException {
|
||||
final var getServerConfig = """
|
||||
DELETE FROM server_configs WHERE id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setLong(1, id);
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No config deleted for server with id: " + id);
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Deleting configuration for server with id: " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countMessagesInLastMinute(final String serverId) {
|
||||
final var getServerConfig = """
|
||||
SELECT count(*) FROM server_messages WHERE server_id = ? AND time <= ? and time >= ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, serverId);
|
||||
final var now = Instant.now();
|
||||
pstmt.setTimestamp(2, Timestamp.from(now));
|
||||
pstmt.setTimestamp(3, Timestamp.from(now.minus(1, ChronoUnit.MINUTES)));
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
if (resultSet.next()) return resultSet.getInt(1);
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Getting messages for server with id: " + serverId, e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
logger.error("Iterating over ServerMessages ResultSet for server with id: " + serverId, e);
|
||||
}
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addMessage(final ServerMessage.NewServerMessage serverMessage) {
|
||||
final var getServerConfig = """
|
||||
INSERT INTO server_messages (server_id, user_id, tokens) VALUES (?,?,?)
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, serverMessage.serverId());
|
||||
pstmt.setLong(2, serverMessage.userId());
|
||||
pstmt.setInt(3, serverMessage.tokens());
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No message added for server with id: " + serverMessage.serverId());
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Adding message to server with id: " + serverMessage.serverId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long tokensOfLast30Days(final String serverId) {
|
||||
final var countTokensOfLast30Days = """
|
||||
SELECT sum(tokens) FROM server_messages WHERE server_id = ? AND time < ? AND time >= ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(countTokensOfLast30Days)
|
||||
) {
|
||||
pstmt.setString(1, serverId);
|
||||
final var now = Instant.now();
|
||||
pstmt.setTimestamp(2, Timestamp.from(now));
|
||||
pstmt.setTimestamp(3, Timestamp.from(now.minus(30, ChronoUnit.DAYS)));
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
if (resultSet.next()) return resultSet.getLong(1);
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Counting tokens of the last 30 days from server with id: " + serverId, e);
|
||||
}
|
||||
logger.error("No tokens found for server with id: " + serverId);
|
||||
return 0;
|
||||
}
|
||||
}
|
|
@ -1,226 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
import de.hhhammer.dchat.db.models.user.UserConfig;
|
||||
import de.hhhammer.dchat.db.models.user.UserMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.*;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.StreamSupport;
|
||||
|
||||
public final class PostgresUserDBService implements UserDBService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(PostgresUserDBService.class);
|
||||
private final DataSource dataSource;
|
||||
|
||||
public PostgresUserDBService(final DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<UserConfig> getConfig(final String userId) {
|
||||
final var getServerConfig = """
|
||||
SELECT * FROM user_configs WHERE user_id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, userId);
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
final Iterable<UserConfig> iterable = () -> new ResultSetIterator<>(resultSet, new UserConfig.UserConfigResultSetTransformer());
|
||||
return StreamSupport.stream(iterable.spliterator(), false).findFirst();
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Getting configuration for user with id: " + userId, e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
logger.error("Iterating over ServerConfig ResultSet for user with id: " + userId, e);
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<UserConfig> getConfigBy(final long id) throws DBException {
|
||||
final var getServerConfig = """
|
||||
SELECT * FROM user_configs WHERE id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setLong(1, id);
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
final Iterable<UserConfig> iterable = () -> new ResultSetIterator<>(resultSet, new UserConfig.UserConfigResultSetTransformer());
|
||||
return StreamSupport.stream(iterable.spliterator(), false).findFirst();
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Getting configuration id: " + id, e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
throw new DBException("Iterating over UserConfig ResultSet with id: " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserConfig> getAllConfigs() throws DBException {
|
||||
final var getServerConfig = """
|
||||
SELECT * FROM user_configs
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
final Iterable<UserConfig> iterable = () -> new ResultSetIterator<>(resultSet, new UserConfig.UserConfigResultSetTransformer());
|
||||
return StreamSupport.stream(iterable.spliterator(), false).toList();
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Getting all configurations", e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
throw new DBException("Iterating over all UserConfig ResultSet", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addConfig(UserConfig.NewUserConfig newUserConfig) throws DBException {
|
||||
final var getServerConfig = """
|
||||
INSERT INTO user_configs (user_id, system_message, context_length, rate_limit) VALUES (?,?,?,?)
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, newUserConfig.userId());
|
||||
pstmt.setString(2, newUserConfig.systemMessage());
|
||||
pstmt.setInt(3, newUserConfig.contextLength());
|
||||
pstmt.setInt(4, newUserConfig.rateLimit());
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No config added for user with id: " + newUserConfig.userId());
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Adding configuration for user with id: " + newUserConfig.userId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateConfig(final long id, final UserConfig.NewUserConfig newUserConfig) throws DBException {
|
||||
final var getServerConfig = """
|
||||
UPDATE user_configs SET system_message = ?, context_length = ?, rate_limit = ?, user_id = ? WHERE id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, newUserConfig.systemMessage());
|
||||
pstmt.setInt(2, newUserConfig.rateLimit());
|
||||
pstmt.setLong(3, newUserConfig.contextLength());
|
||||
pstmt.setString(4, newUserConfig.userId());
|
||||
pstmt.setLong(5, id);
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No config update with id: " + id);
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Updating configuration with id: " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteConfig(final long id) throws DBException {
|
||||
final var getServerConfig = """
|
||||
DELETE FROM user_configs WHERE id = ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setLong(1, id);
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No config deleted for user with id: " + id);
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
throw new DBException("Deleting configuration with id: " + id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int countMessagesInLastMinute(final String userId) {
|
||||
final var getServerConfig = """
|
||||
SELECT count(*) FROM user_messages WHERE user_id = ? AND time <= ? and time >= ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, userId);
|
||||
final var now = Instant.now();
|
||||
pstmt.setTimestamp(2, Timestamp.from(now));
|
||||
pstmt.setTimestamp(3, Timestamp.from(now.minus(1, ChronoUnit.MINUTES)));
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
if (resultSet.next()) return resultSet.getInt(1);
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Getting messages for user with id: " + userId, e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
logger.error("Iterating over ServerMessages ResultSet for user with id: " + userId, e);
|
||||
}
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addMessage(final UserMessage.NewUserMessage newUserMessage) {
|
||||
final var getServerConfig = """
|
||||
INSERT INTO user_messages (user_id, question, answer, tokens) VALUES (?,?,?,?)
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getServerConfig)
|
||||
) {
|
||||
pstmt.setString(1, newUserMessage.userId());
|
||||
pstmt.setString(2, newUserMessage.question());
|
||||
pstmt.setString(3, newUserMessage.answer());
|
||||
pstmt.setInt(4, newUserMessage.tokens());
|
||||
final int affectedRows = pstmt.executeUpdate();
|
||||
if (affectedRows == 0) {
|
||||
logger.error("No message added for user with id: " + newUserMessage.userId());
|
||||
}
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Adding message to user with id: " + newUserMessage.userId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<UserMessage> getLastMessages(final String userId, final int limit) {
|
||||
final var getLastMessages = """
|
||||
SELECT * FROM user_messages WHERE user_id = ? ORDER BY time DESC LIMIT ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(getLastMessages)
|
||||
) {
|
||||
pstmt.setString(1, userId);
|
||||
pstmt.setInt(2, limit);
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
final Iterable<UserMessage> iterable = () -> new ResultSetIterator<>(resultSet, new UserMessage.UserMessageResultSetTransformer());
|
||||
return StreamSupport.stream(iterable.spliterator(), false).toList();
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Fetching last messages for user whit id: " + userId, e);
|
||||
} catch (final ResultSetIteratorException e) {
|
||||
logger.error("Iterating over messages ResultSet from user with id: " + userId, e);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long tokensOfLast30Days(final String userId) {
|
||||
final var countTokensOfLast30Days = """
|
||||
SELECT sum(tokens) FROM user_messages WHERE user_id = ? AND time < ? AND time >= ?
|
||||
""";
|
||||
try (final Connection con = dataSource.getConnection();
|
||||
final PreparedStatement pstmt = con.prepareStatement(countTokensOfLast30Days)
|
||||
) {
|
||||
pstmt.setString(1, userId);
|
||||
final var now = Instant.now();
|
||||
pstmt.setTimestamp(2, Timestamp.from(now));
|
||||
pstmt.setTimestamp(3, Timestamp.from(now.minus(30, ChronoUnit.DAYS)));
|
||||
final ResultSet resultSet = pstmt.executeQuery();
|
||||
if (resultSet.next()) return resultSet.getLong(1);
|
||||
} catch (final SQLException e) {
|
||||
logger.error("Counting tokens of the last 30 days from user with id: " + userId, e);
|
||||
}
|
||||
logger.error("No tokens found for user with id: " + userId);
|
||||
return 0;
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Iterator;
|
||||
|
||||
public final class ResultSetIterator<T> implements Iterator<T> {
|
||||
private final ResultSet resultSet;
|
||||
private final ResultSetTransformer<T> transformer;
|
||||
|
||||
public ResultSetIterator(final ResultSet resultSet, final ResultSetTransformer<T> transformer) {
|
||||
this.resultSet = resultSet;
|
||||
this.transformer = transformer;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasNext() {
|
||||
try {
|
||||
return resultSet.next();
|
||||
} catch (SQLException e) {
|
||||
throw new ResultSetIteratorException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public T next() {
|
||||
try {
|
||||
return transformer.transform(resultSet);
|
||||
} catch (SQLException e) {
|
||||
throw new ResultSetIteratorException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
public final class ResultSetIteratorException extends RuntimeException {
|
||||
public ResultSetIteratorException(final Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ResultSetTransformer<T> {
|
||||
T transform(ResultSet resultSet) throws SQLException;
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
import de.hhhammer.dchat.db.models.server.ServerConfig;
|
||||
import de.hhhammer.dchat.db.models.server.ServerMessage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServerDBService {
|
||||
Optional<ServerConfig> getConfig(String serverId);
|
||||
|
||||
List<ServerConfig> getAllConfigs() throws DBException;
|
||||
|
||||
Optional<ServerConfig> getConfigBy(long id) throws DBException;
|
||||
|
||||
void addConfig(ServerConfig.NewServerConfig newServerConfig) throws DBException;
|
||||
|
||||
void updateConfig(long id, ServerConfig.NewServerConfig newServerConfig) throws DBException;
|
||||
|
||||
void deleteConfig(long id) throws DBException;
|
||||
|
||||
int countMessagesInLastMinute(String serverId);
|
||||
|
||||
void addMessage(ServerMessage.NewServerMessage serverMessage);
|
||||
|
||||
long tokensOfLast30Days(String serverId);
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package de.hhhammer.dchat.db;
|
||||
|
||||
import de.hhhammer.dchat.db.models.user.UserConfig;
|
||||
import de.hhhammer.dchat.db.models.user.UserMessage;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserDBService {
|
||||
Optional<UserConfig> getConfig(String userId);
|
||||
|
||||
Optional<UserConfig> getConfigBy(long id) throws DBException;
|
||||
|
||||
List<UserConfig> getAllConfigs() throws DBException;
|
||||
|
||||
void addConfig(UserConfig.NewUserConfig newUserConfig) throws DBException;
|
||||
|
||||
void updateConfig(long id, UserConfig.NewUserConfig newUserConfig) throws DBException;
|
||||
|
||||
void deleteConfig(long id) throws DBException;
|
||||
|
||||
int countMessagesInLastMinute(String userId);
|
||||
|
||||
void addMessage(UserMessage.NewUserMessage newUserMessage);
|
||||
|
||||
List<UserMessage> getLastMessages(String userId, int limit);
|
||||
|
||||
long tokensOfLast30Days(String userId);
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package de.hhhammer.dchat.db.models.server;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import de.hhhammer.dchat.db.ResultSetTransformer;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
|
||||
public record ServerConfig(long id, String serverId, String systemMessage, int rateLimit, Instant time) {
|
||||
|
||||
public static class ServerConfigResultSetTransformer implements ResultSetTransformer<ServerConfig> {
|
||||
|
||||
@Override
|
||||
public ServerConfig transform(ResultSet resultSet) throws SQLException {
|
||||
var id = resultSet.getLong("id");
|
||||
var serverId = resultSet.getString("server_id");
|
||||
var systemMessage = resultSet.getString("system_message");
|
||||
var rateLimit = resultSet.getInt("rate_limit");
|
||||
var time = resultSet.getTimestamp("time").toInstant();
|
||||
return new ServerConfig(id, serverId, systemMessage, rateLimit, time);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record NewServerConfig(String serverId, String systemMessage, @Nullable int rateLimit) {
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
package de.hhhammer.dchat.db.models.server;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import de.hhhammer.dchat.db.ResultSetTransformer;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
|
||||
public record ServerMessage(long id, String serverId, long userId, int tokens, Instant time) {
|
||||
|
||||
public static class ServerMessageResultSetTransformer implements ResultSetTransformer<ServerMessage> {
|
||||
|
||||
@Override
|
||||
public ServerMessage transform(ResultSet resultSet) throws SQLException {
|
||||
var id = resultSet.getLong("id");
|
||||
var serverId = resultSet.getString("server_id");
|
||||
var userId = resultSet.getLong("user_id");
|
||||
var tokens = resultSet.getInt("tokens");
|
||||
var time = resultSet.getTimestamp("time").toInstant();
|
||||
return new ServerMessage(id, serverId, userId, tokens, time);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record NewServerMessage(String serverId, long userId, int tokens) {
|
||||
}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
package de.hhhammer.dchat.db.models.user;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import de.hhhammer.dchat.db.ResultSetTransformer;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
|
||||
public record UserConfig(long id, String userId, String systemMessage, int contextLength, int rateLimit, Instant time) {
|
||||
|
||||
public static class UserConfigResultSetTransformer implements ResultSetTransformer<UserConfig> {
|
||||
|
||||
@Override
|
||||
public UserConfig transform(ResultSet resultSet) throws SQLException {
|
||||
var id = resultSet.getLong("id");
|
||||
var userId = resultSet.getString("user_id");
|
||||
var systemMessage = resultSet.getString("system_message");
|
||||
var contextLength = resultSet.getInt("context_length");
|
||||
var rateLimit = resultSet.getInt("rate_limit");
|
||||
var time = resultSet.getTimestamp("time").toInstant();
|
||||
return new UserConfig(id, userId, systemMessage, contextLength, rateLimit, time);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record NewUserConfig(String userId, String systemMessage, @Nullable int contextLength,
|
||||
@Nullable int rateLimit) {
|
||||
}
|
||||
}
|
|
@ -1,29 +0,0 @@
|
|||
package de.hhhammer.dchat.db.models.user;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import de.hhhammer.dchat.db.ResultSetTransformer;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.Instant;
|
||||
|
||||
public record UserMessage(long id, String userId, String question, String answer, int tokens, Instant time) {
|
||||
|
||||
public static class UserMessageResultSetTransformer implements ResultSetTransformer<UserMessage> {
|
||||
|
||||
@Override
|
||||
public UserMessage transform(ResultSet resultSet) throws SQLException {
|
||||
var id = resultSet.getLong("id");
|
||||
var userId = resultSet.getString("user_id");
|
||||
var question = resultSet.getString("question");
|
||||
var answer = resultSet.getString("answer");
|
||||
var tokens = resultSet.getInt("tokens");
|
||||
var time = resultSet.getTimestamp("time").toInstant();
|
||||
return new UserMessage(id, userId, question, answer, tokens, time);
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record NewUserMessage(String userId, String question, String answer, int tokens) {
|
||||
}
|
||||
}
|
|
@ -1,31 +1,30 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<artifactId>dchat</artifactId>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<artifactId>dchat</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>db</artifactId>
|
||||
<artifactId>discord</artifactId>
|
||||
<name>discord</name>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<name>db</name>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<dependencies>
|
||||
<!-- https://mvnrepository.com/artifact/org.postgresql/postgresql -->
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
</build>
|
||||
</project>
|
|
@ -0,0 +1,4 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
public record ConnectionConfig(String gatewayUrl, ConnectionInitiator connectionInitiator) {
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import java.net.http.WebSocket;
|
||||
|
||||
public interface ConnectionInitiator {
|
||||
void initiate(final WebSocket webSocket);
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import de.hhhammer.dchat.model.CloseEvent;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class ConnectionManager {
|
||||
private final String initGatewayUrl;
|
||||
private final String token;
|
||||
private final Retryer retryer;
|
||||
|
||||
public ConnectionManager(final String initGatewayUrl, final String token, final Retryer retryer) {
|
||||
this.initGatewayUrl = initGatewayUrl;
|
||||
this.token = token;
|
||||
this.retryer = retryer;
|
||||
}
|
||||
|
||||
public void start(final Function<ConnectionConfig, CloseEvent> connector) throws InterruptedException {
|
||||
while (retryer.hasRetriesLeft()) {
|
||||
connect(connector);
|
||||
final int reconnectDelayInSeconds = retryer.nextRetryInSeconds();
|
||||
Thread.sleep(Duration.ofSeconds(reconnectDelayInSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
private void connect(final Function<ConnectionConfig, CloseEvent> connector) {
|
||||
ConnectionConfig mutConnectionConfig = new ConnectionConfig(initGatewayUrl, new IdendificationConnectionInitiator(token));
|
||||
boolean isResumable = true;
|
||||
while (isResumable) {
|
||||
final CloseEvent closeEvent = connector.apply(mutConnectionConfig);
|
||||
isResumable = switch (closeEvent) {
|
||||
case CloseEvent.ResumableCloseEvent resumableCloseEvent -> {
|
||||
mutConnectionConfig = new ConnectionConfig(
|
||||
resumableCloseEvent.resumeGatewayUrl(),
|
||||
new ResumeConnectionInitiator(
|
||||
token,
|
||||
resumableCloseEvent.sessionId(),
|
||||
resumableCloseEvent.lastSequence()
|
||||
)
|
||||
);
|
||||
yield true;
|
||||
}
|
||||
case CloseEvent.UnresumableCloseEvent unresumableCloseEvent -> false;
|
||||
case CloseEvent.UnrecoverableCloseEvent unrecoverableCloseEvent ->
|
||||
throw new IllegalStateException("Unable to establish connection to the Discord gateway");
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
30
discord/src/main/java/de/hhhammer/dchat/Connector.java
Normal file
30
discord/src/main/java/de/hhhammer/dchat/Connector.java
Normal file
|
@ -0,0 +1,30 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import de.hhhammer.dchat.model.CloseEvent;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class Connector implements Function<ConnectionConfig, CloseEvent> {
|
||||
private final EventHandler eventHandler;
|
||||
|
||||
public Connector(final EventHandler eventHandler) {
|
||||
this.eventHandler = eventHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CloseEvent apply(final ConnectionConfig connectionConfig) {
|
||||
final ArrayBlockingQueue<CloseEvent> closeEventQueue = new ArrayBlockingQueue<>(1);
|
||||
try (final HttpClient client = HttpClient.newHttpClient()) {
|
||||
client.newWebSocketBuilder()
|
||||
.buildAsync(URI.create(connectionConfig.gatewayUrl()), new DiscordListener(connectionConfig.connectionInitiator(), eventHandler, closeEventQueue))
|
||||
.join();
|
||||
|
||||
return closeEventQueue.take();
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public final class DiscordBotWebSocket {
|
||||
private static final Logger logger = LoggerFactory.getLogger(DiscordBotWebSocket.class);
|
||||
private static final String DISCORD_API_URL = "wss://gateway.discord.gg/?v=10&encoding=json";
|
||||
|
||||
public static void main(final String[] args) throws InterruptedException {
|
||||
final String discordApiKey = System.getenv("DISCORD_API_KEY");
|
||||
if (discordApiKey == null) {
|
||||
logger.error("Missing environment variables: DISCORD_API_KEY");
|
||||
System.exit(1);
|
||||
}
|
||||
final var eventHandler = new EventHandler.LogEventHandler();
|
||||
final var connector = new Connector(eventHandler);
|
||||
final var retryer = new Retryer();
|
||||
final var connectionManager = new ConnectionManager(DISCORD_API_URL, discordApiKey, retryer);
|
||||
connectionManager.start(connector);
|
||||
}
|
||||
}
|
151
discord/src/main/java/de/hhhammer/dchat/DiscordListener.java
Normal file
151
discord/src/main/java/de/hhhammer/dchat/DiscordListener.java
Normal file
|
@ -0,0 +1,151 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import de.hhhammer.dchat.model.CloseEvent;
|
||||
import de.hhhammer.dchat.model.Event;
|
||||
import de.hhhammer.dchat.model.EventType;
|
||||
import org.json.JSONObject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.http.WebSocket;
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionStage;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
public final class DiscordListener implements WebSocket.Listener {
|
||||
private static final Logger logger = LoggerFactory.getLogger(DiscordListener.class);
|
||||
private final AtomicInteger lastSeq = new AtomicInteger();
|
||||
private final AtomicBoolean receivedAck = new AtomicBoolean(true);
|
||||
private final AtomicReference<String> resumeGatewayUrl = new AtomicReference<>();
|
||||
private final AtomicReference<String> sessionId = new AtomicReference<>();
|
||||
private final TextCollector textCollector;
|
||||
private final EventDeserializer eventDeserializer;
|
||||
private final ConnectionInitiator connectionInitiator;
|
||||
private final EventHandler eventHandler;
|
||||
private final BlockingQueue<CloseEvent> closeEventQueue;
|
||||
|
||||
public DiscordListener(final TextCollector textCollector,
|
||||
final EventDeserializer eventDeserializer,
|
||||
final ConnectionInitiator connectionInitiator,
|
||||
final EventHandler eventHandler,
|
||||
final BlockingQueue<CloseEvent> closeEventQueue) {
|
||||
this.textCollector = textCollector;
|
||||
this.eventDeserializer = eventDeserializer;
|
||||
this.connectionInitiator = connectionInitiator;
|
||||
this.eventHandler = eventHandler;
|
||||
this.closeEventQueue = closeEventQueue;
|
||||
}
|
||||
|
||||
public DiscordListener(final ConnectionInitiator connectionInitiator,
|
||||
final EventHandler eventHandler,
|
||||
final BlockingQueue<CloseEvent> closeEventQueue) {
|
||||
this(new TextCollector(), new EventDeserializer(), connectionInitiator, eventHandler, closeEventQueue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onOpen(final WebSocket webSocket) {
|
||||
logger.info("Connection opened");
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onText(final WebSocket webSocket, final CharSequence data, final boolean last) {
|
||||
logger.debug("Received message: {}", data);
|
||||
final Optional<String> optText = textCollector.collect(data, last);
|
||||
if (optText.isEmpty()) {
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
final String text = optText.get();
|
||||
final Event event = eventDeserializer.deserialize(text);
|
||||
final int currentSequence = event.sequence();
|
||||
lastSeq.set(currentSequence);
|
||||
if (!(event.type() instanceof EventType.Empty)) {
|
||||
if (event.type() instanceof EventType.Ready) {
|
||||
final JSONObject payload = event.data();
|
||||
final String sessionId = payload.getString("session_id");
|
||||
final String resumeGatewayUrl = payload.getString("resume_gateway_url");
|
||||
this.sessionId.set(sessionId);
|
||||
this.resumeGatewayUrl.set(resumeGatewayUrl);
|
||||
} else if (event.type() instanceof EventType.Unknown unknownEvent) {
|
||||
eventHandler.handle(unknownEvent.value(), event.data());
|
||||
}
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
final int operation = event.operation();
|
||||
switch (operation) {
|
||||
// reconnect
|
||||
case 7 ->
|
||||
this.closeEventQueue.add(new CloseEvent.ResumableCloseEvent(this.resumeGatewayUrl.get(), this.sessionId.get(), lastSeq.get()));
|
||||
// invalid session
|
||||
case 9 ->
|
||||
this.closeEventQueue.add(new CloseEvent.UnresumableCloseEvent()); // it's technically possible to resume but unlikely -> https://discord.com/developers/docs/events/gateway#resuming
|
||||
// hello event
|
||||
case 10 -> init(webSocket, event.data());
|
||||
// heartbeat acknowledgement
|
||||
case 11 -> receivedAck.set(true);
|
||||
default -> logger.debug("Operation '{}' has no handler", operation);
|
||||
}
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(final WebSocket webSocket, final Throwable error) {
|
||||
logger.error("Listener failed", error);
|
||||
this.closeEventQueue.add(new CloseEvent.UnrecoverableCloseEvent());
|
||||
webSocket.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletionStage<?> onClose(final WebSocket webSocket, final int statusCode, final String reason) {
|
||||
switch (statusCode) {
|
||||
case 0, 4000, 4001, 4002, 4005, 4008 ->
|
||||
this.closeEventQueue.add(new CloseEvent.ResumableCloseEvent(this.resumeGatewayUrl.get(), this.sessionId.get(), lastSeq.get()));
|
||||
case 4003, 4007, 4009 -> this.closeEventQueue.add(new CloseEvent.UnresumableCloseEvent());
|
||||
case 4004, 4010, 4011, 4012, 4013, 4014 ->
|
||||
this.closeEventQueue.add(new CloseEvent.UnrecoverableCloseEvent());
|
||||
default -> {
|
||||
logger.warn("Unexpected close status code: {}: {}", statusCode, reason);
|
||||
this.closeEventQueue.add(new CloseEvent.UnrecoverableCloseEvent());
|
||||
}
|
||||
}
|
||||
webSocket.request(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void init(final WebSocket webSocket, final JSONObject data) {
|
||||
final int heartbeatInterval = data.getInt("heartbeat_interval");
|
||||
startHeartbeat(webSocket, heartbeatInterval);
|
||||
connectionInitiator.initiate(webSocket);
|
||||
}
|
||||
|
||||
private void startHeartbeat(final WebSocket webSocket, final int heartbeatInterval) {
|
||||
logger.info("Starting heartbeat");
|
||||
CompletableFuture.runAsync(() -> {
|
||||
final float jitter = new Random().nextFloat();
|
||||
final int startDelay = Math.round(heartbeatInterval * jitter);
|
||||
try {
|
||||
Thread.sleep(startDelay);
|
||||
while (!webSocket.isOutputClosed() && receivedAck.get() && !Thread.interrupted()) {
|
||||
final int intSeq = lastSeq.get();
|
||||
final String stringSeq = intSeq != 0 ? String.valueOf(intSeq) : "null";
|
||||
// Send heartbeat
|
||||
webSocket.sendText("{\"op\": 1, \"d\": %s}".formatted(stringSeq), true);
|
||||
receivedAck.set(false);
|
||||
TimeUnit.MILLISECONDS.sleep(heartbeatInterval);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
logger.info("Stopping heartbeat");
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import de.hhhammer.dchat.model.Event;
|
||||
import de.hhhammer.dchat.model.EventType;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public final class EventDeserializer {
|
||||
public Event deserialize(final String text) {
|
||||
final JSONObject object = new JSONObject(text);
|
||||
final String type = extractPossibleNullString(object, "t");
|
||||
final EventType eventType = resolveEventType(type);
|
||||
final int sequence = extractPossibleNullInt(object, "s");
|
||||
final int operation = extractPossibleNullInt(object, "op");
|
||||
final JSONObject data = extractPossibleNullObject(object, "d");
|
||||
return new Event(eventType, sequence, operation, data);
|
||||
}
|
||||
|
||||
private EventType resolveEventType(final String type) {
|
||||
if (type.isBlank()) return new EventType.Empty();
|
||||
if ("READY".equals(type)) return new EventType.Ready();
|
||||
return new EventType.Unknown(type);
|
||||
}
|
||||
|
||||
private int extractPossibleNullInt(final JSONObject jsonObject, final String key) {
|
||||
try {
|
||||
return jsonObject.getInt(key);
|
||||
} catch (JSONException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private String extractPossibleNullString(final JSONObject jsonObject, final String key) {
|
||||
try {
|
||||
return jsonObject.getString(key);
|
||||
} catch (JSONException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject extractPossibleNullObject(final JSONObject jsonObject, final String key) {
|
||||
try {
|
||||
return jsonObject.getJSONObject(key);
|
||||
} catch (JSONException e) {
|
||||
return new JSONObject(Map.of());
|
||||
}
|
||||
}
|
||||
}
|
18
discord/src/main/java/de/hhhammer/dchat/EventHandler.java
Normal file
18
discord/src/main/java/de/hhhammer/dchat/EventHandler.java
Normal file
|
@ -0,0 +1,18 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public interface EventHandler {
|
||||
void handle(final String type, final JSONObject data);
|
||||
|
||||
final class LogEventHandler implements EventHandler {
|
||||
private static final Logger logger = LoggerFactory.getLogger(LogEventHandler.class);
|
||||
|
||||
@Override
|
||||
public void handle(final String type, final JSONObject data) {
|
||||
logger.info("Handle event {}: {}", type, data.toString());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import java.net.http.WebSocket;
|
||||
|
||||
public final class IdendificationConnectionInitiator implements ConnectionInitiator {
|
||||
private final String token;
|
||||
|
||||
public IdendificationConnectionInitiator(final String token) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initiate(WebSocket webSocket) {
|
||||
final String identifyPayload = "{\"op\": 2, \"d\": {\"token\": \"" + token + "\", \"intents\": 513, \"properties\": {\"os\": \"linux\", \"browser\": \"dchat_lib\", \"device\": \"dchat_lib\"}}}";
|
||||
webSocket.sendText(identifyPayload, true);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import java.net.http.WebSocket;
|
||||
|
||||
public final class ResumeConnectionInitiator implements ConnectionInitiator {
|
||||
private final String token;
|
||||
private final String sessionId;
|
||||
private final int lastSequence;
|
||||
|
||||
public ResumeConnectionInitiator(final String token, final String sessionId, final int lastSequence) {
|
||||
this.token = token;
|
||||
this.sessionId = sessionId;
|
||||
this.lastSequence = lastSequence;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initiate(WebSocket webSocket) {
|
||||
final String identifyPayload = "{\"op\": 6, \"d\": {\"token\": \"" + token + "\", \"session_id\": \"" + sessionId + "\", \"seq\": " + lastSequence + "}}";
|
||||
webSocket.sendText(identifyPayload, true);
|
||||
}
|
||||
}
|
23
discord/src/main/java/de/hhhammer/dchat/Retryer.java
Normal file
23
discord/src/main/java/de/hhhammer/dchat/Retryer.java
Normal file
|
@ -0,0 +1,23 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class Retryer {
|
||||
private static final int maxRetries = 5;
|
||||
private static final int delayInSeconds = 10;
|
||||
private static final int backoffMultiplier = 2;
|
||||
private final AtomicInteger tries = new AtomicInteger();
|
||||
|
||||
public int nextRetryInSeconds() {
|
||||
final int currentTry = tries.getAndIncrement();
|
||||
int seconds = delayInSeconds;
|
||||
for (int i = 0; i < currentTry; i++) {
|
||||
seconds = seconds * backoffMultiplier;
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
|
||||
public boolean hasRetriesLeft() {
|
||||
return tries.get() <= maxRetries;
|
||||
}
|
||||
}
|
22
discord/src/main/java/de/hhhammer/dchat/TextCollector.java
Normal file
22
discord/src/main/java/de/hhhammer/dchat/TextCollector.java
Normal file
|
@ -0,0 +1,22 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public final class TextCollector {
|
||||
private static final Logger logger = LoggerFactory.getLogger(TextCollector.class);
|
||||
private final StringBuilder stringBuilder = new StringBuilder();
|
||||
|
||||
public Optional<String> collect(final CharSequence text, final boolean last) {
|
||||
stringBuilder.append(text);
|
||||
if (!last) {
|
||||
return Optional.empty();
|
||||
}
|
||||
final String completeEvent = stringBuilder.toString();
|
||||
logger.trace("new event: {}", completeEvent);
|
||||
stringBuilder.setLength(0);
|
||||
return Optional.of(completeEvent);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package de.hhhammer.dchat.model;
|
||||
|
||||
public sealed interface CloseEvent {
|
||||
record UnresumableCloseEvent() implements CloseEvent {
|
||||
}
|
||||
|
||||
record ResumableCloseEvent(String resumeGatewayUrl, String sessionId,
|
||||
int lastSequence) implements CloseEvent {
|
||||
}
|
||||
|
||||
record UnrecoverableCloseEvent() implements CloseEvent {
|
||||
}
|
||||
}
|
9
discord/src/main/java/de/hhhammer/dchat/model/Event.java
Normal file
9
discord/src/main/java/de/hhhammer/dchat/model/Event.java
Normal file
|
@ -0,0 +1,9 @@
|
|||
package de.hhhammer.dchat.model;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public record Event(EventType type,
|
||||
int sequence,
|
||||
int operation,
|
||||
JSONObject data) {
|
||||
}
|
22
discord/src/main/java/de/hhhammer/dchat/model/EventType.java
Normal file
22
discord/src/main/java/de/hhhammer/dchat/model/EventType.java
Normal file
|
@ -0,0 +1,22 @@
|
|||
package de.hhhammer.dchat.model;
|
||||
|
||||
public sealed interface EventType {
|
||||
String value();
|
||||
|
||||
record Ready() implements EventType {
|
||||
@Override
|
||||
public String value() {
|
||||
return "READY";
|
||||
}
|
||||
}
|
||||
|
||||
record Empty() implements EventType {
|
||||
@Override
|
||||
public String value() {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
record Unknown(String value) implements EventType {
|
||||
}
|
||||
}
|
18
discord/src/test/java/de/hhhammer/dchat/AppTest.java
Normal file
18
discord/src/test/java/de/hhhammer/dchat/AppTest.java
Normal file
|
@ -0,0 +1,18 @@
|
|||
package de.hhhammer.dchat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class AppTest {
|
||||
@Test
|
||||
void should() {
|
||||
|
||||
var json = """
|
||||
{"t":null,"s":null,"op":10,"d":{"heartbeat_interval":41250}}
|
||||
""";
|
||||
JSONObject object = new JSONObject(json);
|
||||
String d = object.getString("d");
|
||||
System.out.println(d);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,90 +1,10 @@
|
|||
services:
|
||||
monolith:
|
||||
image: git.hhhammer.de/hamburghammer/dchat/monolith:latest
|
||||
bot:
|
||||
image: git.hhhammer.de/hamburghammer/dchat/bot:latest
|
||||
env_file:
|
||||
- .env
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
target: monolith
|
||||
target: bot
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- db
|
||||
- web
|
||||
ports:
|
||||
- 8080:8080
|
||||
user: 1000:1000
|
||||
depends_on:
|
||||
- db
|
||||
- migration
|
||||
|
||||
migration:
|
||||
image: git.hhhammer.de/hamburghammer/dchat/migration:latest
|
||||
env_file:
|
||||
- .env
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
target: migration
|
||||
user: 1000:1000
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- db
|
||||
|
||||
ui:
|
||||
image: git.hhhammer.de/hamburghammer/dchat/ui:latest
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
target: ui
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 8081:80
|
||||
networks:
|
||||
- ui
|
||||
|
||||
caddy:
|
||||
image: git.hhhammer.de/hamburghammer/dchat/caddy:latest
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
context: .
|
||||
target: caddy
|
||||
restart: unless-stopped
|
||||
user: 1000:1000
|
||||
depends_on:
|
||||
- ui
|
||||
- monolith
|
||||
ports:
|
||||
- 8082:8080
|
||||
networks:
|
||||
- ui
|
||||
- web
|
||||
volumes:
|
||||
- caddy_config:/config
|
||||
|
||||
db:
|
||||
image: docker.io/postgres:15-alpine
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 127.0.0.1:5432:5432
|
||||
networks:
|
||||
- db
|
||||
volumes:
|
||||
- ./data/postgres:/var/lib/postgresql/data:rw
|
||||
healthcheck:
|
||||
test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
caddy_config:
|
||||
|
||||
networks:
|
||||
db:
|
||||
ui:
|
||||
web:
|
||||
|
|
|
@ -1,61 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<artifactId>dchat</artifactId>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>migration</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
|
||||
<name>migration</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<configuration>
|
||||
<shadedArtifactAttached>true</shadedArtifactAttached>
|
||||
<shadedClassifierName>fat</shadedClassifierName>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Main-Class>de.hhhammer.dchat.migration.App</Main-Class>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
|
@ -1,25 +0,0 @@
|
|||
package de.hhhammer.dchat.migration;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Hello world!
|
||||
*/
|
||||
public final class App {
|
||||
private static final Logger logger = LoggerFactory.getLogger(App.class);
|
||||
private static final String DB_MIGRATION_PATH = "db/schema.sql";
|
||||
|
||||
public static void main(final String[] args) {
|
||||
final String postgresUser = System.getenv("POSTGRES_USER");
|
||||
final String postgresPassword = System.getenv("POSTGRES_PASSWORD");
|
||||
final String postgresUrl = System.getenv("POSTGRES_URL");
|
||||
if (postgresUser == null || postgresPassword == null || postgresUrl == null) {
|
||||
logger.error("Missing environment variables: POSTGRES_USER and/or POSTGRES_PASSWORD and/or POSTGRES_URL");
|
||||
System.exit(1);
|
||||
}
|
||||
final var migrationExecutor = new MigrationExecutor(postgresUrl, postgresUser, postgresPassword);
|
||||
final var dbMigrator = new DBMigrator(migrationExecutor, DB_MIGRATION_PATH);
|
||||
dbMigrator.run();
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
package de.hhhammer.dchat.migration;
|
||||
|
||||
public final class DBMigrationException extends Exception {
|
||||
public DBMigrationException(final Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
package de.hhhammer.dchat.migration;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
public final class DBMigrator implements Runnable {
|
||||
private static final Logger logger = LoggerFactory.getLogger(DBMigrator.class);
|
||||
private final MigrationExecutor migrationExecutor;
|
||||
private final String resourcePath;
|
||||
|
||||
public DBMigrator(final MigrationExecutor migrationExecutor, final String resourcePath) {
|
||||
this.migrationExecutor = migrationExecutor;
|
||||
this.resourcePath = resourcePath;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
logger.info("Starting db migration");
|
||||
final ClassLoader classLoader = getClass().getClassLoader();
|
||||
try (final InputStream inputStream = classLoader.getResourceAsStream(this.resourcePath)) {
|
||||
if (inputStream == null) {
|
||||
logger.error("Migration file not found: " + resourcePath);
|
||||
throw new RuntimeException("Migration file not found");
|
||||
}
|
||||
migrationExecutor.migrate(inputStream);
|
||||
} catch (IOException | DBMigrationException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
logger.info("Finished migration");
|
||||
}
|
||||
}
|
|
@ -1,38 +0,0 @@
|
|||
package de.hhhammer.dchat.migration;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
|
||||
public final class MigrationExecutor {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MigrationExecutor.class);
|
||||
private final String jdbcConnectionString;
|
||||
private final String username;
|
||||
private final String password;
|
||||
|
||||
public MigrationExecutor(final String jdbcConnectionString, final String username, final String password) {
|
||||
this.jdbcConnectionString = jdbcConnectionString;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public void migrate(final InputStream input) throws DBMigrationException {
|
||||
try (final Connection con = DriverManager
|
||||
.getConnection(this.jdbcConnectionString, this.username, this.password);
|
||||
final Statement stmp = con.createStatement();
|
||||
) {
|
||||
final String content = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
stmp.execute(content);
|
||||
} catch (SQLException | IOException e) {
|
||||
throw new DBMigrationException(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -1,124 +0,0 @@
|
|||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS allowed_servers (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL,
|
||||
comment TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL,
|
||||
system_message TEXT NOT NULL,
|
||||
rate_limit INT NOT NULL DEFAULT 10,
|
||||
context_length INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS server_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
server_id BIGINT NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
tokens int NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
BEGIN;
|
||||
-- Remove unneeded question and answer from server_messages.
|
||||
-- We only support using reply function on servers.
|
||||
ALTER TABLE server_messages
|
||||
DROP COLUMN IF EXISTS question;
|
||||
|
||||
ALTER TABLE server_messages
|
||||
DROP COLUMN IF EXISTS answer;
|
||||
|
||||
-- Remove unused context_length from server_config
|
||||
-- We only support using reply function on servers.
|
||||
ALTER TABLE server_configs
|
||||
DROP COLUMN IF EXISTS context_length;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Add user support
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS allowed_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
comment TEXT,
|
||||
time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
system_message TEXT NOT NULL,
|
||||
rate_limit INT NOT NULL DEFAULT 10
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
tokens int NOT NULL,
|
||||
time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Add question and response to the saved user message to be able to generate a conversation context
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE user_messages
|
||||
ADD COLUMN IF NOT EXISTS question TEXT NOT NULL;
|
||||
|
||||
ALTER TABLE user_messages
|
||||
ADD COLUMN IF NOT EXISTS answer TEXT NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Add a limit to the context length
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE user_configs
|
||||
ADD COLUMN IF NOT EXISTS context_length INT NOT NULL DEFAULT 5;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Add time to config to know when it was added
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE server_configs
|
||||
ADD COLUMN IF NOT EXISTS time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
ALTER TABLE user_configs
|
||||
ADD COLUMN IF NOT EXISTS time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||
|
||||
COMMIT;
|
||||
|
||||
-- Remove obsolete allowed tables that are being replaced the configs tables
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE IF EXISTS allowed_servers;
|
||||
DROP TABLE IF EXISTS allowed_users;
|
||||
|
||||
COMMIT;
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE server_configs
|
||||
ALTER COLUMN server_id TYPE text USING server_id::text;
|
||||
|
||||
ALTER TABLE server_messages
|
||||
ALTER COLUMN server_id TYPE text USING server_id::text;
|
||||
|
||||
ALTER TABLE user_configs
|
||||
ALTER COLUMN user_id TYPE text USING user_id::text;
|
||||
|
||||
ALTER TABLE user_messages
|
||||
ALTER COLUMN user_id TYPE text USING user_id::text;
|
||||
|
||||
COMMIT;
|
|
@ -1,13 +0,0 @@
|
|||
<configuration>
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder>
|
||||
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<logger name="de.hhhammer.dchat" level="DEBUG"/>
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
</root>
|
||||
</configuration>
|
|
@ -1,65 +0,0 @@
|
|||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<artifactId>dchat</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>monolith</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
<name>monolith</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<artifactId>bot</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<artifactId>web</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<configuration>
|
||||
<shadedArtifactAttached>true</shadedArtifactAttached>
|
||||
<shadedClassifierName>fat</shadedClassifierName>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Main-Class>de.hhhammer.dchat.monolith.App</Main-Class>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
|
@ -1,80 +0,0 @@
|
|||
package de.hhhammer.dchat.monolith;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import de.hhhammer.dchat.bot.DiscordBot;
|
||||
import de.hhhammer.dchat.bot.openai.ChatGPTService;
|
||||
import de.hhhammer.dchat.db.PostgresServerDBService;
|
||||
import de.hhhammer.dchat.db.PostgresUserDBService;
|
||||
import de.hhhammer.dchat.web.AppConfig;
|
||||
import de.hhhammer.dchat.web.WebAPI;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public final class App {
|
||||
private static final Logger logger = LoggerFactory.getLogger(App.class);
|
||||
|
||||
public static void main(final String[] args) {
|
||||
final String discordApiKey = System.getenv("DISCORD_API_KEY");
|
||||
if (discordApiKey == null) {
|
||||
logger.error("Missing environment variables: DISCORD_API_KEY");
|
||||
System.exit(1);
|
||||
}
|
||||
final String openaiApiKey = System.getenv("OPENAI_API_KEY");
|
||||
if (openaiApiKey == null) {
|
||||
logger.error("Missing environment variables: OPENAI_API_KEY");
|
||||
System.exit(1);
|
||||
}
|
||||
final String postgresUser = System.getenv("POSTGRES_USER");
|
||||
final String postgresPassword = System.getenv("POSTGRES_PASSWORD");
|
||||
final String postgresUrl = System.getenv("POSTGRES_URL");
|
||||
if (postgresUser == null || postgresPassword == null || postgresUrl == null) {
|
||||
logger.error("Missing environment variables: POSTGRES_USER and/or POSTGRES_PASSWORD and/or POSTGRES_URL");
|
||||
System.exit(1);
|
||||
}
|
||||
final String apiPortStr = System.getenv("API_PORT") != null ? System.getenv("API_PORT") : "8080";
|
||||
final int apiPort = Integer.parseInt(apiPortStr);
|
||||
final boolean debug = "true".equals(System.getenv("API_DEBUG"));
|
||||
|
||||
final var chatGPTService = new ChatGPTService(openaiApiKey, HttpClient.newHttpClient(), new ObjectMapper());
|
||||
|
||||
final var config = new HikariConfig();
|
||||
config.setJdbcUrl(postgresUrl);
|
||||
config.setUsername(postgresUser);
|
||||
config.setPassword(postgresPassword);
|
||||
|
||||
try (final var ds = new HikariDataSource(config)) {
|
||||
final var serverDBService = new PostgresServerDBService(ds);
|
||||
final var userDBService = new PostgresUserDBService(ds);
|
||||
|
||||
final var discordBot = new DiscordBot(serverDBService, userDBService, chatGPTService, discordApiKey);
|
||||
final var appConfig = new AppConfig(apiPort, debug);
|
||||
final var webApi = new WebAPI(serverDBService, userDBService, appConfig);
|
||||
run(discordBot, webApi);
|
||||
} catch (InterruptedException e) {
|
||||
logger.error("Application failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void run(final Runnable... apps) throws InterruptedException {
|
||||
final List<Callable<Void>> callableApps = Arrays.stream(apps).sequential().map(runnable -> (Callable<Void>) () -> {
|
||||
try {
|
||||
runnable.run();
|
||||
} catch (Exception e) {
|
||||
logger.error("Running app failed", e);
|
||||
throw e;
|
||||
}
|
||||
return null;
|
||||
}).toList();
|
||||
try (final var executorService = Executors.newFixedThreadPool(apps.length)) {
|
||||
executorService.invokeAll(callableApps);
|
||||
}
|
||||
}
|
||||
}
|
120
pom.xml
120
pom.xml
|
@ -30,20 +30,14 @@
|
|||
<maven.compiler.source>21</maven.compiler.source>
|
||||
<maven.compiler.target>21</maven.compiler.target>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<jackson.version>2.17.1</jackson.version>
|
||||
</properties>
|
||||
|
||||
|
||||
<!-- logging -->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>ch.qos.logback</groupId>
|
||||
<artifactId>logback-classic</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
|
@ -53,6 +47,13 @@
|
|||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.junit</groupId>
|
||||
<artifactId>junit-bom</artifactId>
|
||||
<version>5.11.3</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
|
@ -71,111 +72,32 @@
|
|||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<version>42.7.3</version>
|
||||
<scope>runtime</scope>
|
||||
<groupId>org.json</groupId>
|
||||
<artifactId>json</artifactId>
|
||||
<version>20240303</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
<version>5.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-annotations</artifactId>
|
||||
<version>${jackson.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
<version>6.1.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.javacord</groupId>
|
||||
<artifactId>javacord</artifactId>
|
||||
<version>3.8.0</version>
|
||||
<groupId>com.fasterxml.jackson</groupId>
|
||||
<artifactId>jackson-bom</artifactId>
|
||||
<version>2.18.1</version>
|
||||
<type>pom</type>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.apache.logging.log4j</groupId>
|
||||
<artifactId>log4j-to-slf4j</artifactId>
|
||||
<version>2.23.1</version>
|
||||
<scope>runtime</scope>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<build>
|
||||
<pluginManagement>
|
||||
<!-- lock down plugins versions to avoid using Maven defaults (may be moved to parent pom) -->
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>com.google.cloud.tools</groupId>
|
||||
<artifactId>jib-maven-plugin</artifactId>
|
||||
<version>3.3.2</version>
|
||||
</plugin>
|
||||
|
||||
<!-- clean lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#clean_Lifecycle -->
|
||||
<plugin>
|
||||
<artifactId>maven-clean-plugin</artifactId>
|
||||
<version>3.1.0</version>
|
||||
</plugin>
|
||||
<!-- default lifecycle, jar packaging: see https://maven.apache.org/ref/current/maven-core/default-bindings.html#Plugin_bindings_for_jar_packaging -->
|
||||
<plugin>
|
||||
<artifactId>maven-resources-plugin</artifactId>
|
||||
<version>3.0.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>2.22.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-jar-plugin</artifactId>
|
||||
<version>3.0.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-install-plugin</artifactId>
|
||||
<version>2.5.2</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-deploy-plugin</artifactId>
|
||||
<version>2.8.2</version>
|
||||
</plugin>
|
||||
<!-- site lifecycle, see https://maven.apache.org/ref/current/maven-core/lifecycles.html#site_Lifecycle -->
|
||||
<plugin>
|
||||
<artifactId>maven-site-plugin</artifactId>
|
||||
<version>3.7.1</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-project-info-reports-plugin</artifactId>
|
||||
<version>3.0.0</version>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.3</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<version>3.5.1</version>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</pluginManagement>
|
||||
</build>
|
||||
<modules>
|
||||
<module>bot</module>
|
||||
<module>db</module>
|
||||
<module>web</module>
|
||||
<module>migration</module>
|
||||
<module>monolith</module>
|
||||
<module>discord</module>
|
||||
</modules>
|
||||
</project>
|
30
ui/.gitignore
vendored
30
ui/.gitignore
vendored
|
@ -1,30 +0,0 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/extensions.json
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
node
|
40
ui/README.md
40
ui/README.md
|
@ -1,40 +0,0 @@
|
|||
# dchat-ui
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
1
ui/env.d.ts
vendored
1
ui/env.d.ts
vendored
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
|
@ -1,13 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dchat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
5052
ui/package-lock.json
generated
5052
ui/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,30 +0,0 @@
|
|||
{
|
||||
"name": "dchat-ui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"license": "MPL-2.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check build-only",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"daisyui": "^2.51.5",
|
||||
"vue": "^3.2.47",
|
||||
"vue-router": "^4.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.14.2",
|
||||
"@vitejs/plugin-vue": "^4.0.0",
|
||||
"@vue/tsconfig": "^0.1.3",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.22",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"typescript": "~4.8.4",
|
||||
"vite": "^4.1.4",
|
||||
"vue-tsc": "^1.2.0"
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 4.2 KiB |
|
@ -1,37 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="navbar bg-base-100">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost normal-case text-xl">
|
||||
<RouterLink to="/">dchat</RouterLink>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li class="m-1">
|
||||
<RouterLink to="/servers">Servers</RouterLink>
|
||||
</li>
|
||||
<li class="m-1">
|
||||
<RouterLink to="/users">Users</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Suspense>
|
||||
<RouterView />
|
||||
</Suspense>
|
||||
<footer class="footer footer-center p-4 bg-base-300 text-base-content">
|
||||
<div>
|
||||
<p>Copyright © 2023 Augusto Dwenger J.</p>
|
||||
<p>This website is licensed under the <a href="https://git.hhhammer.de/hamburghammer/dchat/raw/branch/main/LICENSE"
|
||||
class="link">MPL-2.0 License</a>
|
||||
and uses open source software. View the <a href="https://git.hhhammer.de/hamburghammer/dchat" class="link">source
|
||||
code</a> on Forgejo.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
|
@ -1,3 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
|
@ -1 +0,0 @@
|
|||
@import './base.css';
|
|
@ -1,36 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
export interface Props {
|
||||
modalId: string | number,
|
||||
openModal: { class: string, label: string },
|
||||
isDestructive?: boolean,
|
||||
submitAction: () => Promise<void>,
|
||||
cancelAction?: () => Promise<void>,
|
||||
onLoadAction?: () => Promise<void>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isDestructive: false,
|
||||
cancelAction: () => Promise.resolve(),
|
||||
onLoadAction: () => Promise.resolve()
|
||||
})
|
||||
const modalId = "modal-" + props.modalId
|
||||
</script>
|
||||
<template>
|
||||
<!-- The button to open modal -->
|
||||
<label v-bind:for="modalId" v-bind:class="props.openModal.class" @click="props.onLoadAction">
|
||||
{{ props.openModal.label }}
|
||||
</label>
|
||||
|
||||
<!-- Put this part before </body> tag -->
|
||||
<input type="checkbox" v-bind:id="modalId" class="modal-toggle" />
|
||||
<div class="modal">
|
||||
<div class="modal-box">
|
||||
<slot />
|
||||
<div class="modal-action">
|
||||
<label v-if="props.isDestructive" v-bind:for="modalId" class="btn btn-error" @click="props.submitAction">Delete</label>
|
||||
<label v-else v-bind:for="modalId" class="btn btn-success" @click="props.submitAction">Save</label>
|
||||
<label v-bind:for="modalId" class="btn" @click="props.cancelAction">Cancel</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,35 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
// index arg being the index number of the row
|
||||
type ActionFunction = (index: number) => void
|
||||
const { tableHeader, tableRows, showActions } = defineProps<{
|
||||
tableHeader: string[],
|
||||
tableRows: any[][],
|
||||
showActions: boolean
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table w-full">
|
||||
<!-- head -->
|
||||
<thead>
|
||||
<tr>
|
||||
<template v-for="header in tableHeader">
|
||||
<th>{{ header }}</th>
|
||||
</template>
|
||||
<th v-if="showActions">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- row -->
|
||||
<tr v-for="row in tableRows">
|
||||
<template v-for="data in row">
|
||||
<th>{{ data }}</th>
|
||||
</template>
|
||||
<th>
|
||||
<slot name="action" :row="row" />
|
||||
</th>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
|
@ -1,4 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
const {id} = defineProps<{id: any}>()
|
||||
debugger
|
||||
</script>
|
|
@ -1,34 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ModalComponent from '@/components/ModalComponent.vue';
|
||||
import type { ServerConfig } from "@/models/server";
|
||||
import { addConfig } from '@/services/ServerConfigs';
|
||||
import { ref } from 'vue'
|
||||
|
||||
const modalId = "addServerConfig"
|
||||
|
||||
const newConfig = ref<ServerConfig | Record<string, never>>({})
|
||||
const submitAction = async () => {
|
||||
if (!newConfig) {
|
||||
return
|
||||
}
|
||||
return addConfig(newConfig.value as ServerConfig)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ModalComponent :modalId="modalId" :openModal="{ class: 'btn btn-circle btn-success', label: 'Add' }"
|
||||
:submitAction="submitAction">
|
||||
<h1 class="text-xl normal-case">Add Server Config</h1>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Server ID</label>
|
||||
<input v-model="newConfig.serverId" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">System Message</label>
|
||||
<textarea v-model="newConfig.systemMessage" class="textarea textarea-bordered w-2/3 h-2/3" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Rate Limit</label>
|
||||
<input v-model.number="newConfig.rateLimit" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
|
@ -1,17 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ModalComponent from '@/components/ModalComponent.vue';
|
||||
import { deleteConfig } from '@/services/ServerConfigs';
|
||||
|
||||
const { id } = defineProps<{ id: number }>()
|
||||
|
||||
const submitAction = async () => {
|
||||
return deleteConfig(id)
|
||||
}
|
||||
const modalId = "delete-" + id
|
||||
</script>
|
||||
<template>
|
||||
<ModalComponent :modalId="modalId" :openModal="{ class: 'btn btn-sm btn-error m-1', label: 'Delete' }"
|
||||
:submitAction="submitAction" :isDestructive="true">
|
||||
<h1 class="text-xl normal-case">Are you sure?</h1>
|
||||
</ModalComponent>
|
||||
</template>
|
|
@ -1,41 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ModalComponent from '@/components/ModalComponent.vue';
|
||||
import { getConfig } from '@/services/ServerConfigs'
|
||||
import { ref } from 'vue';
|
||||
import type { ServerConfig } from '@/models/server';
|
||||
import { updateConfig } from '@/services/ServerConfigs';
|
||||
|
||||
const { id } = defineProps<{ id: number }>()
|
||||
|
||||
const configToEdit = ref<ServerConfig | Record<string, never>>({})
|
||||
const submitAction = async () => {
|
||||
return updateConfig(id, configToEdit.value as ServerConfig)
|
||||
|
||||
}
|
||||
const onLoadAction = async () => {
|
||||
configToEdit.value = await getConfig(id)
|
||||
}
|
||||
const modalId = "edit-" + id
|
||||
</script>
|
||||
<template>
|
||||
<ModalComponent :modalId="modalId" :openModal="{ class: 'btn btn-sm btn-info m-1', label: 'Edit' }"
|
||||
:submitAction="submitAction" :onLoadAction="onLoadAction">
|
||||
<h1 class="text-xl normal-case">Edit Server Config</h1>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">ID</label>
|
||||
<input v-model.number="configToEdit.id" class="input input-bordered w-full max-w-xs" disabled />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Server ID</label>
|
||||
<input v-model="configToEdit.serverId" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">System Message</label>
|
||||
<textarea v-model="configToEdit.systemMessage" class="textarea textarea-bordered w-2/3 h-2/3" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Rate Limit</label>
|
||||
<input v-model.number="configToEdit.rateLimit" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
|
@ -1,35 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ModalComponent from '@/components/ModalComponent.vue';
|
||||
import type { UserConfig } from "@/models/user";
|
||||
import { ref } from 'vue'
|
||||
import { addConfig } from '@/services/UserConfigs';
|
||||
|
||||
const modalId = "addUserConfig"
|
||||
|
||||
const newConfig = ref<UserConfig | Record<string, never>>({})
|
||||
const submitAction = async () => {
|
||||
return addConfig(newConfig.value as UserConfig)
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ModalComponent :modalId="modalId" :openModal="{ class: 'btn btn-circle btn-success', label: 'Add' }"
|
||||
:submitAction="submitAction">
|
||||
<h1 class="text-xl normal-case">Add User Config</h1>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">User ID</label>
|
||||
<input v-model="newConfig.userId" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">System Message</label>
|
||||
<textarea v-model="newConfig.systemMessage" class="textarea textarea-bordered w-2/3 h-2/3" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Context Length</label>
|
||||
<input v-model.number="newConfig.contextLength" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Rate Limit</label>
|
||||
<input v-model.number="newConfig.rateLimit" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
|
@ -1,17 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ModalComponent from '@/components/ModalComponent.vue';
|
||||
import { deleteConfig } from '@/services/UserConfigs';
|
||||
|
||||
const { id } = defineProps<{ id: number }>()
|
||||
|
||||
const submitAction = async () => {
|
||||
return deleteConfig(id)
|
||||
}
|
||||
const modalId = "delete-" + id
|
||||
</script>
|
||||
<template>
|
||||
<ModalComponent :modalId="modalId" :openModal="{ class: 'btn btn-sm btn-error m-1', label: 'Delete' }"
|
||||
:submitAction="submitAction" :isDestructive="true">
|
||||
<h1 class="text-xl normal-case">Are you sure?</h1>
|
||||
</ModalComponent>
|
||||
</template>
|
|
@ -1,44 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import ModalComponent from '@/components/ModalComponent.vue';
|
||||
import { getConfig } from '@/services/UserConfigs'
|
||||
import { ref } from 'vue';
|
||||
import type { UserConfig } from '@/models/user';
|
||||
import { updateConfig } from '@/services/UserConfigs';
|
||||
|
||||
const { id } = defineProps<{ id: number }>()
|
||||
|
||||
const configToEdit = ref<UserConfig | Record<string, never>>({})
|
||||
const submitAction = async () => {
|
||||
return updateConfig(id, configToEdit.value as UserConfig)
|
||||
}
|
||||
const onLoadAction = async () => {
|
||||
configToEdit.value = await getConfig(id)
|
||||
}
|
||||
const modalId = "edit-" + id
|
||||
</script>
|
||||
<template>
|
||||
<ModalComponent :modalId="modalId" :openModal="{ class: 'btn btn-sm btn-info m-1', label: 'Edit' }"
|
||||
:submitAction="submitAction" :onLoadAction="onLoadAction">
|
||||
<h1 class="text-xl normal-case">Edit User Config</h1>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">ID</label>
|
||||
<input v-model.number="configToEdit.id" class="input input-bordered w-full max-w-xs" disabled />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">User ID</label>
|
||||
<input v-model="configToEdit.userId" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">System Message</label>
|
||||
<textarea v-model="configToEdit.systemMessage" class="textarea textarea-bordered w-2/3 h-2/3" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Context Length</label>
|
||||
<input v-model.number="configToEdit.contextLength" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
<div class="m-2">
|
||||
<label class="mr-4">Rate Limit</label>
|
||||
<input v-model.number="configToEdit.rateLimit" class="input input-bordered w-full max-w-xs" />
|
||||
</div>
|
||||
</ModalComponent>
|
||||
</template>
|
|
@ -1,11 +0,0 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from '@/App.vue'
|
||||
import router from './router'
|
||||
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
|
@ -1,8 +0,0 @@
|
|||
export type ServerConfig = {
|
||||
id: number,
|
||||
serverId: string,
|
||||
systemMessage: string,
|
||||
rateLimit: number,
|
||||
time: string
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
export type UserConfig = {
|
||||
id: number,
|
||||
userId: string,
|
||||
systemMessage: string,
|
||||
contextLength: number,
|
||||
rateLimit: number,
|
||||
time: string
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import IndexServersView from '@/views/servers/IndexServersView.vue'
|
||||
import ServerConfigsView from '@/views/servers/ServerConfigsView.vue'
|
||||
import IndexUsersView from '@/views/users/IndexUsersView.vue'
|
||||
import UserConfigsView from '@/views/users/UserConfigsView.vue'
|
||||
|
||||
const routerConfig = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: '/servers',
|
||||
name: 'servers',
|
||||
component: IndexServersView
|
||||
},
|
||||
{
|
||||
path: '/servers/configs',
|
||||
name: 'serverConfigs',
|
||||
component: ServerConfigsView
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
component: IndexUsersView
|
||||
},
|
||||
{
|
||||
path: '/users/configs',
|
||||
name: 'userConfigs',
|
||||
component: UserConfigsView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default routerConfig
|
|
@ -1,39 +0,0 @@
|
|||
import type { ServerConfig } from "@/models/server";
|
||||
|
||||
const configUrl = "/api/servers/configs/"
|
||||
|
||||
export async function getConfigs(): Promise<ServerConfig[]> {
|
||||
return fetch(configUrl)
|
||||
.then(shouldBeSuccess)
|
||||
.then(res => res.json())
|
||||
}
|
||||
|
||||
export function getConfig(id: number): Promise<ServerConfig> {
|
||||
return fetch(configUrl + id)
|
||||
.then(shouldBeSuccess)
|
||||
.then(res => res.json())
|
||||
}
|
||||
|
||||
export function updateConfig(id: number, config: ServerConfig): Promise<void> {
|
||||
return fetch(configUrl + id, { method: "PATCH", body: JSON.stringify(config) })
|
||||
.then(shouldBeSuccess)
|
||||
.then()
|
||||
}
|
||||
|
||||
export function addConfig(config: ServerConfig): Promise<void> {
|
||||
debugger
|
||||
return fetch(configUrl, { method: "POST", body: JSON.stringify(config) })
|
||||
.then(shouldBeSuccess)
|
||||
.then()
|
||||
}
|
||||
|
||||
export function deleteConfig(id: number): Promise<void> {
|
||||
return fetch(configUrl + id, { method: "DELETE" })
|
||||
.then(shouldBeSuccess)
|
||||
.then()
|
||||
}
|
||||
|
||||
function shouldBeSuccess(res: Response): Response {
|
||||
if (res.status >= 200 && res.status < 300) return res
|
||||
throw new Error(`Expected success status code but got: ${res.status} ${res.statusText}`)
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
import type { UserConfig } from '@/models/user'
|
||||
|
||||
const configUrl = "/api/users/configs/"
|
||||
|
||||
export async function getConfigs(): Promise<UserConfig[]> {
|
||||
return fetch(configUrl)
|
||||
.then(shouldBeSuccess)
|
||||
.then(res => res.json())
|
||||
}
|
||||
|
||||
export function getConfig(id: number): Promise<UserConfig> {
|
||||
return fetch(configUrl + id)
|
||||
.then(shouldBeSuccess)
|
||||
.then(res => res.json())
|
||||
}
|
||||
|
||||
export function updateConfig(id: number, config: UserConfig): Promise<void> {
|
||||
return fetch(configUrl + id, { method: "PATCH", body: JSON.stringify(config) })
|
||||
.then(shouldBeSuccess)
|
||||
.then()
|
||||
}
|
||||
|
||||
export function addConfig(config: UserConfig): Promise<void> {
|
||||
debugger
|
||||
return fetch(configUrl, { method: "POST", body: JSON.stringify(config) })
|
||||
.then(shouldBeSuccess)
|
||||
.then()
|
||||
}
|
||||
|
||||
export function deleteConfig(id: number): Promise<void> {
|
||||
return fetch(configUrl + id, { method: "DELETE" })
|
||||
.then(shouldBeSuccess)
|
||||
.then()
|
||||
}
|
||||
|
||||
|
||||
function shouldBeSuccess(res: Response): Response {
|
||||
if (res.status >= 200 && res.status < 300) return res
|
||||
throw new Error(`Expected success status code but got: ${res.status} ${res.statusText}`)
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
<template>
|
||||
<h1 class="text-xl normal-case m-5">Overview</h1>
|
||||
<div class="flex justify-center m-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<RouterLink to="/servers">
|
||||
<button class="btn btn-wide btn-outline">Servers</button>
|
||||
</RouterLink>
|
||||
<RouterLink to="/users">
|
||||
<button class="btn btn-wide btn-outline">Users</button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,5 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<template>
|
||||
|
||||
</template>
|
|
@ -1,12 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<template>
|
||||
<h1 class="text-xl normal-case m-5">Server overview</h1>
|
||||
<div class="flex justify-center m-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<RouterLink to="/servers/configs">
|
||||
<button class="btn btn-wide btn-outline">Configs</button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,27 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import TableComponent from '@/components/TableComponent.vue';
|
||||
import AddServerConfig from '@/components/server/AddServerConfig.vue';
|
||||
import { getConfigs } from '@/services/ServerConfigs'
|
||||
import EditServerConfig from '@/components/server/EditServerConfig.vue';
|
||||
import DeleteServerConfig from '@/components/server/DeleteServerConfig.vue';
|
||||
|
||||
const trimSystemMessage = (str: string) => str.length > 50 ? str.slice(0, 47) + "..." : str
|
||||
|
||||
const serverConfigs = await getConfigs()
|
||||
|
||||
const tableHeader = ["ID", "Server ID", "System Message", "Rate limit", "Created"]
|
||||
const tableRows = serverConfigs.map(config => [config.id, config.serverId, trimSystemMessage(config.systemMessage), config.rateLimit, config.time])
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex justify-between m-5">
|
||||
<h1 class="text-xl normal-case">Server Configs</h1>
|
||||
<AddServerConfig />
|
||||
</div>
|
||||
<TableComponent v-if="serverConfigs.length" :tableHeader="tableHeader" :tableRows="tableRows" :showActions="true">
|
||||
<template #action="action">
|
||||
<EditServerConfig :id="action.row[0]" />
|
||||
<DeleteServerConfig :id="action.row[0]" />
|
||||
</template>
|
||||
</TableComponent>
|
||||
<p v-else class="m-5">No server configs</p>
|
||||
</template>
|
|
@ -1,12 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
</script>
|
||||
<template>
|
||||
<h1 class="text-xl normal-case m-5">Server overview</h1>
|
||||
<div class="flex justify-center m-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<RouterLink to="/users/configs">
|
||||
<button class="btn btn-wide btn-outline">Configs</button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,27 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import TableComponent from '@/components/TableComponent.vue';
|
||||
import AddUserConfig from '@/components/user/AddUserConfig.vue';
|
||||
import { getConfigs } from '@/services/UserConfigs'
|
||||
import EditUserConfig from '@/components/user/EditUserConfig.vue';
|
||||
import DeleteServerConfig from '@/components/server/DeleteServerConfig.vue';
|
||||
|
||||
const trimSystemMessage = (str: string) => str.length > 50 ? str.slice(0, 47) + "..." : str
|
||||
|
||||
const userConfigs = await getConfigs()
|
||||
|
||||
const tableHeader = ["ID", "User ID", "System Message", "Context Length", "Rate limit", "Created"]
|
||||
const tableRows = userConfigs.map(config => [config.id, config.userId, trimSystemMessage(config.systemMessage), config.contextLength, config.rateLimit, config.time])
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex justify-between m-5">
|
||||
<h1 class="text-xl normal-case">User Configs</h1>
|
||||
<AddUserConfig />
|
||||
</div>
|
||||
<TableComponent v-if="userConfigs.length" :tableHeader="tableHeader" :tableRows="tableRows" :showActions="true">
|
||||
<template #action="action">
|
||||
<EditUserConfig :id="action.row[0]" />
|
||||
<DeleteServerConfig :id="action.row[0]" />
|
||||
</template>
|
||||
</TableComponent>
|
||||
<p v-else class="m-5">No user configs</p>
|
||||
</template>
|
|
@ -1,9 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("daisyui")],
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.web.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.node.json",
|
||||
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
})
|
77
web/pom.xml
77
web/pom.xml
|
@ -1,77 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<artifactId>dchat</artifactId>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>web</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
<name>web</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>de.hhhammer.dchat</groupId>
|
||||
<artifactId>db</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.zaxxer</groupId>
|
||||
<artifactId>HikariCP</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.javalin</groupId>
|
||||
<artifactId>javalin</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<configuration>
|
||||
<shadedArtifactAttached>true</shadedArtifactAttached>
|
||||
<shadedClassifierName>fat</shadedClassifierName>
|
||||
<transformers>
|
||||
<transformer
|
||||
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
|
||||
<manifestEntries>
|
||||
<Main-Class>de.hhhammer.dchat.web.App</Main-Class>
|
||||
</manifestEntries>
|
||||
</transformer>
|
||||
</transformers>
|
||||
<filters>
|
||||
<filter>
|
||||
<artifact>*:*</artifact>
|
||||
<excludes>
|
||||
<exclude>META-INF/*.SF</exclude>
|
||||
<exclude>META-INF/*.DSA</exclude>
|
||||
<exclude>META-INF/*.RSA</exclude>
|
||||
</excludes>
|
||||
</filter>
|
||||
</filters>
|
||||
</configuration>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
<goals>
|
||||
<goal>shade</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
|
@ -1,43 +0,0 @@
|
|||
package de.hhhammer.dchat.web;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import de.hhhammer.dchat.db.PostgresServerDBService;
|
||||
import de.hhhammer.dchat.db.PostgresUserDBService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Hello world!
|
||||
*/
|
||||
public final class App {
|
||||
private static final Logger logger = LoggerFactory.getLogger(App.class);
|
||||
|
||||
public static void main(final String[] args) {
|
||||
final String postgresUser = System.getenv("POSTGRES_USER");
|
||||
final String postgresPassword = System.getenv("POSTGRES_PASSWORD");
|
||||
final String postgresUrl = System.getenv("POSTGRES_URL");
|
||||
if (postgresUser == null || postgresPassword == null || postgresUrl == null) {
|
||||
logger.error("Missing environment variables: POSTGRES_USER and/or POSTGRES_PASSWORD and/or POSTGRES_URL");
|
||||
System.exit(1);
|
||||
}
|
||||
|
||||
final String apiPortStr = System.getenv("API_PORT") != null ? System.getenv("API_PORT") : "8080";
|
||||
final int apiPort = Integer.parseInt(apiPortStr);
|
||||
final boolean debug = "true".equals(System.getenv("API_DEBUG"));
|
||||
|
||||
final var config = new HikariConfig();
|
||||
config.setJdbcUrl(postgresUrl);
|
||||
config.setUsername(postgresUser);
|
||||
config.setPassword(postgresPassword);
|
||||
|
||||
try (final var ds = new HikariDataSource(config)) {
|
||||
final var serverDBService = new PostgresServerDBService(ds);
|
||||
final var userDBService = new PostgresUserDBService(ds);
|
||||
final var appConfig = new AppConfig(apiPort, debug);
|
||||
|
||||
final var webApi = new WebAPI(serverDBService, userDBService, appConfig);
|
||||
webApi.run();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +0,0 @@
|
|||
package de.hhhammer.dchat.web;
|
||||
|
||||
public record AppConfig(int port, boolean debug) {
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
package de.hhhammer.dchat.web;
|
||||
|
||||
import de.hhhammer.dchat.db.ServerDBService;
|
||||
import de.hhhammer.dchat.db.UserDBService;
|
||||
import de.hhhammer.dchat.web.server.ConfigCrudHandler;
|
||||
import de.hhhammer.dchat.web.user.ConfigUserCrudHandler;
|
||||
import io.javalin.Javalin;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import io.javalin.plugin.bundled.DevLoggingPlugin;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static io.javalin.apibuilder.ApiBuilder.crud;
|
||||
import static io.javalin.apibuilder.ApiBuilder.path;
|
||||
|
||||
public final class WebAPI implements Runnable {
|
||||
private static final Logger logger = LoggerFactory.getLogger(WebAPI.class);
|
||||
private final ServerDBService serverDBService;
|
||||
private final UserDBService userDBService;
|
||||
private final AppConfig appConfig;
|
||||
|
||||
public WebAPI(final ServerDBService serverDBService, final UserDBService userDBService, final AppConfig appConfig) {
|
||||
this.serverDBService = serverDBService;
|
||||
this.userDBService = userDBService;
|
||||
this.appConfig = appConfig;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
logger.info("Starting web application");
|
||||
final Javalin app = Javalin.create(config -> {
|
||||
if (appConfig.debug()) config.registerPlugin(new DevLoggingPlugin());
|
||||
config.http.prefer405over404 = true; // return 405 instead of 404 if path is mapped to different HTTP method
|
||||
config.http.defaultContentType = "application/json";
|
||||
config.useVirtualThreads = true;
|
||||
config.showJavalinBanner = false;
|
||||
config.router.apiBuilder(() ->
|
||||
path("api", () -> {
|
||||
path("servers", () -> crud("configs/{id}", new ConfigCrudHandler(this.serverDBService)));
|
||||
path("users", () -> crud("configs/{id}", new ConfigUserCrudHandler(this.userDBService)));
|
||||
})
|
||||
);
|
||||
});
|
||||
final var waitForShutdown = new CompletableFuture<Void>();
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
||||
logger.info("Shutting down web application");
|
||||
app.stop();
|
||||
waitForShutdown.complete(null);
|
||||
|
||||
}));
|
||||
app.events(event -> {
|
||||
event.serverStopping(() -> logger.info("Stopping web service"));
|
||||
event.serverStopped(() -> logger.info("Stopped web service"));
|
||||
});
|
||||
app.before(ctx -> {
|
||||
ctx.header("Access-Control-Allow-Origin", "*");
|
||||
ctx.header("Access-Control-Allow-Methods", "*");
|
||||
});
|
||||
app.options("*", ctx -> ctx.status(HttpStatus.OK));
|
||||
|
||||
|
||||
app.start(appConfig.port());
|
||||
try {
|
||||
waitForShutdown.get();
|
||||
} catch (InterruptedException | ExecutionException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
package de.hhhammer.dchat.web.server;
|
||||
|
||||
import de.hhhammer.dchat.db.DBException;
|
||||
import de.hhhammer.dchat.db.ServerDBService;
|
||||
import de.hhhammer.dchat.db.models.server.ServerConfig;
|
||||
import io.javalin.apibuilder.CrudHandler;
|
||||
import io.javalin.http.Context;
|
||||
import io.javalin.http.HttpStatus;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class ConfigCrudHandler implements CrudHandler {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ConfigCrudHandler.class);
|
||||
|
||||
private final ServerDBService serverDBService;
|
||||
|
||||
public ConfigCrudHandler(final ServerDBService serverDBService) {
|
||||
this.serverDBService = serverDBService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void create(@NotNull final Context context) {
|
||||
final ServerConfig.NewServerConfig body = context.bodyAsClass(ServerConfig.NewServerConfig.class);
|
||||
try {
|
||||
this.serverDBService.addConfig(body);
|
||||
} catch (DBException e) {
|
||||
logger.error("Adding new server configuration", e);
|
||||
context.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
context.status(HttpStatus.CREATED);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(@NotNull final Context context, @NotNull final String s) {
|
||||
final var id = Long.parseLong(s);
|
||||
try {
|
||||
this.serverDBService.deleteConfig(id);
|
||||
context.status(HttpStatus.NO_CONTENT);
|
||||
} catch (DBException e) {
|
||||
logger.error("Deleting configuration with id: " + s, e);
|
||||
context.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getAll(@NotNull final Context context) {
|
||||
try {
|
||||
final List<ServerConfig> allowedServers = this.serverDBService.getAllConfigs();
|
||||
context.json(allowedServers);
|
||||
} catch (DBException e) {
|
||||
logger.error("Getting all server configs", e);
|
||||
context.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void getOne(@NotNull final Context context, @NotNull final String s) {
|
||||
final var id = Long.parseLong(s);
|
||||
try {
|
||||
final Optional<ServerConfig> server = this.serverDBService.getConfigBy(id);
|
||||
if (server.isEmpty()) {
|
||||
context.status(HttpStatus.NOT_FOUND);
|
||||
return;
|
||||
}
|
||||
context.json(server.get());
|
||||
} catch (DBException e) {
|
||||
logger.error("Searching for config with id: " + s, e);
|
||||
context.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void update(@NotNull final Context context, @NotNull final String idString) {
|
||||
final ServerConfig.NewServerConfig body = context.bodyAsClass(ServerConfig.NewServerConfig.class);
|
||||
final var id = Long.parseLong(idString);
|
||||
|
||||
try {
|
||||
this.serverDBService.updateConfig(id, body);
|
||||
} catch (DBException e) {
|
||||
logger.error("Updating allowed server with id: " + idString, e);
|
||||
context.status(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue