mirror of
https://github.com/prdlk/omarchy-beeper.git
synced 2026-11-02 13:12:48 +00:00
feat: omarchy-beeper bar widget for unread Beeper chats
This commit is contained in:
commit
7fc78f1075
17 files changed
+3483
No files matched your search
+326
@@ -0,0 +1,326 @@
|
||||
"""All HTTP to the local Beeper Desktop API.
|
||||
|
||||
Everything here is bound to http://localhost:23373. The token is read from a
|
||||
0600 file and only ever leaves this process inside an Authorization header:
|
||||
never in argv, never on stderr, never in a payload.
|
||||
|
||||
Endpoints used (https://developers.beeper.com/desktop-api-reference/):
|
||||
|
||||
GET /v1/info server discovery, no auth
|
||||
GET /v1/accounts connected chat accounts
|
||||
GET /v1/chats/search unread chats, cursor paginated
|
||||
GET /v1/chats/{chatID}/messages last message for the row snippet
|
||||
POST /v1/chats/{chatID}/read mark a chat read
|
||||
POST /v1/focus focus Beeper Desktop, optionally a chat
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from common import (
|
||||
API_BASE,
|
||||
FETCH_CAP,
|
||||
MAX_HTTP_ERROR,
|
||||
encode_id,
|
||||
one_line,
|
||||
read_http_body,
|
||||
)
|
||||
|
||||
REQUEST_TIMEOUT = 30
|
||||
SEARCH_PAGE = 200 # /v1/chats/search caps limit at 200
|
||||
SNIPPET_WORKERS = 8
|
||||
|
||||
# Message kinds with no body text. Shown as a short placeholder instead.
|
||||
MEDIA_LABELS = {
|
||||
"IMAGE": "[photo]",
|
||||
"VIDEO": "[video]",
|
||||
"VOICE": "[voice message]",
|
||||
"AUDIO": "[audio]",
|
||||
"FILE": "[file]",
|
||||
"STICKER": "[sticker]",
|
||||
"LOCATION": "[location]",
|
||||
}
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""An API call that came back, but not with a usable answer."""
|
||||
|
||||
def __init__(self, message: str, status: int = 0) -> None:
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
self.message = message
|
||||
|
||||
|
||||
class UnreachableError(Exception):
|
||||
"""Beeper Desktop is not listening on the local port."""
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""The token is missing, expired, or was revoked."""
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""A redirect could point off localhost, so treat any 3xx as an error."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D102
|
||||
return None
|
||||
|
||||
|
||||
_OPENER = urllib.request.build_opener(_NoRedirect)
|
||||
|
||||
|
||||
def _url(path: str, params: dict | None = None) -> str:
|
||||
if not path.startswith("/v1/"):
|
||||
raise ApiError(f"refusing to call {path}")
|
||||
url = API_BASE + path
|
||||
if params:
|
||||
pairs: list[tuple[str, str]] = []
|
||||
for key, value in params.items():
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, bool):
|
||||
pairs.append((key, "true" if value else "false"))
|
||||
elif isinstance(value, (list, tuple)):
|
||||
pairs.extend((key, str(item)) for item in value)
|
||||
else:
|
||||
pairs.append((key, str(value)))
|
||||
if pairs:
|
||||
url += "?" + urllib.parse.urlencode(pairs)
|
||||
if not url.startswith(API_BASE + "/"):
|
||||
raise ApiError("refusing to leave the local API")
|
||||
return url
|
||||
|
||||
|
||||
def request(
|
||||
token: str,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict | None = None,
|
||||
body: dict | None = None,
|
||||
timeout: int = REQUEST_TIMEOUT,
|
||||
) -> object:
|
||||
"""One call to the local API. Returns the decoded JSON body, or None."""
|
||||
payload = None if body is None else json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(_url(path, params), data=payload, method=method)
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
if payload is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
req.add_header("Accept", "application/json")
|
||||
try:
|
||||
with _OPENER.open(req, timeout=timeout) as fp:
|
||||
raw = read_http_body(fp)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = read_http_body(exc, MAX_HTTP_ERROR).decode("utf-8", "replace")
|
||||
except Exception: # noqa: BLE001 - the status matters, the body does not
|
||||
detail = ""
|
||||
finally:
|
||||
exc.close()
|
||||
if exc.code in (401, 403):
|
||||
raise AuthError("token missing or invalid; run: omarchy-beeper auth") from exc
|
||||
raise ApiError(_http_message(exc.code, detail), exc.code) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
reason = exc.reason
|
||||
if isinstance(reason, (socket.timeout, TimeoutError)):
|
||||
raise ApiError("the local API timed out") from exc
|
||||
if isinstance(reason, OSError):
|
||||
# Only localhost is ever dialled, so any socket error means the
|
||||
# port is not being served: Beeper Desktop is down or restarting.
|
||||
raise UnreachableError("Beeper Desktop is not running") from exc
|
||||
raise ApiError(one_line(str(reason)) or "the local API failed") from exc
|
||||
except (socket.timeout, TimeoutError) as exc:
|
||||
raise ApiError("the local API timed out") from exc
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ApiError("the local API returned invalid JSON") from exc
|
||||
|
||||
|
||||
def _http_message(status: int, detail: str) -> str:
|
||||
text = ""
|
||||
try:
|
||||
data = json.loads(detail)
|
||||
if isinstance(data, dict):
|
||||
text = str(data.get("message") or data.get("error") or "")
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
text = ""
|
||||
text = one_line(text, 120)
|
||||
return f"Beeper API error {status}: {text}" if text else f"Beeper API error {status}"
|
||||
|
||||
|
||||
def server_info() -> dict:
|
||||
"""GET /v1/info needs no token, so it separates 'down' from 'bad token'."""
|
||||
data = request("", "GET", "/v1/info", timeout=5)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def probe(token: str) -> None:
|
||||
"""Cheapest authenticated call, used to verify a token during `auth`."""
|
||||
request(token, "GET", "/v1/accounts", timeout=10)
|
||||
|
||||
|
||||
def accounts(token: str) -> list[dict]:
|
||||
data = request(token, "GET", "/v1/accounts")
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
return [item for item in data if isinstance(item, dict)]
|
||||
|
||||
|
||||
def account_label(account: dict) -> str:
|
||||
return one_line(str(account.get("network") or account.get("accountID") or ""), 40)
|
||||
|
||||
|
||||
def unread_chats(token: str, cap: int = FETCH_CAP, budget: float = 0.0) -> tuple[list[dict], bool]:
|
||||
"""Unread chats, newest first.
|
||||
|
||||
Muted, archived, and low-priority chats are not part of the pile:
|
||||
`inbox=primary` drops archived and low-priority, `includeMuted=false`
|
||||
drops muted. Returns (chats, truncated) where truncated means the walk
|
||||
hit `cap` or `budget` before the API ran out of pages.
|
||||
"""
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
cursor = ""
|
||||
truncated = False
|
||||
deadline = (_now() + budget) if budget > 0 else 0.0
|
||||
while True:
|
||||
params = {
|
||||
"unreadOnly": True,
|
||||
"includeMuted": False,
|
||||
"inbox": "primary",
|
||||
"limit": min(SEARCH_PAGE, max(1, cap)),
|
||||
}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
params["direction"] = "before"
|
||||
data = request(token, "GET", "/v1/chats/search", params)
|
||||
if not isinstance(data, dict):
|
||||
raise ApiError("the local API returned invalid JSON")
|
||||
items = data.get("items")
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
for chat in items:
|
||||
if not isinstance(chat, dict):
|
||||
continue
|
||||
chat_id = str(chat.get("id") or "")
|
||||
if not chat_id or chat_id in seen:
|
||||
continue
|
||||
seen.add(chat_id)
|
||||
out.append(chat)
|
||||
if len(out) >= cap:
|
||||
truncated = bool(data.get("hasMore")) or len(out) > cap
|
||||
del out[cap:]
|
||||
break
|
||||
cursor = str(data.get("oldestCursor") or "")
|
||||
if not data.get("hasMore") or not cursor or not items:
|
||||
break
|
||||
if deadline and _now() >= deadline:
|
||||
truncated = True
|
||||
break
|
||||
out.sort(key=lambda chat: (_ts(chat.get("lastActivity")), str(chat.get("id"))), reverse=True)
|
||||
return out, truncated
|
||||
|
||||
|
||||
def last_message(token: str, chat_id: str) -> dict:
|
||||
"""Newest displayable message in a chat, for the row snippet."""
|
||||
data = request(token, "GET", f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/messages")
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list):
|
||||
return {}
|
||||
best: dict = {}
|
||||
best_key = ("", 0.0)
|
||||
for msg in items:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("isDeleted") or msg.get("isHidden") or msg.get("type") == "REACTION":
|
||||
continue
|
||||
key = (str(msg.get("sortKey") or ""), _ts(msg.get("timestamp")))
|
||||
if not best or key[1] > best_key[1] or (key[1] == best_key[1] and key[0] > best_key[0]):
|
||||
best, best_key = msg, key
|
||||
return best
|
||||
|
||||
|
||||
def message_preview(msg: dict) -> str:
|
||||
text = one_line(str(msg.get("text") or ""))
|
||||
if text:
|
||||
return text
|
||||
kind = str(msg.get("type") or "")
|
||||
if kind in MEDIA_LABELS:
|
||||
return MEDIA_LABELS[kind]
|
||||
attachments = msg.get("attachments")
|
||||
if isinstance(attachments, list) and attachments:
|
||||
return "[attachment]"
|
||||
return ""
|
||||
|
||||
|
||||
def mark_read(token: str, chat_id: str, timeout: int = REQUEST_TIMEOUT) -> None:
|
||||
request(
|
||||
token,
|
||||
"POST",
|
||||
f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/read",
|
||||
body={},
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def focus(token: str, chat_id: str = "") -> None:
|
||||
body: dict = {}
|
||||
if chat_id:
|
||||
body["chatID"] = chat_id
|
||||
request(token, "POST", "/v1/focus", body=body)
|
||||
|
||||
|
||||
def chat_row(chat: dict, msg: dict | None = None) -> dict:
|
||||
"""One panel row. Every display string goes through one_line()."""
|
||||
msg = msg or {}
|
||||
title = one_line(str(chat.get("title") or ""))
|
||||
network = one_line(str(chat.get("network") or ""), 40)
|
||||
if not title:
|
||||
title = network or "(no title)"
|
||||
sender = one_line(str(msg.get("senderName") or ""), 60)
|
||||
if msg.get("isSender"):
|
||||
sender = "You"
|
||||
ts = _ts(msg.get("timestamp")) or _ts(chat.get("lastActivity"))
|
||||
return {
|
||||
"id": encode_id(str(chat.get("id") or "")),
|
||||
"threadId": str(chat.get("id") or ""),
|
||||
"subject": title,
|
||||
"from": sender,
|
||||
"snippet": message_preview(msg),
|
||||
"ts": int(ts),
|
||||
"labels": [network] if network else [],
|
||||
"url": "",
|
||||
}
|
||||
|
||||
|
||||
def _ts(value: object) -> float:
|
||||
"""ISO 8601 (or unix seconds) to unix seconds. Unparsable is 0."""
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.monotonic()
|
||||
Reference in new issue
Block a user