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
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()
|
||||
Reference in new issue
Block a user