From a9b0fef4d5f872637805ed37465b174e506801be Mon Sep 17 00:00:00 2001 From: null8626 Date: Thu, 9 Oct 2025 23:20:24 +0700 Subject: [PATCH 01/21] feat: add webhooks --- build.gradle | 23 ++++---- .../discordbots/api/client/Dropwizard.java | 45 ++++++++++++++ .../discordbots/api/client/EclipseJetty.java | 51 ++++++++++++++++ .../discordbots/api/client/SpringBoot.java | 58 +++++++++++++++++++ 4 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/discordbots/api/client/Dropwizard.java create mode 100644 src/main/java/org/discordbots/api/client/EclipseJetty.java create mode 100644 src/main/java/org/discordbots/api/client/SpringBoot.java diff --git a/build.gradle b/build.gradle index 023b55d..d112be3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,19 +1,22 @@ -group 'org.discordbots' +plugins { + id "java" +} -apply plugin: 'java' -apply plugin: 'maven' +group = 'org.discordbots' repositories { mavenCentral() } dependencies { - //Logger - compile group: 'org.slf4j', name: 'slf4j-api', version: '1.7.25' + implementation "org.slf4j:slf4j-api:2.0.17" - 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' -} + implementation "org.json:json:20250517" + implementation "com.squareup.okhttp3:okhttp:5.2.0" + implementation "com.google.code.gson:gson:2.13.2" + implementation "com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2" + implementation "org.springframework.boot:spring-boot-starter-web:3.5.6" + implementation "jakarta.servlet:jakarta.servlet-api:6.1.0" + implementation "jakarta.ws.rs:jakarta.ws.rs-api:4.0.0" +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/Dropwizard.java b/src/main/java/org/discordbots/api/client/Dropwizard.java new file mode 100644 index 0000000..58550ba --- /dev/null +++ b/src/main/java/org/discordbots/api/client/Dropwizard.java @@ -0,0 +1,45 @@ +package org.discordbots.api.client.webhooks; + +import java.io.IOException; +import java.io.InputStreamReader; + +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.core.Context; +import jakarta.ws.rs.core.Response; + +public abstract class Dropwizard { + private final Class aClass; + private final String authorization; + private final Gson gson; + + public Dropwizard(final Class aClass, final String authorization) { + this.aClass = aClass; + this.authorization = authorization; + this.gson = new GsonBuilder().create(); + } + + @POST + public Response handle(@Context HttpServletRequest request) { + final String authorizationHeader = request.getHeader("Authorization"); + + if (authorizationHeader == null || !authorizationHeader.equals(this.authorization)) { + return Response.status(Response.Status.UNAUTHORIZED).entity("Unauthorized").build(); + } + + try { + callback(gson.fromJson(new InputStreamReader(request.getInputStream()), aClass)); + + return Response.noContent().build(); + } catch (final JsonSyntaxException | JsonIOException | IOException ignored) {} + + return Response.status(Response.Status.BAD_REQUEST).entity("Bad request").build(); + } + + public abstract void callback(T data); +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/EclipseJetty.java b/src/main/java/org/discordbots/api/client/EclipseJetty.java new file mode 100644 index 0000000..ee4112e --- /dev/null +++ b/src/main/java/org/discordbots/api/client/EclipseJetty.java @@ -0,0 +1,51 @@ +package org.discordbots.api.client.webhooks; + +import java.io.IOException; +import java.io.InputStreamReader; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; + +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public abstract class EclipseJetty extends HttpServlet { + private final Class aClass; + private final String authorization; + private final Gson gson; + + public EclipseJetty(final Class aClass, final String authorization) { + this.aClass = aClass; + this.authorization = authorization; + this.gson = new GsonBuilder().create(); + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + final String authorizationHeader = request.getHeader("Authorization"); + + if (authorizationHeader == null || !authorizationHeader.equals(this.authorization)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized"); + + return; + } + + try { + callback(gson.fromJson(new InputStreamReader(request.getInputStream()), aClass)); + + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.getWriter().write(""); + + return; + } catch (final JsonSyntaxException | JsonIOException | IOException ignored) {} + + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Bad request"); + } + + public abstract void callback(T data); +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/SpringBoot.java b/src/main/java/org/discordbots/api/client/SpringBoot.java new file mode 100644 index 0000000..5177b96 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/SpringBoot.java @@ -0,0 +1,58 @@ +package org.discordbots.api.client.webhooks; + +import java.io.IOException; +import java.io.InputStreamReader; + +import org.springframework.web.filter.OncePerRequestFilter; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public abstract class SpringBoot extends OncePerRequestFilter { + private final Class aClass; + private final String authorization; + private final Gson gson; + + public SpringBoot(final Class aClass, final String authorization) { + this.aClass = aClass; + this.authorization = authorization; + this.gson = new GsonBuilder().create(); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + if (request.getMethod().equalsIgnoreCase("POST")) { + final String authorizationHeader = request.getHeader("Authorization"); + + if (authorizationHeader == null || !authorizationHeader.equals(this.authorization)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Unauthorized"); + + return; + } + + try { + callback(gson.fromJson(new InputStreamReader(request.getInputStream()), aClass)); + + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.getWriter().write(""); + + return; + } catch (final JsonSyntaxException | JsonIOException | IOException ignored) {} + + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.getWriter().write("Bad request"); + } else { + filterChain.doFilter(request, response); + } + } + + public abstract void callback(T data); +} \ No newline at end of file From 79ee23ac7caa932f1c08314228426c0e76e13c83 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:51:39 +0700 Subject: [PATCH 02/21] feat: implement new webhook authorization approach --- build.gradle | 6 + gradle/wrapper/gradle-wrapper.jar | Bin 54413 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 302 +++++++++++------- gradlew.bat | 177 +++++----- .../discordbots/api/client/Dropwizard.java | 64 +++- .../discordbots/api/client/EclipseJetty.java | 74 +++-- .../discordbots/api/client/SpringBoot.java | 79 +++-- 8 files changed, 452 insertions(+), 255 deletions(-) diff --git a/build.gradle b/build.gradle index d112be3..1d213d9 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,12 @@ plugins { group = 'org.discordbots' +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + repositories { mavenCentral() } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1948b9074f1016d15d505d185bc3f73deb82d8c8..61285a659d17295f1de7c53e24fdf13ad755c379 100644 GIT binary patch literal 46175 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

pVX@HRij*jw$g4?}Pn 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 literal 54413 zcmafaV|Zr4wq`oEZQHiZj%|LijZQlLf{tz5M#r{o+fI6V=G-$g=gzrzeyqLskF}nv zRZs0&c;EUi2L_G~0s;*U0szbMMwKS>Gw zRZ#mYf6f1oqJoH`jHHCB8l!^by~4z}yc`4LEP@;Z?bO6{g9`Hk+s@(L1jC5Tq{1Yf z4E;CQvrx0-gF+peRxFC*gF=&$zNYjO?HlJ?=WqXMz`tYs@0o%B{dRD+{C_6(f9t^g zhmNJQv6-#;f2)f2uc{u-#*U8W&i{|ewYN^n_1~cv|1J!}zc&$eaBy{T{cEpa46s*q zHFkD2cV;xTHFj}{*3kBt*FgS4A5SI|$F%$gB@It9FlC}D3y`sbZG{2P6gGwC$U`6O zb_cId9AhQl#A<&=x>-xDD%=Ppt$;y71@Lwsl{x943#T@8*?cbR<~d`@@}4V${+r$jICUIOzgZJy_9I zu*eA(F)$~J07zX%tmQN}1^wj+RM|9bbwhQA=xrPE*{vB_P!pPYT5{Or^m*;Qz#@Bl zRywCG_RDyM6bf~=xn}FtiFAw|rrUxa1+z^H`j6e|GwKDuq}P)z&@J>MEhsVBvnF|O zOEm)dADU1wi8~mX(j_8`DwMT_OUAnjbWYer;P*^Uku_qMu3}qJU zTAkza-K9aj&wcsGuhQ>RQoD?gz~L8RwCHOZDzhBD$az*$TQ3!uygnx_rsXG`#_x5t zn*lb(%JI3%G^MpYp-Y(KI4@_!&kBRa3q z|Fzn&3R%ZsoMNEn4pN3-BSw2S_{IB8RzRv(eQ1X zyBQZHJ<(~PfUZ~EoI!Aj`9k<+Cy z2DtI<+9sXQu!6&-Sk4SW3oz}?Q~mFvy(urUy<)x!KQ>#7yIPC)(ORhKl7k)4eSy~} z7#H3KG<|lt68$tk^`=yjev%^usOfpQ#+Tqyx|b#dVA(>fPlGuS@9ydo z!Cs#hse9nUETfGX-7lg;F>9)+ml@M8OO^q|W~NiysX2N|2dH>qj%NM`=*d3GvES_# zyLEHw&1Fx<-dYxCQbk_wk^CI?W44%Q9!!9aJKZW-bGVhK?N;q`+Cgc*WqyXcxZ%U5QXKu!Xn)u_dxeQ z;uw9Vysk!3OFzUmVoe)qt3ifPin0h25TU zrG*03L~0|aaBg7^YPEW^Yq3>mSNQgk-o^CEH?wXZ^QiPiuH}jGk;75PUMNquJjm$3 zLcXN*uDRf$Jukqg3;046b;3s8zkxa_6yAlG{+7{81O3w96i_A$KcJhD&+oz1<>?lun#C3+X0q zO4JxN{qZ!e#FCl@e_3G?0I^$CX6e$cy7$BL#4<`AA)Lw+k`^15pmb-447~5lkSMZ` z>Ce|adKhb-F%yy!vx>yQbXFgHyl(an=x^zi(!-~|k;G1=E(e@JgqbAF{;nv`3i)oi zDeT*Q+Mp{+NkURoabYb9@#Bi5FMQnBFEU?H{~9c;g3K%m{+^hNe}(MdpPb?j9`?2l z#%AO!|2QxGq7-2Jn2|%atvGb(+?j&lmP509i5y87`9*BSY++<%%DXb)kaqG0(4Eft zj|2!Od~2TfVTi^0dazAIeVe&b#{J4DjN6;4W;M{yWj7#+oLhJyqeRaO;>?%mX>Ec{Mp~;`bo}p;`)@5dA8fNQ38FyMf;wUPOdZS{U*8SN6xa z-kq3>*Zos!2`FMA7qjhw-`^3ci%c91Lh`;h{qX1r;x1}eW2hYaE*3lTk4GwenoxQ1kHt1Lw!*N8Z%DdZSGg5~Bw}+L!1#d$u+S=Bzo7gi zqGsBV29i)Jw(vix>De)H&PC; z-t2OX_ak#~eSJ?Xq=q9A#0oaP*dO7*MqV;dJv|aUG00UX=cIhdaet|YEIhv6AUuyM zH1h7fK9-AV)k8sr#POIhl+?Z^r?wI^GE)ZI=H!WR<|UI(3_YUaD#TYV$Fxd015^mT zpy&#-IK>ahfBlJm-J(n(A%cKV;)8&Y{P!E|AHPtRHk=XqvYUX?+9po4B$0-6t74UUef${01V{QLEE8gzw* z5nFnvJ|T4dlRiW9;Ed_yB{R@)fC=zo4hCtD?TPW*WJmMXYxN_&@YQYg zBQ$XRHa&EE;YJrS{bn7q?}Y&DH*h;){5MmE(9A6aSU|W?{3Ox%5fHLFScv7O-txuRbPG1KQtI`Oay=IcEG=+hPhlnYC;`wSHeo|XGio0aTS6&W($E$ z?N&?TK*l8;Y^-xPl-WVZwrfdiQv10KdsAb9u-*1co*0-Z(h#H)k{Vc5CT!708cs%sExvPC+7-^UY~jTfFq=cj z!Dmy<+NtKp&}}$}rD{l?%MwHdpE(cPCd;-QFPk1`E5EVNY2i6E`;^aBlx4}h*l42z zpY#2cYzC1l6EDrOY*ccb%kP;k8LHE3tP>l3iK?XZ%FI<3666yPw1rM%>eCgnv^JS_ zK7c~;g7yXt9fz@(49}Dj7VO%+P!eEm& z;z8UXs%NsQ%@2S5nve)@;yT^61BpVlc}=+i6{ZZ9r7<({yUYqe==9*Z+HguP3`sA& z{`inI4G)eLieUQ*pH9M@)u7yVnWTQva;|xq&-B<>MoP(|xP(HqeCk1&h>DHNLT>Zi zQ$uH%s6GoPAi0~)sC;`;ngsk+StYL9NFzhFEoT&Hzfma1f|tEnL0 zMWdX4(@Y*?*tM2@H<#^_l}BC&;PYJl%~E#veQ61{wG6!~nyop<^e)scV5#VkGjYc2 z$u)AW-NmMm%T7WschOnQ!Hbbw&?`oMZrJ&%dVlN3VNra1d0TKfbOz{dHfrCmJ2Jj= zS#Gr}JQcVD?S9X!u|oQ7LZ+qcq{$40 ziG5=X^+WqeqxU00YuftU7o;db=K+Tq!y^daCZgQ)O=M} zK>j*<3oxs=Rcr&W2h%w?0Cn3);~vqG>JO_tTOzuom^g&^vzlEjkx>Sv!@NNX%_C!v zaMpB>%yVb}&ND9b*O>?HxQ$5-%@xMGe4XKjWh7X>CYoRI2^JIwi&3Q5UM)?G^k8;8 zmY$u;(KjZx>vb3fe2zgD7V;T2_|1KZQW$Yq%y5Ioxmna9#xktcgVitv7Sb3SlLd6D zfmBM9Vs4rt1s0M}c_&%iP5O{Dnyp|g1(cLYz^qLqTfN6`+o}59Zlu%~oR3Q3?{Bnr zkx+wTpeag^G12fb_%SghFcl|p2~<)Av?Agumf@v7y-)ecVs`US=q~=QG%(_RTsqQi z%B&JdbOBOmoywgDW|DKR5>l$1^FPhxsBrja<&}*pfvE|5dQ7j-wV|ur%QUCRCzBR3q*X`05O3U@?#$<>@e+Zh&Z&`KfuM!0XL& zI$gc@ZpM4o>d&5)mg7+-Mmp98K^b*28(|Ew8kW}XEV7k^vnX-$onm9OtaO@NU9a|as7iA%5Wrw9*%UtJYacltplA5}gx^YQM` zVkn`TIw~avq)mIQO0F0xg)w$c)=8~6Jl|gdqnO6<5XD)&e7z7ypd3HOIR+ss0ikSVrWar?548HFQ*+hC)NPCq*;cG#B$7 z!n?{e9`&Nh-y}v=nK&PR>PFdut*q&i81Id`Z<0vXUPEbbJ|<~_D!)DJMqSF~ly$tN zygoa)um~xdYT<7%%m!K8+V(&%83{758b0}`b&=`))Tuv_)OL6pf=XOdFk&Mfx9y{! z6nL>V?t=#eFfM$GgGT8DgbGRCF@0ZcWaNs_#yl+6&sK~(JFwJmN-aHX{#Xkpmg;!} zgNyYYrtZdLzW1tN#QZAh!z5>h|At3m+ryJ-DFl%V>w?cmVTxt^DsCi1ZwPaCe*D{) z?#AZV6Debz{*D#C2>44Czy^yT3y92AYDcIXtZrK{L-XacVl$4i=X2|K=Fy5vAzhk{ zu3qG=qSb_YYh^HirWf~n!_Hn;TwV8FU9H8+=BO)XVFV`nt)b>5yACVr!b98QlLOBDY=^KS<*m9@_h3;64VhBQzb_QI)gbM zSDto2i*iFrvxSmAIrePB3i`Ib>LdM8wXq8(R{-)P6DjUi{2;?}9S7l7bND4w%L2!; zUh~sJ(?Yp}o!q6)2CwG*mgUUWlZ;xJZo`U`tiqa)H4j>QVC_dE7ha0)nP5mWGB268 zn~MVG<#fP#R%F=Ic@(&Va4dMk$ysM$^Avr1&hS!p=-7F>UMzd(M^N9Ijb|364}qcj zcIIh7suk$fQE3?Z^W4XKIPh~|+3(@{8*dSo&+Kr(J4^VtC{z*_{2}ld<`+mDE2)S| zQ}G#Q0@ffZCw!%ZGc@kNoMIdQ?1db%N1O0{IPPesUHI;(h8I}ETudk5ESK#boZgln z(0kvE`&6z1xH!s&={%wQe;{^&5e@N0s7IqR?L*x%iXM_czI5R1aU?!bA7)#c4UN2u zc_LZU+@elD5iZ=4*X&8%7~mA;SA$SJ-8q^tL6y)d150iM)!-ry@TI<=cnS#$kJAS# zq%eK**T*Wi2OlJ#w+d_}4=VN^A%1O+{?`BK00wkm)g8;u?vM;RR+F1G?}({ENT3i= zQsjJkp-dmJ&3-jMNo)wrz0!g*1z!V7D(StmL(A}gr^H-CZ~G9u?*Uhcx|x7rb`v^X z9~QGx;wdF4VcxCmEBp$F#sms@MR?CF67)rlpMxvwhEZLgp2?wQq|ci#rLtrYRV~iR zN?UrkDDTu114&d~Utjcyh#tXE_1x%!dY?G>qb81pWWH)Ku@Kxbnq0=zL#x@sCB(gs zm}COI(!{6-XO5li0>1n}Wz?w7AT-Sp+=NQ1aV@fM$`PGZjs*L+H^EW&s!XafStI!S zzgdntht=*p#R*o8-ZiSb5zf6z?TZr$^BtmIfGAGK;cdg=EyEG)fc*E<*T=#a?l=R5 zv#J;6C(umoSfc)W*EODW4z6czg3tXIm?x8{+8i^b;$|w~k)KLhJQnNW7kWXcR^sol z1GYOp?)a+}9Dg*nJ4fy*_riThdkbHO37^csfZRGN;CvQOtRacu6uoh^gg%_oEZKDd z?X_k67s$`|Q&huidfEonytrq!wOg07H&z@`&BU6D114p!rtT2|iukF}>k?71-3Hk< zs6yvmsMRO%KBQ44X4_FEYW~$yx@Y9tKrQ|rC1%W$6w}-9!2%4Zk%NycTzCB=nb)r6*92_Dg+c0;a%l1 zsJ$X)iyYR2iSh|%pIzYV1OUWER&np{w1+RXb~ zMUMRymjAw*{M)UtbT)T!kq5ZAn%n=gq3ssk3mYViE^$paZ;c^7{vXDJ`)q<}QKd2?{r9`X3mpZ{AW^UaRe2^wWxIZ$tuyKzp#!X-hXkHwfD zj@2tA--vFi3o_6B?|I%uwD~emwn0a z+?2Lc1xs(`H{Xu>IHXpz=@-84uw%dNV;{|c&ub|nFz(=W-t4|MME(dE4tZQi?0CE|4_?O_dyZj1)r zBcqB8I^Lt*#)ABdw#yq{OtNgf240Jvjm8^zdSf40 z;H)cp*rj>WhGSy|RC5A@mwnmQ`y4{O*SJ&S@UFbvLWyPdh)QnM=(+m3p;0&$^ysbZ zJt!ZkNQ%3hOY*sF2_~-*`aP|3Jq7_<18PX*MEUH*)t{eIx%#ibC|d&^L5FwoBN}Oe z?!)9RS@Zz%X1mqpHgym75{_BM4g)k1!L{$r4(2kL<#Oh$Ei7koqoccI3(MN1+6cDJ zp=xQhmilz1?+ZjkX%kfn4{_6K_D{wb~rdbkh!!k!Z@cE z^&jz55*QtsuNSlGPrU=R?}{*_8?4L7(+?>?(^3Ss)f!ou&{6<9QgH>#2$?-HfmDPN z6oIJ$lRbDZb)h-fFEm^1-v?Slb8udG{7GhbaGD_JJ8a9f{6{TqQN;m@$&)t81k77A z?{{)61za|e2GEq2)-OqcEjP`fhIlUs_Es-dfgX-3{S08g`w=wGj2{?`k^GD8d$}6Z zBT0T1lNw~fuwjO5BurKM593NGYGWAK%UCYiq{$p^GoYz^Uq0$YQ$j5CBXyog8(p_E znTC+$D`*^PFNc3Ih3b!2Lu|OOH6@46D)bbvaZHy%-9=$cz}V^|VPBpmPB6Ivzlu&c zPq6s7(2c4=1M;xlr}bkSmo9P`DAF>?Y*K%VPsY`cVZ{mN&0I=jagJ?GA!I;R)i&@{ z0Gl^%TLf_N`)`WKs?zlWolWvEM_?{vVyo(!taG$`FH2bqB`(o50pA=W34kl-qI62lt z1~4LG_j%sR2tBFteI{&mOTRVU7AH>>-4ZCD_p6;-J<=qrod`YFBwJz(Siu(`S}&}1 z6&OVJS@(O!=HKr-Xyzuhi;swJYK*ums~y1ePdX#~*04=b9)UqHHg;*XJOxnS6XK#j zG|O$>^2eW2ZVczP8#$C`EpcWwPFX4^}$omn{;P(fL z>J~%-r5}*D3$Kii z34r@JmMW2XEa~UV{bYP=F;Y5=9miJ+Jw6tjkR+cUD5+5TuKI`mSnEaYE2=usXNBs9 zac}V13%|q&Yg6**?H9D620qj62dM+&&1&a{NjF}JqmIP1I1RGppZ|oIfR}l1>itC% zl>ed${{_}8^}m2^br*AIX$L!Vc?Sm@H^=|LnpJg`a7EC+B;)j#9#tx-o0_e4!F5-4 zF4gA;#>*qrpow9W%tBzQ89U6hZ9g=-$gQpCh6Nv_I0X7t=th2ajJ8dBbh{i)Ok4{I z`Gacpl?N$LjC$tp&}7Sm(?A;;Nb0>rAWPN~@3sZ~0_j5bR+dz;Qs|R|k%LdreS3Nn zp*36^t#&ASm=jT)PIjNqaSe4mTjAzlAFr*@nQ~F+Xdh$VjHWZMKaI+s#FF#zjx)BJ zufxkW_JQcPcHa9PviuAu$lhwPR{R{7CzMUi49=MaOA%ElpK;A)6Sgsl7lw)D$8FwE zi(O6g;m*86kcJQ{KIT-Rv&cbv_SY4 zpm1|lSL*o_1LGOlBK0KuU2?vWcEcQ6f4;&K=&?|f`~X+s8H)se?|~2HcJo{M?Ity) zE9U!EKGz2^NgB6Ud;?GcV*1xC^1RYIp&0fr;DrqWLi_Kts()-#&3|wz{wFQsKfnnsC||T?oIgUp z{O(?Df7&vW!i#_~*@naguLLjDAz+)~*_xV2iz2?(N|0y8DMneikrT*dG`mu6vdK`% z=&nX5{F-V!Reau}+w_V3)4?}h@A@O)6GCY7eXC{p-5~p8x{cH=hNR;Sb{*XloSZ_%0ZKYG=w<|!vy?spR4!6mF!sXMUB5S9o_lh^g0!=2m55hGR; z-&*BZ*&;YSo474=SAM!WzrvjmNtq17L`kxbrZ8RN419e=5CiQ-bP1j-C#@@-&5*(8 zRQdU~+e(teUf}I3tu%PB1@Tr{r=?@0KOi3+Dy8}+y#bvgeY(FdN!!`Kb>-nM;7u=6 z;0yBwOJ6OdWn0gnuM{0`*fd=C(f8ASnH5aNYJjpbY1apTAY$-%)uDi$%2)lpH=#)=HH z<9JaYwPKil@QbfGOWvJ?cN6RPBr`f+jBC|-dO|W@x_Vv~)bmY(U(!cs6cnhe0z31O z>yTtL4@KJ*ac85u9|=LFST22~!lb>n7IeHs)_(P_gU}|8G>{D_fJX)8BJ;Se? z67QTTlTzZykb^4!{xF!=C}VeFd@n!9E)JAK4|vWVwWop5vSWcD<;2!88v-lS&ve7C zuYRH^85#hGKX(Mrk};f$j_V&`Nb}MZy1mmfz(e`nnI4Vpq(R}26pZx?fq%^|(n~>* z5a5OFtFJJfrZmgjyHbj1`9||Yp?~`p2?4NCwu_!!*4w8K`&G7U_|np&g7oY*-i;sI zu)~kYH;FddS{7Ri#Z5)U&X3h1$Mj{{yk1Q6bh4!7!)r&rqO6K~{afz@bis?*a56i& zxi#(Ss6tkU5hDQJ0{4sKfM*ah0f$>WvuRL zunQ-eOqa3&(rv4kiQ(N4`FO6w+nko_HggKFWx@5aYr}<~8wuEbD(Icvyl~9QL^MBt zSvD)*C#{2}!Z55k1ukV$kcJLtW2d~%z$t0qMe(%2qG`iF9K_Gsae7OO%Tf8E>ooch ztAw01`WVv6?*14e1w%Wovtj7jz_)4bGAqqo zvTD|B4)Ls8x7-yr6%tYp)A7|A)x{WcI&|&DTQR&2ir(KGR7~_RhNOft)wS<+vQ*|sf;d>s zEfl&B^*ZJp$|N`w**cXOza8(ARhJT{O3np#OlfxP9Nnle4Sto)Fv{w6ifKIN^f1qO*m8+MOgA1^Du!=(@MAh8)@wU8t=Ymh!iuT_lzfm za~xEazL-0xwy9$48!+?^lBwMV{!Gx)N>}CDi?Jwax^YX@_bxl*+4itP;DrTswv~n{ zZ0P>@EB({J9ZJ(^|ptn4ks^Z2UI&87d~J_^z0&vD2yb%*H^AE!w= zm&FiH*c%vvm{v&i3S>_hacFH${|(2+q!`X~zn4$aJDAry>=n|{C7le(0a)nyV{kAD zlud4-6X>1@-XZd`3SKKHm*XNn_zCyKHmf*`C_O509$iy$Wj`Sm3y?nWLCDy>MUx1x zl-sz7^{m(&NUk*%_0(G^>wLDnXW90FzNi$Tu6* z<+{ePBD`%IByu977rI^x;gO5M)Tfa-l*A2mU-#IL2?+NXK-?np<&2rlF;5kaGGrx2 zy8Xrz`kHtTVlSSlC=nlV4_oCsbwyVHG4@Adb6RWzd|Otr!LU=% zEjM5sZ#Ib4#jF(l!)8Na%$5VK#tzS>=05GpV?&o* z3goH1co0YR=)98rPJ~PuHvkA59KUi#i(Mq_$rApn1o&n1mUuZfFLjx@3;h`0^|S##QiTP8rD`r8P+#D@gvDJh>amMIl065I)PxT6Hg(lJ?X7*|XF2Le zv36p8dWHCo)f#C&(|@i1RAag->5ch8TY!LJ3(+KBmLxyMA%8*X%_ARR*!$AL66nF= z=D}uH)D)dKGZ5AG)8N-;Il*-QJ&d8u30&$_Q0n1B58S0ykyDAyGa+BZ>FkiOHm1*& zNOVH;#>Hg5p?3f(7#q*dL74;$4!t?a#6cfy#}9H3IFGiCmevir5@zXQj6~)@zYrWZ zRl*e66rjwksx-)Flr|Kzd#Bg>We+a&E{h7bKSae9P~ z(g|zuXmZ zD?R*MlmoZ##+0c|cJ(O{*h(JtRdA#lChYhfsx25(Z`@AK?Q-S8_PQqk z>|Z@Ki1=wL1_c6giS%E4YVYD|Y-{^ZzFwB*yN8-4#+TxeQ`jhks7|SBu7X|g=!_XL z`mY=0^chZfXm%2DYHJ4z#soO7=NONxn^K3WX={dV>$CTWSZe@<81-8DVtJEw#Uhd3 zxZx+($6%4a&y_rD8a&E`4$pD6-_zZJ%LEE*1|!9uOm!kYXW< zOBXZAowsX-&$5C`xgWkC43GcnY)UQt2Qkib4!!8Mh-Q!_M%5{EC=Gim@_;0+lP%O^ zG~Q$QmatQk{Mu&l{q~#kOD;T-{b1P5u7)o-QPPnqi?7~5?7%IIFKdj{;3~Hu#iS|j z)Zoo2wjf%+rRj?vzWz(6JU`=7H}WxLF*|?WE)ci7aK?SCmd}pMW<{#1Z!_7BmVP{w zSrG>?t}yNyCR%ZFP?;}e8_ zRy67~&u11TN4UlopWGj6IokS{vB!v!n~TJYD6k?~XQkpiPMUGLG2j;lh>Eb5bLTkX zx>CZlXdoJsiPx=E48a4Fkla>8dZYB%^;Xkd(BZK$z3J&@({A`aspC6$qnK`BWL;*O z-nRF{XRS`3Y&b+}G&|pE1K-Ll_NpT!%4@7~l=-TtYRW0JJ!s2C-_UsRBQ=v@VQ+4> z*6jF0;R@5XLHO^&PFyaMDvyo?-lAD(@H61l-No#t@at@Le9xOgTFqkc%07KL^&iss z!S2Ghm)u#26D(e1Q7E;L`rxOy-N{kJ zTgfw}az9=9Su?NEMMtpRlYwDxUAUr8F+P=+9pkX4%iA4&&D<|=B|~s*-U+q6cq`y* zIE+;2rD7&D5X;VAv=5rC5&nP$E9Z3HKTqIFCEV%V;b)Y|dY?8ySn|FD?s3IO>VZ&&f)idp_7AGnwVd1Z znBUOBA}~wogNpEWTt^1Rm-(YLftB=SU|#o&pT7vTr`bQo;=ZqJHIj2MP{JuXQPV7% z0k$5Ha6##aGly<}u>d&d{Hkpu?ZQeL_*M%A8IaXq2SQl35yW9zs4^CZheVgHF`%r= zs(Z|N!gU5gj-B^5{*sF>;~fauKVTq-Ml2>t>E0xl9wywD&nVYZfs1F9Lq}(clpNLz z4O(gm_i}!k`wUoKr|H#j#@XOXQ<#eDGJ=eRJjhOUtiKOG;hym-1Hu)1JYj+Kl*To<8( za1Kf4_Y@Cy>eoC59HZ4o&xY@!G(2p^=wTCV>?rQE`Upo^pbhWdM$WP4HFdDy$HiZ~ zRUJFWTII{J$GLVWR?miDjowFk<1#foE3}C2AKTNFku+BhLUuT>?PATB?WVLzEYyu+ zM*x((pGdotzLJ{}R=OD*jUexKi`mb1MaN0Hr(Wk8-Uj0zA;^1w2rmxLI$qq68D>^$ zj@)~T1l@K|~@YJ6+@1vlWl zHg5g%F{@fW5K!u>4LX8W;ua(t6YCCO_oNu}IIvI6>Fo@MilYuwUR?9p)rKNzDmTAN zzN2d>=Za&?Z!rJFV*;mJ&-sBV80%<-HN1;ciLb*Jk^p?u<~T25%7jjFnorfr={+wm zzl5Q6O>tsN8q*?>uSU6#xG}FpAVEQ_++@}G$?;S7owlK~@trhc#C)TeIYj^N(R&a} zypm~c=fIs;M!YQrL}5{xl=tUU-Tfc0ZfhQuA-u5(*w5RXg!2kChQRd$Fa8xQ0CQIU zC`cZ*!!|O!*y1k1J^m8IIi|Sl3R}gm@CC&;4840^9_bb9%&IZTRk#=^H0w%`5pMDCUef5 zYt-KpWp2ijh+FM`!zZ35>+7eLN;s3*P!bp%-oSx34fdTZ14Tsf2v7ZrP+mitUx$rS zW(sOi^CFxe$g3$x45snQwPV5wpf}>5OB?}&Gh<~i(mU&ss#7;utaLZ!|KaTHniGO9 zVC9OTzuMKz)afey_{93x5S*Hfp$+r*W>O^$2ng|ik!<`U1pkxm3*)PH*d#>7md1y} zs7u^a8zW8bvl92iN;*hfOc-=P7{lJeJ|3=NfX{(XRXr;*W3j845SKG&%N zuBqCtDWj*>KooINK1 zFPCsCWr!-8G}G)X*QM~34R*k zmRmDGF*QE?jCeNfc?k{w<}@29e}W|qKJ1K|AX!htt2|B`nL=HkC4?1bEaHtGBg}V( zl(A`6z*tck_F$4;kz-TNF%7?=20iqQo&ohf@S{_!TTXnVh}FaW2jxAh(DI0f*SDG- z7tqf5X@p#l?7pUNI(BGi>n_phw=lDm>2OgHx-{`T>KP2YH9Gm5ma zb{>7>`tZ>0d5K$j|s2!{^sFWQo3+xDb~#=9-jp(1ydI3_&RXGB~rxWSMgDCGQG)oNoc#>)td zqE|X->35U?_M6{^lB4l(HSN|`TC2U*-`1jSQeiXPtvVXdN-?i1?d#;pw%RfQuKJ|e zjg75M+Q4F0p@8I3ECpBhGs^kK;^0;7O@MV=sX^EJLVJf>L;GmO z3}EbTcoom7QbI(N8ad!z(!6$!MzKaajSRb0c+ZDQ($kFT&&?GvXmu7+V3^_(VJx1z zP-1kW_AB&_A;cxm*g`$ z#Pl@Cg{siF0ST2-w)zJkzi@X)5i@)Z;7M5ewX+xcY36IaE0#flASPY2WmF8St0am{ zV|P|j9wqcMi%r-TaU>(l*=HxnrN?&qAyzimA@wtf;#^%{$G7i4nXu=Pp2#r@O~wi)zB>@25A*|axl zEclXBlXx1LP3x0yrSx@s-kVW4qlF+idF+{M7RG54CgA&soDU-3SfHW@-6_ z+*;{n_SixmGCeZjHmEE!IF}!#aswth_{zm5Qhj0z-@I}pR?cu=P)HJUBClC;U+9;$#@xia30o$% zDw%BgOl>%vRenxL#|M$s^9X}diJ9q7wI1-0n2#6>@q}rK@ng(4M68(t52H_Jc{f&M9NPxRr->vj-88hoI?pvpn}llcv_r0`;uN>wuE{ z&TOx_i4==o;)>V4vCqG)A!mW>dI^Ql8BmhOy$6^>OaUAnI3>mN!Zr#qo4A>BegYj` zNG_)2Nvy2Cqxs1SF9A5HHhL7sai#Umw%K@+riaF+q)7&MUJvA&;$`(w)+B@c6!kX@ zzuY;LGu6|Q2eu^06PzSLspV2v4E?IPf`?Su_g8CX!75l)PCvyWKi4YRoRThB!-BhG zubQ#<7oCvj@z`^y&mPhSlbMf0<;0D z?5&!I?nV-jh-j1g~&R(YL@c=KB_gNup$8abPzXZN`N|WLqxlN)ZJ+#k4UWq#WqvVD z^|j+8f5uxTJtgcUscKTqKcr?5g-Ih3nmbvWvvEk})u-O}h$=-p4WE^qq7Z|rLas0$ zh0j&lhm@Rk(6ZF0_6^>Rd?Ni-#u1y`;$9tS;~!ph8T7fLlYE{P=XtWfV0Ql z#z{_;A%p|8+LhbZT0D_1!b}}MBx9`R9uM|+*`4l3^O(>Mk%@ha>VDY=nZMMb2TnJ= zGlQ+#+pmE98zuFxwAQcVkH1M887y;Bz&EJ7chIQQe!pgWX>(2ruI(emhz@_6t@k8Z zqFEyJFX2PO`$gJ6p$=ku{7!vR#u+$qo|1r;orjtp9FP^o2`2_vV;W&OT)acRXLN^m zY8a;geAxg!nbVu|uS8>@Gvf@JoL&GP`2v4s$Y^5vE32&l;2)`S%e#AnFI-YY7_>d#IKJI!oL6e z_7W3e=-0iz{bmuB*HP+D{Nb;rn+RyimTFqNV9Bzpa0?l`pWmR0yQOu&9c0S*1EPr1 zdoHMYlr>BycjTm%WeVuFd|QF8I{NPT&`fm=dITj&3(M^q ze2J{_2zB;wDME%}SzVWSW6)>1QtiX)Iiy^p2eT}Ii$E9w$5m)kv(3wSCNWq=#DaKZ zs%P`#^b7F-J0DgQ1?~2M`5ClYtYN{AlU|v4pEg4z03=g6nqH`JjQuM{k`!6jaIL_F zC;sn?1x?~uMo_DFg#ypNeie{3udcm~M&bYJ1LI zE%y}P9oCX3I1Y9yhF(y9Ix_=8L(p)EYr&|XZWCOb$7f2qX|A4aJ9bl7pt40Xr zXUT#NMBB8I@xoIGSHAZkYdCj>eEd#>a;W-?v4k%CwBaR5N>e3IFLRbDQTH#m_H+4b zk2UHVymC`%IqwtHUmpS1!1p-uQB`CW1Y!+VD!N4TT}D8(V0IOL|&R&)Rwj@n8g@=`h&z9YTPDT+R9agnwPuM!JW~=_ya~% zIJ*>$Fl;y7_`B7G4*P!kcy=MnNmR`(WS5_sRsvHF42NJ;EaDram5HwQ4Aw*qbYn0j;#)bh1lyKLg#dYjN*BMlh+fxmCL~?zB;HBWho;20WA==ci0mAqMfyG>1!HW zO7rOga-I9bvut1Ke_1eFo9tbzsoPTXDW1Si4}w3fq^Z|5LGf&egnw%DV=b11$F=P~ z(aV+j8S}m=CkI*8=RcrT>GmuYifP%hCoKY22Z4 zmu}o08h3YhcXx-v-QC??8mDn<+}+*X{+gZH-I;G^|7=1fBveS?J$27H&wV5^V^P$! z84?{UeYSmZ3M!@>UFoIN?GJT@IroYr;X@H~ax*CQ>b5|Xi9FXt5j`AwUPBq`0sWEJ z3O|k+g^JKMl}L(wfCqyMdRj9yS8ncE7nI14Tv#&(?}Q7oZpti{Q{Hw&5rN-&i|=fWH`XTQSu~1jx(hqm$Ibv zRzFW9$xf@oZAxL~wpj<0ZJ3rdPAE=0B>G+495QJ7D>=A&v^zXC9)2$$EnxQJ<^WlV zYKCHb1ZzzB!mBEW2WE|QG@&k?VXarY?umPPQ|kziS4{EqlIxqYHP!HN!ncw6BKQzKjqk!M&IiOJ9M^wc~ZQ1xoaI z;4je%ern~?qi&J?eD!vTl__*kd*nFF0n6mGEwI7%dI9rzCe~8vU1=nE&n4d&8}pdL zaz`QAY?6K@{s2x%Sx%#(y+t6qLw==>2(gb>AksEebXv=@ht>NBpqw=mkJR(c?l7vo z&cV)hxNoYPGqUh9KAKT)kc(NqekzE6(wjjotP(ac?`DJF=Sb7^Xet-A3PRl%n&zKk zruT9cS~vV1{%p>OVm1-miuKr<@rotj*5gd$?K`oteNibI&K?D63RoBjw)SommJ5<4 zus$!C8aCP{JHiFn2>XpX&l&jI7E7DcTjzuLYvON2{rz<)#$HNu(;ie-5$G<%eLKnTK7QXfn(UR(n+vX%aeS6!q6kv z!3nzY76-pdJp339zsl_%EI|;ic_m56({wdc(0C5LvLULW=&tWc5PW-4;&n+hm1m`f zzQV0T>OPSTjw=Ox&UF^y< zarsYKY8}YZF+~k70=olu$b$zdLaozBE|QE@H{_R21QlD5BilYBTOyv$D5DQZ8b1r- zIpSKX!SbA0Pb5#cT)L5!KpxX+x+8DRy&`o-nj+nmgV6-Gm%Fe91R1ca3`nt*hRS|^ z<&we;TJcUuPDqkM7k0S~cR%t7a`YP#80{BI$e=E!pY}am)2v3-Iqk2qvuAa1YM>xj#bh+H2V z{b#St2<;Gg>$orQ)c2a4AwD5iPcgZ7o_}7xhO86(JSJ(q(EWKTJDl|iBjGEMbX8|P z4PQHi+n(wZ_5QrX0?X_J)e_yGcTM#E#R^u_n8pK@l5416`c9S=q-e!%0RjoPyTliO zkp{OC@Ep^#Ig-n!C)K0Cy%8~**Vci8F1U(viN{==KU0nAg2(+K+GD_Gu#Bx!{tmUm zCwTrT(tCr6X8j43_n96H9%>>?4akSGMvgd+krS4wRexwZ1JxrJy!Uhz#yt$-=aq?A z@?*)bRZxjG9OF~7d$J0cwE_^CLceRK=LvjfH-~{S><^D;6B2&p-02?cl?|$@>`Qt$ zP*iaOxg<+(rbk>34VQDQpNQ|a9*)wScu!}<{oXC87hRPqyrNWpo?#=;1%^D2n2+C* zKKQH;?rWn-@%Y9g%NHG&lHwK9pBfV1a`!TqeU_Fv8s6_(@=RHua7`VYO|!W&WL*x= zIWE9eQaPq3zMaXuf)D0$V`RIZ74f)0P73xpeyk4)-?8j;|K%pD$eq4j2%tL=;&+E91O(2p91K|85b)GQcbRe&u6Ilu@SnE={^{Ix1Eqgv8D z4=w65+&36|;5WhBm$!n*!)ACCwT9Sip#1_z&g~E1kB=AlEhO0lu`Ls@6gw*a)lzc# zKx!fFP%eSBBs)U>xIcQKF(r_$SWD3TD@^^2Ylm=kC*tR+I@X>&SoPZdJ2fT!ysjH% z-U%|SznY8Fhsq7Vau%{Ad^Pvbf3IqVk{M2oD+w>MWimJA@VSZC$QooAO3 zC=DplXdkyl>mSp^$zk7&2+eoGQ6VVh_^E#Z3>tX7Dmi<2aqlM&YBmK&U}m>a%8)LQ z8v+c}a0QtXmyd%Kc2QNGf8TK?_EK4wtRUQ*VDnf5jHa?VvH2K(FDZOjAqYufW8oIZ z31|o~MR~T;ZS!Lz%8M0*iVARJ>_G2BXEF8(}6Dmn_rFV~5NI`lJjp`Mi~g7~P%H zO`S&-)Fngo3VXDMo7ImlaZxY^s!>2|csKca6!|m7)l^M0SQT1_L~K29%x4KV8*xiu zwP=GlyIE9YPSTC0BV`6|#)30=hJ~^aYeq7d6TNfoYUkk-^k0!(3qp(7Mo-$|48d8Z2d zrsfsRM)y$5)0G`fNq!V?qQ+nh0xwFbcp{nhW%vZ?h);=LxvM(pWd9FG$Bg1;@Bv)mKDW>AP{ol zD(R~mLzdDrBv$OSi{E%OD`Ano=F^vwc)rNb*Bg3-o)bbAgYE=M7Gj2OHY{8#pM${_^ zwkU|tnTKawxUF7vqM9UfcQ`V49zg78V%W)$#5ssR}Rj7E&p(4_ib^?9luZPJ%iJTvW&-U$nFYky>KJwHpEHHx zVEC;!ETdkCnO|${Vj#CY>LLut_+c|(hpWk8HRgMGRY%E--%oKh@{KnbQ~0GZd}{b@ z`J2qHBcqqjfHk^q=uQL!>6HSSF3LXL*cCd%opM|k#=xTShX~qcxpHTW*BI!c3`)hQq{@!7^mdUaG7sFsFYnl1%blslM;?B8Q zuifKqUAmR=>33g~#>EMNfdye#rz@IHgpM$~Z7c5@bO@S>MyFE3_F}HVNLnG0TjtXU zJeRWH^j5w_qXb$IGs+E>daTa}XPtrUnnpTRO9NEx4g6uaFEfHP9gW;xZnJi{oqAH~ z5dHS(ch3^hbvkv@u3QPLuWa}ImaElDrmIc%5HN<^bwej}3+?g) z-ai7D&6Iq_P(}k`i^4l?hRLbCb>X9iq2UYMl=`9U9Rf=3Y!gnJbr?eJqy>Zpp)m>Ae zcQ4Qfs&AaE?UDTODcEj#$_n4KeERZHx-I+E5I~E#L_T3WI3cj$5EYR75H7hy%80a8Ej?Y6hv+fR6wHN%_0$-xL!eI}fdjOK7(GdFD%`f%-qY@-i@fTAS&ETI99jUVg8 zslPSl#d4zbOcrgvopvB2c2A6r^pEr&Sa5I5%@1~BpGq`Wo|x=&)WnnQjE+)$^U-wW zr2Kv?XJby(8fcn z8JgPn)2_#-OhZ+;72R6PspMfCVvtLxFHeb7d}fo(GRjm_+R(*?9QRBr+yPF(iPO~ zA4Tp1<0}#fa{v0CU6jz}q9;!3Pew>ikG1qh$5WPRTQZ~ExQH}b1hDuzRS1}65uydS z~Te*3@?o8fih=mZ`iI!hL5iv3?VUBLQv0X zLtu58MIE7Jbm?)NFUZuMN2_~eh_Sqq*56yIo!+d_zr@^c@UwR&*j!fati$W<=rGGN zD$X`$lI%8Qe+KzBU*y3O+;f-Csr4$?3_l+uJ=K@dxOfZ?3APc5_x2R=a^kLFoxt*_ z4)nvvP+(zwlT5WYi!4l7+HKqzmXKYyM9kL5wX$dTSFSN&)*-&8Q{Q$K-})rWMin8S zy*5G*tRYNqk7&+v;@+>~EIQgf_SB;VxRTQFcm5VtqtKZ)x=?-f+%OY(VLrXb^6*aP zP&0Nu@~l2L!aF8i2!N~fJiHyxRl?I1QNjB)`uP_DuaU?2W;{?0#RGKTr2qH5QqdhK zP__ojm4WV^PUgmrV)`~f>(769t3|13DrzdDeXxqN6XA|_GK*;zHU()a(20>X{y-x| z2P6Ahq;o=)Nge`l+!+xEwY`7Q(8V=93A9C+WS^W%p&yR)eiSX+lp)?*7&WSYSh4i> zJa6i5T9o;Cd5z%%?FhB?J{l+t_)c&_f86gZMU{HpOA=-KoU5lIL#*&CZ_66O5$3?# ztgjGLo`Y7bj&eYnK#5x1trB_6tpu4$EomotZLb*9l6P(JmqG`{z$?lNKgq?GAVhkA zvw!oFhLyX=$K=jTAMwDQ)E-8ZW5$X%P2$YB5aq!VAnhwGv$VR&;Ix#fu%xlG{|j_K zbEYL&bx%*YpXcaGZj<{Y{k@rsrFKh7(|saspt?OxQ~oj_6En(&!rTZPa7fLCEU~mA zB7tbVs=-;cnzv*#INgF_9f3OZhp8c5yk!Dy1+`uA7@eJfvd~g34~wKI1PW%h(y&nA zRwMni12AHEw36)C4Tr-pt6s82EJa^8N#bjy??F*rg4fS@?6^MbiY3;7x=gd~G|Hi& zwmG+pAn!aV>>nNfP7-Zn8BLbJm&7}&ZX+$|z5*5{{F}BRSxN=JKZTa#{ut$v0Z0Fs za@UjXo#3!wACv+p9k*^9^n+(0(YKIUFo`@ib@bjz?Mh8*+V$`c%`Q>mrc5bs4aEf4 zh0qtL1qNE|xQ9JrM}qE>X>Y@dQ?%` zBx(*|1FMzVY&~|dE^}gHJ37O9bjnk$d8vKipgcf+As(kt2cbxAR3^4d0?`}}hYO*O z{+L&>G>AYaauAxE8=#F&u#1YGv%`d*v+EyDcU2TnqvRE33l1r}p#Vmcl%n>NrYOqV z2Car_^^NsZ&K=a~bj%SZlfxzHAxX$>=Q|Zi;E0oyfhgGgqe1Sd5-E$8KV9=`!3jWZCb2crb;rvQ##iw}xm7Da za!H${ls5Ihwxkh^D)M<4Yy3bp<-0a+&KfV@CVd9X6Q?v)$R3*rfT@jsedSEhoV(vqv?R1E8oWV;_{l_+_6= zLjV^-bZU$D_ocfSpRxDGk*J>n4G6s-e>D8JK6-gA>aM^Hv8@)txvKMi7Pi#DS5Y?r zK0%+L;QJdrIPXS2 ztjWAxkSwt2xG$L)Zb7F??cjs!KCTF+D{mZ5e0^8bdu_NLgFHTnO*wx!_8#}NO^mu{FaYeCXGjnUgt_+B-Ru!2_Ue-0UPg2Y)K3phLmR<4 zqUCWYX!KDU!jYF6c?k;;vF@Qh^q(PWwp1ez#I+0>d7V(u_h|L+kX+MN1f5WqMLn!L z!c(pozt7tRQi&duH8n=t-|d)c^;%K~6Kpyz(o53IQ_J+aCapAif$Ek#i0F9U>i+94 zFb=OH5(fk-o`L(o|DyQ(hlozl*2cu#)Y(D*zgNMi1Z!DTex#w#)x(8A-T=S+eByJW z%-k&|XhdZOWjJ&(FTrZNWRm^pHEot_MRQ_?>tKQ&MB~g(&D_e>-)u|`Ot(4j=UT6? zQ&YMi2UnCKlBpwltP!}8a2NJ`LlfL=k8SQf69U)~=G;bq9<2GU&Q#cHwL|o4?ah1` z;fG)%t0wMC;DR?^!jCoKib_iiIjsxCSxRUgJDCE%0P;4JZhJCy)vR1%zRl>K?V6#) z2lDi*W3q9rA zo;yvMujs+)a&00~W<-MNj=dJ@4%tccwT<@+c$#CPR%#aE#Dra+-5eSDl^E>is2v^~ z8lgRwkpeU$|1LW4yFwA{PQ^A{5JY!N5PCZ=hog~|FyPPK0-i;fCl4a%1 z?&@&E-)b4cK)wjXGq|?Kqv0s7y~xqvSj-NpOImt{Riam*Z!wz-coZIMuQU>M%6ben z>P@#o^W;fizVd#?`eeEPs#Gz^ySqJn+~`Pq%-Ee6*X+E>!PJGU#rs6qu0z5{+?`-N zxf1#+JNk7e6AoJTdQwxs&GMTq?Djch_8^xL^A;9XggtGL>!@0|BRuIdE&j$tzvt7I zr@I@0<0io%lpF697s1|qNS|BsA>!>-9DVlgGgw2;;k;=7)3+&t!);W3ulPgR>#JiV zUerO;WxuJqr$ghj-veVGfKF?O7si#mzX@GVt+F&atsB@NmBoV4dK|!owGP005$7LN7AqCG(S+={YA- zn#I{UoP_$~Epc=j78{(!2NLN)3qSm-1&{F&1z4Dz&7Mj_+SdlR^Q5{J=r822d4A@?Rj~xATaWewHUOus{*C|KoH`G zHB8SUT06GpSt)}cFJ18!$Kp@r+V3tE_L^^J%9$&fcyd_AHB)WBghwqBEWW!oh@StV zDrC?ttu4#?Aun!PhC4_KF1s2#kvIh~zds!y9#PIrnk9BWkJpq}{Hlqi+xPOR&A1oP zB0~1tV$Zt1pQuHpJw1TAOS=3$Jl&n{n!a+&SgYVe%igUtvE>eHqKY0`e5lwAf}2x( zP>9Wz+9uirp7<7kK0m2&Y*mzArUx%$CkV661=AIAS=V=|xY{;$B7cS5q0)=oq0uXU z_roo90&gHSfM6@6kmB_FJZ)3y_tt0}7#PA&pWo@_qzdIMRa-;U*Dy>Oo#S_n61Fn! z%mrH%tRmvQvg%UqN_2(C#LSxgQ>m}FKLGG=uqJQuSkk=S@c~QLi4N+>lr}QcOuP&% zQCP^cRk&rk-@lpa0^Lcvdu`F*qE)-0$TnxJlwZf|dP~s8cjhL%>^+L~{umxl5Xr6@ z^7zVKiN1Xg;-h+kr4Yt2BzjZs-Mo54`pDbLc}fWq{34=6>U9@sBP~iWZE`+FhtU|x zTV}ajn*Hc}Y?3agQ+bV@oIRm=qAu%|zE;hBw7kCcDx{pm!_qCxfPX3sh5^B$k_2d` z6#rAeUZC;e-LuMZ-f?gHeZogOa*mE>ffs+waQ+fQl4YKoAyZii_!O0;h55EMzD{;) z8lSJvv((#UqgJ?SCQFqJ-UU?2(0V{;7zT3TW`u6GH6h4m3}SuAAj_K(raGBu>|S&Q zZGL?r9@caTbmRm7p=&Tv?Y1)60*9At38w)$(1c?4cpFY2RLyw9c<{OwQE{b@WI}FQ zTT<2HOF4222d%k70yL~x_d#6SNz`*%@4++8gYQ8?yq0T@w~bF@aOHL2)T4xj`AVps9k z?m;<2ClJh$B6~fOYTWIV*T9y1BpB1*C?dgE{%lVtIjw>4MK{wP6OKTb znbPWrkZjYCbr`GGa%Xo0h;iFPNJBI3fK5`wtJV?wq_G<_PZ<`eiKtvN$IKfyju*^t zXc}HNg>^PPZ16m6bfTpmaW5=qoSsj>3)HS}teRa~qj+Y}mGRE?cH!qMDBJ8 zJB!&-=MG8Tb;V4cZjI_#{>ca0VhG_P=j0kcXVX5)^Sdpk+LKNv#yhpwC$k@v^Am&! z_cz2^4Cc{_BC!K#zN!KEkPzviUFPJ^N_L-kHG6}(X#$>Q=9?!{$A(=B3)P?PkxG9gs#l! zo6TOHo$F|IvjTC3MW%XrDoc7;m-6wb9mL(^2(>PQXY53hE?%4FW$rTHtN`!VgH72U zRY)#?Y*pMA<)x3B-&fgWQ(TQ6S6nUeSY{9)XOo_k=j$<*mA=f+ghSALYwBw~!Egn!jtjubOh?6Cb-Zi3IYn*fYl()^3u zRiX0I{5QaNPJ9w{yh4(o#$geO7b5lSh<5ZaRg9_=aFdZjxjXv(_SCv^v-{ZKQFtAA}kw=GPC7l81GY zeP@0Da{aR#{6`lbI0ON0y#K=t|L*}MG_HSl$e{U;v=BSs{SU3(e*qa(l%rD;(zM^3 zrRgN3M#Sf(Cr9>v{FtB`8JBK?_zO+~{H_0$lLA!l{YOs9KQd4Zt<3*Ns7dVbT{1Ut z?N9{XkN(96?r(4BH~3qeiJ_CAt+h1}O_4IUF$S(5EyTyo=`{^16P z=VhDY!NxkDukQz>T`0*H=(D3G7Np*2P`s(6M*(*ZJa;?@JYj&_z`d5bap=KK37p3I zr5#`%aC)7fUo#;*X5k7g&gQjxlC9CF{0dz*m2&+mf$Sc1LnyXn9lpZ!!Bl!@hnsE5px};b-b-`qne0Kh;hziNC zXV|zH%+PE!2@-IrIq!HM2+ld;VyNUZiDc@Tjt|-1&kq}>muY;TA3#Oy zWdYGP3NOZWSWtx6?S6ES@>)_Yz%%nLG3P>Z7`SrhkZ?shTfrHkYI;2zAn8h65wV3r z^{4izW-c9!MTge3eN=~r5aTnz6*6l#sD68kJ7Nv2wMbL~Ojj0H;M`mAvk*`Q!`KI? z7nCYBqbu$@MSNd+O&_oWdX()8Eh|Z&v&dJPg*o-sOBb2hriny)< zd(o&&kZM^NDtV=hufp8L zCkKu7)k`+czHaAU567$?GPRGdkb4$37zlIuS&<&1pgArURzoWCbyTEl9OiXZBn4p<$48-Gekh7>e)v*?{9xBt z=|Rx!@Y3N@ffW5*5!bio$jhJ7&{!B&SkAaN`w+&3x|D^o@s{ZAuqNss8K;211tUWIi1B!%-ViYX+Ys6w)Q z^o1{V=hK#+tt&aC(g+^bt-J9zNRdv>ZYm9KV^L0y-yoY7QVZJ_ivBS02I|mGD2;9c zR%+KD&jdXjPiUv#t1VmFOM&=OUE2`SNm4jm&a<;ZH`cYqBZoAglCyixC?+I+}*ScG#;?SEAFob{v0ZKw{`zw*tX}<2k zoH(fNh!>b5w8SWSV}rQ*E24cO=_eQHWy8J!5;Y>Bh|p;|nWH|nK9+ol$k`A*u*Y^Uz^%|h4Owu}Cb$zhIxlVJ8XJ0xtrErT zcK;34CB;ohd|^NfmVIF=XlmB5raI}nXjFz;ObQ4Mpl_`$dUe7sj!P3_WIC~I`_Xy@ z>P5*QE{RSPpuV=3z4p3}dh>Dp0=We@fdaF{sJ|+_E*#jyaTrj-6Y!GfD@#y@DUa;& zu4Iqw5(5AamgF!2SI&WT$rvChhIB$RFFF|W6A>(L9XT{0%DM{L`knIQPC$4F`8FWb zGlem_>>JK-Fib;g*xd<-9^&_ue95grYH>5OvTiM;#uT^LVmNXM-n8chJBD2KeDV7t zbnv3CaiyN>w(HfGv86K5MEM{?f#BTR7**smpNZ}ftm+gafRSt=6fN$(&?#6m3hF!>e$X)hFyCF++Qvx(<~q3esTI zH#8Sv!WIl2<&~=B)#sz1x2=+KTHj=0v&}iAi8eD=M->H|a@Qm|CSSzH#eVIR3_Tvu zG8S**NFbz%*X?DbDuP(oNv2;Lo@#_y4k$W+r^#TtJ8NyL&&Rk;@Q}~24`BB)bgwcp z=a^r(K_NEukZ*|*7c2JKrm&h&NP)9<($f)eTN}3|Rt`$5uB0|!$Xr4Vn#i;muSljn zxG?zbRD(M6+8MzGhbOn%C`M#OcRK!&ZHihwl{F+OAnR>cyg~No44>vliu$8^T!>>*vYQJCJg=EF^lJ*3M^=nGCw`Yg@hCmP(Gq^=eCEE1!t-2>%Al{w@*c% zUK{maww*>K$tu;~I@ERb9*uU@LsIJ|&@qcb!&b zsWIvDo4#9Qbvc#IS%sV1_4>^`newSxEcE08c9?rHY2%TRJfK2}-I=Fq-C)jc`gzV( zCn?^noD(9pAf2MP$>ur0;da`>Hr>o>N@8M;X@&mkf;%2A*2CmQBXirsJLY zlX21ma}mKH_LgYUM-->;tt;6F?E5=fUWDwQhp*drQ%hH0<5t2m)rFP%=6aPIC0j$R znGI0hcV~}vk?^&G`v~YCKc7#DrdMM3TcPBmxx#XUC_JVEt@k=%3-+7<3*fTcQ>f~?TdLjv96nb66xj=wVQfpuCD(?kzs~dUV<}P+Fpd)BOTO^<*E#H zeE80(b~h<*Qgez(iFFOkl!G!6#9NZAnsxghe$L=Twi^(Q&48 zD0ohTj)kGLD){xu%pm|}f#ZaFPYpHtg!HB30>F1c=cP)RqzK2co`01O5qwAP zUJm0jS0#mci>|Nu4#MF@u-%-4t>oUTnn_#3K09Hrwnw13HO@9L;wFJ*Z@=gCgpA@p zMswqk;)PTXWuMC-^MQxyNu8_G-i3W9!MLd2>;cM+;Hf&w| zLv{p*hArp9+h2wsMqT5WVqkkc0>1uokMox{AgAvDG^YJebD-czexMB!lJKWllLoBI zetW2;;FKI1xNtA(ZWys!_un~+834+6y|uV&Lo%dKwhcoDzRADYM*peh{o`-tHvwWIBIXW`PKwS3|M>CW37Z2dr!uJWNFS5UwY4;I zNIy1^sr+@8Fob%DHRNa&G{lm?KWU7sV2x9(Ft5?QKsLXi!v6@n&Iyaz5&U*|hCz+d z9vu60IG<v6+^ZmBs_aN!}p|{f(ikVl&LcB+UY;PPz* zj84Tm>g5~-X=GF_4JrVmtEtm=3mMEL1#z+pc~t^Iify^ft~cE=R0TymXu*iQL+XLX zdSK$~5pglr3f@Lrcp`>==b5Z6r7c=p=@A5nXNacsPfr(5m;~ks@*Wu7A z%WyY$Pt*RAKHz_7cghHuQqdU>hq$vD?plol_1EU(Fkgyo&Q2&2e?FT3;H%!|bhU~D z>VX4-6}JLQz8g3%Bq}n^NhfJur~v5H0dbB^$~+7lY{f3ES}E?|JnoLsAG%l^%eu_PM zEl0W(sbMRB3rFeYG&tR~(i2J0)RjngE`N_Jvxx!UAA1mc7J>9)`c=`}4bVbm8&{A` z3sMPU-!r-8de=P(C@7-{GgB<5I%)x{WfzJwEvG#hn3ict8@mexdoTz*(XX!C&~}L* z^%3eYQ8{Smsmq(GIM4d5ilDUk{t@2@*-aevxhy7yk(wH?8yFz%gOAXRbCYzm)=AsM z?~+vo2;{-jkA%Pqwq&co;|m{=y}y2lN$QPK>G_+jP`&?U&Ubq~T`BzAj1TlC`%8+$ zzdwNf<3suPnbh&`AI7RAYuQ<#!sD|A=ky2?hca{uHsB|0VqShI1G3lG5g}9~WSvy4 zX3p~Us^f5AfXlBZ0hA;mR6aj~Q8yb^QDaS*LFQwg!!<|W!%WX9Yu}HThc7>oC9##H zEW`}UQ%JQ38UdsxEUBrA@=6R-v1P6IoIw8$8fw6F{OSC7`cOr*u?p_0*Jvj|S)1cd z-9T);F8F-Y_*+h-Yt9cQQq{E|y^b@r&6=Cd9j0EZL}Pj*RdyxgJentY49AyC@PM<< zl&*aq_ubX%*pqUkQ^Zsi@DqhIeR&Ad)slJ2g zmeo&+(g!tg$z1ao1a#Qq1J022mH4}y?AvWboI4H028;trScqDQrB36t!gs|uZS9}KG0}DD$ zf2xF}M*@VJSzEJ5>ucf+L_AtN-Ht=34g&C?oPP>W^bwoigIncKUyf61!ce!2zpcNT zj&;rPGI~q2!Sy>Q7_lRX*DoIs-1Cei=Cd=+Xv4=%bn#Yqo@C=V`|QwlF0Y- zONtrwpHQ##4}VCL-1ol(e<~KU9-ja^kryz!g!})y-2S5z2^gE$Isj8l{%tF=Rzy`r z^RcP7vu`jHgHLKUE957n3j+BeE(bf;f)Zw($XaU6rZ26Upl#Yv28=8Y`hew{MbH>* z-sGI6dnb5D&dUCUBS`NLAIBP!Vi!2+~=AU+)^X^IpOEAn#+ab=`7c z%7B|mZ>wU+L;^&abXKan&N)O;=XI#dTV|9OMYxYqLbtT#GY8PP$45Rm2~of+J>>HIKIVn(uQf-rp09_MwOVIp@6!8bKV(C#(KxcW z;Pesq(wSafCc>iJNV8sg&`!g&G55<06{_1pIoL`2<7hPvAzR1+>H6Rx0Ra%4j7H-<-fnivydlm{TBr06;J-Bq8GdE^Amo)ptV>kS!Kyp*`wUx=K@{3cGZnz53`+C zLco1jxLkLNgbEdU)pRKB#Pq(#(Jt>)Yh8M?j^w&RPUueC)X(6`@@2R~PV@G(8xPwO z^B8^+`qZnQr$8AJ7<06J**+T8xIs)XCV6E_3W+al18!ycMqCfV>=rW0KBRjC* zuJkvrv;t&xBpl?OB3+Li(vQsS(-TPZ)Pw2>s8(3eF3=n*i0uqv@RM^T#Ql7(Em{(~%f2Fw|Reg@eSCey~P zBQlW)_DioA*yxxDcER@_=C1MC{UswPMLr5BQ~T6AcRyt0W44ffJG#T~Fk}wU^aYoF zYTayu-s?)<`2H(w+1(6X&I4?m3&8sok^jpXBB<|ZENso#?v@R1^DdVvKoD?}3%@{}}_E7;wt9USgrfR3(wabPRhJ{#1es81yP!o4)n~CGsh2_Yj2F^z|t zk((i&%nDLA%4KFdG96pQR26W>R2^?C1X4+a*hIzL$L=n4M7r$NOTQEo+k|2~SUI{XL{ynLSCPe%gWMMPFLO{&VN2pom zBUCQ(30qj=YtD_6H0-ZrJ46~YY*A;?tmaGvHvS^H&FXUG4)%-a1K~ly6LYaIn+4lG zt=wuGLw!%h=Pyz?TP=?6O-K-sT4W%_|Nl~;k~YA^_`gqfe{Xw=PWn#9f1mNz)sFuL zJbrevo(DPgpirvGMb6ByuEPd=Rgn}fYXqeUKyM+!n(cKeo|IY%p!#va6`D8?A*{u3 zEeWw0*oylJ1X!L#OCKktX2|>-z3#>`9xr~azOH+2dXHRwdfnpri9|xmK^Q~AuY!Fg z`9Xx?hxkJge~)NVkPQ(VaW(Ce2pXEtgY*cL8i4E)mM(iz_vdm|f@%cSb*Lw{WbShh41VGuplex9E^VvW}irx|;_{VK=N_WF39^ zH4<*peWzgc)0UQi4fBk2{FEzldDh5+KlRd!$_*@eYRMMRb1gU~9lSO_>Vh-~q|NTD zL}X*~hgMj$*Gp5AEs~>Bbjjq7G>}>ki1VxA>@kIhLe+(EQS0mjNEP&eXs5)I;7m1a zmK0Ly*!d~Dk4uxRIO%iZ!1-ztZxOG#W!Q_$M7_DKND0OwI+uC;PQCbQ#k#Y=^zQve zTZVepdX>5{JSJb;DX3%3g42Wz2D@%rhIhLBaFmx#ZV8mhya}jo1u{t^tzoiQy=jJp zjY2b7D2f$ZzJx)8fknqdD6fd5-iF8e(V}(@xe)N=fvS%{X$BRvW!N3TS8jn=P%;5j zShSbzsLs3uqycFi3=iSvqH~}bQn1WQGOL4?trj(kl?+q2R23I42!ipQ&`I*&?G#i9 zWvNh8xoGKDt>%@i0+}j?Ykw&_2C4!aYEW0^7)h2Hi7$;qgF3;Go?bs=v)kHmvd|`R z%(n94LdfxxZ)zh$ET8dH1F&J#O5&IcPH3=8o;%>OIT6w$P1Yz4S!}kJHNhMQ1(prc zM-jSA-7Iq=PiqxKSWb+YbLB-)lSkD6=!`4VL~`ExISOh2ud=TI&SKfR4J08Bad&rj zcXxMpcNgOB?w$~L7l^wPcXxw$0=$oV?)`I44)}b#ChS`_lBQhvb6ks?HDr3tFgkg&td19?b8=!sETXtp=&+3T$cCwZe z0nAET-7561gsbBws$TVjP7QxY(NuBYXVn9~9%vyN-B#&tJhWgtL1B<%BTS*-2$xB` zO)cMDHoWsm%JACZF--Pa7oP;f!n%p`*trlpvZ!HKoB={l+-(8O;;eYv2A=ra z3U7rSMCkP_6wAy`l|Se(&5|AefXvV1E#XA(LT!% zjj4|~xlZ-kPLNeQLFyXb%$K}YEfCBvHA-Znw#dZSI6V%3YD{Wj2@utT5Hieyofp6Qi+lz!u)htnI1GWzvQsA)baEuw9|+&(E@p8M+#&fsX@Kf`_YQ>VM+40YLv`3-(!Z7HKYg@+l00WGr779i-%t`kid%e zDtbh8UfBVT3|=8FrNian@aR3*DTUy&u&05x%(Lm3yNoBZXMHWS7OjdqHp>cD>g!wK z#~R{1`%v$IP;rBoP0B0P><;dxN9Xr+fp*s_EK3{EZ94{AV0#Mtv?;$1YaAdEiq5)g zYME;XN9cZs$;*2p63Q9^x&>PaA1p^5m7|W?hrXp2^m;B@xg0bD?J;wIbm6O~Nq^^K z2AYQs@7k)L#tgUkTOUHsh&*6b*EjYmwngU}qesKYPWxU-z_D> zDWr|K)XLf_3#k_9Rd;(@=P^S^?Wqlwert#9(A$*Y$s-Hy)BA0U0+Y58zs~h=YtDKxY0~BO^0&9{?6Nny;3=l59(6ec9j(79M?P1cE zex!T%$Ta-KhjFZLHjmPl_D=NhJULC}i$}9Qt?nm6K6-i8&X_P+i(c*LI3mtl3 z*B+F+7pnAZ5}UU_eImDj(et;Khf-z^4uHwrA7dwAm-e4 zwP1$Ov3NP5ts+e(SvM)u!3aZMuFQq@KE-W;K6 zag=H~vzsua&4Sb$4ja>&cSJ)jjVebuj+?ivYqrwp3!5>ul`B*4hJGrF;!`FaE+wKo z#};5)euvxC1zX0-G;AV@R(ZMl=q_~u8mQ5OYl;@BAkt)~#PynFX#c1K zUQ1^_N8g+IZwUl*n0Bb-vvliVtM=zuMGU-4a8|_8f|2GEd(2zSV?aSHUN9X^GDA8M zgTZW06m*iAy@7l>F3!7+_Y3mj^vjBsAux3$%U#d$BT^fTf-7{Y z_W0l=7$ro5IDt7jp;^cWh^Zl3Ga1qFNrprdu#g=n9=KH!CjLF#ucU5gy6*uASO~|b z7gcqm90K@rqe({P>;ww_q%4}@bq`ST8!0{V08YXY)5&V!>Td)?j7#K}HVaN4FU4DZ z%|7OppQq-h`HJ;rw-BAfH* z1H$ufM~W{%+b@9NK?RAp-$(P0N=b<(;wFbBN0{u5vc+>aoZ|3&^a866X@el7E8!E7 z=9V(Ma**m_{DKZit2k;ZOINI~E$|wO99by=HO{GNc1t?nl8soP@gxk8)WfxhIoxTP zoO`RA0VCaq)&iRDN9yh_@|zqF+f07Esbhe!e-j$^PS57%mq2p=+C%0KiwV#t^%_hH zoO?{^_yk5x~S)haR6akK6d|#2TN& zfWcN zc7QAWl)E9`!KlY>7^DNw$=yYmmRto>w0L(~fe?|n6k2TBsyG@sI)goigj=mn)E)I* z4_AGyEL7?(_+2z=1N@D}9$7FYdTu;%MFGP_mEJXc2OuXEcY1-$fpt8m_r2B|<~Xfs zX@3RQi`E-1}^9N{$(|YS@#{ZWuCxo)91{k>ESD54g_LYhm~vlOK_CAJHeYFfuIVB^%cqCfvpy#sU8Do8u}# z>>%PLKOZ^+$H54o@brtL-hHorSKcsjk_ZibBKBgyHt~L z=T6?e0oLX|h!Z3lbkPMO27MM?xn|uZAJwvmX?Yvp#lE3sQFY)xqet>`S2Y@1t)Z*& z;*I3;Ha8DFhk=YBt~{zp=%%*fEC}_8?9=(-k7HfFeN^GrhNw4e?vx*#oMztnO*&zY zmRT9dGI@O)t^=Wj&Og1R3b%(m*kb&yc;i`^-tqY9(0t!eyOkH<$@~1lXmm!SJllE_ zr~{a&w|8*LI>Z^h!m%YLgKv06Js7j7RaoX}ZJGYirR<#4Mghd{#;38j3|V+&=ZUq#1$ zgZb-7kV)WJUko?{R`hpSrC;w2{qa`(Z4gM5*ZL`|#8szO=PV^vpSI-^K_*OQji^J2 zZ_1142N}zG$1E0fI%uqHOhV+7%Tp{9$bAR=kRRs4{0a`r%o%$;vu!_Xgv;go)3!B#;hC5qD-bcUrKR&Sc%Zb1Y($r78T z=eG`X#IpBzmXm(o6NVmZdCQf6wzqawqI63v@e%3TKuF!cQ#NQbZ^?6K-3`_b=?ztW zA>^?F#dvVH=H-r3;;5%6hTN_KVZ=ps4^YtRk>P1i>uLZ)Ii2G7V5vy;OJ0}0!g>j^ z&TY&E2!|BDIf1}U(+4G5L~X6sQ_e7In0qJmWYpn!5j|2V{1zhjZt9cdKm!we6|Pp$ z07E+C8=tOwF<<}11VgVMzV8tCg+cD_z?u+$sBjwPXl^(Ge7y8-=c=fgNg@FxI1i5Y-HYQMEH z_($je;nw`Otdhd1G{Vn*w*u@j8&T=xnL;X?H6;{=WaFY+NJfB2(xN`G)LW?4u39;x z6?eSh3Wc@LR&yA2tJj;0{+h6rxF zKyHo}N}@004HA(adG~0solJ(7>?LoXKoH0~bm+xItnZ;3)VJt!?ue|~2C=ylHbPP7 zv2{DH()FXXS_ho-sbto)gk|2V#;BThoE}b1EkNYGT8U#0ItdHG>vOZx8JYN*5jUh5Fdr9#12^ zsEyffqFEQD(u&76zA^9Jklbiz#S|o1EET$ujLJAVDYF znX&4%;vPm-rT<8fDutDIPC@L=zskw49`G%}q#l$1G3atT(w70lgCyfYkg7-=+r7$%E`G?1NjiH)MvnKMWo-ivPSQHbk&_l5tedNp|3NbU^wk0SSXF9ohtM zUqXiOg*8ERKx{wO%BimK)=g^?w=pxB1Vu_x<9jKOcU7N;(!o3~UxyO+*ZCw|jy2}V*Z22~KhmvxoTszc+#EMWXTM6QF*ks% zW47#2B~?wS)6>_ciKe1Fu!@Tc6oN7e+6nriSU;qT7}f@DJiDF@P2jXUv|o|Wh1QPf zLG31d>@CpThA+Ex#y)ny8wkC4x-ELYCXGm1rFI=1C4`I5qboYgDf322B_Nk@#eMZ% znluCKW2GZ{r9HR@VY`>sNgy~s+D_GkqFyz6jgXKD)U|*eKBkJRRIz{gm3tUd*yXmR z(O4&#ZA*us6!^O*TzpKAZ#}B5@}?f=vdnqnRmG}xyt=)2o%<9jj>-4wLP1X-bI{(n zD9#|rN#J;G%LJ&$+Gl2eTRPx6BQC6Uc~YK?nMmktvy^E8#Y*6ZJVZ>Y(cgsVnd!tV z!%twMNznd)?}YCWyy1-#P|2Fu%~}hcTGoy>_uawRTVl=(xo5!%F#A38L109wyh@wm zdy+S8E_&$Gjm=7va-b7@Hv=*sNo0{i8B7=n4ex-mfg`$!n#)v@xxyQCr3m&O1Jxg! z+FXX^jtlw=utuQ+>Yj$`9!E<5-c!|FX(~q`mvt6i*K!L(MHaqZBTtuSA9V~V9Q$G? zC8wAV|#XY=;TQD#H;;dcHVb9I7Vu2nI0hHo)!_{qIa@|2}9d ztpC*Q{4Py~2;~6URN^4FBCBip`QDf|O_Y%iZyA0R`^MQf$ce0JuaV(_=YA`knEMXw zP6TbjYSGXi#B4eX=QiWqb3bEw-N*a;Yg?dsVPpeYFS*&AsqtW1j2D$h$*ZOdEb$8n0 zGET4Igs^cMTXWG{2#A7w_usx=KMmNfi4oAk8!MA8Y=Rh9^*r>jEV(-{I0=rc);`Y) zm+6KHz-;MIy|@2todN&F+Yv1e&b&ZvycbTHpDoZ>FIiUn+M-=%A2C(I*^Yx@VKf(Z zxJOny&WoWcyKodkeN^5))aV|-UBFw{?AGo?;NNFFcKzk+6|gYfA#FR=y@?;3IoQ zUMI=7lwo9gV9fRvYi}Nd)&gQw7(K3=a0#p27u6Q)7JlP#A)piUUF8B3Li&38Xk$@| z9OR+tU~qgd3T3322E))eV)hAAHYIj$TmhH#R+C-&E-}5Qd{3B}gD{MXnsrS;{Erv1 z6IyQ=S2qD>Weqqj#Pd65rDSdK54%boN+a?=CkR|agnIP6;INm0A*4gF;G4PlA^3%b zN{H%#wYu|!3fl*UL1~f+Iu|;cqDax?DBkZWSUQodSDL4Es@u6zA>sIm>^Aq-&X#X8 zI=#-ucD|iAodfOIY4AaBL$cFO@s(xJ#&_@ZbtU+jjSAW^g;_w`FK%aH_hAY=!MTjI zwh_OEJ_25zTQv$#9&u0A11x_cGd92E74AbOrD`~f6Ir9ENNQAV2_J2Ig~mHWhaO5a zc>fYG$zke^S+fBupw+klDkiljJAha z6DnTemhkf>hv`8J*W_#wBj-2w(cVtXbkWWtE(3j@!A-IfF?`r$MhVknTs3D1N`rYN zKth9jZtX#>v#%U@^DVN!;ni#n1)U&H_uB{6pcq7$TqXJX!Q0P7U*JUZyclb~)l*DS zOLpoQfW_3;a0S$#V0SOwVeeqE$Hd^L`$;l_~2giLYd?7!gUYIpOs!jqSL~pI)4`YuB_692~A z^T#YYQ_W3Rakk}$SL&{`H8mc{>j+3eKprw6BK`$vSSIn;s31M~YlJLApJ)+Gi1{^- zw96WnT9M0Vr_D=e=a}${raR{(35Q!g+8`}vOFj1e&Or(_wp2U2aVQP0_jP57 z2(R4E(E$n!xl<}Zx38wO;27wuQ`P#_j!}L2 z2qr;As4D4n2X$-Jd_-!fsbu_D(64i;c4cJnP576x_>Q4WNushFwkBV!kVd(AYFXe{ zaqO5`Qfr!#ETmE(B;u_&FITotv~W}QYFCI!&ENKIb1p4fg*Yv1)EDMb==EjHHWM#{ zGMpqb2-LXdHB@D~pE3|+B392Gh4q)y9jBd$a^&cJM60VEUnLtHQD5i-X6PVF>9m_k zDvG3P(?CzdaIrC8s4cu~N9MEb!Tt(g*GK~gIp1Gyeaw3b7#YPx_1T6i zRi#pAMr~PJKe9P~I+ARa$a!K~)t(4LaVbjva1yd;b1Yz2$7MMc`aLmMl(a^DgN(u? zq2o9&Gif@Tq~Yq+qDfx^F*nCnpuPv%hRFc$I!p74*quLt^M}D_rwl10uMTr!)(*=7 zSC5ea@#;l(h87k4T4x)(o^#l76P-GYJA(pOa&F9YT=fS<*O{4agzba^dIrh0hjls<~APlIz9{ zgRY{OMv2s|`;VCoYVj?InYoq^QWuA&*VDyOn@pPvK8l~g#1~~MGVVvtLDt}>id_Z` zn(ihfL?Y}Y4YX335m*Xx(y+bbukchHrM zycIGp#1*K3$!(tgTsMD2VyUSg^yvCwB8*V~sACE(yq2!MS6f+gsxv^GR|Q7R_euYx z&X+@@H?_oQddGxJYS&ZG-9O(X+l{wcw;W7srpYjZZvanY(>Q1utSiyuuonkjh5J0q zGz6`&meSuxixIPt{UoHVupUbFKIA+3V5(?ijn}(C(v>=v?L*lJF8|yRjl-m#^|krg zLVbFV6+VkoEGNz6he;EkP!Z6|a@n8?yCzX9>FEzLnp21JpU0x!Qee}lwVKA})LZJq zlI|C??|;gZ8#fC3`gzDU%7R87KZyd)H__0c^T^$zo@TBKTP*i{)Gp3E0TZ}s3mKSY zix@atp^j#QnSc5K&LsU38#{lUdwj%xF zcx&l^?95uq9on1m*0gp$ruu||5MQo)XaN>|ngV5Jb#^wWH^5AdYcn_1>H~XtNwJd3 zd9&?orMSSuj=lhO?6)Ay7;gdU#E}pTBa5wFu`nejq##Xd71BHzH2XqLA5 zeLEo;9$}~u0pEu@(?hXB_l;{jQ=7m?~mwj-ME~Tw-OHPrR7K2Xq9eCNwQO$hR z3_A?=`FJctNXA#yQEorVoh{RWxJbdQga zU%K##XEPgy?E|K(=o#IPgnbk7E&5%J=VHube|2%!Qp}@LznjE%VQhJ?L(XJOmFVY~ zo-az+^5!Ck7Lo<7b~XC6JFk>17*_dY;=z!<0eSdFD2L?CSp_XB+?;N+(5;@=_Ss3& zXse>@sA7hpq;IAeIp3hTe9^$DVYf&?)={zc9*hZAV)|UgKoD!1w{UVo8D)Htwi8*P z%#NAn+8sd@b{h=O)dy9EGKbpyDtl@NBZw0}+Wd=@65JyQ2QgU}q2ii;ot1OsAj zUI&+Pz+NvuRv#8ugesT<<@l4L$zso0AQMh{we$tkeG*mpLmOTiy8|dNYhsqhp+q*yfZA`Z)UC*(oxTNPfOFk3RXkbzAEPofVUy zZ3A%mO?WyTRh@WdXz+zD!ogo}gbUMV!YtTNhr zrt@3PcP%5F;_SQ>Ui`Gq-lUe&taU4*h2)6RDh@8G1$o!){k~3)DT87%tQeHYdO?B` zAmoJvG6wWS?=0(Cj?Aqj59`p(SIEvYyPGJ^reI z`Hr?3#U2zI7k0=UmqMD35l`>3xMcWlDv$oo6;b`dZq3d!~)W z=4Qk)lE8&>#HV>?kRLOHZYz83{u7?^KoXmM^pazj8`7OwQ=5I!==; zA!uN`Q#n=Drmzg}@^nG!mJp9ml3ukWk96^6*us*;&>s+7hWfLXtl?a}(|-#=P12>A zon1}yqh^?9!;on?tRd6Fk0knQSLl4vBGb87A_kJNDGyrnpmn48lz_%P{* z_G*3D#IR<2SS54L5^h*%=)4D9NPpji7DZ5&lHD|99W86QN_(|aJ<5C~PX%YB`Qt_W z>jF_Os@kI6R!ub4n-!orS(G6~mKL7()1g=Lf~{D!LR7#wRHfLxTjYr{*c{neyhz#U zbm@WBKozE+kTd+h-mgF+ELWqTKin57P;0b){ zii5=(B%S(N!Z=rAFGnM6iePtvpxB_Q9-oq_xH!URn2_d-H~i;lro8r{-g!k-Ydb6_w5K@FOV?zPF_hi z%rlxBv$lQi%bjsu^7KT~@u#*c$2-;AkuP)hVEN?W5MO8C9snj*EC&|M!aK6o12q3+ z8e?+dH17E!A$tRlbJW~GtMDkMPT=m1g-v67q{sznnWOI$`g(8E!Pf!#KpO?FETxLK z2b^8^@mE#AR1z(DT~R3!nnvq}LG2zDGoE1URR=A2SA z%lN$#V@#E&ip_KZL}Q6mvm(dsS?oHoRf8TWL~1)4^5<3JvvVbEsQqSa3(lF*_mA$g zv`LWarC79G)zR0J+#=6kB`SgjQZ2460W zN%lZt%M@=EN>Wz4I;eH>C0VnDyFe)DBS_2{h6=0ZJ*w%s)QFxLq+%L%e~UQ0mM9ud zm&|r){_<*Om%vlT(K9>dE(3AHjSYro5Y1I?ZjMqWyHzuCE0nyCn`6eq%MEt(aY=M2rIzHeMds)4^Aub^iTIT|%*izG4YH;sT`D9MR(eND-SB+e66LZT z2VX)RJsn${O{D48aUBl|(>ocol$1@glsxisc#GE*=DXHXA?|hJT#{;X{i$XibrA}X zFHJa+ssa2$F_UC(o2k2Z0vwx%Wb(<6_bdDO#=a$0gK2NoscCr;vyx?#cF)JjM%;a| z$^GIlIzvz%Hx3WVU481}_e4~aWcyC|j&BZ@uWW1`bH1y9EWXOxd~f-VE5DpueNofN zv7vZeV<*!A^|36hUE;`#x%MHhL(~?eZ5fhA9Ql3KHTWoAeO-^7&|2)$IcD1r5X#-u zN~N0$6pHPhop@t1_d`dO3#TC0>y5jm>8;$F5_A2& zt#=^IDfYv?JjPPTPNx2TL-Lrl82VClQSLWW_$3=XPbH}xM34)cyW5@lnxy=&h%eRq zv29&h^fMoxjsDnmua(>~OnX{Cq!7vM0M4Mr@_18|YuSKPBKUTV$s^So zc}JlAW&bVz|JY#Eyup6Ny{|P_s0Pq;5*tinH+>5Xa--{ z2;?2PBs((S4{g=G`S?B3Ien`o#5DmUVwzpGuABthYG~OKIY`2ms;33SN9u^I8i_H5`BQ%yOfW+N3r|ufHS_;U;TWT5z;b14n1gX%Pn`uuO z6#>Vl)L0*8yl|#mICWQUtgzeFp9$puHl~m&O+vj3Ox#SxQUa?fY*uK?A;00RiFg(G zK?g=7b5~U4QIK`C*um%=Sw=OJ1eeaV@WZ%hh-3<=lR#(Xesk%?)l4p(EpTwPvN99V@TT)!A8SeFTV+frN=r|5l?K#odjijx2nFgc3kI zC$hVs1S-!z9>xn9MZcRk0YXdYlf~8*LfH$IHKD59H&gLz%6 z#mAYSRJufbRi~LRadwM*G!O2>&U<^d`@<)otXZJJxT@G}4kTx0zPDVhVXwiU)$}5Y z`0iV`8EEh&GlUk&VY9m0Mqr*U&|^Bc?FB`<%{x-o0ATntwIA%(YDcxWs$C)%a%d_@ z?fx!Co+@3p7ha$|pWYD}p6#(PG%_h8K7sQjT_P~|3ZEH0DRxa3~bP&&lPMj3C~!H2QD zq>(f^RUFSqf6K3BMBFy$jiuoSE+DhEq$xLDb7{57 z0B|1pSjYJ5F@cHG%qDZ{ogL$P!BK&sR%zD`gbK#9gRZX17EtAJxN% zys^gb2=X9=7HP}N(iRqt(tot2yyeE%s;L}AcMh;~-W~s_eAe!gIUYdQz5j~T)0trh z>#1U$uOyyl%!Pi(gD&)uHe9Q^27_kHyFCC}n^-KL(=OxHqUfex1YS__RJh0m-S>eM zqAk`aSev*z1lI&-?CycgDm=bdQCp}RqS0_d-4Mf&>u2KyGFxKe8JM1N{GNWw0n$FL z1UDp(h0(1I2Jh9I`?IS}h4R~n zRwRz>8?$fFMB2{UPe^$Ifl;Oc>}@Q9`|8DCeR{?LUQLPfaMsxs8ps=D_aAXORZH~< zdcIOca-F;+D3~M+)Vi4h)I4O3<)$65yI)goQ_vk#fb;Uim>UI4Dv9#2b1;N_Wg>-F zNwKeMKY+su#~NL0uE%_$mw1%ddX2Qs2P!ncM+>wnz}OCQX1!q~oS?OqYU;&ESAAwP z452QWL0&u^mraF#=j_ZeBWhm&F|d!QjwRl^7=Bl7@(43=BkN=3{BRv#QHIk>Umc_w zvP>q|q{lJ=zs|W9%a@8%W>C@MYN1D5{(=Af31+pR#kB`cd0-YlQQTg}+ zL|_h=F9JQ|Gux5c0ehaffHNYLf8VwF+qnM6IjBEI_eceee;o;FY@#~FFVsZjBSp!j z8V*Bgmn{RK!!zqGc;jy)z@Zjo>5{%m1?K}fLEL$l6Dl4f=ye0wNI#)2L=^K(&18Gb zJoj8@WBB;P^T#V)I0`aDSy?$rJU{+-5472NyFp>;Vw43j@3Z=;D2eSfyw5*0Q+&ML zsV&&*3c3$pa`qcaGbEB0*CA~Wp3%PkF?B87FV&rWNb|@GU$LB;l|;YutU*k za1hjUL_BX%G^s;BuzRi4Hl?eqC2z&ZrKh1tZDwnufG$g$LX(j!h%F5(n8D@in3lnX z(*8+3ZT6TVYRcSpM1eMeCps=Fz8q%gyM&B=a7(Vf`4k3dN$IM+`BO^_7HZq4BR|7w z+5kOJ;9_$X%-~arA@qmXSzD|+NMh--%5-9u6t(M=f%&z$<_V#Y_lzn{E$MZZG)+A> zu2E`_Y(MBJ2l*AqvCUmU;yBT}#oQ{V=((mC-QGJwsCOH*a;{1JRTKv7DBNG+M!XL7(^jbv&Qy-o9HNFrmN)-`D3WFtXs>1vBOJpI(=x; zKhJlFdfMf^G#oU(w1+ucMKYPZaDp>$kt=wiYsBCjUY-uz<4JziB>6fXDSLH*2Y z&Px5y`#3!fF=c4>fCMdg-tX582pemU@ZxyFbznL8-=TTo1Sybg9>7h*J^9^~XxXJO z`k9v~=4amxl<;FCV9h2k%?^-ZUzQy^#{JleyH23o1S{r<+t#z6jKS<9rbAM96^1iY zi6{IjauB)UwBhC-_L(MzGCxhhv`?ryc zja_Uwi7$8l!}*vjJppGyp#Wz=*?;jC*xQ&J894rql5A$2giJRtV&DWQh#(+Vs3-5_ z69_tj(>8%z1VtVp>a74r5}j2rG%&;uaTQ|fr&r%ew-HO}76i8`&ki%#)~}q4Y|d$_ zfNp9uc#$#OEca>>MaY6rF`dB|5#S)bghf>>TmmE&S~IFw;PF0UztO6+R-0!TSC?QP z{b(RA_;q3QAPW^XN?qQqu{h<}Vfiv}Rr!lA$C79^1=U>+ng9Dh>v{`?AOZt>CrQ=o zI}=mSnR))8fJpO->rcX?H);oqSQUZ?sR!fH2SoFdcPm5*2y<_u;4h;BqcF*XbwWSv zcJN%!g|L(22Xp!^1?c;T&qm%rpkP&2EQC3JF+SENm$+@7#e!UKD1uQ{TDw43?!b!3 zUooS_rt=xJfa&h?c^hfV>YwQXre3qosz_^c#)FO~d!<)2o}Oxz5HWtr<)1Yw012v4 zhv0w(RfJspDnA^-6Jmr;GkWt%{mAYOm6yPb&Vl&rv@D^K&;#?=X{kaK5FhScNJ_3> z#5u(Saisq2(~pVlrfG#@kLM#Ot~5rZZc%B&h1=gen?R+#t^1bYKf zVvtefX=D$*)39e^2@!~A_}9c${Gf0?1;dk=!Itp#s%0>Io%k`9(bDeI-udd&E6Zfu zcaiv(h`DM3W3Mfda)fYwhB=8RAPkotVt5-z21Ij~Ot9A^SK-1u*zFVK&mF?q1;|wy zrF+XWs^5Q-%Z6I62gTwrRe#F>riVM#fv_TihxSJ6to1X7NVszgivoTa!fPfBBYj94 zuc2m zL_k-<1FoORng1i3mth0|ZzT1O9&X8W9LkyFWn#Ebm_hAPM%O zNC_$OQHe90; z+@DGs;NHgGW8%wjH$EpvQ-Hd! znZdIh#!H5nOStiOKNV8}QvY~=VMqtG&p$ByF&%pe_gR`|H5ULg47lk20(Xe=k8ptc zn%EmTI7k9gNE=!IN4WnbymtsKoHn2-cL65z^9cQOSp>XFzo;!h*x1s^0U!<{Y-VZ1 zXJ7zekkYf(`@dZ3F9|?O+*dUL4K4?0@V^>I2;k-a1%ZgY9w2|C5r0R5?80e-|&4yEwkklXmZ)!QSYG) zXBKOz|IPC2W_X!t^cgb^@D=|>r@x$f{3Y+`%NoDT^Y@JIuJ%jxe;es9vi`kJmbnPYT%X}rzs0K#=H)Q`)_L7%?KLLJP+0XJbL&JgdJE{i*){MOFSK z{7XUfXZR-Te}aE8RelNkQV0AQ7RC0TVE^o8c!~K^RQ4GY+xed`|A+zjZ(qij@~zLP zkS@Q0`rpM|UsnI6B;_+vw)^iA{n0%C7N~ql@KXNonIOUIHwgYg4Dcn>OOdc=rUl>M zVEQe|u$P=Kb)TL&-2#4t^Pg0pUQ)dj%6O)#3;zwOe~`_1$@Ef`;F+l=>NlAFFbBS0 zN))`LdKnA;OjQ{B+f;z>i|wCv-CmNs46S`8X-oKRl0V+pKZ%XJWO*6G`OMOs^xG_d zj_7-p06{fybw_P;UzX^eX5Pkcrm04%9rPFa56 zyZE \(.*\)$'` - 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/main/java/org/discordbots/api/client/Dropwizard.java b/src/main/java/org/discordbots/api/client/Dropwizard.java index 58550ba..1ec2892 100644 --- a/src/main/java/org/discordbots/api/client/Dropwizard.java +++ b/src/main/java/org/discordbots/api/client/Dropwizard.java @@ -1,7 +1,16 @@ package org.discordbots.api.client.webhooks; import java.io.IOException; -import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +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 com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -10,35 +19,62 @@ 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; public abstract class Dropwizard { private final Class aClass; - private final String authorization; + private final byte[] authorization; private final Gson gson; public Dropwizard(final Class aClass, final String authorization) { this.aClass = aClass; - this.authorization = authorization; + this.authorization = authorization.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder().create(); } @POST - public Response handle(@Context HttpServletRequest request) { - final String authorizationHeader = request.getHeader("Authorization"); - - if (authorizationHeader == null || !authorizationHeader.equals(this.authorization)) { - return Response.status(Response.Status.UNAUTHORIZED).entity("Unauthorized").build(); - } - + public Response handle(@Context HttpServletRequest request) throws WebApplicationException { try { - callback(gson.fromJson(new InputStreamReader(request.getInputStream()), aClass)); + final String signatureHeader = request.getHeader("x-topgg-signature"); - return Response.noContent().build(); - } catch (final JsonSyntaxException | JsonIOException | IOException ignored) {} + 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; - return Response.status(Response.Status.BAD_REQUEST).entity("Bad request").build(); + final SecretKeySpec key = new SecretKeySpec(this.authorization, "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(); + } + + callback(gson.fromJson(body, aClass)); + + return Response.noContent().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("Invalid Request").build(); + } + } } public abstract void callback(T data); diff --git a/src/main/java/org/discordbots/api/client/EclipseJetty.java b/src/main/java/org/discordbots/api/client/EclipseJetty.java index ee4112e..b2011d1 100644 --- a/src/main/java/org/discordbots/api/client/EclipseJetty.java +++ b/src/main/java/org/discordbots/api/client/EclipseJetty.java @@ -1,50 +1,84 @@ package org.discordbots.api.client.webhooks; import java.io.IOException; -import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +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 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; public abstract class EclipseJetty extends HttpServlet { private final Class aClass; - private final String authorization; + private final byte[] authorization; private final Gson gson; public EclipseJetty(final Class aClass, final String authorization) { this.aClass = aClass; - this.authorization = authorization; + this.authorization = authorization.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder().create(); } @Override - protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { - final String authorizationHeader = request.getHeader("Authorization"); - - if (authorizationHeader == null || !authorizationHeader.equals(this.authorization)) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("Unauthorized"); - - return; - } - + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { try { - callback(gson.fromJson(new InputStreamReader(request.getInputStream()), aClass)); + 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(this.authorization, "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; + } + + callback(gson.fromJson(body, aClass)); response.setStatus(HttpServletResponse.SC_NO_CONTENT); response.getWriter().write(""); - - return; - } catch (final JsonSyntaxException | JsonIOException | IOException ignored) {} - - response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - response.getWriter().write("Bad request"); + } 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("Invalid Request"); + } + } } public abstract void callback(T data); diff --git a/src/main/java/org/discordbots/api/client/SpringBoot.java b/src/main/java/org/discordbots/api/client/SpringBoot.java index 5177b96..a340c72 100644 --- a/src/main/java/org/discordbots/api/client/SpringBoot.java +++ b/src/main/java/org/discordbots/api/client/SpringBoot.java @@ -1,7 +1,16 @@ package org.discordbots.api.client.webhooks; import java.io.IOException; -import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +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.springframework.web.filter.OncePerRequestFilter; @@ -17,41 +26,67 @@ public abstract class SpringBoot extends OncePerRequestFilter { private final Class aClass; - private final String authorization; + private final byte[] authorization; private final Gson gson; public SpringBoot(final Class aClass, final String authorization) { this.aClass = aClass; - this.authorization = authorization; + this.authorization = authorization.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder().create(); } @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { if (request.getMethod().equalsIgnoreCase("POST")) { - final String authorizationHeader = request.getHeader("Authorization"); - - if (authorizationHeader == null || !authorizationHeader.equals(this.authorization)) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("Unauthorized"); + final String signatureHeader = request.getHeader("x-topgg-signature"); - return; - } + if (signatureHeader != null) { + 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 + )); - try { - callback(gson.fromJson(new InputStreamReader(request.getInputStream()), aClass)); + final String signature = parsedSignature.get("v1"); + final String timestamp = parsedSignature.get("t"); - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - response.getWriter().write(""); - - return; - } catch (final JsonSyntaxException | JsonIOException | IOException ignored) {} + assert signature != null && timestamp != null; + + final SecretKeySpec key = new SecretKeySpec(this.authorization, "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)); - response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - response.getWriter().write("Bad request"); - } else { - filterChain.doFilter(request, response); + if (!signature.equals(HexFormat.of().formatHex(digest))) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Invalid Authorization"); + + return; + } + + callback(gson.fromJson(body, aClass)); + + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.getWriter().write(""); + } 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("Invalid Request"); + } + } + + return; + } } + + filterChain.doFilter(request, response); } public abstract void callback(T data); From aeeb7c68dfd8ae63e4dfd0264851f57edb3c5c0a Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:56:28 +0700 Subject: [PATCH 03/21] deps: bump dependencies --- build.gradle | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/build.gradle b/build.gradle index 1d213d9..8e39493 100644 --- a/build.gradle +++ b/build.gradle @@ -2,14 +2,12 @@ plugins { id "java" } -group = 'org.discordbots' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } +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.") } +group = 'org.discordbots' + repositories { mavenCentral() } @@ -18,11 +16,11 @@ dependencies { //Logger implementation "org.slf4j:slf4j-api:2.0.17" - implementation "org.json:json:20250517" - implementation "com.squareup.okhttp3:okhttp:5.2.0" + 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" - implementation "org.springframework.boot:spring-boot-starter-web:3.5.6" + implementation "org.springframework.boot:spring-boot-starter-web:4.0.2" implementation "jakarta.servlet:jakarta.servlet-api:6.1.0" implementation "jakarta.ws.rs:jakarta.ws.rs-api:4.0.0" } \ No newline at end of file From a752e4dfbdcf6bf9521bbd84fc5e8d363e2ec1be Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:25:53 +0700 Subject: [PATCH 04/21] fix: fix incorrect package error --- .../org/discordbots/api/client/{ => webhooks}/Dropwizard.java | 0 .../org/discordbots/api/client/{ => webhooks}/EclipseJetty.java | 0 .../org/discordbots/api/client/{ => webhooks}/SpringBoot.java | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/org/discordbots/api/client/{ => webhooks}/Dropwizard.java (100%) rename src/main/java/org/discordbots/api/client/{ => webhooks}/EclipseJetty.java (100%) rename src/main/java/org/discordbots/api/client/{ => webhooks}/SpringBoot.java (100%) diff --git a/src/main/java/org/discordbots/api/client/Dropwizard.java b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java similarity index 100% rename from src/main/java/org/discordbots/api/client/Dropwizard.java rename to src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java diff --git a/src/main/java/org/discordbots/api/client/EclipseJetty.java b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java similarity index 100% rename from src/main/java/org/discordbots/api/client/EclipseJetty.java rename to src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java diff --git a/src/main/java/org/discordbots/api/client/SpringBoot.java b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java similarity index 100% rename from src/main/java/org/discordbots/api/client/SpringBoot.java rename to src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java From 61a6a2901955de6cdd2699e43e44a835a921ab15 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 17 Feb 2026 22:30:16 +0700 Subject: [PATCH 05/21] feat: add x-topgg-trace and custom response support --- .../org/discordbots/api/client/webhooks/Dropwizard.java | 6 ++---- .../org/discordbots/api/client/webhooks/EclipseJetty.java | 7 ++----- .../org/discordbots/api/client/webhooks/SpringBoot.java | 7 ++----- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java index 1ec2892..704fd9f 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java +++ b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java @@ -65,9 +65,7 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio return Response.status(Response.Status.UNAUTHORIZED).entity("Invalid Authorization").build(); } - callback(gson.fromJson(body, aClass)); - - return Response.noContent().build(); + return callback(gson.fromJson(body, aClass), request.getHeader("x-topgg-trace")); } 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); @@ -77,5 +75,5 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio } } - public abstract void callback(T data); + public abstract Response callback(T data, String trace); } \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java index b2011d1..754a727 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java +++ b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java @@ -67,10 +67,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } - callback(gson.fromJson(body, aClass)); - - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - response.getWriter().write(""); + callback(gson.fromJson(body, aClass), request.getHeader("x-topgg-trace"), response); } 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); @@ -81,5 +78,5 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } } - public abstract void callback(T data); + public abstract void callback(T data, String trace, HttpServletResponse response); } \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java index a340c72..40cedcd 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java +++ b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java @@ -69,10 +69,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - callback(gson.fromJson(body, aClass)); - - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - response.getWriter().write(""); + callback(gson.fromJson(body, aClass), request.getHeader("x-topgg-trace"), response); } 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); @@ -89,5 +86,5 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } - public abstract void callback(T data); + public abstract void callback(T data, String trace, HttpServletResponse response); } \ No newline at end of file From adf075f6b9682bbf816b009e416427fe49c7f021 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:16:07 +0700 Subject: [PATCH 06/21] feat: update webhooks --- .../api/client/webhooks/Dropwizard.java | 37 ++++++++++++--- .../api/client/webhooks/EclipseJetty.java | 36 ++++++++++++--- .../webhooks/IntegrationCreatePayload.java | 31 +++++++++++++ .../webhooks/IntegrationDeletePayload.java | 12 +++++ .../api/client/webhooks/PartialProject.java | 30 +++++++++++++ .../api/client/webhooks/Payload.java | 19 ++++++++ .../api/client/webhooks/Platform.java | 8 ++++ .../api/client/webhooks/ProjectType.java | 11 +++++ .../api/client/webhooks/SpringBoot.java | 36 ++++++++++++--- .../api/client/webhooks/TestPayload.java | 15 +++++++ .../discordbots/api/client/webhooks/User.java | 31 +++++++++++++ .../client/webhooks/VoteCreatePayload.java | 45 +++++++++++++++++++ 12 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/PartialProject.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/Payload.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/Platform.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/ProjectType.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/TestPayload.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/User.java create mode 100644 src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java diff --git a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java index 704fd9f..43987c0 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java +++ b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java @@ -23,15 +23,15 @@ import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.Response; -public abstract class Dropwizard { - private final Class aClass; +public class Dropwizard { private final byte[] authorization; private final Gson gson; + private final Dropwizard.Listener listener; - public Dropwizard(final Class aClass, final String authorization) { - this.aClass = aClass; + public Dropwizard(final String authorization, final Dropwizard.Listener listener) { this.authorization = authorization.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder().create(); + this.listener = listener; } @POST @@ -65,7 +65,16 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio return Response.status(Response.Status.UNAUTHORIZED).entity("Invalid Authorization").build(); } - return callback(gson.fromJson(body, aClass), request.getHeader("x-topgg-trace")); + final Payload payload = gson.fromJson(body, Payload.class); + final String trace = request.getHeader("x-topgg-trace"); + + return switch (payload.getType()) { + case "integration.create" -> listener.onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> listener.onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> listener.onTest(payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> listener.onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + default -> Response.status(Response.Status.BAD_REQUEST).entity("Invalid Request").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); @@ -75,5 +84,21 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio } } - public abstract Response callback(T data, String trace); + public interface Listener { + default Response onIntegrationCreate(IntegrationCreatePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onIntegrationDelete(IntegrationDeletePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onTest(TestPayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onVoteCreate(VoteCreatePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + } } \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java index 754a727..e4f7c4e 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java +++ b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java @@ -22,15 +22,15 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -public abstract class EclipseJetty extends HttpServlet { - private final Class aClass; +public class EclipseJetty extends HttpServlet { private final byte[] authorization; private final Gson gson; + private final EclipseJetty.Listener listener; - public EclipseJetty(final Class aClass, final String authorization) { - this.aClass = aClass; + public EclipseJetty(final String authorization, final EclipseJetty.Listener listener) { this.authorization = authorization.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder().create(); + this.listener = listener; } @Override @@ -67,7 +67,15 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) return; } - callback(gson.fromJson(body, aClass), request.getHeader("x-topgg-trace"), response); + final Payload payload = gson.fromJson(body, Payload.class); + final String trace = request.getHeader("x-topgg-trace"); + + switch (payload.getType()) { + case "integration.create" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + } } 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); @@ -78,5 +86,21 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } } - public abstract void callback(T data, String trace, HttpServletResponse response); + public interface Listener { + default void onIntegrationCreate(HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onIntegrationDelete(HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + } } \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java b/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java new file mode 100644 index 0000000..5c2b96c --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java @@ -0,0 +1,31 @@ +package org.discordbots.api.client.webhooks; + +import com.google.gson.annotations.SerializedName; + +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; + } +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java b/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java new file mode 100644 index 0000000..1a0fc40 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java @@ -0,0 +1,12 @@ +package org.discordbots.api.client.webhooks; + +import com.google.gson.annotations.SerializedName; + +public class IntegrationDeletePayload { + @SerializedName("connection_id") + private String connectionId; + + public String getConnectionId() { + return connectionId; + } +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java b/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java new file mode 100644 index 0000000..45584f4 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java @@ -0,0 +1,30 @@ +package org.discordbots.api.client.webhooks; + +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; + } +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/Payload.java b/src/main/java/org/discordbots/api/client/webhooks/Payload.java new file mode 100644 index 0000000..6d8c5ab --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/Payload.java @@ -0,0 +1,19 @@ +package org.discordbots.api.client.webhooks; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; + +class Payload { + private String type; + + private JsonObject data; + + public String getType() { + return type; + } + + public T getData(Gson gson, Class cls) throws JsonSyntaxException { + return gson.fromJson(data, cls); + } +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/Platform.java b/src/main/java/org/discordbots/api/client/webhooks/Platform.java new file mode 100644 index 0000000..b63aea9 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/Platform.java @@ -0,0 +1,8 @@ +package org.discordbots.api.client.webhooks; + +import com.google.gson.annotations.SerializedName; + +public enum Platform { + @SerializedName("discord") + DISCORD +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java b/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java new file mode 100644 index 0000000..eaa0ab6 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java @@ -0,0 +1,11 @@ +package org.discordbots.api.client.webhooks; + +import com.google.gson.annotations.SerializedName; + +public enum ProjectType { + @SerializedName("bot") + DISCORD_BOT, + + @SerializedName("server") + DISCORD_SERVER +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java index 40cedcd..ac70183 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java +++ b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java @@ -24,15 +24,15 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -public abstract class SpringBoot extends OncePerRequestFilter { - private final Class aClass; +public class SpringBoot extends OncePerRequestFilter { private final byte[] authorization; private final Gson gson; + private final SpringBoot.Listener listener; - public SpringBoot(final Class aClass, final String authorization) { - this.aClass = aClass; + public SpringBoot(final String authorization, final SpringBoot.Listener listener) { this.authorization = authorization.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder().create(); + this.listener = listener; } @Override @@ -69,7 +69,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - callback(gson.fromJson(body, aClass), request.getHeader("x-topgg-trace"), response); + final Payload payload = gson.fromJson(body, Payload.class); + final String trace = request.getHeader("x-topgg-trace"); + + switch (payload.getType()) { + case "integration.create" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + } } 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); @@ -86,5 +94,21 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } - public abstract void callback(T data, String trace, HttpServletResponse response); + public interface Listener { + default void onIntegrationCreate(HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onIntegrationDelete(HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + } } \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java b/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java new file mode 100644 index 0000000..a2c5c18 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java @@ -0,0 +1,15 @@ +package org.discordbots.api.client.webhooks; + +public class TestPayload { + private PartialProject project; + + private User user; + + public PartialProject getProject() { + return project; + } + + public User getUser() { + return user; + } +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/User.java b/src/main/java/org/discordbots/api/client/webhooks/User.java new file mode 100644 index 0000000..4dcb729 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/User.java @@ -0,0 +1,31 @@ +package org.discordbots.api.client.webhooks; + +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; + } +} \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java b/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java new file mode 100644 index 0000000..5a0550a --- /dev/null +++ b/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java @@ -0,0 +1,45 @@ +package org.discordbots.api.client.webhooks; + +import java.time.OffsetDateTime; + +import com.google.gson.annotations.SerializedName; + +public class VoteCreatePayload { + private String id; + + private int weight; + + @SerializedName("voted_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; + } +} \ No newline at end of file From 50761dcec0f8b3e4ea69327609db9a70e3e9635a Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:41:47 +0700 Subject: [PATCH 07/21] feat: return 500 upon callback exception --- .../api/client/webhooks/Dropwizard.java | 18 +++++++++++------- .../api/client/webhooks/EclipseJetty.java | 15 ++++++++++----- .../api/client/webhooks/SpringBoot.java | 15 ++++++++++----- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java index 43987c0..7c4332e 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java +++ b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java @@ -68,13 +68,17 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio final Payload payload = gson.fromJson(body, Payload.class); final String trace = request.getHeader("x-topgg-trace"); - return switch (payload.getType()) { - case "integration.create" -> listener.onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); - case "integration.delete" -> listener.onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> listener.onTest(payload.getData(gson, TestPayload.class), trace); - case "vote.create" -> listener.onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); - default -> Response.status(Response.Status.BAD_REQUEST).entity("Invalid Request").build(); - }; + try { + return switch (payload.getType()) { + case "integration.create" -> listener.onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> listener.onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> listener.onTest(payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> listener.onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + default -> Response.status(Response.Status.BAD_REQUEST).entity("Invalid Request").build(); + }; + } catch (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); diff --git a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java index e4f7c4e..573a3ab 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java +++ b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java @@ -70,11 +70,16 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) final Payload payload = gson.fromJson(body, Payload.class); final String trace = request.getHeader("x-topgg-trace"); - switch (payload.getType()) { - case "integration.create" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); - case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); - case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + try { + switch (payload.getType()) { + case "integration.create" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + } + } catch (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) { diff --git a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java index ac70183..0a43f50 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java +++ b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java @@ -72,11 +72,16 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse final Payload payload = gson.fromJson(body, Payload.class); final String trace = request.getHeader("x-topgg-trace"); - switch (payload.getType()) { - case "integration.create" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); - case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); - case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + try { + switch (payload.getType()) { + case "integration.create" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); + } + } catch (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) { From 984d5a8a950d9547cf73664e946e0ad7c28660e8 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Tue, 3 Mar 2026 16:59:26 +0700 Subject: [PATCH 08/21] fix: deserialize created_at, NOT voted_at --- .../org/discordbots/api/client/webhooks/VoteCreatePayload.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java b/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java index 5a0550a..f0e3678 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java +++ b/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java @@ -9,7 +9,7 @@ public class VoteCreatePayload { private int weight; - @SerializedName("voted_at") + @SerializedName("created_at") private OffsetDateTime votedAt; @SerializedName("expires_at") From 75ae723e2eb133adb6c5590c0c3287b98537a4a0 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 05:27:52 +0700 Subject: [PATCH 09/21] [feat,style]: apply v1-based rewrite and use Google Java formatter --- build.gradle | 65 ++++- .../api/client/DiscordBotListAPI.java | 229 +++++++++++---- .../discordbots/api/client/entity/Bot.java | 144 ---------- .../api/client/entity/BotResult.java | 5 - .../api/client/entity/BotStats.java | 16 -- .../api/client/entity/PaginatedVotes.java | 26 ++ .../api/client/entity/PartialVote.java | 26 ++ .../api/client/entity/Platform.java | 8 + .../api/client/entity/Project.java | 65 +++++ .../api/client/entity/ProjectType.java | 11 + .../discordbots/api/client/entity/Result.java | 32 --- .../api/client/entity/SimpleUser.java | 29 -- .../discordbots/api/client/entity/Social.java | 7 - .../discordbots/api/client/entity/User.java | 79 ++---- .../api/client/entity/UserSource.java | 11 + .../discordbots/api/client/entity/Vote.java | 81 +++--- .../api/client/entity/VotingMultiplier.java | 16 -- .../client/impl/DiscordBotListAPIImpl.java | 264 ------------------ .../client/io/DefaultResponseTransformer.java | 26 +- .../client/io/EmptyResponseTransformer.java | 10 + .../client/io/PaginatedVotesConverter.java | 35 +++ .../api/client/io/ResponseTransformer.java | 4 +- .../client/io/UnsuccessfulHttpException.java | 15 +- .../api/client/webhooks/Dropwizard.java | 191 +++++++------ .../api/client/webhooks/EclipseJetty.java | 199 +++++++------ .../webhooks/IntegrationCreatePayload.java | 2 +- .../webhooks/IntegrationDeletePayload.java | 2 +- .../api/client/webhooks/PartialProject.java | 2 +- .../api/client/webhooks/Payload.java | 4 +- .../api/client/webhooks/Platform.java | 2 +- .../api/client/webhooks/ProjectType.java | 2 +- .../api/client/webhooks/SpringBoot.java | 210 +++++++------- .../api/client/webhooks/TestPayload.java | 2 +- .../discordbots/api/client/webhooks/User.java | 2 +- .../client/webhooks/VoteCreatePayload.java | 5 +- 35 files changed, 849 insertions(+), 978 deletions(-) delete mode 100644 src/main/java/org/discordbots/api/client/entity/Bot.java delete mode 100644 src/main/java/org/discordbots/api/client/entity/BotResult.java delete mode 100644 src/main/java/org/discordbots/api/client/entity/BotStats.java create mode 100644 src/main/java/org/discordbots/api/client/entity/PaginatedVotes.java create mode 100644 src/main/java/org/discordbots/api/client/entity/PartialVote.java create mode 100644 src/main/java/org/discordbots/api/client/entity/Platform.java create mode 100644 src/main/java/org/discordbots/api/client/entity/Project.java create mode 100644 src/main/java/org/discordbots/api/client/entity/ProjectType.java delete mode 100644 src/main/java/org/discordbots/api/client/entity/Result.java delete mode 100644 src/main/java/org/discordbots/api/client/entity/SimpleUser.java delete mode 100644 src/main/java/org/discordbots/api/client/entity/Social.java create mode 100644 src/main/java/org/discordbots/api/client/entity/UserSource.java delete mode 100644 src/main/java/org/discordbots/api/client/entity/VotingMultiplier.java delete mode 100644 src/main/java/org/discordbots/api/client/impl/DiscordBotListAPIImpl.java create mode 100644 src/main/java/org/discordbots/api/client/io/EmptyResponseTransformer.java create mode 100644 src/main/java/org/discordbots/api/client/io/PaginatedVotesConverter.java diff --git a/build.gradle b/build.gradle index 8e39493..a7cda24 100644 --- a/build.gradle +++ b/build.gradle @@ -1,26 +1,61 @@ plugins { - id "java" + id 'java-library' + id 'maven-publish' } +group = 'org.discordbots' +version = '3.0.0' +description = 'The community-maintained Java library for Top.gg.' + 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.") + throw new GradleException('Top.gg\'s Java SDK requires Java 17 or later. Java ${JavaVersion.current()} is not supported.') } -group = 'org.discordbots' - repositories { - mavenCentral() + mavenCentral() +} + +configurations { + googleJavaFormat } dependencies { - //Logger - 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" - implementation "org.springframework.boot:spring-boot-starter-web:4.0.2" - implementation "jakarta.servlet:jakarta.servlet-api:6.1.0" - implementation "jakarta.ws.rs:jakarta.ws.rs-api:4.0.0" + //Logger + 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' + implementation 'org.springframework.boot:spring-boot-starter-web:4.0.3' + implementation 'jakarta.servlet:jakarta.servlet-api:6.1.0' + implementation 'jakarta.ws.rs:jakarta.ws.rs-api:4.0.0' + + 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' + ) +} + +tasks.withType(JavaCompile) { + options.compilerArgs << '-Xlint:deprecation' } \ No newline at end of file diff --git a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java index 1dbd21c..b8fc827 100644 --- a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java +++ b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java @@ -1,59 +1,178 @@ 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 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.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; 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); - } - - } - +import okhttp3.*; +import org.discordbots.api.client.entity.PaginatedVotes; +import org.discordbots.api.client.entity.PartialVote; +import org.discordbots.api.client.entity.Project; +import org.discordbots.api.client.entity.UserSource; +import org.discordbots.api.client.io.DefaultResponseTransformer; +import org.discordbots.api.client.io.EmptyResponseTransformer; +import org.discordbots.api.client.io.PaginatedVotesConverter; +import org.discordbots.api.client.io.ResponseTransformer; +import org.discordbots.api.client.io.UnsuccessfulHttpException; + +public class DiscordBotListAPI { + private static final HttpUrl baseUrl = + new HttpUrl.Builder() + .scheme("https") + .host("top.gg") + .addPathSegment("api") + .addPathSegment("v1") + .build(); + + private final OkHttpClient httpClient; + private final Gson gson; + + private final String token; + + public DiscordBotListAPI(final String token) { + this.token = "Bearer " + token; + + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .registerTypeAdapter(PaginatedVotes.class, new PaginatedVotesConverter(this)) + .create(); + + this.httpClient = + new OkHttpClient.Builder() + .addInterceptor( + (chain) -> + chain.proceed( + chain + .request() + .newBuilder() + .addHeader("Authorization", this.token) + .build())) + .build(); + } + + public CompletionStage getSelf() { + final HttpUrl url = + baseUrl.newBuilder().addPathSegment("projects").addPathSegment("@me").build(); + + return get(url, Project.class); + } + + public CompletionStage postCommands(final JsonArray commands) { + final HttpUrl url = + baseUrl + .newBuilder() + .addPathSegment("projects") + .addPathSegment("@me") + .addPathSegment("commands") + .build(); + + return post(url, commands, new EmptyResponseTransformer()); + } + + public CompletionStage getVote(final UserSource userSource, final String id) { + final HttpUrl url = + baseUrl + .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 OffsetDateTime since) { + final HttpUrl url = + baseUrl + .newBuilder() + .addPathSegment("projects") + .addPathSegment("@me") + .addPathSegment("votes") + .addQueryParameter("startDate", since.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + .build(); + + return get(url, PaginatedVotes.class); + } + + public CompletionStage getVotes(final String cursor) { + final HttpUrl url = + baseUrl + .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 JsonArray jsonBody, + final ResponseTransformer responseTransformer) { + final RequestBody body = + RequestBody.create(jsonBody.toString(), 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(Call call, IOException error) { + future.completeExceptionally(error); + } + + @Override + public void onResponse(Call call, Response response) { + try { + if (response.isSuccessful()) { + future.complete(responseTransformer.transform(response)); + } else { + future.completeExceptionally(new UnsuccessfulHttpException(response)); + } + } catch (Throwable error) { + future.completeExceptionally(error); + } finally { + response.body().close(); + } + } + }); + + return future; + } } 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/PaginatedVotes.java b/src/main/java/org/discordbots/api/client/entity/PaginatedVotes.java new file mode 100644 index 0000000..dc4dad2 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/entity/PaginatedVotes.java @@ -0,0 +1,26 @@ +package org.discordbots.api.client.entity; + +import java.util.List; +import java.util.concurrent.CompletionStage; +import org.discordbots.api.client.DiscordBotListAPI; + +public class PaginatedVotes { + private final List votes; + private final String cursor; + private final DiscordBotListAPI client; + + public PaginatedVotes( + final List votes, final String cursor, final DiscordBotListAPI 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/client/entity/PartialVote.java b/src/main/java/org/discordbots/api/client/entity/PartialVote.java new file mode 100644 index 0000000..a0d543c --- /dev/null +++ b/src/main/java/org/discordbots/api/client/entity/PartialVote.java @@ -0,0 +1,26 @@ +package org.discordbots.api.client.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/client/entity/Platform.java b/src/main/java/org/discordbots/api/client/entity/Platform.java new file mode 100644 index 0000000..07ff428 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/entity/Platform.java @@ -0,0 +1,8 @@ +package org.discordbots.api.client.entity; + +import com.google.gson.annotations.SerializedName; + +public enum Platform { + @SerializedName("discord") + DISCORD +} diff --git a/src/main/java/org/discordbots/api/client/entity/Project.java b/src/main/java/org/discordbots/api/client/entity/Project.java new file mode 100644 index 0000000..96f09ee --- /dev/null +++ b/src/main/java/org/discordbots/api/client/entity/Project.java @@ -0,0 +1,65 @@ +package org.discordbots.api.client.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/client/entity/ProjectType.java b/src/main/java/org/discordbots/api/client/entity/ProjectType.java new file mode 100644 index 0000000..ca739ac --- /dev/null +++ b/src/main/java/org/discordbots/api/client/entity/ProjectType.java @@ -0,0 +1,11 @@ +package org.discordbots.api.client.entity; + +import com.google.gson.annotations.SerializedName; + +public enum ProjectType { + @SerializedName("bot") + DISCORD_BOT, + + @SerializedName("server") + DISCORD_SERVER +} 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 index bfed556..8b59469 100644 --- a/src/main/java/org/discordbots/api/client/entity/User.java +++ b/src/main/java/org/discordbots/api/client/entity/User.java @@ -1,49 +1,30 @@ -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; - } - -} +package org.discordbots.api.client.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/main/java/org/discordbots/api/client/entity/UserSource.java b/src/main/java/org/discordbots/api/client/entity/UserSource.java new file mode 100644 index 0000000..445879b --- /dev/null +++ b/src/main/java/org/discordbots/api/client/entity/UserSource.java @@ -0,0 +1,11 @@ +package org.discordbots.api.client.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/client/entity/Vote.java b/src/main/java/org/discordbots/api/client/entity/Vote.java index 047f6de..03e67c6 100644 --- a/src/main/java/org/discordbots/api/client/entity/Vote.java +++ b/src/main/java/org/discordbots/api/client/entity/Vote.java @@ -1,41 +1,40 @@ -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; - } - -} +package org.discordbots.api.client.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/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 index 8639e10..33f6141 100644 --- a/src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java +++ b/src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java @@ -1,24 +1,20 @@ package org.discordbots.api.client.io; import com.google.gson.Gson; -import okhttp3.Response; - import java.io.IOException; +import okhttp3.Response; public class DefaultResponseTransformer implements ResponseTransformer { + private final Class aClass; + private final Gson gson; - 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); - } + public DefaultResponseTransformer(Class aClass, Gson gson) { + this.aClass = aClass; + this.gson = gson; + } + @Override + public E transform(Response response) throws IOException { + return gson.fromJson(response.body().string(), aClass); + } } diff --git a/src/main/java/org/discordbots/api/client/io/EmptyResponseTransformer.java b/src/main/java/org/discordbots/api/client/io/EmptyResponseTransformer.java new file mode 100644 index 0000000..c79e4d7 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/io/EmptyResponseTransformer.java @@ -0,0 +1,10 @@ +package org.discordbots.api.client.io; + +import okhttp3.Response; + +public class EmptyResponseTransformer implements ResponseTransformer { + @Override + public Void transform(Response response) { + return null; + } +} diff --git a/src/main/java/org/discordbots/api/client/io/PaginatedVotesConverter.java b/src/main/java/org/discordbots/api/client/io/PaginatedVotesConverter.java new file mode 100644 index 0000000..37642c0 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/io/PaginatedVotesConverter.java @@ -0,0 +1,35 @@ +package org.discordbots.api.client.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.client.DiscordBotListAPI; +import org.discordbots.api.client.entity.PaginatedVotes; +import org.discordbots.api.client.entity.Vote; + +public class PaginatedVotesConverter implements JsonDeserializer { + private final DiscordBotListAPI client; + + public PaginatedVotesConverter(final DiscordBotListAPI 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/client/io/ResponseTransformer.java b/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java index 730b783..1ee6989 100644 --- a/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java +++ b/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java @@ -3,7 +3,5 @@ import okhttp3.Response; public interface ResponseTransformer { - - E transform(Response response) throws Exception; - + 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 index ae8a294..ad2b2d0 100644 --- a/src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java +++ b/src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java @@ -1,9 +1,18 @@ package org.discordbots.api.client.io; +import okhttp3.Response; + public class UnsuccessfulHttpException extends Exception { + private final Response response; + + public UnsuccessfulHttpException(Response response) { + super( + "The server responded with code: " + response.code() + ", message: " + response.message()); - public UnsuccessfulHttpException(int code, String message) { - super("The server responded with code: " + code + ", message: " + message); - } + this.response = response; + } + public Response getResponse() { + return response; + } } diff --git a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java index 7c4332e..99b3829 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java +++ b/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java @@ -1,5 +1,14 @@ package org.discordbots.api.client.webhooks; +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; @@ -8,101 +17,109 @@ import java.util.HashMap; import java.util.HexFormat; import java.util.stream.Collectors; - import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -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; - public class Dropwizard { - private final byte[] authorization; - private final Gson gson; - private final Dropwizard.Listener listener; - - public Dropwizard(final String authorization, final Dropwizard.Listener listener) { - this.authorization = authorization.getBytes(StandardCharsets.UTF_8); - this.gson = new GsonBuilder().create(); - this.listener = listener; + private final byte[] authorization; + private final Gson gson; + private final Dropwizard.Listener listener; + + public Dropwizard(final String authorization, final Dropwizard.Listener listener) { + this.authorization = authorization.getBytes(StandardCharsets.UTF_8); + this.gson = new GsonBuilder().create(); + this.listener = listener; + } + + @POST + 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(authorization, "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" -> + listener.onIntegrationCreate( + payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + listener.onIntegrationDelete( + payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> listener.onTest(payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> + listener.onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + default -> Response.status(Response.Status.BAD_REQUEST).entity("Invalid Request").build(); + }; + } catch (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("Invalid Request").build(); + } } + } - @POST - 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(this.authorization, "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" -> listener.onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); - case "integration.delete" -> listener.onIntegrationDelete(payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> listener.onTest(payload.getData(gson, TestPayload.class), trace); - case "vote.create" -> listener.onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); - default -> Response.status(Response.Status.BAD_REQUEST).entity("Invalid Request").build(); - }; - } catch (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("Invalid Request").build(); - } - } + public interface Listener { + default Response onIntegrationCreate(IntegrationCreatePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); } - public interface Listener { - default Response onIntegrationCreate(IntegrationCreatePayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } - - default Response onIntegrationDelete(IntegrationDeletePayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } + default Response onIntegrationDelete(IntegrationDeletePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } - default Response onTest(TestPayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } + default Response onTest(TestPayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } - default Response onVoteCreate(VoteCreatePayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } + default Response onVoteCreate(VoteCreatePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); } -} \ No newline at end of file + } +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java index 573a3ab..1d2d6bf 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java +++ b/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java @@ -1,5 +1,13 @@ package org.discordbots.api.client.webhooks; +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; @@ -8,104 +16,115 @@ import java.util.HashMap; import java.util.HexFormat; import java.util.stream.Collectors; - import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -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; - public class EclipseJetty extends HttpServlet { - private final byte[] authorization; - private final Gson gson; - private final EclipseJetty.Listener listener; - - public EclipseJetty(final String authorization, final EclipseJetty.Listener listener) { - this.authorization = authorization.getBytes(StandardCharsets.UTF_8); - this.gson = new GsonBuilder().create(); - this.listener = listener; - } - - @Override - protected void doPost(HttpServletRequest request, 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(this.authorization, "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" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); - case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); - case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); - } - } catch (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("Invalid Request"); - } + private final byte[] authorization; + private final Gson gson; + private final EclipseJetty.Listener listener; + + public EclipseJetty(final String authorization, final EclipseJetty.Listener listener) { + this.authorization = authorization.getBytes(StandardCharsets.UTF_8); + this.gson = new GsonBuilder().create(); + this.listener = listener; + } + + @Override + protected void doPost(HttpServletRequest request, 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(authorization, "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" -> + listener.onIntegrationCreate( + response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + listener.onIntegrationDelete( + response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> + listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> + listener.onVoteCreate( + response, payload.getData(gson, VoteCreatePayload.class), trace); } + } catch (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("Invalid Request"); + } } + } - public interface Listener { - default void onIntegrationCreate(HttpServletResponse response, IntegrationCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + public interface Listener { + default void onIntegrationCreate( + HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } - default void onIntegrationDelete(HttpServletResponse response, IntegrationDeletePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + default void onIntegrationDelete( + HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } - default void onTest(HttpServletResponse response, TestPayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } - default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + default void onVoteCreate( + HttpServletResponse response, VoteCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); } -} \ No newline at end of file + } +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java b/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java index 5c2b96c..f214781 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java +++ b/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java @@ -28,4 +28,4 @@ public PartialProject getProject() { public User getUser() { return user; } -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java b/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java index 1a0fc40..9c2b11b 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java +++ b/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java @@ -9,4 +9,4 @@ public class IntegrationDeletePayload { public String getConnectionId() { return connectionId; } -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java b/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java index 45584f4..3319279 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java +++ b/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java @@ -27,4 +27,4 @@ public Platform getPlatform() { public String getPlatformId() { return platformId; } -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/Payload.java b/src/main/java/org/discordbots/api/client/webhooks/Payload.java index 6d8c5ab..6c6d6d9 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Payload.java +++ b/src/main/java/org/discordbots/api/client/webhooks/Payload.java @@ -13,7 +13,7 @@ public String getType() { return type; } - public T getData(Gson gson, Class cls) throws JsonSyntaxException { + public T getData(Gson gson, Class cls) throws JsonSyntaxException { return gson.fromJson(data, cls); } -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/Platform.java b/src/main/java/org/discordbots/api/client/webhooks/Platform.java index b63aea9..6885db7 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Platform.java +++ b/src/main/java/org/discordbots/api/client/webhooks/Platform.java @@ -5,4 +5,4 @@ public enum Platform { @SerializedName("discord") DISCORD -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java b/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java index eaa0ab6..8cb1d4c 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java +++ b/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java @@ -8,4 +8,4 @@ public enum ProjectType { @SerializedName("server") DISCORD_SERVER -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java index 0a43f50..e5979e1 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java +++ b/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java @@ -1,5 +1,13 @@ package org.discordbots.api.client.webhooks; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; @@ -8,112 +16,124 @@ import java.util.HashMap; import java.util.HexFormat; import java.util.stream.Collectors; - import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; - import org.springframework.web.filter.OncePerRequestFilter; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonIOException; -import com.google.gson.JsonSyntaxException; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - public class SpringBoot extends OncePerRequestFilter { - private final byte[] authorization; - private final Gson gson; - private final SpringBoot.Listener listener; - - public SpringBoot(final String authorization, final SpringBoot.Listener listener) { - this.authorization = authorization.getBytes(StandardCharsets.UTF_8); - this.gson = new GsonBuilder().create(); - this.listener = listener; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { - if (request.getMethod().equalsIgnoreCase("POST")) { - final String signatureHeader = request.getHeader("x-topgg-signature"); - - if (signatureHeader != null) { - 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(this.authorization, "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" -> listener.onIntegrationCreate(response, payload.getData(gson, IntegrationCreatePayload.class), trace); - case "integration.delete" -> listener.onIntegrationDelete(response, payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> listener.onTest(response, payload.getData(gson, TestPayload.class), trace); - case "vote.create" -> listener.onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); - } - } catch (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("Invalid Request"); - } - } - - return; + private final byte[] authorization; + private final Gson gson; + private final SpringBoot.Listener listener; + + public SpringBoot(final String authorization, final SpringBoot.Listener listener) { + this.authorization = authorization.getBytes(StandardCharsets.UTF_8); + this.gson = new GsonBuilder().create(); + this.listener = listener; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws IOException, ServletException { + if (request.getMethod().equalsIgnoreCase("POST")) { + final String signatureHeader = request.getHeader("x-topgg-signature"); + + if (signatureHeader != null) { + 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(this.authorization, "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" -> + listener.onIntegrationCreate( + response, payload.getData(gson, IntegrationCreatePayload.class), trace); + case "integration.delete" -> + listener.onIntegrationDelete( + response, payload.getData(gson, IntegrationDeletePayload.class), trace); + case "webhook.test" -> + listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + case "vote.create" -> + listener.onVoteCreate( + response, payload.getData(gson, VoteCreatePayload.class), trace); } + } catch (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("Invalid Request"); + } } - filterChain.doFilter(request, response); + return; + } } - public interface Listener { - default void onIntegrationCreate(HttpServletResponse response, IntegrationCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + filterChain.doFilter(request, response); + } - default void onIntegrationDelete(HttpServletResponse response, IntegrationDeletePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + public interface Listener { + default void onIntegrationCreate( + HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } - default void onTest(HttpServletResponse response, TestPayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + default void onIntegrationDelete( + HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } - default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } + default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onVoteCreate( + HttpServletResponse response, VoteCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); } -} \ No newline at end of file + } +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java b/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java index a2c5c18..2873f0b 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java +++ b/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java @@ -12,4 +12,4 @@ public PartialProject getProject() { public User getUser() { return user; } -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/User.java b/src/main/java/org/discordbots/api/client/webhooks/User.java index 4dcb729..20a605f 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/User.java +++ b/src/main/java/org/discordbots/api/client/webhooks/User.java @@ -28,4 +28,4 @@ public String getAvatar() { public String getPlatformId() { return platformId; } -} \ No newline at end of file +} diff --git a/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java b/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java index f0e3678..ac359d4 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java +++ b/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java @@ -1,8 +1,7 @@ package org.discordbots.api.client.webhooks; -import java.time.OffsetDateTime; - import com.google.gson.annotations.SerializedName; +import java.time.OffsetDateTime; public class VoteCreatePayload { private String id; @@ -42,4 +41,4 @@ public PartialProject getProject() { public User getUser() { return user; } -} \ No newline at end of file +} From 842e0fe148532de11a869d86c85e5d7ba4bdec73 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 06:11:41 +0700 Subject: [PATCH 10/21] feat: move all webhooks to their own respective source set --- build.gradle | 29 ++++++++++-- .../dropwizard/DropwizardWebhooks.java} | 47 ++++++++++++------- .../eclipsejetty/EclipseJettyWebhooks.java} | 43 +++++++++++------ .../api/client/DiscordBotListAPI.java | 9 +++- .../discordbots/api/client/webhooks/User.java | 31 ------------ .../springboot/SpringBootWebhooks.java} | 43 +++++++++++------ .../webhooks/IntegrationCreatePayload.java | 2 +- .../webhooks/IntegrationDeletePayload.java | 2 +- .../discordbots}/webhooks/PartialProject.java | 2 +- .../org/discordbots}/webhooks/Payload.java | 4 +- .../org/discordbots}/webhooks/Platform.java | 2 +- .../discordbots}/webhooks/ProjectType.java | 2 +- .../discordbots}/webhooks/TestPayload.java | 2 +- .../java/org/discordbots/webhooks}/User.java | 3 +- .../webhooks/VoteCreatePayload.java | 2 +- 15 files changed, 133 insertions(+), 90 deletions(-) rename src/{main/java/org/discordbots/api/client/webhooks/Dropwizard.java => dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java} (84%) rename src/{main/java/org/discordbots/api/client/webhooks/EclipseJetty.java => eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java} (84%) delete mode 100644 src/main/java/org/discordbots/api/client/webhooks/User.java rename src/{main/java/org/discordbots/api/client/webhooks/SpringBoot.java => springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java} (85%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/IntegrationCreatePayload.java (87%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/IntegrationDeletePayload.java (79%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/PartialProject.java (85%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/Payload.java (80%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/Platform.java (68%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/ProjectType.java (74%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/TestPayload.java (76%) rename src/{main/java/org/discordbots/api/client/entity => webhooks/java/org/discordbots/webhooks}/User.java (86%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots}/webhooks/VoteCreatePayload.java (88%) diff --git a/build.gradle b/build.gradle index a7cda24..d3aa9ce 100644 --- a/build.gradle +++ b/build.gradle @@ -15,21 +15,42 @@ repositories { mavenCentral() } +sourceSets { + webhooks + dropwizardWebhooks + eclipseJettyWebhooks + springBootWebhooks +} + configurations { + dropwizardWebhooksImplementation.extendsFrom(webhooksImplementation) + eclipseJettyWebhooksImplementation.extendsFrom(webhooksImplementation) + springBootWebhooksImplementation.extendsFrom(webhooksImplementation) + googleJavaFormat } dependencies { - //Logger 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' - implementation 'org.springframework.boot:spring-boot-starter-web:4.0.3' - implementation 'jakarta.servlet:jakarta.servlet-api:6.1.0' - implementation 'jakarta.ws.rs:jakarta.ws.rs-api:4.0.0' + + 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' + + springBootWebhooksImplementation sourceSets.webhooks.output + springBootWebhooksImplementation 'org.springframework.boot:spring-boot-starter-web:4.0.3' + springBootWebhooksImplementation 'jakarta.servlet:jakarta.servlet-api:6.1.0' googleJavaFormat 'com.google.googlejavaformat:google-java-format:1.34.1' } diff --git a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java similarity index 84% rename from src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java rename to src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java index 99b3829..a399c67 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Dropwizard.java +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java @@ -1,33 +1,48 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks.dropwizard; -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; -public class Dropwizard { +import org.discordbots.webhooks.IntegrationCreatePayload; +import org.discordbots.webhooks.IntegrationDeletePayload; +import org.discordbots.webhooks.Payload; +import org.discordbots.webhooks.TestPayload; +import org.discordbots.webhooks.VoteCreatePayload; + +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; + +public class DropwizardWebhooks { private final byte[] authorization; private final Gson gson; - private final Dropwizard.Listener listener; + private final DropwizardWebhooks.Listener listener; - public Dropwizard(final String authorization, final Dropwizard.Listener listener) { + public DropwizardWebhooks( + final String authorization, final DropwizardWebhooks.Listener listener) { this.authorization = authorization.getBytes(StandardCharsets.UTF_8); - this.gson = new GsonBuilder().create(); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); this.listener = listener; } @@ -83,7 +98,7 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio case "webhook.test" -> listener.onTest(payload.getData(gson, TestPayload.class), trace); case "vote.create" -> listener.onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); - default -> Response.status(Response.Status.BAD_REQUEST).entity("Invalid Request").build(); + default -> Response.status(Response.Status.BAD_REQUEST).entity("Bad Request").build(); }; } catch (Throwable ignored) { return Response.status(Response.Status.INTERNAL_SERVER_ERROR) @@ -100,7 +115,7 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio 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("Invalid Request").build(); + return Response.status(Response.Status.BAD_REQUEST).entity("Bad Request").build(); } } } diff --git a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java similarity index 84% rename from src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java rename to src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java index 1d2d6bf..d10dd12 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/EclipseJetty.java +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java @@ -1,32 +1,47 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks.eclipsejetty; -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; -public class EclipseJetty extends HttpServlet { +import org.discordbots.webhooks.IntegrationCreatePayload; +import org.discordbots.webhooks.IntegrationDeletePayload; +import org.discordbots.webhooks.Payload; +import org.discordbots.webhooks.TestPayload; +import org.discordbots.webhooks.VoteCreatePayload; + +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; + +public class EclipseJettyWebhooks extends HttpServlet { private final byte[] authorization; private final Gson gson; - private final EclipseJetty.Listener listener; + private final EclipseJettyWebhooks.Listener listener; - public EclipseJetty(final String authorization, final EclipseJetty.Listener listener) { + public EclipseJettyWebhooks( + final String authorization, final EclipseJettyWebhooks.Listener listener) { this.authorization = authorization.getBytes(StandardCharsets.UTF_8); - this.gson = new GsonBuilder().create(); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); this.listener = listener; } @@ -102,7 +117,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) throw new ServletException("Unable to find HMAC SHA-256 algorithm", error); } else { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - response.getWriter().write("Invalid Request"); + response.getWriter().write("Bad Request"); } } } diff --git a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java index b8fc827..ec4e2b4 100644 --- a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java +++ b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java @@ -10,7 +10,14 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; -import okhttp3.*; +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.client.entity.PaginatedVotes; import org.discordbots.api.client.entity.PartialVote; import org.discordbots.api.client.entity.Project; diff --git a/src/main/java/org/discordbots/api/client/webhooks/User.java b/src/main/java/org/discordbots/api/client/webhooks/User.java deleted file mode 100644 index 20a605f..0000000 --- a/src/main/java/org/discordbots/api/client/webhooks/User.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.discordbots.api.client.webhooks; - -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/main/java/org/discordbots/api/client/webhooks/SpringBoot.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java similarity index 85% rename from src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java rename to src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java index e5979e1..ade4d99 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/SpringBoot.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java @@ -1,33 +1,48 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks.springboot; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; -import com.google.gson.JsonIOException; -import com.google.gson.JsonSyntaxException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -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.IntegrationCreatePayload; +import org.discordbots.webhooks.IntegrationDeletePayload; +import org.discordbots.webhooks.Payload; +import org.discordbots.webhooks.TestPayload; +import org.discordbots.webhooks.VoteCreatePayload; import org.springframework.web.filter.OncePerRequestFilter; -public class SpringBoot extends OncePerRequestFilter { +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.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class SpringBootWebhooks extends OncePerRequestFilter { private final byte[] authorization; private final Gson gson; - private final SpringBoot.Listener listener; + private final SpringBootWebhooks.Listener listener; - public SpringBoot(final String authorization, final SpringBoot.Listener listener) { + public SpringBootWebhooks( + final String authorization, final SpringBootWebhooks.Listener listener) { this.authorization = authorization.getBytes(StandardCharsets.UTF_8); - this.gson = new GsonBuilder().create(); + this.gson = + new GsonBuilder() + .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) + .create(); this.listener = listener; } @@ -105,7 +120,7 @@ protected void doFilterInternal( throw new ServletException("Unable to find HMAC SHA-256 algorithm", error); } else { response.setStatus(HttpServletResponse.SC_BAD_REQUEST); - response.getWriter().write("Invalid Request"); + response.getWriter().write("Bad Request"); } } diff --git a/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/IntegrationCreatePayload.java similarity index 87% rename from src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java rename to src/webhooks/java/org/discordbots/webhooks/IntegrationCreatePayload.java index f214781..ede7c76 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/IntegrationCreatePayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/IntegrationCreatePayload.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java b/src/webhooks/java/org/discordbots/webhooks/IntegrationDeletePayload.java similarity index 79% rename from src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java rename to src/webhooks/java/org/discordbots/webhooks/IntegrationDeletePayload.java index 9c2b11b..604305f 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/IntegrationDeletePayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/IntegrationDeletePayload.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java b/src/webhooks/java/org/discordbots/webhooks/PartialProject.java similarity index 85% rename from src/main/java/org/discordbots/api/client/webhooks/PartialProject.java rename to src/webhooks/java/org/discordbots/webhooks/PartialProject.java index 3319279..d5960fb 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/PartialProject.java +++ b/src/webhooks/java/org/discordbots/webhooks/PartialProject.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/webhooks/Payload.java b/src/webhooks/java/org/discordbots/webhooks/Payload.java similarity index 80% rename from src/main/java/org/discordbots/api/client/webhooks/Payload.java rename to src/webhooks/java/org/discordbots/webhooks/Payload.java index 6c6d6d9..ad02129 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Payload.java +++ b/src/webhooks/java/org/discordbots/webhooks/Payload.java @@ -1,10 +1,10 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonSyntaxException; -class Payload { +public class Payload { private String type; private JsonObject data; diff --git a/src/main/java/org/discordbots/api/client/webhooks/Platform.java b/src/webhooks/java/org/discordbots/webhooks/Platform.java similarity index 68% rename from src/main/java/org/discordbots/api/client/webhooks/Platform.java rename to src/webhooks/java/org/discordbots/webhooks/Platform.java index 6885db7..7943265 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/Platform.java +++ b/src/webhooks/java/org/discordbots/webhooks/Platform.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java b/src/webhooks/java/org/discordbots/webhooks/ProjectType.java similarity index 74% rename from src/main/java/org/discordbots/api/client/webhooks/ProjectType.java rename to src/webhooks/java/org/discordbots/webhooks/ProjectType.java index 8cb1d4c..34dd603 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/ProjectType.java +++ b/src/webhooks/java/org/discordbots/webhooks/ProjectType.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java b/src/webhooks/java/org/discordbots/webhooks/TestPayload.java similarity index 76% rename from src/main/java/org/discordbots/api/client/webhooks/TestPayload.java rename to src/webhooks/java/org/discordbots/webhooks/TestPayload.java index 2873f0b..9d77e07 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/TestPayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/TestPayload.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; public class TestPayload { private PartialProject project; diff --git a/src/main/java/org/discordbots/api/client/entity/User.java b/src/webhooks/java/org/discordbots/webhooks/User.java similarity index 86% rename from src/main/java/org/discordbots/api/client/entity/User.java rename to src/webhooks/java/org/discordbots/webhooks/User.java index 8b59469..88b5865 100644 --- a/src/main/java/org/discordbots/api/client/entity/User.java +++ b/src/webhooks/java/org/discordbots/webhooks/User.java @@ -1,9 +1,10 @@ -package org.discordbots.api.client.entity; +package org.discordbots.webhooks; import com.google.gson.annotations.SerializedName; public class User { private String id; + private String name; @SerializedName("avatar_url") diff --git a/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/VoteCreatePayload.java similarity index 88% rename from src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java rename to src/webhooks/java/org/discordbots/webhooks/VoteCreatePayload.java index ac359d4..6bb89f6 100644 --- a/src/main/java/org/discordbots/api/client/webhooks/VoteCreatePayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/VoteCreatePayload.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.webhooks; +package org.discordbots.webhooks; import com.google.gson.annotations.SerializedName; import java.time.OffsetDateTime; From f6a4b84d3cb4e8863d122230f2873546fc92b08b Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 06:27:57 +0700 Subject: [PATCH 11/21] [style,feat]: prettier and add PostCommands interface --- .../dropwizard/DropwizardWebhooks.java | 24 ++++++++----------- .../eclipsejetty/EclipseJettyWebhooks.java | 22 +++++++---------- .../api/client/DiscordBotListAPI.java | 17 +++++++------ .../api/client/io/PostCommands.java | 5 ++++ .../client/io/RawPostCommandsTransformer.java | 16 +++++++++++++ .../springboot/SpringBootWebhooks.java | 22 +++++++---------- 6 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 src/main/java/org/discordbots/api/client/io/PostCommands.java create mode 100644 src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java index a399c67..f87c144 100644 --- a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java @@ -1,5 +1,15 @@ 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; @@ -9,28 +19,14 @@ 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.IntegrationCreatePayload; import org.discordbots.webhooks.IntegrationDeletePayload; import org.discordbots.webhooks.Payload; import org.discordbots.webhooks.TestPayload; import org.discordbots.webhooks.VoteCreatePayload; -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; - public class DropwizardWebhooks { private final byte[] authorization; private final Gson gson; diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java index d10dd12..e5c854e 100644 --- a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java @@ -1,5 +1,14 @@ 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; @@ -9,27 +18,14 @@ 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.IntegrationCreatePayload; import org.discordbots.webhooks.IntegrationDeletePayload; import org.discordbots.webhooks.Payload; import org.discordbots.webhooks.TestPayload; import org.discordbots.webhooks.VoteCreatePayload; -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; - public class EclipseJettyWebhooks extends HttpServlet { private final byte[] authorization; private final Gson gson; diff --git a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java index ec4e2b4..aff2016 100644 --- a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java +++ b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java @@ -25,6 +25,8 @@ import org.discordbots.api.client.io.DefaultResponseTransformer; import org.discordbots.api.client.io.EmptyResponseTransformer; import org.discordbots.api.client.io.PaginatedVotesConverter; +import org.discordbots.api.client.io.PostCommands; +import org.discordbots.api.client.io.RawPostCommandsTransformer; import org.discordbots.api.client.io.ResponseTransformer; import org.discordbots.api.client.io.UnsuccessfulHttpException; @@ -71,7 +73,7 @@ public CompletionStage getSelf() { return get(url, Project.class); } - public CompletionStage postCommands(final JsonArray commands) { + public CompletionStage postCommands(final PostCommands commands) { final HttpUrl url = baseUrl .newBuilder() @@ -80,7 +82,11 @@ public CompletionStage postCommands(final JsonArray commands) { .addPathSegment("commands") .build(); - return post(url, commands, new EmptyResponseTransformer()); + return post(url, commands.toJsonString(), new EmptyResponseTransformer()); + } + + public CompletionStage postCommands(final JsonArray commands) { + return postCommands(new RawPostCommandsTransformer(commands)); } public CompletionStage getVote(final UserSource userSource, final String id) { @@ -142,11 +148,8 @@ private CompletionStage get( } private CompletionStage post( - final HttpUrl url, - final JsonArray jsonBody, - final ResponseTransformer responseTransformer) { - final RequestBody body = - RequestBody.create(jsonBody.toString(), MediaType.parse("application/json")); + 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); diff --git a/src/main/java/org/discordbots/api/client/io/PostCommands.java b/src/main/java/org/discordbots/api/client/io/PostCommands.java new file mode 100644 index 0000000..827fa3d --- /dev/null +++ b/src/main/java/org/discordbots/api/client/io/PostCommands.java @@ -0,0 +1,5 @@ +package org.discordbots.api.client.io; + +public interface PostCommands { + String toJsonString(); +} diff --git a/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java b/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java new file mode 100644 index 0000000..cfe0a12 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java @@ -0,0 +1,16 @@ +package org.discordbots.api.client.io; + +import com.google.gson.JsonArray; + +public class RawPostCommandsTransformer implements PostCommands { + private final JsonArray object; + + public RawPostCommandsTransformer(final JsonArray object) { + this.object = object; + } + + @Override + public String toJsonString() { + return this.object.toString(); + } +} diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java index ade4d99..ceefb1b 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java @@ -1,5 +1,14 @@ 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 jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; @@ -9,10 +18,8 @@ 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.IntegrationCreatePayload; import org.discordbots.webhooks.IntegrationDeletePayload; import org.discordbots.webhooks.Payload; @@ -20,17 +27,6 @@ import org.discordbots.webhooks.VoteCreatePayload; import org.springframework.web.filter.OncePerRequestFilter; -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.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - public class SpringBootWebhooks extends OncePerRequestFilter { private final byte[] authorization; private final Gson gson; From 8ad36face02f3706095faaa1fc34fe4022861141 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 07:43:44 +0700 Subject: [PATCH 12/21] feat: add Discord4J and JDA wrappers --- build.gradle | 13 +++++++ .../Discord4JPostCommandsTransformer.java | 37 +++++++++++++++++++ .../api/jda/JDAPostCommandsTransformer.java | 35 ++++++++++++++++++ .../api/client/DiscordBotListAPI.java | 8 ++-- .../api/client/io/PostCommands.java | 5 --- .../client/io/PostCommandsTransformer.java | 7 ++++ .../client/io/RawPostCommandsTransformer.java | 8 ++-- 7 files changed, 102 insertions(+), 11 deletions(-) create mode 100644 src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java create mode 100644 src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java delete mode 100644 src/main/java/org/discordbots/api/client/io/PostCommands.java create mode 100644 src/main/java/org/discordbots/api/client/io/PostCommandsTransformer.java diff --git a/build.gradle b/build.gradle index d3aa9ce..52dd175 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,9 @@ repositories { } sourceSets { + jdaWrapper + discord4jWrapper + webhooks dropwizardWebhooks eclipseJettyWebhooks @@ -38,6 +41,16 @@ dependencies { 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' 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..53a4694 --- /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.client.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/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java b/src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java new file mode 100644 index 0000000..1ca08a8 --- /dev/null +++ b/src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java @@ -0,0 +1,35 @@ +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.client.io.PostCommandsTransformer; + +public class JDAPostCommandsTransformer implements PostCommandsTransformer { + private final JDA jda; + + public JDAPostCommandsTransformer(final JDA jda) { + this.jda = jda; + } + + @Override + public CompletionStage toJsonString() { + return this.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/client/DiscordBotListAPI.java b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java index aff2016..1dd4d06 100644 --- a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java +++ b/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java @@ -25,7 +25,7 @@ import org.discordbots.api.client.io.DefaultResponseTransformer; import org.discordbots.api.client.io.EmptyResponseTransformer; import org.discordbots.api.client.io.PaginatedVotesConverter; -import org.discordbots.api.client.io.PostCommands; +import org.discordbots.api.client.io.PostCommandsTransformer; import org.discordbots.api.client.io.RawPostCommandsTransformer; import org.discordbots.api.client.io.ResponseTransformer; import org.discordbots.api.client.io.UnsuccessfulHttpException; @@ -73,7 +73,7 @@ public CompletionStage getSelf() { return get(url, Project.class); } - public CompletionStage postCommands(final PostCommands commands) { + public CompletionStage postCommands(final PostCommandsTransformer commands) { final HttpUrl url = baseUrl .newBuilder() @@ -82,7 +82,9 @@ public CompletionStage postCommands(final PostCommands commands) { .addPathSegment("commands") .build(); - return post(url, commands.toJsonString(), new EmptyResponseTransformer()); + return commands + .toJsonString() + .thenCompose(jsonBody -> post(url, jsonBody, new EmptyResponseTransformer())); } public CompletionStage postCommands(final JsonArray commands) { diff --git a/src/main/java/org/discordbots/api/client/io/PostCommands.java b/src/main/java/org/discordbots/api/client/io/PostCommands.java deleted file mode 100644 index 827fa3d..0000000 --- a/src/main/java/org/discordbots/api/client/io/PostCommands.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.discordbots.api.client.io; - -public interface PostCommands { - String toJsonString(); -} diff --git a/src/main/java/org/discordbots/api/client/io/PostCommandsTransformer.java b/src/main/java/org/discordbots/api/client/io/PostCommandsTransformer.java new file mode 100644 index 0000000..6837421 --- /dev/null +++ b/src/main/java/org/discordbots/api/client/io/PostCommandsTransformer.java @@ -0,0 +1,7 @@ +package org.discordbots.api.client.io; + +import java.util.concurrent.CompletionStage; + +public interface PostCommandsTransformer { + CompletionStage toJsonString(); +} diff --git a/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java b/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java index cfe0a12..5567173 100644 --- a/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java +++ b/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java @@ -1,8 +1,10 @@ package org.discordbots.api.client.io; import com.google.gson.JsonArray; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; -public class RawPostCommandsTransformer implements PostCommands { +public class RawPostCommandsTransformer implements PostCommandsTransformer { private final JsonArray object; public RawPostCommandsTransformer(final JsonArray object) { @@ -10,7 +12,7 @@ public RawPostCommandsTransformer(final JsonArray object) { } @Override - public String toJsonString() { - return this.object.toString(); + public CompletionStage toJsonString() { + return CompletableFuture.completedFuture(object.toString()); } } From a0a7a025afd644464ada59c64ee24a1987ed6b18 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:18:29 +0700 Subject: [PATCH 13/21] feat: overhaul webhooks and add tests for the API --- build.gradle | 3 + .../Discord4JPostCommandsTransformer.java | 2 +- ...hooks.java => DiscordBotListWebhooks.java} | 65 +++++++----------- .../DiscordBotListWebhooksListener.java | 25 +++++++ ...hooks.java => DiscordBotListWebhooks.java} | 67 +++++++------------ .../DiscordBotListWebhooksListener.java | 27 ++++++++ .../api/jda/JDAPostCommandsTransformer.java | 5 +- .../api/{client => }/DiscordBotListAPI.java | 48 ++++++------- .../discordbots/api/DiscordBotListWidget.java | 23 +++++++ .../api/client/entity/ProjectType.java | 11 --- .../{client => }/entity/PaginatedVotes.java | 4 +- .../api/{client => }/entity/PartialVote.java | 2 +- .../org/discordbots/api/entity}/Platform.java | 2 +- .../api/{client => }/entity/Project.java | 2 +- .../discordbots/api/entity/ProjectType.java | 18 +++++ .../api/{client => }/entity/UserSource.java | 2 +- .../api/{client => }/entity/Vote.java | 2 +- .../io/DefaultResponseTransformer.java | 2 +- .../io/EmptyResponseTransformer.java | 2 +- .../io/PaginatedVotesConverter.java | 8 +-- .../io/PostCommandsTransformer.java | 2 +- .../io/RawPostCommandsTransformer.java | 2 +- .../{client => }/io/ResponseTransformer.java | 2 +- .../io/UnsuccessfulHttpException.java | 2 +- ...hooks.java => DiscordBotListWebhooks.java} | 65 +++++++----------- .../DiscordBotListWebhooksListener.java | 27 ++++++++ .../api/DiscordBotListAPITest.java | 67 +++++++++++++++++++ .../api/interceptors/BaseInterceptor.java | 63 +++++++++++++++++ .../api/interceptors/GetSelfInterceptor.java | 20 ++++++ .../api/interceptors/GetVoteInterceptor.java | 22 ++++++ .../api/interceptors/GetVotesInterceptor.java | 22 ++++++ .../interceptors/PostCommandsInterceptor.java | 20 ++++++ src/test/resources/GetSelfResponse.json | 16 +++++ src/test/resources/GetVoteResponse.json | 5 ++ src/test/resources/GetVotesResponse.json | 33 +++++++++ src/test/resources/LeadResponse.json | 3 + src/test/resources/PostCommands.json | 11 +++ .../webhooks/{ => entity}/PartialProject.java | 2 +- .../webhooks}/entity/Platform.java | 2 +- .../webhooks/{ => entity}/ProjectType.java | 2 +- .../webhooks/{ => entity}/User.java | 2 +- .../IntegrationCreatePayload.java | 4 +- .../IntegrationDeletePayload.java | 2 +- .../webhooks/{ => payload}/Payload.java | 2 +- .../webhooks/{ => payload}/TestPayload.java | 5 +- .../{ => payload}/VoteCreatePayload.java | 4 +- 46 files changed, 541 insertions(+), 186 deletions(-) rename src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/{DropwizardWebhooks.java => DiscordBotListWebhooks.java} (65%) create mode 100644 src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooksListener.java rename src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/{EclipseJettyWebhooks.java => DiscordBotListWebhooks.java} (66%) create mode 100644 src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooksListener.java rename src/main/java/org/discordbots/api/{client => }/DiscordBotListAPI.java (82%) create mode 100644 src/main/java/org/discordbots/api/DiscordBotListWidget.java delete mode 100644 src/main/java/org/discordbots/api/client/entity/ProjectType.java rename src/main/java/org/discordbots/api/{client => }/entity/PaginatedVotes.java (81%) rename src/main/java/org/discordbots/api/{client => }/entity/PartialVote.java (86%) rename src/{webhooks/java/org/discordbots/webhooks => main/java/org/discordbots/api/entity}/Platform.java (72%) rename src/main/java/org/discordbots/api/{client => }/entity/Project.java (90%) create mode 100644 src/main/java/org/discordbots/api/entity/ProjectType.java rename src/main/java/org/discordbots/api/{client => }/entity/UserSource.java (74%) rename src/main/java/org/discordbots/api/{client => }/entity/Vote.java (89%) rename src/main/java/org/discordbots/api/{client => }/io/DefaultResponseTransformer.java (92%) rename src/main/java/org/discordbots/api/{client => }/io/EmptyResponseTransformer.java (78%) rename src/main/java/org/discordbots/api/{client => }/io/PaginatedVotesConverter.java (81%) rename src/main/java/org/discordbots/api/{client => }/io/PostCommandsTransformer.java (74%) rename src/main/java/org/discordbots/api/{client => }/io/RawPostCommandsTransformer.java (89%) rename src/main/java/org/discordbots/api/{client => }/io/ResponseTransformer.java (75%) rename src/main/java/org/discordbots/api/{client => }/io/UnsuccessfulHttpException.java (90%) rename src/springBootWebhooks/java/org/discordbots/webhooks/springboot/{SpringBootWebhooks.java => DiscordBotListWebhooks.java} (68%) create mode 100644 src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooksListener.java create mode 100644 src/test/java/org/discordbots/api/DiscordBotListAPITest.java create mode 100644 src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java create mode 100644 src/test/java/org/discordbots/api/interceptors/GetSelfInterceptor.java create mode 100644 src/test/java/org/discordbots/api/interceptors/GetVoteInterceptor.java create mode 100644 src/test/java/org/discordbots/api/interceptors/GetVotesInterceptor.java create mode 100644 src/test/java/org/discordbots/api/interceptors/PostCommandsInterceptor.java create mode 100644 src/test/resources/GetSelfResponse.json create mode 100644 src/test/resources/GetVoteResponse.json create mode 100644 src/test/resources/GetVotesResponse.json create mode 100644 src/test/resources/LeadResponse.json create mode 100644 src/test/resources/PostCommands.json rename src/webhooks/java/org/discordbots/webhooks/{ => entity}/PartialProject.java (86%) rename src/{main/java/org/discordbots/api/client => webhooks/java/org/discordbots/webhooks}/entity/Platform.java (69%) rename src/webhooks/java/org/discordbots/webhooks/{ => entity}/ProjectType.java (76%) rename src/webhooks/java/org/discordbots/webhooks/{ => entity}/User.java (86%) rename src/webhooks/java/org/discordbots/webhooks/{ => payload}/IntegrationCreatePayload.java (74%) rename src/webhooks/java/org/discordbots/webhooks/{ => payload}/IntegrationDeletePayload.java (80%) rename src/webhooks/java/org/discordbots/webhooks/{ => payload}/Payload.java (85%) rename src/webhooks/java/org/discordbots/webhooks/{ => payload}/TestPayload.java (55%) rename src/webhooks/java/org/discordbots/webhooks/{ => payload}/VoteCreatePayload.java (78%) diff --git a/build.gradle b/build.gradle index 52dd175..9eb396d 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,9 @@ dependencies { 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 'com.google.code.gson:gson:2.13.2' + googleJavaFormat 'com.google.googlejavaformat:google-java-format:1.34.1' } diff --git a/src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java b/src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java index 53a4694..89fa7de 100644 --- a/src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java +++ b/src/discord4jWrapper/java/org/discordbots/api/discord4j/Discord4JPostCommandsTransformer.java @@ -4,7 +4,7 @@ import discord4j.common.JacksonResources; import discord4j.core.DiscordClient; import java.util.concurrent.CompletionStage; -import org.discordbots.api.client.io.PostCommandsTransformer; +import org.discordbots.api.io.PostCommandsTransformer; public class Discord4JPostCommandsTransformer implements PostCommandsTransformer { private final DiscordClient client; diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooks.java similarity index 65% rename from src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java rename to src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooks.java index f87c144..5a8d079 100644 --- a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DropwizardWebhooks.java +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooks.java @@ -21,28 +21,34 @@ import java.util.stream.Collectors; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import org.discordbots.webhooks.IntegrationCreatePayload; -import org.discordbots.webhooks.IntegrationDeletePayload; -import org.discordbots.webhooks.Payload; -import org.discordbots.webhooks.TestPayload; -import org.discordbots.webhooks.VoteCreatePayload; - -public class DropwizardWebhooks { - private final byte[] authorization; +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 DiscordBotListWebhooks implements DiscordBotListWebhooksListener { + private byte[] secret; private final Gson gson; - private final DropwizardWebhooks.Listener listener; - public DropwizardWebhooks( - final String authorization, final DropwizardWebhooks.Listener listener) { - this.authorization = authorization.getBytes(StandardCharsets.UTF_8); + public DiscordBotListWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder() .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) .create(); - this.listener = listener; + } + + 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"); @@ -64,7 +70,7 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio assert signature != null && timestamp != null; - final SecretKeySpec key = new SecretKeySpec(authorization, "HmacSHA256"); + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); final Mac hmac = Mac.getInstance("HmacSHA256"); hmac.init(key); @@ -86,17 +92,14 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio try { return switch (payload.getType()) { case "integration.create" -> - listener.onIntegrationCreate( - payload.getData(gson, IntegrationCreatePayload.class), trace); + onIntegrationCreate(payload.getData(gson, IntegrationCreatePayload.class), trace); case "integration.delete" -> - listener.onIntegrationDelete( - payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> listener.onTest(payload.getData(gson, TestPayload.class), trace); - case "vote.create" -> - listener.onVoteCreate(payload.getData(gson, VoteCreatePayload.class), trace); + 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 (Throwable ignored) { + } catch (final Throwable ignored) { return Response.status(Response.Status.INTERNAL_SERVER_ERROR) .entity("Internal Server Error") .build(); @@ -115,22 +118,4 @@ public Response handle(@Context HttpServletRequest request) throws WebApplicatio } } } - - public interface Listener { - default Response onIntegrationCreate(IntegrationCreatePayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } - - default Response onIntegrationDelete(IntegrationDeletePayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } - - default Response onTest(TestPayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } - - default Response onVoteCreate(VoteCreatePayload payload, String trace) { - return Response.status(Response.Status.NO_CONTENT).build(); - } - } } diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooksListener.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooksListener.java new file mode 100644 index 0000000..42a5291 --- /dev/null +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooksListener.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 DiscordBotListWebhooksListener { + default Response onIntegrationCreate(IntegrationCreatePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onIntegrationDelete(IntegrationDeletePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onTest(TestPayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } + + default Response onVoteCreate(VoteCreatePayload payload, String trace) { + return Response.status(Response.Status.NO_CONTENT).build(); + } +} diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooks.java similarity index 66% rename from src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java rename to src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooks.java index e5c854e..8fe5baf 100644 --- a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/EclipseJettyWebhooks.java +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooks.java @@ -20,28 +20,34 @@ import java.util.stream.Collectors; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import org.discordbots.webhooks.IntegrationCreatePayload; -import org.discordbots.webhooks.IntegrationDeletePayload; -import org.discordbots.webhooks.Payload; -import org.discordbots.webhooks.TestPayload; -import org.discordbots.webhooks.VoteCreatePayload; - -public class EclipseJettyWebhooks extends HttpServlet { - private final byte[] authorization; +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 DiscordBotListWebhooks extends HttpServlet implements DiscordBotListWebhooksListener { + private byte[] secret; private final Gson gson; - private final EclipseJettyWebhooks.Listener listener; - public EclipseJettyWebhooks( - final String authorization, final EclipseJettyWebhooks.Listener listener) { - this.authorization = authorization.getBytes(StandardCharsets.UTF_8); + public DiscordBotListWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder() .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) .create(); - this.listener = listener; + } + + 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(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { try { @@ -64,7 +70,7 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) assert signature != null && timestamp != null; - final SecretKeySpec key = new SecretKeySpec(authorization, "HmacSHA256"); + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); final Mac hmac = Mac.getInstance("HmacSHA256"); hmac.init(key); @@ -87,18 +93,16 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) try { switch (payload.getType()) { case "integration.create" -> - listener.onIntegrationCreate( + onIntegrationCreate( response, payload.getData(gson, IntegrationCreatePayload.class), trace); case "integration.delete" -> - listener.onIntegrationDelete( + onIntegrationDelete( response, payload.getData(gson, IntegrationDeletePayload.class), trace); - case "webhook.test" -> - listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + case "webhook.test" -> onTest(response, payload.getData(gson, TestPayload.class), trace); case "vote.create" -> - listener.onVoteCreate( - response, payload.getData(gson, VoteCreatePayload.class), trace); + onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); } - } catch (Throwable ignored) { + } catch (final Throwable ignored) { response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.getWriter().write("Internal Server Error"); } @@ -117,25 +121,4 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) } } } - - public interface Listener { - default void onIntegrationCreate( - HttpServletResponse response, IntegrationCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - - default void onIntegrationDelete( - HttpServletResponse response, IntegrationDeletePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - - default void onTest(HttpServletResponse response, TestPayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - - default void onVoteCreate( - HttpServletResponse response, VoteCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - } } diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooksListener.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooksListener.java new file mode 100644 index 0000000..58ddbfd --- /dev/null +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooksListener.java @@ -0,0 +1,27 @@ +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 DiscordBotListWebhooksListener { + default void onIntegrationCreate( + HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onIntegrationDelete( + HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, 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 index 1ca08a8..471094f 100644 --- a/src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java +++ b/src/jdaWrapper/java/org/discordbots/api/jda/JDAPostCommandsTransformer.java @@ -6,7 +6,7 @@ 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.client.io.PostCommandsTransformer; +import org.discordbots.api.io.PostCommandsTransformer; public class JDAPostCommandsTransformer implements PostCommandsTransformer { private final JDA jda; @@ -17,8 +17,7 @@ public JDAPostCommandsTransformer(final JDA jda) { @Override public CompletionStage toJsonString() { - return this.jda - .retrieveCommands() + return jda.retrieveCommands() .submit() .thenApply( commands -> { diff --git a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java b/src/main/java/org/discordbots/api/DiscordBotListAPI.java similarity index 82% rename from src/main/java/org/discordbots/api/client/DiscordBotListAPI.java rename to src/main/java/org/discordbots/api/DiscordBotListAPI.java index 1dd4d06..eb98ff2 100644 --- a/src/main/java/org/discordbots/api/client/DiscordBotListAPI.java +++ b/src/main/java/org/discordbots/api/DiscordBotListAPI.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client; +package org.discordbots.api; import com.fatboyindustrial.gsonjavatime.OffsetDateTimeConverter; import com.google.gson.Gson; @@ -7,6 +7,7 @@ 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; @@ -18,17 +19,17 @@ import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; -import org.discordbots.api.client.entity.PaginatedVotes; -import org.discordbots.api.client.entity.PartialVote; -import org.discordbots.api.client.entity.Project; -import org.discordbots.api.client.entity.UserSource; -import org.discordbots.api.client.io.DefaultResponseTransformer; -import org.discordbots.api.client.io.EmptyResponseTransformer; -import org.discordbots.api.client.io.PaginatedVotesConverter; -import org.discordbots.api.client.io.PostCommandsTransformer; -import org.discordbots.api.client.io.RawPostCommandsTransformer; -import org.discordbots.api.client.io.ResponseTransformer; -import org.discordbots.api.client.io.UnsuccessfulHttpException; +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 DiscordBotListAPI { private static final HttpUrl baseUrl = @@ -42,18 +43,18 @@ public class DiscordBotListAPI { private final OkHttpClient httpClient; private final Gson gson; - private final String token; - - public DiscordBotListAPI(final String token) { - this.token = "Bearer " + token; - + public DiscordBotListAPI(final OkHttpClient httpClient) { this.gson = new GsonBuilder() .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) .registerTypeAdapter(PaginatedVotes.class, new PaginatedVotesConverter(this)) .create(); - this.httpClient = + this.httpClient = httpClient; + } + + public DiscordBotListAPI(final String token) { + this( new OkHttpClient.Builder() .addInterceptor( (chain) -> @@ -61,9 +62,9 @@ public DiscordBotListAPI(final String token) { chain .request() .newBuilder() - .addHeader("Authorization", this.token) + .addHeader("Authorization", "Bearer " + token) .build())) - .build(); + .build()); } public CompletionStage getSelf() { @@ -114,14 +115,14 @@ public CompletionStage getVote(final UserSource userSource, final S }); } - public CompletionStage getVotes(final OffsetDateTime since) { + public CompletionStage getVotes(final TemporalAccessor since) { final HttpUrl url = baseUrl .newBuilder() .addPathSegment("projects") .addPathSegment("@me") .addPathSegment("votes") - .addQueryParameter("startDate", since.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) + .addQueryParameter("startDate", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(since)) .build(); return get(url, PaginatedVotes.class); @@ -170,6 +171,7 @@ public void onFailure(Call call, IOException error) { } @Override + @SuppressWarnings("UseSpecificCatch") public void onResponse(Call call, Response response) { try { if (response.isSuccessful()) { @@ -177,7 +179,7 @@ public void onResponse(Call call, Response response) { } else { future.completeExceptionally(new UnsuccessfulHttpException(response)); } - } catch (Throwable error) { + } catch (final Throwable error) { future.completeExceptionally(error); } finally { response.body().close(); diff --git a/src/main/java/org/discordbots/api/DiscordBotListWidget.java b/src/main/java/org/discordbots/api/DiscordBotListWidget.java new file mode 100644 index 0000000..ddba869 --- /dev/null +++ b/src/main/java/org/discordbots/api/DiscordBotListWidget.java @@ -0,0 +1,23 @@ +package org.discordbots.api; + +import org.discordbots.api.entity.ProjectType; + +public final class DiscordBotListWidget { + 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/entity/ProjectType.java b/src/main/java/org/discordbots/api/client/entity/ProjectType.java deleted file mode 100644 index ca739ac..0000000 --- a/src/main/java/org/discordbots/api/client/entity/ProjectType.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.discordbots.api.client.entity; - -import com.google.gson.annotations.SerializedName; - -public enum ProjectType { - @SerializedName("bot") - DISCORD_BOT, - - @SerializedName("server") - DISCORD_SERVER -} diff --git a/src/main/java/org/discordbots/api/client/entity/PaginatedVotes.java b/src/main/java/org/discordbots/api/entity/PaginatedVotes.java similarity index 81% rename from src/main/java/org/discordbots/api/client/entity/PaginatedVotes.java rename to src/main/java/org/discordbots/api/entity/PaginatedVotes.java index dc4dad2..70d8b3f 100644 --- a/src/main/java/org/discordbots/api/client/entity/PaginatedVotes.java +++ b/src/main/java/org/discordbots/api/entity/PaginatedVotes.java @@ -1,8 +1,8 @@ -package org.discordbots.api.client.entity; +package org.discordbots.api.entity; import java.util.List; import java.util.concurrent.CompletionStage; -import org.discordbots.api.client.DiscordBotListAPI; +import org.discordbots.api.DiscordBotListAPI; public class PaginatedVotes { private final List votes; diff --git a/src/main/java/org/discordbots/api/client/entity/PartialVote.java b/src/main/java/org/discordbots/api/entity/PartialVote.java similarity index 86% rename from src/main/java/org/discordbots/api/client/entity/PartialVote.java rename to src/main/java/org/discordbots/api/entity/PartialVote.java index a0d543c..f27c90b 100644 --- a/src/main/java/org/discordbots/api/client/entity/PartialVote.java +++ b/src/main/java/org/discordbots/api/entity/PartialVote.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.entity; +package org.discordbots.api.entity; import com.google.gson.annotations.SerializedName; import java.time.OffsetDateTime; diff --git a/src/webhooks/java/org/discordbots/webhooks/Platform.java b/src/main/java/org/discordbots/api/entity/Platform.java similarity index 72% rename from src/webhooks/java/org/discordbots/webhooks/Platform.java rename to src/main/java/org/discordbots/api/entity/Platform.java index 7943265..cd01779 100644 --- a/src/webhooks/java/org/discordbots/webhooks/Platform.java +++ b/src/main/java/org/discordbots/api/entity/Platform.java @@ -1,4 +1,4 @@ -package org.discordbots.webhooks; +package org.discordbots.api.entity; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/entity/Project.java b/src/main/java/org/discordbots/api/entity/Project.java similarity index 90% rename from src/main/java/org/discordbots/api/client/entity/Project.java rename to src/main/java/org/discordbots/api/entity/Project.java index 96f09ee..4256280 100644 --- a/src/main/java/org/discordbots/api/client/entity/Project.java +++ b/src/main/java/org/discordbots/api/entity/Project.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.entity; +package org.discordbots.api.entity; import com.google.gson.annotations.SerializedName; import java.util.List; 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/client/entity/UserSource.java b/src/main/java/org/discordbots/api/entity/UserSource.java similarity index 74% rename from src/main/java/org/discordbots/api/client/entity/UserSource.java rename to src/main/java/org/discordbots/api/entity/UserSource.java index 445879b..effca21 100644 --- a/src/main/java/org/discordbots/api/client/entity/UserSource.java +++ b/src/main/java/org/discordbots/api/entity/UserSource.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.entity; +package org.discordbots.api.entity; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/entity/Vote.java b/src/main/java/org/discordbots/api/entity/Vote.java similarity index 89% rename from src/main/java/org/discordbots/api/client/entity/Vote.java rename to src/main/java/org/discordbots/api/entity/Vote.java index 03e67c6..a8d79ea 100644 --- a/src/main/java/org/discordbots/api/client/entity/Vote.java +++ b/src/main/java/org/discordbots/api/entity/Vote.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.entity; +package org.discordbots.api.entity; import com.google.gson.annotations.SerializedName; import java.time.OffsetDateTime; diff --git a/src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java b/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java similarity index 92% rename from src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java rename to src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java index 33f6141..e823759 100644 --- a/src/main/java/org/discordbots/api/client/io/DefaultResponseTransformer.java +++ b/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.io; +package org.discordbots.api.io; import com.google.gson.Gson; import java.io.IOException; diff --git a/src/main/java/org/discordbots/api/client/io/EmptyResponseTransformer.java b/src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java similarity index 78% rename from src/main/java/org/discordbots/api/client/io/EmptyResponseTransformer.java rename to src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java index c79e4d7..adbe15a 100644 --- a/src/main/java/org/discordbots/api/client/io/EmptyResponseTransformer.java +++ b/src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.io; +package org.discordbots.api.io; import okhttp3.Response; diff --git a/src/main/java/org/discordbots/api/client/io/PaginatedVotesConverter.java b/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java similarity index 81% rename from src/main/java/org/discordbots/api/client/io/PaginatedVotesConverter.java rename to src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java index 37642c0..e380c53 100644 --- a/src/main/java/org/discordbots/api/client/io/PaginatedVotesConverter.java +++ b/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.io; +package org.discordbots.api.io; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; @@ -8,9 +8,9 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.StreamSupport; -import org.discordbots.api.client.DiscordBotListAPI; -import org.discordbots.api.client.entity.PaginatedVotes; -import org.discordbots.api.client.entity.Vote; +import org.discordbots.api.DiscordBotListAPI; +import org.discordbots.api.entity.PaginatedVotes; +import org.discordbots.api.entity.Vote; public class PaginatedVotesConverter implements JsonDeserializer { private final DiscordBotListAPI client; diff --git a/src/main/java/org/discordbots/api/client/io/PostCommandsTransformer.java b/src/main/java/org/discordbots/api/io/PostCommandsTransformer.java similarity index 74% rename from src/main/java/org/discordbots/api/client/io/PostCommandsTransformer.java rename to src/main/java/org/discordbots/api/io/PostCommandsTransformer.java index 6837421..c1846e8 100644 --- a/src/main/java/org/discordbots/api/client/io/PostCommandsTransformer.java +++ b/src/main/java/org/discordbots/api/io/PostCommandsTransformer.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.io; +package org.discordbots.api.io; import java.util.concurrent.CompletionStage; diff --git a/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java b/src/main/java/org/discordbots/api/io/RawPostCommandsTransformer.java similarity index 89% rename from src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java rename to src/main/java/org/discordbots/api/io/RawPostCommandsTransformer.java index 5567173..06ae3fe 100644 --- a/src/main/java/org/discordbots/api/client/io/RawPostCommandsTransformer.java +++ b/src/main/java/org/discordbots/api/io/RawPostCommandsTransformer.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.io; +package org.discordbots.api.io; import com.google.gson.JsonArray; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java b/src/main/java/org/discordbots/api/io/ResponseTransformer.java similarity index 75% rename from src/main/java/org/discordbots/api/client/io/ResponseTransformer.java rename to src/main/java/org/discordbots/api/io/ResponseTransformer.java index 1ee6989..c0574e4 100644 --- a/src/main/java/org/discordbots/api/client/io/ResponseTransformer.java +++ b/src/main/java/org/discordbots/api/io/ResponseTransformer.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.io; +package org.discordbots.api.io; import okhttp3.Response; diff --git a/src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java b/src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java similarity index 90% rename from src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java rename to src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java index ad2b2d0..6337c44 100644 --- a/src/main/java/org/discordbots/api/client/io/UnsuccessfulHttpException.java +++ b/src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.io; +package org.discordbots.api.io; import okhttp3.Response; diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooks.java similarity index 68% rename from src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java rename to src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooks.java index ceefb1b..063584e 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/SpringBootWebhooks.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooks.java @@ -20,29 +20,36 @@ import java.util.stream.Collectors; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; -import org.discordbots.webhooks.IntegrationCreatePayload; -import org.discordbots.webhooks.IntegrationDeletePayload; -import org.discordbots.webhooks.Payload; -import org.discordbots.webhooks.TestPayload; -import org.discordbots.webhooks.VoteCreatePayload; +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.web.filter.OncePerRequestFilter; -public class SpringBootWebhooks extends OncePerRequestFilter { - private final byte[] authorization; +public class DiscordBotListWebhooks extends OncePerRequestFilter + implements DiscordBotListWebhooksListener { + private byte[] secret; private final Gson gson; - private final SpringBootWebhooks.Listener listener; - public SpringBootWebhooks( - final String authorization, final SpringBootWebhooks.Listener listener) { - this.authorization = authorization.getBytes(StandardCharsets.UTF_8); + public DiscordBotListWebhooks(final String secret) { + this.secret = secret.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder() .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) .create(); - this.listener = listener; + } + + 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 doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException { @@ -66,7 +73,7 @@ protected void doFilterInternal( assert signature != null && timestamp != null; - final SecretKeySpec key = new SecretKeySpec(this.authorization, "HmacSHA256"); + final SecretKeySpec key = new SecretKeySpec(secret, "HmacSHA256"); final Mac hmac = Mac.getInstance("HmacSHA256"); hmac.init(key); @@ -90,18 +97,17 @@ protected void doFilterInternal( try { switch (payload.getType()) { case "integration.create" -> - listener.onIntegrationCreate( + onIntegrationCreate( response, payload.getData(gson, IntegrationCreatePayload.class), trace); case "integration.delete" -> - listener.onIntegrationDelete( + onIntegrationDelete( response, payload.getData(gson, IntegrationDeletePayload.class), trace); case "webhook.test" -> - listener.onTest(response, payload.getData(gson, TestPayload.class), trace); + onTest(response, payload.getData(gson, TestPayload.class), trace); case "vote.create" -> - listener.onVoteCreate( - response, payload.getData(gson, VoteCreatePayload.class), trace); + onVoteCreate(response, payload.getData(gson, VoteCreatePayload.class), trace); } - } catch (Throwable ignored) { + } catch (final Throwable ignored) { response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.getWriter().write("Internal Server Error"); } @@ -126,25 +132,4 @@ protected void doFilterInternal( filterChain.doFilter(request, response); } - - public interface Listener { - default void onIntegrationCreate( - HttpServletResponse response, IntegrationCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - - default void onIntegrationDelete( - HttpServletResponse response, IntegrationDeletePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - - default void onTest(HttpServletResponse response, TestPayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - - default void onVoteCreate( - HttpServletResponse response, VoteCreatePayload payload, String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); - } - } } diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooksListener.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooksListener.java new file mode 100644 index 0000000..262e2bd --- /dev/null +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooksListener.java @@ -0,0 +1,27 @@ +package org.discordbots.webhooks.springboot; + +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 DiscordBotListWebhooksListener { + default void onIntegrationCreate( + HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onIntegrationDelete( + HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } + + default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, String trace) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + } +} diff --git a/src/test/java/org/discordbots/api/DiscordBotListAPITest.java b/src/test/java/org/discordbots/api/DiscordBotListAPITest.java new file mode 100644 index 0000000..cf7b7d6 --- /dev/null +++ b/src/test/java/org/discordbots/api/DiscordBotListAPITest.java @@ -0,0 +1,67 @@ +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.BeforeEach; +import org.junit.jupiter.api.Test; + +public class DiscordBotListAPITest { + private DiscordBotListAPI client; + + @BeforeEach + public void initiate() { + this.client = + new DiscordBotListAPI( + new OkHttpClient.Builder() + .addInterceptor(new GetSelfInterceptor()) + .addInterceptor(new GetVoteInterceptor()) + .addInterceptor(new GetVotesInterceptor()) + .addInterceptor(new PostCommandsInterceptor()) + .build()); + } + + @Test + public void getSelf() { + this.client.getSelf().toCompletableFuture().join(); + } + + @Test + public void postCommands() { + final JsonArray commands = + JsonParser.parseReader( + new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("PostCommands.json"), + StandardCharsets.UTF_8)) + .getAsJsonArray(); + + this.client.postCommands(commands).toCompletableFuture().join(); + } + + @Test + public void getVote() { + this.client.getVote(UserSource.DISCORD, "123456").toCompletableFuture().join(); + this.client.getVote(UserSource.TOPGG, "123456").toCompletableFuture().join(); + } + + @Test + @SuppressWarnings("unused") + public void getVotes() { + final PaginatedVotes firstPage = + this.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/interceptors/BaseInterceptor.java b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java new file mode 100644 index 0000000..77b188e --- /dev/null +++ b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java @@ -0,0 +1,63 @@ +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 InputStream inputStream = + getClass() + .getClassLoader() + .getResourceAsStream(getClass().getSimpleName().substring(0, -11) + "Response.json"); + + this.response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (final IOException ignored) { + this.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(Chain chain) throws IOException { + final Request request = chain.request(); + + final String method = request.method(); + final HttpUrl url = request.url(); + final String path = String.join("/", url.pathSegments()); + final String authorization = request.header("Authorization"); + + if (url.host().equals("top.gg") + && path.startsWith("api/v1") + && authorization != null + && authorization.startsWith("Bearer ") + && isCorrect(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/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/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/webhooks/java/org/discordbots/webhooks/PartialProject.java b/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java similarity index 86% rename from src/webhooks/java/org/discordbots/webhooks/PartialProject.java rename to src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java index d5960fb..85fff1d 100644 --- a/src/webhooks/java/org/discordbots/webhooks/PartialProject.java +++ b/src/webhooks/java/org/discordbots/webhooks/entity/PartialProject.java @@ -1,4 +1,4 @@ -package org.discordbots.webhooks; +package org.discordbots.webhooks.entity; import com.google.gson.annotations.SerializedName; diff --git a/src/main/java/org/discordbots/api/client/entity/Platform.java b/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java similarity index 69% rename from src/main/java/org/discordbots/api/client/entity/Platform.java rename to src/webhooks/java/org/discordbots/webhooks/entity/Platform.java index 07ff428..8cb1ad2 100644 --- a/src/main/java/org/discordbots/api/client/entity/Platform.java +++ b/src/webhooks/java/org/discordbots/webhooks/entity/Platform.java @@ -1,4 +1,4 @@ -package org.discordbots.api.client.entity; +package org.discordbots.webhooks.entity; import com.google.gson.annotations.SerializedName; diff --git a/src/webhooks/java/org/discordbots/webhooks/ProjectType.java b/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java similarity index 76% rename from src/webhooks/java/org/discordbots/webhooks/ProjectType.java rename to src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java index 34dd603..675f7df 100644 --- a/src/webhooks/java/org/discordbots/webhooks/ProjectType.java +++ b/src/webhooks/java/org/discordbots/webhooks/entity/ProjectType.java @@ -1,4 +1,4 @@ -package org.discordbots.webhooks; +package org.discordbots.webhooks.entity; import com.google.gson.annotations.SerializedName; diff --git a/src/webhooks/java/org/discordbots/webhooks/User.java b/src/webhooks/java/org/discordbots/webhooks/entity/User.java similarity index 86% rename from src/webhooks/java/org/discordbots/webhooks/User.java rename to src/webhooks/java/org/discordbots/webhooks/entity/User.java index 88b5865..0be2afb 100644 --- a/src/webhooks/java/org/discordbots/webhooks/User.java +++ b/src/webhooks/java/org/discordbots/webhooks/entity/User.java @@ -1,4 +1,4 @@ -package org.discordbots.webhooks; +package org.discordbots.webhooks.entity; import com.google.gson.annotations.SerializedName; diff --git a/src/webhooks/java/org/discordbots/webhooks/IntegrationCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java similarity index 74% rename from src/webhooks/java/org/discordbots/webhooks/IntegrationCreatePayload.java rename to src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java index ede7c76..99303c1 100644 --- a/src/webhooks/java/org/discordbots/webhooks/IntegrationCreatePayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationCreatePayload.java @@ -1,6 +1,8 @@ -package org.discordbots.webhooks; +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") diff --git a/src/webhooks/java/org/discordbots/webhooks/IntegrationDeletePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java similarity index 80% rename from src/webhooks/java/org/discordbots/webhooks/IntegrationDeletePayload.java rename to src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java index 604305f..2653cda 100644 --- a/src/webhooks/java/org/discordbots/webhooks/IntegrationDeletePayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/payload/IntegrationDeletePayload.java @@ -1,4 +1,4 @@ -package org.discordbots.webhooks; +package org.discordbots.webhooks.payload; import com.google.gson.annotations.SerializedName; diff --git a/src/webhooks/java/org/discordbots/webhooks/Payload.java b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java similarity index 85% rename from src/webhooks/java/org/discordbots/webhooks/Payload.java rename to src/webhooks/java/org/discordbots/webhooks/payload/Payload.java index ad02129..aa790c0 100644 --- a/src/webhooks/java/org/discordbots/webhooks/Payload.java +++ b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java @@ -1,4 +1,4 @@ -package org.discordbots.webhooks; +package org.discordbots.webhooks.payload; import com.google.gson.Gson; import com.google.gson.JsonObject; diff --git a/src/webhooks/java/org/discordbots/webhooks/TestPayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java similarity index 55% rename from src/webhooks/java/org/discordbots/webhooks/TestPayload.java rename to src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java index 9d77e07..6a90483 100644 --- a/src/webhooks/java/org/discordbots/webhooks/TestPayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/payload/TestPayload.java @@ -1,4 +1,7 @@ -package org.discordbots.webhooks; +package org.discordbots.webhooks.payload; + +import org.discordbots.webhooks.entity.PartialProject; +import org.discordbots.webhooks.entity.User; public class TestPayload { private PartialProject project; diff --git a/src/webhooks/java/org/discordbots/webhooks/VoteCreatePayload.java b/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java similarity index 78% rename from src/webhooks/java/org/discordbots/webhooks/VoteCreatePayload.java rename to src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java index 6bb89f6..9f8e819 100644 --- a/src/webhooks/java/org/discordbots/webhooks/VoteCreatePayload.java +++ b/src/webhooks/java/org/discordbots/webhooks/payload/VoteCreatePayload.java @@ -1,7 +1,9 @@ -package org.discordbots.webhooks; +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; From 6c44b941284df696bb3e1273e092ae3d6603498e Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:59:10 +0700 Subject: [PATCH 14/21] fix: make tests work --- build.gradle | 7 +++++ .../api/DiscordBotListAPITest.java | 10 +++--- .../api/DiscordBotListWidgetTest.java | 31 +++++++++++++++++++ .../api/interceptors/BaseInterceptor.java | 15 ++++----- 4 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 src/test/java/org/discordbots/api/DiscordBotListWidgetTest.java diff --git a/build.gradle b/build.gradle index 9eb396d..e654b49 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,10 @@ configurations { googleJavaFormat } +test { + useJUnitPlatform() +} + dependencies { implementation 'org.slf4j:slf4j-api:2.0.17' @@ -67,6 +71,9 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.3' testImplementation 'com.google.code.gson:gson:2.13.2' + 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' } diff --git a/src/test/java/org/discordbots/api/DiscordBotListAPITest.java b/src/test/java/org/discordbots/api/DiscordBotListAPITest.java index cf7b7d6..5418f93 100644 --- a/src/test/java/org/discordbots/api/DiscordBotListAPITest.java +++ b/src/test/java/org/discordbots/api/DiscordBotListAPITest.java @@ -15,6 +15,8 @@ import org.discordbots.api.interceptors.PostCommandsInterceptor; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; public class DiscordBotListAPITest { private DiscordBotListAPI client; @@ -48,10 +50,10 @@ public void postCommands() { this.client.postCommands(commands).toCompletableFuture().join(); } - @Test - public void getVote() { - this.client.getVote(UserSource.DISCORD, "123456").toCompletableFuture().join(); - this.client.getVote(UserSource.TOPGG, "123456").toCompletableFuture().join(); + @ParameterizedTest + @EnumSource(UserSource.class) + public void getVote(final UserSource userSource) { + this.client.getVote(userSource, "123456").toCompletableFuture().join(); } @Test diff --git a/src/test/java/org/discordbots/api/DiscordBotListWidgetTest.java b/src/test/java/org/discordbots/api/DiscordBotListWidgetTest.java new file mode 100644 index 0000000..b9ea26b --- /dev/null +++ b/src/test/java/org/discordbots/api/DiscordBotListWidgetTest.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 DiscordBotListWidgetTest { + @ParameterizedTest + @EnumSource(ProjectType.class) + public void large(final ProjectType projectType) { + DiscordBotListWidget.large(projectType, "123456"); + } + + @ParameterizedTest + @EnumSource(ProjectType.class) + public void votes(final ProjectType projectType) { + DiscordBotListWidget.votes(projectType, "123456"); + } + + @ParameterizedTest + @EnumSource(ProjectType.class) + public void owner(final ProjectType projectType) { + DiscordBotListWidget.owner(projectType, "123456"); + } + + @ParameterizedTest + @EnumSource(ProjectType.class) + public void social(final ProjectType projectType) { + DiscordBotListWidget.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 index 77b188e..dafa027 100644 --- a/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java +++ b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java @@ -17,13 +17,14 @@ public abstract class BaseInterceptor implements Interceptor { public BaseInterceptor() { try { + final String className = getClass().getSimpleName(); + final InputStream inputStream = - getClass() - .getClassLoader() - .getResourceAsStream(getClass().getSimpleName().substring(0, -11) + "Response.json"); + BaseInterceptor.class.getResourceAsStream( + "/" + className.substring(0, className.length() - 11) + "Response.json"); this.response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } catch (final IOException ignored) { + } catch (final IOException | NullPointerException ignored) { this.response = ""; } } @@ -38,16 +39,12 @@ public BaseInterceptor() { public Response intercept(Chain chain) throws IOException { final Request request = chain.request(); - final String method = request.method(); final HttpUrl url = request.url(); final String path = String.join("/", url.pathSegments()); - final String authorization = request.header("Authorization"); if (url.host().equals("top.gg") && path.startsWith("api/v1") - && authorization != null - && authorization.startsWith("Bearer ") - && isCorrect(method, path, url)) { + && isCorrect(request.method(), path, url)) { return new Response.Builder() .request(request) .protocol(Protocol.HTTP_1_1) From f8246e6a6082b8ee26056ac632705dd49d0acc0d Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:54:24 +0700 Subject: [PATCH 15/21] [feat,meta]: shorten DiscordBotList to DBL and fix feature names --- build.gradle | 125 +++++++++++++++++- ...dBotListWebhooks.java => DBLWebhooks.java} | 4 +- ...Listener.java => DBLWebhooksListener.java} | 2 +- ...dBotListWebhooks.java => DBLWebhooks.java} | 4 +- ...Listener.java => DBLWebhooksListener.java} | 2 +- .../{DiscordBotListAPI.java => DBLAPI.java} | 6 +- ...scordBotListWidget.java => DBLWidget.java} | 2 +- .../api/entity/PaginatedVotes.java | 7 +- .../api/io/PaginatedVotesConverter.java | 6 +- ...dBotListWebhooks.java => DBLWebhooks.java} | 5 +- ...Listener.java => DBLWebhooksListener.java} | 2 +- ...ordBotListAPITest.java => DBLAPITest.java} | 6 +- ...ListWidgetTest.java => DBLWidgetTest.java} | 10 +- 13 files changed, 146 insertions(+), 35 deletions(-) rename src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/{DiscordBotListWebhooks.java => DBLWebhooks.java} (96%) rename src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/{DiscordBotListWebhooksListener.java => DBLWebhooksListener.java} (92%) rename src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/{DiscordBotListWebhooks.java => DBLWebhooks.java} (96%) rename src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/{DiscordBotListWebhooksListener.java => DBLWebhooksListener.java} (93%) rename src/main/java/org/discordbots/api/{DiscordBotListAPI.java => DBLAPI.java} (97%) rename src/main/java/org/discordbots/api/{DiscordBotListWidget.java => DBLWidget.java} (92%) rename src/springBootWebhooks/java/org/discordbots/webhooks/springboot/{DiscordBotListWebhooks.java => DBLWebhooks.java} (96%) rename src/springBootWebhooks/java/org/discordbots/webhooks/springboot/{DiscordBotListWebhooksListener.java => DBLWebhooksListener.java} (93%) rename src/test/java/org/discordbots/api/{DiscordBotListAPITest.java => DBLAPITest.java} (92%) rename src/test/java/org/discordbots/api/{DiscordBotListWidgetTest.java => DBLWidgetTest.java} (68%) diff --git a/build.gradle b/build.gradle index e654b49..89fc59b 100644 --- a/build.gradle +++ b/build.gradle @@ -3,8 +3,10 @@ plugins { id 'maven-publish' } +def ver = '3.0.0' + group = 'org.discordbots' -version = '3.0.0' +version = ver description = 'The community-maintained Java library for Top.gg.' if (JavaVersion.current() < JavaVersion.VERSION_17) { @@ -26,9 +28,9 @@ sourceSets { } configurations { - dropwizardWebhooksImplementation.extendsFrom(webhooksImplementation) - eclipseJettyWebhooksImplementation.extendsFrom(webhooksImplementation) - springBootWebhooksImplementation.extendsFrom(webhooksImplementation) + dropwizardWebhooksImplementation.extendsFrom webhooksImplementation + eclipseJettyWebhooksImplementation.extendsFrom webhooksImplementation + springBootWebhooksImplementation.extendsFrom webhooksImplementation googleJavaFormat } @@ -37,6 +39,112 @@ test { useJUnitPlatform() } +java { + withSourcesJar() + + registerFeature('api') { + usingSourceSet sourceSets.main + + capability('org.discordbots', 'api', ver) + } + + 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/$version/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' @@ -71,6 +179,11 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.3' testImplementation 'com.google.code.gson:gson:2.13.2' + + 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' @@ -87,8 +200,8 @@ tasks.register('format', JavaExec) { def sources = fileTree(dir: 'src', include: '**/*.java') args = ['-i'] + sources - inputs.files(sources) - outputs.files(sources) + inputs.files sources + outputs.files sources jvmArgs( '--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED', diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooks.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java similarity index 96% rename from src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooks.java rename to src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java index 5a8d079..817c98e 100644 --- a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooks.java +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooks.java @@ -27,11 +27,11 @@ import org.discordbots.webhooks.payload.TestPayload; import org.discordbots.webhooks.payload.VoteCreatePayload; -public abstract class DiscordBotListWebhooks implements DiscordBotListWebhooksListener { +public abstract class DBLWebhooks implements DBLWebhooksListener { private byte[] secret; private final Gson gson; - public DiscordBotListWebhooks(final String secret) { + public DBLWebhooks(final String secret) { this.secret = secret.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder() diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooksListener.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java similarity index 92% rename from src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooksListener.java rename to src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java index 42a5291..d1abb0b 100644 --- a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DiscordBotListWebhooksListener.java +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java @@ -6,7 +6,7 @@ import org.discordbots.webhooks.payload.TestPayload; import org.discordbots.webhooks.payload.VoteCreatePayload; -public interface DiscordBotListWebhooksListener { +public interface DBLWebhooksListener { default Response onIntegrationCreate(IntegrationCreatePayload payload, String trace) { return Response.status(Response.Status.NO_CONTENT).build(); } diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooks.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java similarity index 96% rename from src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooks.java rename to src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java index 8fe5baf..7891a94 100644 --- a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooks.java +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java @@ -26,11 +26,11 @@ import org.discordbots.webhooks.payload.TestPayload; import org.discordbots.webhooks.payload.VoteCreatePayload; -public class DiscordBotListWebhooks extends HttpServlet implements DiscordBotListWebhooksListener { +public class DBLWebhooks extends HttpServlet implements DBLWebhooksListener { private byte[] secret; private final Gson gson; - public DiscordBotListWebhooks(final String secret) { + public DBLWebhooks(final String secret) { this.secret = secret.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder() diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooksListener.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java similarity index 93% rename from src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooksListener.java rename to src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java index 58ddbfd..0a9a1ca 100644 --- a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DiscordBotListWebhooksListener.java +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java @@ -6,7 +6,7 @@ import org.discordbots.webhooks.payload.TestPayload; import org.discordbots.webhooks.payload.VoteCreatePayload; -public interface DiscordBotListWebhooksListener { +public interface DBLWebhooksListener { default void onIntegrationCreate( HttpServletResponse response, IntegrationCreatePayload payload, String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); diff --git a/src/main/java/org/discordbots/api/DiscordBotListAPI.java b/src/main/java/org/discordbots/api/DBLAPI.java similarity index 97% rename from src/main/java/org/discordbots/api/DiscordBotListAPI.java rename to src/main/java/org/discordbots/api/DBLAPI.java index eb98ff2..70dc13c 100644 --- a/src/main/java/org/discordbots/api/DiscordBotListAPI.java +++ b/src/main/java/org/discordbots/api/DBLAPI.java @@ -31,7 +31,7 @@ import org.discordbots.api.io.ResponseTransformer; import org.discordbots.api.io.UnsuccessfulHttpException; -public class DiscordBotListAPI { +public class DBLAPI { private static final HttpUrl baseUrl = new HttpUrl.Builder() .scheme("https") @@ -43,7 +43,7 @@ public class DiscordBotListAPI { private final OkHttpClient httpClient; private final Gson gson; - public DiscordBotListAPI(final OkHttpClient httpClient) { + public DBLAPI(final OkHttpClient httpClient) { this.gson = new GsonBuilder() .registerTypeAdapter(OffsetDateTime.class, new OffsetDateTimeConverter()) @@ -53,7 +53,7 @@ public DiscordBotListAPI(final OkHttpClient httpClient) { this.httpClient = httpClient; } - public DiscordBotListAPI(final String token) { + public DBLAPI(final String token) { this( new OkHttpClient.Builder() .addInterceptor( diff --git a/src/main/java/org/discordbots/api/DiscordBotListWidget.java b/src/main/java/org/discordbots/api/DBLWidget.java similarity index 92% rename from src/main/java/org/discordbots/api/DiscordBotListWidget.java rename to src/main/java/org/discordbots/api/DBLWidget.java index ddba869..f724dc8 100644 --- a/src/main/java/org/discordbots/api/DiscordBotListWidget.java +++ b/src/main/java/org/discordbots/api/DBLWidget.java @@ -2,7 +2,7 @@ import org.discordbots.api.entity.ProjectType; -public final class DiscordBotListWidget { +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) { diff --git a/src/main/java/org/discordbots/api/entity/PaginatedVotes.java b/src/main/java/org/discordbots/api/entity/PaginatedVotes.java index 70d8b3f..8344493 100644 --- a/src/main/java/org/discordbots/api/entity/PaginatedVotes.java +++ b/src/main/java/org/discordbots/api/entity/PaginatedVotes.java @@ -2,15 +2,14 @@ import java.util.List; import java.util.concurrent.CompletionStage; -import org.discordbots.api.DiscordBotListAPI; +import org.discordbots.api.DBLAPI; public class PaginatedVotes { private final List votes; private final String cursor; - private final DiscordBotListAPI client; + private final DBLAPI client; - public PaginatedVotes( - final List votes, final String cursor, final DiscordBotListAPI client) { + public PaginatedVotes(final List votes, final String cursor, final DBLAPI client) { this.votes = votes; this.cursor = cursor; this.client = client; diff --git a/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java b/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java index e380c53..a3216ff 100644 --- a/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java +++ b/src/main/java/org/discordbots/api/io/PaginatedVotesConverter.java @@ -8,14 +8,14 @@ import java.util.List; import java.util.stream.Collectors; import java.util.stream.StreamSupport; -import org.discordbots.api.DiscordBotListAPI; +import org.discordbots.api.DBLAPI; import org.discordbots.api.entity.PaginatedVotes; import org.discordbots.api.entity.Vote; public class PaginatedVotesConverter implements JsonDeserializer { - private final DiscordBotListAPI client; + private final DBLAPI client; - public PaginatedVotesConverter(final DiscordBotListAPI client) { + public PaginatedVotesConverter(final DBLAPI client) { this.client = client; } diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooks.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java similarity index 96% rename from src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooks.java rename to src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java index 063584e..8af5777 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooks.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java @@ -27,12 +27,11 @@ import org.discordbots.webhooks.payload.VoteCreatePayload; import org.springframework.web.filter.OncePerRequestFilter; -public class DiscordBotListWebhooks extends OncePerRequestFilter - implements DiscordBotListWebhooksListener { +public class DBLWebhooks extends OncePerRequestFilter implements DBLWebhooksListener { private byte[] secret; private final Gson gson; - public DiscordBotListWebhooks(final String secret) { + public DBLWebhooks(final String secret) { this.secret = secret.getBytes(StandardCharsets.UTF_8); this.gson = new GsonBuilder() diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooksListener.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java similarity index 93% rename from src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooksListener.java rename to src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java index 262e2bd..033910b 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DiscordBotListWebhooksListener.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java @@ -6,7 +6,7 @@ import org.discordbots.webhooks.payload.TestPayload; import org.discordbots.webhooks.payload.VoteCreatePayload; -public interface DiscordBotListWebhooksListener { +public interface DBLWebhooksListener { default void onIntegrationCreate( HttpServletResponse response, IntegrationCreatePayload payload, String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); diff --git a/src/test/java/org/discordbots/api/DiscordBotListAPITest.java b/src/test/java/org/discordbots/api/DBLAPITest.java similarity index 92% rename from src/test/java/org/discordbots/api/DiscordBotListAPITest.java rename to src/test/java/org/discordbots/api/DBLAPITest.java index 5418f93..02a2417 100644 --- a/src/test/java/org/discordbots/api/DiscordBotListAPITest.java +++ b/src/test/java/org/discordbots/api/DBLAPITest.java @@ -18,13 +18,13 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; -public class DiscordBotListAPITest { - private DiscordBotListAPI client; +public class DBLAPITest { + private DBLAPI client; @BeforeEach public void initiate() { this.client = - new DiscordBotListAPI( + new DBLAPI( new OkHttpClient.Builder() .addInterceptor(new GetSelfInterceptor()) .addInterceptor(new GetVoteInterceptor()) diff --git a/src/test/java/org/discordbots/api/DiscordBotListWidgetTest.java b/src/test/java/org/discordbots/api/DBLWidgetTest.java similarity index 68% rename from src/test/java/org/discordbots/api/DiscordBotListWidgetTest.java rename to src/test/java/org/discordbots/api/DBLWidgetTest.java index b9ea26b..1d8780f 100644 --- a/src/test/java/org/discordbots/api/DiscordBotListWidgetTest.java +++ b/src/test/java/org/discordbots/api/DBLWidgetTest.java @@ -4,28 +4,28 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; -public class DiscordBotListWidgetTest { +public class DBLWidgetTest { @ParameterizedTest @EnumSource(ProjectType.class) public void large(final ProjectType projectType) { - DiscordBotListWidget.large(projectType, "123456"); + DBLWidget.large(projectType, "123456"); } @ParameterizedTest @EnumSource(ProjectType.class) public void votes(final ProjectType projectType) { - DiscordBotListWidget.votes(projectType, "123456"); + DBLWidget.votes(projectType, "123456"); } @ParameterizedTest @EnumSource(ProjectType.class) public void owner(final ProjectType projectType) { - DiscordBotListWidget.owner(projectType, "123456"); + DBLWidget.owner(projectType, "123456"); } @ParameterizedTest @EnumSource(ProjectType.class) public void social(final ProjectType projectType) { - DiscordBotListWidget.social(projectType, "123456"); + DBLWidget.social(projectType, "123456"); } } From 49fc48e2b544d1a9ae50244aeba210518afa0ac2 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:16:58 +0700 Subject: [PATCH 16/21] feat: add dropwizard webhook tests --- build.gradle | 6 ++ .../dropwizard/DBLWebhooksListener.java | 8 +-- .../eclipsejetty/DBLWebhooksListener.java | 14 ++-- src/main/java/org/discordbots/api/DBLAPI.java | 12 ++-- .../webhooks/springboot/DBLWebhooks.java | 2 +- .../springboot/DBLWebhooksListener.java | 14 ++-- .../java/org/discordbots/api/DBLAPITest.java | 18 ++--- .../api/interceptors/BaseInterceptor.java | 4 +- .../discordbots/webhooks/MockPayloads.java | 26 +++++++ .../discordbots/webhooks/MockSignature.java | 38 ++++++++++ .../webhooks/dropwizard/CustomServer.java | 16 +++++ .../webhooks/dropwizard/CustomWebhooks.java | 35 ++++++++++ .../webhooks/dropwizard/DBLWebhooksTest.java | 70 +++++++++++++++++++ .../resources/IntegrationCreatePayload.json | 19 +++++ .../resources/IntegrationDeletePayload.json | 6 ++ src/test/resources/TestPayload.json | 17 +++++ src/test/resources/VoteCreatePayload.json | 21 ++++++ src/test/resources/dropwizard-test-config.yml | 7 ++ 18 files changed, 303 insertions(+), 30 deletions(-) create mode 100644 src/test/java/org/discordbots/webhooks/MockPayloads.java create mode 100644 src/test/java/org/discordbots/webhooks/MockSignature.java create mode 100644 src/test/java/org/discordbots/webhooks/dropwizard/CustomServer.java create mode 100644 src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java create mode 100644 src/test/java/org/discordbots/webhooks/dropwizard/DBLWebhooksTest.java create mode 100644 src/test/resources/IntegrationCreatePayload.json create mode 100644 src/test/resources/IntegrationDeletePayload.json create mode 100644 src/test/resources/TestPayload.json create mode 100644 src/test/resources/VoteCreatePayload.json create mode 100644 src/test/resources/dropwizard-test-config.yml diff --git a/build.gradle b/build.gradle index 89fc59b..44bac01 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,11 @@ configurations { eclipseJettyWebhooksImplementation.extendsFrom webhooksImplementation springBootWebhooksImplementation.extendsFrom webhooksImplementation + + testImplementation.extendsFrom dropwizardWebhooksImplementation + testImplementation.extendsFrom eclipseJettyWebhooksImplementation + testImplementation.extendsFrom springBootWebhooksImplementation + googleJavaFormat } @@ -179,6 +184,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.3' testImplementation 'com.google.code.gson:gson:2.13.2' + testImplementation 'io.dropwizard:dropwizard-testing:5.0.1' testImplementation sourceSets.dropwizardWebhooks.output testImplementation sourceSets.eclipseJettyWebhooks.output diff --git a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java index d1abb0b..5a76818 100644 --- a/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java +++ b/src/dropwizardWebhooks/java/org/discordbots/webhooks/dropwizard/DBLWebhooksListener.java @@ -7,19 +7,19 @@ import org.discordbots.webhooks.payload.VoteCreatePayload; public interface DBLWebhooksListener { - default Response onIntegrationCreate(IntegrationCreatePayload payload, String trace) { + default Response onIntegrationCreate(final IntegrationCreatePayload payload, final String trace) { return Response.status(Response.Status.NO_CONTENT).build(); } - default Response onIntegrationDelete(IntegrationDeletePayload payload, String trace) { + default Response onIntegrationDelete(final IntegrationDeletePayload payload, final String trace) { return Response.status(Response.Status.NO_CONTENT).build(); } - default Response onTest(TestPayload payload, String trace) { + default Response onTest(final TestPayload payload, final String trace) { return Response.status(Response.Status.NO_CONTENT).build(); } - default Response onVoteCreate(VoteCreatePayload payload, String trace) { + 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/DBLWebhooksListener.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java index 0a9a1ca..cee3a90 100644 --- a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooksListener.java @@ -8,20 +8,26 @@ public interface DBLWebhooksListener { default void onIntegrationCreate( - HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + final HttpServletResponse response, + final IntegrationCreatePayload payload, + final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } default void onIntegrationDelete( - HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + final HttpServletResponse response, + final IntegrationDeletePayload payload, + final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } - default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + default void onTest( + final HttpServletResponse response, final TestPayload payload, final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } - default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, String trace) { + default void onVoteCreate( + final HttpServletResponse response, final VoteCreatePayload payload, final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } } diff --git a/src/main/java/org/discordbots/api/DBLAPI.java b/src/main/java/org/discordbots/api/DBLAPI.java index 70dc13c..7ccbc31 100644 --- a/src/main/java/org/discordbots/api/DBLAPI.java +++ b/src/main/java/org/discordbots/api/DBLAPI.java @@ -32,7 +32,7 @@ import org.discordbots.api.io.UnsuccessfulHttpException; public class DBLAPI { - private static final HttpUrl baseUrl = + private static final HttpUrl BASE_URL = new HttpUrl.Builder() .scheme("https") .host("top.gg") @@ -69,14 +69,14 @@ public DBLAPI(final String token) { public CompletionStage getSelf() { final HttpUrl url = - baseUrl.newBuilder().addPathSegment("projects").addPathSegment("@me").build(); + BASE_URL.newBuilder().addPathSegment("projects").addPathSegment("@me").build(); return get(url, Project.class); } public CompletionStage postCommands(final PostCommandsTransformer commands) { final HttpUrl url = - baseUrl + BASE_URL .newBuilder() .addPathSegment("projects") .addPathSegment("@me") @@ -94,7 +94,7 @@ public CompletionStage postCommands(final JsonArray commands) { public CompletionStage getVote(final UserSource userSource, final String id) { final HttpUrl url = - baseUrl + BASE_URL .newBuilder() .addPathSegment("projects") .addPathSegment("@me") @@ -117,7 +117,7 @@ public CompletionStage getVote(final UserSource userSource, final S public CompletionStage getVotes(final TemporalAccessor since) { final HttpUrl url = - baseUrl + BASE_URL .newBuilder() .addPathSegment("projects") .addPathSegment("@me") @@ -130,7 +130,7 @@ public CompletionStage getVotes(final TemporalAccessor since) { public CompletionStage getVotes(final String cursor) { final HttpUrl url = - baseUrl + BASE_URL .newBuilder() .addPathSegment("projects") .addPathSegment("@me") diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java index 8af5777..f528a6b 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java @@ -50,7 +50,7 @@ public void setSecret(final String newSecret) { @Override @SuppressWarnings("UseSpecificCatch") protected void doFilterInternal( - HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws IOException, ServletException { if (request.getMethod().equalsIgnoreCase("POST")) { final String signatureHeader = request.getHeader("x-topgg-signature"); diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java index 033910b..3bfea2c 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java @@ -8,20 +8,26 @@ public interface DBLWebhooksListener { default void onIntegrationCreate( - HttpServletResponse response, IntegrationCreatePayload payload, String trace) { + final HttpServletResponse response, + final IntegrationCreatePayload payload, + final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } default void onIntegrationDelete( - HttpServletResponse response, IntegrationDeletePayload payload, String trace) { + final HttpServletResponse response, + final IntegrationDeletePayload payload, + final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } - default void onTest(HttpServletResponse response, TestPayload payload, String trace) { + default void onTest( + final HttpServletResponse response, final TestPayload payload, final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } - default void onVoteCreate(HttpServletResponse response, VoteCreatePayload payload, String trace) { + default void onVoteCreate( + final HttpServletResponse response, final VoteCreatePayload payload, final String trace) { response.setStatus(HttpServletResponse.SC_NO_CONTENT); } } diff --git a/src/test/java/org/discordbots/api/DBLAPITest.java b/src/test/java/org/discordbots/api/DBLAPITest.java index 02a2417..99fb651 100644 --- a/src/test/java/org/discordbots/api/DBLAPITest.java +++ b/src/test/java/org/discordbots/api/DBLAPITest.java @@ -13,17 +13,17 @@ import org.discordbots.api.interceptors.GetVoteInterceptor; import org.discordbots.api.interceptors.GetVotesInterceptor; import org.discordbots.api.interceptors.PostCommandsInterceptor; -import org.junit.jupiter.api.BeforeEach; +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 DBLAPI client; + private static DBLAPI CLIENT; - @BeforeEach - public void initiate() { - this.client = + @BeforeAll + public static void setup() { + CLIENT = new DBLAPI( new OkHttpClient.Builder() .addInterceptor(new GetSelfInterceptor()) @@ -35,7 +35,7 @@ public void initiate() { @Test public void getSelf() { - this.client.getSelf().toCompletableFuture().join(); + CLIENT.getSelf().toCompletableFuture().join(); } @Test @@ -47,20 +47,20 @@ public void postCommands() { StandardCharsets.UTF_8)) .getAsJsonArray(); - this.client.postCommands(commands).toCompletableFuture().join(); + CLIENT.postCommands(commands).toCompletableFuture().join(); } @ParameterizedTest @EnumSource(UserSource.class) public void getVote(final UserSource userSource) { - this.client.getVote(userSource, "123456").toCompletableFuture().join(); + CLIENT.getVote(userSource, "123456").toCompletableFuture().join(); } @Test @SuppressWarnings("unused") public void getVotes() { final PaginatedVotes firstPage = - this.client + CLIENT .getVotes(OffsetDateTime.of(2026, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC)) .toCompletableFuture() .join(); diff --git a/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java index dafa027..bc9d6bd 100644 --- a/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java +++ b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java @@ -23,9 +23,9 @@ public BaseInterceptor() { BaseInterceptor.class.getResourceAsStream( "/" + className.substring(0, className.length() - 11) + "Response.json"); - this.response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + response = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); } catch (final IOException | NullPointerException ignored) { - this.response = ""; + response = ""; } } diff --git a/src/test/java/org/discordbots/webhooks/MockPayloads.java b/src/test/java/org/discordbots/webhooks/MockPayloads.java new file mode 100644 index 0000000..8ef29de --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/MockPayloads.java @@ -0,0 +1,26 @@ +package org.discordbots.webhooks; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public class MockPayloads { + public final String integrationCreate; + public final String integrationDelete; + public final String test; + public final String voteCreate; + + public MockPayloads() throws IOException, NullPointerException { + integrationCreate = read("IntegrationCreate"); + integrationDelete = read("IntegrationDelete"); + test = read("Test"); + voteCreate = read("VoteCreate"); + } + + private static String read(final String name) throws IOException, NullPointerException { + final InputStream inputStream = + MockPayloads.class.getResourceAsStream("/" + name + "Payload.json"); + + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/org/discordbots/webhooks/MockSignature.java b/src/test/java/org/discordbots/webhooks/MockSignature.java new file mode 100644 index 0000000..ce64580 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/MockSignature.java @@ -0,0 +1,38 @@ +package org.discordbots.webhooks; + +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 MockSignature { + private final long timestamp; + private final String signature; + + public MockSignature(final String secret, final String body) + throws NoSuchAlgorithmException, InvalidKeyException { + 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)); + + signature = HexFormat.of().formatHex(digest); + } + + public long getTimestamp() { + return timestamp; + } + + public String getSignatureHeader() { + return "t=" + Long.toString(timestamp) + ",v1=" + signature; + } +} 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..f96a86d --- /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(String[] args) throws Exception { + new CustomServer().run(args); + } + + @Override + public void run(Configuration config, 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..fcd5349 --- /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("integrationCreate," + trace).build(); + } + + @Override + public Response onIntegrationDelete(final IntegrationDeletePayload payload, final String trace) { + return Response.status(Response.Status.OK).entity("integrationDelete," + trace).build(); + } + + @Override + public Response onTest(final TestPayload payload, final String trace) { + return Response.status(Response.Status.OK).entity("test," + trace).build(); + } + + @Override + public Response onVoteCreate(final VoteCreatePayload payload, final String trace) { + return Response.status(Response.Status.OK).entity("voteCreate," + trace).build(); + } +} diff --git a/src/test/java/org/discordbots/webhooks/dropwizard/DBLWebhooksTest.java b/src/test/java/org/discordbots/webhooks/dropwizard/DBLWebhooksTest.java new file mode 100644 index 0000000..a4ade5e --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/dropwizard/DBLWebhooksTest.java @@ -0,0 +1,70 @@ +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.MockPayloads; +import org.discordbots.webhooks.MockSignature; +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 DBLWebhooksTest { + 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 MockPayloads MOCK_PAYLOADS; + + @BeforeAll + public static void setup() throws IOException, NullPointerException { + MOCK_PAYLOADS = new MockPayloads(); + } + + private void send(final String name, final String payload) + throws NoSuchAlgorithmException, InvalidKeyException { + final MockSignature signature = new MockSignature(SECRET, payload); + + final Response response = + APP.client() + .target(String.format("http://localhost:%d/webhook", APP.getLocalPort())) + .request() + .header("Content-Type", "application/json") + .header("x-topgg-signature", signature.getSignatureHeader()) + .header("x-topgg-trace", TRACE) + .post(Entity.entity(payload, MediaType.APPLICATION_JSON)); + + Assertions.assertEquals(200, response.getStatus()); + Assertions.assertEquals(name + "," + TRACE, response.readEntity(String.class)); + } + + @Test + void integrationCreate() throws NoSuchAlgorithmException, InvalidKeyException { + send("integrationCreate", MOCK_PAYLOADS.integrationCreate); + } + + @Test + void integrationDelete() throws NoSuchAlgorithmException, InvalidKeyException { + send("integrationDelete", MOCK_PAYLOADS.integrationDelete); + } + + @Test + void test() throws NoSuchAlgorithmException, InvalidKeyException { + send("test", MOCK_PAYLOADS.test); + } + + @Test + void voteCreate() throws NoSuchAlgorithmException, InvalidKeyException { + send("voteCreate", MOCK_PAYLOADS.voteCreate); + } +} 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/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 From 206d4eca2d62596461feb9f95d2df1c447b447ad Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:38:30 +0700 Subject: [PATCH 17/21] feat: add eclipse jetty webhooks tests --- build.gradle | 1 + .../webhooks/DBLWebhooksTestSuite.java | 10 ++ .../webhooks/dropwizard/CustomWebhooks.java | 8 +- ...st.java => DBLDropwizardWebhooksTest.java} | 5 +- .../webhooks/eclipsejetty/CustomWebhooks.java | 51 +++++++++ .../DBLEclipseJettyWebhooksTest.java | 107 ++++++++++++++++++ 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java rename src/test/java/org/discordbots/webhooks/dropwizard/{DBLWebhooksTest.java => DBLDropwizardWebhooksTest.java} (92%) create mode 100644 src/test/java/org/discordbots/webhooks/eclipsejetty/CustomWebhooks.java create mode 100644 src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java diff --git a/build.gradle b/build.gradle index 44bac01..98dbb16 100644 --- a/build.gradle +++ b/build.gradle @@ -183,6 +183,7 @@ dependencies { 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' 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..16b4890 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java @@ -0,0 +1,10 @@ +package org.discordbots.webhooks; + +import org.discordbots.webhooks.dropwizard.DBLDropwizardWebhooksTest; +import org.discordbots.webhooks.eclipsejetty.DBLEclipseJettyWebhooksTest; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({DBLDropwizardWebhooksTest.class, DBLEclipseJettyWebhooksTest.class}) +public class DBLWebhooksTestSuite {} diff --git a/src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java b/src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java index fcd5349..246abd8 100644 --- a/src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java +++ b/src/test/java/org/discordbots/webhooks/dropwizard/CustomWebhooks.java @@ -15,21 +15,21 @@ public CustomWebhooks() { @Override public Response onIntegrationCreate(final IntegrationCreatePayload payload, final String trace) { - return Response.status(Response.Status.OK).entity("integrationCreate," + trace).build(); + 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("integrationDelete," + trace).build(); + 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("test," + trace).build(); + 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("voteCreate," + trace).build(); + return Response.status(Response.Status.OK).entity("dw:voteCreate," + trace).build(); } } diff --git a/src/test/java/org/discordbots/webhooks/dropwizard/DBLWebhooksTest.java b/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java similarity index 92% rename from src/test/java/org/discordbots/webhooks/dropwizard/DBLWebhooksTest.java rename to src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java index a4ade5e..ef7de35 100644 --- a/src/test/java/org/discordbots/webhooks/dropwizard/DBLWebhooksTest.java +++ b/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java @@ -18,10 +18,11 @@ import org.junit.jupiter.api.extension.ExtendWith; @ExtendWith(DropwizardExtensionsSupport.class) -public class DBLWebhooksTest { +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 MockPayloads MOCK_PAYLOADS; @@ -45,7 +46,7 @@ private void send(final String name, final String payload) .post(Entity.entity(payload, MediaType.APPLICATION_JSON)); Assertions.assertEquals(200, response.getStatus()); - Assertions.assertEquals(name + "," + TRACE, response.readEntity(String.class)); + Assertions.assertEquals("dw:" + name + "," + TRACE, response.readEntity(String.class)); } @Test 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..13f2db3 --- /dev/null +++ b/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java @@ -0,0 +1,107 @@ +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.MockPayloads; +import org.discordbots.webhooks.MockSignature; +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 MockPayloads MOCK_PAYLOADS; + + @BeforeAll + public static void setup() throws IOException, NullPointerException, Exception { + MOCK_PAYLOADS = new MockPayloads(); + + 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 MockSignature signature = new MockSignature(SECRET, payload); + + 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", signature.getSignatureHeader()); + 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 + void integrationCreate() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("integrationCreate", MOCK_PAYLOADS.integrationCreate); + } + + @Test + void integrationDelete() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("integrationDelete", MOCK_PAYLOADS.integrationDelete); + } + + @Test + void test() throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("test", MOCK_PAYLOADS.test); + } + + @Test + void voteCreate() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("voteCreate", MOCK_PAYLOADS.voteCreate); + } + + @AfterAll + public static void cleanup() throws Exception { + if (SERVER != null) { + SERVER.stop(); + } + } +} From e70045811c564d707da39a38601e571dc240b2f0 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 07:28:24 +0700 Subject: [PATCH 18/21] [feat,refactor,fix]: finalize webhooks --- build.gradle | 8 +- .../webhooks/eclipsejetty/DBLWebhooks.java | 6 +- src/main/java/org/discordbots/api/DBLAPI.java | 4 +- .../api/io/DefaultResponseTransformer.java | 4 +- .../api/io/EmptyResponseTransformer.java | 2 +- .../api/io/ResponseTransformer.java | 2 +- .../api/io/UnsuccessfulHttpException.java | 2 +- .../webhooks/springboot/DBLWebhooks.java | 143 +++++++----------- .../springboot/DBLWebhooksListener.java | 31 ++-- .../api/interceptors/BaseInterceptor.java | 2 +- .../webhooks/DBLWebhooksTestSuite.java | 7 +- .../discordbots/webhooks/MockPayloads.java | 26 ---- .../discordbots/webhooks/MockSignature.java | 38 ----- .../java/org/discordbots/webhooks/Mocks.java | 47 ++++++ .../webhooks/dropwizard/CustomServer.java | 4 +- .../dropwizard/DBLDropwizardWebhooksTest.java | 27 ++-- .../DBLEclipseJettyWebhooksTest.java | 28 ++-- .../webhooks/springboot/CustomServer.java | 11 ++ .../CustomServerSecurityConfiguration.java | 16 ++ .../webhooks/springboot/CustomWebhooks.java | 49 ++++++ .../springboot/DBLSpringBootWebhooksTest.java | 59 ++++++++ .../discordbots/webhooks/payload/Payload.java | 2 +- 22 files changed, 301 insertions(+), 217 deletions(-) delete mode 100644 src/test/java/org/discordbots/webhooks/MockPayloads.java delete mode 100644 src/test/java/org/discordbots/webhooks/MockSignature.java create mode 100644 src/test/java/org/discordbots/webhooks/Mocks.java create mode 100644 src/test/java/org/discordbots/webhooks/springboot/CustomServer.java create mode 100644 src/test/java/org/discordbots/webhooks/springboot/CustomServerSecurityConfiguration.java create mode 100644 src/test/java/org/discordbots/webhooks/springboot/CustomWebhooks.java create mode 100644 src/test/java/org/discordbots/webhooks/springboot/DBLSpringBootWebhooksTest.java diff --git a/build.gradle b/build.gradle index 98dbb16..3afa435 100644 --- a/build.gradle +++ b/build.gradle @@ -47,12 +47,6 @@ test { java { withSourcesJar() - registerFeature('api') { - usingSourceSet sourceSets.main - - capability('org.discordbots', 'api', ver) - } - registerFeature('jdaWrapper') { usingSourceSet sourceSets.jdaWrapper @@ -186,6 +180,8 @@ dependencies { 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 diff --git a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java index 7891a94..c27d3f7 100644 --- a/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java +++ b/src/eclipseJettyWebhooks/java/org/discordbots/webhooks/eclipsejetty/DBLWebhooks.java @@ -48,7 +48,7 @@ public void setSecret(final String newSecret) { @Override @SuppressWarnings("UseSpecificCatch") - protected void doPost(HttpServletRequest request, HttpServletResponse response) + protected void doPost(final HttpServletRequest request, final HttpServletResponse response) throws IOException, ServletException { try { final String signatureHeader = request.getHeader("x-topgg-signature"); @@ -101,6 +101,10 @@ protected void doPost(HttpServletRequest request, HttpServletResponse response) 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); diff --git a/src/main/java/org/discordbots/api/DBLAPI.java b/src/main/java/org/discordbots/api/DBLAPI.java index 7ccbc31..2fe5abf 100644 --- a/src/main/java/org/discordbots/api/DBLAPI.java +++ b/src/main/java/org/discordbots/api/DBLAPI.java @@ -166,13 +166,13 @@ private CompletionStage execute( call.enqueue( new Callback() { @Override - public void onFailure(Call call, IOException error) { + public void onFailure(final Call call, final IOException error) { future.completeExceptionally(error); } @Override @SuppressWarnings("UseSpecificCatch") - public void onResponse(Call call, Response response) { + public void onResponse(final Call call, final Response response) { try { if (response.isSuccessful()) { future.complete(responseTransformer.transform(response)); diff --git a/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java b/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java index e823759..a7e4cba 100644 --- a/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java +++ b/src/main/java/org/discordbots/api/io/DefaultResponseTransformer.java @@ -8,13 +8,13 @@ public class DefaultResponseTransformer implements ResponseTransformer { private final Class aClass; private final Gson gson; - public DefaultResponseTransformer(Class aClass, Gson gson) { + public DefaultResponseTransformer(final Class aClass, final Gson gson) { this.aClass = aClass; this.gson = gson; } @Override - public E transform(Response response) throws IOException { + 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 index adbe15a..61f989c 100644 --- a/src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java +++ b/src/main/java/org/discordbots/api/io/EmptyResponseTransformer.java @@ -4,7 +4,7 @@ public class EmptyResponseTransformer implements ResponseTransformer { @Override - public Void transform(Response response) { + public Void transform(final Response response) { return null; } } diff --git a/src/main/java/org/discordbots/api/io/ResponseTransformer.java b/src/main/java/org/discordbots/api/io/ResponseTransformer.java index c0574e4..6eb55f1 100644 --- a/src/main/java/org/discordbots/api/io/ResponseTransformer.java +++ b/src/main/java/org/discordbots/api/io/ResponseTransformer.java @@ -3,5 +3,5 @@ import okhttp3.Response; public interface ResponseTransformer { - E transform(Response response) throws Exception; + 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 index 6337c44..1665aa1 100644 --- a/src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java +++ b/src/main/java/org/discordbots/api/io/UnsuccessfulHttpException.java @@ -5,7 +5,7 @@ public class UnsuccessfulHttpException extends Exception { private final Response response; - public UnsuccessfulHttpException(Response response) { + public UnsuccessfulHttpException(final Response response) { super( "The server responded with code: " + response.code() + ", message: " + response.message()); diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java index f528a6b..e6ebee0 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooks.java @@ -5,11 +5,6 @@ import com.google.gson.GsonBuilder; import com.google.gson.JsonIOException; import com.google.gson.JsonSyntaxException; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -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; @@ -25,9 +20,10 @@ import org.discordbots.webhooks.payload.Payload; import org.discordbots.webhooks.payload.TestPayload; import org.discordbots.webhooks.payload.VoteCreatePayload; -import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; -public class DBLWebhooks extends OncePerRequestFilter implements DBLWebhooksListener { +public class DBLWebhooks implements DBLWebhooksListener { private byte[] secret; private final Gson gson; @@ -47,88 +43,63 @@ public void setSecret(final String newSecret) { secret = newSecret.getBytes(StandardCharsets.UTF_8); } - @Override @SuppressWarnings("UseSpecificCatch") - protected void doFilterInternal( - HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) - throws IOException, ServletException { - if (request.getMethod().equalsIgnoreCase("POST")) { - final String signatureHeader = request.getHeader("x-topgg-signature"); - - if (signatureHeader != null) { - 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 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); - } - } 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"); - } - } + 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(); + } - return; + 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(); } - - filterChain.doFilter(request, response); } } diff --git a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java index 3bfea2c..c1f2df6 100644 --- a/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java +++ b/src/springBootWebhooks/java/org/discordbots/webhooks/springboot/DBLWebhooksListener.java @@ -1,33 +1,28 @@ package org.discordbots.webhooks.springboot; -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; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; -public interface DBLWebhooksListener { - default void onIntegrationCreate( - final HttpServletResponse response, - final IntegrationCreatePayload payload, - final String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); +public interface DBLWebhooksListener { + default ResponseEntity onIntegrationCreate( + final IntegrationCreatePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } - default void onIntegrationDelete( - final HttpServletResponse response, - final IntegrationDeletePayload payload, - final String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); + default ResponseEntity onIntegrationDelete( + final IntegrationDeletePayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } - default void onTest( - final HttpServletResponse response, final TestPayload payload, final String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); + default ResponseEntity onTest(final TestPayload payload, final String trace) { + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } - default void onVoteCreate( - final HttpServletResponse response, final VoteCreatePayload payload, final String trace) { - response.setStatus(HttpServletResponse.SC_NO_CONTENT); + 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/interceptors/BaseInterceptor.java b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java index bc9d6bd..63147a6 100644 --- a/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java +++ b/src/test/java/org/discordbots/api/interceptors/BaseInterceptor.java @@ -36,7 +36,7 @@ public BaseInterceptor() { protected abstract String getMessage(); @Override - public Response intercept(Chain chain) throws IOException { + public Response intercept(final Chain chain) throws IOException { final Request request = chain.request(); final HttpUrl url = request.url(); diff --git a/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java b/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java index 16b4890..66f58ae 100644 --- a/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java +++ b/src/test/java/org/discordbots/webhooks/DBLWebhooksTestSuite.java @@ -2,9 +2,14 @@ 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}) +@SelectClasses({ + DBLDropwizardWebhooksTest.class, + DBLEclipseJettyWebhooksTest.class, + DBLSpringBootWebhooksTest.class +}) public class DBLWebhooksTestSuite {} diff --git a/src/test/java/org/discordbots/webhooks/MockPayloads.java b/src/test/java/org/discordbots/webhooks/MockPayloads.java deleted file mode 100644 index 8ef29de..0000000 --- a/src/test/java/org/discordbots/webhooks/MockPayloads.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.discordbots.webhooks; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; - -public class MockPayloads { - public final String integrationCreate; - public final String integrationDelete; - public final String test; - public final String voteCreate; - - public MockPayloads() throws IOException, NullPointerException { - integrationCreate = read("IntegrationCreate"); - integrationDelete = read("IntegrationDelete"); - test = read("Test"); - voteCreate = read("VoteCreate"); - } - - private static String read(final String name) throws IOException, NullPointerException { - final InputStream inputStream = - MockPayloads.class.getResourceAsStream("/" + name + "Payload.json"); - - return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - } -} diff --git a/src/test/java/org/discordbots/webhooks/MockSignature.java b/src/test/java/org/discordbots/webhooks/MockSignature.java deleted file mode 100644 index ce64580..0000000 --- a/src/test/java/org/discordbots/webhooks/MockSignature.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.discordbots.webhooks; - -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 MockSignature { - private final long timestamp; - private final String signature; - - public MockSignature(final String secret, final String body) - throws NoSuchAlgorithmException, InvalidKeyException { - 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)); - - signature = HexFormat.of().formatHex(digest); - } - - public long getTimestamp() { - return timestamp; - } - - public String getSignatureHeader() { - return "t=" + Long.toString(timestamp) + ",v1=" + signature; - } -} 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 index f96a86d..0885b6c 100644 --- a/src/test/java/org/discordbots/webhooks/dropwizard/CustomServer.java +++ b/src/test/java/org/discordbots/webhooks/dropwizard/CustomServer.java @@ -5,12 +5,12 @@ import io.dropwizard.core.setup.Environment; public class CustomServer extends Application { - public static void main(String[] args) throws Exception { + public static void main(final String[] args) throws Exception { new CustomServer().run(args); } @Override - public void run(Configuration config, Environment env) { + public void run(final Configuration config, final Environment env) { env.jersey().register(new CustomWebhooks()); } } diff --git a/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java b/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java index ef7de35..0456eee 100644 --- a/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java +++ b/src/test/java/org/discordbots/webhooks/dropwizard/DBLDropwizardWebhooksTest.java @@ -10,8 +10,7 @@ import java.io.IOException; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import org.discordbots.webhooks.MockPayloads; -import org.discordbots.webhooks.MockSignature; +import org.discordbots.webhooks.Mocks; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -25,23 +24,21 @@ public class DBLDropwizardWebhooksTest { private static final String SECRET = System.getenv("TOPGG_WEBHOOK_SECRET"); private static final String TRACE = "trace"; - private static MockPayloads MOCK_PAYLOADS; + private static Mocks MOCKS; @BeforeAll public static void setup() throws IOException, NullPointerException { - MOCK_PAYLOADS = new MockPayloads(); + MOCKS = new Mocks(); } private void send(final String name, final String payload) throws NoSuchAlgorithmException, InvalidKeyException { - final MockSignature signature = new MockSignature(SECRET, payload); - final Response response = APP.client() .target(String.format("http://localhost:%d/webhook", APP.getLocalPort())) .request() .header("Content-Type", "application/json") - .header("x-topgg-signature", signature.getSignatureHeader()) + .header("x-topgg-signature", Mocks.signature(SECRET, payload)) .header("x-topgg-trace", TRACE) .post(Entity.entity(payload, MediaType.APPLICATION_JSON)); @@ -50,22 +47,22 @@ private void send(final String name, final String payload) } @Test - void integrationCreate() throws NoSuchAlgorithmException, InvalidKeyException { - send("integrationCreate", MOCK_PAYLOADS.integrationCreate); + public void integrationCreate() throws NoSuchAlgorithmException, InvalidKeyException { + send("integrationCreate", MOCKS.integrationCreatePayload); } @Test - void integrationDelete() throws NoSuchAlgorithmException, InvalidKeyException { - send("integrationDelete", MOCK_PAYLOADS.integrationDelete); + public void integrationDelete() throws NoSuchAlgorithmException, InvalidKeyException { + send("integrationDelete", MOCKS.integrationDeletePayload); } @Test - void test() throws NoSuchAlgorithmException, InvalidKeyException { - send("test", MOCK_PAYLOADS.test); + public void test() throws NoSuchAlgorithmException, InvalidKeyException { + send("test", MOCKS.testPayload); } @Test - void voteCreate() throws NoSuchAlgorithmException, InvalidKeyException { - send("voteCreate", MOCK_PAYLOADS.voteCreate); + public void voteCreate() throws NoSuchAlgorithmException, InvalidKeyException { + send("voteCreate", MOCKS.voteCreatePayload); } } diff --git a/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java b/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java index 13f2db3..7450a20 100644 --- a/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java +++ b/src/test/java/org/discordbots/webhooks/eclipsejetty/DBLEclipseJettyWebhooksTest.java @@ -9,8 +9,7 @@ import java.net.URI; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import org.discordbots.webhooks.MockPayloads; -import org.discordbots.webhooks.MockSignature; +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; @@ -24,11 +23,11 @@ public class DBLEclipseJettyWebhooksTest { private static final String SECRET = System.getenv("TOPGG_WEBHOOK_SECRET"); private static final String TRACE = "trace"; - private static MockPayloads MOCK_PAYLOADS; + private static Mocks MOCKS; @BeforeAll public static void setup() throws IOException, NullPointerException, Exception { - MOCK_PAYLOADS = new MockPayloads(); + MOCKS = new Mocks(); SERVER = new Server(8080); @@ -43,14 +42,12 @@ public static void setup() throws IOException, NullPointerException, Exception { private void send(final String name, final String payload) throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { - final MockSignature signature = new MockSignature(SECRET, payload); - 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", signature.getSignatureHeader()); + connection.setRequestProperty("x-topgg-signature", Mocks.signature(SECRET, payload)); connection.setRequestProperty("x-topgg-trace", TRACE); connection.setDoOutput(true); @@ -76,26 +73,27 @@ private void send(final String name, final String payload) } @Test - void integrationCreate() + public void integrationCreate() throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { - send("integrationCreate", MOCK_PAYLOADS.integrationCreate); + send("integrationCreate", MOCKS.integrationCreatePayload); } @Test - void integrationDelete() + public void integrationDelete() throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { - send("integrationDelete", MOCK_PAYLOADS.integrationDelete); + send("integrationDelete", MOCKS.integrationDeletePayload); } @Test - void test() throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { - send("test", MOCK_PAYLOADS.test); + public void test() + throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { + send("test", MOCKS.testPayload); } @Test - void voteCreate() + public void voteCreate() throws NoSuchAlgorithmException, InvalidKeyException, ProtocolException, IOException { - send("voteCreate", MOCK_PAYLOADS.voteCreate); + send("voteCreate", MOCKS.voteCreatePayload); } @AfterAll 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/webhooks/java/org/discordbots/webhooks/payload/Payload.java b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java index aa790c0..424ea39 100644 --- a/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java +++ b/src/webhooks/java/org/discordbots/webhooks/payload/Payload.java @@ -13,7 +13,7 @@ public String getType() { return type; } - public T getData(Gson gson, Class cls) throws JsonSyntaxException { + public T getData(final Gson gson, final Class cls) throws JsonSyntaxException { return gson.fromJson(data, cls); } } From ebe09e7121918bd4f41e8aa6f8a4cfa661673332 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:26:06 +0700 Subject: [PATCH 19/21] doc: update README --- README.md | 422 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 356 insertions(+), 66 deletions(-) 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 From fb5aa925282fb187a26df0553dfae1cbcfd30ee8 Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 08:43:43 +0700 Subject: [PATCH 20/21] meta: fix license URL variable in build.gradle --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 3afa435..6267511 100644 --- a/build.gradle +++ b/build.gradle @@ -106,7 +106,7 @@ publishing { license { name = 'Apache License 2.0' distribution = 'repo' - url = 'https://github.com/top-gg-community/java-sdk/blob/$version/LICENSE' + url = 'https://github.com/top-gg-community/java-sdk/blob/$ver/LICENSE' } } @@ -218,4 +218,4 @@ tasks.register('format', JavaExec) { tasks.withType(JavaCompile) { options.compilerArgs << '-Xlint:deprecation' -} \ No newline at end of file +} From a92b008c9baaaff582e5dba5c0fa7ebb644feecc Mon Sep 17 00:00:00 2001 From: null <60427892+null8626@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:04:36 +0700 Subject: [PATCH 21/21] meta: add .gitattributes and .gitignore --- .gitattributes | 1 + .gitignore | 2 ++ 2 files changed, 3 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore 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