trustcord-sdk 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.
- trustcord_sdk-1.0.0/PKG-INFO +233 -0
- trustcord_sdk-1.0.0/README.md +217 -0
- trustcord_sdk-1.0.0/pyproject.toml +43 -0
- trustcord_sdk-1.0.0/setup.cfg +4 -0
- trustcord_sdk-1.0.0/tests/test_client.py +187 -0
- trustcord_sdk-1.0.0/tests/test_discord_ext.py +302 -0
- trustcord_sdk-1.0.0/tests/test_status.py +61 -0
- trustcord_sdk-1.0.0/tests/test_validation.py +93 -0
- trustcord_sdk-1.0.0/trustcord/__init__.py +33 -0
- trustcord_sdk-1.0.0/trustcord/client.py +295 -0
- trustcord_sdk-1.0.0/trustcord/discord_ext.py +273 -0
- trustcord_sdk-1.0.0/trustcord_sdk.egg-info/PKG-INFO +233 -0
- trustcord_sdk-1.0.0/trustcord_sdk.egg-info/SOURCES.txt +14 -0
- trustcord_sdk-1.0.0/trustcord_sdk.egg-info/dependency_links.txt +1 -0
- trustcord_sdk-1.0.0/trustcord_sdk.egg-info/requires.txt +10 -0
- trustcord_sdk-1.0.0/trustcord_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trustcord-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Client and discord.py helpers for the Trustcord register
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.27
|
|
9
|
+
Provides-Extra: discord
|
|
10
|
+
Requires-Dist: discord.py>=2.4; extra == "discord"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: discord.py>=2.4; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest>=8.3; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.25; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
16
|
+
|
|
17
|
+
# trustcord-sdk
|
|
18
|
+
|
|
19
|
+
A Python client for the Trustcord register, and the discord.py helpers that go
|
|
20
|
+
with it.
|
|
21
|
+
|
|
22
|
+
The register answers one question: has this Discord account been reported, had
|
|
23
|
+
that report examined by a jury, and upheld? It also exposes the public role of
|
|
24
|
+
active jurors and administrators, including accounts that are not listed. There is
|
|
25
|
+
no endpoint that returns a reason, a piece of evidence, a username or a report
|
|
26
|
+
count, so there is no method here that returns one either.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install trustcord-sdk # the HTTP client alone
|
|
32
|
+
pip install 'trustcord-sdk[discord]' # plus the discord.py helpers
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.11 or newer.
|
|
36
|
+
|
|
37
|
+
## Getting a key
|
|
38
|
+
|
|
39
|
+
Access is not open. Apply on the `/developers` page of the instance you want
|
|
40
|
+
to use: you describe your bot, you say why a register lookup belongs in it, and
|
|
41
|
+
an administrator reads the application before a key is issued.
|
|
42
|
+
|
|
43
|
+
A key identifies an application, never a person, and it must stay server side.
|
|
44
|
+
Anything that reaches the users of your bot can spend your whole quota.
|
|
45
|
+
|
|
46
|
+
## The client
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import asyncio
|
|
50
|
+
from trustcord import Level, Trustcord
|
|
51
|
+
|
|
52
|
+
async def main():
|
|
53
|
+
async with Trustcord("or_live_…", base_url="https://api.example") as client:
|
|
54
|
+
listing = await client.lookup(123456789012345678)
|
|
55
|
+
if listing and listing.level >= Level.MEDIUM:
|
|
56
|
+
print(f"listed {listing.level} since {listing.since:%Y-%m-%d}")
|
|
57
|
+
|
|
58
|
+
asyncio.run(main())
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`base_url` can come from the `TRUSTCORD_API_URL` environment variable instead
|
|
62
|
+
of the argument. If neither is set, it defaults to `https://api.trustcord.org`.
|
|
63
|
+
|
|
64
|
+
`lookup` returns `None` for an account that is not listed. It also returns
|
|
65
|
+
`None` for an account whose report is still being deliberated, and that is
|
|
66
|
+
deliberate: an open report is an accusation nobody has examined yet, and the API
|
|
67
|
+
makes it indistinguishable from an account nobody ever named. Nothing here lets
|
|
68
|
+
you sanction on suspicion.
|
|
69
|
+
|
|
70
|
+
### Public staff roles
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from trustcord import StaffRole
|
|
74
|
+
|
|
75
|
+
status = await client.lookup_status(123456789012345678)
|
|
76
|
+
if status.staff_role is StaffRole.JURY:
|
|
77
|
+
print("Active Trustcord juror")
|
|
78
|
+
listing = status.listed # Listing or None, as with lookup().
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`Status` exposes `user_id`, `listed` and `staff_role`. The role is `StaffRole.JURY`,
|
|
82
|
+
`StaffRole.ADMIN` or `None`; it does not imply that an NDA has been signed.
|
|
83
|
+
`Listing.staff_role` also exposes the role when an account is listed, including
|
|
84
|
+
results from `lookup_many`. Existing `lookup` and `lookup_many` return types are
|
|
85
|
+
unchanged. Missing or null role fields from older servers become `None`.
|
|
86
|
+
|
|
87
|
+
### A whole member list at once
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
answers = await client.lookup_many(member.id for member in guild.members)
|
|
91
|
+
listed = {user_id: l for user_id, l in answers.items() if l is not None}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
One call against the per-minute quota, one call per identifier against the
|
|
95
|
+
daily quota. Lists longer than the API's ceiling of 100 are split here rather
|
|
96
|
+
than refused.
|
|
97
|
+
|
|
98
|
+
### Filing a report
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
reference = await client.report(
|
|
102
|
+
[123456789012345678],
|
|
103
|
+
"What happened, in at least 30 characters.",
|
|
104
|
+
[("capture.png", screenshot_bytes)],
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
At least one screenshot is required: the jury judges the conversation, not a
|
|
109
|
+
summary of it. Each file's real type is checked against its bytes, so naming a
|
|
110
|
+
text file `capture.png` fails.
|
|
111
|
+
|
|
112
|
+
Your key is spent for the quota and nothing else. No column on the report points
|
|
113
|
+
back to your application, which also means you cannot read the report back or
|
|
114
|
+
learn what became of it. The reference is the only thread, and it belongs to
|
|
115
|
+
whoever filed.
|
|
116
|
+
|
|
117
|
+
### Errors
|
|
118
|
+
|
|
119
|
+
| Exception | When |
|
|
120
|
+
| --- | --- |
|
|
121
|
+
| `Unauthorized` | key missing, invalid, revoked, application suspended (401, 403) |
|
|
122
|
+
| `RateLimited` | quota spent; `retry_after` is the wait in seconds (429) |
|
|
123
|
+
| `TrustcordError` | anything else the API refused; `status` and `message` |
|
|
124
|
+
|
|
125
|
+
## The discord.py helpers
|
|
126
|
+
|
|
127
|
+
Article 14.1 of the GDPR puts the duty to inform on the bot, not on Trustcord.
|
|
128
|
+
If you restrict someone because the register says so, you must tell them, on the
|
|
129
|
+
server concerned, at the latest when they first interact, and point them at the
|
|
130
|
+
notice page. So the call that screens a member is the same call that informs
|
|
131
|
+
them:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from trustcord import Level, Trustcord
|
|
135
|
+
from trustcord.discord_ext import Listed, Registry, not_listed
|
|
136
|
+
|
|
137
|
+
client = Trustcord("or_live_…")
|
|
138
|
+
registry = Registry(client, notice_url="https://trustcord.example/notice")
|
|
139
|
+
|
|
140
|
+
@bot.event
|
|
141
|
+
async def on_member_join(member):
|
|
142
|
+
listing = await registry.screen(member) # informs if listed
|
|
143
|
+
if listing and listing.level is Level.HIGH:
|
|
144
|
+
await member.ban(reason=f"Trustcord {listing.level}")
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
| Method | Looks up | Informs |
|
|
148
|
+
| --- | --- | --- |
|
|
149
|
+
| `screen(member, channel=None)` | one account | yes, if listed |
|
|
150
|
+
| `lookup(member_or_id)` | one account | no |
|
|
151
|
+
| `audit(guild)` | every member, batched | no |
|
|
152
|
+
| `inform(member, listing, channel=None)` | nothing | yes |
|
|
153
|
+
|
|
154
|
+
`lookup` and `audit` inform nobody because they restrict nobody. The notice is
|
|
155
|
+
owed when you act on the answer, not when you read it.
|
|
156
|
+
|
|
157
|
+
`inform` sends a direct message first, since the notice is addressed to the
|
|
158
|
+
person and not to the server, and falls back to `channel` when their DMs are
|
|
159
|
+
closed. Everyone in that channel reads the notice, so pass one only if it is
|
|
160
|
+
private to that person. The `not_listed` check never uses the command's
|
|
161
|
+
channel: for a slash command it answers ephemerally instead, and for a prefix
|
|
162
|
+
command it has no fallback. It returns `False` when neither worked: that means nobody was informed,
|
|
163
|
+
and you have to inform them another way before you restrict them. It sends once
|
|
164
|
+
per member and per guild.
|
|
165
|
+
|
|
166
|
+
### Blocking a command
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
@bot.tree.command()
|
|
170
|
+
@not_listed(registry, minimum=Level.MEDIUM)
|
|
171
|
+
async def giveaway(interaction): ...
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`not_listed` works on prefix commands and application commands alike. It raises
|
|
175
|
+
`Listed`, which inherits from both check-failure hierarchies, so one
|
|
176
|
+
`except Listed` in an error handler covers both:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
@bot.tree.error
|
|
180
|
+
async def on_error(interaction, error):
|
|
181
|
+
if isinstance(error, Listed):
|
|
182
|
+
await interaction.response.send_message("…", ephemeral=True)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The check informs the caller before it blocks them. It fails open: if the
|
|
186
|
+
register is unreachable, the command runs. A moderation aid that takes the bot
|
|
187
|
+
down with it is worse than one that misses a listing for a minute.
|
|
188
|
+
|
|
189
|
+
`minimum` is the level from which the command is refused, so
|
|
190
|
+
`minimum=Level.HIGH` lets a `low` listing through untouched, and informs nobody
|
|
191
|
+
because nothing was restricted.
|
|
192
|
+
|
|
193
|
+
### The notice text
|
|
194
|
+
|
|
195
|
+
`DEFAULT_NOTICE` is in French, the language of the service and of the people it
|
|
196
|
+
lists. Override it with `notice=` if your bot speaks another language, but keep
|
|
197
|
+
what the article requires: who holds the register, what it holds, where it came
|
|
198
|
+
from, and where to object. Three fields are interpolated: `{level}`, `{since}`
|
|
199
|
+
and `{notice_url}`.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
registry = Registry(client, notice_url=…, notice=MY_NOTICE)
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## What stays your responsibility
|
|
206
|
+
|
|
207
|
+
The SDK screens and informs. Whether to act on a listing, and how hard, is your
|
|
208
|
+
decision: for what your bot does with the answer, you are the controller, and
|
|
209
|
+
the person you restricted comes to you, not to Trustcord. A `high` listing
|
|
210
|
+
means a jury thought the facts might be criminal; it is not a conviction.
|
|
211
|
+
|
|
212
|
+
## Levels
|
|
213
|
+
|
|
214
|
+
| Level | What the jury found |
|
|
215
|
+
| --- | --- |
|
|
216
|
+
| `Level.LOW` | a lapse, nothing grave |
|
|
217
|
+
| `Level.MEDIUM` | conduct Discord itself forbids |
|
|
218
|
+
| `Level.HIGH` | facts that may be criminal; reported to Pharos |
|
|
219
|
+
|
|
220
|
+
They compare by gravity, so `listing.level >= Level.MEDIUM` reads the way it
|
|
221
|
+
sounds. A plain string comparison would sort `high` below `low`.
|
|
222
|
+
|
|
223
|
+
## Development
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
python -m venv .venv
|
|
227
|
+
.venv/bin/pip install -e '.[dev]'
|
|
228
|
+
.venv/bin/python -m pytest -q
|
|
229
|
+
.venv/bin/ruff check .
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The tests use `httpx.MockTransport` for the client and stand-in objects for
|
|
233
|
+
discord.py, so nothing reaches the network.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# trustcord-sdk
|
|
2
|
+
|
|
3
|
+
A Python client for the Trustcord register, and the discord.py helpers that go
|
|
4
|
+
with it.
|
|
5
|
+
|
|
6
|
+
The register answers one question: has this Discord account been reported, had
|
|
7
|
+
that report examined by a jury, and upheld? It also exposes the public role of
|
|
8
|
+
active jurors and administrators, including accounts that are not listed. There is
|
|
9
|
+
no endpoint that returns a reason, a piece of evidence, a username or a report
|
|
10
|
+
count, so there is no method here that returns one either.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pip install trustcord-sdk # the HTTP client alone
|
|
16
|
+
pip install 'trustcord-sdk[discord]' # plus the discord.py helpers
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Python 3.11 or newer.
|
|
20
|
+
|
|
21
|
+
## Getting a key
|
|
22
|
+
|
|
23
|
+
Access is not open. Apply on the `/developers` page of the instance you want
|
|
24
|
+
to use: you describe your bot, you say why a register lookup belongs in it, and
|
|
25
|
+
an administrator reads the application before a key is issued.
|
|
26
|
+
|
|
27
|
+
A key identifies an application, never a person, and it must stay server side.
|
|
28
|
+
Anything that reaches the users of your bot can spend your whole quota.
|
|
29
|
+
|
|
30
|
+
## The client
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import asyncio
|
|
34
|
+
from trustcord import Level, Trustcord
|
|
35
|
+
|
|
36
|
+
async def main():
|
|
37
|
+
async with Trustcord("or_live_…", base_url="https://api.example") as client:
|
|
38
|
+
listing = await client.lookup(123456789012345678)
|
|
39
|
+
if listing and listing.level >= Level.MEDIUM:
|
|
40
|
+
print(f"listed {listing.level} since {listing.since:%Y-%m-%d}")
|
|
41
|
+
|
|
42
|
+
asyncio.run(main())
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`base_url` can come from the `TRUSTCORD_API_URL` environment variable instead
|
|
46
|
+
of the argument. If neither is set, it defaults to `https://api.trustcord.org`.
|
|
47
|
+
|
|
48
|
+
`lookup` returns `None` for an account that is not listed. It also returns
|
|
49
|
+
`None` for an account whose report is still being deliberated, and that is
|
|
50
|
+
deliberate: an open report is an accusation nobody has examined yet, and the API
|
|
51
|
+
makes it indistinguishable from an account nobody ever named. Nothing here lets
|
|
52
|
+
you sanction on suspicion.
|
|
53
|
+
|
|
54
|
+
### Public staff roles
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from trustcord import StaffRole
|
|
58
|
+
|
|
59
|
+
status = await client.lookup_status(123456789012345678)
|
|
60
|
+
if status.staff_role is StaffRole.JURY:
|
|
61
|
+
print("Active Trustcord juror")
|
|
62
|
+
listing = status.listed # Listing or None, as with lookup().
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`Status` exposes `user_id`, `listed` and `staff_role`. The role is `StaffRole.JURY`,
|
|
66
|
+
`StaffRole.ADMIN` or `None`; it does not imply that an NDA has been signed.
|
|
67
|
+
`Listing.staff_role` also exposes the role when an account is listed, including
|
|
68
|
+
results from `lookup_many`. Existing `lookup` and `lookup_many` return types are
|
|
69
|
+
unchanged. Missing or null role fields from older servers become `None`.
|
|
70
|
+
|
|
71
|
+
### A whole member list at once
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
answers = await client.lookup_many(member.id for member in guild.members)
|
|
75
|
+
listed = {user_id: l for user_id, l in answers.items() if l is not None}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
One call against the per-minute quota, one call per identifier against the
|
|
79
|
+
daily quota. Lists longer than the API's ceiling of 100 are split here rather
|
|
80
|
+
than refused.
|
|
81
|
+
|
|
82
|
+
### Filing a report
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
reference = await client.report(
|
|
86
|
+
[123456789012345678],
|
|
87
|
+
"What happened, in at least 30 characters.",
|
|
88
|
+
[("capture.png", screenshot_bytes)],
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
At least one screenshot is required: the jury judges the conversation, not a
|
|
93
|
+
summary of it. Each file's real type is checked against its bytes, so naming a
|
|
94
|
+
text file `capture.png` fails.
|
|
95
|
+
|
|
96
|
+
Your key is spent for the quota and nothing else. No column on the report points
|
|
97
|
+
back to your application, which also means you cannot read the report back or
|
|
98
|
+
learn what became of it. The reference is the only thread, and it belongs to
|
|
99
|
+
whoever filed.
|
|
100
|
+
|
|
101
|
+
### Errors
|
|
102
|
+
|
|
103
|
+
| Exception | When |
|
|
104
|
+
| --- | --- |
|
|
105
|
+
| `Unauthorized` | key missing, invalid, revoked, application suspended (401, 403) |
|
|
106
|
+
| `RateLimited` | quota spent; `retry_after` is the wait in seconds (429) |
|
|
107
|
+
| `TrustcordError` | anything else the API refused; `status` and `message` |
|
|
108
|
+
|
|
109
|
+
## The discord.py helpers
|
|
110
|
+
|
|
111
|
+
Article 14.1 of the GDPR puts the duty to inform on the bot, not on Trustcord.
|
|
112
|
+
If you restrict someone because the register says so, you must tell them, on the
|
|
113
|
+
server concerned, at the latest when they first interact, and point them at the
|
|
114
|
+
notice page. So the call that screens a member is the same call that informs
|
|
115
|
+
them:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from trustcord import Level, Trustcord
|
|
119
|
+
from trustcord.discord_ext import Listed, Registry, not_listed
|
|
120
|
+
|
|
121
|
+
client = Trustcord("or_live_…")
|
|
122
|
+
registry = Registry(client, notice_url="https://trustcord.example/notice")
|
|
123
|
+
|
|
124
|
+
@bot.event
|
|
125
|
+
async def on_member_join(member):
|
|
126
|
+
listing = await registry.screen(member) # informs if listed
|
|
127
|
+
if listing and listing.level is Level.HIGH:
|
|
128
|
+
await member.ban(reason=f"Trustcord {listing.level}")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
| Method | Looks up | Informs |
|
|
132
|
+
| --- | --- | --- |
|
|
133
|
+
| `screen(member, channel=None)` | one account | yes, if listed |
|
|
134
|
+
| `lookup(member_or_id)` | one account | no |
|
|
135
|
+
| `audit(guild)` | every member, batched | no |
|
|
136
|
+
| `inform(member, listing, channel=None)` | nothing | yes |
|
|
137
|
+
|
|
138
|
+
`lookup` and `audit` inform nobody because they restrict nobody. The notice is
|
|
139
|
+
owed when you act on the answer, not when you read it.
|
|
140
|
+
|
|
141
|
+
`inform` sends a direct message first, since the notice is addressed to the
|
|
142
|
+
person and not to the server, and falls back to `channel` when their DMs are
|
|
143
|
+
closed. Everyone in that channel reads the notice, so pass one only if it is
|
|
144
|
+
private to that person. The `not_listed` check never uses the command's
|
|
145
|
+
channel: for a slash command it answers ephemerally instead, and for a prefix
|
|
146
|
+
command it has no fallback. It returns `False` when neither worked: that means nobody was informed,
|
|
147
|
+
and you have to inform them another way before you restrict them. It sends once
|
|
148
|
+
per member and per guild.
|
|
149
|
+
|
|
150
|
+
### Blocking a command
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
@bot.tree.command()
|
|
154
|
+
@not_listed(registry, minimum=Level.MEDIUM)
|
|
155
|
+
async def giveaway(interaction): ...
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`not_listed` works on prefix commands and application commands alike. It raises
|
|
159
|
+
`Listed`, which inherits from both check-failure hierarchies, so one
|
|
160
|
+
`except Listed` in an error handler covers both:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
@bot.tree.error
|
|
164
|
+
async def on_error(interaction, error):
|
|
165
|
+
if isinstance(error, Listed):
|
|
166
|
+
await interaction.response.send_message("…", ephemeral=True)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The check informs the caller before it blocks them. It fails open: if the
|
|
170
|
+
register is unreachable, the command runs. A moderation aid that takes the bot
|
|
171
|
+
down with it is worse than one that misses a listing for a minute.
|
|
172
|
+
|
|
173
|
+
`minimum` is the level from which the command is refused, so
|
|
174
|
+
`minimum=Level.HIGH` lets a `low` listing through untouched, and informs nobody
|
|
175
|
+
because nothing was restricted.
|
|
176
|
+
|
|
177
|
+
### The notice text
|
|
178
|
+
|
|
179
|
+
`DEFAULT_NOTICE` is in French, the language of the service and of the people it
|
|
180
|
+
lists. Override it with `notice=` if your bot speaks another language, but keep
|
|
181
|
+
what the article requires: who holds the register, what it holds, where it came
|
|
182
|
+
from, and where to object. Three fields are interpolated: `{level}`, `{since}`
|
|
183
|
+
and `{notice_url}`.
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
registry = Registry(client, notice_url=…, notice=MY_NOTICE)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## What stays your responsibility
|
|
190
|
+
|
|
191
|
+
The SDK screens and informs. Whether to act on a listing, and how hard, is your
|
|
192
|
+
decision: for what your bot does with the answer, you are the controller, and
|
|
193
|
+
the person you restricted comes to you, not to Trustcord. A `high` listing
|
|
194
|
+
means a jury thought the facts might be criminal; it is not a conviction.
|
|
195
|
+
|
|
196
|
+
## Levels
|
|
197
|
+
|
|
198
|
+
| Level | What the jury found |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `Level.LOW` | a lapse, nothing grave |
|
|
201
|
+
| `Level.MEDIUM` | conduct Discord itself forbids |
|
|
202
|
+
| `Level.HIGH` | facts that may be criminal; reported to Pharos |
|
|
203
|
+
|
|
204
|
+
They compare by gravity, so `listing.level >= Level.MEDIUM` reads the way it
|
|
205
|
+
sounds. A plain string comparison would sort `high` below `low`.
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
```sh
|
|
210
|
+
python -m venv .venv
|
|
211
|
+
.venv/bin/pip install -e '.[dev]'
|
|
212
|
+
.venv/bin/python -m pytest -q
|
|
213
|
+
.venv/bin/ruff check .
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The tests use `httpx.MockTransport` for the client and stand-in objects for
|
|
217
|
+
discord.py, so nothing reaches the network.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "trustcord-sdk"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "Client and discord.py helpers for the Trustcord register"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"httpx>=0.27",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
discord = [
|
|
14
|
+
"discord.py>=2.4",
|
|
15
|
+
]
|
|
16
|
+
dev = [
|
|
17
|
+
"discord.py>=2.4",
|
|
18
|
+
"pytest>=8.3",
|
|
19
|
+
"pytest-asyncio>=0.25",
|
|
20
|
+
"ruff>=0.8",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["setuptools>=75"]
|
|
25
|
+
build-backend = "setuptools.build_meta"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["trustcord"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py311"
|
|
33
|
+
indent-width = 4
|
|
34
|
+
|
|
35
|
+
[tool.ruff.lint]
|
|
36
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ASYNC"]
|
|
37
|
+
|
|
38
|
+
[tool.ruff.format]
|
|
39
|
+
indent-style = "space"
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
asyncio_mode = "auto"
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""What the client promises, checked against a fake API.
|
|
2
|
+
|
|
3
|
+
The register's whole contract is three fields, so most of these tests are about
|
|
4
|
+
what the client refuses to invent from them: no listing for an account that is
|
|
5
|
+
not listed, no listing for one whose case is still open, and no silent
|
|
6
|
+
truncation when a caller asks about more accounts than the bulk endpoint takes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from trustcord import (
|
|
17
|
+
BULK_MAX,
|
|
18
|
+
Level,
|
|
19
|
+
RateLimited,
|
|
20
|
+
Trustcord,
|
|
21
|
+
TrustcordError,
|
|
22
|
+
Unauthorized,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
BASE = "https://register.test"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def client(handler) -> Trustcord:
|
|
29
|
+
return Trustcord("key", base_url=BASE, transport=httpx.MockTransport(handler))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def test_a_listed_account_comes_back_whole() -> None:
|
|
33
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
34
|
+
assert request.headers["Authorization"] == "Bearer key"
|
|
35
|
+
return httpx.Response(
|
|
36
|
+
200,
|
|
37
|
+
json={
|
|
38
|
+
"blacklisted": True,
|
|
39
|
+
"blacklistdate": 1_758_374_400,
|
|
40
|
+
"blacklistlevel": "medium",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
async with client(handler) as api:
|
|
45
|
+
listing = await api.lookup(123456789012345678)
|
|
46
|
+
|
|
47
|
+
assert listing is not None
|
|
48
|
+
assert listing.user_id == "123456789012345678"
|
|
49
|
+
assert listing.level is Level.MEDIUM
|
|
50
|
+
assert listing.since == datetime(2025, 9, 20, 13, 20, tzinfo=UTC)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def test_an_unlisted_account_is_none_not_an_empty_listing() -> None:
|
|
54
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
55
|
+
return httpx.Response(200, json={"blacklisted": False})
|
|
56
|
+
|
|
57
|
+
async with client(handler) as api:
|
|
58
|
+
assert await api.lookup(123456789012345678) is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def test_an_account_under_deliberation_reads_as_unlisted() -> None:
|
|
62
|
+
"""The API answers the same for an open case as for an unknown account."""
|
|
63
|
+
|
|
64
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
65
|
+
return httpx.Response(
|
|
66
|
+
200,
|
|
67
|
+
json={"blacklisted": False, "blacklistdate": None, "blacklistlevel": None},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async with client(handler) as api:
|
|
71
|
+
assert await api.lookup(123456789012345678) is None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def test_levels_compare_by_gravity_not_alphabetically() -> None:
|
|
75
|
+
assert Level.HIGH > Level.MEDIUM > Level.LOW
|
|
76
|
+
assert Level.LOW < Level.HIGH
|
|
77
|
+
assert not Level.LOW >= Level.MEDIUM
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def test_a_long_member_list_is_split_instead_of_refused() -> None:
|
|
81
|
+
chunks: list[int] = []
|
|
82
|
+
|
|
83
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
ids = json.loads(request.content)["ids"]
|
|
87
|
+
chunks.append(len(ids))
|
|
88
|
+
return httpx.Response(
|
|
89
|
+
200,
|
|
90
|
+
json={user_id: {"blacklisted": False} for user_id in ids},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async with client(handler) as api:
|
|
94
|
+
answers = await api.lookup_many(range(BULK_MAX + 5))
|
|
95
|
+
|
|
96
|
+
assert chunks == [BULK_MAX, 5]
|
|
97
|
+
assert len(answers) == BULK_MAX + 5
|
|
98
|
+
assert all(listing is None for listing in answers.values())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def test_bulk_keeps_the_listings_it_is_given() -> None:
|
|
102
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
103
|
+
return httpx.Response(
|
|
104
|
+
200,
|
|
105
|
+
json={
|
|
106
|
+
"1": {"blacklisted": True, "blacklistdate": None, "blacklistlevel": "high"},
|
|
107
|
+
"2": {"blacklisted": False},
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async with client(handler) as api:
|
|
112
|
+
answers = await api.lookup_many([1, 2])
|
|
113
|
+
|
|
114
|
+
assert answers["1"] is not None
|
|
115
|
+
assert answers["1"].level is Level.HIGH
|
|
116
|
+
assert answers["1"].since is None
|
|
117
|
+
assert answers["2"] is None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def test_filing_a_report_sends_the_reason_the_ids_and_the_files() -> None:
|
|
121
|
+
seen: dict[str, bytes] = {}
|
|
122
|
+
|
|
123
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
124
|
+
seen["body"] = request.content
|
|
125
|
+
return httpx.Response(201, json={"reference": "OR-2025-0001", "targets": 1})
|
|
126
|
+
|
|
127
|
+
async with client(handler) as api:
|
|
128
|
+
reference = await api.report(
|
|
129
|
+
[123456789012345678],
|
|
130
|
+
"a reason long enough for the API to accept it",
|
|
131
|
+
[("capture.png", b"\x89PNG\r\n\x1a\n")],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
assert reference == "OR-2025-0001"
|
|
135
|
+
body = seen["body"]
|
|
136
|
+
assert b"a reason long enough" in body
|
|
137
|
+
assert b"123456789012345678" in body
|
|
138
|
+
assert b"capture.png" in body
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_a_spent_quota_raises_with_the_wait_the_api_asked_for() -> None:
|
|
142
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
143
|
+
return httpx.Response(
|
|
144
|
+
429, json={"detail": "quota dépassé"}, headers={"Retry-After": "42"}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async with client(handler) as api:
|
|
148
|
+
with pytest.raises(RateLimited) as caught:
|
|
149
|
+
await api.lookup(123456789012345678)
|
|
150
|
+
|
|
151
|
+
assert caught.value.retry_after == 42
|
|
152
|
+
assert caught.value.status == 429
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def test_a_revoked_key_raises_unauthorized() -> None:
|
|
156
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
157
|
+
return httpx.Response(401, json={"detail": "clé révoquée"})
|
|
158
|
+
|
|
159
|
+
async with client(handler) as api:
|
|
160
|
+
with pytest.raises(Unauthorized):
|
|
161
|
+
await api.lookup(123456789012345678)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_an_unparseable_error_still_carries_its_status() -> None:
|
|
165
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
166
|
+
return httpx.Response(500, text="<html>gateway</html>")
|
|
167
|
+
|
|
168
|
+
async with client(handler) as api:
|
|
169
|
+
with pytest.raises(TrustcordError) as caught:
|
|
170
|
+
await api.lookup(123456789012345678)
|
|
171
|
+
|
|
172
|
+
assert caught.value.status == 500
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def test_the_base_url_defaults_to_trustcord(monkeypatch) -> None:
|
|
176
|
+
monkeypatch.delenv("TRUSTCORD_API_URL", raising=False)
|
|
177
|
+
api = Trustcord("key")
|
|
178
|
+
assert str(api._http.base_url) == "https://api.trustcord.org"
|
|
179
|
+
assert api._http.headers["User-Agent"] == "trustcord-sdk/1.0"
|
|
180
|
+
await api.close()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
async def test_the_base_url_can_come_from_the_environment(monkeypatch) -> None:
|
|
184
|
+
monkeypatch.setenv("TRUSTCORD_API_URL", BASE)
|
|
185
|
+
api = Trustcord("key")
|
|
186
|
+
assert str(api._http.base_url) == BASE
|
|
187
|
+
await api.close()
|