txapi-sdk 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.
- txapi/__init__.py +75 -0
- txapi/_client.py +364 -0
- txapi/_core.py +302 -0
- txapi/_endpoints.py +813 -0
- txapi/_errors.py +190 -0
- txapi/_generated.py +10599 -0
- txapi/_pagination.py +140 -0
- txapi/_protocols.py +41 -0
- txapi/_ratelimit.py +81 -0
- txapi/_version.py +7 -0
- txapi/live.py +632 -0
- txapi/py.typed +0 -0
- txapi/types.py +532 -0
- txapi/webhooks.py +137 -0
- txapi_sdk-1.0.0.dist-info/METADATA +537 -0
- txapi_sdk-1.0.0.dist-info/RECORD +18 -0
- txapi_sdk-1.0.0.dist-info/WHEEL +4 -0
- txapi_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
txapi/types.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""Typed response models.
|
|
2
|
+
|
|
3
|
+
Every model is a :class:`~typing.TypedDict` with ``total=False``: the values
|
|
4
|
+
returned by the SDK are plain ``dict`` objects, so fields the API adds in a
|
|
5
|
+
later minor version simply show up as extra keys and never break parsing.
|
|
6
|
+
Shapes follow the ``responseExample`` entries of the API catalog.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Literal, TypedDict, Union
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ActionLog",
|
|
15
|
+
"ActionLogLine",
|
|
16
|
+
"Activity",
|
|
17
|
+
"ActivityBucket",
|
|
18
|
+
"ActivityTotals",
|
|
19
|
+
"Admin",
|
|
20
|
+
"AdminCreated",
|
|
21
|
+
"AdminList",
|
|
22
|
+
"AdminPermission",
|
|
23
|
+
"AdminSummary",
|
|
24
|
+
"AvailablePermissions",
|
|
25
|
+
"BanTemplate",
|
|
26
|
+
"BanTemplates",
|
|
27
|
+
"ConsoleBuffer",
|
|
28
|
+
"DiscordGuildResult",
|
|
29
|
+
"DiscordResult",
|
|
30
|
+
"Event",
|
|
31
|
+
"EventPage",
|
|
32
|
+
"EventSummary",
|
|
33
|
+
"HistoryPage",
|
|
34
|
+
"HistoryRow",
|
|
35
|
+
"JsonValue",
|
|
36
|
+
"Leaderboard",
|
|
37
|
+
"LeaderboardRow",
|
|
38
|
+
"Me",
|
|
39
|
+
"MeKey",
|
|
40
|
+
"MePlan",
|
|
41
|
+
"MeQuota",
|
|
42
|
+
"MutationResult",
|
|
43
|
+
"NamedCount",
|
|
44
|
+
"PlayerEventStats",
|
|
45
|
+
"PlayerPage",
|
|
46
|
+
"PlayerRow",
|
|
47
|
+
"PlayerStatsSummary",
|
|
48
|
+
"Playtime",
|
|
49
|
+
"PlaytimeRow",
|
|
50
|
+
"ServerList",
|
|
51
|
+
"ServerLogBuffer",
|
|
52
|
+
"ServerLogEntry",
|
|
53
|
+
"ServerLogHistory",
|
|
54
|
+
"ServerStatus",
|
|
55
|
+
"ServerSummary",
|
|
56
|
+
"WebhookEvent",
|
|
57
|
+
"WebhookEventName",
|
|
58
|
+
"WhitelistApproval",
|
|
59
|
+
"WhitelistApprovals",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
JsonValue = Any
|
|
63
|
+
"""Untyped JSON passed through from txAdmin (``dict``, ``list``, ``str`` …)."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── mutations ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DiscordGuildResult(TypedDict, total=False):
|
|
70
|
+
"""Outcome of a Discord ban/unban on one linked Discord server."""
|
|
71
|
+
|
|
72
|
+
guildId: str
|
|
73
|
+
guildName: str
|
|
74
|
+
ok: bool
|
|
75
|
+
code: str
|
|
76
|
+
"""``banned``, ``unbanned``, ``not_banned``, ``disabled``, ``missing_permission``,
|
|
77
|
+
``hierarchy``, ``owner``, ``unknown_user``, ``invalid_user``, ``not_in_guild``,
|
|
78
|
+
``bot_unavailable``, ``failed`` …"""
|
|
79
|
+
message: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DiscordResult(TypedDict, total=False):
|
|
83
|
+
"""``discord`` field of a ban (``discord_ban=True``) or revoke (``discord_unban=True``)."""
|
|
84
|
+
|
|
85
|
+
discordId: str
|
|
86
|
+
skipped: str
|
|
87
|
+
"""Set when nothing was attempted: ``no_discord_id``, ``no_linked_guild``, ``not_a_ban``."""
|
|
88
|
+
message: str
|
|
89
|
+
guilds: list[DiscordGuildResult]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class MutationResult(TypedDict, total=False):
|
|
93
|
+
"""Answer of every write endpoint: ``{success, message, discord?}``."""
|
|
94
|
+
|
|
95
|
+
success: bool
|
|
96
|
+
message: str
|
|
97
|
+
discord: DiscordResult
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── account ──────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class MePlan(TypedDict, total=False):
|
|
104
|
+
code: str
|
|
105
|
+
features: list[str]
|
|
106
|
+
serverLimit: int
|
|
107
|
+
logRetentionDays: int
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class MeQuota(TypedDict, total=False):
|
|
111
|
+
unlimited: bool
|
|
112
|
+
used: int
|
|
113
|
+
limit: int
|
|
114
|
+
remaining: int
|
|
115
|
+
resetsAt: str
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class MeKey(TypedDict, total=False):
|
|
119
|
+
id: int
|
|
120
|
+
name: str
|
|
121
|
+
prefix: str
|
|
122
|
+
serverId: int
|
|
123
|
+
expiresAt: str
|
|
124
|
+
scopes: list[str]
|
|
125
|
+
allowedIps: list[str]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class Me(TypedDict, total=False):
|
|
129
|
+
success: bool
|
|
130
|
+
accountId: int
|
|
131
|
+
plan: MePlan
|
|
132
|
+
quota: MeQuota
|
|
133
|
+
rpmLimit: int
|
|
134
|
+
rpmUnlimited: bool
|
|
135
|
+
auth: str
|
|
136
|
+
"""``apiKey`` or ``discordBot``."""
|
|
137
|
+
key: MeKey
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ServerSummary(TypedDict, total=False):
|
|
141
|
+
id: int
|
|
142
|
+
name: str
|
|
143
|
+
txServerName: str
|
|
144
|
+
active: bool
|
|
145
|
+
health: str
|
|
146
|
+
"""``ONLINE``, ``STOPPED`` or ``UNREACHABLE`` (only with offline alerts enabled)."""
|
|
147
|
+
healthCheckedAt: str
|
|
148
|
+
offlineAlerts: bool
|
|
149
|
+
eventLog: bool
|
|
150
|
+
banSync: bool
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ServerList(TypedDict, total=False):
|
|
154
|
+
success: bool
|
|
155
|
+
count: int
|
|
156
|
+
boundServerId: int
|
|
157
|
+
servers: list[ServerSummary]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── players ──────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class PlayerRow(TypedDict, total=False):
|
|
164
|
+
license: str
|
|
165
|
+
displayName: str
|
|
166
|
+
playTime: int
|
|
167
|
+
"""Minutes."""
|
|
168
|
+
tsJoined: int
|
|
169
|
+
"""Unix seconds."""
|
|
170
|
+
tsLastConnection: int
|
|
171
|
+
"""Unix seconds."""
|
|
172
|
+
notes: str
|
|
173
|
+
isAdmin: bool
|
|
174
|
+
isOnline: bool
|
|
175
|
+
isWhitelisted: bool
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class PlayerPage(TypedDict, total=False):
|
|
179
|
+
players: list[PlayerRow]
|
|
180
|
+
hasReachedEnd: bool
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ── history ──────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class HistoryRow(TypedDict, total=False):
|
|
187
|
+
id: str
|
|
188
|
+
type: str
|
|
189
|
+
"""``ban`` or ``warn``."""
|
|
190
|
+
playerName: str
|
|
191
|
+
author: str
|
|
192
|
+
reason: str
|
|
193
|
+
timestamp: int
|
|
194
|
+
isRevoked: bool
|
|
195
|
+
banExpiration: str
|
|
196
|
+
"""``permanent``, ``active`` or ``expired`` (bans only)."""
|
|
197
|
+
warnAcked: bool
|
|
198
|
+
"""Warns only."""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class HistoryPage(TypedDict, total=False):
|
|
202
|
+
history: list[HistoryRow]
|
|
203
|
+
hasReachedEnd: bool
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class BanTemplate(TypedDict, total=False):
|
|
207
|
+
id: str
|
|
208
|
+
reason: str
|
|
209
|
+
duration: str | dict[str, Any]
|
|
210
|
+
"""``"permanent"`` or ``{"value": 2, "unit": "days"}``."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
BanTemplates = list[BanTemplate]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ── server ───────────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class PlayerStatsSummary(TypedDict, total=False):
|
|
220
|
+
total: int
|
|
221
|
+
playedLast24h: int
|
|
222
|
+
joinedLast24h: int
|
|
223
|
+
joinedLast7d: int
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ServerStatus(TypedDict, total=False):
|
|
227
|
+
serverName: str
|
|
228
|
+
txaVersion: str
|
|
229
|
+
fxsVersion: str
|
|
230
|
+
online: bool
|
|
231
|
+
"""The FXServer process runs (absent when txAdmin's diagnostics are unreadable)."""
|
|
232
|
+
txAdminReachable: bool
|
|
233
|
+
playerStats: PlayerStatsSummary
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class ConsoleBuffer(TypedDict, total=False):
|
|
237
|
+
lines: list[str]
|
|
238
|
+
count: int
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
ServerLogEntry = Union[str, dict[str, Any]]
|
|
242
|
+
"""A server-log entry: txAdmin's structured object, or a plain string."""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ServerLogBuffer(TypedDict, total=False):
|
|
246
|
+
entries: list[ServerLogEntry]
|
|
247
|
+
count: int
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class ServerLogHistory(TypedDict, total=False):
|
|
251
|
+
boundry: bool
|
|
252
|
+
"""Spelled like txAdmin spells it."""
|
|
253
|
+
log: list[ServerLogEntry]
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── events ───────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Event(TypedDict, total=False):
|
|
260
|
+
"""A stored server event; which fields are present depends on ``type``."""
|
|
261
|
+
|
|
262
|
+
id: int
|
|
263
|
+
ts: str
|
|
264
|
+
type: str
|
|
265
|
+
"""``join``, ``leave``, ``join_denied``, ``death``, ``chat``, ``command``, ``menu``,
|
|
266
|
+
``admin``, ``ban``, ``warn``, ``system``, ``other``."""
|
|
267
|
+
source: str
|
|
268
|
+
player: str
|
|
269
|
+
playerRef: str
|
|
270
|
+
victim: str
|
|
271
|
+
victimRef: str
|
|
272
|
+
killer: str
|
|
273
|
+
killerRef: str
|
|
274
|
+
cause: str
|
|
275
|
+
pvp: bool
|
|
276
|
+
actionId: str
|
|
277
|
+
admin: str
|
|
278
|
+
reason: str
|
|
279
|
+
expiration: str
|
|
280
|
+
revoked: bool
|
|
281
|
+
message: str
|
|
282
|
+
kind: str
|
|
283
|
+
loggedTime: str
|
|
284
|
+
session: str
|
|
285
|
+
identifiers: list[str]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class EventPage(TypedDict, total=False):
|
|
289
|
+
success: bool
|
|
290
|
+
serverId: int
|
|
291
|
+
eventLog: bool
|
|
292
|
+
chatLog: bool
|
|
293
|
+
count: int
|
|
294
|
+
nextCursor: str
|
|
295
|
+
"""Absent on the last page."""
|
|
296
|
+
events: list[Event]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class LeaderboardRow(TypedDict, total=False):
|
|
300
|
+
rank: int
|
|
301
|
+
player: str
|
|
302
|
+
kills: int
|
|
303
|
+
deaths: int
|
|
304
|
+
kd: float
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Leaderboard(TypedDict, total=False):
|
|
308
|
+
success: bool
|
|
309
|
+
serverId: int
|
|
310
|
+
eventLog: bool
|
|
311
|
+
since: str
|
|
312
|
+
sort: str
|
|
313
|
+
players: list[LeaderboardRow]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class NamedCount(TypedDict, total=False):
|
|
317
|
+
name: str
|
|
318
|
+
count: int
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class PlayerEventStats(TypedDict, total=False):
|
|
322
|
+
success: bool
|
|
323
|
+
serverId: int
|
|
324
|
+
eventLog: bool
|
|
325
|
+
player: str
|
|
326
|
+
identifier: str
|
|
327
|
+
names: list[str]
|
|
328
|
+
since: str
|
|
329
|
+
found: bool
|
|
330
|
+
kills: int
|
|
331
|
+
deaths: int
|
|
332
|
+
pvpDeaths: int
|
|
333
|
+
kd: float
|
|
334
|
+
topVictims: list[NamedCount]
|
|
335
|
+
topKillers: list[NamedCount]
|
|
336
|
+
topWeapons: list[NamedCount]
|
|
337
|
+
sessions: int
|
|
338
|
+
firstSeen: str
|
|
339
|
+
lastSeen: str
|
|
340
|
+
bans: int
|
|
341
|
+
warns: int
|
|
342
|
+
recent: list[Event]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class EventSummary(TypedDict, total=False):
|
|
346
|
+
success: bool
|
|
347
|
+
serverId: int
|
|
348
|
+
eventLog: bool
|
|
349
|
+
since: str
|
|
350
|
+
byType: dict[str, int]
|
|
351
|
+
kills: int
|
|
352
|
+
uniquePlayers: int
|
|
353
|
+
topKiller: LeaderboardRow
|
|
354
|
+
topCauses: list[NamedCount]
|
|
355
|
+
oldestEvent: str
|
|
356
|
+
newestEvent: str
|
|
357
|
+
storedEvents: int
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class PlaytimeRow(TypedDict, total=False):
|
|
361
|
+
player: str
|
|
362
|
+
identifier: str
|
|
363
|
+
sessions: int
|
|
364
|
+
seconds: int
|
|
365
|
+
longestSeconds: int
|
|
366
|
+
online: bool
|
|
367
|
+
lastSeen: str
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class Playtime(TypedDict, total=False):
|
|
371
|
+
success: bool
|
|
372
|
+
serverId: int
|
|
373
|
+
eventLog: bool
|
|
374
|
+
since: str
|
|
375
|
+
totalSeconds: int
|
|
376
|
+
sessionCount: int
|
|
377
|
+
unclosedSessions: int
|
|
378
|
+
players: list[PlaytimeRow]
|
|
379
|
+
recentSessions: list[dict[str, Any]]
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class ActivityTotals(TypedDict, total=False):
|
|
383
|
+
joins: int
|
|
384
|
+
deaths: int
|
|
385
|
+
kills: int
|
|
386
|
+
chat: int
|
|
387
|
+
commands: int
|
|
388
|
+
bans: int
|
|
389
|
+
warns: int
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class ActivityBucket(TypedDict, total=False):
|
|
393
|
+
start: str
|
|
394
|
+
joins: int
|
|
395
|
+
players: int
|
|
396
|
+
deaths: int
|
|
397
|
+
kills: int
|
|
398
|
+
chat: int
|
|
399
|
+
commands: int
|
|
400
|
+
bans: int
|
|
401
|
+
warns: int
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class Activity(TypedDict, total=False):
|
|
405
|
+
success: bool
|
|
406
|
+
serverId: int
|
|
407
|
+
eventLog: bool
|
|
408
|
+
since: str
|
|
409
|
+
bucket: str
|
|
410
|
+
timeZone: str
|
|
411
|
+
totals: ActivityTotals
|
|
412
|
+
buckets: list[ActivityBucket]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
class ActionLogLine(TypedDict, total=False):
|
|
416
|
+
time: str
|
|
417
|
+
admin: str
|
|
418
|
+
message: str
|
|
419
|
+
kind: str
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
class ActionLog(TypedDict, total=False):
|
|
423
|
+
lines: list[ActionLogLine]
|
|
424
|
+
count: int
|
|
425
|
+
total: int
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ── whitelist ────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
class WhitelistApproval(TypedDict, total=False):
|
|
432
|
+
identifier: str
|
|
433
|
+
playerName: str
|
|
434
|
+
playerAvatar: str | None
|
|
435
|
+
tsApproved: int
|
|
436
|
+
approvedBy: str
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
WhitelistApprovals = list[WhitelistApproval]
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ── txAdmin panel admins ─────────────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class AdminSummary(TypedDict, total=False):
|
|
446
|
+
name: str
|
|
447
|
+
hasPassword: bool
|
|
448
|
+
hasCitizenfx: bool
|
|
449
|
+
hasDiscord: bool
|
|
450
|
+
permsSummary: str
|
|
451
|
+
isMaster: bool
|
|
452
|
+
isSelf: bool
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class AdminList(TypedDict, total=False):
|
|
456
|
+
admins: list[AdminSummary]
|
|
457
|
+
total: int
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
class Admin(TypedDict, total=False):
|
|
461
|
+
name: str
|
|
462
|
+
citizenfxID: str
|
|
463
|
+
discordID: str
|
|
464
|
+
permissions: list[str]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class AdminPermission(TypedDict, total=False):
|
|
468
|
+
id: str
|
|
469
|
+
label: str
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class AvailablePermissions(TypedDict, total=False):
|
|
473
|
+
permissions: list[AdminPermission]
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class AdminCreated(TypedDict, total=False):
|
|
477
|
+
success: bool
|
|
478
|
+
password: str
|
|
479
|
+
"""One-time generated password — store it, it is never shown again."""
|
|
480
|
+
note: str
|
|
481
|
+
refresh: bool
|
|
482
|
+
message: str
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ── webhooks ─────────────────────────────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class WebhookEvent(TypedDict, total=False):
|
|
489
|
+
"""A verified webhook delivery (see :func:`txapi.verify_webhook`)."""
|
|
490
|
+
|
|
491
|
+
id: str
|
|
492
|
+
"""Stable delivery id — use it to skip duplicates (deliveries can repeat)."""
|
|
493
|
+
event: str
|
|
494
|
+
createdAt: str
|
|
495
|
+
serverId: int
|
|
496
|
+
data: dict[str, Any]
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
WebhookEventName = Literal[
|
|
500
|
+
"action.revoked",
|
|
501
|
+
"console.command",
|
|
502
|
+
"log.admin_action",
|
|
503
|
+
"log.ban",
|
|
504
|
+
"log.kill",
|
|
505
|
+
"log.warn",
|
|
506
|
+
"ping",
|
|
507
|
+
"player.ban_synced",
|
|
508
|
+
"player.banned",
|
|
509
|
+
"player.kicked",
|
|
510
|
+
"player.messaged",
|
|
511
|
+
"player.note",
|
|
512
|
+
"player.warned",
|
|
513
|
+
"player.whitelist",
|
|
514
|
+
"quota.warning",
|
|
515
|
+
"schedule.ran",
|
|
516
|
+
"server.announcement",
|
|
517
|
+
"server.kickall",
|
|
518
|
+
"server.offline",
|
|
519
|
+
"server.online",
|
|
520
|
+
"server.resource",
|
|
521
|
+
"server.restart_scheduled",
|
|
522
|
+
"server.restart_skipped",
|
|
523
|
+
"server.restarted",
|
|
524
|
+
"server.started",
|
|
525
|
+
"server.stopped",
|
|
526
|
+
"txadmin.admin",
|
|
527
|
+
"whitelist.approval_added",
|
|
528
|
+
"whitelist.approval_removed",
|
|
529
|
+
"whitelist.approved",
|
|
530
|
+
"whitelist.denied",
|
|
531
|
+
"whitelist.denied_all",
|
|
532
|
+
]
|
txapi/webhooks.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Verification of outgoing txApi webhooks (SPEC §8)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import json
|
|
8
|
+
import time
|
|
9
|
+
from typing import cast
|
|
10
|
+
|
|
11
|
+
from ._errors import WebhookVerificationError
|
|
12
|
+
from .types import WebhookEvent, WebhookEventName
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DEFAULT_TOLERANCE_SECONDS",
|
|
16
|
+
"SIGNATURE_HEADER",
|
|
17
|
+
"WEBHOOK_EVENTS",
|
|
18
|
+
"WebhookEventName",
|
|
19
|
+
"verify_webhook",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
SIGNATURE_HEADER = "X-Txapi-Signature"
|
|
23
|
+
"""Header carrying ``t=<unix seconds>,v1=<hex HMAC-SHA256>``."""
|
|
24
|
+
EVENT_HEADER = "X-Txapi-Event"
|
|
25
|
+
DELIVERY_HEADER = "X-Txapi-Delivery"
|
|
26
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
27
|
+
|
|
28
|
+
WEBHOOK_EVENTS: tuple[WebhookEventName, ...] = (
|
|
29
|
+
"action.revoked",
|
|
30
|
+
"console.command",
|
|
31
|
+
"log.admin_action",
|
|
32
|
+
"log.ban",
|
|
33
|
+
"log.kill",
|
|
34
|
+
"log.warn",
|
|
35
|
+
"ping",
|
|
36
|
+
"player.ban_synced",
|
|
37
|
+
"player.banned",
|
|
38
|
+
"player.kicked",
|
|
39
|
+
"player.messaged",
|
|
40
|
+
"player.note",
|
|
41
|
+
"player.warned",
|
|
42
|
+
"player.whitelist",
|
|
43
|
+
"quota.warning",
|
|
44
|
+
"schedule.ran",
|
|
45
|
+
"server.announcement",
|
|
46
|
+
"server.kickall",
|
|
47
|
+
"server.offline",
|
|
48
|
+
"server.online",
|
|
49
|
+
"server.resource",
|
|
50
|
+
"server.restart_scheduled",
|
|
51
|
+
"server.restart_skipped",
|
|
52
|
+
"server.restarted",
|
|
53
|
+
"server.started",
|
|
54
|
+
"server.stopped",
|
|
55
|
+
"txadmin.admin",
|
|
56
|
+
"whitelist.approval_added",
|
|
57
|
+
"whitelist.approval_removed",
|
|
58
|
+
"whitelist.approved",
|
|
59
|
+
"whitelist.denied",
|
|
60
|
+
"whitelist.denied_all",
|
|
61
|
+
)
|
|
62
|
+
"""Every event name a webhook can deliver (``spec/endpoints.json -> webhookEvents``)."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _fail(message: str, reason: str) -> WebhookVerificationError:
|
|
66
|
+
return WebhookVerificationError(f"webhook verification failed: {message}", reason=reason)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def verify_webhook(
|
|
70
|
+
raw_body: bytes | str,
|
|
71
|
+
signature_header: str | None,
|
|
72
|
+
secret: str,
|
|
73
|
+
tolerance_seconds: float = DEFAULT_TOLERANCE_SECONDS,
|
|
74
|
+
now: float | None = None,
|
|
75
|
+
) -> WebhookEvent:
|
|
76
|
+
"""Verify a webhook delivery and return the parsed event.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
raw_body: The request body **exactly as received** (bytes). Parsing the JSON
|
|
80
|
+
and serialising it again changes the bytes and breaks the signature.
|
|
81
|
+
signature_header: Value of the ``X-Txapi-Signature`` header.
|
|
82
|
+
secret: The endpoint's signing secret (``whsec_…``) from the dashboard.
|
|
83
|
+
tolerance_seconds: Maximum age (and clock skew) of the timestamp ``t``.
|
|
84
|
+
now: Current Unix time in seconds (for tests); defaults to ``time.time()``.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
``{id, event, createdAt, serverId, data}``.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
WebhookVerificationError: Header malformed, signature mismatch, timestamp
|
|
91
|
+
outside the tolerance or body not JSON. ``reason`` tells which.
|
|
92
|
+
"""
|
|
93
|
+
if not secret:
|
|
94
|
+
raise _fail("no secret configured", "missing_secret")
|
|
95
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
|
|
96
|
+
|
|
97
|
+
timestamp: int | None = None
|
|
98
|
+
signatures: list[str] = []
|
|
99
|
+
for part in (signature_header or "").split(","):
|
|
100
|
+
key, sep, value = part.strip().partition("=")
|
|
101
|
+
if not sep:
|
|
102
|
+
continue
|
|
103
|
+
key, value = key.strip(), value.strip()
|
|
104
|
+
if key == "t":
|
|
105
|
+
try:
|
|
106
|
+
timestamp = int(value)
|
|
107
|
+
except ValueError:
|
|
108
|
+
raise _fail("timestamp is not a number", "malformed_header") from None
|
|
109
|
+
elif key == "v1" and value:
|
|
110
|
+
signatures.append(value.lower())
|
|
111
|
+
if timestamp is None:
|
|
112
|
+
raise _fail("no timestamp (t=) in the signature header", "missing_timestamp")
|
|
113
|
+
if not signatures:
|
|
114
|
+
raise _fail("no signature (v1=) in the signature header", "missing_signature")
|
|
115
|
+
|
|
116
|
+
current = time.time() if now is None else now
|
|
117
|
+
if abs(current - timestamp) > tolerance_seconds:
|
|
118
|
+
raise _fail("timestamp outside the tolerance (replay?)", "timestamp_out_of_tolerance")
|
|
119
|
+
|
|
120
|
+
expected = (
|
|
121
|
+
hmac.new(secret.encode("utf-8"), f"{timestamp}.".encode() + body, hashlib.sha256)
|
|
122
|
+
.hexdigest()
|
|
123
|
+
.encode("ascii")
|
|
124
|
+
)
|
|
125
|
+
# Compare bytes: compare_digest raises TypeError for non-ASCII str input.
|
|
126
|
+
if not any(
|
|
127
|
+
hmac.compare_digest(expected, candidate.encode("utf-8")) for candidate in signatures
|
|
128
|
+
):
|
|
129
|
+
raise _fail("signature does not match", "signature_mismatch")
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
event = json.loads(body)
|
|
133
|
+
except ValueError:
|
|
134
|
+
raise _fail("body is not valid JSON", "invalid_body") from None
|
|
135
|
+
if not isinstance(event, dict):
|
|
136
|
+
raise _fail("body is not a JSON object", "invalid_body")
|
|
137
|
+
return cast(WebhookEvent, event)
|