mirror of
https://github.com/prdlk/omarchy-beeper.git
synced 2026-11-02 13:12:48 +00:00
313 lines
9.6 KiB
Python
313 lines
9.6 KiB
Python
#!/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()
|