Spaces:
Running
Running
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  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
|