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.
@@ -0,0 +1,13 @@
1
+ node_modules
2
+ dist
3
+ .next
4
+ .turbo
5
+ coverage
6
+ *.log
7
+ *.tsbuildinfo
8
+ next-env.d.ts
9
+ .env
10
+ .env.local
11
+ .DS_Store
12
+ __pycache__/
13
+ *.pyc
@@ -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`.
@@ -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()