nexobots 0.1.0__tar.gz
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.
- nexobots-0.1.0/.gitignore +13 -0
- nexobots-0.1.0/PKG-INFO +58 -0
- nexobots-0.1.0/README.md +48 -0
- nexobots-0.1.0/nexobots/__init__.py +100 -0
- nexobots-0.1.0/pyproject.toml +15 -0
- nexobots-0.1.0/tests/test_nexobots.py +91 -0
nexobots-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nexobots
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Post stats and check votes on Nexobots, the Discord bot directory.
|
|
5
|
+
Project-URL: Homepage, https://nexobots.app
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Requires-Dist: aiohttp>=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# nexobots
|
|
12
|
+
|
|
13
|
+
Post stats and check votes on [Nexobots](https://nexobots.app), the Discord bot directory.
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install nexobots
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Get your API token from your bot's page in the Nexobots dashboard, and keep it in your `.env` file.
|
|
20
|
+
|
|
21
|
+
## Post your server count
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
from discord.ext import tasks
|
|
27
|
+
from nexobots import Nexobots
|
|
28
|
+
|
|
29
|
+
@tasks.loop(minutes=30)
|
|
30
|
+
async def post_stats():
|
|
31
|
+
async with Nexobots(os.environ["NEXOBOTS_TOKEN"], bot.user.id) as nexobots:
|
|
32
|
+
await nexobots.post_stats(server_count=len(bot.guilds), shard_count=bot.shard_count)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The API allows one post per 5 minutes.
|
|
36
|
+
|
|
37
|
+
## Check a vote
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
async with Nexobots(os.environ["NEXOBOTS_TOKEN"], bot.user.id) as nexobots:
|
|
41
|
+
if await nexobots.has_voted(user.id):
|
|
42
|
+
... # Thank them.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Receive vote webhooks
|
|
46
|
+
|
|
47
|
+
Pass the raw request body, before any JSON parsing.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from nexobots import parse_webhook
|
|
51
|
+
|
|
52
|
+
vote = parse_webhook(os.environ["NEXOBOTS_WEBHOOK_SECRET"], raw_body, request.headers.get("X-Nexo-Signature"))
|
|
53
|
+
if vote is None:
|
|
54
|
+
return Response(status=401)
|
|
55
|
+
print(f"{vote['user']} voted")
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Errors from the API are raised as `NexobotsError`, with `status`, `code` and `message`.
|
nexobots-0.1.0/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# nexobots
|
|
2
|
+
|
|
3
|
+
Post stats and check votes on [Nexobots](https://nexobots.app), the Discord bot directory.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install nexobots
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Get your API token from your bot's page in the Nexobots dashboard, and keep it in your `.env` file.
|
|
10
|
+
|
|
11
|
+
## Post your server count
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from discord.ext import tasks
|
|
17
|
+
from nexobots import Nexobots
|
|
18
|
+
|
|
19
|
+
@tasks.loop(minutes=30)
|
|
20
|
+
async def post_stats():
|
|
21
|
+
async with Nexobots(os.environ["NEXOBOTS_TOKEN"], bot.user.id) as nexobots:
|
|
22
|
+
await nexobots.post_stats(server_count=len(bot.guilds), shard_count=bot.shard_count)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The API allows one post per 5 minutes.
|
|
26
|
+
|
|
27
|
+
## Check a vote
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
async with Nexobots(os.environ["NEXOBOTS_TOKEN"], bot.user.id) as nexobots:
|
|
31
|
+
if await nexobots.has_voted(user.id):
|
|
32
|
+
... # Thank them.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Receive vote webhooks
|
|
36
|
+
|
|
37
|
+
Pass the raw request body, before any JSON parsing.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from nexobots import parse_webhook
|
|
41
|
+
|
|
42
|
+
vote = parse_webhook(os.environ["NEXOBOTS_WEBHOOK_SECRET"], raw_body, request.headers.get("X-Nexo-Signature"))
|
|
43
|
+
if vote is None:
|
|
44
|
+
return Response(status=401)
|
|
45
|
+
print(f"{vote['user']} voted")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Errors from the API are raised as `NexobotsError`, with `status`, `code` and `message`.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Post stats and check votes on Nexobots, the Discord bot directory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
__all__ = ["Nexobots", "NexobotsError", "verify_webhook", "parse_webhook"]
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.nexobots.app/v1"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NexobotsError(Exception):
|
|
17
|
+
"""An error response from the Nexobots API."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status: int, code: str, message: str) -> None:
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|
|
22
|
+
self.code = code
|
|
23
|
+
self.message = message
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Nexobots:
|
|
27
|
+
"""A client for one bot. Use it with ``async with``, or pass your own aiohttp session."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, token: str, bot_id: Union[int, str], *, base_url: str = DEFAULT_BASE_URL, session: Any = None) -> None:
|
|
30
|
+
if not token:
|
|
31
|
+
raise ValueError("A Nexobots API token is required.")
|
|
32
|
+
if not bot_id:
|
|
33
|
+
raise ValueError("Your bot's client ID is required.")
|
|
34
|
+
self._token = token
|
|
35
|
+
self._bot_id = str(bot_id)
|
|
36
|
+
self._base_url = base_url.rstrip("/")
|
|
37
|
+
self._session = session
|
|
38
|
+
self._owns_session = False
|
|
39
|
+
|
|
40
|
+
async def __aenter__(self) -> "Nexobots":
|
|
41
|
+
if self._session is None:
|
|
42
|
+
import aiohttp
|
|
43
|
+
|
|
44
|
+
self._session = aiohttp.ClientSession()
|
|
45
|
+
self._owns_session = True
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
49
|
+
await self.close()
|
|
50
|
+
|
|
51
|
+
async def close(self) -> None:
|
|
52
|
+
if self._owns_session and self._session is not None:
|
|
53
|
+
await self._session.close()
|
|
54
|
+
self._session = None
|
|
55
|
+
self._owns_session = False
|
|
56
|
+
|
|
57
|
+
async def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
58
|
+
if self._session is None:
|
|
59
|
+
raise RuntimeError('Use "async with Nexobots(...)" or pass a session.')
|
|
60
|
+
headers = {"Authorization": self._token}
|
|
61
|
+
async with self._session.request(method, f"{self._base_url}{path}", headers=headers, **kwargs) as res:
|
|
62
|
+
try:
|
|
63
|
+
body = await res.json(content_type=None)
|
|
64
|
+
except ValueError:
|
|
65
|
+
body = {}
|
|
66
|
+
if res.status >= 400:
|
|
67
|
+
raise NexobotsError(res.status, body.get("error", "error"), body.get("message", f"Nexobots returned {res.status}"))
|
|
68
|
+
return body
|
|
69
|
+
|
|
70
|
+
async def post_stats(self, server_count: int, shard_count: Optional[int] = None) -> None:
|
|
71
|
+
"""Posts your server count. The API allows one post per 5 minutes."""
|
|
72
|
+
payload: Dict[str, int] = {"server_count": server_count}
|
|
73
|
+
if shard_count is not None:
|
|
74
|
+
payload["shard_count"] = shard_count
|
|
75
|
+
await self._request("POST", f"/bots/{self._bot_id}/stats", json=payload)
|
|
76
|
+
|
|
77
|
+
async def has_voted(self, user_id: Union[int, str]) -> bool:
|
|
78
|
+
"""Whether this user voted for your bot in the last 12 hours."""
|
|
79
|
+
body = await self._request("GET", f"/bots/{self._bot_id}/votes/check", params={"userId": str(user_id)})
|
|
80
|
+
return body.get("voted") == 1
|
|
81
|
+
|
|
82
|
+
async def recent_voters(self) -> List[Dict[str, Any]]:
|
|
83
|
+
"""The most recent voters, up to 1,000."""
|
|
84
|
+
return await self._request("GET", f"/bots/{self._bot_id}/votes")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verify_webhook(secret: str, raw_body: Union[bytes, str], signature: Optional[str]) -> bool:
|
|
88
|
+
"""Checks a vote webhook really came from Nexobots. Pass the raw request body and the X-Nexo-Signature header."""
|
|
89
|
+
if not signature:
|
|
90
|
+
return False
|
|
91
|
+
body = raw_body.encode() if isinstance(raw_body, str) else raw_body
|
|
92
|
+
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
93
|
+
return hmac.compare_digest(expected, signature)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_webhook(secret: str, raw_body: Union[bytes, str], signature: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
97
|
+
"""Verifies a webhook and returns the vote, or None if the signature is wrong."""
|
|
98
|
+
if not verify_webhook(secret, raw_body, signature):
|
|
99
|
+
return None
|
|
100
|
+
return json.loads(raw_body)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nexobots"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Post stats and check votes on Nexobots, the Discord bot directory."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = ["aiohttp>=3.8"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://nexobots.app"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from nexobots import Nexobots, NexobotsError, parse_webhook, verify_webhook
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeResponse:
|
|
11
|
+
def __init__(self, status, body):
|
|
12
|
+
self.status = status
|
|
13
|
+
self._body = body
|
|
14
|
+
|
|
15
|
+
async def json(self, content_type=None):
|
|
16
|
+
return self._body
|
|
17
|
+
|
|
18
|
+
async def __aenter__(self):
|
|
19
|
+
return self
|
|
20
|
+
|
|
21
|
+
async def __aexit__(self, *_):
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FakeSession:
|
|
26
|
+
def __init__(self, status=200, body=None):
|
|
27
|
+
self.status = status
|
|
28
|
+
self.body = body if body is not None else {}
|
|
29
|
+
self.calls = []
|
|
30
|
+
|
|
31
|
+
def request(self, method, url, **kwargs):
|
|
32
|
+
self.calls.append((method, url, kwargs))
|
|
33
|
+
return FakeResponse(self.status, self.body)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(coroutine):
|
|
37
|
+
return asyncio.run(coroutine)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ClientTests(unittest.TestCase):
|
|
41
|
+
def client(self, session):
|
|
42
|
+
return Nexobots("nexo_token", 400000000000000001, base_url="https://api.test/v1/", session=session)
|
|
43
|
+
|
|
44
|
+
def test_posts_stats_in_the_apis_format(self):
|
|
45
|
+
session = FakeSession(body={"ok": True})
|
|
46
|
+
run(self.client(session).post_stats(12, shard_count=2))
|
|
47
|
+
method, url, kwargs = session.calls[0]
|
|
48
|
+
self.assertEqual(method, "POST")
|
|
49
|
+
self.assertEqual(url, "https://api.test/v1/bots/400000000000000001/stats")
|
|
50
|
+
self.assertEqual(kwargs["json"], {"server_count": 12, "shard_count": 2})
|
|
51
|
+
self.assertEqual(kwargs["headers"]["Authorization"], "nexo_token")
|
|
52
|
+
|
|
53
|
+
def test_turns_the_vote_check_into_a_bool(self):
|
|
54
|
+
self.assertTrue(run(self.client(FakeSession(body={"voted": 1})).has_voted(1)))
|
|
55
|
+
self.assertFalse(run(self.client(FakeSession(body={"voted": 0})).has_voted(1)))
|
|
56
|
+
|
|
57
|
+
def test_raises_a_readable_error(self):
|
|
58
|
+
session = FakeSession(status=429, body={"error": "rate_limited", "message": "Too many requests."})
|
|
59
|
+
with self.assertRaises(NexobotsError) as caught:
|
|
60
|
+
run(self.client(session).post_stats(1))
|
|
61
|
+
self.assertEqual(caught.exception.status, 429)
|
|
62
|
+
self.assertEqual(caught.exception.code, "rate_limited")
|
|
63
|
+
|
|
64
|
+
def test_needs_a_token_and_bot_id(self):
|
|
65
|
+
with self.assertRaises(ValueError):
|
|
66
|
+
Nexobots("", 1)
|
|
67
|
+
with self.assertRaises(ValueError):
|
|
68
|
+
Nexobots("x", "")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class WebhookTests(unittest.TestCase):
|
|
72
|
+
secret = "whsec"
|
|
73
|
+
body = json.dumps({"bot": "1", "user": "2", "type": "upvote", "isWeekend": False})
|
|
74
|
+
|
|
75
|
+
def signature(self):
|
|
76
|
+
return hmac.new(self.secret.encode(), self.body.encode(), hashlib.sha256).hexdigest()
|
|
77
|
+
|
|
78
|
+
def test_accepts_a_good_signature(self):
|
|
79
|
+
self.assertTrue(verify_webhook(self.secret, self.body, self.signature()))
|
|
80
|
+
self.assertTrue(verify_webhook(self.secret, self.body.encode(), self.signature()))
|
|
81
|
+
self.assertEqual(parse_webhook(self.secret, self.body, self.signature())["user"], "2")
|
|
82
|
+
|
|
83
|
+
def test_refuses_a_bad_signature(self):
|
|
84
|
+
self.assertFalse(verify_webhook("other", self.body, self.signature()))
|
|
85
|
+
self.assertFalse(verify_webhook(self.secret, self.body + " ", self.signature()))
|
|
86
|
+
self.assertFalse(verify_webhook(self.secret, self.body, None))
|
|
87
|
+
self.assertIsNone(parse_webhook("other", self.body, self.signature()))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__":
|
|
91
|
+
unittest.main()
|