Compare commits

...

2 commits
main ... v2

Author SHA1 Message Date
33aeb356a4 discord: Add new module for the discord interaction
Currently in WIP status - just trying things out.
The idea is to build the discord interactions with an own WebSocket
implementation.
2024-11-10 23:52:06 +01:00
18ee28daee Remove old modules
Time for a new beginning :)
2024-10-31 18:37:08 +01:00
102 changed files with 520 additions and 8125 deletions

View file

@ -1,10 +1,7 @@
matrix:
MODULE:
- db
- bot
- web
- migration
- monolith
- discord
when:
event: [tag, push, manual]

View file

@ -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

View file

@ -1,10 +1,6 @@
matrix:
TARGET:
- bot
- web
- migration
- ui
- monolith
PLATFORM:
- linux/amd64

View file

@ -4,7 +4,6 @@ matrix:
depends_on:
- java
- nodejs
when:
event: [tag, push, manual]

View file

@ -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

View file

@ -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>

View file

@ -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!");
}
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View file

@ -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
);
}
}

View file

@ -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);
}
}

View file

@ -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 {
}
}

View file

@ -1,7 +0,0 @@
package de.hhhammer.dchat.bot.openai;
public final class ResponseException extends Exception {
public ResponseException(final String message) {
super(message);
}
}

View file

@ -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) {
}
}

View file

@ -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) {
}
}
}

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}

View file

@ -1,7 +0,0 @@
package de.hhhammer.dchat.db;
public final class ResultSetIteratorException extends RuntimeException {
public ResultSetIteratorException(final Throwable cause) {
super(cause);
}
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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) {
}
}

View file

@ -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) {
}
}

View file

@ -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) {
}
}

View file

@ -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) {
}
}

View file

@ -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>

View file

@ -0,0 +1,4 @@
package de.hhhammer.dchat;
public record ConnectionConfig(String gatewayUrl, ConnectionInitiator connectionInitiator) {
}

View file

@ -0,0 +1,7 @@
package de.hhhammer.dchat;
import java.net.http.WebSocket;
public interface ConnectionInitiator {
void initiate(final WebSocket webSocket);
}

View file

@ -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");
};
}
}
}

View 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);
}
}
}

View file

@ -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);
}
}

View 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");
});
}
}

View file

@ -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());
}
}
}

View 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());
}
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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;
}
}

View 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);
}
}

View file

@ -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 {
}
}

View 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) {
}

View 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 {
}
}

View 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);
}
}

View file

@ -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:

View file

@ -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>

View file

@ -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();
}
}

View file

@ -1,7 +0,0 @@
package de.hhhammer.dchat.migration;
public final class DBMigrationException extends Exception {
public DBMigrationException(final Throwable cause) {
super(cause);
}
}

View file

@ -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");
}
}

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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
View file

@ -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
View file

@ -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

View file

@ -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
View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View file

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -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>

View file

@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -1 +0,0 @@
@import './base.css';

View file

@ -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>

View file

@ -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>

View file

@ -1,4 +0,0 @@
<script setup lang="ts">
const {id} = defineProps<{id: any}>()
debugger
</script>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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')

View file

@ -1,8 +0,0 @@
export type ServerConfig = {
id: number,
serverId: string,
systemMessage: string,
rateLimit: number,
time: string
}

View file

@ -1,8 +0,0 @@
export type UserConfig = {
id: number,
userId: string,
systemMessage: string,
contextLength: number,
rateLimit: number,
time: string
}

View file

@ -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

View file

@ -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}`)
}

View file

@ -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}`)
}

View file

@ -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>

View file

@ -1,5 +0,0 @@
<script setup lang="ts">
</script>
<template>
</template>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -1,9 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [require("daisyui")],
}

View file

@ -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"
}
]
}

View file

@ -1,8 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.node.json",
"include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"],
"compilerOptions": {
"composite": true,
"types": ["node"]
}
}

View file

@ -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))
}
}
})

View file

@ -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>

View file

@ -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();
}
}
}

View file

@ -1,4 +0,0 @@
package de.hhhammer.dchat.web;
public record AppConfig(int port, boolean debug) {
}

View file

@ -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);
}
}
}

View file

@ -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