discordbots 1.0.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.
@@ -0,0 +1,487 @@
1
+ """DiscordBots SDK for Python (3.9+). Standard library only; aiohttp optional for the async client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import re
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from typing import Any, Mapping, Optional
15
+
16
+ __all__ = ["Api", "AsyncApi", "AutoPoster", "Webhook", "DiscordBotsError", "DiscoveryError", "VoteRequired", "vote", "needs_vote"]
17
+ __version__ = "1.0.0"
18
+
19
+ DEFAULT_BASE = "https://api.discordbots.in/api/v1"
20
+ _TOKEN_RE = re.compile(r"^hcd_[A-Za-z0-9]{48}$")
21
+
22
+
23
+ class DiscoveryError(Exception):
24
+ def __init__(self, status: int, message: str):
25
+ super().__init__(message)
26
+ self.status = status
27
+
28
+ async def _call(fn, *args):
29
+ import inspect
30
+ r = fn(*args)
31
+ return await r if inspect.isawaitable(r) else r
32
+
33
+
34
+ def needs_vote(command, vote_all=False) -> bool:
35
+ """Reads the vote flag from a discord.py command (extras["vote"]) or a function marked with @vote()."""
36
+ if command is None:
37
+ return vote_all
38
+ extras = getattr(command, "extras", None) or {}
39
+ flag = extras.get("vote") if isinstance(extras, dict) else None
40
+ if flag is None:
41
+ flag = getattr(getattr(command, "callback", command), "__discordbots_vote__", None)
42
+ return flag if isinstance(flag, bool) else vote_all
43
+
44
+
45
+ def vote(required: bool = True):
46
+ """Mark a command as voters-only (True) or never locked (False). Put it above or below the command decorator.
47
+
48
+ @vote(True)
49
+ @bot.tree.command(name="daily")
50
+ async def daily(interaction): ...
51
+ """
52
+ def deco(obj):
53
+ extras = getattr(obj, "extras", None)
54
+ if isinstance(extras, dict):
55
+ extras["vote"] = required
56
+ target = getattr(obj, "callback", obj)
57
+ try:
58
+ setattr(target, "__discordbots_vote__", required)
59
+ except Exception:
60
+ pass
61
+ return obj
62
+ return deco
63
+
64
+
65
+ try:
66
+ from discord import app_commands as _ac
67
+ from discord.ext import commands as _ext
68
+
69
+ class VoteRequired(_ac.CheckFailure, _ext.CheckFailure):
70
+ """Raised by setup() checks when a non-voter uses a locked command. Already handled; ignore it."""
71
+ except Exception: # discord.py not installed: the blocking client still works
72
+ class VoteRequired(Exception):
73
+ pass
74
+
75
+
76
+
77
+ def _check_token(token: str) -> None:
78
+ if not _TOKEN_RE.match(token or ""):
79
+ raise ValueError("Invalid DiscordBots API token.")
80
+
81
+
82
+ class _VoteCache:
83
+ """Voters cached until their vote expires (max 12h), non-voters for `ttl` seconds. Bounded (oldest dropped)."""
84
+
85
+ def __init__(self, ttl: int, max_size: int):
86
+ from collections import OrderedDict
87
+ self.ttl, self.max_size = ttl, max_size
88
+ self.data: "OrderedDict[str, tuple[bool, float]]" = OrderedDict()
89
+ self.blocked_until = 0.0
90
+
91
+ def get(self, uid: str):
92
+ hit = self.data.get(uid)
93
+ return hit[0] if hit and hit[1] > time.time() else None
94
+
95
+ def put(self, uid: str, voted: bool, expires_at: Optional[str]):
96
+ now = time.time()
97
+ until = now + self.ttl
98
+ if voted:
99
+ until = now + 12 * 3600
100
+ if expires_at:
101
+ try:
102
+ from datetime import datetime
103
+ until = min(until, datetime.fromisoformat(expires_at.replace("Z", "+00:00")).timestamp())
104
+ except ValueError:
105
+ pass
106
+ self.data.pop(uid, None)
107
+ self.data[uid] = (voted, until)
108
+ if len(self.data) > self.max_size:
109
+ for k in [k for k, v in self.data.items() if v[1] <= now]:
110
+ del self.data[k]
111
+ while len(self.data) > self.max_size:
112
+ self.data.popitem(last=False)
113
+
114
+ def check_blocked(self):
115
+ if time.time() < self.blocked_until:
116
+ raise DiscoveryError(429, f"Rate limited, retry in {int(self.blocked_until - time.time()) + 1}s")
117
+
118
+ def block(self, retry_after):
119
+ try:
120
+ wait = int(float(retry_after or 60))
121
+ except ValueError:
122
+ wait = 60
123
+ self.blocked_until = time.time() + min(max(wait, 1), 3600)
124
+
125
+
126
+ class Api:
127
+ """Blocking client (fine for scripts, or call from a thread)."""
128
+
129
+ def __init__(self, token: Optional[str] = None, base_url: Optional[str] = None, cache_seconds: int = 30,
130
+ max_cache_size: int = 50_000):
131
+ token = token or os.environ.get("DISCORDBOTS_TOKEN")
132
+ base_url = base_url or os.environ.get("DISCORDBOTS_API_URL") or DEFAULT_BASE
133
+ _check_token(token)
134
+ self.token = token
135
+ self.base_url = base_url.rstrip("/")
136
+ self.cache_seconds = cache_seconds
137
+ self._cache = _VoteCache(cache_seconds, max_cache_size)
138
+
139
+ def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
140
+ data = json.dumps(body).encode() if body is not None else None
141
+ req = urllib.request.Request(self.base_url + path, data=data, method=method)
142
+ req.add_header("Authorization", self.token)
143
+ req.add_header("Accept", "application/json")
144
+ if data is not None:
145
+ req.add_header("Content-Type", "application/json")
146
+ self._cache.check_blocked()
147
+ try:
148
+ with urllib.request.urlopen(req, timeout=10) as res:
149
+ return json.loads(res.read() or b"{}")
150
+ except urllib.error.HTTPError as e:
151
+ if e.code == 429:
152
+ self._cache.block(e.headers.get("Retry-After"))
153
+ try:
154
+ msg = json.loads(e.read()).get("message", str(e))
155
+ except Exception:
156
+ msg = str(e)
157
+ raise DiscoveryError(e.code, msg) from None
158
+
159
+ def has_voted(self, user_id: int | str) -> bool:
160
+ """Has this Discord user voted in the last 12 hours? Voters are cached until the vote expires."""
161
+ uid = str(user_id)
162
+ hit = self._cache.get(uid)
163
+ if hit is not None:
164
+ return hit
165
+ r = self.check_vote(uid)
166
+ self._cache.put(uid, bool(r["voted"]), r.get("expires_at"))
167
+ return bool(r["voted"])
168
+
169
+ def forget(self, user_id: int | str) -> None:
170
+ """Drop a cached answer, e.g. when your vote webhook fires for this user."""
171
+ self._cache.data.pop(str(user_id), None)
172
+
173
+ def check_vote(self, user_id: int | str) -> dict:
174
+ return self._request("GET", "/bot/check?userId=" + urllib.parse.quote(str(user_id)))
175
+
176
+ def get_bot(self) -> dict:
177
+ return self._request("GET", "/bot")
178
+
179
+ def get_votes(self, limit: int = 100) -> list:
180
+ return self._request("GET", f"/bot/votes?limit={int(limit)}")["data"]
181
+
182
+ def is_weekend(self) -> bool:
183
+ return bool(self._request("GET", "/bot/weekend")["double_votes"])
184
+
185
+ def post_stats(self, server_count: int, user_count: Optional[int] = None, shard_count: Optional[int] = None) -> dict:
186
+ return self._request("POST", "/bot/stats", {"server_count": server_count, "user_count": user_count, "shard_count": shard_count})
187
+
188
+
189
+ class AsyncApi:
190
+ """asyncio client for discord.py / nextcord / pycord. Needs `pip install discordbots[aiohttp]`."""
191
+
192
+ def __init__(self, token: Optional[str] = None, base_url: Optional[str] = None, cache_seconds: int = 30,
193
+ max_cache_size: int = 50_000):
194
+ token = token or os.environ.get("DISCORDBOTS_TOKEN")
195
+ base_url = base_url or os.environ.get("DISCORDBOTS_API_URL") or DEFAULT_BASE
196
+ _check_token(token)
197
+ self.token = token
198
+ self.base_url = base_url.rstrip("/")
199
+ self.cache_seconds = cache_seconds
200
+ self._cache = _VoteCache(cache_seconds, max_cache_size)
201
+ self._session = None
202
+ self._inflight: dict = {}
203
+
204
+ async def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
205
+ import aiohttp
206
+
207
+ if self._session is None or self._session.closed:
208
+ self._session = aiohttp.ClientSession(
209
+ headers={"Authorization": self.token, "Accept": "application/json"},
210
+ timeout=aiohttp.ClientTimeout(total=10),
211
+ )
212
+ self._cache.check_blocked()
213
+ async with self._session.request(method, self.base_url + path, json=body) as res:
214
+ if res.status == 429:
215
+ self._cache.block(res.headers.get("Retry-After"))
216
+ data = await res.json(content_type=None) if res.content_length != 0 else {}
217
+ if res.status >= 400:
218
+ raise DiscoveryError(res.status, (data or {}).get("message", f"Request failed ({res.status})"))
219
+ return data
220
+
221
+ async def has_voted(self, user_id: int | str) -> bool:
222
+ """Voters are cached until the vote expires; parallel checks for one user share one request."""
223
+ import asyncio
224
+
225
+ uid = str(user_id)
226
+ hit = self._cache.get(uid)
227
+ if hit is not None:
228
+ return hit
229
+ if uid in self._inflight:
230
+ return await asyncio.shield(self._inflight[uid])
231
+
232
+ async def fetch():
233
+ try:
234
+ r = await self._request("GET", "/bot/check?userId=" + urllib.parse.quote(uid))
235
+ self._cache.put(uid, bool(r["voted"]), r.get("expires_at"))
236
+ return bool(r["voted"])
237
+ finally:
238
+ self._inflight.pop(uid, None)
239
+
240
+ task = asyncio.ensure_future(fetch())
241
+ self._inflight[uid] = task
242
+ return await asyncio.shield(task)
243
+
244
+ def forget(self, user_id: int | str) -> None:
245
+ """Drop a cached answer, e.g. when your vote webhook fires for this user."""
246
+ self._cache.data.pop(str(user_id), None)
247
+
248
+ async def check_vote(self, user_id: int | str) -> dict:
249
+ """{"voted", "voted_at", "expires_at"} for this user, not cached."""
250
+ return await self._request("GET", "/bot/check?userId=" + urllib.parse.quote(str(user_id)))
251
+
252
+ async def get_bot(self) -> dict:
253
+ return await self._request("GET", "/bot")
254
+
255
+ async def vote_url(self) -> str:
256
+ if not getattr(self, "_vote_url", None):
257
+ self._vote_url = (await self.get_bot())["vote_url"]
258
+ return self._vote_url
259
+
260
+ async def _allowed(self, ctx, on_not_voted=None, get_user_id=None, on_error=None) -> bool:
261
+ """True if the command may run. When it may not, the user has already been told."""
262
+ if get_user_id:
263
+ uid = get_user_id(ctx)
264
+ else:
265
+ u = getattr(ctx, "user", None) or getattr(ctx, "author", None)
266
+ uid = getattr(u, "id", None)
267
+ try:
268
+ if await self.has_voted(uid):
269
+ return True
270
+ except Exception as err:
271
+ if on_error:
272
+ await _call(on_error, ctx, err)
273
+ return False
274
+ # Only fail open when our API is unreachable (network / 5xx). Bad ids or token stay locked.
275
+ if not isinstance(err, DiscoveryError) or err.status >= 500 or err.status == 429:
276
+ return True
277
+ try:
278
+ url = await self.vote_url()
279
+ except Exception:
280
+ url = None
281
+ if on_not_voted:
282
+ await _call(on_not_voted, ctx, url)
283
+ return False
284
+ msg = f"You need to vote to use this command: {url}"
285
+ resp = getattr(ctx, "response", None)
286
+ if resp is not None and not resp.is_done():
287
+ await resp.send_message(msg, ephemeral=True)
288
+ elif hasattr(ctx, "send"):
289
+ await ctx.send(msg)
290
+ return False
291
+
292
+ def require_vote(self, on_not_voted=None, get_user_id=None, on_error=None):
293
+ """Decorator for a single command: it only runs for voters.
294
+
295
+ @tree.command(name="daily")
296
+ @dbots.require_vote(on_not_voted=my_vote_embed)
297
+ async def daily(interaction): ...
298
+ """
299
+ import functools
300
+
301
+ def deco(func):
302
+ @functools.wraps(func)
303
+ async def wrapper(ctx, *args, **kwargs):
304
+ if await self._allowed(ctx, on_not_voted, get_user_id, on_error):
305
+ return await func(ctx, *args, **kwargs)
306
+ return wrapper
307
+ return deco
308
+
309
+ def vote_guard(self, vote_all=False, on_not_voted=None, get_user_id=None, on_error=None):
310
+ """Returns `async guard(ctx, command) -> bool` for your own command handler.
311
+
312
+ A command needs a vote when it is marked `vote=True` (see `vote()` / extras={"vote": True}),
313
+ or when vote_all=True and it isn't marked `vote=False`.
314
+ """
315
+ async def guard(ctx, command=None) -> bool:
316
+ if not needs_vote(command, vote_all):
317
+ return True
318
+ return await self._allowed(ctx, on_not_voted, get_user_id, on_error)
319
+ return guard
320
+
321
+ def setup(self, bot, vote_all=False, on_not_voted=None, on_error=None):
322
+ """One line in your main file. Every slash and prefix command is checked:
323
+
324
+ dbots.setup(bot) # only commands marked vote=True are locked
325
+ dbots.setup(bot, vote_all=True) # everything is locked unless marked vote=False
326
+
327
+ Mark commands with `@vote(True)` / `@vote(False)` or `extras={"vote": True}`.
328
+ """
329
+ from discord import app_commands
330
+ from discord.ext import commands as ext
331
+
332
+ guard = self.vote_guard(vote_all, on_not_voted, None, on_error)
333
+
334
+ tree = getattr(bot, "tree", None)
335
+ if tree is not None:
336
+ original_check = tree.interaction_check
337
+ original_error = tree.on_error
338
+
339
+ async def interaction_check(interaction):
340
+ if not await original_check(interaction):
341
+ return False
342
+ if not await guard(interaction, interaction.command):
343
+ raise VoteRequired()
344
+ return True
345
+
346
+ async def on_error(interaction, error):
347
+ if isinstance(error, VoteRequired):
348
+ return # the user was already told to vote
349
+ await original_error(interaction, error)
350
+
351
+ tree.interaction_check = interaction_check
352
+ tree.on_error = on_error
353
+
354
+ if isinstance(bot, ext.Bot):
355
+ async def prefix_check(ctx):
356
+ if not await guard(ctx, ctx.command):
357
+ raise VoteRequired()
358
+ return True
359
+
360
+ original_cmd_error = bot.on_command_error
361
+
362
+ async def on_command_error(ctx, error):
363
+ if isinstance(error, VoteRequired):
364
+ return
365
+ await original_cmd_error(ctx, error)
366
+
367
+ bot.add_check(prefix_check)
368
+ bot.on_command_error = on_command_error
369
+ return guard
370
+
371
+ async def get_votes(self, limit: int = 100) -> list:
372
+ return (await self._request("GET", f"/bot/votes?limit={int(limit)}"))["data"]
373
+
374
+ async def is_weekend(self) -> bool:
375
+ return bool((await self._request("GET", "/bot/weekend"))["double_votes"])
376
+
377
+ async def post_stats(self, server_count: int, user_count: Optional[int] = None, shard_count: Optional[int] = None) -> dict:
378
+ return await self._request("POST", "/bot/stats", {"server_count": server_count, "user_count": user_count, "shard_count": shard_count})
379
+
380
+ async def close(self) -> None:
381
+ if self._session and not self._session.closed:
382
+ await self._session.close()
383
+
384
+
385
+ class AutoPoster:
386
+ """Posts servers, users and shards automatically for discord.py / nextcord / pycord bots.
387
+
388
+ poster = AutoPoster(os.environ["DISCORDBOTS_TOKEN"], bot) # create once, e.g. in setup_hook
389
+ poster.start()
390
+
391
+ Waits until the bot is ready, then posts every `interval_minutes` (minimum 5, default 30).
392
+ For AutoShardedBot the totals across all shards are posted.
393
+ """
394
+
395
+ def __init__(self, token: Optional[str], bot, interval_minutes: int = 30, api: "Optional[AsyncApi]" = None,
396
+ on_success=None, on_error=None):
397
+ self.api = api or AsyncApi(token)
398
+ self.bot = bot
399
+ self.interval = max(5, int(interval_minutes)) * 60
400
+ self.on_success = on_success
401
+ self.on_error = on_error
402
+ self._task = None
403
+
404
+ def stats(self) -> dict:
405
+ guilds = list(getattr(self.bot, "guilds", []))
406
+ return {
407
+ "server_count": len(guilds),
408
+ "user_count": sum((getattr(g, "member_count", None) or 0) for g in guilds),
409
+ "shard_count": getattr(self.bot, "shard_count", None) or 1,
410
+ }
411
+
412
+ async def post_once(self) -> dict:
413
+ s = self.stats()
414
+ await self.api.post_stats(**s)
415
+ return s
416
+
417
+ async def _loop(self):
418
+ import asyncio
419
+
420
+ wait = getattr(self.bot, "wait_until_ready", None)
421
+ if wait:
422
+ await wait()
423
+ while True:
424
+ try:
425
+ s = await self.post_once()
426
+ if self.on_success:
427
+ self.on_success(s)
428
+ except Exception as e: # never crash the bot because stats failed
429
+ if self.on_error:
430
+ self.on_error(e)
431
+ await asyncio.sleep(self.interval)
432
+
433
+ def start(self):
434
+ import asyncio
435
+
436
+ if self._task is None or self._task.done():
437
+ self._task = asyncio.get_event_loop().create_task(self._loop())
438
+ return self._task
439
+
440
+ def stop(self):
441
+ if self._task and not self._task.done():
442
+ self._task.cancel()
443
+
444
+
445
+ class Webhook:
446
+ """Verifies vote webhooks: Authorization == secret, and the X-DiscordBots-Signature HMAC when present."""
447
+
448
+ def __init__(self, secret: Optional[str] = None):
449
+ secret = secret or os.environ.get("DISCORDBOTS_WEBHOOK_SECRET")
450
+ if not secret:
451
+ raise ValueError("Webhook secret is required.")
452
+ self.secret = secret
453
+
454
+ def verify(self, headers: Mapping[str, str], raw_body: bytes) -> bool:
455
+ h = {k.lower(): v for k, v in headers.items()}
456
+ if not hmac.compare_digest(h.get("authorization", ""), self.secret):
457
+ return False
458
+ sig = h.get("x-discordbots-signature")
459
+ if sig:
460
+ expected = "sha256=" + hmac.new(self.secret.encode(), raw_body, hashlib.sha256).hexdigest()
461
+ if not hmac.compare_digest(sig, expected):
462
+ return False
463
+ return True
464
+
465
+ def parse(self, headers: Mapping[str, str], raw_body: bytes) -> Optional[dict]:
466
+ """Returns the vote dict if the request is genuine, otherwise None."""
467
+ if not self.verify(headers, raw_body):
468
+ return None
469
+ try:
470
+ return json.loads(raw_body)
471
+ except ValueError:
472
+ return None
473
+
474
+ def aiohttp_handler(self, on_vote):
475
+ """aiohttp route handler: app.router.add_post('/votes', webhook.aiohttp_handler(on_vote))"""
476
+ from aiohttp import web
477
+
478
+ async def handler(request):
479
+ vote = self.parse(request.headers, await request.read())
480
+ if vote is None:
481
+ return web.json_response({"error": "Unauthorized"}, status=401)
482
+ await on_vote(vote)
483
+ return web.Response(status=204)
484
+
485
+ return handler
486
+
487
+ DiscordBotsError = DiscoveryError # new name, same class
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: discordbots
3
+ Version: 1.0.0
4
+ Summary: Official SDK for the DiscordBots bot API: vote checks, server stats and vote webhooks.
5
+ Author: DiscordBots
6
+ License: MIT
7
+ Project-URL: Documentation, https://discordbots.in/docs
8
+ Keywords: discord,bot,votes,webhook,discordbots
9
+ Requires-Python: >=3.9
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Provides-Extra: aiohttp
13
+ Requires-Dist: aiohttp>=3.9; extra == "aiohttp"
14
+ Dynamic: license-file
15
+
16
+ # discordbots
17
+
18
+ Check votes, post your server count and receive vote webhooks for bots listed on DiscordBots. Python 3.9+.
19
+
20
+ ```bash
21
+ pip install "discordbots[aiohttp]" # async client + webhook server (discord.py, nextcord, pycord)
22
+ pip install discordbots # blocking client only, no dependencies
23
+ ```
24
+
25
+ Get a token from your bot's page on DiscordBots: **Manage → Webhooks & API → Generate token**.
26
+ Put it in `DISCORDBOTS_TOKEN` and the SDK picks it up.
27
+
28
+ ## Vote-only command
29
+
30
+ ```python
31
+ from discordbots import AsyncApi
32
+
33
+ dbots = AsyncApi() # reads DISCORDBOTS_TOKEN
34
+
35
+ @bot.tree.command(name="daily")
36
+ async def daily(interaction: discord.Interaction):
37
+ if not await dbots.has_voted(interaction.user.id):
38
+ url = await dbots.vote_url()
39
+ return await interaction.response.send_message(f"Vote first: {url}", ephemeral=True)
40
+ await interaction.response.send_message("Here is your daily reward.")
41
+ ```
42
+
43
+ A vote lasts 12 hours. A "yes" is cached until the vote expires and a "no" for 30 seconds (`cache_seconds`), and parallel checks for one user share one request. After a 429 the SDK waits for `Retry-After`. Call `dbots.forget(user_id)` in your webhook to unlock a new voter instantly.
44
+
45
+ Or use the decorator and choose what non-voters see:
46
+
47
+ ```python
48
+ async def locked(interaction, vote_url):
49
+ await interaction.response.send_message(f"Vote first: {vote_url}", ephemeral=True)
50
+
51
+ @bot.tree.command(name="daily")
52
+ @dbots.require_vote(on_not_voted=locked)
53
+ async def daily(interaction):
54
+ await interaction.response.send_message("You got 500 coins.")
55
+
56
+ @bot.command() # prefix commands work too
57
+ @dbots.require_vote()
58
+ async def rob(ctx, target: str):
59
+ await ctx.send(f"Robbing {target}")
60
+ ```
61
+
62
+ If the API is down the command still runs. Pass `on_error=` to handle that yourself.
63
+
64
+ ## One setup for all commands
65
+
66
+ ```python
67
+ from discordbots import AsyncApi, vote
68
+
69
+ dbots = AsyncApi()
70
+ dbots.setup(bot) # vote_all=True locks every command unless marked vote=False
71
+
72
+ @vote(True)
73
+ @bot.tree.command(name="daily")
74
+ async def daily(interaction): ...
75
+
76
+ @bot.command(extras={"vote": True}) # extras works too, slash and prefix
77
+ async def rob(ctx): ...
78
+ ```
79
+
80
+ Own handler? `guard = dbots.vote_guard()` then `if not await guard(ctx, command): return`.
81
+
82
+ ## Post your server count
83
+
84
+ ```python
85
+ from discordbots import AutoPoster
86
+
87
+ class MyBot(commands.Bot):
88
+ async def setup_hook(self):
89
+ AutoPoster(None, self, on_error=print).start() # None = token from env
90
+ ```
91
+
92
+ Waits for the bot to be ready, then sends servers, users and shards every 30 minutes (`interval_minutes=60`, at least 5).
93
+ `AutoShardedBot` works the same. To post by hand: `await dbots.post_stats(server_count, user_count, shard_count)`.
94
+
95
+ ## Vote webhook
96
+
97
+ ```python
98
+ from aiohttp import web
99
+ from discordbots import Webhook
100
+
101
+ webhook = Webhook() # reads DISCORDBOTS_WEBHOOK_SECRET
102
+
103
+ async def on_vote(vote):
104
+ if vote["type"] == "test":
105
+ return
106
+ await give_coins(int(vote["user"]), 100 * vote["weight"]) # weight is 2 on double-vote weekends
107
+
108
+ app = web.Application()
109
+ app.router.add_post("/votes", webhook.aiohttp_handler(on_vote))
110
+ web.run_app(app, port=3000)
111
+ ```
112
+
113
+ Flask or FastAPI: `vote = webhook.parse(request.headers, raw_body)` returns the vote, or `None` if it didn't come from us.
114
+
115
+ ## All methods
116
+
117
+ `Api` (blocking) and `AsyncApi` have the same methods; on `AsyncApi` you `await` them.
118
+
119
+ | Method | Returns |
120
+ |---|---|
121
+ | `has_voted(user_id)` | `bool` |
122
+ | `check_vote(user_id)` | `{"voted", "voted_at", "expires_at"}` |
123
+ | `require_vote(on_not_voted=, get_user_id=, on_error=)` | decorator (`AsyncApi`) |
124
+ | `setup(bot, vote_all=False, on_not_voted=)` | checks every command's vote flag (`AsyncApi`) |
125
+ | `vote_guard(vote_all=False, ...)` | `async (ctx, command) -> bool` (`AsyncApi`) |
126
+ | `get_bot()` | your listing (name, votes, stats, `vote_url`) |
127
+ | `vote_url()` | link to your vote page (`AsyncApi`) |
128
+ | `get_votes(limit=100)` | recent voters |
129
+ | `is_weekend()` | `True` when votes count double |
130
+ | `post_stats(server_count, user_count=None, shard_count=None)` | saved stats |
131
+
132
+ Errors are raised as `DiscordBotsError` with `.status`.
133
+
134
+ Environment variables: `DISCORDBOTS_TOKEN`, `DISCORDBOTS_WEBHOOK_SECRET`, `DISCORDBOTS_API_URL` (testing only).
135
+
136
+ Full docs: https://discordbots.in/docs
@@ -0,0 +1,6 @@
1
+ discordbots/__init__.py,sha256=QKfmOgX3nWVIeuIJfmzq_sWT91CgNfIVumKcuI5wTGg,19512
2
+ discordbots-1.0.0.dist-info/licenses/LICENSE,sha256=_sOd_US10FqNGSDszQ056YCFAp6pAQUvJvEpaYUmrw8,1068
3
+ discordbots-1.0.0.dist-info/METADATA,sha256=qsF6RQkIX4SZYd001O2XfVpGypOKVAIFItR6TjQ0PV4,4905
4
+ discordbots-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ discordbots-1.0.0.dist-info/top_level.txt,sha256=75x0N3An76xrwTqR_6gcgBwpND1l3vCYaqhk15_Vl00,12
6
+ discordbots-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DiscordBots
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ discordbots