hwgdreqs 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hwgdreqs/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
hwgdreqs/api_server.py ADDED
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from dataclasses import asdict
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from urllib.parse import parse_qs, urlparse
8
+
9
+ from hwgdreqs.queue_manager import QueueManager
10
+ from hwgdreqs.twitch_auth import TwitchSession, get_channel_moderate_enabled, ban_twitch_user
11
+
12
+
13
+ def _make_handler(queue: QueueManager, session: TwitchSession | None = None):
14
+ class Handler(BaseHTTPRequestHandler):
15
+ def log_message(self, format: str, *args) -> None:
16
+ return
17
+
18
+ def _send_json(self, payload: object, status: int = 200) -> None:
19
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
20
+ self.send_response(status)
21
+ self.send_header("Content-Type", "application/json; charset=utf-8")
22
+ self.send_header("Content-Length", str(len(data)))
23
+ self.end_headers()
24
+ self.wfile.write(data)
25
+
26
+ def _read_json(self) -> dict:
27
+ length_raw = self.headers.get("Content-Length")
28
+ if not length_raw:
29
+ return {}
30
+ try:
31
+ length = int(length_raw)
32
+ except ValueError:
33
+ return {}
34
+ if length <= 0:
35
+ return {}
36
+ raw = self.rfile.read(length)
37
+ try:
38
+ return json.loads(raw.decode("utf-8"))
39
+ except (UnicodeDecodeError, json.JSONDecodeError):
40
+ return {}
41
+
42
+ def _params(self) -> dict[str, str]:
43
+ parsed = urlparse(self.path)
44
+ qs = parse_qs(parsed.query)
45
+ out: dict[str, str] = {}
46
+ for k, v in qs.items():
47
+ if v:
48
+ out[k] = v[0]
49
+ if self.command == "POST":
50
+ body = self._read_json()
51
+ for k, v in body.items():
52
+ if isinstance(v, str):
53
+ out[k] = v
54
+ return out
55
+
56
+ def _find_entry(self, level_id: str):
57
+ for entry in queue.levels:
58
+ if str(entry.id) == level_id:
59
+ return entry
60
+ return None
61
+
62
+ def do_GET(self) -> None:
63
+ path = urlparse(self.path).path
64
+ if path == "/queue":
65
+ params = self._params()
66
+ if "row" in params:
67
+ try:
68
+ row = int(params["row"])
69
+ levels = queue.levels
70
+ if row >= 0 and row < len(levels):
71
+ self._send_json({"level": asdict(levels[row])})
72
+ else:
73
+ self._send_json({"ok": False, "error": "invalid_row"}, status=400)
74
+ except ValueError:
75
+ self._send_json({"ok": False, "error": "invalid_row"}, status=400)
76
+ else:
77
+ self._send_json({"levels": [asdict(e) for e in queue.levels]})
78
+ return
79
+ if path == "/current":
80
+ levels = queue.levels
81
+ self._send_json({"level": asdict(levels[0]) if levels else None})
82
+ return
83
+ if path == "/add":
84
+ from hwgdreqs.gdbrowser import fetch_level
85
+ params = self._params()
86
+ level_id = params.get("id") or params.get("level_id") or ""
87
+ if not level_id:
88
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
89
+ return
90
+
91
+ level_data = fetch_level(level_id)
92
+ if not level_data:
93
+ self._send_json({"ok": False, "error": "level_not_found"}, status=404)
94
+ return
95
+
96
+ success = queue.add_level(
97
+ level_id=level_id,
98
+ name=level_data.get("name", ""),
99
+ author=level_data.get("author", ""),
100
+ difficulty=level_data.get("difficulty", ""),
101
+ requester="API",
102
+ platform="custom",
103
+ description=level_data.get("description", ""),
104
+ length=level_data.get("length", ""),
105
+ large=bool(level_data.get("large", False)),
106
+ two_player=bool(level_data.get("twoPlayer", False)),
107
+ disliked=bool(level_data.get("disliked", False)),
108
+ )
109
+
110
+ if success:
111
+ self._send_json({"ok": True})
112
+ else:
113
+ self._send_json({"ok": False, "error": "add_failed"}, status=400)
114
+ return
115
+ if path in ("/delete", "/banauthor", "/banrequester", "/blacklistlevel", "/clear", "/bantwitch"):
116
+ params = self._params()
117
+ level_id = params.get("id") or params.get("level_id") or ""
118
+
119
+ if path == "/delete":
120
+ if not level_id:
121
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
122
+ return
123
+ queue.remove_level(level_id)
124
+ self._send_json({"ok": True})
125
+ return
126
+
127
+ if path == "/banrequester":
128
+ if not level_id:
129
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
130
+ return
131
+ entry = self._find_entry(level_id)
132
+ if not entry:
133
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
134
+ return
135
+ queue.blacklist_requester(entry.requester)
136
+ self._send_json({"ok": True})
137
+ return
138
+
139
+ if path == "/banauthor":
140
+ if not level_id:
141
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
142
+ return
143
+ entry = self._find_entry(level_id)
144
+ if not entry:
145
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
146
+ return
147
+ queue.blacklist_author(entry.author)
148
+ self._send_json({"ok": True})
149
+ return
150
+
151
+ if path == "/blacklistlevel":
152
+ if not level_id:
153
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
154
+ return
155
+ queue.blacklist_level(level_id)
156
+ self._send_json({"ok": True})
157
+ return
158
+
159
+ if path == "/bantwitch":
160
+ if not level_id:
161
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
162
+ return
163
+ entry = self._find_entry(level_id)
164
+ if not entry:
165
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
166
+ return
167
+ if not session:
168
+ self._send_json({"ok": False, "error": "no_twitch_session"}, status=400)
169
+ return
170
+ if not get_channel_moderate_enabled():
171
+ self._send_json({"ok": False, "error": "moderation_not_enabled"}, status=400)
172
+ return
173
+ if entry.requester.lower() == session.login.lower():
174
+ self._send_json({"ok": False, "error": "cannot_ban_self"}, status=400)
175
+ return
176
+ error = ban_twitch_user(session, entry.requester)
177
+ if error:
178
+ self._send_json({"ok": False, "error": error}, status=400)
179
+ else:
180
+ self._send_json({"ok": True})
181
+ return
182
+
183
+ queue.clear_queue()
184
+ self._send_json({"ok": True})
185
+ return
186
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
187
+
188
+ def do_POST(self) -> None:
189
+ path = urlparse(self.path).path
190
+ params = self._params()
191
+ level_id = params.get("id") or params.get("level_id") or ""
192
+
193
+ if path == "/delete":
194
+ if not level_id:
195
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
196
+ return
197
+ queue.remove_level(level_id)
198
+ self._send_json({"ok": True})
199
+ return
200
+
201
+ if path == "/banrequester":
202
+ if not level_id:
203
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
204
+ return
205
+ entry = self._find_entry(level_id)
206
+ if not entry:
207
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
208
+ return
209
+ queue.blacklist_requester(entry.requester)
210
+ self._send_json({"ok": True})
211
+ return
212
+
213
+ if path == "/banauthor":
214
+ if not level_id:
215
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
216
+ return
217
+ entry = self._find_entry(level_id)
218
+ if not entry:
219
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
220
+ return
221
+ queue.blacklist_author(entry.author)
222
+ self._send_json({"ok": True})
223
+ return
224
+
225
+ if path == "/blacklistlevel":
226
+ if not level_id:
227
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
228
+ return
229
+ queue.blacklist_level(level_id)
230
+ self._send_json({"ok": True})
231
+ return
232
+
233
+ if path == "/bantwitch":
234
+ if not level_id:
235
+ self._send_json({"ok": False, "error": "missing_id"}, status=400)
236
+ return
237
+ entry = self._find_entry(level_id)
238
+ if not entry:
239
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
240
+ return
241
+ if not session:
242
+ self._send_json({"ok": False, "error": "no_twitch_session"}, status=400)
243
+ return
244
+ if not get_channel_moderate_enabled():
245
+ self._send_json({"ok": False, "error": "moderation_not_enabled"}, status=400)
246
+ return
247
+ if entry.requester.lower() == session.login.lower():
248
+ self._send_json({"ok": False, "error": "cannot_ban_self"}, status=400)
249
+ return
250
+ error = ban_twitch_user(session, entry.requester)
251
+ if error:
252
+ self._send_json({"ok": False, "error": error}, status=400)
253
+ else:
254
+ self._send_json({"ok": True})
255
+ return
256
+
257
+ if path == "/clear":
258
+ queue.clear_queue()
259
+ self._send_json({"ok": True})
260
+ return
261
+
262
+ self._send_json({"ok": False, "error": "not_found"}, status=404)
263
+
264
+ return Handler
265
+
266
+
267
+ class ApiServer:
268
+ def __init__(self, queue: QueueManager) -> None:
269
+ self._queue = queue
270
+ self._local_httpd: ThreadingHTTPServer | None = None
271
+ self._local_thread: threading.Thread | None = None
272
+ self._network_httpd: ThreadingHTTPServer | None = None
273
+ self._network_thread: threading.Thread | None = None
274
+ self._session: TwitchSession | None = None
275
+ self._local_port: int = 6767
276
+ self._host_to_network: bool = False
277
+ self._network_port: int = 0
278
+
279
+ def set_config(self, local_port: int, host_to_network: bool, network_port: int) -> None:
280
+ restart = (self._local_port != local_port or \
281
+ self._host_to_network != host_to_network or \
282
+ self._network_port != network_port)
283
+ self._local_port = local_port
284
+ self._host_to_network = host_to_network
285
+ self._network_port = network_port
286
+ if restart:
287
+ self.stop()
288
+ self.start()
289
+
290
+ def set_session(self, session: TwitchSession | None) -> None:
291
+ self._session = session
292
+ self.stop()
293
+ self.start()
294
+
295
+ def start(self) -> bool:
296
+ success = False
297
+ # local server
298
+ if self._local_thread and self._local_thread.is_alive():
299
+ success = True
300
+ else:
301
+ handler = _make_handler(self._queue, self._session)
302
+ try:
303
+ self._local_httpd = ThreadingHTTPServer(("127.0.0.1", self._local_port), handler)
304
+ self._local_thread = threading.Thread(target=self._local_httpd.serve_forever, daemon=True)
305
+ self._local_thread.start()
306
+ success = True
307
+ except OSError:
308
+ pass
309
+
310
+ # network server if on
311
+ if self._host_to_network:
312
+ if not (self._network_thread and self._network_thread.is_alive()):
313
+ handler = _make_handler(self._queue, self._session)
314
+ try:
315
+ self._network_httpd = ThreadingHTTPServer(("0.0.0.0", self._network_port), handler)
316
+ self._network_thread = threading.Thread(target=self._network_httpd.serve_forever, daemon=True)
317
+ self._network_thread.start()
318
+ success = True
319
+ except OSError:
320
+ pass
321
+
322
+ return success
323
+
324
+ def stop(self) -> None:
325
+ # stop local server
326
+ if self._local_httpd:
327
+ try:
328
+ self._local_httpd.shutdown()
329
+ except OSError:
330
+ pass
331
+ try:
332
+ self._local_httpd.server_close()
333
+ except OSError:
334
+ pass
335
+ self._local_httpd = None
336
+
337
+ # stop network server
338
+ if self._network_httpd:
339
+ try:
340
+ self._network_httpd.shutdown()
341
+ except OSError:
342
+ pass
343
+ try:
344
+ self._network_httpd.server_close()
345
+ except OSError:
346
+ pass
347
+ self._network_httpd = None
hwgdreqs/config.py ADDED
@@ -0,0 +1,123 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ APP_NAME = "HwGDReqs"
11
+ APP_VERSION = "0.8.0"
12
+
13
+ TWITCH_CLIENT_ID = "hq65d75rdxry2cfjgemvydqp2vfr84"
14
+ TWITCH_SCOPES = ["chat:read", "user:read:email"]
15
+ TWITCH_CHAT_EDIT_SCOPE = "chat:edit"
16
+ TWITCH_CHANNEL_MODERATE_SCOPE = ["channel:moderate", "moderator:manage:banned_users"]
17
+ TWITCH_DEVICE_URL = "https://id.twitch.tv/oauth2/device"
18
+ TWITCH_TOKEN_URL = "https://id.twitch.tv/oauth2/token"
19
+ TWITCH_USERS_URL = "https://api.twitch.tv/helix/users"
20
+ TWITCH_IRC_HOST = "irc.chat.twitch.tv"
21
+ TWITCH_IRC_PORT = 6667
22
+
23
+ LEVEL_ID_PATTERN = r"\b(\d{7,9})\b"
24
+ COMMA_LEVEL_ID_PATTERN = r"\b(\d{1,3}(?:,\d{3})+)\b"
25
+
26
+
27
+ def app_root() -> Path:
28
+ if getattr(sys, "frozen", False):
29
+ # use _MEIPASS
30
+ return Path(sys._MEIPASS)
31
+ return Path(__file__).resolve().parent.parent
32
+
33
+
34
+ def exec_dir() -> Path:
35
+ if getattr(sys, "frozen", False):
36
+ return Path(sys.executable).parent
37
+ return Path(__file__).resolve().parent.parent
38
+
39
+
40
+ import sys
41
+ import shutil
42
+
43
+ def data_dir() -> Path:
44
+ if sys.platform == "win32":
45
+ # Windows
46
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
47
+ new_path = base / APP_NAME
48
+
49
+ # old data chek
50
+ old_base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
51
+ old_path = old_base / APP_NAME
52
+
53
+ if old_path.exists() and not new_path.exists():
54
+ shutil.copytree(old_path, new_path)
55
+ # bye old data :3
56
+ shutil.rmtree(old_path)
57
+
58
+ new_path.mkdir(parents=True, exist_ok=True)
59
+ return new_path
60
+ elif sys.platform == "darwin":
61
+ # macOS
62
+ base = Path.home() / "Library" / "Application Support" / APP_NAME
63
+ base.mkdir(parents=True, exist_ok=True)
64
+ return base
65
+ else:
66
+ # Linux
67
+ base = Path.home() / ".config" / APP_NAME
68
+ base.mkdir(parents=True, exist_ok=True)
69
+ return base
70
+
71
+
72
+ def asset_path(name: str) -> Path:
73
+ return app_root() / "assets" / name
74
+
75
+
76
+ def queue_file() -> Path:
77
+ return data_dir() / "data.json"
78
+
79
+
80
+ def token_file() -> Path:
81
+ return data_dir() / "auth.dat"
82
+
83
+
84
+ def encode_token(data: dict) -> str:
85
+ raw = json.dumps(data).encode("utf-8")
86
+ return base64.b64encode(raw).decode("ascii")
87
+
88
+
89
+ def decode_token(encoded: str) -> dict | None:
90
+ try:
91
+ raw = base64.b64decode(encoded.encode("ascii"))
92
+ return json.loads(raw.decode("utf-8"))
93
+ except (ValueError, json.JSONDecodeError):
94
+ return None
95
+
96
+
97
+ def save_auth(data: dict) -> None:
98
+ token_file().write_text(encode_token(data), encoding="utf-8")
99
+
100
+
101
+ def load_auth() -> dict | None:
102
+ path = token_file()
103
+ if not path.exists():
104
+ return None
105
+ return decode_token(path.read_text(encoding="utf-8"))
106
+
107
+
108
+ def clear_auth() -> None:
109
+ path = token_file()
110
+ if path.exists():
111
+ path.unlink()
112
+
113
+
114
+ def get_local_ip() -> str:
115
+ """Get the local IPv4 address of the machine."""
116
+ import socket
117
+ try:
118
+ # dummy socket to a public server to find the local IP
119
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
120
+ s.connect(("8.8.8.8", 80)) # Doesn't need to actually connect
121
+ return s.getsockname()[0]
122
+ except Exception:
123
+ return "127.0.0.1" # Fallback to localhost
hwgdreqs/gdbrowser.py ADDED
@@ -0,0 +1,17 @@
1
+ import requests
2
+
3
+ GDBROWSER_LEVEL_URL = "https://gdbrowser.com/api/level/{level_id}"
4
+
5
+ def fetch_level(level_id: str) -> dict | None:
6
+ try:
7
+ response = requests.get(
8
+ GDBROWSER_LEVEL_URL.format(level_id=level_id),
9
+ timeout=10,
10
+ )
11
+ response.raise_for_status()
12
+ data = response.json()
13
+ if not data.get("name"):
14
+ return None
15
+ return data
16
+ except (requests.RequestException, ValueError):
17
+ return None
@@ -0,0 +1,82 @@
1
+ import json
2
+ import logging
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ from hwgdreqs.config import data_dir
7
+
8
+
9
+ def get_logger() -> logging.Logger:
10
+ log_dir = data_dir() / "logs"
11
+ log_dir.mkdir(parents=True, exist_ok=True)
12
+
13
+ log_file = log_dir / "hwgdreqs.log"
14
+
15
+ logger = logging.getLogger("hwgdreqs")
16
+ if logger.handlers:
17
+ return logger
18
+
19
+ logger.setLevel(logging.INFO)
20
+
21
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
22
+ file_handler.setLevel(logging.INFO)
23
+
24
+ formatter = logging.Formatter(
25
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
26
+ datefmt="%Y-%m-%d %H:%M:%S",
27
+ )
28
+ file_handler.setFormatter(formatter)
29
+
30
+ logger.addHandler(file_handler)
31
+
32
+ return logger
33
+
34
+
35
+ def log_level_added(level_id: str, level_name: str, requester: str, platform: str) -> None:
36
+ logger = get_logger()
37
+ logger.info(f"Level added | ID: {level_id} | Name: {level_name} | Requester: {requester} | Platform: {platform}")
38
+
39
+
40
+ def log_level_deleted(level_id: str, level_name: str, requester: str) -> None:
41
+ logger = get_logger()
42
+ logger.info(f"Level deleted | ID: {level_id} | Name: {level_name} | Requested by: {requester}")
43
+
44
+
45
+ def log_level_swapped(old_level_id: str, old_level_name: str, new_level_id: str, new_level_name: str) -> None:
46
+ logger = get_logger()
47
+ logger.info(f"Level swapped | Old ID: {old_level_id} ({old_level_name}) | New ID: {new_level_id} ({new_level_name})")
48
+
49
+
50
+ def log_requester_blacklisted(requester: str) -> None:
51
+ logger = get_logger()
52
+ logger.info(f"Requester blacklisted | {requester}")
53
+
54
+
55
+ def log_level_blacklisted(level_id: str, level_name: str) -> None:
56
+ logger = get_logger()
57
+ logger.info(f"Level blacklisted | ID: {level_id} | Name: {level_name}")
58
+
59
+
60
+ def log_author_blacklisted(author: str) -> None:
61
+ logger = get_logger()
62
+ logger.info(f"Author blacklisted | {author}")
63
+
64
+
65
+ def log_requester_unblacklisted(requester: str) -> None:
66
+ logger = get_logger()
67
+ logger.info(f"Requester unblacklisted | {requester}")
68
+
69
+
70
+ def log_level_unblacklisted(level_id: str) -> None:
71
+ logger = get_logger()
72
+ logger.info(f"Level unblacklisted | ID: {level_id}")
73
+
74
+
75
+ def log_author_unblacklisted(author: str) -> None:
76
+ logger = get_logger()
77
+ logger.info(f"Author unblacklisted | {author}")
78
+
79
+
80
+ def log_queue_cleared() -> None:
81
+ logger = get_logger()
82
+ logger.info("Queue cleared")