slim-m 0.1.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.
- slim_m-0.1.0/PKG-INFO +92 -0
- slim_m-0.1.0/README.md +77 -0
- slim_m-0.1.0/pyproject.toml +28 -0
- slim_m-0.1.0/setup.cfg +4 -0
- slim_m-0.1.0/slim_m.egg-info/PKG-INFO +92 -0
- slim_m-0.1.0/slim_m.egg-info/SOURCES.txt +18 -0
- slim_m-0.1.0/slim_m.egg-info/dependency_links.txt +1 -0
- slim_m-0.1.0/slim_m.egg-info/requires.txt +5 -0
- slim_m-0.1.0/slim_m.egg-info/top_level.txt +1 -0
- slim_m-0.1.0/slimbots/__init__.py +29 -0
- slim_m-0.1.0/slimbots/client.py +107 -0
- slim_m-0.1.0/slimbots/cursor.py +65 -0
- slim_m-0.1.0/slimbots/retry.py +40 -0
- slim_m-0.1.0/slimbots/runner.py +38 -0
- slim_m-0.1.0/slimbots/ws.py +55 -0
- slim_m-0.1.0/tests/test_client.py +126 -0
- slim_m-0.1.0/tests/test_cursor.py +82 -0
- slim_m-0.1.0/tests/test_retry.py +140 -0
- slim_m-0.1.0/tests/test_runner.py +69 -0
- slim_m-0.1.0/tests/test_ws.py +102 -0
slim_m-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slim-m
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared plumbing for slim-m bots: auth, REST, retries, websocket reconnect, seq cursors
|
|
5
|
+
License-Expression: LicenseRef-PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/NC1107/slim-m
|
|
7
|
+
Project-URL: Source, https://github.com/NC1107/slim-bots
|
|
8
|
+
Keywords: slim-m,bots,chat
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: websockets>=12
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# slimbots
|
|
17
|
+
|
|
18
|
+
Shared plumbing for slim-m bot templates in this repo.
|
|
19
|
+
|
|
20
|
+
This is not a typed model of slim-m's API. There is no `Channel` class, no
|
|
21
|
+
`Message` object, no cache, no event hierarchy - the kind of thing a
|
|
22
|
+
discord.js-shaped SDK would give you for a few hundred endpoints. slim-m's
|
|
23
|
+
entire bot surface is: log in with a token, call REST, hold one websocket,
|
|
24
|
+
track a per-scope `seq`. That is what this package covers:
|
|
25
|
+
|
|
26
|
+
- authentication (`Authorization: Bearer`, a real `User-Agent`, `GET /me`)
|
|
27
|
+
- a REST helper, `Client.call`, for any route a template needs
|
|
28
|
+
- an idempotent `send` that keeps the message id fixed across a retry, and
|
|
29
|
+
retries only a genuinely uncertain failure - a network error, a 5xx, or a
|
|
30
|
+
429 - never a rejected 4xx
|
|
31
|
+
- the websocket connect and hello handshake (`Connection`)
|
|
32
|
+
- the reconnect loop with exponential backoff, and treating a `401` as
|
|
33
|
+
terminal rather than retryable (`run_forever`)
|
|
34
|
+
- a `seq` cursor in sqlite and the `/sync` catch-up call (`cursor`)
|
|
35
|
+
- refusing to carry a token over plain `ws://` to anything but a loopback
|
|
36
|
+
address (`socket_url`)
|
|
37
|
+
|
|
38
|
+
It deliberately does **not** give you a typed route client, a cache, or an
|
|
39
|
+
event-object hierarchy. See `docs/bots/building-bots.md` in slim-m for the
|
|
40
|
+
protocol this wraps, and `bot-ping/` in this repo for the same protocol
|
|
41
|
+
written out with nothing hidden - it stays free of this package on purpose,
|
|
42
|
+
so there is always one template that shows the whole thing in one file.
|
|
43
|
+
|
|
44
|
+
## Hand-written, not generated
|
|
45
|
+
|
|
46
|
+
slim-m's wire contract is `schema/openapi.yaml`, and
|
|
47
|
+
`crates/slimm-server/tests/openapi_contract.rs` already fails CI on drift, so
|
|
48
|
+
a generated client would stay honest automatically. This package is
|
|
49
|
+
hand-written anyway. What it wraps is not route-shaped: auth, a socket, and
|
|
50
|
+
a retry policy, not a set of typed request/response pairs. A generator
|
|
51
|
+
produces that badly, and slim-m already prefers hand-written DTOs and models
|
|
52
|
+
on both the server and the client for the same reason. If somebody wants a
|
|
53
|
+
fully typed route client later, generating one from `openapi.yaml` is a
|
|
54
|
+
reasonable project - it just is not this one.
|
|
55
|
+
|
|
56
|
+
## Installing
|
|
57
|
+
|
|
58
|
+
The distribution is named `slim-m` on PyPI; the import stays `slimbots`.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
pip install slim-m
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
To run against unreleased changes, install from git instead:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
pip install "slim-m @ git+https://github.com/NC1107/slim-bots.git@main#subdirectory=slimbots"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Each template's `requirements.txt` pins one of those two lines.
|
|
71
|
+
|
|
72
|
+
## Releasing
|
|
73
|
+
|
|
74
|
+
`.github/workflows/publish.yml` builds and uploads on a published GitHub
|
|
75
|
+
release, using PyPI trusted publishing - it exchanges the workflow's own
|
|
76
|
+
OIDC identity for an upload token, so there is no API token stored in this
|
|
77
|
+
repo. The publisher on PyPI must name this repository, `publish.yml`, and
|
|
78
|
+
the `pypi` environment, or the exchange is refused.
|
|
79
|
+
|
|
80
|
+
Bump `version` in `pyproject.toml`, then publish a release.
|
|
81
|
+
|
|
82
|
+
## Tests
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
pip install -e ".[dev]"
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Covers the retry policy (which errors retry, which do not, and that the
|
|
90
|
+
message id and content never change across a retry), the reconnect loop's
|
|
91
|
+
backoff and its terminal handling of a 401, and `socket_url`'s loopback-only
|
|
92
|
+
refusal of plaintext `ws://`.
|
slim_m-0.1.0/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# slimbots
|
|
2
|
+
|
|
3
|
+
Shared plumbing for slim-m bot templates in this repo.
|
|
4
|
+
|
|
5
|
+
This is not a typed model of slim-m's API. There is no `Channel` class, no
|
|
6
|
+
`Message` object, no cache, no event hierarchy - the kind of thing a
|
|
7
|
+
discord.js-shaped SDK would give you for a few hundred endpoints. slim-m's
|
|
8
|
+
entire bot surface is: log in with a token, call REST, hold one websocket,
|
|
9
|
+
track a per-scope `seq`. That is what this package covers:
|
|
10
|
+
|
|
11
|
+
- authentication (`Authorization: Bearer`, a real `User-Agent`, `GET /me`)
|
|
12
|
+
- a REST helper, `Client.call`, for any route a template needs
|
|
13
|
+
- an idempotent `send` that keeps the message id fixed across a retry, and
|
|
14
|
+
retries only a genuinely uncertain failure - a network error, a 5xx, or a
|
|
15
|
+
429 - never a rejected 4xx
|
|
16
|
+
- the websocket connect and hello handshake (`Connection`)
|
|
17
|
+
- the reconnect loop with exponential backoff, and treating a `401` as
|
|
18
|
+
terminal rather than retryable (`run_forever`)
|
|
19
|
+
- a `seq` cursor in sqlite and the `/sync` catch-up call (`cursor`)
|
|
20
|
+
- refusing to carry a token over plain `ws://` to anything but a loopback
|
|
21
|
+
address (`socket_url`)
|
|
22
|
+
|
|
23
|
+
It deliberately does **not** give you a typed route client, a cache, or an
|
|
24
|
+
event-object hierarchy. See `docs/bots/building-bots.md` in slim-m for the
|
|
25
|
+
protocol this wraps, and `bot-ping/` in this repo for the same protocol
|
|
26
|
+
written out with nothing hidden - it stays free of this package on purpose,
|
|
27
|
+
so there is always one template that shows the whole thing in one file.
|
|
28
|
+
|
|
29
|
+
## Hand-written, not generated
|
|
30
|
+
|
|
31
|
+
slim-m's wire contract is `schema/openapi.yaml`, and
|
|
32
|
+
`crates/slimm-server/tests/openapi_contract.rs` already fails CI on drift, so
|
|
33
|
+
a generated client would stay honest automatically. This package is
|
|
34
|
+
hand-written anyway. What it wraps is not route-shaped: auth, a socket, and
|
|
35
|
+
a retry policy, not a set of typed request/response pairs. A generator
|
|
36
|
+
produces that badly, and slim-m already prefers hand-written DTOs and models
|
|
37
|
+
on both the server and the client for the same reason. If somebody wants a
|
|
38
|
+
fully typed route client later, generating one from `openapi.yaml` is a
|
|
39
|
+
reasonable project - it just is not this one.
|
|
40
|
+
|
|
41
|
+
## Installing
|
|
42
|
+
|
|
43
|
+
The distribution is named `slim-m` on PyPI; the import stays `slimbots`.
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
pip install slim-m
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
To run against unreleased changes, install from git instead:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
pip install "slim-m @ git+https://github.com/NC1107/slim-bots.git@main#subdirectory=slimbots"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Each template's `requirements.txt` pins one of those two lines.
|
|
56
|
+
|
|
57
|
+
## Releasing
|
|
58
|
+
|
|
59
|
+
`.github/workflows/publish.yml` builds and uploads on a published GitHub
|
|
60
|
+
release, using PyPI trusted publishing - it exchanges the workflow's own
|
|
61
|
+
OIDC identity for an upload token, so there is no API token stored in this
|
|
62
|
+
repo. The publisher on PyPI must name this repository, `publish.yml`, and
|
|
63
|
+
the `pypi` environment, or the exchange is refused.
|
|
64
|
+
|
|
65
|
+
Bump `version` in `pyproject.toml`, then publish a release.
|
|
66
|
+
|
|
67
|
+
## Tests
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
pip install -e ".[dev]"
|
|
71
|
+
pytest
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Covers the retry policy (which errors retry, which do not, and that the
|
|
75
|
+
message id and content never change across a retry), the reconnect loop's
|
|
76
|
+
backoff and its terminal handling of a 401, and `socket_url`'s loopback-only
|
|
77
|
+
refusal of plaintext `ws://`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "slim-m"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared plumbing for slim-m bots: auth, REST, retries, websocket reconnect, seq cursors"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = "LicenseRef-PolyForm-Noncommercial-1.0.0"
|
|
8
|
+
keywords = ["slim-m", "bots", "chat"]
|
|
9
|
+
|
|
10
|
+
dependencies = ["websockets>=12"]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://github.com/NC1107/slim-m"
|
|
14
|
+
Source = "https://github.com/NC1107/slim-bots"
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest", "pytest-asyncio"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=68"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["."]
|
|
25
|
+
include = ["slimbots*"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
asyncio_mode = "auto"
|
slim_m-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: slim-m
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared plumbing for slim-m bots: auth, REST, retries, websocket reconnect, seq cursors
|
|
5
|
+
License-Expression: LicenseRef-PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/NC1107/slim-m
|
|
7
|
+
Project-URL: Source, https://github.com/NC1107/slim-bots
|
|
8
|
+
Keywords: slim-m,bots,chat
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: websockets>=12
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# slimbots
|
|
17
|
+
|
|
18
|
+
Shared plumbing for slim-m bot templates in this repo.
|
|
19
|
+
|
|
20
|
+
This is not a typed model of slim-m's API. There is no `Channel` class, no
|
|
21
|
+
`Message` object, no cache, no event hierarchy - the kind of thing a
|
|
22
|
+
discord.js-shaped SDK would give you for a few hundred endpoints. slim-m's
|
|
23
|
+
entire bot surface is: log in with a token, call REST, hold one websocket,
|
|
24
|
+
track a per-scope `seq`. That is what this package covers:
|
|
25
|
+
|
|
26
|
+
- authentication (`Authorization: Bearer`, a real `User-Agent`, `GET /me`)
|
|
27
|
+
- a REST helper, `Client.call`, for any route a template needs
|
|
28
|
+
- an idempotent `send` that keeps the message id fixed across a retry, and
|
|
29
|
+
retries only a genuinely uncertain failure - a network error, a 5xx, or a
|
|
30
|
+
429 - never a rejected 4xx
|
|
31
|
+
- the websocket connect and hello handshake (`Connection`)
|
|
32
|
+
- the reconnect loop with exponential backoff, and treating a `401` as
|
|
33
|
+
terminal rather than retryable (`run_forever`)
|
|
34
|
+
- a `seq` cursor in sqlite and the `/sync` catch-up call (`cursor`)
|
|
35
|
+
- refusing to carry a token over plain `ws://` to anything but a loopback
|
|
36
|
+
address (`socket_url`)
|
|
37
|
+
|
|
38
|
+
It deliberately does **not** give you a typed route client, a cache, or an
|
|
39
|
+
event-object hierarchy. See `docs/bots/building-bots.md` in slim-m for the
|
|
40
|
+
protocol this wraps, and `bot-ping/` in this repo for the same protocol
|
|
41
|
+
written out with nothing hidden - it stays free of this package on purpose,
|
|
42
|
+
so there is always one template that shows the whole thing in one file.
|
|
43
|
+
|
|
44
|
+
## Hand-written, not generated
|
|
45
|
+
|
|
46
|
+
slim-m's wire contract is `schema/openapi.yaml`, and
|
|
47
|
+
`crates/slimm-server/tests/openapi_contract.rs` already fails CI on drift, so
|
|
48
|
+
a generated client would stay honest automatically. This package is
|
|
49
|
+
hand-written anyway. What it wraps is not route-shaped: auth, a socket, and
|
|
50
|
+
a retry policy, not a set of typed request/response pairs. A generator
|
|
51
|
+
produces that badly, and slim-m already prefers hand-written DTOs and models
|
|
52
|
+
on both the server and the client for the same reason. If somebody wants a
|
|
53
|
+
fully typed route client later, generating one from `openapi.yaml` is a
|
|
54
|
+
reasonable project - it just is not this one.
|
|
55
|
+
|
|
56
|
+
## Installing
|
|
57
|
+
|
|
58
|
+
The distribution is named `slim-m` on PyPI; the import stays `slimbots`.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
pip install slim-m
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
To run against unreleased changes, install from git instead:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
pip install "slim-m @ git+https://github.com/NC1107/slim-bots.git@main#subdirectory=slimbots"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Each template's `requirements.txt` pins one of those two lines.
|
|
71
|
+
|
|
72
|
+
## Releasing
|
|
73
|
+
|
|
74
|
+
`.github/workflows/publish.yml` builds and uploads on a published GitHub
|
|
75
|
+
release, using PyPI trusted publishing - it exchanges the workflow's own
|
|
76
|
+
OIDC identity for an upload token, so there is no API token stored in this
|
|
77
|
+
repo. The publisher on PyPI must name this repository, `publish.yml`, and
|
|
78
|
+
the `pypi` environment, or the exchange is refused.
|
|
79
|
+
|
|
80
|
+
Bump `version` in `pyproject.toml`, then publish a release.
|
|
81
|
+
|
|
82
|
+
## Tests
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
pip install -e ".[dev]"
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Covers the retry policy (which errors retry, which do not, and that the
|
|
90
|
+
message id and content never change across a retry), the reconnect loop's
|
|
91
|
+
backoff and its terminal handling of a 401, and `socket_url`'s loopback-only
|
|
92
|
+
refusal of plaintext `ws://`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
slim_m.egg-info/PKG-INFO
|
|
4
|
+
slim_m.egg-info/SOURCES.txt
|
|
5
|
+
slim_m.egg-info/dependency_links.txt
|
|
6
|
+
slim_m.egg-info/requires.txt
|
|
7
|
+
slim_m.egg-info/top_level.txt
|
|
8
|
+
slimbots/__init__.py
|
|
9
|
+
slimbots/client.py
|
|
10
|
+
slimbots/cursor.py
|
|
11
|
+
slimbots/retry.py
|
|
12
|
+
slimbots/runner.py
|
|
13
|
+
slimbots/ws.py
|
|
14
|
+
tests/test_client.py
|
|
15
|
+
tests/test_cursor.py
|
|
16
|
+
tests/test_retry.py
|
|
17
|
+
tests/test_runner.py
|
|
18
|
+
tests/test_ws.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
slimbots
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Shared plumbing for slim-m bot templates.
|
|
2
|
+
|
|
3
|
+
This is not a client library for slim-m's API in the discord.js sense: there
|
|
4
|
+
is no typed model of a channel or a message, no cache, and no object for
|
|
5
|
+
every route. slim-m's bot surface is small - log in with a token, call REST,
|
|
6
|
+
hold one websocket, track a per-scope `seq` - and that surface is what this
|
|
7
|
+
package covers, nothing more.
|
|
8
|
+
|
|
9
|
+
See `docs/bots/building-bots.md` in the slim-m repo for the protocol itself.
|
|
10
|
+
`bot-ping` in this repo stays free of this package on purpose, so there is
|
|
11
|
+
still one file that shows the whole protocol with nothing hidden.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from . import cursor
|
|
15
|
+
from .client import Client, is_not_found, is_token_revoked, socket_url
|
|
16
|
+
from .retry import call_with_retry
|
|
17
|
+
from .runner import run_forever
|
|
18
|
+
from .ws import Connection
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Client",
|
|
22
|
+
"Connection",
|
|
23
|
+
"call_with_retry",
|
|
24
|
+
"cursor",
|
|
25
|
+
"is_not_found",
|
|
26
|
+
"is_token_revoked",
|
|
27
|
+
"run_forever",
|
|
28
|
+
"socket_url",
|
|
29
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The REST side of a bot: one authenticated call, and an idempotent send."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from .retry import call_with_retry
|
|
12
|
+
|
|
13
|
+
LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def socket_url(base):
|
|
17
|
+
"""The WebSocket URL for `base`, refusing to carry a token in plaintext.
|
|
18
|
+
|
|
19
|
+
An https deployment becomes wss. Plain http is allowed only for a
|
|
20
|
+
loopback address, because that is a developer running a server on their
|
|
21
|
+
own machine; anywhere else it would put a long-lived bot token on the
|
|
22
|
+
wire in the clear, and a token is the one thing a bot cannot afford to
|
|
23
|
+
leak.
|
|
24
|
+
"""
|
|
25
|
+
parts = urllib.parse.urlsplit(base)
|
|
26
|
+
if parts.scheme == "https":
|
|
27
|
+
return urllib.parse.urlunsplit(("wss", parts.netloc, "/ws", "", ""))
|
|
28
|
+
if parts.scheme == "http" and parts.hostname in LOOPBACK_HOSTS:
|
|
29
|
+
print("warning: plaintext ws, loopback only", file=sys.stderr)
|
|
30
|
+
return urllib.parse.urlunsplit(("ws", parts.netloc, "/ws", "", ""))
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
f"refusing to send a bot token over {parts.scheme or 'no'} scheme to "
|
|
33
|
+
f"{parts.hostname or base}; use https"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Client:
|
|
38
|
+
"""An authenticated slim-m REST client for one bot token.
|
|
39
|
+
|
|
40
|
+
`call` is the general-purpose escape hatch: it makes no assumptions
|
|
41
|
+
about the route, so a template reaches for it directly for anything this
|
|
42
|
+
class does not name explicitly - `/sync`, canvas routes, `/users/{id}`,
|
|
43
|
+
whatever the bot needs. `send`, `me` and `ws_ticket` exist only because
|
|
44
|
+
every template calls them, byte for byte.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, base, token, user_agent):
|
|
48
|
+
if not base or not token:
|
|
49
|
+
raise ValueError("a Client needs both a base URL and a token")
|
|
50
|
+
self.base = base.rstrip("/")
|
|
51
|
+
self.token = token
|
|
52
|
+
self.user_agent = user_agent
|
|
53
|
+
|
|
54
|
+
def call(self, method, path, body=None):
|
|
55
|
+
"""One authenticated REST call, returning parsed JSON or None for 204."""
|
|
56
|
+
data = json.dumps(body).encode() if body is not None else None
|
|
57
|
+
request = urllib.request.Request(f"{self.base}{path}", data=data, method=method)
|
|
58
|
+
request.add_header("authorization", f"Bearer {self.token}")
|
|
59
|
+
# A CDN can reject urllib's default UA; see docs/bots/building-bots.md.
|
|
60
|
+
request.add_header("user-agent", self.user_agent)
|
|
61
|
+
if data is not None:
|
|
62
|
+
request.add_header("content-type", "application/json")
|
|
63
|
+
with urllib.request.urlopen(request, timeout=15) as response:
|
|
64
|
+
raw = response.read()
|
|
65
|
+
return json.loads(raw) if raw else None
|
|
66
|
+
|
|
67
|
+
def me(self):
|
|
68
|
+
return self.call("GET", "/me")
|
|
69
|
+
|
|
70
|
+
def ws_ticket(self):
|
|
71
|
+
return self.call("POST", "/auth/ws-ticket")["ticket"]
|
|
72
|
+
|
|
73
|
+
def socket_url(self):
|
|
74
|
+
return socket_url(self.base)
|
|
75
|
+
|
|
76
|
+
def send(self, channel_id, content, *, message_id=None, reply_to_id=None, retries=5, sleep=time.sleep):
|
|
77
|
+
"""Posts a message, retrying only a genuinely uncertain failure.
|
|
78
|
+
|
|
79
|
+
`message_id` is generated once, before any retry, and never
|
|
80
|
+
regenerated between attempts: that is what makes a retried send safe
|
|
81
|
+
rather than a second message. Never vary `content` across calls
|
|
82
|
+
sharing one `message_id` - the server replays what it first stored,
|
|
83
|
+
whatever a later attempt carries, and this method has no way to catch
|
|
84
|
+
a caller doing that. `sleep` exists for tests; a real caller never
|
|
85
|
+
needs it.
|
|
86
|
+
"""
|
|
87
|
+
body = {"id": message_id or str(uuid.uuid4()), "content": content}
|
|
88
|
+
if reply_to_id:
|
|
89
|
+
body["reply_to_id"] = reply_to_id
|
|
90
|
+
return call_with_retry(
|
|
91
|
+
lambda: self.call("POST", f"/channels/{channel_id}/messages", body),
|
|
92
|
+
retries=retries,
|
|
93
|
+
sleep=sleep,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_token_revoked(err):
|
|
98
|
+
"""Whether `err` is the terminal 401 that means the token was revoked.
|
|
99
|
+
|
|
100
|
+
A 401 is never retried: the credential is gone, not the network. See
|
|
101
|
+
"When a token is revoked" in docs/bots/building-bots.md.
|
|
102
|
+
"""
|
|
103
|
+
return isinstance(err, urllib.error.HTTPError) and err.code == 401
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def is_not_found(err):
|
|
107
|
+
return isinstance(err, urllib.error.HTTPError) and err.code == 404
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""A per-channel `seq` cursor in sqlite, and the `/sync` catch-up call.
|
|
2
|
+
|
|
3
|
+
This is storage and transport only. What a template does with the messages
|
|
4
|
+
`/sync` hands back - which triggers fire, what state they update - stays in
|
|
5
|
+
the template, because that is the part that differs bot to bot.
|
|
6
|
+
|
|
7
|
+
A bot that deliberately keeps its cursor in memory instead (see bot-roles's
|
|
8
|
+
README on why a command-driven bot can accept losing that on restart) has no
|
|
9
|
+
reason to reach for this module at all; a plain variable is simpler and nothing
|
|
10
|
+
here is missed by skipping it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init_table(conn, table="cursors"):
|
|
15
|
+
conn.execute(
|
|
16
|
+
f"CREATE TABLE IF NOT EXISTS {table} "
|
|
17
|
+
"(channel_id TEXT PRIMARY KEY, after_seq INTEGER NOT NULL)"
|
|
18
|
+
)
|
|
19
|
+
conn.commit()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get(conn, channel_id, table="cursors"):
|
|
23
|
+
row = conn.execute(
|
|
24
|
+
f"SELECT after_seq FROM {table} WHERE channel_id = ?", (channel_id,)
|
|
25
|
+
).fetchone()
|
|
26
|
+
return row[0] if row else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set(conn, channel_id, seq, table="cursors"):
|
|
30
|
+
"""Advances the stored cursor to `seq`, never backwards.
|
|
31
|
+
|
|
32
|
+
A frame handled twice (once live, once again via `/sync` after a
|
|
33
|
+
reconnect that turned out not to have dropped anything) must not move
|
|
34
|
+
the cursor earlier than where it already was.
|
|
35
|
+
"""
|
|
36
|
+
conn.execute(
|
|
37
|
+
f"INSERT INTO {table} (channel_id, after_seq) VALUES (?, ?) "
|
|
38
|
+
f"ON CONFLICT(channel_id) DO UPDATE SET after_seq = excluded.after_seq "
|
|
39
|
+
f"WHERE excluded.after_seq > after_seq",
|
|
40
|
+
(channel_id, seq),
|
|
41
|
+
)
|
|
42
|
+
conn.commit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def bootstrap(client, conn, channel_id, table="cursors"):
|
|
46
|
+
"""A channel this process has never watched starts at its current head,
|
|
47
|
+
not at the beginning of history - otherwise a bot's first minute would
|
|
48
|
+
be spent re-triggering years of old messages."""
|
|
49
|
+
if get(conn, channel_id, table) is not None:
|
|
50
|
+
return
|
|
51
|
+
latest = client.call("GET", f"/channels/{channel_id}/messages?limit=1")
|
|
52
|
+
set(conn, channel_id, latest[0]["seq"] if latest else 0, table)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sync(client, scopes):
|
|
56
|
+
"""POSTs `/sync` for `scopes` (a list of `{"channel_id", "after_seq"}`)
|
|
57
|
+
and returns the response's own `scopes` list, one entry per input scope,
|
|
58
|
+
each carrying `messages` and a `reset` flag.
|
|
59
|
+
|
|
60
|
+
`reset: true` means the gap was too large for `/sync` to answer and the
|
|
61
|
+
cursor should be re-baselined (typically via `bootstrap`) rather than
|
|
62
|
+
trusted to resume exactly where it left off - the same tradeoff slim-m's
|
|
63
|
+
own reactions and pins accept.
|
|
64
|
+
"""
|
|
65
|
+
return client.call("POST", "/sync", {"scopes": scopes})["scopes"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Retrying a REST call, but only when the outcome is genuinely uncertain.
|
|
2
|
+
|
|
3
|
+
A rejected request (a 4xx other than 429) is a certain outcome: the server
|
|
4
|
+
looked at it and said no, and sending the same thing again gets the same no.
|
|
5
|
+
Retrying that would be wrong, not just wasteful. What is worth retrying is a
|
|
6
|
+
call whose outcome we never actually learned: a network error before any
|
|
7
|
+
response came back, a 5xx (the server broke, not the request), or a 429
|
|
8
|
+
(rate limited). slim-m's plain rate limit carries no `Retry-After`, so a
|
|
9
|
+
fixed or exponential backoff is the only option - there is nothing to read
|
|
10
|
+
that would do better.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def is_retryable(err):
|
|
18
|
+
"""Whether `err`, raised by `urllib.request.urlopen`, is worth another try."""
|
|
19
|
+
if isinstance(err, urllib.error.HTTPError):
|
|
20
|
+
return err.code == 429 or err.code >= 500
|
|
21
|
+
# A URLError with no `code` attribute never reached a server at all.
|
|
22
|
+
return isinstance(err, urllib.error.URLError)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def call_with_retry(fn, *, retries=5, base_delay=0.5, max_delay=8.0, sleep=time.sleep):
|
|
26
|
+
"""Calls `fn()`, retrying up to `retries` times on a retryable failure.
|
|
27
|
+
|
|
28
|
+
Backoff doubles from `base_delay`, capped at `max_delay`. `fn` must be
|
|
29
|
+
safe to call more than once with the same effect - true of any call
|
|
30
|
+
built on an idempotent id, and never true of a plain uuid4() per call.
|
|
31
|
+
"""
|
|
32
|
+
attempt = 0
|
|
33
|
+
while True:
|
|
34
|
+
try:
|
|
35
|
+
return fn()
|
|
36
|
+
except (urllib.error.HTTPError, urllib.error.URLError) as err:
|
|
37
|
+
if attempt >= retries or not is_retryable(err):
|
|
38
|
+
raise
|
|
39
|
+
sleep(min(base_delay * (2**attempt), max_delay))
|
|
40
|
+
attempt += 1
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The reconnect loop every template but bot-ping needs: exponential backoff,
|
|
2
|
+
reset on a connection that actually completes its hello handshake, and a
|
|
3
|
+
revoked token (401) as the one failure that stops the loop instead of
|
|
4
|
+
retrying it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from .client import is_token_revoked
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run_forever(attempt, *, base_delay=1.0, max_delay=60.0):
|
|
14
|
+
"""Calls `await attempt(reset_delay)` forever, backing off between
|
|
15
|
+
failures.
|
|
16
|
+
|
|
17
|
+
`attempt` does one connection's worth of work - typically resync, open a
|
|
18
|
+
`Connection`, call `reset_delay()` once hello succeeds, then read frames
|
|
19
|
+
until the socket drops or errors. Returns 1 once the token is found to be
|
|
20
|
+
revoked, so `main()` can propagate it as the process exit code; runs
|
|
21
|
+
until cancelled otherwise.
|
|
22
|
+
"""
|
|
23
|
+
delay = base_delay
|
|
24
|
+
|
|
25
|
+
def reset_delay():
|
|
26
|
+
nonlocal delay
|
|
27
|
+
delay = base_delay
|
|
28
|
+
|
|
29
|
+
while True:
|
|
30
|
+
try:
|
|
31
|
+
await attempt(reset_delay)
|
|
32
|
+
except Exception as err:
|
|
33
|
+
if is_token_revoked(err):
|
|
34
|
+
print("token rejected - revoked?", file=sys.stderr)
|
|
35
|
+
return 1
|
|
36
|
+
print(f"{type(err).__name__}: {err}, retrying in {delay}s", file=sys.stderr)
|
|
37
|
+
await asyncio.sleep(delay)
|
|
38
|
+
delay = min(delay * 2, max_delay)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""The websocket side of a bot: mint a ticket, say hello, read frames.
|
|
2
|
+
|
|
3
|
+
`Connection` is deliberately thin. It does the hello handshake once and then
|
|
4
|
+
gets out of the way - a frame this bot's caller does not recognise is that
|
|
5
|
+
caller's job to ignore, not this module's, because a new event type must not
|
|
6
|
+
break a bot that has never heard of it (see docs/bots/building-bots.md).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
import websockets
|
|
12
|
+
|
|
13
|
+
PROTOCOL = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Connection:
|
|
17
|
+
"""An open, hello-shaken slim-m websocket connection.
|
|
18
|
+
|
|
19
|
+
Use it as an async context manager:
|
|
20
|
+
|
|
21
|
+
async with await Connection.open(client) as conn:
|
|
22
|
+
async for frame in conn.frames():
|
|
23
|
+
...
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, socket):
|
|
27
|
+
self._socket = socket
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
async def open(cls, client, *, protocol=PROTOCOL):
|
|
31
|
+
ticket = client.ws_ticket()
|
|
32
|
+
socket = await websockets.connect(client.socket_url(), user_agent_header=client.user_agent)
|
|
33
|
+
try:
|
|
34
|
+
await socket.send(json.dumps({"type": "hello", "ticket": ticket, "protocol": protocol}))
|
|
35
|
+
hello = json.loads(await socket.recv())
|
|
36
|
+
if hello.get("type") != "hello":
|
|
37
|
+
raise RuntimeError(f"expected a hello back, got {hello}")
|
|
38
|
+
except BaseException:
|
|
39
|
+
await socket.close()
|
|
40
|
+
raise
|
|
41
|
+
return cls(socket)
|
|
42
|
+
|
|
43
|
+
async def frames(self):
|
|
44
|
+
"""Yields each frame as parsed JSON, in arrival order."""
|
|
45
|
+
async for raw in self._socket:
|
|
46
|
+
yield json.loads(raw)
|
|
47
|
+
|
|
48
|
+
async def close(self):
|
|
49
|
+
await self._socket.close()
|
|
50
|
+
|
|
51
|
+
async def __aenter__(self):
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
55
|
+
await self.close()
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.error
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from slimbots.client import Client, is_not_found, is_token_revoked, socket_url
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_https_becomes_wss():
|
|
10
|
+
assert socket_url("https://my.space") == "wss://my.space/ws"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.mark.parametrize(
|
|
14
|
+
"authority", ["localhost:8080", "127.0.0.1:8080", "[::1]:8080"]
|
|
15
|
+
)
|
|
16
|
+
def test_http_loopback_allowed(authority, capsys):
|
|
17
|
+
url = socket_url(f"http://{authority}")
|
|
18
|
+
assert url == f"ws://{authority}/ws"
|
|
19
|
+
assert "loopback only" in capsys.readouterr().err
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_http_off_loopback_refused():
|
|
23
|
+
with pytest.raises(RuntimeError, match="https"):
|
|
24
|
+
socket_url("http://example.com")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_unknown_scheme_refused():
|
|
28
|
+
with pytest.raises(RuntimeError):
|
|
29
|
+
socket_url("ftp://example.com")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_client_requires_base_and_token():
|
|
33
|
+
with pytest.raises(ValueError):
|
|
34
|
+
Client("", "token", "ua")
|
|
35
|
+
with pytest.raises(ValueError):
|
|
36
|
+
Client("https://x", "", "ua")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FakeResponse:
|
|
40
|
+
def __init__(self, payload):
|
|
41
|
+
self._raw = json.dumps(payload).encode() if payload is not None else b""
|
|
42
|
+
|
|
43
|
+
def read(self):
|
|
44
|
+
return self._raw
|
|
45
|
+
|
|
46
|
+
def __enter__(self):
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *exc):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_call_sends_auth_and_user_agent(monkeypatch):
|
|
54
|
+
seen = {}
|
|
55
|
+
|
|
56
|
+
def fake_urlopen(request, timeout):
|
|
57
|
+
seen["headers"] = dict(request.header_items())
|
|
58
|
+
seen["url"] = request.full_url
|
|
59
|
+
seen["method"] = request.get_method()
|
|
60
|
+
return FakeResponse({"ok": True})
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr("slimbots.client.urllib.request.urlopen", fake_urlopen)
|
|
63
|
+
client = Client("https://my.space", "slimbot_abc", "slimm-bot-test/1.0")
|
|
64
|
+
result = client.call("GET", "/me")
|
|
65
|
+
|
|
66
|
+
assert result == {"ok": True}
|
|
67
|
+
assert seen["headers"]["Authorization"] == "Bearer slimbot_abc"
|
|
68
|
+
assert seen["headers"]["User-agent"] == "slimm-bot-test/1.0"
|
|
69
|
+
assert seen["url"] == "https://my.space/me"
|
|
70
|
+
assert seen["method"] == "GET"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_send_keeps_id_fixed_across_a_retry(monkeypatch):
|
|
74
|
+
bodies = []
|
|
75
|
+
attempt = {"n": 0}
|
|
76
|
+
|
|
77
|
+
def fake_urlopen(request, timeout):
|
|
78
|
+
attempt["n"] += 1
|
|
79
|
+
bodies.append(json.loads(request.data))
|
|
80
|
+
if attempt["n"] < 3:
|
|
81
|
+
raise urllib.error.HTTPError(request.full_url, 503, "busy", {}, None)
|
|
82
|
+
return FakeResponse({"id": bodies[-1]["id"]})
|
|
83
|
+
|
|
84
|
+
monkeypatch.setattr("slimbots.client.urllib.request.urlopen", fake_urlopen)
|
|
85
|
+
client = Client("https://my.space", "slimbot_abc", "ua")
|
|
86
|
+
client.send("chan-1", "pong", sleep=lambda _: None)
|
|
87
|
+
|
|
88
|
+
assert attempt["n"] == 3
|
|
89
|
+
ids = {b["id"] for b in bodies}
|
|
90
|
+
contents = {b["content"] for b in bodies}
|
|
91
|
+
assert len(ids) == 1
|
|
92
|
+
assert len(contents) == 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_send_never_retries_a_rejected_4xx(monkeypatch):
|
|
96
|
+
attempt = {"n": 0}
|
|
97
|
+
|
|
98
|
+
def fake_urlopen(request, timeout):
|
|
99
|
+
attempt["n"] += 1
|
|
100
|
+
raise urllib.error.HTTPError(request.full_url, 422, "bad", {}, None)
|
|
101
|
+
|
|
102
|
+
monkeypatch.setattr("slimbots.client.urllib.request.urlopen", fake_urlopen)
|
|
103
|
+
client = Client("https://my.space", "slimbot_abc", "ua")
|
|
104
|
+
with pytest.raises(urllib.error.HTTPError):
|
|
105
|
+
client.send("chan-1", "pong")
|
|
106
|
+
assert attempt["n"] == 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_send_uses_given_message_id():
|
|
110
|
+
# This checks the body assembled, so it stubs call rather than the network.
|
|
111
|
+
client = Client("https://my.space", "slimbot_abc", "ua")
|
|
112
|
+
calls = []
|
|
113
|
+
client.call = lambda method, path, body=None: calls.append(body) or {"ok": True}
|
|
114
|
+
client.send("chan-1", "hi", message_id="fixed-id")
|
|
115
|
+
assert calls[0]["id"] == "fixed-id"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_is_token_revoked():
|
|
119
|
+
assert is_token_revoked(urllib.error.HTTPError("u", 401, "e", {}, None))
|
|
120
|
+
assert not is_token_revoked(urllib.error.HTTPError("u", 403, "e", {}, None))
|
|
121
|
+
assert not is_token_revoked(urllib.error.URLError("boom"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_is_not_found():
|
|
125
|
+
assert is_not_found(urllib.error.HTTPError("u", 404, "e", {}, None))
|
|
126
|
+
assert not is_not_found(urllib.error.HTTPError("u", 401, "e", {}, None))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from slimbots import cursor
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def conn():
|
|
10
|
+
connection = sqlite3.connect(":memory:")
|
|
11
|
+
cursor.init_table(connection)
|
|
12
|
+
yield connection
|
|
13
|
+
connection.close()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_get_is_none_before_anything_is_set(conn):
|
|
17
|
+
assert cursor.get(conn, "chan-1") is None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_set_then_get(conn):
|
|
21
|
+
cursor.set(conn, "chan-1", 10)
|
|
22
|
+
assert cursor.get(conn, "chan-1") == 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_set_never_moves_backwards(conn):
|
|
26
|
+
cursor.set(conn, "chan-1", 10)
|
|
27
|
+
cursor.set(conn, "chan-1", 5)
|
|
28
|
+
assert cursor.get(conn, "chan-1") == 10
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_set_advances_forward(conn):
|
|
32
|
+
cursor.set(conn, "chan-1", 10)
|
|
33
|
+
cursor.set(conn, "chan-1", 20)
|
|
34
|
+
assert cursor.get(conn, "chan-1") == 20
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_channels_are_independent(conn):
|
|
38
|
+
cursor.set(conn, "chan-1", 10)
|
|
39
|
+
cursor.set(conn, "chan-2", 3)
|
|
40
|
+
assert cursor.get(conn, "chan-1") == 10
|
|
41
|
+
assert cursor.get(conn, "chan-2") == 3
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FakeClient:
|
|
45
|
+
def __init__(self, latest_seq):
|
|
46
|
+
self.calls = []
|
|
47
|
+
self._latest_seq = latest_seq
|
|
48
|
+
|
|
49
|
+
def call(self, method, path, body=None):
|
|
50
|
+
self.calls.append((method, path, body))
|
|
51
|
+
if self._latest_seq is None:
|
|
52
|
+
return []
|
|
53
|
+
return [{"seq": self._latest_seq}]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_bootstrap_baselines_at_current_head_on_first_run(conn):
|
|
57
|
+
client = FakeClient(latest_seq=42)
|
|
58
|
+
cursor.bootstrap(client, conn, "chan-1")
|
|
59
|
+
assert cursor.get(conn, "chan-1") == 42
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_bootstrap_on_an_empty_channel_starts_at_zero(conn):
|
|
63
|
+
client = FakeClient(latest_seq=None)
|
|
64
|
+
cursor.bootstrap(client, conn, "chan-1")
|
|
65
|
+
assert cursor.get(conn, "chan-1") == 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_bootstrap_is_a_no_op_once_a_cursor_exists(conn):
|
|
69
|
+
cursor.set(conn, "chan-1", 7)
|
|
70
|
+
client = FakeClient(latest_seq=999)
|
|
71
|
+
cursor.bootstrap(client, conn, "chan-1")
|
|
72
|
+
assert cursor.get(conn, "chan-1") == 7
|
|
73
|
+
assert client.calls == []
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_sync_posts_scopes_and_returns_the_response_scopes():
|
|
77
|
+
client = FakeClient(latest_seq=None)
|
|
78
|
+
client.call = lambda method, path, body=None: {
|
|
79
|
+
"scopes": [{"channel_id": "chan-1", "messages": [], "reset": False}]
|
|
80
|
+
}
|
|
81
|
+
result = cursor.sync(client, [{"channel_id": "chan-1", "after_seq": 5}])
|
|
82
|
+
assert result == [{"channel_id": "chan-1", "messages": [], "reset": False}]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import urllib.error
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from slimbots.retry import call_with_retry, is_retryable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def http_error(code):
|
|
9
|
+
return urllib.error.HTTPError("http://x", code, "err", {}, None)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.parametrize("code", [429, 500, 502, 503, 599])
|
|
13
|
+
def test_retryable_codes(code):
|
|
14
|
+
assert is_retryable(http_error(code))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.parametrize("code", [400, 401, 403, 404, 409, 422])
|
|
18
|
+
def test_non_retryable_4xx(code):
|
|
19
|
+
assert not is_retryable(http_error(code))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_network_error_is_retryable():
|
|
23
|
+
assert is_retryable(urllib.error.URLError("connection refused"))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_succeeds_without_retry():
|
|
27
|
+
calls = []
|
|
28
|
+
|
|
29
|
+
def fn():
|
|
30
|
+
calls.append(1)
|
|
31
|
+
return "ok"
|
|
32
|
+
|
|
33
|
+
assert call_with_retry(fn, sleep=lambda _: None) == "ok"
|
|
34
|
+
assert len(calls) == 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_retries_a_5xx_then_succeeds():
|
|
38
|
+
attempts = []
|
|
39
|
+
|
|
40
|
+
def fn():
|
|
41
|
+
attempts.append(1)
|
|
42
|
+
if len(attempts) < 3:
|
|
43
|
+
raise http_error(503)
|
|
44
|
+
return "ok"
|
|
45
|
+
|
|
46
|
+
result = call_with_retry(fn, retries=5, sleep=lambda _: None)
|
|
47
|
+
assert result == "ok"
|
|
48
|
+
assert len(attempts) == 3
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_retries_a_429_then_succeeds():
|
|
52
|
+
attempts = []
|
|
53
|
+
|
|
54
|
+
def fn():
|
|
55
|
+
attempts.append(1)
|
|
56
|
+
if len(attempts) < 2:
|
|
57
|
+
raise http_error(429)
|
|
58
|
+
return "ok"
|
|
59
|
+
|
|
60
|
+
assert call_with_retry(fn, sleep=lambda _: None) == "ok"
|
|
61
|
+
assert len(attempts) == 2
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_retries_a_network_error():
|
|
65
|
+
attempts = []
|
|
66
|
+
|
|
67
|
+
def fn():
|
|
68
|
+
attempts.append(1)
|
|
69
|
+
if len(attempts) < 2:
|
|
70
|
+
raise urllib.error.URLError("boom")
|
|
71
|
+
return "ok"
|
|
72
|
+
|
|
73
|
+
assert call_with_retry(fn, sleep=lambda _: None) == "ok"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_never_retries_a_rejected_4xx():
|
|
77
|
+
attempts = []
|
|
78
|
+
|
|
79
|
+
def fn():
|
|
80
|
+
attempts.append(1)
|
|
81
|
+
raise http_error(400)
|
|
82
|
+
|
|
83
|
+
with pytest.raises(urllib.error.HTTPError):
|
|
84
|
+
call_with_retry(fn, sleep=lambda _: None)
|
|
85
|
+
assert len(attempts) == 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_never_retries_a_401():
|
|
89
|
+
attempts = []
|
|
90
|
+
|
|
91
|
+
def fn():
|
|
92
|
+
attempts.append(1)
|
|
93
|
+
raise http_error(401)
|
|
94
|
+
|
|
95
|
+
with pytest.raises(urllib.error.HTTPError):
|
|
96
|
+
call_with_retry(fn, sleep=lambda _: None)
|
|
97
|
+
assert len(attempts) == 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_gives_up_after_retries_exhausted():
|
|
101
|
+
attempts = []
|
|
102
|
+
|
|
103
|
+
def fn():
|
|
104
|
+
attempts.append(1)
|
|
105
|
+
raise http_error(500)
|
|
106
|
+
|
|
107
|
+
with pytest.raises(urllib.error.HTTPError):
|
|
108
|
+
call_with_retry(fn, retries=3, sleep=lambda _: None)
|
|
109
|
+
# the first attempt plus 3 retries
|
|
110
|
+
assert len(attempts) == 4
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_backoff_doubles_and_caps():
|
|
114
|
+
delays = []
|
|
115
|
+
|
|
116
|
+
def fn():
|
|
117
|
+
raise http_error(500)
|
|
118
|
+
|
|
119
|
+
with pytest.raises(urllib.error.HTTPError):
|
|
120
|
+
call_with_retry(
|
|
121
|
+
fn, retries=4, base_delay=1.0, max_delay=4.0, sleep=delays.append
|
|
122
|
+
)
|
|
123
|
+
assert delays == [1.0, 2.0, 4.0, 4.0]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_retry_never_needs_a_new_id_or_content():
|
|
127
|
+
"""The id and content a retried call sends are fixed by the caller before
|
|
128
|
+
call_with_retry ever runs; this asserts the same closure - and therefore
|
|
129
|
+
the same body - is invoked on every attempt."""
|
|
130
|
+
bodies_seen = []
|
|
131
|
+
|
|
132
|
+
def fn():
|
|
133
|
+
# A real caller closes over one fixed body; we simulate that here.
|
|
134
|
+
bodies_seen.append({"id": "fixed-id", "content": "fixed content"})
|
|
135
|
+
if len(bodies_seen) < 3:
|
|
136
|
+
raise http_error(500)
|
|
137
|
+
return bodies_seen[-1]
|
|
138
|
+
|
|
139
|
+
call_with_retry(fn, sleep=lambda _: None)
|
|
140
|
+
assert len({(b["id"], b["content"]) for b in bodies_seen}) == 1
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import urllib.error
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from slimbots.runner import run_forever
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.asyncio
|
|
9
|
+
async def test_stops_on_a_revoked_token(monkeypatch):
|
|
10
|
+
async def fake_sleep(_):
|
|
11
|
+
pytest.fail("should not sleep after a terminal 401")
|
|
12
|
+
|
|
13
|
+
monkeypatch.setattr("slimbots.runner.asyncio.sleep", fake_sleep)
|
|
14
|
+
|
|
15
|
+
async def attempt(reset_delay):
|
|
16
|
+
raise urllib.error.HTTPError("u", 401, "e", {}, None)
|
|
17
|
+
|
|
18
|
+
assert await run_forever(attempt) == 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.asyncio
|
|
22
|
+
async def test_retries_other_failures_with_backoff(monkeypatch):
|
|
23
|
+
delays = []
|
|
24
|
+
|
|
25
|
+
async def fake_sleep(seconds):
|
|
26
|
+
delays.append(seconds)
|
|
27
|
+
if len(delays) >= 3:
|
|
28
|
+
raise SystemExit("stop the loop")
|
|
29
|
+
|
|
30
|
+
monkeypatch.setattr("slimbots.runner.asyncio.sleep", fake_sleep)
|
|
31
|
+
|
|
32
|
+
async def attempt(reset_delay):
|
|
33
|
+
raise ConnectionError("dropped")
|
|
34
|
+
|
|
35
|
+
with pytest.raises(SystemExit):
|
|
36
|
+
await run_forever(attempt, base_delay=1.0, max_delay=4.0)
|
|
37
|
+
|
|
38
|
+
assert delays == [1.0, 2.0, 4.0]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_a_403_is_not_terminal(monkeypatch):
|
|
43
|
+
delays = []
|
|
44
|
+
|
|
45
|
+
async def fake_sleep(seconds):
|
|
46
|
+
delays.append(seconds)
|
|
47
|
+
raise SystemExit("stop the loop")
|
|
48
|
+
|
|
49
|
+
monkeypatch.setattr("slimbots.runner.asyncio.sleep", fake_sleep)
|
|
50
|
+
|
|
51
|
+
async def attempt(reset_delay):
|
|
52
|
+
raise urllib.error.HTTPError("u", 403, "e", {}, None)
|
|
53
|
+
|
|
54
|
+
with pytest.raises(SystemExit):
|
|
55
|
+
await run_forever(attempt)
|
|
56
|
+
assert delays == [1.0]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_reset_delay_is_offered_to_the_attempt():
|
|
61
|
+
calls = []
|
|
62
|
+
|
|
63
|
+
async def attempt(reset_delay):
|
|
64
|
+
calls.append(reset_delay)
|
|
65
|
+
raise urllib.error.HTTPError("u", 401, "e", {}, None)
|
|
66
|
+
|
|
67
|
+
await run_forever(attempt)
|
|
68
|
+
assert len(calls) == 1
|
|
69
|
+
calls[0]() # must not raise
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from slimbots import ws as ws_module
|
|
6
|
+
from slimbots.ws import Connection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class FakeSocket:
|
|
10
|
+
def __init__(self, incoming, hello_reply=None):
|
|
11
|
+
self.sent = []
|
|
12
|
+
self.closed = False
|
|
13
|
+
self._incoming = list(incoming)
|
|
14
|
+
self._hello_reply = hello_reply if hello_reply is not None else {"type": "hello", "protocol": 1}
|
|
15
|
+
|
|
16
|
+
async def send(self, message):
|
|
17
|
+
self.sent.append(message)
|
|
18
|
+
|
|
19
|
+
async def recv(self):
|
|
20
|
+
return json.dumps(self._hello_reply)
|
|
21
|
+
|
|
22
|
+
async def close(self):
|
|
23
|
+
self.closed = True
|
|
24
|
+
|
|
25
|
+
def __aiter__(self):
|
|
26
|
+
return self._iter()
|
|
27
|
+
|
|
28
|
+
async def _iter(self):
|
|
29
|
+
for frame in self._incoming:
|
|
30
|
+
yield json.dumps(frame)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FakeClient:
|
|
34
|
+
def __init__(self, socket):
|
|
35
|
+
self.socket = socket
|
|
36
|
+
self.user_agent = "slimm-bot-test/1.0"
|
|
37
|
+
|
|
38
|
+
def ws_ticket(self):
|
|
39
|
+
return "ticket-123"
|
|
40
|
+
|
|
41
|
+
def socket_url(self):
|
|
42
|
+
return "wss://my.space/ws"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_open_sends_hello_with_ticket_and_protocol(monkeypatch):
|
|
47
|
+
socket = FakeSocket(incoming=[])
|
|
48
|
+
|
|
49
|
+
async def fake_connect(url, user_agent_header=None):
|
|
50
|
+
assert url == "wss://my.space/ws"
|
|
51
|
+
assert user_agent_header == "slimm-bot-test/1.0"
|
|
52
|
+
return socket
|
|
53
|
+
|
|
54
|
+
monkeypatch.setattr(ws_module.websockets, "connect", fake_connect)
|
|
55
|
+
conn = await Connection.open(FakeClient(socket))
|
|
56
|
+
|
|
57
|
+
sent = json.loads(socket.sent[0])
|
|
58
|
+
assert sent == {"type": "hello", "ticket": "ticket-123", "protocol": 1}
|
|
59
|
+
await conn.close()
|
|
60
|
+
assert socket.closed
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_open_rejects_a_non_hello_reply(monkeypatch):
|
|
65
|
+
socket = FakeSocket(incoming=[], hello_reply={"type": "error"})
|
|
66
|
+
|
|
67
|
+
async def fake_connect(url, user_agent_header=None):
|
|
68
|
+
return socket
|
|
69
|
+
|
|
70
|
+
monkeypatch.setattr(ws_module.websockets, "connect", fake_connect)
|
|
71
|
+
with pytest.raises(RuntimeError, match="expected a hello"):
|
|
72
|
+
await Connection.open(FakeClient(socket))
|
|
73
|
+
# the socket must not be left open after a failed handshake
|
|
74
|
+
assert socket.closed
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pytest.mark.asyncio
|
|
78
|
+
async def test_frames_yields_parsed_json(monkeypatch):
|
|
79
|
+
frames_in = [{"type": "message.created", "n": 1}, {"type": "unknown.future.event"}]
|
|
80
|
+
socket = FakeSocket(incoming=frames_in)
|
|
81
|
+
|
|
82
|
+
async def fake_connect(url, user_agent_header=None):
|
|
83
|
+
return socket
|
|
84
|
+
|
|
85
|
+
monkeypatch.setattr(ws_module.websockets, "connect", fake_connect)
|
|
86
|
+
conn = await Connection.open(FakeClient(socket))
|
|
87
|
+
|
|
88
|
+
seen = [frame async for frame in conn.frames()]
|
|
89
|
+
assert seen == frames_in
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.mark.asyncio
|
|
93
|
+
async def test_context_manager_closes_on_exit(monkeypatch):
|
|
94
|
+
socket = FakeSocket(incoming=[])
|
|
95
|
+
|
|
96
|
+
async def fake_connect(url, user_agent_header=None):
|
|
97
|
+
return socket
|
|
98
|
+
|
|
99
|
+
monkeypatch.setattr(ws_module.websockets, "connect", fake_connect)
|
|
100
|
+
async with await Connection.open(FakeClient(socket)) as conn:
|
|
101
|
+
assert conn is not None
|
|
102
|
+
assert socket.closed
|