blu-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.
- blu_sdk-1.0.0/LICENSE +21 -0
- blu_sdk-1.0.0/PKG-INFO +178 -0
- blu_sdk-1.0.0/README.md +158 -0
- blu_sdk-1.0.0/blu/__init__.py +27 -0
- blu_sdk-1.0.0/blu/_async.py +44 -0
- blu_sdk-1.0.0/blu/_core.py +428 -0
- blu_sdk-1.0.0/blu/_errors.py +93 -0
- blu_sdk-1.0.0/blu/_generated.py +3080 -0
- blu_sdk-1.0.0/blu/_uploads.py +199 -0
- blu_sdk-1.0.0/blu/_webhooks.py +78 -0
- blu_sdk-1.0.0/blu_sdk.egg-info/PKG-INFO +178 -0
- blu_sdk-1.0.0/blu_sdk.egg-info/SOURCES.txt +18 -0
- blu_sdk-1.0.0/blu_sdk.egg-info/dependency_links.txt +1 -0
- blu_sdk-1.0.0/blu_sdk.egg-info/top_level.txt +1 -0
- blu_sdk-1.0.0/pyproject.toml +30 -0
- blu_sdk-1.0.0/setup.cfg +4 -0
- blu_sdk-1.0.0/tests/test_client.py +582 -0
- blu_sdk-1.0.0/tests/test_every_operation.py +94 -0
- blu_sdk-1.0.0/tests/test_uploads.py +314 -0
- blu_sdk-1.0.0/tests/test_webhooks.py +94 -0
blu_sdk-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BLU
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
blu_sdk-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blu-sdk
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: BLU API client for Python. No dependencies.
|
|
5
|
+
Author-email: BLU <corp@blu.luxe>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://blu.luxe
|
|
8
|
+
Project-URL: Documentation, https://docs.blu.luxe/integrations/sdks/
|
|
9
|
+
Project-URL: Source, https://github.com/blu-luxe/blu-integrations/tree/main/sdks/python
|
|
10
|
+
Keywords: blu,onlyfans,agency,crm,api,webhooks,sdk
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# BLU API for Python
|
|
22
|
+
|
|
23
|
+
Python 3.9 or later. No dependencies.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install blu-sdk
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
from blu import Blu
|
|
31
|
+
|
|
32
|
+
client = Blu() # reads the key from the environment variable BLU_KEY
|
|
33
|
+
|
|
34
|
+
me = client.account.whoami()
|
|
35
|
+
print(me.data["keyName"])
|
|
36
|
+
|
|
37
|
+
sent = client.chats.send("1000000001", "fan_EXAMPLE0000000000", {"text": "Thanks for subscribing!"})
|
|
38
|
+
if sent.outcome == "unknown":
|
|
39
|
+
print("Check the chat before trying again:", sent.action_id)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Every endpoint is a method on a group, in the same order as the [API reference](https://docs.blu.luxe/reference/): `client.chats`, `client.fans`, `client.vault`, `client.mass`, `client.posts`, `client.links`, `client.analytics`, `client.account`, `client.webhooks`, `client.creators`, `client.employees`, `client.org`.
|
|
43
|
+
|
|
44
|
+
## Calling a method
|
|
45
|
+
|
|
46
|
+
The path's ids come first, then the body or the query as a dict, with the API's own field names. Options are keywords:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key="order-1234")
|
|
50
|
+
client.chats.list(creator_id, {"limit": 50, "order": "recent"})
|
|
51
|
+
client.chats.mute(creator_id, fan_id)
|
|
52
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"}, dry_run=True) # BLU's own checks, OnlyFans isn't asked, nothing is sent
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Options on any call: `timeout` (seconds of silence), `max_retries`. On a write, also `idempotency_key` and, where the endpoint has one, `dry_run`.
|
|
56
|
+
|
|
57
|
+
For asyncio, `from blu import AsyncBlu` takes the same arguments: `await client.chats.send(...)`. It runs the same client on a worker thread.
|
|
58
|
+
|
|
59
|
+
## What comes back
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
res = client.posts.delete(creator_id, post_id)
|
|
63
|
+
res.outcome # "sent", "unknown", "scheduled", "accepted", "dry_run", or None on a read
|
|
64
|
+
res.data # what the endpoint answered
|
|
65
|
+
res.action_id # look it up later with client.account.get_action(res.action_id)
|
|
66
|
+
res.job_id # on "scheduled" and on a mass message
|
|
67
|
+
res.status_url # where to check how it went
|
|
68
|
+
res.replayed # True: this is the first answer again, and nothing new happened
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| Outcome | What to do |
|
|
72
|
+
|---|---|
|
|
73
|
+
| `sent` | Nothing. It was done. |
|
|
74
|
+
| `unknown` | **Never resend it automatically.** OnlyFans may have done it. Read the chat first. If you still want to send it, make a new call. |
|
|
75
|
+
| `scheduled` | A delete waiting out its cancel window. It's a success, with a job. `client.account.cancel_job(res.job_id)` stops it. |
|
|
76
|
+
| `accepted` | BLU has it, and it's on its way. A mass message or a finished upload answers this. |
|
|
77
|
+
| `dry_run` | BLU's own checks passed, and nothing was sent. OnlyFans wasn't asked. |
|
|
78
|
+
|
|
79
|
+
## Retries, and why a retry can't send twice
|
|
80
|
+
|
|
81
|
+
Every write gets an `Idempotency-Key`, generated if you give none and kept across that call's own retries. BLU answers a repeat with the first answer, so the fan gets one message.
|
|
82
|
+
|
|
83
|
+
It retries a dropped connection, a timeout, a `429` and a `503`, waiting at least as long as `Retry-After` says, or backing off with jitter. It stops after `max_retries` (default 3). A wait longer than `max_retry_delay` (default 60 seconds) isn't waited for: the error reaches you with `retry_after_ms`.
|
|
84
|
+
|
|
85
|
+
It never retries a 2xx, so an `unknown` is never resent. It never retries an error that carries an `outcome`. The API ignores the key on a few webhook calls, such as `rotate_secret`, so those go again only when BLU itself says to try later, never after a lost answer: a second rotation deletes the secret you hold.
|
|
86
|
+
|
|
87
|
+
A new call is a new action with a new key, so wrap a call in your own retry loop only if you pass the same `idempotency_key` each time:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
key = "welcome-" + fan_id # survives your program restarting
|
|
91
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key=key)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Errors
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from blu import BluConnectionError, BluError
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"})
|
|
101
|
+
except BluConnectionError as err:
|
|
102
|
+
# No answer arrived. Asking again with this key can't send twice.
|
|
103
|
+
print("ask again with", err.idempotency_key)
|
|
104
|
+
except BluError as err:
|
|
105
|
+
print(err.status, err.code, err.outcome, err.retryable, err.retry_after_ms, err.details, err)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`err.code` is stable: use it in your code. `err.outcome` is `not_sent` when nothing happened, and then a new call is safe. Classes: `BluBadRequestError` (400, 422), `BluAuthError` (401), `BluAllowanceError` (402), `BluPermissionError` (403), `BluNotFoundError` (404), `BluConflictError` (409), `BluRateLimitError` (429), `BluServerError` (5xx), `BluConnectionError`, `BluPaginationError`, `BluUploadError`. All are a `BluError`.
|
|
109
|
+
|
|
110
|
+
Every code: [Errors & outcomes](https://docs.blu.luxe/outcomes/).
|
|
111
|
+
|
|
112
|
+
## Every row of a list
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
for chat in client.chats.list_all(creator_id, {"limit": 50}):
|
|
116
|
+
print(chat["fanId"], chat["lastMessageTime"])
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Any list that pages has a `..._all` method, and with `AsyncBlu` it's an async iterator: `async for chat in client.chats.list_all(...)`. Only `hasMore: false` ends it: a short or empty page doesn't. A list that says there's more and gives no way to get it raises `BluPaginationError` after its rows, because stopping quietly would lose some. Each page of a live list uses one live call, so `break` when you have enough.
|
|
120
|
+
|
|
121
|
+
`client.webhooks.pull_events_all({"since": last_id})` walks the event log the same way.
|
|
122
|
+
|
|
123
|
+
## Webhooks
|
|
124
|
+
|
|
125
|
+
Check the raw body, before any JSON parser touches it.
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
import json
|
|
129
|
+
import os
|
|
130
|
+
from flask import Flask, request
|
|
131
|
+
from blu import verify_signature
|
|
132
|
+
|
|
133
|
+
app = Flask(__name__)
|
|
134
|
+
|
|
135
|
+
@app.post("/blu-webhook")
|
|
136
|
+
def receive():
|
|
137
|
+
raw = request.get_data()
|
|
138
|
+
# Anyone can reach this address, so the body can be anything.
|
|
139
|
+
try:
|
|
140
|
+
event = json.loads(raw)
|
|
141
|
+
except ValueError:
|
|
142
|
+
return "", 400
|
|
143
|
+
# The first challenge arrives before you have the secret. There's nothing secret in it.
|
|
144
|
+
if event.get("event") == "webhook.verification":
|
|
145
|
+
return {"challenge": event["payload"]["challenge"]}
|
|
146
|
+
# No secret yet means you can't check anything. A 503 makes BLU try again later.
|
|
147
|
+
secret = os.environ.get("BLU_WEBHOOK_SECRET")
|
|
148
|
+
if not secret:
|
|
149
|
+
return "", 503
|
|
150
|
+
if not verify_signature(raw, request.headers.get("BLU-Signature"), secret):
|
|
151
|
+
return "", 401
|
|
152
|
+
# Ignore an event id you've seen.
|
|
153
|
+
return "", 200
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`client.webhooks.create` answers the secret once. If its first answer was lost, the retry comes back with `secret` set to `None` and `replayed` true: call `client.webhooks.rotate_secret` to get a new one. During a rotation, pass both secrets: `verify_signature(raw, header, [old_secret, new_secret])`. `tolerance_seconds` defaults to 300. `check_signature` takes the same arguments and says why: `malformed`, `too_old` or `no_match`.
|
|
157
|
+
|
|
158
|
+
## Uploading to the vault
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
upload = client.vault.upload(
|
|
162
|
+
creator_id,
|
|
163
|
+
file="./clip.mp4",
|
|
164
|
+
mime_type="video/mp4",
|
|
165
|
+
link_fan_id="fan_EXAMPLE0000000000", # this fan really receives the file, for free
|
|
166
|
+
)
|
|
167
|
+
print(upload["mediaId"])
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
It creates the upload, sends parts of the size the API answers, completes it and waits until the file is at OnlyFans. A file on disk is read one part at a time. For a file object, also pass `file_name` and `file_size_bytes`: it holds one part in memory, because a part that fails has to go again. `wait=False` returns once it's on its way, and `on_progress` reports each part.
|
|
171
|
+
|
|
172
|
+
If it stops before the file is sent, it cancels the upload, which gives back the credits and the creator's one upload slot. `BluUploadError` carries `outcome`. An `unknown` there means the file may already be with the fan: don't upload it again without checking.
|
|
173
|
+
|
|
174
|
+
## Good to know
|
|
175
|
+
|
|
176
|
+
- **Keep the key on a server.** It isn't in `repr(client)` or in an error, and this package logs nothing.
|
|
177
|
+
- **Redirects aren't followed**, so the key can't reach another host.
|
|
178
|
+
- The key defaults to the environment variable `BLU_KEY`.
|
blu_sdk-1.0.0/README.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# BLU API for Python
|
|
2
|
+
|
|
3
|
+
Python 3.9 or later. No dependencies.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install blu-sdk
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from blu import Blu
|
|
11
|
+
|
|
12
|
+
client = Blu() # reads the key from the environment variable BLU_KEY
|
|
13
|
+
|
|
14
|
+
me = client.account.whoami()
|
|
15
|
+
print(me.data["keyName"])
|
|
16
|
+
|
|
17
|
+
sent = client.chats.send("1000000001", "fan_EXAMPLE0000000000", {"text": "Thanks for subscribing!"})
|
|
18
|
+
if sent.outcome == "unknown":
|
|
19
|
+
print("Check the chat before trying again:", sent.action_id)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Every endpoint is a method on a group, in the same order as the [API reference](https://docs.blu.luxe/reference/): `client.chats`, `client.fans`, `client.vault`, `client.mass`, `client.posts`, `client.links`, `client.analytics`, `client.account`, `client.webhooks`, `client.creators`, `client.employees`, `client.org`.
|
|
23
|
+
|
|
24
|
+
## Calling a method
|
|
25
|
+
|
|
26
|
+
The path's ids come first, then the body or the query as a dict, with the API's own field names. Options are keywords:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key="order-1234")
|
|
30
|
+
client.chats.list(creator_id, {"limit": 50, "order": "recent"})
|
|
31
|
+
client.chats.mute(creator_id, fan_id)
|
|
32
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"}, dry_run=True) # BLU's own checks, OnlyFans isn't asked, nothing is sent
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Options on any call: `timeout` (seconds of silence), `max_retries`. On a write, also `idempotency_key` and, where the endpoint has one, `dry_run`.
|
|
36
|
+
|
|
37
|
+
For asyncio, `from blu import AsyncBlu` takes the same arguments: `await client.chats.send(...)`. It runs the same client on a worker thread.
|
|
38
|
+
|
|
39
|
+
## What comes back
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
res = client.posts.delete(creator_id, post_id)
|
|
43
|
+
res.outcome # "sent", "unknown", "scheduled", "accepted", "dry_run", or None on a read
|
|
44
|
+
res.data # what the endpoint answered
|
|
45
|
+
res.action_id # look it up later with client.account.get_action(res.action_id)
|
|
46
|
+
res.job_id # on "scheduled" and on a mass message
|
|
47
|
+
res.status_url # where to check how it went
|
|
48
|
+
res.replayed # True: this is the first answer again, and nothing new happened
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| Outcome | What to do |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `sent` | Nothing. It was done. |
|
|
54
|
+
| `unknown` | **Never resend it automatically.** OnlyFans may have done it. Read the chat first. If you still want to send it, make a new call. |
|
|
55
|
+
| `scheduled` | A delete waiting out its cancel window. It's a success, with a job. `client.account.cancel_job(res.job_id)` stops it. |
|
|
56
|
+
| `accepted` | BLU has it, and it's on its way. A mass message or a finished upload answers this. |
|
|
57
|
+
| `dry_run` | BLU's own checks passed, and nothing was sent. OnlyFans wasn't asked. |
|
|
58
|
+
|
|
59
|
+
## Retries, and why a retry can't send twice
|
|
60
|
+
|
|
61
|
+
Every write gets an `Idempotency-Key`, generated if you give none and kept across that call's own retries. BLU answers a repeat with the first answer, so the fan gets one message.
|
|
62
|
+
|
|
63
|
+
It retries a dropped connection, a timeout, a `429` and a `503`, waiting at least as long as `Retry-After` says, or backing off with jitter. It stops after `max_retries` (default 3). A wait longer than `max_retry_delay` (default 60 seconds) isn't waited for: the error reaches you with `retry_after_ms`.
|
|
64
|
+
|
|
65
|
+
It never retries a 2xx, so an `unknown` is never resent. It never retries an error that carries an `outcome`. The API ignores the key on a few webhook calls, such as `rotate_secret`, so those go again only when BLU itself says to try later, never after a lost answer: a second rotation deletes the secret you hold.
|
|
66
|
+
|
|
67
|
+
A new call is a new action with a new key, so wrap a call in your own retry loop only if you pass the same `idempotency_key` each time:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
key = "welcome-" + fan_id # survives your program restarting
|
|
71
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"}, idempotency_key=key)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Errors
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from blu import BluConnectionError, BluError
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
client.chats.send(creator_id, fan_id, {"text": "Hi"})
|
|
81
|
+
except BluConnectionError as err:
|
|
82
|
+
# No answer arrived. Asking again with this key can't send twice.
|
|
83
|
+
print("ask again with", err.idempotency_key)
|
|
84
|
+
except BluError as err:
|
|
85
|
+
print(err.status, err.code, err.outcome, err.retryable, err.retry_after_ms, err.details, err)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`err.code` is stable: use it in your code. `err.outcome` is `not_sent` when nothing happened, and then a new call is safe. Classes: `BluBadRequestError` (400, 422), `BluAuthError` (401), `BluAllowanceError` (402), `BluPermissionError` (403), `BluNotFoundError` (404), `BluConflictError` (409), `BluRateLimitError` (429), `BluServerError` (5xx), `BluConnectionError`, `BluPaginationError`, `BluUploadError`. All are a `BluError`.
|
|
89
|
+
|
|
90
|
+
Every code: [Errors & outcomes](https://docs.blu.luxe/outcomes/).
|
|
91
|
+
|
|
92
|
+
## Every row of a list
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
for chat in client.chats.list_all(creator_id, {"limit": 50}):
|
|
96
|
+
print(chat["fanId"], chat["lastMessageTime"])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Any list that pages has a `..._all` method, and with `AsyncBlu` it's an async iterator: `async for chat in client.chats.list_all(...)`. Only `hasMore: false` ends it: a short or empty page doesn't. A list that says there's more and gives no way to get it raises `BluPaginationError` after its rows, because stopping quietly would lose some. Each page of a live list uses one live call, so `break` when you have enough.
|
|
100
|
+
|
|
101
|
+
`client.webhooks.pull_events_all({"since": last_id})` walks the event log the same way.
|
|
102
|
+
|
|
103
|
+
## Webhooks
|
|
104
|
+
|
|
105
|
+
Check the raw body, before any JSON parser touches it.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
import json
|
|
109
|
+
import os
|
|
110
|
+
from flask import Flask, request
|
|
111
|
+
from blu import verify_signature
|
|
112
|
+
|
|
113
|
+
app = Flask(__name__)
|
|
114
|
+
|
|
115
|
+
@app.post("/blu-webhook")
|
|
116
|
+
def receive():
|
|
117
|
+
raw = request.get_data()
|
|
118
|
+
# Anyone can reach this address, so the body can be anything.
|
|
119
|
+
try:
|
|
120
|
+
event = json.loads(raw)
|
|
121
|
+
except ValueError:
|
|
122
|
+
return "", 400
|
|
123
|
+
# The first challenge arrives before you have the secret. There's nothing secret in it.
|
|
124
|
+
if event.get("event") == "webhook.verification":
|
|
125
|
+
return {"challenge": event["payload"]["challenge"]}
|
|
126
|
+
# No secret yet means you can't check anything. A 503 makes BLU try again later.
|
|
127
|
+
secret = os.environ.get("BLU_WEBHOOK_SECRET")
|
|
128
|
+
if not secret:
|
|
129
|
+
return "", 503
|
|
130
|
+
if not verify_signature(raw, request.headers.get("BLU-Signature"), secret):
|
|
131
|
+
return "", 401
|
|
132
|
+
# Ignore an event id you've seen.
|
|
133
|
+
return "", 200
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`client.webhooks.create` answers the secret once. If its first answer was lost, the retry comes back with `secret` set to `None` and `replayed` true: call `client.webhooks.rotate_secret` to get a new one. During a rotation, pass both secrets: `verify_signature(raw, header, [old_secret, new_secret])`. `tolerance_seconds` defaults to 300. `check_signature` takes the same arguments and says why: `malformed`, `too_old` or `no_match`.
|
|
137
|
+
|
|
138
|
+
## Uploading to the vault
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
upload = client.vault.upload(
|
|
142
|
+
creator_id,
|
|
143
|
+
file="./clip.mp4",
|
|
144
|
+
mime_type="video/mp4",
|
|
145
|
+
link_fan_id="fan_EXAMPLE0000000000", # this fan really receives the file, for free
|
|
146
|
+
)
|
|
147
|
+
print(upload["mediaId"])
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
It creates the upload, sends parts of the size the API answers, completes it and waits until the file is at OnlyFans. A file on disk is read one part at a time. For a file object, also pass `file_name` and `file_size_bytes`: it holds one part in memory, because a part that fails has to go again. `wait=False` returns once it's on its way, and `on_progress` reports each part.
|
|
151
|
+
|
|
152
|
+
If it stops before the file is sent, it cancels the upload, which gives back the credits and the creator's one upload slot. `BluUploadError` carries `outcome`. An `unknown` there means the file may already be with the fan: don't upload it again without checking.
|
|
153
|
+
|
|
154
|
+
## Good to know
|
|
155
|
+
|
|
156
|
+
- **Keep the key on a server.** It isn't in `repr(client)` or in an error, and this package logs nothing.
|
|
157
|
+
- **Redirects aren't followed**, so the key can't reach another host.
|
|
158
|
+
- The key defaults to the environment variable `BLU_KEY`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""BLU API client. No dependencies. See README.md."""
|
|
2
|
+
from ._async import AsyncBlu
|
|
3
|
+
from ._core import Blu, BluDownload, BluResponse
|
|
4
|
+
from ._errors import (
|
|
5
|
+
BluAllowanceError,
|
|
6
|
+
BluAuthError,
|
|
7
|
+
BluBadRequestError,
|
|
8
|
+
BluConflictError,
|
|
9
|
+
BluConnectionError,
|
|
10
|
+
BluError,
|
|
11
|
+
BluNotFoundError,
|
|
12
|
+
BluPaginationError,
|
|
13
|
+
BluPermissionError,
|
|
14
|
+
BluRateLimitError,
|
|
15
|
+
BluServerError,
|
|
16
|
+
BluUploadError,
|
|
17
|
+
)
|
|
18
|
+
from ._generated import SDK_VERSION, SPEC_VERSION
|
|
19
|
+
from ._webhooks import SignatureCheck, check_signature, parse_signature_header, verify_signature
|
|
20
|
+
|
|
21
|
+
__version__ = SDK_VERSION
|
|
22
|
+
__all__ = [
|
|
23
|
+
"AsyncBlu", "Blu", "BluDownload", "BluResponse",
|
|
24
|
+
"BluAllowanceError", "BluAuthError", "BluBadRequestError", "BluConflictError", "BluConnectionError", "BluError",
|
|
25
|
+
"BluNotFoundError", "BluPaginationError", "BluPermissionError", "BluRateLimitError", "BluServerError", "BluUploadError",
|
|
26
|
+
"SDK_VERSION", "SPEC_VERSION", "SignatureCheck", "check_signature", "parse_signature_header", "verify_signature",
|
|
27
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The same client for asyncio. It runs the standard-library client on a worker thread, so a call
|
|
2
|
+
never blocks your event loop. Methods ending in _all are async iterators."""
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from ._core import Blu
|
|
6
|
+
from ._generated import GROUPS
|
|
7
|
+
|
|
8
|
+
_END = object()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def _walk(method, args, kwargs):
|
|
12
|
+
iterator = await asyncio.to_thread(method, *args, **kwargs)
|
|
13
|
+
while True:
|
|
14
|
+
item = await asyncio.to_thread(next, iterator, _END)
|
|
15
|
+
if item is _END:
|
|
16
|
+
return
|
|
17
|
+
yield item
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _AsyncGroup:
|
|
21
|
+
def __init__(self, group):
|
|
22
|
+
self._group = group
|
|
23
|
+
|
|
24
|
+
def __getattr__(self, name):
|
|
25
|
+
method = getattr(self._group, name)
|
|
26
|
+
if name.endswith("_all"):
|
|
27
|
+
return lambda *args, **kwargs: _walk(method, args, kwargs)
|
|
28
|
+
|
|
29
|
+
async def call(*args, **kwargs):
|
|
30
|
+
return await asyncio.to_thread(method, *args, **kwargs)
|
|
31
|
+
|
|
32
|
+
return call
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncBlu:
|
|
36
|
+
"""Takes what Blu takes. await client.chats.send(...), async for row in client.chats.list_all(...)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, api_key=None, **config):
|
|
39
|
+
self._sync = Blu(api_key, **config)
|
|
40
|
+
for name in GROUPS:
|
|
41
|
+
setattr(self, name, _AsyncGroup(getattr(self._sync, name)))
|
|
42
|
+
|
|
43
|
+
def __repr__(self):
|
|
44
|
+
return "Async%r" % (self._sync,)
|