thunder-solution-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.
- thunder_slip/__init__.py +12 -0
- thunder_slip/_base_error.py +8 -0
- thunder_slip/_codec.py +102 -0
- thunder_slip/_transport.py +282 -0
- thunder_slip/client.py +860 -0
- thunder_slip/errors.py +367 -0
- thunder_slip/files.py +59 -0
- thunder_slip/models.py +600 -0
- thunder_slip/py.typed +0 -0
- thunder_slip/webhook.py +26 -0
- thunder_slip/webhooks.py +25 -0
- thunder_solution_sdk-1.0.0.dist-info/METADATA +452 -0
- thunder_solution_sdk-1.0.0.dist-info/RECORD +16 -0
- thunder_solution_sdk-1.0.0.dist-info/WHEEL +5 -0
- thunder_solution_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- thunder_solution_sdk-1.0.0.dist-info/top_level.txt +1 -0
thunder_slip/errors.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Typed API and client failures; request credentials are never retained."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import email.utils
|
|
5
|
+
import json
|
|
6
|
+
from urllib.parse import unquote
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Callable, cast
|
|
10
|
+
import httpx
|
|
11
|
+
from ._base_error import SDKError
|
|
12
|
+
from .models import BankSlip, TrueWalletSlip, RateLimit
|
|
13
|
+
from ._codec import decode
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ApiError(SDKError):
|
|
17
|
+
"""HTTP error with tracing, rate limits and the response body."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
status: int,
|
|
22
|
+
code: str | None,
|
|
23
|
+
message: str,
|
|
24
|
+
request_id: str | None,
|
|
25
|
+
raw_body: str,
|
|
26
|
+
*,
|
|
27
|
+
headers: dict[str, str] | None = None,
|
|
28
|
+
details: Any = None,
|
|
29
|
+
retry_after: float | None = None,
|
|
30
|
+
rate_limit: RateLimit | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._sensitive_body: Callable[[], str] = lambda: raw_body
|
|
33
|
+
self.status = status
|
|
34
|
+
self.code = code
|
|
35
|
+
self.message = message
|
|
36
|
+
self.request_id = request_id
|
|
37
|
+
self.raw_body = raw_body
|
|
38
|
+
self.headers = headers or {}
|
|
39
|
+
self.details = details
|
|
40
|
+
self.retry_after = retry_after
|
|
41
|
+
self.rate_limit = rate_limit
|
|
42
|
+
self.slip: BankSlip | TrueWalletSlip | None = None
|
|
43
|
+
super().__init__(f"[{status} {code or ''}] {message}")
|
|
44
|
+
|
|
45
|
+
def sensitive_raw_body(self) -> str:
|
|
46
|
+
"""Return the original response body. Do not log or serialize this value."""
|
|
47
|
+
return self._sensitive_body()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
APIError = ApiError
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AuthenticationError(ApiError):
|
|
54
|
+
"""The API key is missing, malformed or unknown."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PermissionDeniedError(ApiError):
|
|
58
|
+
"""The key is valid but the account, branch or IP may not call this endpoint."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class QuotaExceededError(PermissionDeniedError):
|
|
62
|
+
"""The service quota is used up. Nothing was charged."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class InvalidRequestError(ApiError):
|
|
66
|
+
"""The API rejected the request body, query or parameters."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class InvalidImageError(InvalidRequestError):
|
|
70
|
+
"""The image is missing, too large, not an image, or not a slip of the right kind."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class InvalidUrlError(InvalidRequestError):
|
|
74
|
+
"""An image URL or callback URL was refused (protocol, private address, unreachable)."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DuplicateSlipError(InvalidRequestError):
|
|
78
|
+
"""v1 only. The slip was verified before; `slip` holds the parsed original slip."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class NotFoundError(ApiError):
|
|
82
|
+
"""The resource (bank account, job, route) does not exist or is not visible to this branch."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class SlipNotFoundError(NotFoundError):
|
|
86
|
+
"""The bank or wallet could not find the slip, or no QR code was found in the image."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SlipPendingError(NotFoundError):
|
|
90
|
+
"""Bangkok Bank slip from the last few minutes; try again later."""
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class ConflictError(ApiError):
|
|
94
|
+
"""The request conflicts with existing data (duplicate bank account)."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RateLimitError(ApiError):
|
|
98
|
+
"""Too many requests. Carries retryAfter (seconds) when the API sends Retry-After."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ServerError(ApiError):
|
|
102
|
+
"""5xx from the API or an upstream bank service."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ServiceUnavailableError(ServerError):
|
|
106
|
+
"""503, a dependency is briefly unavailable. Usually sent with Retry-After."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NetworkError(SDKError):
|
|
110
|
+
"""A transport failure, with a credential-free cause."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, message: str, cause: BaseException | None = None) -> None:
|
|
113
|
+
self.cause = cause
|
|
114
|
+
super().__init__(message)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RequestTimeoutError(NetworkError):
|
|
118
|
+
"""The configured client timeout expired."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
TimeoutError = RequestTimeoutError
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class InputError(SDKError, ValueError):
|
|
125
|
+
"""An argument rejected before sending any request."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, code: str, message: str) -> None:
|
|
128
|
+
self.code = code
|
|
129
|
+
super().__init__(message)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class WebhookSignatureError(SDKError):
|
|
133
|
+
"""The raw body did not match the supplied signature."""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class JobWaitTimeoutError(SDKError):
|
|
137
|
+
"""Polling deadline reached; last_job retains the last observation."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, last_job: Any) -> None:
|
|
140
|
+
self.last_job = last_job
|
|
141
|
+
super().__init__("Job polling timed out")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class BatchSubmitError(SDKError):
|
|
145
|
+
"""A chunk failed after earlier independent chunks were accepted."""
|
|
146
|
+
|
|
147
|
+
def __init__(self, submitted: list[Any], failed_chunk: int, cause: Exception) -> None:
|
|
148
|
+
self.submitted = submitted
|
|
149
|
+
self.failed_chunk = failed_chunk
|
|
150
|
+
self.cause = cause
|
|
151
|
+
super().__init__(f"Batch chunk {failed_chunk} failed")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def rate_limit(headers: dict[str, str]) -> RateLimit | None:
|
|
155
|
+
"""Read the complete rate limit triple, or return None."""
|
|
156
|
+
try:
|
|
157
|
+
return RateLimit(
|
|
158
|
+
**{x: int(headers["x-ratelimit-" + x]) for x in ("limit", "remaining", "reset")}
|
|
159
|
+
)
|
|
160
|
+
except (KeyError, ValueError):
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def retry_after(value: str | None) -> float | None:
|
|
165
|
+
"""Parse Retry-After seconds or an HTTP date."""
|
|
166
|
+
if value is None:
|
|
167
|
+
return None
|
|
168
|
+
try:
|
|
169
|
+
return max(0, float(value))
|
|
170
|
+
except ValueError:
|
|
171
|
+
try:
|
|
172
|
+
return max(
|
|
173
|
+
0,
|
|
174
|
+
(
|
|
175
|
+
email.utils.parsedate_to_datetime(value) - datetime.now(timezone.utc)
|
|
176
|
+
).total_seconds(),
|
|
177
|
+
)
|
|
178
|
+
except (ValueError, TypeError):
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_CODES = {
|
|
183
|
+
"MISSING_API_KEY": "AuthenticationError",
|
|
184
|
+
"INVALID_API_KEY": "AuthenticationError",
|
|
185
|
+
"BRANCH_INACTIVE": "PermissionDeniedError",
|
|
186
|
+
"SERVICE_BANNED": "PermissionDeniedError",
|
|
187
|
+
"SERVICE_DELETED": "PermissionDeniedError",
|
|
188
|
+
"USER_BANNED": "PermissionDeniedError",
|
|
189
|
+
"IP_NOT_ALLOWED": "PermissionDeniedError",
|
|
190
|
+
"SERVICE_EXPIRED": "PermissionDeniedError",
|
|
191
|
+
"QUOTA_EXCEEDED": "QuotaExceededError",
|
|
192
|
+
"VALIDATION_ERROR": "InvalidRequestError",
|
|
193
|
+
"INVALID_BANK_CODE": "InvalidRequestError",
|
|
194
|
+
"INVALID_EXTRA_VERIFY": "InvalidRequestError",
|
|
195
|
+
"INVALID_MATCH_MODE": "InvalidRequestError",
|
|
196
|
+
"IMAGE_SIZE_TOO_LARGE": "InvalidImageError",
|
|
197
|
+
"INVALID_IMAGE_FORMAT": "InvalidImageError",
|
|
198
|
+
"INVALID_IMAGE_TYPE": "InvalidImageError",
|
|
199
|
+
"INVALID_IMAGE": "InvalidImageError",
|
|
200
|
+
"INVALID_WALLET_QR": "InvalidImageError",
|
|
201
|
+
"URL_PROTOCOL_NOT_ALLOWED": "InvalidUrlError",
|
|
202
|
+
"URL_INVALID_IP_RANGE": "InvalidUrlError",
|
|
203
|
+
"IMAGE_URL_UNREACHABLE": "InvalidUrlError",
|
|
204
|
+
"INVALID_CALLBACK_URL": "InvalidUrlError",
|
|
205
|
+
"SLIP_NOT_FOUND": "SlipNotFoundError",
|
|
206
|
+
"SLIP_PENDING": "SlipPendingError",
|
|
207
|
+
"BANK_ACCOUNT_NOT_FOUND": "NotFoundError",
|
|
208
|
+
"JOB_NOT_FOUND": "NotFoundError",
|
|
209
|
+
"NOT_FOUND": "NotFoundError",
|
|
210
|
+
"BANK_ACCOUNT_DUPLICATE": "ConflictError",
|
|
211
|
+
"RATE_LIMIT_EXCEEDED": "RateLimitError",
|
|
212
|
+
"API_SERVER_ERROR": "ServerError",
|
|
213
|
+
"INTERNAL_SERVER_ERROR": "ServerError",
|
|
214
|
+
"RENEWAL_TEMPORARILY_UNAVAILABLE": "ServiceUnavailableError",
|
|
215
|
+
"CALLBACK_UNAVAILABLE": "ServiceUnavailableError",
|
|
216
|
+
"unauthorized": "AuthenticationError",
|
|
217
|
+
"access_denied": "PermissionDeniedError",
|
|
218
|
+
"account_not_verified": "PermissionDeniedError",
|
|
219
|
+
"application_expired": "PermissionDeniedError",
|
|
220
|
+
"application_deactivated": "PermissionDeniedError",
|
|
221
|
+
"quota_exceeded": "QuotaExceededError",
|
|
222
|
+
"invalid_payload": "InvalidRequestError",
|
|
223
|
+
"invalid_check_duplicate": "InvalidRequestError",
|
|
224
|
+
"invalid_request": "InvalidRequestError",
|
|
225
|
+
"invalid_type": "InvalidRequestError",
|
|
226
|
+
"invalid_transaction_id": "InvalidRequestError",
|
|
227
|
+
"invalid_sender_name": "InvalidRequestError",
|
|
228
|
+
"invalid_receiver_name": "InvalidRequestError",
|
|
229
|
+
"invalid_receiver_phone": "InvalidRequestError",
|
|
230
|
+
"invalid_date": "InvalidRequestError",
|
|
231
|
+
"invalid_amount": "InvalidRequestError",
|
|
232
|
+
"invalid_image": "InvalidImageError",
|
|
233
|
+
"invalid_wallet_qr": "InvalidImageError",
|
|
234
|
+
"invalid_base64": "InvalidImageError",
|
|
235
|
+
"image_size_too_large": "InvalidImageError",
|
|
236
|
+
"invalid_url": "InvalidUrlError",
|
|
237
|
+
"url_unreachable": "InvalidUrlError",
|
|
238
|
+
"duplicate_slip": "DuplicateSlipError",
|
|
239
|
+
"slip_not_found": "SlipNotFoundError",
|
|
240
|
+
"qrcode_not_found": "SlipNotFoundError",
|
|
241
|
+
"slip_pending": "SlipPendingError",
|
|
242
|
+
"not_found": "NotFoundError",
|
|
243
|
+
"rate_limit_exceeded": "RateLimitError",
|
|
244
|
+
"server_error": "ServerError",
|
|
245
|
+
"api_server_error": "ServerError",
|
|
246
|
+
"renewal_temporarily_unavailable": "ServiceUnavailableError",
|
|
247
|
+
}
|
|
248
|
+
_FALLBACK = {
|
|
249
|
+
400: "InvalidRequestError",
|
|
250
|
+
401: "AuthenticationError",
|
|
251
|
+
403: "PermissionDeniedError",
|
|
252
|
+
404: "NotFoundError",
|
|
253
|
+
409: "ConflictError",
|
|
254
|
+
429: "RateLimitError",
|
|
255
|
+
500: "ServerError",
|
|
256
|
+
502: "ServerError",
|
|
257
|
+
503: "ServiceUnavailableError",
|
|
258
|
+
504: "ServerError",
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
_JSON_ESCAPE = re.compile(r'(?:\\u[0-9a-fA-F]{4})+|\\[/\\"]|\\[bfnrt]')
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _decoded(value: str) -> str:
|
|
266
|
+
# Decode valid escapes independently: a header need not be a JSON document.
|
|
267
|
+
def escape(match: re.Match[str]) -> str:
|
|
268
|
+
return cast(str, json.loads('"' + match.group() + '"'))
|
|
269
|
+
|
|
270
|
+
return _JSON_ESCAPE.sub(escape, unquote(value))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def redact(value: str, secret: str) -> str:
|
|
274
|
+
"""Remove credential echoes, including mixed URL and JSON escapes."""
|
|
275
|
+
if len(secret) < 4:
|
|
276
|
+
return value
|
|
277
|
+
original = value
|
|
278
|
+
while True:
|
|
279
|
+
if secret in value:
|
|
280
|
+
return "[REDACTED]".join(redact(part, secret) for part in value.split(secret))
|
|
281
|
+
decoded = _decoded(value)
|
|
282
|
+
if decoded == value:
|
|
283
|
+
return original
|
|
284
|
+
value = decoded
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def redact_header(name: str, value: str, secret: str) -> str:
|
|
288
|
+
"""Remove an entire credential-bearing header value."""
|
|
289
|
+
if name.lower() in {"authorization", "proxy-authorization"} or redact(value, secret) != value:
|
|
290
|
+
return "[REDACTED]"
|
|
291
|
+
return value
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _clean_json(value: Any, secret: str) -> Any:
|
|
295
|
+
if isinstance(value, str):
|
|
296
|
+
return redact(value, secret)
|
|
297
|
+
if isinstance(value, list):
|
|
298
|
+
return [_clean_json(item, secret) for item in value]
|
|
299
|
+
if isinstance(value, dict):
|
|
300
|
+
return {redact(k, secret): _clean_json(v, secret) for k, v in value.items()}
|
|
301
|
+
return value
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def api_error(response: httpx.Response, secret: str) -> ApiError:
|
|
305
|
+
"""Decode an HTTP error and redact literal or JSON-escaped credential echoes."""
|
|
306
|
+
original = response.text
|
|
307
|
+
raw = original
|
|
308
|
+
try:
|
|
309
|
+
body = json.loads(original)
|
|
310
|
+
clean = _clean_json(body, secret)
|
|
311
|
+
if clean != body:
|
|
312
|
+
raw = json.dumps(clean, ensure_ascii=False)
|
|
313
|
+
body = clean
|
|
314
|
+
except ValueError:
|
|
315
|
+
raw = redact(original, secret)
|
|
316
|
+
if raw != original and _decoded(original) != original:
|
|
317
|
+
raw = "[REDACTED]"
|
|
318
|
+
body = {}
|
|
319
|
+
if not isinstance(body, dict):
|
|
320
|
+
body = {}
|
|
321
|
+
value: dict[str, Any] = (
|
|
322
|
+
cast(dict[str, Any], body["error"]) if isinstance(body.get("error"), dict) else body
|
|
323
|
+
)
|
|
324
|
+
code = value.get("code") or value.get("message")
|
|
325
|
+
message = value.get("message") or f"HTTP {response.status_code}"
|
|
326
|
+
headers = {k.lower(): redact_header(k, v, secret) for k, v in response.headers.items()}
|
|
327
|
+
cls = globals()[
|
|
328
|
+
_CODES.get(
|
|
329
|
+
code or "",
|
|
330
|
+
_FALLBACK.get(
|
|
331
|
+
response.status_code, "ServerError" if response.status_code >= 500 else "ApiError"
|
|
332
|
+
),
|
|
333
|
+
)
|
|
334
|
+
]
|
|
335
|
+
result: ApiError = cls(
|
|
336
|
+
response.status_code,
|
|
337
|
+
code,
|
|
338
|
+
message,
|
|
339
|
+
headers.get("x-request-id") or headers.get("cf-ray"),
|
|
340
|
+
raw,
|
|
341
|
+
headers=headers,
|
|
342
|
+
details=body.get("details"),
|
|
343
|
+
retry_after=retry_after(headers.get("retry-after")),
|
|
344
|
+
rate_limit=rate_limit(headers),
|
|
345
|
+
)
|
|
346
|
+
result._sensitive_body = lambda: original
|
|
347
|
+
if isinstance(result, DuplicateSlipError) and isinstance(body.get("data"), dict):
|
|
348
|
+
data = body["data"]
|
|
349
|
+
result.slip = (
|
|
350
|
+
decode(TrueWalletSlip, data) if "transactionId" in data else decode(BankSlip, data)
|
|
351
|
+
)
|
|
352
|
+
return result
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class SanitizedTransportError(Exception):
|
|
356
|
+
"""An allowlisted transport diagnostic; never retains the source exception."""
|
|
357
|
+
|
|
358
|
+
def __init__(self, error: BaseException, secret: str) -> None:
|
|
359
|
+
self.type_name = type(error).__name__
|
|
360
|
+
message = redact(str(error), secret)
|
|
361
|
+
if message != str(error) and _decoded(str(error)) != str(error):
|
|
362
|
+
message = "[REDACTED]"
|
|
363
|
+
self.code: str | int | float | None = None
|
|
364
|
+
code = getattr(error, "code", getattr(error, "errno", None))
|
|
365
|
+
if isinstance(code, (str, int, float)):
|
|
366
|
+
self.code = redact(str(code), secret) if isinstance(code, str) else code
|
|
367
|
+
super().__init__(message)
|
thunder_slip/files.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Bounded, content-sniffed image input."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import BinaryIO
|
|
6
|
+
from .errors import InputError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ImageInput:
|
|
11
|
+
"""An image ready for multipart upload."""
|
|
12
|
+
|
|
13
|
+
data: bytes
|
|
14
|
+
content_type: str
|
|
15
|
+
filename: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def image_input(value: str | Path | bytes | BinaryIO, *, max_bytes: int) -> ImageInput:
|
|
19
|
+
"""Read at most limit+1 bytes from paths or streams and validate image magic."""
|
|
20
|
+
if max_bytes <= 0:
|
|
21
|
+
raise InputError("INVALID_ARGUMENT", "Image size limit must be positive")
|
|
22
|
+
if isinstance(value, (str, Path)):
|
|
23
|
+
path = Path(value)
|
|
24
|
+
try:
|
|
25
|
+
with path.open("rb") as stream:
|
|
26
|
+
data = stream.read(max_bytes + 1)
|
|
27
|
+
except OSError:
|
|
28
|
+
raise InputError("FILE_NOT_READABLE", "Cannot read image file") from None
|
|
29
|
+
elif isinstance(value, bytes):
|
|
30
|
+
data = value
|
|
31
|
+
else:
|
|
32
|
+
try:
|
|
33
|
+
data = value.read(max_bytes + 1)
|
|
34
|
+
except (OSError, ValueError, AttributeError):
|
|
35
|
+
raise InputError("FILE_NOT_READABLE", "Cannot read image stream") from None
|
|
36
|
+
if not isinstance(data, bytes):
|
|
37
|
+
raise InputError("INVALID_ARGUMENT", "Expected a binary image stream")
|
|
38
|
+
if not data:
|
|
39
|
+
raise InputError("EMPTY_FILE", "Image is empty")
|
|
40
|
+
if len(data) > max_bytes:
|
|
41
|
+
raise InputError("FILE_TOO_LARGE", "Image exceeds upload size limit")
|
|
42
|
+
if data.startswith(b"\x89PNG"):
|
|
43
|
+
mime = "image/png"
|
|
44
|
+
elif data.startswith(b"\xff\xd8\xff"):
|
|
45
|
+
mime = "image/jpeg"
|
|
46
|
+
elif data.startswith(b"GIF8"):
|
|
47
|
+
mime = "image/gif"
|
|
48
|
+
elif data.startswith(b"RIFF") and data[8:12] == b"WEBP":
|
|
49
|
+
mime = "image/webp"
|
|
50
|
+
else:
|
|
51
|
+
raise InputError("UNSUPPORTED_FILE_TYPE", "Unsupported image content")
|
|
52
|
+
extension = {
|
|
53
|
+
"image/png": "png",
|
|
54
|
+
"image/jpeg": "jpg",
|
|
55
|
+
"image/gif": "gif",
|
|
56
|
+
"image/webp": "webp",
|
|
57
|
+
}[mime]
|
|
58
|
+
filename = f"slip.{extension}"
|
|
59
|
+
return ImageInput(data, mime, filename)
|