discordbots 1.0.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.
- discordbots-1.0.0/LICENSE +21 -0
- discordbots-1.0.0/PKG-INFO +136 -0
- discordbots-1.0.0/README.md +121 -0
- discordbots-1.0.0/discordbots/__init__.py +487 -0
- discordbots-1.0.0/discordbots.egg-info/PKG-INFO +136 -0
- discordbots-1.0.0/discordbots.egg-info/SOURCES.txt +9 -0
- discordbots-1.0.0/discordbots.egg-info/dependency_links.txt +1 -0
- discordbots-1.0.0/discordbots.egg-info/requires.txt +3 -0
- discordbots-1.0.0/discordbots.egg-info/top_level.txt +1 -0
- discordbots-1.0.0/pyproject.toml +21 -0
- discordbots-1.0.0/setup.cfg +4 -0
|
@@ -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,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,121 @@
|
|
|
1
|
+
# discordbots
|
|
2
|
+
|
|
3
|
+
Check votes, post your server count and receive vote webhooks for bots listed on DiscordBots. Python 3.9+.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install "discordbots[aiohttp]" # async client + webhook server (discord.py, nextcord, pycord)
|
|
7
|
+
pip install discordbots # blocking client only, no dependencies
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Get a token from your bot's page on DiscordBots: **Manage → Webhooks & API → Generate token**.
|
|
11
|
+
Put it in `DISCORDBOTS_TOKEN` and the SDK picks it up.
|
|
12
|
+
|
|
13
|
+
## Vote-only command
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from discordbots import AsyncApi
|
|
17
|
+
|
|
18
|
+
dbots = AsyncApi() # reads DISCORDBOTS_TOKEN
|
|
19
|
+
|
|
20
|
+
@bot.tree.command(name="daily")
|
|
21
|
+
async def daily(interaction: discord.Interaction):
|
|
22
|
+
if not await dbots.has_voted(interaction.user.id):
|
|
23
|
+
url = await dbots.vote_url()
|
|
24
|
+
return await interaction.response.send_message(f"Vote first: {url}", ephemeral=True)
|
|
25
|
+
await interaction.response.send_message("Here is your daily reward.")
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
Or use the decorator and choose what non-voters see:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
async def locked(interaction, vote_url):
|
|
34
|
+
await interaction.response.send_message(f"Vote first: {vote_url}", ephemeral=True)
|
|
35
|
+
|
|
36
|
+
@bot.tree.command(name="daily")
|
|
37
|
+
@dbots.require_vote(on_not_voted=locked)
|
|
38
|
+
async def daily(interaction):
|
|
39
|
+
await interaction.response.send_message("You got 500 coins.")
|
|
40
|
+
|
|
41
|
+
@bot.command() # prefix commands work too
|
|
42
|
+
@dbots.require_vote()
|
|
43
|
+
async def rob(ctx, target: str):
|
|
44
|
+
await ctx.send(f"Robbing {target}")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
If the API is down the command still runs. Pass `on_error=` to handle that yourself.
|
|
48
|
+
|
|
49
|
+
## One setup for all commands
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from discordbots import AsyncApi, vote
|
|
53
|
+
|
|
54
|
+
dbots = AsyncApi()
|
|
55
|
+
dbots.setup(bot) # vote_all=True locks every command unless marked vote=False
|
|
56
|
+
|
|
57
|
+
@vote(True)
|
|
58
|
+
@bot.tree.command(name="daily")
|
|
59
|
+
async def daily(interaction): ...
|
|
60
|
+
|
|
61
|
+
@bot.command(extras={"vote": True}) # extras works too, slash and prefix
|
|
62
|
+
async def rob(ctx): ...
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Own handler? `guard = dbots.vote_guard()` then `if not await guard(ctx, command): return`.
|
|
66
|
+
|
|
67
|
+
## Post your server count
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from discordbots import AutoPoster
|
|
71
|
+
|
|
72
|
+
class MyBot(commands.Bot):
|
|
73
|
+
async def setup_hook(self):
|
|
74
|
+
AutoPoster(None, self, on_error=print).start() # None = token from env
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Waits for the bot to be ready, then sends servers, users and shards every 30 minutes (`interval_minutes=60`, at least 5).
|
|
78
|
+
`AutoShardedBot` works the same. To post by hand: `await dbots.post_stats(server_count, user_count, shard_count)`.
|
|
79
|
+
|
|
80
|
+
## Vote webhook
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from aiohttp import web
|
|
84
|
+
from discordbots import Webhook
|
|
85
|
+
|
|
86
|
+
webhook = Webhook() # reads DISCORDBOTS_WEBHOOK_SECRET
|
|
87
|
+
|
|
88
|
+
async def on_vote(vote):
|
|
89
|
+
if vote["type"] == "test":
|
|
90
|
+
return
|
|
91
|
+
await give_coins(int(vote["user"]), 100 * vote["weight"]) # weight is 2 on double-vote weekends
|
|
92
|
+
|
|
93
|
+
app = web.Application()
|
|
94
|
+
app.router.add_post("/votes", webhook.aiohttp_handler(on_vote))
|
|
95
|
+
web.run_app(app, port=3000)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Flask or FastAPI: `vote = webhook.parse(request.headers, raw_body)` returns the vote, or `None` if it didn't come from us.
|
|
99
|
+
|
|
100
|
+
## All methods
|
|
101
|
+
|
|
102
|
+
`Api` (blocking) and `AsyncApi` have the same methods; on `AsyncApi` you `await` them.
|
|
103
|
+
|
|
104
|
+
| Method | Returns |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `has_voted(user_id)` | `bool` |
|
|
107
|
+
| `check_vote(user_id)` | `{"voted", "voted_at", "expires_at"}` |
|
|
108
|
+
| `require_vote(on_not_voted=, get_user_id=, on_error=)` | decorator (`AsyncApi`) |
|
|
109
|
+
| `setup(bot, vote_all=False, on_not_voted=)` | checks every command's vote flag (`AsyncApi`) |
|
|
110
|
+
| `vote_guard(vote_all=False, ...)` | `async (ctx, command) -> bool` (`AsyncApi`) |
|
|
111
|
+
| `get_bot()` | your listing (name, votes, stats, `vote_url`) |
|
|
112
|
+
| `vote_url()` | link to your vote page (`AsyncApi`) |
|
|
113
|
+
| `get_votes(limit=100)` | recent voters |
|
|
114
|
+
| `is_weekend()` | `True` when votes count double |
|
|
115
|
+
| `post_stats(server_count, user_count=None, shard_count=None)` | saved stats |
|
|
116
|
+
|
|
117
|
+
Errors are raised as `DiscordBotsError` with `.status`.
|
|
118
|
+
|
|
119
|
+
Environment variables: `DISCORDBOTS_TOKEN`, `DISCORDBOTS_WEBHOOK_SECRET`, `DISCORDBOTS_API_URL` (testing only).
|
|
120
|
+
|
|
121
|
+
Full docs: https://discordbots.in/docs
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
discordbots
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "discordbots"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Official SDK for the DiscordBots bot API: vote checks, server stats and vote webhooks."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["discord", "bot", "votes", "webhook", "discordbots"]
|
|
14
|
+
|
|
15
|
+
authors = [{ name = "DiscordBots" }]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Documentation = "https://discordbots.in/docs"
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
aiohttp = ["aiohttp>=3.9"]
|