feat: omarchy-beeper bar widget for unread Beeper chats

This commit is contained in:
prdlk committed 2026-09-01 11:15:22 -04:00
commit 7fc78f1075
17 files changed
+3483

No files matched your search

+326
View File
@@ -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
View File
@@ -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
View File
@@ -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