mirror of
https://github.com/prdlk/omarchy-beeper.git
synced 2026-11-03 13:37:41 +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()
|
||||
Executable
+313
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env python3
|
||||
"""CLI entry for omarchy-beeper.
|
||||
|
||||
Every command prints exactly one JSON object and exits 0, so the bar widget
|
||||
never has to deal with empty stdout or a stack trace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
LIB = Path(__file__).resolve().parent
|
||||
if str(LIB) not in sys.path:
|
||||
sys.path.insert(0, str(LIB))
|
||||
|
||||
import beeper
|
||||
from common import (
|
||||
FETCH_CAP,
|
||||
TOKEN_FILE,
|
||||
die,
|
||||
decode_id,
|
||||
emit,
|
||||
load_token,
|
||||
max_chats,
|
||||
save_token,
|
||||
)
|
||||
|
||||
# The panel is on a refresh timer, so a slow list must still return.
|
||||
LIST_BUDGET = 40.0
|
||||
READ_ALL_BUDGET = 100.0
|
||||
READ_ALL_CAP = 1000
|
||||
READ_ALL_WORKERS = 6
|
||||
|
||||
HELP = """\
|
||||
omarchy-beeper list [--limit N] [--page OFFSET]
|
||||
omarchy-beeper read <id>
|
||||
omarchy-beeper read-all
|
||||
omarchy-beeper open [<id>]
|
||||
omarchy-beeper auth
|
||||
|
||||
The bar widget calls list, read, read-all, and open. The token is set up
|
||||
once in a terminal:
|
||||
|
||||
omarchy-beeper auth
|
||||
|
||||
--limit is the panel page size (1-50); the CLI also honours
|
||||
OMARCHY_BEEPER_MAX. One row is one unread chat. Muted, archived, and
|
||||
low-priority chats are not part of the pile.
|
||||
"""
|
||||
|
||||
|
||||
def _fail(exc: Exception) -> None:
|
||||
"""Turn an API exception into the single JSON error line."""
|
||||
if isinstance(exc, beeper.UnreachableError):
|
||||
die("Beeper Desktop is not running")
|
||||
if isinstance(exc, beeper.AuthError):
|
||||
die("token missing or invalid; run: omarchy-beeper auth")
|
||||
die(str(exc) or "the local Beeper API failed")
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
token = load_token()
|
||||
if token:
|
||||
return token
|
||||
# No token yet: say which of the two setup steps is missing.
|
||||
try:
|
||||
beeper.server_info()
|
||||
except (beeper.UnreachableError, beeper.ApiError):
|
||||
die("Beeper Desktop is not running")
|
||||
die("token missing or invalid; run: omarchy-beeper auth")
|
||||
return "" # unreachable, keeps type checkers quiet
|
||||
|
||||
|
||||
def _inboxes(accounts: list[dict], chats: list[dict]) -> list[dict]:
|
||||
"""Unread chats per connected account, in the order the API lists them."""
|
||||
counts: dict[str, int] = {}
|
||||
for chat in chats:
|
||||
acc_id = str(chat.get("accountID") or "")
|
||||
counts[acc_id] = counts.get(acc_id, 0) + 1
|
||||
out = []
|
||||
claimed = 0
|
||||
for account in accounts:
|
||||
acc_id = str(account.get("accountID") or "")
|
||||
unread = counts.get(acc_id, 0)
|
||||
claimed += unread
|
||||
out.append(
|
||||
{
|
||||
"account": beeper.account_label(account) or acc_id,
|
||||
"unread": unread,
|
||||
"searchUrl": "",
|
||||
}
|
||||
)
|
||||
leftover = len(chats) - claimed
|
||||
if leftover > 0:
|
||||
# A chat on an account /v1/accounts did not report; still count it.
|
||||
out.append({"account": "Other", "unread": leftover, "searchUrl": ""})
|
||||
return out
|
||||
|
||||
|
||||
def _snippets(token: str, chats: list[dict], deadline: float) -> dict[str, dict]:
|
||||
"""Last message per displayed chat. A failure just loses the snippet."""
|
||||
if not chats:
|
||||
return {}
|
||||
|
||||
def work(chat: dict) -> tuple[str, dict]:
|
||||
chat_id = str(chat.get("id") or "")
|
||||
if time.monotonic() >= deadline:
|
||||
return chat_id, {}
|
||||
try:
|
||||
return chat_id, beeper.last_message(token, chat_id)
|
||||
except (beeper.ApiError, beeper.UnreachableError, beeper.AuthError, OSError, ValueError):
|
||||
return chat_id, {}
|
||||
|
||||
workers = min(beeper.SNIPPET_WORKERS, len(chats))
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
return dict(pool.map(work, chats))
|
||||
|
||||
|
||||
def cmd_list(page_token: str) -> None:
|
||||
started = time.monotonic()
|
||||
token = _token()
|
||||
per = max_chats()
|
||||
start = 0
|
||||
if page_token:
|
||||
try:
|
||||
start = max(0, int(page_token))
|
||||
except ValueError:
|
||||
start = 0
|
||||
|
||||
try:
|
||||
chats, truncated = beeper.unread_chats(token, cap=FETCH_CAP, budget=LIST_BUDGET * 0.5)
|
||||
accounts = beeper.accounts(token)
|
||||
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||
_fail(exc)
|
||||
return
|
||||
|
||||
unread = len(chats)
|
||||
chunk = chats[start : start + per]
|
||||
deadline = started + LIST_BUDGET
|
||||
previews = _snippets(token, chunk, deadline)
|
||||
rows = [beeper.chat_row(chat, previews.get(str(chat.get("id") or ""))) for chat in chunk]
|
||||
|
||||
# A page past the pile would always be empty, so do not offer one.
|
||||
next_start = start + per
|
||||
next_page = str(next_start) if next_start < unread else ""
|
||||
|
||||
out = {
|
||||
"ok": True,
|
||||
"unread": unread,
|
||||
"accountCount": len(accounts),
|
||||
"nextPage": next_page,
|
||||
"thisPage": str(start),
|
||||
"inboxes": _inboxes(accounts, chats),
|
||||
"messages": rows,
|
||||
}
|
||||
if truncated:
|
||||
out["warning"] = f"showing the newest {FETCH_CAP} unread chats"
|
||||
emit(out)
|
||||
|
||||
|
||||
def cmd_read(opaque: str) -> None:
|
||||
chat_id = decode_id(opaque)
|
||||
token = _token()
|
||||
try:
|
||||
beeper.mark_read(token, chat_id)
|
||||
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||
_fail(exc)
|
||||
return
|
||||
emit({"ok": True})
|
||||
|
||||
|
||||
def cmd_read_all() -> None:
|
||||
started = time.monotonic()
|
||||
deadline = started + READ_ALL_BUDGET
|
||||
token = _token()
|
||||
try:
|
||||
# Snapshot first: marking changes what an unread query would return.
|
||||
chats, _ = beeper.unread_chats(token, cap=READ_ALL_CAP, budget=READ_ALL_BUDGET * 0.3)
|
||||
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||
_fail(exc)
|
||||
return
|
||||
ids = [str(chat.get("id") or "") for chat in chats]
|
||||
ids = [chat_id for chat_id in ids if chat_id]
|
||||
if not ids:
|
||||
emit({"ok": True, "marked": 0})
|
||||
return
|
||||
|
||||
marked = 0
|
||||
errors: list[str] = []
|
||||
ran_out = False
|
||||
|
||||
def work(chat_id: str) -> str:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 1:
|
||||
return "budget"
|
||||
try:
|
||||
beeper.mark_read(token, chat_id, timeout=int(min(beeper.REQUEST_TIMEOUT, remaining)))
|
||||
except Exception as exc: # noqa: BLE001 - one chat failing is not fatal
|
||||
return str(exc) or "could not mark as read"
|
||||
return ""
|
||||
|
||||
with ThreadPoolExecutor(max_workers=READ_ALL_WORKERS) as pool:
|
||||
for result in pool.map(work, ids):
|
||||
if result == "":
|
||||
marked += 1
|
||||
elif result == "budget":
|
||||
ran_out = True
|
||||
elif len(errors) < 3:
|
||||
errors.append(result)
|
||||
|
||||
if ran_out and not errors:
|
||||
errors.append(f"stopped after {int(READ_ALL_BUDGET)}s")
|
||||
if errors and marked == 0:
|
||||
emit({"ok": False, "marked": 0, "error": "; ".join(errors)})
|
||||
return
|
||||
if errors:
|
||||
emit({"ok": False, "marked": marked, "error": "; ".join(errors)})
|
||||
return
|
||||
emit({"ok": True, "marked": marked})
|
||||
|
||||
|
||||
def cmd_open(opaque: str) -> None:
|
||||
chat_id = decode_id(opaque) if opaque else ""
|
||||
token = _token()
|
||||
try:
|
||||
beeper.focus(token, chat_id)
|
||||
except Exception as exc: # noqa: BLE001 - mapped to the JSON error contract
|
||||
_fail(exc)
|
||||
return
|
||||
emit({"ok": True})
|
||||
|
||||
|
||||
AUTH_HELP = """\
|
||||
Create an access token in Beeper Desktop:
|
||||
|
||||
Settings -> Integrations -> create a token (read access is enough)
|
||||
|
||||
Then paste it here. It is stored in
|
||||
"""
|
||||
|
||||
|
||||
def cmd_auth() -> None:
|
||||
# A token typed into a pipe would end up in a shell history or a log.
|
||||
if not (sys.stdin.isatty() and sys.stderr.isatty()):
|
||||
sys.stderr.write("omarchy-beeper auth needs a terminal; see docs/SETUP.md\n")
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
info = beeper.server_info()
|
||||
except (beeper.UnreachableError, beeper.ApiError):
|
||||
sys.stderr.write("Beeper Desktop is not running on localhost:23373; start it first.\n")
|
||||
raise SystemExit(1) from None
|
||||
app = info.get("app") if isinstance(info.get("app"), dict) else {}
|
||||
sys.stderr.write(f"Found {app.get('name', 'Beeper')} {app.get('version', '')}".rstrip() + "\n")
|
||||
sys.stderr.write(AUTH_HELP + f"{TOKEN_FILE} with mode 600.\n")
|
||||
token = getpass.getpass("Access token: ").strip()
|
||||
if not token:
|
||||
sys.stderr.write("No token entered; nothing was saved.\n")
|
||||
raise SystemExit(1)
|
||||
try:
|
||||
beeper.probe(token)
|
||||
except beeper.AuthError:
|
||||
sys.stderr.write("Beeper rejected that token; nothing was saved.\n")
|
||||
raise SystemExit(1) from None
|
||||
except (beeper.ApiError, beeper.UnreachableError) as exc:
|
||||
sys.stderr.write(f"Could not verify the token: {exc}\n")
|
||||
raise SystemExit(1) from None
|
||||
save_token(token)
|
||||
sys.stdout.write(f"Token saved to {TOKEN_FILE}. The panel picks it up on the next refresh.\n")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
if not args or args[0] == "list":
|
||||
page = ""
|
||||
rest = args[1:] if args else []
|
||||
while rest:
|
||||
if rest[0] == "--page" and len(rest) > 1:
|
||||
page = rest[1]
|
||||
rest = rest[2:]
|
||||
elif rest[0] == "--limit" and len(rest) > 1:
|
||||
os.environ["OMARCHY_BEEPER_MAX"] = rest[1]
|
||||
rest = rest[2:]
|
||||
else:
|
||||
rest = rest[1:]
|
||||
cmd_list(page)
|
||||
return
|
||||
if args[0] == "read":
|
||||
if len(args) < 2:
|
||||
die("usage: omarchy-beeper read <id>")
|
||||
cmd_read(args[1])
|
||||
return
|
||||
if args[0] == "read-all":
|
||||
cmd_read_all()
|
||||
return
|
||||
if args[0] == "open":
|
||||
cmd_open(args[1] if len(args) > 1 else "")
|
||||
return
|
||||
if args[0] == "auth":
|
||||
cmd_auth()
|
||||
return
|
||||
if args[0] in ("-h", "--help", "help"):
|
||||
sys.stdout.write(HELP)
|
||||
return
|
||||
die("unknown command; try: omarchy-beeper --help")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
"""Shared helpers for omarchy-beeper. No secrets are printed.
|
||||
|
||||
Ported from omarchy-you-got-mail: the file-handling and body-cap rules are
|
||||
identical, only the config directory and the id shape changed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "omarchy-beeper"
|
||||
SECRETS_DIR = CONFIG_DIR / "secrets"
|
||||
TOKEN_FILE = SECRETS_DIR / "token.json"
|
||||
|
||||
# Only the local Beeper Desktop API. Nothing else is reachable from here.
|
||||
API_HOST = "localhost"
|
||||
API_PORT = 23373
|
||||
API_BASE = f"http://{API_HOST}:{API_PORT}"
|
||||
|
||||
PAGE_SIZE_MAX = 50
|
||||
FETCH_CAP = 200
|
||||
MAX_HTTP_BODY = 2 * 1024 * 1024
|
||||
MAX_HTTP_ERROR = 64 * 1024
|
||||
MAX_LOCAL_FILE = 64 * 1024
|
||||
|
||||
# Beeper chat ids are Matrix-ish: "!room:server", or a numeric local chat id.
|
||||
CHAT_ID_RE = re.compile(r"^[!@#$+]?[A-Za-z0-9._=/+-]{1,255}(?::[A-Za-z0-9._-]{1,255})?$")
|
||||
ID_PREFIX = "beeper"
|
||||
|
||||
|
||||
class ResponseTooLargeError(ValueError):
|
||||
def __init__(self, message: str = "response too large") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class FileTooLargeError(ValueError):
|
||||
def __init__(self, message: str = "file too large") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _declared_length(fp: object) -> int | None:
|
||||
headers = getattr(fp, "headers", None)
|
||||
if headers is None or not hasattr(headers, "get"):
|
||||
return None
|
||||
raw = headers.get("Content-Length")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
n = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if n < 0:
|
||||
return None
|
||||
return n
|
||||
|
||||
|
||||
def read_http_body(fp: object, limit: int = MAX_HTTP_BODY) -> bytes:
|
||||
declared = _declared_length(fp)
|
||||
if declared is not None and declared > limit:
|
||||
raise ResponseTooLargeError()
|
||||
read = getattr(fp, "read")
|
||||
data = read(limit + 1)
|
||||
if not isinstance(data, (bytes, bytearray)):
|
||||
data = bytes(data)
|
||||
if len(data) > limit:
|
||||
raise ResponseTooLargeError()
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def die(message: str, code: int = 0) -> None:
|
||||
sys.stdout.write(json.dumps({"ok": False, "error": message}, ensure_ascii=False) + "\n")
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def emit(payload: dict) -> None:
|
||||
sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def read_owned_file(
|
||||
path: Path,
|
||||
limit: int = MAX_LOCAL_FILE,
|
||||
*,
|
||||
require_private: bool = False,
|
||||
) -> bytes | None:
|
||||
"""Open path once, validate the fd, and read at most *limit* bytes.
|
||||
|
||||
Uses O_NOFOLLOW|O_NONBLOCK so a swapped symlink or FIFO cannot redirect
|
||||
or block the read. Missing paths, dangling/final-component symlinks, and
|
||||
non-regular files return None. Wrong owner is None unless require_private
|
||||
is set, in which case PermissionError is raised (and too-open mode too).
|
||||
"""
|
||||
flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
||||
try:
|
||||
fd = os.open(path, flags)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ELOOP, errno.ENXIO, errno.EAGAIN, errno.EWOULDBLOCK, errno.ENOTDIR):
|
||||
return None
|
||||
raise
|
||||
try:
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
return None
|
||||
if st.st_uid != os.getuid():
|
||||
if require_private:
|
||||
raise PermissionError("not owned")
|
||||
return None
|
||||
if require_private and st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
raise PermissionError("too open")
|
||||
if st.st_size > limit:
|
||||
raise FileTooLargeError()
|
||||
data = os.read(fd, limit + 1)
|
||||
if len(data) > limit:
|
||||
raise FileTooLargeError()
|
||||
return data
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _config_value(key: str) -> str | None:
|
||||
path = CONFIG_DIR / "config"
|
||||
try:
|
||||
raw = read_owned_file(path)
|
||||
except (OSError, FileTooLargeError, PermissionError):
|
||||
return None
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
lines = raw.decode("utf-8").splitlines()
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
name, _, rest = stripped.partition("=")
|
||||
if name.strip().lower() == key:
|
||||
return rest.strip().strip("\"'")
|
||||
return None
|
||||
|
||||
|
||||
def clamp_int(raw: object, default: int, lo: int, hi: int) -> int:
|
||||
try:
|
||||
n = int(str(raw).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(lo, min(hi, n))
|
||||
|
||||
|
||||
def max_chats() -> int:
|
||||
raw = os.environ.get("OMARCHY_BEEPER_MAX") or _config_value("max") or "25"
|
||||
return clamp_int(raw, 25, 1, PAGE_SIZE_MAX)
|
||||
|
||||
|
||||
def fetch_limit(raw: object | None = None) -> int:
|
||||
if raw is None:
|
||||
raw = os.environ.get("OMARCHY_BEEPER_FETCH") or os.environ.get("OMARCHY_BEEPER_MAX") or "25"
|
||||
return clamp_int(raw, 25, 1, FETCH_CAP)
|
||||
|
||||
|
||||
def one_line(value: str, limit: int = 180) -> str:
|
||||
text = re.sub(r"[\u00ad\u034f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]+", "", value or "")
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def ensure_config_dirs() -> None:
|
||||
CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(CONFIG_DIR, 0o700)
|
||||
SECRETS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
os.chmod(SECRETS_DIR, 0o700)
|
||||
|
||||
|
||||
def write_private(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent))
|
||||
replaced = False
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fd = -1
|
||||
fh.write(text)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
replaced = True
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
dirfd = os.open(str(path.parent), flags)
|
||||
try:
|
||||
os.fsync(dirfd)
|
||||
finally:
|
||||
os.close(dirfd)
|
||||
os.chmod(path, 0o600)
|
||||
finally:
|
||||
if fd >= 0:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
if not replaced:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_secret_file(path: Path, label: str = "") -> dict:
|
||||
label = label or path.name
|
||||
try:
|
||||
raw = read_owned_file(path, require_private=True)
|
||||
except FileTooLargeError:
|
||||
die(f"secret file for {label} is too large")
|
||||
except PermissionError as exc:
|
||||
reason = str(exc)
|
||||
if "too open" in reason:
|
||||
die(f"secret file for {label} is too open; chmod 600 it")
|
||||
if "not owned" in reason:
|
||||
die(f"secret file for {label} is not owned by you")
|
||||
die(f"secret file for {label} is not readable")
|
||||
except OSError:
|
||||
die(f"secret file for {label} is not readable")
|
||||
if raw is None:
|
||||
return {}
|
||||
parent = path.parent
|
||||
if parent.is_symlink():
|
||||
die(f"secret directory for {label} is a symlink")
|
||||
try:
|
||||
pst = parent.stat()
|
||||
except OSError:
|
||||
die(f"secret directory for {label} is not readable")
|
||||
if pst.st_uid != os.getuid() or pst.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
|
||||
die(f"secret directory for {label} is too open; chmod 700 it")
|
||||
try:
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
die(f"secret file for {label} is not valid JSON")
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def load_token() -> str:
|
||||
"""Read the access token. Empty string means 'not set up yet'."""
|
||||
data = load_secret_file(TOKEN_FILE, "beeper")
|
||||
token = data.get("token")
|
||||
return str(token) if isinstance(token, str) else ""
|
||||
|
||||
|
||||
def save_token(token: str) -> None:
|
||||
ensure_config_dirs()
|
||||
write_private(TOKEN_FILE, json.dumps({"token": token}, indent=2) + "\n")
|
||||
|
||||
|
||||
def encode_id(chat_id: str) -> str:
|
||||
"""One row per chat, so the opaque id carries only the chat id."""
|
||||
blob = base64.urlsafe_b64encode(chat_id.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
return f"{ID_PREFIX}:{blob}"
|
||||
|
||||
|
||||
def decode_id(opaque: str) -> str:
|
||||
prefix, _, blob = str(opaque).partition(":")
|
||||
if prefix != ID_PREFIX or not blob:
|
||||
die("not a chat id")
|
||||
pad = "=" * ((4 - len(blob) % 4) % 4)
|
||||
try:
|
||||
chat_id = base64.urlsafe_b64decode(blob + pad).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
die("not a chat id")
|
||||
if not CHAT_ID_RE.match(chat_id):
|
||||
die("not a chat id")
|
||||
return chat_id
|
||||
Reference in new issue
Block a user