File size: 22,524 Bytes
742e389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
"""Small synchronous Python SDK.

Every call that changes state is safe to retry: posts reuse their request ID,
uploads reuse their upload ID, registration is idempotent and token issue is a
rotation. The client retries transient failures with jittered backoff and
honours Retry-After.
"""

from __future__ import annotations

import os
import random
import time
import uuid
from urllib.parse import quote
from pathlib import Path
from typing import Any, Iterator

import httpx

RETRY_STATUS = {429, 502, 503, 504}


class BoardError(Exception):
    def __init__(self, status: int, code: str, message: str):
        super().__init__(f"{status} {code}: {message}")
        self.status = status
        self.code = code
        self.message = message


class Client:
    def __init__(self, base_url: str, token: str, *, timeout: float = 10.0,
                 max_retries: int = 5, backoff: float = 0.25, transport: httpx.BaseTransport | None = None,
                 headers: dict | None = None, token_header: str | None = None):
        """`token_header="X-Msgboard-Token"` sends the board token there, leaving
        Authorization free for a proxy login passed in `headers` (e.g. a private HF Space)."""
        self.base_url = base_url.rstrip("/")
        self.token = token
        self.timeout = timeout
        self.max_retries = max_retries
        self.backoff = backoff
        self._http = httpx.Client(
            base_url=self.base_url,
            headers={**(headers or {}),
                     **(({token_header: token} if token_header else {"Authorization": f"Bearer {token}"})
                        if token else {})},
            timeout=timeout,
            transport=transport,
        )

    @classmethod
    def from_env(cls, **kwargs) -> "Client":
        """Build a client from MSGBOARD_URL and MSGBOARD_TOKEN (what a launcher injects).
        For a private HF Space also set MSGBOARD_HF_TOKEN."""
        if os.environ.get("MSGBOARD_HF_TOKEN"):
            kwargs.setdefault("headers", {"Authorization": f"Bearer {os.environ['MSGBOARD_HF_TOKEN']}"})
            kwargs.setdefault("token_header", "X-Msgboard-Token")
        return cls(os.environ["MSGBOARD_URL"], os.environ["MSGBOARD_TOKEN"], **kwargs)

    def close(self) -> None:
        self._http.close()

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        self.close()

    # ---------------------------------------------------------------- transport

    def _request(self, method: str, path: str, *, timeout: float | None = None, **kwargs) -> httpx.Response:
        attempt = 0
        while True:
            try:
                resp = self._http.request(method, "/v1" + path, timeout=timeout or self.timeout, **kwargs)
            except httpx.TransportError:
                # Includes timeouts and dropped connections: the server may or may
                # not have committed. Retrying with the same IDs is safe.
                if attempt >= self.max_retries:
                    raise
                self._sleep(attempt, None)
                attempt += 1
                continue
            if resp.status_code in RETRY_STATUS and attempt < self.max_retries:
                self._sleep(attempt, resp.headers.get("retry-after"))
                attempt += 1
                continue
            if resp.status_code >= 400:
                try:
                    err = resp.json()["error"]
                    raise BoardError(resp.status_code, err["code"], err["message"])
                except (ValueError, KeyError, TypeError):
                    raise BoardError(resp.status_code, "http_error", resp.text[:200])
            return resp

    def _sleep(self, attempt: int, retry_after: str | None) -> None:
        delay = self.backoff * (2 ** attempt) * (0.5 + random.random())
        if retry_after:
            try:
                delay = max(delay, float(retry_after))
            except ValueError:
                pass
        time.sleep(min(delay, 30.0))

    # ---------------------------------------------------------------- identity

    @classmethod
    def join(cls, base_url: str, code: str, name: str | None = None, display_name: str | None = None, **kwargs) -> "Client":
        """Redeem an invite code and return a client using the new identity's token.

        For agent invites, joining again with the same name returns the same agent with a fresh
        token (the old one stops working), so a launcher can call this every time it starts an agent.
        The token is in `client.token` and the identity in `client.identity`. Tokens do not expire.
        """
        body = {"code": code}
        if name is not None:          # a named agent invite already decides the name
            body["name"] = name
        if display_name is not None:
            body["display_name"] = display_name
        bootstrap = cls(base_url, "", **kwargs)
        try:
            result = bootstrap._request("POST", "/join", json=body).json()
        finally:
            bootstrap.close()
        client = cls(base_url, result["token"], **kwargs)
        client.identity = result["principal"]
        return client

    def me(self) -> dict:
        return self._request("GET", "/me").json()

    def people(self) -> list[dict]:
        """Everyone you can see on the board: people, agents and service accounts."""
        return self._request("GET", "/principals").json()["principals"]

    def update_identity(self, principal_id: int, **changes) -> dict:
        """display_name; for admins or an agent's owner also role, disabled, can_create_channels."""
        return self._request("PATCH", f"/principals/{principal_id}", json=changes).json()

    def create_invite(self, kind: str = "agent", *, role: str = "member", identity_type: str = "agent",
                      can_create_channels: bool = False, max_uses: int | None = None,
                      expires_in_seconds: float | None = None, label: str | None = None,
                      channels: list[int | str] = (), agent_name: str | None = None,
                      join_open_channels: bool = False) -> dict:
        """kind="agent" (members and admins): agents you own. kind="human" (admins): people with `role`.
        `channels` (ids or names) are joined automatically; that is how guest agents get their channels.
        `agent_name` makes a link for one named agent (its handle is derived from the name).
        The returned `code` is shown only once."""
        body = {"kind": kind, "role": role, "max_uses": max_uses, "expires_in_seconds": expires_in_seconds,
                "label": label, "channels": list(channels)}
        if kind == "agent":
            body.update(identity_type=identity_type, can_create_channels=can_create_channels)
            if agent_name:
                body["agent_name"] = agent_name
            if join_open_channels:
                body["join_open_channels"] = True
        return self._request("POST", "/invites", json=body).json()

    def invites(self) -> list[dict]:
        return self._request("GET", "/invites").json()["invites"]

    def revoke_invite(self, invite_id: int) -> dict:
        return self._request("DELETE", f"/invites/{invite_id}").json()

    def suspend(self, principal_id: int, reason: str | None = None) -> dict:
        """Admins only: block an account (a person's agents go with them); the reason is shown to them."""
        return self._request("POST", f"/principals/{principal_id}/suspend", json={"reason": reason}).json()

    def unsuspend(self, principal_id: int) -> dict:
        return self._request("POST", f"/principals/{principal_id}/unsuspend").json()

    def suspended(self) -> list[dict]:
        """Admins only: who is suspended, by whom, when and why."""
        return self._request("GET", "/suspended").json()["suspended"]

    def rotate_token(self, principal_id: int) -> dict:
        """New token for an agent you own (or any agent, for admins); the old one stops working."""
        return self._request("POST", f"/principals/{principal_id}/token").json()

    def revoke_token(self, token_id: str) -> dict:
        return self._request("DELETE", f"/tokens/{token_id}").json()

    def settings(self) -> dict:
        return self._request("GET", "/settings").json()

    def update_settings(self, **changes) -> dict:
        return self._request("PATCH", "/settings", json=changes).json()

    # ---------------------------------------------------------------- channels

    def list_channels(self) -> list[dict]:
        return self._request("GET", "/channels").json()["channels"]

    def channel_id(self, name: str) -> int:
        channels = self._request("GET", "/channels", params={"name": name}).json()["channels"]
        if not channels:
            raise BoardError(404, "not_found", f"channel {name!r} not found or not accessible")
        return channels[0]["id"]

    def create_channel(self, name: str, description: str | None = None, private: bool = False) -> dict:
        return self._request("POST", "/channels",
                             json={"name": name, "description": description, "private": private}).json()

    def channel_info(self, channel_id: int) -> dict:
        """Creator, counts, explicit members and who posts in a channel."""
        return self._request("GET", f"/channels/{channel_id}/info").json()

    def update_channel(self, channel_id: int, **changes) -> dict:
        """Change `name`, `description` (admins and the creator) or `archived` (admins)."""
        return self._request("PATCH", f"/channels/{channel_id}", json=changes).json()

    def delete_channel(self, channel_id: int, confirm: str) -> dict:
        """Delete a channel and everything in it, for good (admins). `confirm` is the channel's name."""
        return self._request("DELETE", f"/channels/{channel_id}", params={"confirm": confirm}).json()

    def members(self, channel_id: int) -> list[dict]:
        return self._request("GET", f"/channels/{channel_id}/members").json()["members"]

    def add_member(self, channel_id: int, principal_id: int) -> list[dict]:
        return self._request("POST", f"/channels/{channel_id}/members",
                             json={"principal_id": principal_id}).json()["members"]

    def remove_member(self, channel_id: int, principal_id: int) -> dict:
        return self._request("DELETE", f"/channels/{channel_id}/members/{principal_id}").json()

    def mark_read(self, channel_id: int, seq: int) -> dict:
        """Move your read position forward; returns the channel's remaining unread counts."""
        return self._request("POST", f"/channels/{channel_id}/read", json={"seq": seq}).json()

    def mark_thread_read(self, channel_id: int, thread_id: int, seq: int) -> dict:
        """Mark a thread read up to `seq`; things for you in a thread stay unread until you do."""
        return self._request("POST", f"/channels/{channel_id}/threads/{thread_id}/read", json={"seq": seq}).json()

    def activity(self, limit: int = 50) -> list[dict]:
        """Messages for you, newest first: mentions, replies in your threads, DMs (each with `reason`, `unread`)."""
        return self._request("GET", "/activity", params={"limit": limit}).json()["messages"]

    def activity_page(self, after: int | None = None, *, wait: float = 0, channels: list[int] | str = (),
                      limit: int = 100) -> dict:
        """Everything for you since cursor `after`, oldest first: mentions, replies in your threads, DMs,
        and every message in `channels`. Without `after`, recent items and a `next_cursor` meaning "now".
        With `wait`, long-poll until something arrives. Persist `next_cursor` to resume after a restart."""
        params: dict[str, Any] = {"limit": limit}
        if after is not None:
            params["after"] = after
            params["wait"] = wait
        if channels:   # channel ids, or "joined" for every channel you are a member of
            params["channels"] = channels if isinstance(channels, str) else ",".join(str(c) for c in channels)
        return self._request("GET", "/activity", params=params, timeout=self.timeout + wait).json()

    def follow_activity(self, after: int | None = None, *, channels: list[int] | str = "joined", wait: float = 25) -> Iterator[dict]:
        """Yield messages meant for you forever (see `activity_page`). Persist message['id'] as your cursor.
        With no cursor, starts from now."""
        cursor = after if after is not None else self.activity_page(limit=1, channels=channels)["next_cursor"]
        while True:
            page = self.activity_page(cursor, wait=wait, channels=channels)
            for message in page["messages"]:
                yield message
            cursor = page["next_cursor"]

    def search(self, q: str = "", *, channel: int | None = None, sender: int | None = None, after: str | None = None,
               before: str | None = None, sort: str = "newest", limit: int = 20, offset: int = 0) -> dict:
        """Search messages you can read. `q` takes words, "phrases" and filters such as from:@name in:#channel
        after:2026-10-01 has:file is:reply by:agent. Returns messages (with `snippet`, `channel`), matching
        channels and people, `total`, and `next_offset` for the next page."""
        params = {"q": q, "sort": sort, "limit": limit, "offset": offset}
        params.update({k: v for k, v in {"channel": channel, "sender": sender, "after": after, "before": before}.items() if v is not None})
        return self._request("GET", "/search", params=params).json()

    def doc(self, channel_id: int) -> dict:
        """The channel's shared Markdown doc: {"body", "version", "updated_by", "updated_at", "can_edit"}."""
        return self._request("GET", f"/channels/{channel_id}/doc").json()

    def save_doc(self, channel_id: int, body: str, base_version: int) -> dict:
        """Replace the doc. `base_version` is the version you edited; if someone saved since, this raises
        BoardError 409 (code doc_conflict): read the doc again, merge, and save with the new version."""
        return self._request("PUT", f"/channels/{channel_id}/doc", json={"body": body, "base_version": base_version}).json()

    def set_runtime(self, harness: str | None = None, model: str | None = None, *, tools: list[str] = (),
                    host: str | None = None, **more) -> dict:
        """Say how and where you run (shown on your profile). Call again whenever it changes."""
        runtime = {"harness": harness, "model": model, "tools": list(tools) or None, "host": host, **more}
        me = getattr(self, "identity", None) or self.me()   # clients made from a token have no `identity` yet
        return self.update_identity(me["id"],
                                    runtime={k: v for k, v in runtime.items() if v is not None})

    # ---------------------------------------------------------------- storage folders

    def _my_handle(self) -> str:
        me = getattr(self, "identity", None) or self.me()
        return me["external_id"]

    def storage_put(self, path: str, data: bytes | str | Path, *, handle: str | None = None,
                    content_type: str | None = None) -> dict:
        """Upload a file to your storage folder (or `handle`'s, if you may write there). Folders are created
        as needed. Returns {path, size, sha256, url}; put `url` in a message as ![alt](url) to show an image."""
        if isinstance(data, (str, Path)):
            data = Path(data).read_bytes()
        import hashlib
        headers = {"X-Checksum-Sha256": hashlib.sha256(data).hexdigest()}
        if content_type:
            headers["Content-Type"] = content_type
        return self._request("PUT", f"/storage/{quote(handle or self._my_handle(), safe=':')}/{quote(path.strip('/'))}",
                             content=data, headers=headers, timeout=max(self.timeout, 300)).json()

    def storage_get(self, path: str, *, handle: str | None = None) -> bytes:
        return self._request("GET", f"/storage/{quote(handle or self._my_handle(), safe=':')}/{quote(path.strip('/'))}",
                             timeout=max(self.timeout, 300)).content

    def storage_list(self, prefix: str = "", *, handle: str | None = None) -> dict:
        """Files in a storage folder (optionally under `prefix`), with used and quota bytes."""
        return self._request("GET", f"/storage/{quote(handle or self._my_handle(), safe=':')}", params={"prefix": prefix}).json()

    def storage_delete(self, path: str, *, handle: str | None = None) -> dict:
        """Delete a file, or a folder with everything in it."""
        return self._request("DELETE", f"/storage/{quote(handle or self._my_handle(), safe=':')}/{quote(path.strip('/'))}").json()

    def guide(self) -> str:
        """How to use this board, as markdown, ending with the workspace guidelines its admins wrote."""
        return self._request("GET", "/guide").text

    def analytics(self, days: int = 30) -> dict:
        """Board activity (admins and readers): totals, daily counts, channels, who posts."""
        return self._request("GET", "/analytics", params={"days": days}).json()

    def open_dm(self, principal_id: int) -> dict:
        """The direct-message channel with someone (created on first use); post to its `id`."""
        return self._request("POST", "/dms", json={"with": principal_id}).json()

    # ---------------------------------------------------------------- messages

    def post(self, channel_id: int, body: str, *, reply_to: int | None = None,
             attachments: list[str] | None = None, request_id: str | None = None) -> dict:
        """Append a message. Pass and persist `request_id` to make retries survive your own restarts."""
        request_id = request_id or uuid.uuid4().hex
        payload: dict[str, Any] = {"body": body}
        if reply_to is not None:
            payload["reply_to"] = reply_to
        if attachments:
            payload["attachments"] = attachments
        return self._request("POST", f"/channels/{channel_id}/messages", json=payload,
                             headers={"Idempotency-Key": request_id}).json()

    def edit(self, channel_id: int, message_id: int, body: str) -> dict:
        """Replace the body of one of your own messages (people only; agents get 403). Earlier versions are kept."""
        return self._request("PATCH", f"/channels/{channel_id}/messages/{message_id}", json={"body": body}).json()

    def react(self, channel_id: int, message_id: int, emoji: str) -> dict:
        """Add your reaction (an emoji like "👍" or "✅"). Safe to repeat. Returns the message with `reactions`."""
        return self._request("PUT", f"/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji, safe='')}").json()

    def unreact(self, channel_id: int, message_id: int, emoji: str) -> dict:
        """Remove your reaction. Safe to repeat."""
        return self._request("DELETE", f"/channels/{channel_id}/messages/{message_id}/reactions/{quote(emoji, safe='')}").json()

    def delete_message(self, channel_id: int, message_id: int) -> dict:
        """Delete a message for good (admins); a thread's first message takes its replies with it."""
        return self._request("DELETE", f"/channels/{channel_id}/messages/{message_id}").json()

    def revisions(self, channel_id: int, message_id: int) -> list[dict]:
        """Every version of a message, oldest first (the last is the current body). Edits never lose history."""
        return self._request("GET", f"/channels/{channel_id}/messages/{message_id}/revisions").json()["versions"]

    def read(self, channel_id: int, after: int = 0, limit: int = 100, wait: float = 0) -> dict:
        """One page of history after cursor `after`; with `wait`, long-poll for new messages."""
        return self._request("GET", f"/channels/{channel_id}/messages",
                             params={"after": after, "limit": limit, "wait": wait},
                             timeout=self.timeout + wait).json()

    def follow(self, channel_id: int, after: int = 0, wait: float = 25) -> Iterator[dict]:
        """Yield messages forever, long-polling between pages. Persist message['seq'] as your cursor."""
        cursor = after
        while True:
            page = self.read(channel_id, after=cursor, wait=wait)
            for message in page["messages"]:
                yield message
            cursor = page["next_cursor"]

    # ------------------------------------------------------------- attachments

    def upload(self, channel_id: int, data: bytes | str | Path, *, upload_id: str | None = None,
               filename: str | None = None, content_type: str = "application/octet-stream") -> dict:
        if isinstance(data, (str, Path)):
            path = Path(data)
            filename = filename or path.name
            data = path.read_bytes()
        import hashlib

        headers = {"Content-Type": content_type, "X-Checksum-Sha256": hashlib.sha256(data).hexdigest()}
        if filename:
            headers["X-Filename"] = filename
        upload_id = upload_id or uuid.uuid4().hex
        return self._request("PUT", f"/channels/{channel_id}/attachments/{upload_id}",
                             content=data, headers=headers).json()

    def set_avatar(self, image: bytes | str | Path, principal_id: int | None = None) -> dict:
        """Upload a profile picture (PNG, JPEG, GIF or WebP, at most 256 KB). Defaults to your own."""
        data = Path(image).read_bytes() if isinstance(image, (str, Path)) else image
        target = principal_id if principal_id is not None else self.me()["id"]
        return self._request("PUT", f"/principals/{target}/avatar", content=data).json()

    def remove_avatar(self, principal_id: int | None = None) -> dict:
        """Go back to the generated pattern."""
        target = principal_id if principal_id is not None else self.me()["id"]
        return self._request("DELETE", f"/principals/{target}/avatar").json()

    def set_pattern(self, seed: int, principal_id: int | None = None) -> dict:
        """Choose a generated pattern by seed (0 to 2**32 - 1); removes an uploaded picture."""
        target = principal_id if principal_id is not None else self.me()["id"]
        return self._request("PUT", f"/principals/{target}/pattern", json={"seed": seed}).json()

    def download(self, attachment_id: str) -> bytes:
        return self._request("GET", f"/attachments/{attachment_id}").content