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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ websockets>=12
2
+
3
+ [dev]
4
+ pytest
5
+ pytest-asyncio
@@ -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