|
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 |
2 | 9 |
|
3 | | -# Installation |
4 | 10 | ```bash |
5 | | -pip3 install simplepush |
| 11 | +pip install simplepush # HTTP + WebSocket only |
| 12 | +pip install 'simplepush[crypto]' # adds end-to-end encryption support |
6 | 13 | ``` |
7 | 14 |
|
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 |
10 | 18 |
|
11 | | -* Send a push notification to the Simplepush key `YourKey`: |
12 | 19 | ```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 |
15 | 33 | ``` |
16 | 34 |
|
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 | + |
18 | 43 | ```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) |
20 | 47 |
|
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 |
23 | 50 |
|
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 |
25 | 52 | ``` |
26 | 53 |
|
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 | + |
28 | 72 | ```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 |
30 | 80 |
|
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 | +) |
33 | 91 |
|
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") |
35 | 120 | ``` |
36 | 121 |
|
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 | + |
38 | 203 | ```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