scriptivox 0.1.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.
scriptivox/_types.py ADDED
@@ -0,0 +1,473 @@
1
+ # GENERATED by scripts/generate.py from openapi/openapi.json — do not edit by hand.
2
+ # OpenAPI 1.0.0 · sha256 489f83bddaa9893f
3
+ """Response and request shapes of the Scriptivox API, as TypedDicts (plain dicts at runtime)."""
4
+ from __future__ import annotations
5
+
6
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
7
+
8
+ class _ErrorDetailRequired(TypedDict):
9
+ #: Machine-readable error code.
10
+ code: Literal["INVALID_REQUEST", "INVALID_FILENAME", "INVALID_MEDIA_FORMAT", "FILE_NOT_UPLOADED", "FILE_TOO_LARGE", "UPLOAD_ALREADY_USED", "UPLOAD_EXPIRED", "INVALID_API_KEY", "API_KEY_REVOKED", "INSUFFICIENT_BALANCE", "ZERO_BALANCE", "UPLOAD_NOT_FOUND", "TRANSCRIPTION_NOT_FOUND", "NOT_FOUND", "FORBIDDEN", "METHOD_NOT_ALLOWED", "CONFLICT", "PAYLOAD_TOO_LARGE", "UNSUPPORTED_MEDIA_TYPE", "IDEMPOTENCY_KEY_LOCKED", "IDEMPOTENCY_KEY_CONFLICT", "RATE_LIMIT_EXCEEDED", "INTERNAL_ERROR", "MAINTENANCE", "ENDPOINT_REMOVED", "ISSUER_UNCONFIGURED"]
11
+ #: Human-readable description of what went wrong.
12
+ message: str
13
+
14
+
15
+ class ErrorDetail(_ErrorDetailRequired, total=False):
16
+ """The error envelope. Always present on a failed request."""
17
+ #: Documentation for this error code.
18
+ docs_url: str
19
+ #: The id of this request, the same value as the `X-Request-Id` response header. Include it when you contact support.
20
+ request_id: str
21
+
22
+
23
+ class _ErrorRequired(TypedDict):
24
+ #: The error envelope. Always present on a failed request.
25
+ error: ErrorDetail
26
+
27
+
28
+ class Error(_ErrorRequired, total=False):
29
+ """Every failed request returns this shape. `code` is stable and machine-readable; `message` is a sanitized, customer-safe string and never a raw stack trace; `request_id` identifies the request for support."""
30
+ #: Seconds to wait before retrying. Present on 429 responses alongside the Retry-After header.
31
+ retry_after: int
32
+
33
+
34
+ class CreateUploadRequest(TypedDict):
35
+ #: Name of the file being uploaded, e.g. "meeting.mp3". ASCII only, no path separators, and it must carry a supported extension.
36
+ filename: str
37
+
38
+
39
+ class Upload(TypedDict):
40
+ #: Pass this to POST /v1/transcribe once the PUT succeeds.
41
+ upload_id: str
42
+ #: Presigned URL to PUT the file bytes to.
43
+ upload_url: str
44
+ #: Seconds until upload_url expires. Currently 3600.
45
+ expires_in: int
46
+ #: HTTP method to use against upload_url.
47
+ method: Literal["PUT"]
48
+ #: Headers that must be sent with the PUT request.
49
+ headers: Dict[str, str]
50
+
51
+
52
+ class _CreateTranscriptionFromUrlRequired(TypedDict):
53
+ #: Anonymously accessible http or https URL. An anonymous GET must return the raw file bytes, not an HTML preview, login form or password page. Direct and presigned URLs (S3, GCS, Azure SAS) always work. Share links work only when set to "anyone with the link can view": Google Drive, Dropbox files (not /scl/fo/ folder links), and OneDrive personal /redir or onedrive.live.com links. OneDrive 1drv.ms/v/c/ and /p/c/ photo shares cannot be used — Microsoft routes them through SharePoint Photos, which requires sign-in.
54
+ url: str
55
+
56
+
57
+ class CreateTranscriptionFromUrl(_CreateTranscriptionFromUrlRequired, total=False):
58
+ """Transcribe a file we download from a public URL."""
59
+ #: ISO 639-1 language code (with BCP-47 fallbacks such as `yue` and `kea`), e.g. "en", "es", "fr". Omit or pass null for auto-detection. Passing the language explicitly is recommended: it is faster (the model skips its detection pass) and avoids known auto-detect failure modes on short clips, code-switched audio, and files that open with music. Forcing a language the audio is not in can produce a translation rather than a transcription. Invalid codes return 400 INVALID_REQUEST.
60
+ language: Optional[str]
61
+ #: Identify who said what and attach a speaker label to every utterance. Implies align: true, because speaker assignment needs word-level alignment.
62
+ diarize: bool
63
+ #: Expected number of speakers. Requires diarize: true; passing it without diarize returns 400 INVALID_REQUEST. This is a soft prior, not a hard ceiling — the result may contain slightly more or fewer labels. Strongly recommended whenever the count is known: it measurably reduces over- and under-segmentation.
64
+ speaker_count: int
65
+ #: Produce word-level start/end timestamps plus per-word confidence where the alignment model supports it. Confidence is language-dependent and may be null. Ignored (treated as true) when diarize is true.
66
+ align: bool
67
+ #: URL to receive the job's webhooks (processing, then completed or failed). Must be https://. Signed with the key's webhook signing secret and retried with backoff until your server answers 2xx — see https://platform.scriptivox.com/docs/webhooks.
68
+ webhook_url: str
69
+
70
+
71
+ class _CreateTranscriptionFromUploadRequired(TypedDict):
72
+ #: The upload_id returned by POST /v1/upload.
73
+ upload_id: str
74
+
75
+
76
+ class CreateTranscriptionFromUpload(_CreateTranscriptionFromUploadRequired, total=False):
77
+ """Transcribe a file previously sent to a presigned upload URL."""
78
+ #: ISO 639-1 language code (with BCP-47 fallbacks such as `yue` and `kea`), e.g. "en", "es", "fr". Omit or pass null for auto-detection. Passing the language explicitly is recommended: it is faster (the model skips its detection pass) and avoids known auto-detect failure modes on short clips, code-switched audio, and files that open with music. Forcing a language the audio is not in can produce a translation rather than a transcription. Invalid codes return 400 INVALID_REQUEST.
79
+ language: Optional[str]
80
+ #: Identify who said what and attach a speaker label to every utterance. Implies align: true, because speaker assignment needs word-level alignment.
81
+ diarize: bool
82
+ #: Expected number of speakers. Requires diarize: true; passing it without diarize returns 400 INVALID_REQUEST. This is a soft prior, not a hard ceiling — the result may contain slightly more or fewer labels. Strongly recommended whenever the count is known: it measurably reduces over- and under-segmentation.
83
+ speaker_count: int
84
+ #: Produce word-level start/end timestamps plus per-word confidence where the alignment model supports it. Confidence is language-dependent and may be null. Ignored (treated as true) when diarize is true.
85
+ align: bool
86
+ #: URL to receive the job's webhooks (processing, then completed or failed). Must be https://. Signed with the key's webhook signing secret and retried with backoff until your server answers 2xx — see https://platform.scriptivox.com/docs/webhooks.
87
+ webhook_url: str
88
+
89
+
90
+ # Supply exactly one of `url` (Scriptivox downloads the file) or `upload_id` (returned by POST /v1/upload). Every other property is optional and shared by both forms.
91
+ CreateTranscriptionRequest = Union[CreateTranscriptionFromUrl, CreateTranscriptionFromUpload]
92
+
93
+
94
+ class _TranscriptionCreatedRequired(TypedDict):
95
+ #: Transcription id. Pass it to GET /v1/transcribe/{id} to poll for the result.
96
+ id: str
97
+ #: Always "created" here. The file has been accepted but not yet downloaded or validated, so this is not a statement that the input is usable.
98
+ status: Literal["created"]
99
+
100
+
101
+ class TranscriptionCreated(_TranscriptionCreatedRequired, total=False):
102
+ #: Human-readable confirmation. Not machine-readable; do not branch on it.
103
+ message: str
104
+
105
+
106
+ class TranscriptionError(TypedDict):
107
+ """Present when status is "failed". These codes surface only on GET /v1/transcribe/{id}, never on the submit call. Failed jobs are free — the reservation is released."""
108
+ #: Stable machine-readable reason the job failed. Branch on this, never on message.
109
+ code: Literal["URL_NOT_ACCESSIBLE", "DOWNLOAD_FAILED", "INVALID_MEDIA_FORMAT", "FILE_TOO_LARGE", "DURATION_TOO_LONG", "INSUFFICIENT_BALANCE", "PROCESSING_ERROR", "CREATED_TIMEOUT", "DOWNLOAD_TIMEOUT", "PROCESSING_TIMEOUT", "BILLING_ERROR", "INTERNAL_ERROR", "CANCELLED"]
110
+ #: Human-readable explanation. Wording may change between releases.
111
+ message: str
112
+
113
+
114
+ class _TranscriptionCancelledRequired(TypedDict):
115
+ #: The transcription that was cancelled.
116
+ id: str
117
+ #: A cancelled job is reported as "failed" with error.code CANCELLED — there is no separate "cancelled" status.
118
+ status: Literal["failed"]
119
+
120
+
121
+ class TranscriptionCancelled(_TranscriptionCancelledRequired, total=False):
122
+ error: TranscriptionError
123
+ #: Kept for compatibility, always 0: nothing is reserved before transcription starts, and a job can only be cancelled before then.
124
+ released_cents: float
125
+
126
+
127
+ # created: accepted, about to download or validate. downloading: URL flow only. processing: being transcribed. completed: result is populated. failed: inspect error.code.
128
+ TranscriptionStatus = Literal["created", "downloading", "pending", "processing", "completed", "failed"]
129
+
130
+
131
+ class Word(TypedDict, total=False):
132
+ """Present only when align is effectively true. Leading whitespace is stripped — reconstruct spacing from utterance.text if you need it exactly."""
133
+ #: The token as transcribed, including any trailing punctuation.
134
+ word: str
135
+ #: Seconds from the start of the audio.
136
+ start: float
137
+ #: Seconds from the start of the audio.
138
+ end: float
139
+ #: Null for languages whose alignment model does not report confidence. Handle null defensively.
140
+ confidence: Optional[float]
141
+ #: Absent when diarize is false.
142
+ speaker: str
143
+
144
+
145
+ class Utterance(TypedDict, total=False):
146
+ #: Seconds from the start of the audio to the first word.
147
+ start: float
148
+ #: Seconds from the start of the audio to the last word.
149
+ end: float
150
+ #: The utterance as a single string, with original spacing.
151
+ text: str
152
+ #: Labels are "SPEAKER 1", "SPEAKER 2", ... (space, 1-indexed). Null when diarize is false.
153
+ speaker: Optional[str]
154
+ #: Mean word confidence for this utterance. Null for languages whose alignment model does not report it.
155
+ confidence: Optional[float]
156
+ #: Empty array when align is false.
157
+ words: List[Word]
158
+
159
+
160
+ class TranscriptionResult(TypedDict, total=False):
161
+ """Populated once status is "completed"."""
162
+ #: The whole transcript as one string, utterances joined in order.
163
+ full_transcript: str
164
+ #: The language actually used, detected or supplied.
165
+ language: str
166
+ #: Length of the decoded audio in seconds.
167
+ duration_seconds: float
168
+ #: Null when diarize is false.
169
+ speakers: Optional[List[str]]
170
+ #: Speech segments in chronological order. This is the structure to iterate; full_transcript is a convenience.
171
+ utterances: List[Utterance]
172
+
173
+
174
+ class _TranscriptionSummaryRequired(TypedDict):
175
+ #: Transcription id, as returned by POST /v1/transcribe.
176
+ id: str
177
+ status: TranscriptionStatus
178
+
179
+
180
+ class TranscriptionSummary(_TranscriptionSummaryRequired, total=False):
181
+ """A transcription without its transcript. This is what GET /v1/transcriptions returns per item."""
182
+ #: Rounded to the nearest whole second. Null until the file has been probed.
183
+ audio_duration_seconds: Optional[int]
184
+ #: Size of the source file. Null until it has been downloaded.
185
+ file_size_bytes: Optional[int]
186
+ #: ISO code of the language used. Null until detection has run.
187
+ language: Optional[str]
188
+ #: Whether speaker diarization was requested for this job.
189
+ diarize: bool
190
+ #: The speaker count supplied on the request, not a detected one. Null when it was not supplied.
191
+ speaker_count: Optional[int]
192
+ #: The effective value. Always true when diarize is true, even if the request passed align: false.
193
+ align: bool
194
+ #: Fractional and exact, e.g. 0.061111 for an 11-second clip at $0.20/hour. The balance is debited against this precise value, not a rounded one.
195
+ cost_cents: Optional[float]
196
+ #: Present only on URL-flow transcriptions.
197
+ source_url: str
198
+ #: Free-text stage label while processing, e.g. "downloading". For display only — the machine-readable state is status.
199
+ progress: Optional[str]
200
+ #: When the job was accepted. RFC 3339, UTC.
201
+ created_at: str
202
+ #: When transcription began. Null while the job is still queued.
203
+ started_at: Optional[str]
204
+ #: When the job reached a terminal state. Null until then.
205
+ completed_at: Optional[str]
206
+ error: TranscriptionError
207
+
208
+
209
+ class Transcription(TranscriptionSummary, total=False):
210
+ """A transcription including its result once complete. Some result fields depend on the flags the job was created with: result.speakers and utterances[].speaker are null when diarize is false, and utterances[].words is empty when align is false."""
211
+ #: Null until status is "completed".
212
+ result: Optional[TranscriptionResult]
213
+
214
+
215
+ class TranscriptionPage(TypedDict):
216
+ #: This page of transcriptions, newest first unless order says otherwise. Each item omits the heavy result object.
217
+ items: List[TranscriptionSummary]
218
+ #: Pass back as the cursor query parameter for the next page. Null on the last page.
219
+ next_cursor: Optional[str]
220
+
221
+
222
+ class ServiceDiscoveryEndpointsItem(TypedDict, total=False):
223
+ #: HTTP method.
224
+ method: str
225
+ #: Path, including the /v1 prefix.
226
+ path: str
227
+ #: What the operation does.
228
+ summary: str
229
+
230
+
231
+ class _ServiceDiscoveryRequired(TypedDict):
232
+ #: Human-readable name of the API.
233
+ service: str
234
+ #: The API version this host serves, matching the URL prefix.
235
+ version: str
236
+ #: Prefix every path in `endpoints` with this.
237
+ base_url: str
238
+ #: Human-readable documentation index.
239
+ documentation: str
240
+ #: This document.
241
+ openapi: str
242
+
243
+
244
+ class ServiceDiscovery(_ServiceDiscoveryRequired, total=False):
245
+ """What GET /v1 returns. Deliberately loose: it is a signpost for an agent that has just found this host, not a contract to generate a client from. The authoritative description of every operation is this document."""
246
+ #: One sentence on what the API does.
247
+ description: str
248
+ #: What the service is for, and when not to use it.
249
+ llms_txt: str
250
+ #: Model Context Protocol manifest, for agents that call tools natively.
251
+ mcp: str
252
+ #: Live uptime and incident history.
253
+ status: str
254
+ #: How to authenticate every other operation: the scheme, the accepted headers, the key format, and where a human gets one.
255
+ authentication: Dict[str, Any]
256
+ #: One entry per operation, with method, path and a one-line summary.
257
+ endpoints: List[ServiceDiscoveryEndpointsItem]
258
+ #: Per-IP and per-key ceilings, the header names that carry them, and where they are documented.
259
+ rate_limits: Dict[str, Any]
260
+ #: Versioning scheme, current version, policy URL, and the headers a deprecation would arrive on.
261
+ versioning: Dict[str, Any]
262
+ #: The error envelope, and where the code list lives.
263
+ errors: Dict[str, Any]
264
+
265
+
266
+ class FeedbackRequest(TypedDict, total=False):
267
+ #: How good the transcript is, 1 (poor) to 5 (excellent). Completed jobs only.
268
+ rating: int
269
+ #: What the rating is about.
270
+ topics: List[Literal["accuracy", "speakers", "timing", "language", "formatting", "other"]]
271
+ #: A short comment on the transcript.
272
+ comment: str
273
+ #: Your end user's own words, unchanged, if you have them.
274
+ their_words: str
275
+ #: true to report a problem with this job (failed, empty or wrong). Needs what_happened.
276
+ problem: bool
277
+ #: What went wrong, for a problem report.
278
+ what_happened: str
279
+
280
+
281
+ class FeedbackResultProblemReport(TypedDict, total=False):
282
+ """The problem report opened or added to, or null."""
283
+ #: The report id (null if a parallel call opened it a moment earlier).
284
+ id: Optional[str]
285
+ #: Open until the team resolves it (within 24 hours).
286
+ status: Literal["open"]
287
+ #: True when this was added to a report on the same job that is still open.
288
+ added_to_open_report: bool
289
+ #: True when a rating of 1 or 2 with words opened it.
290
+ opened_by_rating: bool
291
+
292
+
293
+ class _FeedbackResultRequired(TypedDict):
294
+ #: The job the feedback is about.
295
+ transcription_id: str
296
+ #: True when a rating was sent and saved.
297
+ rating_saved: bool
298
+ #: The problem report opened or added to, or null.
299
+ problem_report: Optional[FeedbackResultProblemReport]
300
+
301
+
302
+ class FeedbackResult(_FeedbackResultRequired, total=False):
303
+ #: Set when the rating was saved but the problem report was not (for example the daily limit).
304
+ problem_report_error: str
305
+
306
+
307
+ class WebhookTestRequest(TypedDict, total=False):
308
+ #: Where to send the test event. https:// only. Without it, the account's default webhook URL is used.
309
+ url: str
310
+
311
+
312
+ class _WebhookSendResultRequired(TypedDict):
313
+ #: The X-Scriptivox-Delivery id of this event.
314
+ delivery_id: str
315
+ #: The event sent.
316
+ event: str
317
+ #: The X-Scriptivox-Attempt value of this try.
318
+ attempt: int
319
+ #: True when your server answered with a 2xx status.
320
+ delivered: bool
321
+ #: The HTTP status your server answered, or null when there was no answer.
322
+ response_code: Optional[int]
323
+ #: Why it was not delivered, e.g. "HTTP 500" or "No answer within 10 s.".
324
+ error: Optional[str]
325
+ #: How long the try took.
326
+ duration_ms: int
327
+ #: The event after this try: pending (automatic retries continue), succeeded, failed (no tries left, or 410), superseded.
328
+ status: Literal["pending", "succeeded", "failed", "superseded", "unknown"]
329
+
330
+
331
+ class WebhookSendResult(_WebhookSendResultRequired, total=False):
332
+ """What happened to one try of a webhook made on request."""
333
+ #: The job (resend only).
334
+ transcription_id: str
335
+ #: Where the test event went (test only).
336
+ url: str
337
+ #: When the next automatic retry is due, if any.
338
+ next_attempt_at: Optional[str]
339
+
340
+
341
+ class _WebhookTranscriptionProcessingRequired(TypedDict):
342
+ #: The event name.
343
+ event: Literal["transcription.processing"]
344
+ #: The job this event is about.
345
+ transcription_id: str
346
+ #: The job status when the event was made.
347
+ status: Literal["processing"]
348
+
349
+
350
+ class WebhookTranscriptionProcessing(_WebhookTranscriptionProcessingRequired, total=False):
351
+ #: Measured length of the audio.
352
+ duration_seconds: float
353
+ #: Reserved for this job, in cents; charged when it completes.
354
+ cost_cents: float
355
+
356
+
357
+ class _WebhookTranscriptionCompletedRequired(TypedDict):
358
+ #: The event name.
359
+ event: Literal["transcription.completed"]
360
+ #: The job this event is about.
361
+ transcription_id: str
362
+ #: The job status when the event was made.
363
+ status: Literal["completed"]
364
+ #: The transcript, the same as GET /v1/transcribe/{id} returns.
365
+ result: TranscriptionResult
366
+
367
+
368
+ class WebhookTranscriptionCompleted(_WebhookTranscriptionCompletedRequired, total=False):
369
+ #: Billed length of the audio.
370
+ duration_seconds: float
371
+ #: Charged for this job, in cents.
372
+ cost_cents: float
373
+
374
+
375
+ class WebhookTranscriptionFailed(TypedDict):
376
+ #: The event name.
377
+ event: Literal["transcription.failed"]
378
+ #: The job this event is about.
379
+ transcription_id: str
380
+ #: The job status when the event was made.
381
+ status: Literal["failed"]
382
+ #: Why the job failed. Branch on error.code.
383
+ error: TranscriptionError
384
+
385
+
386
+ class WebhookTranscriptionDownloading(TypedDict):
387
+ #: The event name.
388
+ event: Literal["transcription.downloading"]
389
+ #: The job this event is about.
390
+ transcription_id: str
391
+ #: The job status when the event was made.
392
+ status: Literal["downloading"]
393
+
394
+
395
+ class _WebhookTestRequired(TypedDict):
396
+ #: The event name.
397
+ event: Literal["webhook.test"]
398
+ #: Always true: this is not about a real job.
399
+ test: Literal[True]
400
+ #: A fixed sentence saying this is a test.
401
+ message: str
402
+
403
+
404
+ class WebhookTest(_WebhookTestRequired, total=False):
405
+ #: When the test was requested.
406
+ created_at: str
407
+
408
+
409
+ class AutoTopup(TypedDict):
410
+ """Auto top-up, set on the Billing page of the API dashboard. When on and available_cents falls below threshold_cents, the saved card is charged amount_cents, up to the monthly limit set there. A top-up shows on a later call, not this one."""
411
+ #: Whether auto top-up is on.
412
+ enabled: bool
413
+ #: Top up when available_cents falls below this, in cents.
414
+ threshold_cents: int
415
+ #: How much each top-up adds, in cents.
416
+ amount_cents: int
417
+
418
+
419
+ class _BalanceRequired(TypedDict):
420
+ #: Total balance, in cents. Can be below zero after a refunded deposit.
421
+ balance_cents: float
422
+ #: Held against in-progress transcriptions, in cents.
423
+ reserved_cents: float
424
+ #: balance_cents minus reserved_cents. Must be above zero to start a job.
425
+ available_cents: float
426
+ #: Current price per hour of audio, in cents.
427
+ price_per_hour_cents: float
428
+ #: Auto top-up, set on the Billing page of the API dashboard. When on and available_cents falls below threshold_cents, the saved card is charged amount_cents, up to the monthly limit set there. A top-up shows on a later call, not this one.
429
+ auto_topup: AutoTopup
430
+
431
+
432
+ class Balance(_BalanceRequired, total=False):
433
+ #: available_cents divided by price_per_hour_cents.
434
+ estimated_hours_available: float
435
+ #: Where a human can add funds. Send a user here on a 402.
436
+ deposit_url: str
437
+ #: When the balance last changed. RFC 3339, UTC.
438
+ updated_at: str
439
+
440
+
441
+ __all__ = [
442
+ "AutoTopup",
443
+ "Balance",
444
+ "CreateTranscriptionFromUpload",
445
+ "CreateTranscriptionFromUrl",
446
+ "CreateTranscriptionRequest",
447
+ "CreateUploadRequest",
448
+ "Error",
449
+ "ErrorDetail",
450
+ "FeedbackRequest",
451
+ "FeedbackResult",
452
+ "FeedbackResultProblemReport",
453
+ "ServiceDiscovery",
454
+ "ServiceDiscoveryEndpointsItem",
455
+ "Transcription",
456
+ "TranscriptionCancelled",
457
+ "TranscriptionCreated",
458
+ "TranscriptionError",
459
+ "TranscriptionPage",
460
+ "TranscriptionResult",
461
+ "TranscriptionStatus",
462
+ "TranscriptionSummary",
463
+ "Upload",
464
+ "Utterance",
465
+ "WebhookSendResult",
466
+ "WebhookTest",
467
+ "WebhookTestRequest",
468
+ "WebhookTranscriptionCompleted",
469
+ "WebhookTranscriptionDownloading",
470
+ "WebhookTranscriptionFailed",
471
+ "WebhookTranscriptionProcessing",
472
+ "Word",
473
+ ]
scriptivox/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,108 @@
1
+ """Webhook signature check. The signing formula (platform.scriptivox.com/docs/webhooks)::
2
+
3
+ signature = hex(HMAC-SHA256(secret, f"{X-Scriptivox-Timestamp}.{raw body}"))
4
+
5
+ where ``secret`` is the key's whole ``whsec_…`` string. A timestamp more than 5 minutes from now is refused.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import time
13
+ from typing import Any, Iterable, Mapping, Optional, Tuple, Union, cast
14
+
15
+ from ._exceptions import WebhookVerificationError
16
+ from ._types import WebhookTest, WebhookTranscriptionCompleted, WebhookTranscriptionDownloading, WebhookTranscriptionFailed, WebhookTranscriptionProcessing
17
+
18
+ SIGNATURE_HEADER = "X-Scriptivox-Signature"
19
+ TIMESTAMP_HEADER = "X-Scriptivox-Timestamp"
20
+ DELIVERY_HEADER = "X-Scriptivox-Delivery"
21
+ ATTEMPT_HEADER = "X-Scriptivox-Attempt"
22
+ #: Seconds a webhook's timestamp may be away from now.
23
+ DEFAULT_TOLERANCE = 300
24
+
25
+ #: Every event body a webhook can carry; switch on ``event``. Answer 2xx to event types you do not know.
26
+ WebhookEvent = Union[
27
+ WebhookTranscriptionProcessing,
28
+ WebhookTranscriptionCompleted,
29
+ WebhookTranscriptionFailed,
30
+ WebhookTranscriptionDownloading,
31
+ WebhookTest,
32
+ ]
33
+
34
+ HeadersLike = Union[Mapping[str, Any], Iterable[Tuple[str, str]]]
35
+
36
+
37
+ def read_header(headers: HeadersLike, name: str) -> Optional[str]:
38
+ """Case-insensitive lookup in a dict, httpx/Starlette/Django/Flask headers, or a list of pairs."""
39
+ wanted = name.lower()
40
+ items = headers.items() if hasattr(headers, "items") else headers
41
+ for key, value in cast(Iterable[Tuple[str, Any]], items):
42
+ if isinstance(key, bytes):
43
+ key = key.decode("latin-1")
44
+ if key.lower() != wanted or value is None:
45
+ continue
46
+ if isinstance(value, (list, tuple)):
47
+ value = value[0] if value else None
48
+ if isinstance(value, bytes):
49
+ value = value.decode("latin-1")
50
+ return None if value is None else str(value)
51
+ return None
52
+
53
+
54
+ def _body_bytes(raw_body: Union[bytes, bytearray, memoryview, str]) -> bytes:
55
+ if isinstance(raw_body, str):
56
+ return raw_body.encode("utf-8")
57
+ if isinstance(raw_body, (bytes, bytearray, memoryview)):
58
+ return bytes(raw_body)
59
+ raise TypeError(
60
+ "verify_webhook needs the raw request body (bytes or str), not parsed JSON: "
61
+ "re-serialising changes the bytes and the signature."
62
+ )
63
+
64
+
65
+ def compute_signature(raw_body: Union[bytes, bytearray, memoryview, str], timestamp: str, secret: str) -> str:
66
+ """The signature the API would send for this body and timestamp."""
67
+ mac = hmac.new(secret.encode("utf-8"), f"{timestamp}.".encode("utf-8"), hashlib.sha256)
68
+ mac.update(_body_bytes(raw_body))
69
+ return mac.hexdigest()
70
+
71
+
72
+ def verify_webhook(
73
+ raw_body: Union[bytes, bytearray, memoryview, str],
74
+ headers: HeadersLike,
75
+ secret: str,
76
+ *,
77
+ tolerance: int = DEFAULT_TOLERANCE,
78
+ now: Optional[float] = None,
79
+ ) -> WebhookEvent:
80
+ """Check that a webhook request came from Scriptivox and is fresh, then return its parsed event.
81
+
82
+ Pass the body exactly as received (``await request.body()``, ``request.get_data()``,
83
+ ``request.body``), never re-serialised JSON. Raises WebhookVerificationError; answer such a
84
+ request with 400. Deduplicate on the X-Scriptivox-Delivery header: an event can arrive twice.
85
+ """
86
+ body = _body_bytes(raw_body)
87
+ if not isinstance(secret, str) or not secret:
88
+ raise TypeError("verify_webhook needs the key's webhook signing secret (whsec_…).")
89
+ signature = read_header(headers, SIGNATURE_HEADER)
90
+ timestamp = read_header(headers, TIMESTAMP_HEADER)
91
+ if not signature or not timestamp:
92
+ raise WebhookVerificationError("missing_header", "Missing X-Scriptivox-Signature or X-Scriptivox-Timestamp header.")
93
+ timestamp = timestamp.strip()
94
+ if not timestamp.isdigit():
95
+ raise WebhookVerificationError("invalid_timestamp", "X-Scriptivox-Timestamp is not a Unix time in seconds.")
96
+ current = time.time() if now is None else now
97
+ if abs(int(current) - int(timestamp)) > tolerance:
98
+ raise WebhookVerificationError(
99
+ "timestamp_outside_tolerance",
100
+ f"The webhook's timestamp is more than {tolerance} s from now: refused as a possible replay.",
101
+ )
102
+ expected = compute_signature(body, timestamp, secret)
103
+ if not hmac.compare_digest(expected.encode("ascii"), signature.strip().lower().encode("utf-8")):
104
+ raise WebhookVerificationError("signature_mismatch", "The webhook signature does not match: wrong secret, or the body was changed.")
105
+ try:
106
+ return cast(WebhookEvent, json.loads(body.decode("utf-8")))
107
+ except (ValueError, UnicodeDecodeError):
108
+ raise WebhookVerificationError("invalid_json", "The webhook body is not JSON.") from None
scriptivox/py.typed ADDED
File without changes