Skip to content

Commit 1ebe536

Browse files
committed
feat!: rewrite for new Simplepush app
1 parent a41ac2f commit 1ebe536

20 files changed

Lines changed: 5860 additions & 378 deletions

.gitignore

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
# Packaging / build artifacts
2-
build/
1+
# Build artifacts
32
dist/
3+
build/
44
*.egg-info/
55

6-
# Python bytecode
6+
# Bytecode
77
__pycache__/
88
*.py[cod]
99

10-
# Test / tooling caches
11-
.pytest_cache/
12-
.tox/
13-
.coverage
10+
# Virtual envs
11+
.venv/
12+
venv/
13+
env/
14+
15+
# Editors
16+
.idea/
17+
.vscode/
18+
19+
# OS
20+
.DS_Store

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Timm Schaeuble
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.

LICENSE.txt

Lines changed: 0 additions & 8 deletions
This file was deleted.

README.md

Lines changed: 290 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,309 @@
1-
Python module to send push notifications via [Simplepush](https://simplepush.io/).
1+
# simplepush
2+
3+
Python client for [Simplepush](https://simplepu.sh).
4+
5+
Send tasks, stream events over WebSocket, and decrypt end-to-end-encrypted
6+
payloads from Python.
7+
8+
## Install
29

3-
# Installation
410
```bash
5-
pip3 install simplepush
11+
pip install simplepush # HTTP + WebSocket only
12+
pip install 'simplepush[crypto]' # adds end-to-end encryption support
613
```
714

8-
# Examples
9-
All examples can be made asynchronous by using `async_send` instead of `send`.
15+
Requires Python 3.10+.
16+
17+
## Sending a task
1018

11-
* Send a push notification to the Simplepush key `YourKey`:
1219
```python
13-
import simplepush
14-
simplepush.send(key='YourKey', title='Notification title', message='Notification message')
20+
from simplepush import Client, TextInput, ChoiceInput
21+
22+
client = Client(api_token="USER_API_TOKEN")
23+
24+
group = client.send_task(
25+
topic="mytopic",
26+
title="Approve deploy?",
27+
inputs=[
28+
ChoiceInput(description="Deploy v1.2.3?", options=["yes", "no"], required=True),
29+
TextInput(description="Note (optional)"),
30+
],
31+
)
32+
task = group.sole # single-recipient topic; iterate the group for many
1533
```
1634

17-
* Send a push notification with actions and a callback function that will print the selected action:
35+
> **Ids are type-prefixed strings.** `task_id`, `subtask_id`, input/reply/file
36+
> ids and the like come back type-tagged — `tsk_…`, `sub_…`, `inp_…`, `rpl_…`
37+
> (a reply), `rfl_…` (a reply's file) — not bare UUIDs.
38+
39+
By default every recipient gets their **own independent task instance** (one
40+
recipient's answers never touch another's task), returned as a `TaskGroup` of
41+
per-recipient `Task` handles:
42+
1843
```python
19-
import simplepush
44+
group = client.send_task(topic="mytopic", content="check in")
45+
for task in group: # or group.instances
46+
print(task.task_id, task.recipient.public_id, task.recipient.name)
2047

21-
def callback(action_selected, action_selected_at, action_delivered_at, feedback_id):
22-
print(action_selected)
48+
subs = group.append(content="follow-up") # a subtask on every member's chain
49+
group.append(content="just you", instances=[group.instances[0]]) # or a subset
2350

24-
simplepush.send(key='YourKey', title='Title', message='Actionable notification', actions=['yes', 'no', 'maybe'], feedback_callback=callback)
51+
task = client.send_task(topic="mytopic", content="hi", shared=True) # shared mode: ONE task everyone answers together
2552
```
2653

27-
* Send an end-to-end encrypted push notification with actions and a callback function that will print the selected action and times out after 120 seconds:
54+
Both send methods take exactly one keyword-only target: `topic=` on any client,
55+
or `member=` / `broadcast=` on an `OrgClient`. Omit the target on a personal
56+
`Client` to send to your own devices (a self-send, returned as a single `Task` /
57+
`Notification`; encrypted under the account personal password when one is
58+
configured).
59+
60+
Other send options: `auto_commit=False` has the recipient submit the whole
61+
form at once (by default each filled input arrives as an intermediate
62+
`InputEvent`, then the terminal `TaskCompleted` carries the full committed
63+
set); `reply=ReplyMode.STICKY` (or `"one-shot"` / `"one-time-per-user"`) shows
64+
recipients an in-thread reply composer (collect via `replies()`);
65+
`content_format=ContentFormat.MARKDOWN` renders `content` as Markdown;
66+
`critical=True` sends an iOS Critical Alert.
67+
68+
A task can have **subtasks** appended to its chain. A subtask inherits the
69+
parent's recipients and encryption (no target, no password); its `inputs()` /
70+
`replies()` are scoped to it, and stream off the same shared connection:
71+
2872
```python
29-
import simplepush
73+
sub = task.append(title="One more thing", inputs=[TextInput()])
74+
async for ev in sub.inputs():
75+
if isinstance(ev, SubtaskCompleted):
76+
print(ev.uploads)
77+
```
78+
79+
## Inputs
3080

31-
def callback(action_selected, action_selected_at, action_delivered_at, feedback_id):
32-
print(action_selected)
81+
Task inputs: `TextInput`, `ChoiceInput` (set `multi=True`, with optional
82+
`min_selections`/`max_selections`), `ActionsInput` (styled buttons; the tapped
83+
action's stable `key` comes back), `SliderInput` (`min`/`max`/`step`/`unit`),
84+
`PhotoInput`, `VoiceRecordingInput`, `FileUploadInput`, `LocationInput`.
85+
86+
```python
87+
from simplepush import (
88+
Client, Action, ActionStyle, ActionsInput, SliderInput, ChoiceInput, PhotoInput,
89+
TaskCompleted, ActionUpload, SliderUpload, MultiChoiceUpload, PhotoUpload,
90+
)
3391

34-
simplepush.send(key='YourKey', password='password', salt='salt', message='Actionable notification', actions=['yes', 'no', 'maybe'], feedback_callback=callback, feedback_callback_timeout=120)
92+
client = Client(api_token="USER_API_TOKEN")
93+
94+
incident = client.send_task(
95+
topic="ops",
96+
title="Incident 4711",
97+
inputs=[
98+
ActionsInput(actions=[
99+
Action(key="ack", label="Acknowledge", style=ActionStyle.PRIMARY),
100+
Action(key="escalate", label="Escalate", style=ActionStyle.DESTRUCTIVE),
101+
]),
102+
SliderInput(min=0, max=10, step=1, unit="sev"),
103+
ChoiceInput(options=["db", "api", "infra"], multi=True, required=False),
104+
PhotoInput(required=False),
105+
],
106+
)
107+
async for ev in incident.inputs():
108+
if not isinstance(ev.item, TaskCompleted):
109+
continue
110+
for u in ev.item.uploads:
111+
match u:
112+
case ActionUpload(key=key):
113+
print(ev.recipient.name, "pressed", key)
114+
case SliderUpload(value=value):
115+
print("severity", value)
116+
case MultiChoiceUpload(values=values):
117+
print("areas", values)
118+
case PhotoUpload() as photo:
119+
await photo.save("./incident-4711")
35120
```
36121

37-
* Send an end-to-end encrypted push notification with an image and a video file:
122+
Streams accept `timeout=` (seconds of silence before iteration stops; on a
123+
group stream the timeout is group-wide) and `replay=True` (replay the buffered
124+
backlog since the send before going live).
125+
126+
**File downloads.** The binary upload objects (photo/voice/file uploads, a
127+
reply's `photo`/`file`/`audio`, and submission files) are download handles
128+
bound to the client that yielded them: `await x.read()` returns the bytes
129+
(checksum-verified, decrypted on encrypted chains), `await x.save(path)`
130+
writes to disk (a directory uses the file's own name), and
131+
`await x.download_url()` returns the raw short-lived presigned URL plus its
132+
expiry. Failures raise `DownloadError`.
133+
134+
## Sending a notification
135+
136+
A notification is a lighter sibling of a task: it carries a single input
137+
(choice/text/actions only) and has no replies or subtasks.
138+
139+
Like `send_task`, the default is **independent** — every recipient gets their
140+
own notification instance, returned as a `NotificationGroup`:
141+
142+
```python
143+
from simplepush import Client, NotificationChoiceInput, NotificationActionInput, Action, ActionStyle
144+
145+
client = Client(api_token="USER_API_TOKEN")
146+
147+
group = client.send_notification(
148+
topic="mytopic",
149+
title="Build failed",
150+
content="main @ a1b2c3 failed 3 tests",
151+
input=NotificationChoiceInput(options=["ack", "mute"]),
152+
)
153+
note = group.sole # single-recipient topic; iterate the group for many
154+
155+
async for ev in note.inputs():
156+
print(ev.reply) # NotificationTextReply / NotificationChoiceReply / NotificationActionReply
157+
158+
# Action buttons (approve/deny), like a task's ActionsInput — on an encrypted
159+
# send both the `key` and the `label` are sealed, and so is the reported answer:
160+
group = client.send_notification(
161+
topic="mytopic",
162+
title="Deploy v1.2.3?",
163+
input=NotificationActionInput(actions=[
164+
Action(key="approve", label="Approve"),
165+
Action(key="deny", label="Deny", style=ActionStyle.DESTRUCTIVE),
166+
]),
167+
)
168+
169+
# Shared mode: ONE notification all recipients see and answer together (the
170+
# first answer completes it for everyone), returned as a plain `Notification`:
171+
note = client.send_notification(topic="mytopic", content="heads up", shared=True)
172+
```
173+
174+
A notification can also carry ONE media item — `image=` (renders on iOS +
175+
Android) or `audio=` (plays inline on iOS only) — as either an http(s) URL or
176+
a local file path (uploaded, encrypted when the notification is).
177+
178+
## Attachments
179+
180+
`files=` uploads local files alongside a task/subtask (encrypted when the send
181+
is; each file is read fully into memory). A notification takes its single
182+
media item the same way, or as a URL:
183+
184+
```python
185+
client.send_task(
186+
topic="reports",
187+
title="Q3 numbers",
188+
content="Full report attached.",
189+
files=["q3.pdf"],
190+
)
191+
client.send_notification(topic="alerts", title="Door cam", image="https://cam.example/last.jpg")
192+
```
193+
194+
## Organizations
195+
196+
`OrgClient` authenticates with the org `api_key` and addresses sends with
197+
exactly one target: `topic=`, `member=` (by member name), or `broadcast=True`.
198+
Encryption is automatic: pass the org's master key(s) (from your org's
199+
encryption vault; the library can't derive them) and every send is encrypted
200+
under the current key — there are no per-send passwords. Without keys, sends
201+
go out in the clear and org ciphertext is passed through undecrypted.
202+
38203
```python
39-
import simplepush
40-
simplepush.send(key='YourKey', message='Attachments', password='password', salt='salt', attachments=['https://upload.wikimedia.org/wikipedia/commons/e/ee/Sample_abc.jpg', {'video': 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', 'thumbnail': 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/images/ForBiggerEscapes.jpg'}])
41-
```
204+
from simplepush import OrgClient, ChoiceInput
205+
206+
org = OrgClient(
207+
api_key="ORG_API_KEY",
208+
master_key=MASTER_KEY, # 32 bytes (raw or base64)
209+
master_key_version=3, # or several: master_keys={3: key3, 2: key2}
210+
)
211+
212+
group = org.send_task(
213+
broadcast=True,
214+
title="All hands?",
215+
inputs=[ChoiceInput(options=["yes", "no"])],
216+
)
217+
async for ev in group.inputs():
218+
print(ev.recipient.name, ev.item) # recipient = the org member
219+
```
220+
221+
Everything else works as on a personal `Client`: independent-mode groups (the
222+
member name rides on each instance's `recipient`), subtasks, streams,
223+
submissions, downloads.
224+
225+
## Submissions
226+
227+
A **submission** is self-authored user content — a text body plus an optional
228+
photo, file, audio clip, and location — pushed into a user's own stream with
229+
no associated task; a task reply without the task. Submissions are *created*
230+
by the app; the library *observes* them on the client's feed (both `Client`
231+
and `OrgClient`):
232+
233+
```python
234+
async for sub in client.submissions(timeout=300):
235+
# sub: Submission — body / photo / file / audio / location
236+
if sub.photo:
237+
await sub.photo.save("./inbox")
238+
```
239+
240+
`photo`/`file`/`audio` are download handles (`read()` / `save()` /
241+
`download_url()`); `audio` carries `duration_seconds`. `location` is inline
242+
decoded data (latitude, longitude, accuracy, altitude, heading, speed,
243+
timestamp). `timeout=` stops iteration after that many seconds of silence.
244+
245+
Encrypted submissions are decrypted with your **personal password**
246+
(not a topic password). Pass it in `passwords=` (a bare string), or per call:
247+
248+
```python
249+
client = Client(api_token="USER_API_TOKEN", passwords="your-personal-password")
250+
# or: client.submissions(password="your-personal-password")
251+
```
252+
253+
## Streaming events
254+
255+
```python
256+
import asyncio
257+
from simplepush import Client
258+
259+
async def main():
260+
client = Client(api_token="USER_API_TOKEN")
261+
async for event in client.events():
262+
print(event.event_type, event.data)
263+
264+
asyncio.run(main())
265+
```
266+
267+
Every stream on a client shares one WebSocket. Call `await client.aclose()` when
268+
you are done collecting; sends on their own never open it.
269+
270+
## End-to-end encryption
271+
272+
Pass `password=` to encrypt a send's body fields. The returned handle decrypts
273+
the recipient's replies/inputs under the same password.
274+
275+
```python
276+
from simplepush import Client, ReplyMode
277+
278+
# Per-send password (`reply=` so there is a composer to collect from):
279+
client = Client(api_token="USER_API_TOKEN")
280+
group = client.send_task(topic="mytopic", title="Secret", content="🤫",
281+
password="hunter2", reply=ReplyMode.STICKY)
282+
283+
# Or configure a topic's password on the client; sends to it omit `password=`,
284+
# and a per-send password still overrides. The pair's topic must match the
285+
# topic you send to — otherwise nothing matches and the send goes plaintext:
286+
client = Client(api_token="USER_API_TOKEN", passwords=[("hunter2", "mytopic")])
287+
client.send_task(topic="mytopic", content="🤫") # encrypted with "hunter2"
288+
client.send_task(topic="mytopic", content="!", password="x") # overridden for this send
289+
290+
async for reply in group.sole.replies():
291+
print(reply.body) # decrypted
292+
```
293+
294+
To decrypt the raw `events()` feed across many passwords, build a keyring from
295+
the client's configured `(password, topic)` pairs (it also grows with every
296+
send) and apply it per event:
297+
298+
```python
299+
from simplepush import try_decrypt_event_data
300+
301+
client = Client(api_token="USER_API_TOKEN", passwords=[("hunter2", "mytopic"), ("other", "alerts")])
302+
ring = client.keyring()
303+
async for event in client.events():
304+
data = try_decrypt_event_data(event, ring) # decrypted dict, or None if no key matches
305+
```
306+
307+
## License
308+
309+
MIT

0 commit comments

Comments
 (0)