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
« 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/>.
14"""A permanent redirect to an invite of the Discord guild."""
16from __future__ import annotations
18import time
19from typing import Final
21import orjson as json
22from tornado.httpclient import AsyncHTTPClient
23from tornado.web import HTTPError
25from .. import CA_BUNDLE_PATH
26from ..utils.request_handler import APIRequestHandler, HTMLRequestHandler
27from ..utils.utils import ModuleInfo
29GUILD_ID: Final[str] = "367648314184826880"
31INVITE_CACHE: dict[
32 str,
33 tuple[float, str, str] | tuple[float, HTTPError],
34] = {}
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 )
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
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.
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)
80 If the invite couldn't be fetched an HTTPError is raised.
81 """
82 reason = "Invite not found."
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."
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 )
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}/"
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 )
121 raise HTTPError(404, reason=reason)
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]
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
141 INVITE_CACHE[guild_id] = (time.monotonic(), invite, source)
143 return invite, source
146class ANDiscord(HTMLRequestHandler):
147 """The request handler that gets the Discord invite and redirects to it."""
149 RATELIMIT_GET_LIMIT = 10
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 )
167class DiscordAPI(APIRequestHandler):
168 """The API request handler that gets the Discord invite and returns it."""
170 RATELIMIT_GET_LIMIT = 5
171 RATELIMIT_GET_COUNT_PER_PERIOD = 10
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)
182class ANDiscordAPI(DiscordAPI):
183 """The API request handler only for the AN Discord guild."""
185 RATELIMIT_GET_LIMIT = 10
186 RATELIMIT_GET_COUNT_PER_PERIOD = 30