diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..44b4224 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39ba6cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/ \ No newline at end of file diff --git a/README.md b/README.md index fdd1f5b..b5cac69 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,396 @@ -# DBL Java Library -A Java wrapper for the [top.gg API](https://top.gg/api/docs) +# Top.gg Java SDK + +The community-maintained Java library for Top.gg. + +## Chapters + +- [Installation](#installation) +- [Capabilities](#capabilities) +- [Setting up](#setting-up) +- [Usage](#usage) + - [Getting your project's information](#getting-your-projects-information) + - [Getting your project's vote information of a user](#getting-your-projects-vote-information-of-a-user) + - [Getting a cursor-based paginated list of votes for your project](#getting-a-cursor-based-paginated-list-of-votes-for-your-project) + - [Posting your bot's application commands list](#posting-your-bots-application-commands-list) + - [Generating widget URLs](#generating-widget-urls) + - [Webhooks](#webhooks) + +## Installation + +### Gradle + +Add the following line to the `dependencies` section of your `build.gradle`: + +```groovy +implementation 'org.discordbots:DBL-Java-Library:3.0.0' +``` + +### Maven + +Add the following line to the `dependencies` section of your `pom.xml`: + +```xml + + com.discordbots + DBL-Java-Library + 3.0.0 + +``` + +## Capabilities + +This library provides several capabilities that can be enabled/disabled, such as: + +- **`jdaWrapper`**: Additional wrappers for working with [JDA](https://github.com/discord-jda/JDA). +- **`discord4jWrapper`**: Additional wrappers for working with [Discord4J](https://github.com/Discord4J/Discord4J). +- **`webhooks`**: Accessing deserializable webhook payload classes. + - **`dropwizardWebhooks`**: Wrapper for working with the [Dropwizard](https://www.dropwizard.io/en/stable/) web framework. + - **`eclipseJettyWebhooks`**: Wrapper for working with the [Eclipse Jetty](https://jetty.org/index.html) web framework. + - **`springBootWebhooks`**: Wrapper for working with the [Spring Boot](https://spring.io/projects/spring-boot/) web framework. + +## Setting up + +```java +import org.discordbots.api.DBLAPI; + +final DBLAPI client = new DBLAPI(System.getenv("TOPGG_TOKEN")); +``` ## Usage -First, build a DiscordBotListAPI object. +### Getting your project's information ```java -DiscordBotListAPI api = new DiscordBotListAPI.Builder() - .token("token") - .botId("botId") - .build(); +client.getSelf().whenComplete((project, error) -> { + if (error != null) { + System.err.println("Error: " + error.getMessage()); + } else { + // ... + } +}); ``` -#### Posting stats +### Getting your project's vote information of a user + +#### Discord ID + +```java +import org.discordbots.api.entity.UserSource; + +client.getVote(UserSource.DISCORD, "661200758510977084").whenComplete((vote, error) -> { + if (error != null) { + System.err.println("Error: " + error.getMessage()); + } else if (vote == null) { + System.out.println("The user has not voted."); + } else { + // ... + } +}); +``` -DBL provides three ways to post your bots stats. +#### Top.gg ID -**#1** -Posts the server count for the whole bot. ```java -int serverCount = ...; // the total amount of servers across all shards +import org.discordbots.api.entity.UserSource; -api.setStats(serverCount); +client.getVote(UserSource.TOPGG, "8226924471638491136").whenComplete((vote, error) -> { + if (error != null) { + System.err.println("Error: " + error.getMessage()); + } else if (vote == null) { + System.out.println("The user has not voted."); + } else { + // ... + } +}); ``` -**#2** -Posts the server count for an individual shard. +### Getting a cursor-based paginated list of votes for your project + ```java -int shardId = ...; // the id of this shard -int shardCount = ...; // the amount of shards -int serverCount = ...; // the server count of this shard +import org.discordbots.api.entity.Vote; + +final OffsetDateTime since = OffsetDateTime.of(2026, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC); + +client.getVotes(since).whenComplete((firstPage, error) -> { + if (error != null) { + System.err.println("Error: " + error.getMessage()); + } else { + final List firstPageVotes = firstPage.getVotes(); + + firstPage.next().whenComplete((secondPage, secondError) => { + if (secondError != null) { + System.err.println("Error: " + secondError.getMessage()); + } else { + final List secondPageVotes = secondPage.getVotes(); -api.setStats(shardId, shardCount, serverCount); + // ... + } + }); + } +}); ``` -**#3** -Posts the server counts for every shard in one request. +### Posting your bot's application commands list + +#### JDA + +> **NOTE**: Requires the `jdaWrapper` capability. + ```java -List shardServerCounts = ...; // a list of all the shards' server counts +final JDA jda = ...; -api.setStats(shardServerCounts); +client.postCommands(jda); ``` -#### Checking votes +#### Discord4J + +> **NOTE**: Requires the `discord4jWrapper` capability. ```java -String userId = ...; // ID of the user you're checking -api.hasVoted(userId).whenComplete((hasVoted, e) -> { - if(hasVoted) - System.out.println("This person has voted!"); - else - System.out.println("This person has not voted!"); -}); +final DiscordClient bot = ...; + +client.postCommands(bot); ``` -#### Getting voting multiplier +#### Raw ```java -api.getVotingMultiplier().whenComplete((multiplier, e) -> { - if(multiplier.isWeekend()) - System.out.println("It's the weekend, so votes are worth 2x!"); - else - System.out.println("It's not the weekend :pensive:"); -}); +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; + +// Array of application commands that +// can be serialized to Discord API's raw JSON format. +final String commandsJson = + "[{" + + " \"id\": \"1\"," + + " \"type\": 1," + + " \"application_id\": \"1\"," + + " \"name\": \"test\"," + + " \"description\": \"command description\"," + + " \"default_member_permissions\": \"\"," + + " \"version\": \"1\"" + + "}]"; + +final JsonArray commands = JsonParser.parseString(commandsJson).getAsJsonArray(); + +client.postCommands(commands); ``` -## Download +### Generating widget URLs -[![Release](https://jitpack.io/v/top-gg/java-sdk.svg)](https://jitpack.io/#top-gg/java-sdk) +#### Large -Replace `VERSION` with the latest version or commit hash. The latest version can be found under releases. +```java +import org.discordbots.api.DBLWidget; +import org.discordbots.api.entity.ProjectType; -#### Maven +final String widgetUrl = DBLWidget.large(ProjectType.DISCORD_BOT, "574652751745777665"); +``` -```xml - - - jitpack.io - https://jitpack.io - - +#### Votes + +```java +import org.discordbots.api.DBLWidget; +import org.discordbots.api.entity.ProjectType; + +final String widgetUrl = DBLWidget.votes(ProjectType.DISCORD_BOT, "574652751745777665"); ``` -```xml - - - com.github.top-gg - java-sdk - VERSION - - -``` - -#### Gradle -```gradle -repositories { - maven { url 'https://jitpack.io' } + +#### Owner + +```java +import org.discordbots.api.DBLWidget; +import org.discordbots.api.entity.ProjectType; + +final String widgetUrl = DBLWidget.owner(ProjectType.DISCORD_BOT, "574652751745777665"); +``` + +#### Social + +```java +import org.discordbots.api.DBLWidget; +import org.discordbots.api.entity.ProjectType; + +final String widgetUrl = DBLWidget.social(ProjectType.DISCORD_BOT, "574652751745777665"); +``` + +### Webhooks + +#### Dropwizard + +> **NOTE**: Requires the `dropwizardWebhooks` capability. + +In your `Webhooks.java`: + +```java +import org.discordbots.webhooks.dropwizard.DBLWebhooks; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +// POST /webhook +@Path("/webhook") +public class Webhooks extends DBLWebhooks { + public Webhooks() { + super(System.getenv("TOPGG_WEBHOOK_SECRET")); + } + + // Optional + @Override + public Response onIntegrationCreate(final IntegrationCreatePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + // Optional + @Override + public Response onIntegrationDelete(final IntegrationDeletePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + // Optional + @Override + public Response onTest(final TestPayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + // Optional + @Override + public Response onVoteCreate(final VoteCreatePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } } ``` -```gradle -dependencies { - compile 'com.github.top-gg:java-sdk:VERSION' + +Later, in your server's `run` function: + +```java +env.jersey().register(new Webhooks()); +``` + +#### Eclipse Jetty + +> **NOTE**: Requires the `eclipseJettyWebhooks` capability. + +In your `Webhooks.java`: + +```java +import org.discordbots.webhooks.eclipsejetty.DBLWebhooks; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +import jakarta.servlet.http.HttpServletResponse; + +public class Webhooks extends DBLWebhooks { + public Webhooks() { + super(System.getenv("TOPGG_WEBHOOK_SECRET")); + } + + // Optional + @Override + public void onIntegrationCreate( + final HttpServletResponse response, + final IntegrationCreatePayload payload, + final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + // Optional + @Override + public void onIntegrationDelete( + final HttpServletResponse response, + final IntegrationDeletePayload payload, + final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + // Optional + @Override + public void onTest( + final HttpServletResponse response, final TestPayload payload, final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + // Optional + @Override + public void onVoteCreate( + final HttpServletResponse response, final VoteCreatePayload payload, final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } } ``` +Later, in your server's setup: + +```java +// POST /webhook +context.addServlet(new ServletHolder(new Webhooks()), "/webhook"); +``` +#### Spring Boot + +> **NOTE**: Requires the `springBootWebhooks` capability. + +In your `Webhooks.java`: + +```java +import org.discordbots.webhooks.springboot.DBLWebhooks; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Webhooks extends DBLWebhooks { + public Webhooks() { + super(System.getenv("TOPGG_WEBHOOK_SECRET")); + } + + // POST /webhook + @PostMapping("/webhook") + public ResponseEntity main( + @RequestBody final String body, + @RequestHeader("x-topgg-signature") final String signature, + @RequestHeader("x-topgg-trace") final String trace) { + return dispatch(body, signature, trace); + } + + // Optional + @Override + public ResponseEntity onIntegrationCreate( + final IntegrationCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + // Optional + @Override + public ResponseEntity onIntegrationDelete( + final IntegrationDeletePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + // Optional + @Override + public ResponseEntity onTest(final TestPayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + // Optional + @Override + public ResponseEntity onVoteCreate(final VoteCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} +``` \ No newline at end of file diff --git a/build.gradle b/build.gradle index 023b55d..6267511 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,221 @@ -group 'org.discordbots' +plugins { + id 'java-library' + id 'maven-publish' +} + +def ver = '3.0.0' + +group = 'org.discordbots' +version = ver +description = 'The community-maintained Java library for Top.gg.' -apply plugin: 'java' -apply plugin: 'maven' +if (JavaVersion.current() < JavaVersion.VERSION_17) { + throw new GradleException('Top.gg\'s Java SDK requires Java 17 or later. Java ${JavaVersion.current()} is not supported.') +} repositories { - mavenCentral() + mavenCentral() +} + +sourceSets { + jdaWrapper + discord4jWrapper + + webhooks + dropwizardWebhooks + eclipseJettyWebhooks + springBootWebhooks +} + +configurations { + dropwizardWebhooksImplementation.extendsFrom webhooksImplementation + eclipseJettyWebhooksImplementation.extendsFrom webhooksImplementation + springBootWebhooksImplementation.extendsFrom webhooksImplementation + + + testImplementation.extendsFrom dropwizardWebhooksImplementation + testImplementation.extendsFrom eclipseJettyWebhooksImplementation + testImplementation.extendsFrom springBootWebhooksImplementation + + googleJavaFormat +} + +test { + useJUnitPlatform() +} + +java { + withSourcesJar() + + registerFeature('jdaWrapper') { + usingSourceSet sourceSets.jdaWrapper + + capability('org.discordbots', 'jdaWrapper', ver) + } + + registerFeature('discord4jWrapper') { + usingSourceSet sourceSets.discord4jWrapper + + capability('org.discordbots', 'discord4jWrapper', ver) + } + + registerFeature('webhooks') { + usingSourceSet sourceSets.webhooks + + capability('org.discordbots', 'webhooks', ver) + } + + registerFeature('dropwizardWebhooks') { + usingSourceSet sourceSets.webhooks + usingSourceSet sourceSets.dropwizardWebhooks + + capability('org.discordbots', 'dropwizardWebhooks', ver) + } + + registerFeature('eclipseJettyWebhooks') { + usingSourceSet sourceSets.webhooks + usingSourceSet sourceSets.eclipseJettyWebhooks + + capability('org.discordbots', 'eclipseJettyWebhooks', ver) + } + + registerFeature('springBootWebhooks') { + usingSourceSet sourceSets.webhooks + usingSourceSet sourceSets.springBootWebhooks + + capability('org.discordbots', 'springBootWebhooks', ver) + } +} + +publishing { + publications { + maven(MavenPublication) { + artifactId = 'DBL-Java-Library' + groupId = 'org.discordbots' + version = ver + + from components.java + + pom { + name = 'DBL-Java-Library' + description = 'The community-maintained Java library for Top.gg.' + url = 'https://github.com/top-gg-community/java-sdk' + inceptionYear = '2020' + + licenses { + license { + name = 'Apache License 2.0' + distribution = 'repo' + url = 'https://github.com/top-gg-community/java-sdk/blob/$ver/LICENSE' + } + } + + developers { + developer { + id = 'top-gg-community' + name = 'Top.gg' + url = 'https://github.com/top-gg-community' + } + } + + scm { + url = 'https://github.com/top-gg-community/java-sdk' + connection = 'scm:git:github.com/top-gg-community/java-sdk.git' + developerConnection = 'scm:git:ssh://github.com/top-gg-community/java-sdk.git' + } + + issueManagement { + system = 'GitHub' + url = 'https://github.com/top-gg-community/java-sdk/issues' + } + + ciManagement { + system = 'Github Actions' + url = 'https://github.com/top-gg-community/java-sdk/actions' + } + } + } + } + + repositories { + maven { + url = layout.buildDirectory.dir 'staging-deploy' + } + } } dependencies { + implementation 'org.slf4j:slf4j-api:2.0.17' + + implementation 'org.json:json:20251224' + implementation 'com.squareup.okhttp3:okhttp:5.3.2' + implementation 'com.google.code.gson:gson:2.13.2' + implementation 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2' + + jdaWrapperImplementation sourceSets.main.output + jdaWrapperImplementation 'com.google.code.gson:gson:2.13.2' + jdaWrapperImplementation('net.dv8tion:JDA:6.3.1') { + exclude module: 'opus-java' + exclude module: 'tink' + } + + discord4jWrapperImplementation sourceSets.main.output + discord4jWrapperImplementation 'com.discord4j:discord4j-core:3.3.1' + + webhooksImplementation 'com.google.code.gson:gson:2.13.2' + webhooksImplementation 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2' + + dropwizardWebhooksImplementation sourceSets.webhooks.output + dropwizardWebhooksImplementation 'jakarta.ws.rs:jakarta.ws.rs-api:4.0.0' + dropwizardWebhooksImplementation 'jakarta.servlet:jakarta.servlet-api:6.1.0' + + eclipseJettyWebhooksImplementation sourceSets.webhooks.output + eclipseJettyWebhooksImplementation 'jakarta.servlet:jakarta.servlet-api:6.1.0' - //Logger - compile group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25' + springBootWebhooksImplementation sourceSets.webhooks.output + springBootWebhooksImplementation 'org.springframework.boot:spring-boot-starter-web:4.0.3' + springBootWebhooksImplementation 'jakarta.servlet:jakarta.servlet-api:6.1.0' + + testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.3' + testImplementation 'org.junit.platform:junit-platform-suite-api:6.0.3' + testImplementation 'com.google.code.gson:gson:2.13.2' + testImplementation 'io.dropwizard:dropwizard-testing:5.0.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test-classic:4.0.3' + testImplementation 'org.springframework.boot:spring-boot-starter-security-test:4.0.3' + + testImplementation sourceSets.dropwizardWebhooks.output + testImplementation sourceSets.eclipseJettyWebhooks.output + testImplementation sourceSets.springBootWebhooks.output + + testCompileOnly 'org.junit.jupiter:junit-jupiter-params:6.0.3' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:6.0.3' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:6.0.3' + + googleJavaFormat 'com.google.googlejavaformat:google-java-format:1.34.1' +} + +tasks.register('format', JavaExec) { + group = 'format' + description = 'Format code' + classpath = configurations.googleJavaFormat + mainClass = 'com.google.googlejavaformat.java.Main' + + def sources = fileTree(dir: 'src', include: '**/*.java') + args = ['-i'] + sources + + inputs.files sources + outputs.files sources + + jvmArgs( + '--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED', + '--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED' + ) +} - compile group: 'org.json', name: 'json', version: '20180130' - compile group: 'com.squareup.okhttp3', name: 'okhttp', version: '3.11.0' - compile group: 'com.google.code.gson', name: 'gson', version: '2.8.5' - compile group: 'com.fatboyindustrial.gson-javatime-serialisers', name: 'gson-javatime-serialisers', version: '1.1.1' +tasks.withType(JavaCompile) { + options.compilerArgs << '-Xlint:deprecation' } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1948b90..61285a6 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3eba147..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Sat Jul 14 00:30:34 EDT 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.8-all.zip diff --git a/gradlew b/gradlew index cccdd3d..adff685 100644 --- a/gradlew +++ b/gradlew @@ -1,78 +1,128 @@ -#!/usr/bin/env sh +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -81,92 +131,118 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=$((i+1)) + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=$(save "$@") - -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" -# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong -if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then - cd "$(dirname "$0")" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" fi +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index f955316..e509b2d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,84 +1,93 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java b/src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java new file mode 100644 index 0000000..89fa7de --- /dev/null +++ b/src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java @@ -0,0 +1,37 @@ +package org.discordbots.api.discord4j; + +import com.fasterxml.jackson.core.JsonProcessingException; +import discord4j.common.JacksonResources; +import discord4j.core.DiscordClient; +import java.util.concurrent.CompletionStage; +import org.discordbots.api.io.PostCommandsTransformer; + +public class Discord4JPostCommandsTransformer implements PostCommandsTransformer { + private final DiscordClient client; + + public Discord4JPostCommandsTransformer(final DiscordClient client) { + this.client = client; + } + + @Override + public CompletionStage toJsonString() { + return this.client + .getApplicationId() + .toFuture() + .thenCompose( + applicationId -> + this.client + .getApplicationService() + .getGlobalApplicationCommands(applicationId) + .collectList() + .toFuture()) + .thenApply( + commands -> { + try { + return JacksonResources.create().getObjectMapper().writeValueAsString(commands); + } catch (final JsonProcessingException ignored) { + return "[]"; + } + }); + } +} diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java new file mode 100644 index 0000000..817c98e --- /dev/null +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java @@ -0,0 +1,121 @@ +package org.discordbots.webhooks.dropwizard; + +import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.stream.Collectors; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.Payload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public abstract class DBLWebhooks implements DBLWebhooksListener { + private byte[] secret; + private final Gson gson; + + public DBLWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); + } + + public String getSecret() { + return new String(secret, StandardCharsets.UTF_8); + } + + public void setSecret(final String newSecret) { + secret = newSecret.getBytes(StandardCharsets.UTF_8); + } + + @POST + @SuppressWarnings("UseSpecificCatch") + public Response handle(@Context HttpServletRequest request) throws WebApplicationException { + try { + final String signatureHeader = request.getHeader("x-topgg-signature"); + + assert signatureHeader != null; + + final HashMap parsedSignature = + Arrays.stream(signatureHeader.split(",")) + .map(part -> part.split("=", 2)) + .collect( + Collectors.toMap( + part -> part[0].trim(), + part -> part[1].trim(), + (existing, replacement) -> replacement, + HashMap::new)); + + final String signature = parsedSignature.get("v1"); + final String timestamp = parsedSignature.get("t"); + + assert signature != null && timestamp != null; + + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); + final Mac hmac = Mac.getInstance("HmacSHA256"); + + hmac.init(key); + + final String body = + new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + final byte[] digest = + hmac.doFinal(String.format("%s.%s", timestamp, body).getBytes(StandardCharsets.UTF_8)); + + if (!signature.equals(HexFormat.of().formatHex(digest))) { + return Response.status(Response.Status.UNAUTHORIZED) + .entity("Invalid Authorization") + .build(); + } + + final Payload payload = gson.fromJson(body, Payload.class); + final String trace = request.getHeader("x-topgg-trace"); + + try { + return switch (payload.getType()) { + case "integration.create" -> + onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> onTest(payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + default -> Response.status(Response.Status.BAD_REQUEST).entity("Bad Request").build(); + }; + } catch (final Throwable ignored) { + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity("Internal Server Error") + .build(); + } + } catch (final NoSuchAlgorithmException + | InvalidKeyException + | ArrayIndexOutOfBoundsException + | AssertionError + | JsonSyntaxException + | JsonIOException + | IOException error) { + if (error instanceof NoSuchAlgorithmException || error instanceof InvalidKeyException) { + throw new WebApplicationException("Unable to find HMAC SHA-256 algorithm", error); + } else { + return Response.status(Response.Status.BAD_REQUEST).entity("Bad Request").build(); + } + } + } +} diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java new file mode 100644 index 0000000..5a76818 --- /dev/null +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java @@ -0,0 +1,25 @@ +package org.discordbots.webhooks.dropwizard; + +import jakarta.ws.rs.core.Response; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public interface DBLWebhooksListener { + default Response onIntegrationCreate(final IntegrationCreatePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onIntegrationDelete(final IntegrationDeletePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onTest(final TestPayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onVoteCreate(final VoteCreatePayload payload, final String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } +} diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java new file mode 100644 index 0000000..c27d3f7 --- /dev/null +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java @@ -0,0 +1,128 @@ +package org.discordbots.webhooks.eclipsejetty; + +import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.stream.Collectors; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.Payload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public class DBLWebhooks extends HttpServlet implements DBLWebhooksListener { + private byte[] secret; + private final Gson gson; + + public DBLWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); + } + + public String getSecret() { + return new String(secret, StandardCharsets.UTF_8); + } + + public void setSecret(final String newSecret) { + secret = newSecret.getBytes(StandardCharsets.UTF_8); + } + + @Override + @SuppressWarnings("UseSpecificCatch") + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) + throws IOException, ServletException { + try { + final String signatureHeader = request.getHeader("x-topgg-signature"); + + assert signatureHeader != null; + + final HashMap parsedSignature = + Arrays.stream(signatureHeader.split(",")) + .map(part -> part.split("=", 2)) + .collect( + Collectors.toMap( + part -> part[0].trim(), + part -> part[1].trim(), + (existing, replacement) -> replacement, + HashMap::new)); + + final String signature = parsedSignature.get("v1"); + final String timestamp = parsedSignature.get("t"); + + assert signature != null && timestamp != null; + + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); + final Mac hmac = Mac.getInstance("HmacSHA256"); + + hmac.init(key); + + final String body = + new String(request.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + final byte[] digest = + hmac.doFinal(String.format("%s.%s", timestamp, body).getBytes(StandardCharsets.UTF_8)); + + if (!signature.equals(HexFormat.of().formatHex(digest))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Invalid Authorization"); + + return; + } + + final Payload payload = gson.fromJson(body, Payload.class); + final String trace = request.getHeader("x-topgg-trace"); + + try { + switch (payload.getType()) { + case "integration.create" -> + onIntegrationCreate( + response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + onIntegrationDelete( + response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> + onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + default -> { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Bad Request"); + } + } + } catch (final Throwable ignored) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.getWriter().write("Internal Server Error"); + } + } catch (final NoSuchAlgorithmException + | InvalidKeyException + | ArrayIndexOutOfBoundsException + | AssertionError + | JsonSyntaxException + | JsonIOException + | IOException error) { + if (error instanceof NoSuchAlgorithmException || error instanceof InvalidKeyException) { + throw new ServletException("Unable to find HMAC SHA-256 algorithm", error); + } else { + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Bad Request"); + } + } + } +} diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java new file mode 100644 index 0000000..cee3a90 --- /dev/null +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java @@ -0,0 +1,33 @@ +package org.discordbots.webhooks.eclipsejetty; + +import jakarta.servlet.http.HttpServletResponse; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public interface DBLWebhooksListener { + default void onIntegrationCreate( + final HttpServletResponse response, + final IntegrationCreatePayload payload, + final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onIntegrationDelete( + final HttpServletResponse response, + final IntegrationDeletePayload payload, + final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onTest( + final HttpServletResponse response, final TestPayload payload, final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onVoteCreate( + final HttpServletResponse response, final VoteCreatePayload payload, final String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } +} diff --git a/src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java b/src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java new file mode 100644 index 0000000..471094f --- /dev/null +++ b/src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java @@ -0,0 +1,34 @@ +package org.discordbots.api.jda; + +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import java.util.concurrent.CompletionStage; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.interactions.commands.Command; +import net.dv8tion.jda.api.interactions.commands.build.CommandData; +import org.discordbots.api.io.PostCommandsTransformer; + +public class JDAPostCommandsTransformer implements PostCommandsTransformer { + private final JDA jda; + + public JDAPostCommandsTransformer(final JDA jda) { + this.jda = jda; + } + + @Override + public CompletionStage toJsonString() { + return jda.retrieveCommands() + .submit() + .thenApply( + commands -> { + final JsonArray object = new JsonArray(); + + for (final Command command : commands) { + object.add( + JsonParser.parseString(CommandData.fromCommand(command).toData().toString())); + } + + return object.toString(); + }); + } +} diff --git a/src/main/java/org/discordbots/api/DBLAPI.java b/src/main/java/org/discordbots/api/DBLAPI.java new file mode 100644 index 0000000..2fe5abf --- /dev/null +++ b/src/main/java/org/discordbots/api/DBLAPI.java @@ -0,0 +1,192 @@ +package org.discordbots.api; + +import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import java.io.IOException; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.discordbots.api.entity.PaginatedVotes; +import org.discordbots.api.entity.PartialVote; +import org.discordbots.api.entity.Project; +import org.discordbots.api.entity.UserSource; +import org.discordbots.api.io.DefaultResponseTransformer; +import org.discordbots.api.io.EmptyResponseTransformer; +import org.discordbots.api.io.PaginatedVotesConverter; +import org.discordbots.api.io.PostCommandsTransformer; +import org.discordbots.api.io.RawPostCommandsTransformer; +import org.discordbots.api.io.ResponseTransformer; +import org.discordbots.api.io.UnsuccessfulHttpException; + +public class DBLAPI { + private static final HttpUrl BASE_URL = + new HttpUrl.Builder() + .scheme("https") + .host("top.gg") + .addPathSegment("api") + .addPathSegment("v1") + .build(); + + private final OkHttpClient httpClient; + private final Gson gson; + + public DBLAPI(final OkHttpClient httpClient) { + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .registerTypeAdapter(PaginatedVotes.class, new PaginatedVotesConverter(this)) + .create(); + + this.httpClient = httpClient; + } + + public DBLAPI(final String token) { + this( + new OkHttpClient.Builder() + .addInterceptor( + (chain) -> + chain.proceed( + chain + .request() + .newBuilder() + .addHeader("Authorization", "Bearer " + token) + .build())) + .build()); + } + + public CompletionStage getSelf() { + final HttpUrl url = + BASE_URL.newBuilder().addPathSegment("projects").addPathSegment("@me").build(); + + return get(url, Project.class); + } + + public CompletionStage postCommands(final PostCommandsTransformer commands) { + final HttpUrl url = + BASE_URL + .newBuilder() + .addPathSegment("projects") + .addPathSegment("@me") + .addPathSegment("commands") + .build(); + + return commands + .toJsonString() + .thenCompose(jsonBody -> post(url, jsonBody, new EmptyResponseTransformer())); + } + + public CompletionStage postCommands(final JsonArray commands) { + return postCommands(new RawPostCommandsTransformer(commands)); + } + + public CompletionStage getVote(final UserSource userSource, final String id) { + final HttpUrl url = + BASE_URL + .newBuilder() + .addPathSegment("projects") + .addPathSegment("@me") + .addPathSegment("votes") + .addPathSegment(id) + .addQueryParameter("source", gson.toJson(userSource)) + .build(); + + return get(url, PartialVote.class) + .exceptionally( + error -> { + if (error instanceof UnsuccessfulHttpException + && ((UnsuccessfulHttpException) error).getResponse().code() == 404) { + return null; + } + + throw new CompletionException(error); + }); + } + + public CompletionStage getVotes(final TemporalAccessor since) { + final HttpUrl url = + BASE_URL + .newBuilder() + .addPathSegment("projects") + .addPathSegment("@me") + .addPathSegment("votes") + .addQueryParameter("startDate", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(since)) + .build(); + + return get(url, PaginatedVotes.class); + } + + public CompletionStage getVotes(final String cursor) { + final HttpUrl url = + BASE_URL + .newBuilder() + .addPathSegment("projects") + .addPathSegment("@me") + .addPathSegment("votes") + .addQueryParameter("cursor", cursor) + .build(); + + return get(url, PaginatedVotes.class); + } + + private CompletionStage get(final HttpUrl url, final Class aClass) { + return get(url, new DefaultResponseTransformer<>(aClass, gson)); + } + + private CompletionStage get( + final HttpUrl url, final ResponseTransformer responseTransformer) { + return execute(new Request.Builder().get().url(url).build(), responseTransformer); + } + + private CompletionStage post( + final HttpUrl url, final String jsonBody, final ResponseTransformer responseTransformer) { + final RequestBody body = RequestBody.create(jsonBody, MediaType.parse("application/json")); + final Request req = new Request.Builder().post(body).url(url).build(); + + return execute(req, responseTransformer); + } + + private CompletionStage execute( + final Request request, final ResponseTransformer responseTransformer) { + final Call call = httpClient.newCall(request); + final CompletableFuture future = new CompletableFuture<>(); + + call.enqueue( + new Callback() { + @Override + public void onFailure(final Call call, final IOException error) { + future.completeExceptionally(error); + } + + @Override + @SuppressWarnings("UseSpecificCatch") + public void onResponse(final Call call, final Response response) { + try { + if (response.isSuccessful()) { + future.complete(responseTransformer.transform(response)); + } else { + future.completeExceptionally(new UnsuccessfulHttpException(response)); + } + } catch (final Throwable error) { + future.completeExceptionally(error); + } finally { + response.body().close(); + } + } + }); + + return future; + } +} diff --git a/src/main/java/org/discordbots/api/DBLWidget.java b/src/main/java/org/discordbots/api/DBLWidget.java new file mode 100644 index 0000000..f724dc8 --- /dev/null +++ b/src/main/java/org/discordbots/api/DBLWidget.java @@ -0,0 +1,23 @@ +package org.discordbots.api; + +import org.discordbots.api.entity.ProjectType; + +public final class DBLWidget { + private static final String BASE_URL = "https://top.gg/api/v1/widgets"; + + public static String large(final ProjectType projectType, final String id) { + return BASE_URL + "/large/" + projectType.asWidgetPath() + "/" + id; + } + + public static String votes(final ProjectType projectType, final String id) { + return BASE_URL + "/small/votes/" + projectType.asWidgetPath() + "/" + id; + } + + public static String owner(final ProjectType projectType, final String id) { + return BASE_URL + "/small/owner/" + projectType.asWidgetPath() + "/" + id; + } + + public static String social(final ProjectType projectType, final String id) { + return BASE_URL + "/small/social/" + projectType.asWidgetPath() + "/" + id; + } +} diff --git a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java deleted file mode 100644 index 1dbd21c..0000000 --- a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java +++ /dev/null @@ -1,59 +0,0 @@ -package org.discordbots.api.client; - -import org.discordbots.api.client.entity.*; -import org.discordbots.api.client.impl.DiscordBotListAPIImpl; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletionStage; - -public interface DiscordBotListAPI { - - CompletionStage setStats(int shardId, int shardTotal, int serverCount); - CompletionStage setStats(List shardServerCounts); - CompletionStage setStats(int serverCount); - - CompletionStage getStats(String botId); - - @Deprecated - CompletionStage> getVoters(String botId); - CompletionStage hasVoted(String userId); - - CompletionStage getBots(Map search, int limit, int offset); - CompletionStage getBots(Map search, int limit, int offset, String sort); - CompletionStage getBots(Map search, int limit, int offset, String sort, List fields); - CompletionStage getBot(String botId); - - CompletionStage getUser(String userId); - - CompletionStage getVotingMultiplier(); - - class Builder { - - // Required - private String botId = null; - private String token = null; - - public Builder token(String token) { - this.token = token; - return this; - } - - public Builder botId(String botId) { - this.botId = botId; - return this; - } - - public DiscordBotListAPI build() { - if(token == null) - throw new IllegalArgumentException("The provided token cannot be null!"); - - if(botId == null) - throw new IllegalArgumentException("The provided bot ID cannot be null!"); - - return new DiscordBotListAPIImpl(token, botId); - } - - } - -} diff --git a/src/main/java/org/discordbots/api/client/entity/Bot.java b/src/main/java/org/discordbots/api/client/entity/Bot.java deleted file mode 100644 index 0576dd9..0000000 --- a/src/main/java/org/discordbots/api/client/entity/Bot.java +++ /dev/null @@ -1,144 +0,0 @@ -package org.discordbots.api.client.entity; - -import com.google.gson.annotations.SerializedName; - -import java.time.OffsetDateTime; -import java.util.List; - -public class Bot { - - private String id; - @SerializedName("clientid") - private String clientId; - private String username; - private String discriminator; - - private String avatar; - @SerializedName("defAvatar") - private String defaultAvatar; - - private String prefix; - private String invite; - private String website; - private String vanity; - private String support; - private List tags; - - @SerializedName("longdesc") - private String longDescription; - @SerializedName("shortdesc") - private String shortDescription; - @SerializedName("betadesc") - private String betaDescription; - - @SerializedName("certifiedBot") - private boolean certified; - - @SerializedName("date") // rename so that the naming actually makes sense - private OffsetDateTime approvalTime; - - @SerializedName("server_count") - private long serverCount; - - private List guilds; - private List shards; - private int monthlyPoints; - private int points; - - private boolean legacy; - - - - public String getId() { - return id; - } - - public String getClientId() { - return clientId; - } - - public String getUsername() { - return username; - } - - public String getDiscriminator() { - return discriminator; - } - - public String getAvatar() { - return avatar; - } - - public String getDefaultAvatar() { - return defaultAvatar; - } - - public String getPrefix() { - return prefix; - } - - public String getInvite() { - return invite; - } - - public String getWebsite() { - return website; - } - - public String getVanity() { - return vanity; - } - - public String getSupport() { - return support; - } - - public List getTags() { - return tags; - } - - public String getLongDescription() { - return longDescription; - } - - public String getShortDescription() { - return shortDescription; - } - - public String getBetaDescription() { - return betaDescription; - } - - public boolean isCertified() { - return certified; - } - - public OffsetDateTime getApprovalTime() { - return approvalTime; - } - - public long getServerCount() { - return serverCount; - } - - public List getGuilds() { - return guilds; - } - - public List getShards() { - return shards; - } - - public int getMonthlyPoints() { - return monthlyPoints; - } - - public int getPoints() { - return points; - } - - public boolean isLegacy() { - return legacy; - } - -} diff --git a/src/main/java/org/discordbots/api/client/entity/BotResult.java b/src/main/java/org/discordbots/api/client/entity/BotResult.java deleted file mode 100644 index 7581b60..0000000 --- a/src/main/java/org/discordbots/api/client/entity/BotResult.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.discordbots.api.client.entity; - -// This class is needed because of the way Java generics work. I can't reference Result as a class -// so this is just a simple workaround for that -public class BotResult extends Result {} diff --git a/src/main/java/org/discordbots/api/client/entity/BotStats.java b/src/main/java/org/discordbots/api/client/entity/BotStats.java deleted file mode 100644 index 209edfd..0000000 --- a/src/main/java/org/discordbots/api/client/entity/BotStats.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.discordbots.api.client.entity; - -import com.google.gson.annotations.SerializedName; - -import java.util.Collections; -import java.util.List; - -public class BotStats { - - @SerializedName("server_count") - private int serverCount; - private List shards; - - public int getServerCount() { return serverCount; } - public List getShards() { return Collections.unmodifiableList(shards); } -} diff --git a/src/main/java/org/discordbots/api/client/entity/Result.java b/src/main/java/org/discordbots/api/client/entity/Result.java deleted file mode 100644 index 1dd0130..0000000 --- a/src/main/java/org/discordbots/api/client/entity/Result.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.discordbots.api.client.entity; - -import java.util.List; - -public class Result { - - private List results; - private int limit, offset, count, total; - - - - public List getResults() { - return results; - } - - public int getLimit() { - return limit; - } - - public int getOffset() { - return offset; - } - - public int getCount() { - return count; - } - - public int getTotal() { - return total; - } - -} diff --git a/src/main/java/org/discordbots/api/client/entity/SimpleUser.java b/src/main/java/org/discordbots/api/client/entity/SimpleUser.java deleted file mode 100644 index 562f0ed..0000000 --- a/src/main/java/org/discordbots/api/client/entity/SimpleUser.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.discordbots.api.client.entity; - -public class SimpleUser { - - private String id; - private String username; - private String discriminator; - - private String avatar; - - - - public String getId() { - return id; - } - - public String getUsername() { - return username; - } - - public String getDiscriminator() { - return discriminator; - } - - public String getAvatar() { - return avatar; - } - -} diff --git a/src/main/java/org/discordbots/api/client/entity/Social.java b/src/main/java/org/discordbots/api/client/entity/Social.java deleted file mode 100644 index d7a64e0..0000000 --- a/src/main/java/org/discordbots/api/client/entity/Social.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.discordbots.api.client.entity; - -public class Social { - - String youtube, reddit, twitter, instagram, github; - -} diff --git a/src/main/java/org/discordbots/api/client/entity/User.java b/src/main/java/org/discordbots/api/client/entity/User.java deleted file mode 100644 index bfed556..0000000 --- a/src/main/java/org/discordbots/api/client/entity/User.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.discordbots.api.client.entity; - -import com.google.gson.annotations.SerializedName; - -public class User extends SimpleUser { - - @SerializedName("defAvatar") - private String defaultAvatar; - - private boolean admin, mod, webMod; - private boolean artist, certifiedDev, supporter; - - private Social social; - - - - public String getDefaultAvatar() { - return defaultAvatar; - } - - public boolean isAdmin() { - return admin; - } - - public boolean isMod() { - return mod; - } - - public boolean isWebMod() { - return webMod; - } - - public boolean isArtist() { - return artist; - } - - public boolean isCertifiedDev() { - return certifiedDev; - } - - public boolean isSupporter() { - return supporter; - } - - public Social getSocial() { - return social; - } - -} diff --git a/src/main/java/org/discordbots/api/client/entity/Vote.java b/src/main/java/org/discordbots/api/client/entity/Vote.java deleted file mode 100644 index 047f6de..0000000 --- a/src/main/java/org/discordbots/api/client/entity/Vote.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.discordbots.api.client.entity; - -import com.google.gson.annotations.SerializedName; - -public class Vote { - - @SerializedName("bot") - private String botId; - @SerializedName("user") - private String userId; - - private String type; - - private String query; - - @SerializedName("isWeekend") - private boolean weekend; - - - - public String getBotId() { - return botId; - } - - public String getUserId() { - return userId; - } - - public String getType() { - return type; - } - - public String getQuery() { - return query; - } - - public boolean isWeekend() { - return weekend; - } - -} diff --git a/src/main/java/org/discordbots/api/client/entity/VotingMultiplier.java b/src/main/java/org/discordbots/api/client/entity/VotingMultiplier.java deleted file mode 100644 index 6f927fe..0000000 --- a/src/main/java/org/discordbots/api/client/entity/VotingMultiplier.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.discordbots.api.client.entity; - -import com.google.gson.annotations.SerializedName; - -public class VotingMultiplier { - - @SerializedName("is_weekend") - private boolean weekend; - - - - public boolean isWeekend() { - return weekend; - } - -} diff --git a/src/main/java/org/discordbots/api/client/impl/DiscordBotListAPIImpl.java b/src/main/java/org/discordbots/api/client/impl/DiscordBotListAPIImpl.java deleted file mode 100644 index 546df9a..0000000 --- a/src/main/java/org/discordbots/api/client/impl/DiscordBotListAPIImpl.java +++ /dev/null @@ -1,264 +0,0 @@ -package org.discordbots.api.client.impl; - -import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import okhttp3.*; -import org.discordbots.api.client.DiscordBotListAPI; -import org.discordbots.api.client.entity.*; -import org.discordbots.api.client.io.DefaultResponseTransformer; -import org.discordbots.api.client.io.ResponseTransformer; -import org.discordbots.api.client.io.UnsuccessfulHttpException; -import org.json.JSONObject; - -import java.io.IOException; -import java.time.OffsetDateTime; -import java.util.Arrays; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionStage; -import java.util.stream.Collectors; - -public class DiscordBotListAPIImpl implements DiscordBotListAPI { - - private static final HttpUrl baseUrl = new HttpUrl.Builder() - .scheme("https") - .host("top.gg") - .addPathSegment("api") - .build(); - - private final OkHttpClient httpClient; - private final Gson gson; - - private final String token, botId; - - public DiscordBotListAPIImpl(String token, String botId) { - this.token = token; - this.botId = botId; - - this.gson = new GsonBuilder() - .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) - .create(); - - this.httpClient = new OkHttpClient.Builder() - .addInterceptor((chain) -> { - Request req = chain.request().newBuilder() - .addHeader("Authorization", this.token) - .build(); - return chain.proceed(req); - }) - .build(); - } - - public CompletionStage setStats(int shardId, int shardTotal, int serverCount) { - JSONObject json = new JSONObject() - .put("shard_id", shardId) - .put("shard_count", shardTotal) - .put("server_count", serverCount); - - return setStats(json); - } - - public CompletionStage setStats(List shardServerCounts) { - JSONObject json = new JSONObject() - .put("shards", shardServerCounts); - - return setStats(json); - } - - public CompletionStage setStats(int serverCount) { - JSONObject json = new JSONObject() - .put("server_count", serverCount); - - return setStats(json); - } - - private CompletionStage setStats(JSONObject jsonBody) { - HttpUrl url = baseUrl.newBuilder() - .addPathSegment("bots") - .addPathSegment(botId) - .addPathSegment("stats") - .build(); - - return post(url, jsonBody, Void.class); - } - - public CompletionStage getStats(String botId) { - HttpUrl url = baseUrl.newBuilder() - .addPathSegment("bots") - .addPathSegment(botId) - .addPathSegment("stats") - .build(); - - return get(url, BotStats.class); - } - - public CompletionStage> getVoters(String botId) { - HttpUrl url = baseUrl.newBuilder() - .addPathSegment("bots") - .addPathSegment(botId) - .addPathSegment("votes") - .build(); - - return get(url, resp -> { - // This is kinda awkward but this is done so that it can return it was a list instead of - // an array - ResponseTransformer arrayTransformer = new DefaultResponseTransformer<>(SimpleUser[].class, gson); - return Arrays.asList(arrayTransformer.transform(resp)); - }); - } - - public CompletionStage getBot(String botId) { - HttpUrl url = baseUrl.newBuilder() - .addPathSegment("bots") - .addPathSegment(botId) - .build(); - - return get(url, Bot.class); - } - - public CompletionStage getBots(Map search, int limit, int offset) { - return getBots(search, limit, offset, null); - } - - public CompletionStage getBots(Map search, int limit, int offset, String sort) { - return getBots(search, limit, offset, sort, null); - } - - public CompletionStage getBots(Map search, int limit, int offset, String sort, List fields) { - // DBL search uses this format: field1: value1 field2: value2 - String searchString = search.entrySet().stream() - .map(entry -> entry.getKey() + ": " + entry.getValue()) - .collect(Collectors.joining(" ")); - - HttpUrl.Builder urlBuilder = baseUrl.newBuilder() - .addPathSegment("bots") - .addQueryParameter("search", searchString) - .addQueryParameter("limit", String.valueOf(limit)) - .addQueryParameter("offset", String.valueOf(offset)); - - if(sort != null) { - urlBuilder.addQueryParameter("sort", sort); - } - - if(fields != null) { - String fieldsString = fields.stream() - .collect(Collectors.joining(" ")); - - urlBuilder.addQueryParameter("fields", fieldsString); - } - - return get(urlBuilder.build(), BotResult.class); - } - - public CompletionStage getUser(String userId) { - HttpUrl url = baseUrl.newBuilder() - .addPathSegment("users") - .addPathSegment(userId) - .build(); - - return get(url, User.class); - } - - public CompletionStage hasVoted(String userId) { - HttpUrl url = baseUrl.newBuilder() - .addPathSegment("bots") - .addPathSegment(botId) - .addPathSegment("check") - .addQueryParameter("userId", userId) - .build(); - - return get(url, (resp) -> { - JSONObject json = new JSONObject(resp.body().string()); - return json.getInt("voted") == 1; - }); - } - - public CompletionStage getVotingMultiplier() { - HttpUrl url = baseUrl.newBuilder() - .addPathSegment("weekend") - .build(); - - return get(url, VotingMultiplier.class); - } - - private CompletionStage get(HttpUrl url, Class aClass) { - return get(url, new DefaultResponseTransformer<>(aClass, gson)); - } - - private CompletionStage get(HttpUrl url, ResponseTransformer responseTransformer) { - Request req = new Request.Builder() - .get() - .url(url) - .build(); - - return execute(req, responseTransformer); - } - - // The class provided in this is kinda unneeded because the only thing ever given to it - // is Void, but I wanted to make it expandable (maybe some post methods will return objects - // in the future) - private CompletionStage post(HttpUrl url, JSONObject jsonBody, Class aClass) { - return post(url, jsonBody, new DefaultResponseTransformer<>(aClass, gson)); - } - - private CompletionStage post(HttpUrl url, JSONObject jsonBody, ResponseTransformer responseTransformer) { - MediaType mediaType = MediaType.parse("application/json"); - RequestBody body = RequestBody.create(mediaType, jsonBody.toString()); - - Request req = new Request.Builder() - .post(body) - .url(url) - .build(); - - return execute(req, responseTransformer); - } - - private CompletionStage execute(Request request, ResponseTransformer responseTransformer) { - Call call = httpClient.newCall(request); - - final CompletableFuture future = new CompletableFuture<>(); - - call.enqueue(new Callback() { - @Override - public void onFailure(Call call, IOException e) { - future.completeExceptionally(e); - } - - @Override - public void onResponse(Call call, Response response) { - try { - - if (response.isSuccessful()) { - E transformed = responseTransformer.transform(response); - future.complete(transformed); - } else { - String message = response.message(); - - // DBL sends error messages as part of the body and leaves the - // actual message blank so this will just pull that instead because - // it's 1000x more useful than the actual message - if (message == null || message.isEmpty()) { - try { - JSONObject body = new JSONObject(response.body().string()); - message = body.getString("error"); - } catch (Exception ignored) {} - } - - Exception e = new UnsuccessfulHttpException(response.code(), message); - future.completeExceptionally(e); - } - - } catch (Exception e) { - future.completeExceptionally(e); - } finally { - response.body().close(); - } - } - }); - - return future; - } - -} diff --git a/src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java b/src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java deleted file mode 100644 index 8639e10..0000000 --- a/src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.discordbots.api.client.io; - -import com.google.gson.Gson; -import okhttp3.Response; - -import java.io.IOException; - -public class DefaultResponseTransformer implements ResponseTransformer { - - private final Class aClass; - private final Gson gson; - - public DefaultResponseTransformer(Class aClass, Gson gson) { - this.aClass = aClass; - this.gson = gson; - } - - @Override - public E transform(Response response) throws IOException { - String body = response.body().string(); - return gson.fromJson(body, aClass); - } - -} diff --git a/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java b/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java deleted file mode 100644 index 730b783..0000000 --- a/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.discordbots.api.client.io; - -import okhttp3.Response; - -public interface ResponseTransformer { - - E transform(Response response) throws Exception; - -} diff --git a/src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java b/src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java deleted file mode 100644 index ae8a294..0000000 --- a/src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.discordbots.api.client.io; - -public class UnsuccessfulHttpException extends Exception { - - public UnsuccessfulHttpException(int code, String message) { - super("The server responded with code: " + code + ", message: " + message); - } - -} diff --git a/src/main/java/org/discordbots/api/entity/PaginatedVotes.java b/src/main/java/org/discordbots/api/entity/PaginatedVotes.java new file mode 100644 index 0000000..8344493 --- /dev/null +++ b/src/main/java/org/discordbots/api/entity/PaginatedVotes.java @@ -0,0 +1,25 @@ +package org.discordbots.api.entity; + +import java.util.List; +import java.util.concurrent.CompletionStage; +import org.discordbots.api.DBLAPI; + +public class PaginatedVotes { + private final List votes; + private final String cursor; + private final DBLAPI client; + + public PaginatedVotes(final List votes, final String cursor, final DBLAPI client) { + this.votes = votes; + this.cursor = cursor; + this.client = client; + } + + public List getVotes() { + return votes; + } + + public CompletionStage next() { + return client.getVotes(cursor); + } +} diff --git a/src/main/java/org/discordbots/api/entity/PartialVote.java b/src/main/java/org/discordbots/api/entity/PartialVote.java new file mode 100644 index 0000000..f27c90b --- /dev/null +++ b/src/main/java/org/discordbots/api/entity/PartialVote.java @@ -0,0 +1,26 @@ +package org.discordbots.api.entity; + +import com.google.gson.annotations.SerializedName; +import java.time.OffsetDateTime; + +public class PartialVote { + @SerializedName("created_at") + private OffsetDateTime votedAt; + + @SerializedName("expires_at") + private OffsetDateTime expiresAt; + + private int weight; + + public OffsetDateTime getVotedAt() { + return votedAt; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public int getWeight() { + return weight; + } +} diff --git a/src/main/java/org/discordbots/api/entity/Platform.java b/src/main/java/org/discordbots/api/entity/Platform.java new file mode 100644 index 0000000..cd01779 --- /dev/null +++ b/src/main/java/org/discordbots/api/entity/Platform.java @@ -0,0 +1,8 @@ +package org.discordbots.api.entity; + +import com.google.gson.annotations.SerializedName; + +public enum Platform { + @SerializedName("discord") + DISCORD +} diff --git a/src/main/java/org/discordbots/api/entity/Project.java b/src/main/java/org/discordbots/api/entity/Project.java new file mode 100644 index 0000000..4256280 --- /dev/null +++ b/src/main/java/org/discordbots/api/entity/Project.java @@ -0,0 +1,65 @@ +package org.discordbots.api.entity; + +import com.google.gson.annotations.SerializedName; +import java.util.List; + +public class Project { + private String id; + private String name; + private Platform platform; + private ProjectType type; + private String headline; + private List tags; + + @SerializedName("votes") + private long currentVotes; + + @SerializedName("votes_total") + private long totalVotes; + + @SerializedName("review_score") + private float reviewScore; + + @SerializedName("review_count") + private long reviewCount; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public Platform getPlatform() { + return platform; + } + + public ProjectType getType() { + return type; + } + + public String getHeadline() { + return headline; + } + + public List getTags() { + return tags; + } + + public long getCurrentVotes() { + return currentVotes; + } + + public long getTotalVotes() { + return totalVotes; + } + + public float getReviewScore() { + return reviewScore; + } + + public long getReviewCount() { + return reviewCount; + } +} diff --git a/src/main/java/org/discordbots/api/entity/ProjectType.java b/src/main/java/org/discordbots/api/entity/ProjectType.java new file mode 100644 index 0000000..367f34b --- /dev/null +++ b/src/main/java/org/discordbots/api/entity/ProjectType.java @@ -0,0 +1,18 @@ +package org.discordbots.api.entity; + +import com.google.gson.annotations.SerializedName; + +public enum ProjectType { + @SerializedName("bot") + DISCORD_BOT, + + @SerializedName("server") + DISCORD_SERVER; + + public String asWidgetPath() { + return switch (this) { + case ProjectType.DISCORD_BOT -> "discord/bot"; + case ProjectType.DISCORD_SERVER -> "discord/server"; + }; + } +} diff --git a/src/main/java/org/discordbots/api/entity/UserSource.java b/src/main/java/org/discordbots/api/entity/UserSource.java new file mode 100644 index 0000000..effca21 --- /dev/null +++ b/src/main/java/org/discordbots/api/entity/UserSource.java @@ -0,0 +1,11 @@ +package org.discordbots.api.entity; + +import com.google.gson.annotations.SerializedName; + +public enum UserSource { + @SerializedName("discord") + DISCORD, + + @SerializedName("topgg") + TOPGG +} diff --git a/src/main/java/org/discordbots/api/entity/Vote.java b/src/main/java/org/discordbots/api/entity/Vote.java new file mode 100644 index 0000000..a8d79ea --- /dev/null +++ b/src/main/java/org/discordbots/api/entity/Vote.java @@ -0,0 +1,40 @@ +package org.discordbots.api.entity; + +import com.google.gson.annotations.SerializedName; +import java.time.OffsetDateTime; + +public class Vote { + @SerializedName("user_id") + private String userId; + + @SerializedName("platform_id") + private String platformId; + + @SerializedName("created_at") + private OffsetDateTime votedAt; + + @SerializedName("expires_at") + private OffsetDateTime expiresAt; + + private int weight; + + public String getUserId() { + return userId; + } + + public String getPlatformId() { + return platformId; + } + + public OffsetDateTime getVotedAt() { + return votedAt; + } + + public OffsetDateTime getExpiresAt() { + return expiresAt; + } + + public int getWeight() { + return weight; + } +} diff --git a/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java b/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java new file mode 100644 index 0000000..a7e4cba --- /dev/null +++ b/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java @@ -0,0 +1,20 @@ +package org.discordbots.api.io; + +import com.google.gson.Gson; +import java.io.IOException; +import okhttp3.Response; + +public class DefaultResponseTransformer implements ResponseTransformer { + private final Class aClass; + private final Gson gson; + + public DefaultResponseTransformer(final Class aClass, final Gson gson) { + this.aClass = aClass; + this.gson = gson; + } + + @Override + public E transform(final Response response) throws IOException { + return gson.fromJson(response.body().string(), aClass); + } +} diff --git a/src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java b/src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java new file mode 100644 index 0000000..61f989c --- /dev/null +++ b/src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java @@ -0,0 +1,10 @@ +package org.discordbots.api.io; + +import okhttp3.Response; + +public class EmptyResponseTransformer implements ResponseTransformer { + @Override + public Void transform(final Response response) { + return null; + } +} diff --git a/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java b/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java new file mode 100644 index 0000000..a3216ff --- /dev/null +++ b/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java @@ -0,0 +1,35 @@ +package org.discordbots.api.io; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.lang.reflect.Type; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; +import org.discordbots.api.DBLAPI; +import org.discordbots.api.entity.PaginatedVotes; +import org.discordbots.api.entity.Vote; + +public class PaginatedVotesConverter implements JsonDeserializer { + private final DBLAPI client; + + public PaginatedVotesConverter(final DBLAPI client) { + this.client = client; + } + + @Override + public PaginatedVotes deserialize( + JsonElement json, Type typeOfT, JsonDeserializationContext context) { + final JsonObject object = json.getAsJsonObject(); + + final List votes = + StreamSupport.stream(object.getAsJsonArray("data").spliterator(), false) + .map(vote -> (Vote) context.deserialize(vote, Vote.class)) + .collect(Collectors.toList()); + final String cursor = object.get("cursor").getAsString(); + + return new PaginatedVotes(votes, cursor, client); + } +} diff --git a/src/main/java/org/discordbots/api/io/PostCommandsTransformer.java b/src/main/java/org/discordbots/api/io/PostCommandsTransformer.java new file mode 100644 index 0000000..c1846e8 --- /dev/null +++ b/src/main/java/org/discordbots/api/io/PostCommandsTransformer.java @@ -0,0 +1,7 @@ +package org.discordbots.api.io; + +import java.util.concurrent.CompletionStage; + +public interface PostCommandsTransformer { + CompletionStage toJsonString(); +} diff --git a/src/main/java/org/discordbots/api/io/RawPostCommandsTransformer.java b/src/main/java/org/discordbots/api/io/RawPostCommandsTransformer.java new file mode 100644 index 0000000..06ae3fe --- /dev/null +++ b/src/main/java/org/discordbots/api/io/RawPostCommandsTransformer.java @@ -0,0 +1,18 @@ +package org.discordbots.api.io; + +import com.google.gson.JsonArray; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +public class RawPostCommandsTransformer implements PostCommandsTransformer { + private final JsonArray object; + + public RawPostCommandsTransformer(final JsonArray object) { + this.object = object; + } + + @Override + public CompletionStage toJsonString() { + return CompletableFuture.completedFuture(object.toString()); + } +} diff --git a/src/main/java/org/discordbots/api/io/ResponseTransformer.java b/src/main/java/org/discordbots/api/io/ResponseTransformer.java new file mode 100644 index 0000000..6eb55f1 --- /dev/null +++ b/src/main/java/org/discordbots/api/io/ResponseTransformer.java @@ -0,0 +1,7 @@ +package org.discordbots.api.io; + +import okhttp3.Response; + +public interface ResponseTransformer { + E transform(final Response response) throws Exception; +} diff --git a/src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java b/src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java new file mode 100644 index 0000000..1665aa1 --- /dev/null +++ b/src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java @@ -0,0 +1,18 @@ +package org.discordbots.api.io; + +import okhttp3.Response; + +public class UnsuccessfulHttpException extends Exception { + private final Response response; + + public UnsuccessfulHttpException(final Response response) { + super( + "The server responded with code: " + response.code() + ", message: " + response.message()); + + this.response = response; + } + + public Response getResponse() { + return response; + } +} diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java new file mode 100644 index 0000000..e6ebee0 --- /dev/null +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java @@ -0,0 +1,105 @@ +package org.discordbots.webhooks.springboot; + +import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.stream.Collectors; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.Payload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public class DBLWebhooks implements DBLWebhooksListener { + private byte[] secret; + private final Gson gson; + + public DBLWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); + } + + public String getSecret() { + return new String(secret, StandardCharsets.UTF_8); + } + + public void setSecret(final String newSecret) { + secret = newSecret.getBytes(StandardCharsets.UTF_8); + } + + @SuppressWarnings("UseSpecificCatch") + protected ResponseEntity dispatch( + final String body, final String signatureHeader, final String trace) { + try { + final HashMap parsedSignature = + Arrays.stream(signatureHeader.split(",")) + .map(part -> part.split("=", 2)) + .collect( + Collectors.toMap( + part -> part[0].trim(), + part -> part[1].trim(), + (existing, replacement) -> replacement, + HashMap::new)); + + final String signature = parsedSignature.get("v1"); + final String timestamp = parsedSignature.get("t"); + + assert signature != null && timestamp != null; + + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); + final Mac hmac = Mac.getInstance("HmacSHA256"); + + hmac.init(key); + + final byte[] digest = + hmac.doFinal(String.format("%s.%s", timestamp, body).getBytes(StandardCharsets.UTF_8)); + + if (!signature.equals(HexFormat.of().formatHex(digest))) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + final Payload payload = gson.fromJson(body, Payload.class); + + try { + return switch (payload.getType()) { + case "integration.create" -> + onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> onTest(payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + default -> ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + }; + } catch (final Throwable ignored) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } catch (final NoSuchAlgorithmException + | InvalidKeyException + | ArrayIndexOutOfBoundsException + | AssertionError + | JsonSyntaxException + | JsonIOException error) { + return ResponseEntity.status( + (error instanceof NoSuchAlgorithmException || error instanceof InvalidKeyException) + ? HttpStatus.INTERNAL_SERVER_ERROR + : HttpStatus.BAD_REQUEST) + .build(); + } + } +} diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java new file mode 100644 index 0000000..c1f2df6 --- /dev/null +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java @@ -0,0 +1,28 @@ +package org.discordbots.webhooks.springboot; + +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public interface DBLWebhooksListener { + default ResponseEntity onIntegrationCreate( + final IntegrationCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + default ResponseEntity onIntegrationDelete( + final IntegrationDeletePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + default ResponseEntity onTest(final TestPayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + default ResponseEntity onVoteCreate(final VoteCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} diff --git a/src/test/java/org/discordbots/api/DBLAPITest.java b/src/test/java/org/discordbots/api/DBLAPITest.java new file mode 100644 index 0000000..99fb651 --- /dev/null +++ b/src/test/java/org/discordbots/api/DBLAPITest.java @@ -0,0 +1,69 @@ +package org.discordbots.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonParser; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import okhttp3.OkHttpClient; +import org.discordbots.api.entity.PaginatedVotes; +import org.discordbots.api.entity.UserSource; +import org.discordbots.api.interceptors.GetSelfInterceptor; +import org.discordbots.api.interceptors.GetVoteInterceptor; +import org.discordbots.api.interceptors.GetVotesInterceptor; +import org.discordbots.api.interceptors.PostCommandsInterceptor; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class DBLAPITest { + private static DBLAPI CLIENT; + + @BeforeAll + public static void setup() { + CLIENT = + new DBLAPI( + new OkHttpClient.Builder() + .addInterceptor(new GetSelfInterceptor()) + .addInterceptor(new GetVoteInterceptor()) + .addInterceptor(new GetVotesInterceptor()) + .addInterceptor(new PostCommandsInterceptor()) + .build()); + } + + @Test + public void getSelf() { + CLIENT.getSelf().toCompletableFuture().join(); + } + + @Test + public void postCommands() { + final JsonArray commands = + JsonParser.parseReader( + new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("PostCommands.json"), + StandardCharsets.UTF_8)) + .getAsJsonArray(); + + CLIENT.postCommands(commands).toCompletableFuture().join(); + } + + @ParameterizedTest + @EnumSource(UserSource.class) + public void getVote(final UserSource userSource) { + CLIENT.getVote(userSource, "123456").toCompletableFuture().join(); + } + + @Test + @SuppressWarnings("unused") + public void getVotes() { + final PaginatedVotes firstPage = + CLIENT + .getVotes(OffsetDateTime.of(2026, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)) + .toCompletableFuture() + .join(); + final PaginatedVotes secondPage = firstPage.next().toCompletableFuture().join(); + } +} diff --git a/src/test/java/org/discordbots/api/DBLWidgetTest.java b/src/test/java/org/discordbots/api/DBLWidgetTest.java new file mode 100644 index 0000000..1d8780f --- /dev/null +++ b/src/test/java/org/discordbots/api/DBLWidgetTest.java @@ -0,0 +1,31 @@ +package org.discordbots.api; + +import org.discordbots.api.entity.ProjectType; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class DBLWidgetTest { + @ParameterizedTest + @EnumSource(ProjectType.class) + public void large(final ProjectType projectType) { + DBLWidget.large(projectType, "123456"); + } + + @ParameterizedTest + @EnumSource(ProjectType.class) + public void votes(final ProjectType projectType) { + DBLWidget.votes(projectType, "123456"); + } + + @ParameterizedTest + @EnumSource(ProjectType.class) + public void owner(final ProjectType projectType) { + DBLWidget.owner(projectType, "123456"); + } + + @ParameterizedTest + @EnumSource(ProjectType.class) + public void social(final ProjectType projectType) { + DBLWidget.social(projectType, "123456"); + } +} diff --git a/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java new file mode 100644 index 0000000..63147a6 --- /dev/null +++ b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java @@ -0,0 +1,60 @@ +package org.discordbots.api.interceptors; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import okhttp3.HttpUrl; +import okhttp3.Interceptor; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public abstract class BaseInterceptor implements Interceptor { + @SuppressWarnings("FieldMayBeFinal") + private String response; + + public BaseInterceptor() { + try { + final String className = getClass().getSimpleName(); + + final InputStream inputStream = + BaseInterceptor.class.getResourceAsStream( + "/" + className.substring(0, className.length() - 11) + "Response.json"); + + response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (final IOException | NullPointerException ignored) { + response = ""; + } + } + + protected abstract boolean isCorrect(final String method, final String path, final HttpUrl url); + + protected abstract int getStatusCode(); + + protected abstract String getMessage(); + + @Override + public Response intercept(final Chain chain) throws IOException { + final Request request = chain.request(); + + final HttpUrl url = request.url(); + final String path = String.join("/", url.pathSegments()); + + if (url.host().equals("top.gg") + && path.startsWith("api/v1") + && isCorrect(request.method(), path, url)) { + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(getStatusCode()) + .message(getMessage()) + .body(ResponseBody.create(response, MediaType.get("application/json"))) + .addHeader("content-type", "application/json") + .build(); + } + + return chain.proceed(request); + } +} diff --git a/src/test/java/org/discordbots/api/interceptors/GetSelfInterceptor.java b/src/test/java/org/discordbots/api/interceptors/GetSelfInterceptor.java new file mode 100644 index 0000000..33cc308 --- /dev/null +++ b/src/test/java/org/discordbots/api/interceptors/GetSelfInterceptor.java @@ -0,0 +1,20 @@ +package org.discordbots.api.interceptors; + +import okhttp3.HttpUrl; + +public class GetSelfInterceptor extends BaseInterceptor { + @Override + protected boolean isCorrect(final String method, final String path, final HttpUrl url) { + return method.equals("GET") && path.endsWith("/projects/@me"); + } + + @Override + protected int getStatusCode() { + return 200; + } + + @Override + protected String getMessage() { + return "OK"; + } +} diff --git a/src/test/java/org/discordbots/api/interceptors/GetVoteInterceptor.java b/src/test/java/org/discordbots/api/interceptors/GetVoteInterceptor.java new file mode 100644 index 0000000..dd99906 --- /dev/null +++ b/src/test/java/org/discordbots/api/interceptors/GetVoteInterceptor.java @@ -0,0 +1,22 @@ +package org.discordbots.api.interceptors; + +import okhttp3.HttpUrl; + +public class GetVoteInterceptor extends BaseInterceptor { + @Override + protected boolean isCorrect(final String method, final String path, final HttpUrl url) { + return method.equals("GET") + && path.contains("/projects/@me/votes/") + && url.queryParameter("source") != null; + } + + @Override + protected int getStatusCode() { + return 200; + } + + @Override + protected String getMessage() { + return "OK"; + } +} diff --git a/src/test/java/org/discordbots/api/interceptors/GetVotesInterceptor.java b/src/test/java/org/discordbots/api/interceptors/GetVotesInterceptor.java new file mode 100644 index 0000000..829329b --- /dev/null +++ b/src/test/java/org/discordbots/api/interceptors/GetVotesInterceptor.java @@ -0,0 +1,22 @@ +package org.discordbots.api.interceptors; + +import okhttp3.HttpUrl; + +public class GetVotesInterceptor extends BaseInterceptor { + @Override + protected boolean isCorrect(final String method, final String path, final HttpUrl url) { + return method.equals("GET") + && path.endsWith("/projects/@me/votes") + && (url.queryParameter("startDate") != null || url.queryParameter("cursor") != null); + } + + @Override + protected int getStatusCode() { + return 200; + } + + @Override + protected String getMessage() { + return "OK"; + } +} diff --git a/src/test/java/org/discordbots/api/interceptors/PostCommandsInterceptor.java b/src/test/java/org/discordbots/api/interceptors/PostCommandsInterceptor.java new file mode 100644 index 0000000..9ff895e --- /dev/null +++ b/src/test/java/org/discordbots/api/interceptors/PostCommandsInterceptor.java @@ -0,0 +1,20 @@ +package org.discordbots.api.interceptors; + +import okhttp3.HttpUrl; + +public class PostCommandsInterceptor extends BaseInterceptor { + @Override + protected boolean isCorrect(final String method, final String path, final HttpUrl url) { + return method.equals("POST") && path.endsWith("/projects/@me/commands"); + } + + @Override + protected int getStatusCode() { + return 204; + } + + @Override + protected String getMessage() { + return "No Content"; + } +} diff --git a/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java b/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java new file mode 100644 index 0000000..66f58ae --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java @@ -0,0 +1,15 @@ +package org.discordbots.webhooks; + +import org.discordbots.webhooks.dropwizard.DBLDropwizardWebhooksTest; +import org.discordbots.webhooks.eclipsejetty.DBLEclipseJettyWebhooksTest; +import org.discordbots.webhooks.springboot.DBLSpringBootWebhooksTest; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + DBLDropwizardWebhooksTest.class, + DBLEclipseJettyWebhooksTest.class, + DBLSpringBootWebhooksTest.class +}) +public class DBLWebhooksTestSuite {} diff --git a/src/test/java/org/discordbots/webhooks/Mocks.java b/src/test/java/org/discordbots/webhooks/Mocks.java new file mode 100644 index 0000000..c471d13 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/Mocks.java @@ -0,0 +1,47 @@ +package org.discordbots.webhooks; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public class Mocks { + public final String integrationCreatePayload; + public final String integrationDeletePayload; + public final String testPayload; + public final String voteCreatePayload; + + public Mocks() throws IOException, NullPointerException { + integrationCreatePayload = read("IntegrationCreate"); + integrationDeletePayload = read("IntegrationDelete"); + testPayload = read("Test"); + voteCreatePayload = read("VoteCreate"); + } + + private static String read(final String name) throws IOException, NullPointerException { + final InputStream inputStream = Mocks.class.getResourceAsStream("/" + name + "Payload.json"); + + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + + public static String signature(final String secret, final String body) + throws NoSuchAlgorithmException, InvalidKeyException { + final long timestamp = Instant.now().getEpochSecond(); + + final SecretKeySpec key = + new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + final Mac hmac = Mac.getInstance("HmacSHA256"); + + hmac.init(key); + + final byte[] digest = + hmac.doFinal(String.format("%s.%s", timestamp, body).getBytes(StandardCharsets.UTF_8)); + + return "t=" + Long.toString(timestamp) + ",v1=" + HexFormat.of().formatHex(digest); + } +} diff --git a/src/test/java/org/discordbots/webhooks/dropwizard/CustomServer.java b/src/test/java/org/discordbots/webhooks/dropwizard/CustomServer.java new file mode 100644 index 0000000..0885b6c --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/dropwizard/CustomServer.java @@ -0,0 +1,16 @@ +package org.discordbots.webhooks.dropwizard; + +import io.dropwizard.core.Application; +import io.dropwizard.core.Configuration; +import io.dropwizard.core.setup.Environment; + +public class CustomServer extends Application { + public static void main(final String[] args) throws Exception { + new CustomServer().run(args); + } + + @Override + public void run(final Configuration config, final Environment env) { + env.jersey().register(new CustomWebhooks()); + } +} diff --git a/src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java b/src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java new file mode 100644 index 0000000..246abd8 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java @@ -0,0 +1,35 @@ +package org.discordbots.webhooks.dropwizard; + +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +@Path("/webhook") +public class CustomWebhooks extends DBLWebhooks { + public CustomWebhooks() { + super(System.getenv("TOPGG_WEBHOOK_SECRET")); + } + + @Override + public Response onIntegrationCreate(final IntegrationCreatePayload payload, final String trace) { + return Response.status(Response.Status.OK).entity("dw:integrationCreate," + trace).build(); + } + + @Override + public Response onIntegrationDelete(final IntegrationDeletePayload payload, final String trace) { + return Response.status(Response.Status.OK).entity("dw:integrationDelete," + trace).build(); + } + + @Override + public Response onTest(final TestPayload payload, final String trace) { + return Response.status(Response.Status.OK).entity("dw:test," + trace).build(); + } + + @Override + public Response onVoteCreate(final VoteCreatePayload payload, final String trace) { + return Response.status(Response.Status.OK).entity("dw:voteCreate," + trace).build(); + } +} diff --git a/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java b/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java new file mode 100644 index 0000000..0456eee --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java @@ -0,0 +1,68 @@ +package org.discordbots.webhooks.dropwizard; + +import io.dropwizard.core.Configuration; +import io.dropwizard.testing.ResourceHelpers; +import io.dropwizard.testing.junit5.DropwizardAppExtension; +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import jakarta.ws.rs.client.Entity; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.io.IOException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import org.discordbots.webhooks.Mocks; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(DropwizardExtensionsSupport.class) +public class DBLDropwizardWebhooksTest { + private static final DropwizardAppExtension APP = + new DropwizardAppExtension<>( + CustomServer.class, ResourceHelpers.resourceFilePath("dropwizard-test-config.yml")); + + private static final String SECRET = System.getenv("TOPGG_WEBHOOK_SECRET"); + private static final String TRACE = "trace"; + private static Mocks MOCKS; + + @BeforeAll + public static void setup() throws IOException, NullPointerException { + MOCKS = new Mocks(); + } + + private void send(final String name, final String payload) + throws NoSuchAlgorithmException, InvalidKeyException { + final Response response = + APP.client() + .target(String.format("http://localhost:%d/webhook", APP.getLocalPort())) + .request() + .header("Content-Type", "application/json") + .header("x-topgg-signature", Mocks.signature(SECRET, payload)) + .header("x-topgg-trace", TRACE) + .post(Entity.entity(payload, MediaType.APPLICATION_JSON)); + + Assertions.assertEquals(200, response.getStatus()); + Assertions.assertEquals("dw:" + name + "," + TRACE, response.readEntity(String.class)); + } + + @Test + public void integrationCreate() throws NoSuchAlgorithmException, InvalidKeyException { + send("integrationCreate", MOCKS.integrationCreatePayload); + } + + @Test + public void integrationDelete() throws NoSuchAlgorithmException, InvalidKeyException { + send("integrationDelete", MOCKS.integrationDeletePayload); + } + + @Test + public void test() throws NoSuchAlgorithmException, InvalidKeyException { + send("test", MOCKS.testPayload); + } + + @Test + public void voteCreate() throws NoSuchAlgorithmException, InvalidKeyException { + send("voteCreate", MOCKS.voteCreatePayload); + } +} diff --git a/src/test/java/org/discordbots/webhooks/eclipsejetty/CustomWebhooks.java b/src/test/java/org/discordbots/webhooks/eclipsejetty/CustomWebhooks.java new file mode 100644 index 0000000..3bcfdcf --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/eclipsejetty/CustomWebhooks.java @@ -0,0 +1,51 @@ +package org.discordbots.webhooks.eclipsejetty; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; + +public class CustomWebhooks extends DBLWebhooks { + public CustomWebhooks() { + super(System.getenv("TOPGG_WEBHOOK_SECRET")); + } + + private void reply(final String name, final HttpServletResponse response, final String trace) { + try { + response.setStatus(HttpServletResponse.SC_OK); + response.getWriter().write("ej:" + name + "," + trace); + } catch (final IOException ignored) { + response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + + @Override + public void onIntegrationCreate( + final HttpServletResponse response, + final IntegrationCreatePayload payload, + final String trace) { + reply("integrationCreate", response, trace); + } + + @Override + public void onIntegrationDelete( + final HttpServletResponse response, + final IntegrationDeletePayload payload, + final String trace) { + reply("integrationDelete", response, trace); + } + + @Override + public void onTest( + final HttpServletResponse response, final TestPayload payload, final String trace) { + reply("test", response, trace); + } + + @Override + public void onVoteCreate( + final HttpServletResponse response, final VoteCreatePayload payload, final String trace) { + reply("voteCreate", response, trace); + } +} diff --git a/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java b/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java new file mode 100644 index 0000000..7450a20 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java @@ -0,0 +1,105 @@ +package org.discordbots.webhooks.eclipsejetty; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.ProtocolException; +import java.net.URI; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import org.discordbots.webhooks.Mocks; +import org.eclipse.jetty.ee10.servlet.ServletContextHandler; +import org.eclipse.jetty.ee10.servlet.ServletHolder; +import org.eclipse.jetty.server.Server; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +public class DBLEclipseJettyWebhooksTest { + private static Server SERVER = null; + + private static final String SECRET = System.getenv("TOPGG_WEBHOOK_SECRET"); + private static final String TRACE = "trace"; + private static Mocks MOCKS; + + @BeforeAll + public static void setup() throws IOException, NullPointerException, Exception { + MOCKS = new Mocks(); + + SERVER = new Server(8080); + + final ServletContextHandler context = new ServletContextHandler(); + + context.setContextPath("/"); + context.addServlet(new ServletHolder(new CustomWebhooks()), "/webhook"); + + SERVER.setHandler(context); + SERVER.start(); + } + + private void send(final String name, final String payload) + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + final HttpURLConnection connection = + (HttpURLConnection) URI.create("http://localhost:8080/webhook").toURL().openConnection(); + + connection.setRequestMethod("POST"); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("x-topgg-signature", Mocks.signature(SECRET, payload)); + connection.setRequestProperty("x-topgg-trace", TRACE); + connection.setDoOutput(true); + + try (final OutputStream outputStream = connection.getOutputStream()) { + final byte[] payloadBytes = payload.getBytes("utf-8"); + + outputStream.write(payloadBytes, 0, payloadBytes.length); + } + + Assertions.assertEquals(200, connection.getResponseCode()); + + try (final BufferedReader reader = + new BufferedReader(new InputStreamReader(connection.getInputStream(), "utf-8"))) { + final StringBuilder response = new StringBuilder(); + String responseLine; + + while ((responseLine = reader.readLine()) != null) { + response.append(responseLine.trim()); + } + + Assertions.assertEquals("ej:" + name + "," + TRACE, response.toString()); + } + } + + @Test + public void integrationCreate() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("integrationCreate", MOCKS.integrationCreatePayload); + } + + @Test + public void integrationDelete() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("integrationDelete", MOCKS.integrationDeletePayload); + } + + @Test + public void test() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("test", MOCKS.testPayload); + } + + @Test + public void voteCreate() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("voteCreate", MOCKS.voteCreatePayload); + } + + @AfterAll + public static void cleanup() throws Exception { + if (SERVER != null) { + SERVER.stop(); + } + } +} diff --git a/src/test/java/org/discordbots/webhooks/springboot/CustomServer.java b/src/test/java/org/discordbots/webhooks/springboot/CustomServer.java new file mode 100644 index 0000000..e5c584f --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/springboot/CustomServer.java @@ -0,0 +1,11 @@ +package org.discordbots.webhooks.springboot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CustomServer { + public static void main(final String[] args) throws Exception { + SpringApplication.run(CustomServer.class, args); + } +} diff --git a/src/test/java/org/discordbots/webhooks/springboot/CustomServerSecurityConfiguration.java b/src/test/java/org/discordbots/webhooks/springboot/CustomServerSecurityConfiguration.java new file mode 100644 index 0000000..9805872 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/springboot/CustomServerSecurityConfiguration.java @@ -0,0 +1,16 @@ +package org.discordbots.webhooks.springboot; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class CustomServerSecurityConfiguration { + @Bean + public SecurityFilterChain filterChain(final HttpSecurity http) { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/test/java/org/discordbots/webhooks/springboot/CustomWebhooks.java b/src/test/java/org/discordbots/webhooks/springboot/CustomWebhooks.java new file mode 100644 index 0000000..c6eca75 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/springboot/CustomWebhooks.java @@ -0,0 +1,49 @@ +package org.discordbots.webhooks.springboot; + +import org.discordbots.webhooks.payload.IntegrationCreatePayload; +import org.discordbots.webhooks.payload.IntegrationDeletePayload; +import org.discordbots.webhooks.payload.TestPayload; +import org.discordbots.webhooks.payload.VoteCreatePayload; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class CustomWebhooks extends DBLWebhooks { + public CustomWebhooks() { + super(System.getenv("TOPGG_WEBHOOK_SECRET")); + } + + @PostMapping("/webhook") + public ResponseEntity main( + @RequestBody final String body, + @RequestHeader("x-topgg-signature") final String signature, + @RequestHeader("x-topgg-trace") final String trace) { + return dispatch(body, signature, trace); + } + + @Override + public ResponseEntity onIntegrationCreate( + final IntegrationCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.OK).body("sb:integrationCreate," + trace); + } + + @Override + public ResponseEntity onIntegrationDelete( + final IntegrationDeletePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.OK).body("sb:integrationDelete," + trace); + } + + @Override + public ResponseEntity onTest(final TestPayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.OK).body("sb:test," + trace); + } + + @Override + public ResponseEntity onVoteCreate(final VoteCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.OK).body("sb:voteCreate," + trace); + } +} diff --git a/src/test/java/org/discordbots/webhooks/springboot/DBLSpringBootWebhooksTest.java b/src/test/java/org/discordbots/webhooks/springboot/DBLSpringBootWebhooksTest.java new file mode 100644 index 0000000..8405f4e --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/springboot/DBLSpringBootWebhooksTest.java @@ -0,0 +1,59 @@ +package org.discordbots.webhooks.springboot; + +import java.io.IOException; +import org.discordbots.webhooks.Mocks; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +@SpringBootTest +@AutoConfigureMockMvc +public class DBLSpringBootWebhooksTest { + private static final String SECRET = System.getenv("TOPGG_WEBHOOK_SECRET"); + private static final String TRACE = "trace"; + private static Mocks MOCKS; + + @Autowired private MockMvc mvc; + + @BeforeAll + public static void setup() throws IOException, NullPointerException { + MOCKS = new Mocks(); + } + + private void send(final String name, final String payload) throws IOException, Exception { + mvc.perform( + MockMvcRequestBuilders.post("/webhook") + .content(payload) + .header("x-topgg-signature", Mocks.signature(SECRET, payload)) + .header("x-topgg-trace", TRACE) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(MockMvcResultMatchers.status().is(200)) + .andExpect(MockMvcResultMatchers.content().string("sb:" + name + "," + TRACE)); + } + + @Test + public void integrationCreate() throws IOException, Exception { + send("integrationCreate", MOCKS.integrationCreatePayload); + } + + @Test + public void integrationDelete() throws IOException, Exception { + send("integrationDelete", MOCKS.integrationDeletePayload); + } + + @Test + public void test() throws IOException, Exception { + send("test", MOCKS.testPayload); + } + + @Test + public void voteCreate() throws IOException, Exception { + send("voteCreate", MOCKS.voteCreatePayload); + } +} diff --git a/src/test/resources/GetSelfResponse.json b/src/test/resources/GetSelfResponse.json new file mode 100644 index 0000000..e74997b --- /dev/null +++ b/src/test/resources/GetSelfResponse.json @@ -0,0 +1,16 @@ +{ + "id": "364806029876555776", + "name": "Top.gg Lib Dev API Access", + "type": "bot", + "platform": "discord", + "headline": "API access for Top.gg Library Developers", + "tags": [ + "api", + "library", + "topgg" + ], + "votes": 4, + "votes_total": 34, + "review_score": 5, + "review_count": 2 +} \ No newline at end of file diff --git a/src/test/resources/GetVoteResponse.json b/src/test/resources/GetVoteResponse.json new file mode 100644 index 0000000..c6a0227 --- /dev/null +++ b/src/test/resources/GetVoteResponse.json @@ -0,0 +1,5 @@ +{ + "created_at": "2026-02-25T22:35:36.978392+00:00", + "expires_at": "2026-02-26T10:35:36.978392+00:00", + "weight": 1 +} \ No newline at end of file diff --git a/src/test/resources/GetVotesResponse.json b/src/test/resources/GetVotesResponse.json new file mode 100644 index 0000000..5eab378 --- /dev/null +++ b/src/test/resources/GetVotesResponse.json @@ -0,0 +1,33 @@ +{ + "cursor": "", + "data": [ + { + "user_id": "800506814562787328", + "platform_id": "1461830808796139662", + "weight": 2, + "created_at": "2026-01-17T23:36:06.34732Z", + "expires_at": "2026-01-18T11:36:06.34732Z" + }, + { + "user_id": "316026718115037184", + "platform_id": "481068576363773972", + "weight": 2, + "created_at": "2026-02-20T05:43:58.392411Z", + "expires_at": "2026-02-20T17:43:58.392411Z" + }, + { + "user_id": "794153497215045632", + "platform_id": "1425259851600101457", + "weight": 2, + "created_at": "2026-02-21T18:59:20.660734Z", + "expires_at": "2026-02-22T06:59:20.660734Z" + }, + { + "user_id": "8226924471638491136", + "platform_id": "661200758510977084", + "weight": 1, + "created_at": "2026-02-25T22:35:36.978392Z", + "expires_at": "2026-02-26T10:35:36.978392Z" + } + ] +} \ No newline at end of file diff --git a/src/test/resources/IntegrationCreatePayload.json b/src/test/resources/IntegrationCreatePayload.json new file mode 100644 index 0000000..77df6b0 --- /dev/null +++ b/src/test/resources/IntegrationCreatePayload.json @@ -0,0 +1,19 @@ +{ + "type": "integration.create", + "data": { + "connection_id": "112402021105124", + "webhook_secret": "whs_abcd", + "project": { + "id": "1230954036934033243", + "platform": "discord", + "platform_id": "3949456393249234923", + "type": "bot" + }, + "user": { + "id": "3949456393249234923", + "platform_id": "3949456393249234923", + "name": "username", + "avatar_url": "" + } + } +} \ No newline at end of file diff --git a/src/test/resources/IntegrationDeletePayload.json b/src/test/resources/IntegrationDeletePayload.json new file mode 100644 index 0000000..cb44375 --- /dev/null +++ b/src/test/resources/IntegrationDeletePayload.json @@ -0,0 +1,6 @@ +{ + "type": "integration.delete", + "data": { + "connection_id": "112402021105124" + } +} \ No newline at end of file diff --git a/src/test/resources/LeadResponse.json b/src/test/resources/LeadResponse.json new file mode 100644 index 0000000..52b54a3 --- /dev/null +++ b/src/test/resources/LeadResponse.json @@ -0,0 +1,3 @@ +{ + "error": "Not Found" +} \ No newline at end of file diff --git a/src/test/resources/PostCommands.json b/src/test/resources/PostCommands.json new file mode 100644 index 0000000..071db3e --- /dev/null +++ b/src/test/resources/PostCommands.json @@ -0,0 +1,11 @@ +[ + { + "id": "1", + "type": 1, + "application_id": "1", + "name": "test", + "description": "command description", + "default_member_permissions": "", + "version": "1" + } +] \ No newline at end of file diff --git a/src/test/resources/TestPayload.json b/src/test/resources/TestPayload.json new file mode 100644 index 0000000..b7a7432 --- /dev/null +++ b/src/test/resources/TestPayload.json @@ -0,0 +1,17 @@ +{ + "type": "webhook.test", + "data": { + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + }, + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + } + } +} \ No newline at end of file diff --git a/src/test/resources/VoteCreatePayload.json b/src/test/resources/VoteCreatePayload.json new file mode 100644 index 0000000..6850196 --- /dev/null +++ b/src/test/resources/VoteCreatePayload.json @@ -0,0 +1,21 @@ +{ + "type": "vote.create", + "data": { + "id": "808499215864008704", + "weight": 1, + "created_at": "2026-02-09T00:47:14.2510149+00:00", + "expires_at": "2026-02-09T12:47:14.2510149+00:00", + "project": { + "id": "803190510032756736", + "type": "bot", + "platform": "discord", + "platform_id": "160105994217586689" + }, + "user": { + "id": "160105994217586689", + "platform_id": "160105994217586689", + "name": "username", + "avatar_url": "" + } + } +} \ No newline at end of file diff --git a/src/test/resources/dropwizard-test-config.yml b/src/test/resources/dropwizard-test-config.yml new file mode 100644 index 0000000..c8aa4d6 --- /dev/null +++ b/src/test/resources/dropwizard-test-config.yml @@ -0,0 +1,7 @@ +server: + applicationConnectors: + - type: http + port: 0 + adminConnectors: + - type: http + port: 0 \ No newline at end of file diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java b/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java new file mode 100644 index 0000000..85fff1d --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java @@ -0,0 +1,30 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public class PartialProject { + private String id; + + private ProjectType type; + + private Platform platform; + + @SerializedName("platform_id") + private String platformId; + + public String getId() { + return id; + } + + public ProjectType getType() { + return type; + } + + public Platform getPlatform() { + return platform; + } + + public String getPlatformId() { + return platformId; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java b/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java new file mode 100644 index 0000000..8cb1ad2 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java @@ -0,0 +1,8 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public enum Platform { + @SerializedName("discord") + DISCORD +} diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java b/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java new file mode 100644 index 0000000..675f7df --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java @@ -0,0 +1,11 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public enum ProjectType { + @SerializedName("bot") + DISCORD_BOT, + + @SerializedName("server") + DISCORD_SERVER +} diff --git a/src/webhooks/java/org/discordbots/webhooks/entity/User.java b/src/webhooks/java/org/discordbots/webhooks/entity/User.java new file mode 100644 index 0000000..0be2afb --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/entity/User.java @@ -0,0 +1,31 @@ +package org.discordbots.webhooks.entity; + +import com.google.gson.annotations.SerializedName; + +public class User { + private String id; + + private String name; + + @SerializedName("avatar_url") + private String avatar; + + @SerializedName("platform_id") + private String platformId; + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getAvatar() { + return avatar; + } + + public String getPlatformId() { + return platformId; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java new file mode 100644 index 0000000..99303c1 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java @@ -0,0 +1,33 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.annotations.SerializedName; +import org.discordbots.webhooks.entity.PartialProject; +import org.discordbots.webhooks.entity.User; + +public class IntegrationCreatePayload { + @SerializedName("connection_id") + private String connectionId; + + @SerializedName("webhook_secret") + private String secret; + + private PartialProject project; + + private User user; + + public String getConnectionId() { + return connectionId; + } + + public String getSecret() { + return secret; + } + + public PartialProject getProject() { + return project; + } + + public User getUser() { + return user; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java new file mode 100644 index 0000000..2653cda --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java @@ -0,0 +1,12 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.annotations.SerializedName; + +public class IntegrationDeletePayload { + @SerializedName("connection_id") + private String connectionId; + + public String getConnectionId() { + return connectionId; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java new file mode 100644 index 0000000..424ea39 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java @@ -0,0 +1,19 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; + +public class Payload { + private String type; + + private JsonObject data; + + public String getType() { + return type; + } + + public T getData(final Gson gson, final Class cls) throws JsonSyntaxException { + return gson.fromJson(data, cls); + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java new file mode 100644 index 0000000..6a90483 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java @@ -0,0 +1,18 @@ +package org.discordbots.webhooks.payload; + +import org.discordbots.webhooks.entity.PartialProject; +import org.discordbots.webhooks.entity.User; + +public class TestPayload { + private PartialProject project; + + private User user; + + public PartialProject getProject() { + return project; + } + + public User getUser() { + return user; + } +} diff --git a/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java new file mode 100644 index 0000000..9f8e819 --- /dev/null +++ b/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java @@ -0,0 +1,46 @@ +package org.discordbots.webhooks.payload; + +import com.google.gson.annotations.SerializedName; +import java.time.OffsetDateTime; +import org.discordbots.webhooks.entity.PartialProject; +import org.discordbots.webhooks.entity.User; + +public class VoteCreatePayload { + private String id; + + private int weight; + + @SerializedName("created_at") + private OffsetDateTime votedAt; + + @SerializedName("expires_at") + private OffsetDateTime expiresAt; + + private PartialProject project; + + private User user; + + public String getId() { + return id; + } + + public int getWeight() { + return weight; + } + + public OffsetDateTime getVotedAt() { + return votedAt; + } + + public OffsetDateTime getExpiredAt() { + return expiresAt; + } + + public PartialProject getProject() { + return project; + } + + public User getUser() { + return user; + } +}