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/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)