Coverage for an_website/discord/discord.py: 73.134%

67 statements  

« prev     ^ index     » next       coverage.py v7.6.4, created at 2024-11-16 19:56 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13 

14"""A permanent redirect to an invite of the Discord guild.""" 

15 

16from __future__ import annotations 

17 

18import time 

19from typing import Final 

20 

21import orjson as json 

22from tornado.httpclient import AsyncHTTPClient 

23from tornado.web import HTTPError 

24 

25from .. import CA_BUNDLE_PATH 

26from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler 

27from ..utils.utils import ModuleInfo 

28 

29GUILD_ID: Final[str] = "367648314184826880" 

30 

31INVITE_CACHE: dict[ 

32 str, 

33 tuple[float, str, str] | tuple[float, HTTPError], 

34] = {} 

35 

36 

37def get_module_info() -> ModuleInfo: 

38 """Create and return the ModuleInfo for this module.""" 

39 return ModuleInfo( 

40 handlers=( 

41 (r"/discord", ANDiscord), 

42 (r"/api/discord", ANDiscordAPI), 

43 (f"/api/discord/({GUILD_ID})", ANDiscordAPI), 

44 (r"/api/discord/(\d+)", DiscordAPI), 

45 ), 

46 name="Discord-Einladung", 

47 short_name="Discord", 

48 description="Eine permanente Einladung zu unserer Discord-Gilde", 

49 path="/discord", 

50 keywords=( 

51 "Discord", 

52 "Server", 

53 "Guild", 

54 "Gilde", 

55 "Invite", 

56 "Einladung", 

57 ), 

58 ) 

59 

60 

61async def url_returns_200(url: str) -> bool: 

62 """Check whether a URL returns a status code of 200.""" 

63 response = await AsyncHTTPClient().fetch( 

64 url, 

65 method="HEAD", 

66 raise_error=False, 

67 ca_certs=CA_BUNDLE_PATH, 

68 ) 

69 return response.code == 200 

70 

71 

72async def get_invite(guild_id: str = GUILD_ID) -> tuple[str, str]: 

73 """ 

74 Get the invite to a Discord guild and return it with the source. 

75 

76 How to get the invite: 

77 - from the widget (has to be enabled in guild settings) 

78 - from DISBOARD (the guild needs to set it up first) 

79 

80 If the invite couldn't be fetched an HTTPError is raised. 

81 """ 

82 reason = "Invite not found." 

83 

84 # try getting the invite from the widget 

85 url = f"https://discord.com/api/guilds/{guild_id}/widget.json" 

86 response = await AsyncHTTPClient().fetch( 

87 url, raise_error=False, ca_certs=CA_BUNDLE_PATH 

88 ) 

89 if response.code == 200: 

90 response_json = json.loads(response.body) 

91 if invite := response_json["instant_invite"]: 

92 return invite, url 

93 reason = f"No instant invite in widget ({url}) found." 

94 

95 # try getting the invite from DISBOARD 

96 url = f"https://disboard.org/site/get-invite/{guild_id}" 

97 response = await AsyncHTTPClient().fetch( 

98 url, raise_error=False, ca_certs=CA_BUNDLE_PATH 

99 ) 

100 if response.code == 200: 

101 return ( 

102 json.loads(response.body), 

103 f"https://disboard.org/server/{guild_id}", 

104 ) 

105 

106 # check if Top.gg lists the guild 

107 url = f"https://top.gg/servers/{guild_id}/join" 

108 if await url_returns_200(url): 

109 return url, f"https://top.gg/servers/{guild_id}/" 

110 

111 # check if Discords.com lists the guild 

112 if await url_returns_200( 

113 # API endpoint that only returns 200 if the guild exists 

114 f"https://discords.com/api-v2/server/{guild_id}/relevant" 

115 ): 

116 return ( 

117 f"https://discords.com/servers/{guild_id}/join", 

118 f"https://discords.com/servers/{guild_id}/", 

119 ) 

120 

121 raise HTTPError(404, reason=reason) 

122 

123 

124async def get_invite_with_cache( 

125 guild_id: str = GUILD_ID, 

126) -> tuple[str, str]: 

127 """Get an invite from cache or from get_invite().""" 

128 if guild_id in INVITE_CACHE: 

129 cache_entry = INVITE_CACHE[guild_id] 

130 if cache_entry[0] > time.monotonic() - 300: 

131 if isinstance(cache_entry[1], HTTPError): 

132 raise cache_entry[1] 

133 return cache_entry[1], cache_entry[2] 

134 

135 try: 

136 invite, source = await get_invite(guild_id) 

137 except HTTPError as exc: 

138 INVITE_CACHE[guild_id] = (time.monotonic(), exc) 

139 raise exc 

140 

141 INVITE_CACHE[guild_id] = (time.monotonic(), invite, source) 

142 

143 return invite, source 

144 

145 

146class ANDiscord(HTMLRequestHandler): 

147 """The request handler that gets the Discord invite and redirects to it.""" 

148 

149 RATELIMIT_GET_LIMIT = 10 

150 

151 async def get(self, *, head: bool = False) -> None: 

152 """Get the Discord invite.""" 

153 invite = (await get_invite_with_cache(GUILD_ID))[0] 

154 if not self.user_settings.ask_before_leaving: 

155 return self.redirect(invite) 

156 if head: 

157 return 

158 return await self.render( 

159 "pages/redirect.html", 

160 send_referrer=True, 

161 redirect_url=invite, 

162 from_url=None, 

163 discord=True, 

164 ) 

165 

166 

167class DiscordAPI(APIRequestHandler): 

168 """The API request handler that gets the Discord invite and returns it.""" 

169 

170 RATELIMIT_GET_LIMIT = 5 

171 RATELIMIT_GET_COUNT_PER_PERIOD = 10 

172 

173 async def get( 

174 self, guild_id: str = GUILD_ID, *, head: bool = False 

175 ) -> None: 

176 """Get the Discord invite and render it as JSON.""" 

177 # pylint: disable=unused-argument 

178 invite, source_url = await get_invite_with_cache(guild_id) 

179 return await self.finish_dict(invite=invite, source=source_url) 

180 

181 

182class ANDiscordAPI(DiscordAPI): 

183 """The API request handler only for the AN Discord guild.""" 

184 

185 RATELIMIT_GET_LIMIT = 10 

186 RATELIMIT_GET_COUNT_PER_PERIOD = 30