|
#!/usr/bin/env python3 |
|
"""Read and send chats on any Beeper-bridged network (WhatsApp, Instagram, |
|
Telegram, Signal, LinkedIn, Messenger, X, Discord, Slack, ...). |
|
|
|
beeper.py --list recently active chats, all networks |
|
beeper.py "Chris Leiter" last 20 messages |
|
beeper.py "Chris Leiter" -n 40 |
|
beeper.py "Sarah" --network whatsapp narrow when a name is ambiguous |
|
beeper.py --send "Chris Leiter" "text" send (see the guard below) |
|
beeper.py --auth re-run the OAuth handshake |
|
|
|
Full-text search over message content, across every network at once: |
|
|
|
beeper.py --search "fund II" everywhere |
|
beeper.py --search dinner --in "Chris Leiter" within one thread |
|
beeper.py --search "intro" --from me --since 2026-01-01 |
|
|
|
--search matches what was *said*; a bare name argument matches who it was said |
|
to. Use --search when you remember a phrase, a name lookup when you remember a |
|
person. |
|
|
|
Sending refuses to guess. If a name matches zero or several chats it prints the |
|
candidates and exits 1 rather than picking one, because a message delivered to |
|
the wrong thread cannot be recalled. Narrow with --network, or pass the exact |
|
chat ID shown by --list. |
|
|
|
For iMessage use msgs.py instead: it reads chat.db directly and works whether or |
|
not Beeper is running. |
|
|
|
Requires Beeper Desktop to be running (the API dies with the app, and |
|
beeper-nightly-quit.sh quits it when the machine idles overnight). |
|
""" |
|
import argparse, json, os, re, sys, urllib.error, urllib.parse, urllib.request |
|
from pathlib import Path |
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
|
import watermark # per-session read watermarks; --send refuses a stale draft |
|
|
|
BASE = "http://127.0.0.1:23373" |
|
TOKEN_FILE = Path.home() / ".claude/.beeper-token.json" |
|
REDIRECT_PORT = 8899 |
|
|
|
|
|
def die(msg, code=1): |
|
print(msg, file=sys.stderr) |
|
raise SystemExit(code) |
|
|
|
|
|
def strip_html(s): |
|
"""Bridged messages arrive as HTML fragments; render them flat for the terminal.""" |
|
s = re.sub(r"</p>\s*<p>", "\n", s or "") |
|
s = re.sub(r"<br\s*/?>", "\n", s) |
|
s = re.sub(r"<[^>]+>", "", s) |
|
return (s.replace("&", "&").replace("<", "<") |
|
.replace(">", ">").replace(""", '"').replace("'", "'").strip()) |
|
|
|
|
|
def api(path, method="GET", body=None, params=None): |
|
if params: |
|
path += "?" + urllib.parse.urlencode(params, doseq=True) |
|
tok = load_token() |
|
req = urllib.request.Request( |
|
BASE + path, |
|
data=json.dumps(body).encode() if body is not None else None, |
|
headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}, |
|
method=method, |
|
) |
|
try: |
|
with urllib.request.urlopen(req, timeout=30) as r: |
|
return json.load(r) |
|
except urllib.error.HTTPError as e: |
|
if e.code == 401: |
|
die("Token rejected. Re-run: beeper.py --auth") |
|
die(f"HTTP {e.code}: {e.read().decode()[:300]}") |
|
except urllib.error.URLError: |
|
die("Cannot reach Beeper on 127.0.0.1:23373. Is Beeper Desktop running?") |
|
|
|
|
|
def load_token(): |
|
if not TOKEN_FILE.exists(): |
|
die("No saved token. Run: beeper.py --auth") |
|
return json.loads(TOKEN_FILE.read_text())["access_token"] |
|
|
|
|
|
def do_auth(): |
|
"""OAuth2 PKCE against the local server. Registers a client, then opens a browser |
|
approval that Beeper serves itself.""" |
|
import base64, hashlib, http.server, secrets, threading, webbrowser |
|
|
|
redirect = f"http://127.0.0.1:{REDIRECT_PORT}/callback" |
|
reg = json.loads(urllib.request.urlopen(urllib.request.Request( |
|
f"{BASE}/oauth/register", |
|
data=json.dumps({ |
|
"client_name": "claude-cli", "redirect_uris": [redirect], |
|
"grant_types": ["authorization_code"], "response_types": ["code"], |
|
"token_endpoint_auth_method": "none", "scope": "read write", |
|
}).encode(), |
|
headers={"Content-Type": "application/json"}, method="POST"), timeout=15).read()) |
|
client_id = reg["client_id"] |
|
|
|
verifier = base64.urlsafe_b64encode(os.urandom(40)).decode().rstrip("=") |
|
challenge = base64.urlsafe_b64encode( |
|
hashlib.sha256(verifier.encode()).digest()).decode().rstrip("=") |
|
state = secrets.token_urlsafe(16) |
|
got = {} |
|
|
|
class H(http.server.BaseHTTPRequestHandler): |
|
def do_GET(self): |
|
got.update({k: v[0] for k, v in urllib.parse.parse_qs( |
|
urllib.parse.urlparse(self.path).query).items()}) |
|
self.send_response(200) |
|
self.send_header("Content-Type", "text/html") |
|
self.end_headers() |
|
self.wfile.write(b"<h2>Approved. You can close this tab.</h2>") |
|
threading.Thread(target=self.server.shutdown).start() |
|
|
|
def log_message(self, *a): |
|
pass |
|
|
|
srv = http.server.HTTPServer(("127.0.0.1", REDIRECT_PORT), H) |
|
url = f"{BASE}/oauth/authorize?" + urllib.parse.urlencode({ |
|
"response_type": "code", "client_id": client_id, "redirect_uri": redirect, |
|
"scope": "read write", "state": state, |
|
"code_challenge": challenge, "code_challenge_method": "S256"}) |
|
print("Approve in the browser tab that just opened:\n " + url) |
|
webbrowser.open(url) |
|
srv.serve_forever() |
|
|
|
if got.get("state") != state or "code" not in got: |
|
die("Authorization failed or was denied.") |
|
tok = json.loads(urllib.request.urlopen(urllib.request.Request( |
|
f"{BASE}/oauth/token", |
|
data=urllib.parse.urlencode({ |
|
"grant_type": "authorization_code", "code": got["code"], |
|
"redirect_uri": redirect, "client_id": client_id, |
|
"code_verifier": verifier}).encode(), |
|
headers={"Content-Type": "application/x-www-form-urlencoded"}), timeout=20).read()) |
|
TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True) |
|
TOKEN_FILE.write_text(json.dumps({**tok, "client_id": client_id})) |
|
TOKEN_FILE.chmod(0o600) |
|
print(f"Saved to {TOKEN_FILE} (scopes: {tok.get('scope')})") |
|
|
|
|
|
def find_chats(name, network=None): |
|
if name.startswith("!") or name.startswith("~"): |
|
return [api("/v1/chats/" + urllib.parse.quote(name, safe=""))] |
|
p = {"query": name, "scope": "titles", "limit": 50, "includeMuted": True} |
|
hits = api("/v1/chats/search", params=p).get("items", []) |
|
if not hits: |
|
hits = api("/v1/chats/search", params={**p, "scope": "participants"}).get("items", []) |
|
if network: |
|
hits = [c for c in hits if network.lower() in (c.get("network", "") + c.get("accountID", "")).lower()] |
|
# An exact title match wins outright, so "Chris Leiter" never fans out. |
|
exact = [c for c in hits if (c.get("title") or "").lower() == name.lower()] |
|
return exact or hits |
|
|
|
|
|
def _cursor(msgs): |
|
"""Newest timestamp in a message list, as a comparable string. Beeper has no |
|
monotonic row id, but ISO-8601 timestamps sort correctly as strings.""" |
|
return max((m.get("timestamp") or "" for m in msgs), default="") or None |
|
|
|
|
|
def head_cursor(chat_id): |
|
msgs = api(f"/v1/chats/{urllib.parse.quote(chat_id, safe='')}/messages", |
|
params={"limit": 1}).get("items", []) |
|
return _cursor(msgs) |
|
|
|
|
|
def show(chat, n): |
|
# The endpoint ignores `limit`, so trim client-side. Items arrive newest-first. |
|
msgs = api(f"/v1/chats/{urllib.parse.quote(chat['id'], safe='')}/messages", |
|
params={"limit": n}).get("items", [])[:n] |
|
# Record the newest message this session has now seen, so --send can refuse a |
|
# reply drafted before they said something new. watermark.py |
|
watermark.mark("wa", chat["id"], _cursor(msgs)) |
|
print(f"=== {chat.get('title')} ({chat.get('network')}) — {len(msgs)} msgs ===") |
|
for m in reversed(msgs): |
|
body = strip_html(m.get("text") or "") |
|
if not body and m.get("type") != "TEXT": |
|
body = f"[{(m.get('type') or 'attachment').lower()}]" |
|
who = "ME " if m.get("isSender") else "THEM" |
|
print(f"{m['timestamp'][:16].replace('T',' ')} {who} {body}") |
|
|
|
|
|
def do_search(a): |
|
# Beeper ignores `sender`, `dateAfter` and `dateBefore` — verified 2026-07-25, they |
|
# return identical results filtered or not. Apply them here instead, and over-fetch |
|
# so the trim still leaves n rows. `chatIDs` does work server-side. |
|
local = bool(a.sender or a.since or a.until) |
|
p = {"query": a.search, "limit": min(a.n if not local else 20, 20), |
|
"includeMuted": True, "excludeLowPriority": False} |
|
if a.within: |
|
chats = find_chats(a.within, a.network) |
|
if len(chats) != 1: |
|
die(f"--in {a.within!r} matched {len(chats)} chats; be more specific.") |
|
p["chatIDs"] = [chats[0]["id"]] |
|
|
|
lo = (a.since if "T" in a.since else a.since + "T00:00:00Z") if a.since else None |
|
hi = (a.until if "T" in a.until else a.until + "T00:00:00Z") if a.until else None |
|
|
|
def keep(m): |
|
if a.sender and bool(m.get("isSender")) != (a.sender == "me"): |
|
return False |
|
if lo and m.get("timestamp", "") < lo: |
|
return False |
|
if hi and m.get("timestamp", "") >= hi: |
|
return False |
|
return True |
|
|
|
# Results come newest-first, 20 per page. With client-side filters we page back |
|
# until we have n, but a narrow --since/--until can outrun the cap, so say so |
|
# rather than printing "no messages" and implying the archive is empty. |
|
MAX_PAGES = 100 if local else 1 |
|
hits, chats, cursor, pages, exhausted = [], {}, None, 0, False |
|
while pages < MAX_PAGES: |
|
if cursor: |
|
p.update({"cursor": cursor, "direction": "before"}) |
|
r = api("/v1/messages/search", params=p) |
|
pages += 1 |
|
items = r.get("items", []) |
|
chats.update(r.get("chats") or {}) |
|
hits += [m for m in items if keep(m)] |
|
cursor = r.get("oldestCursor") |
|
if not r.get("hasMore") or not cursor or not items: |
|
exhausted = True |
|
break |
|
# Paged past the start of the window; older pages cannot match. |
|
if lo and items[-1].get("timestamp", "") < lo: |
|
exhausted = True |
|
break |
|
if len(hits) >= a.n: |
|
break |
|
# Pages aren't internally sorted, so order explicitly: keep the n newest, show oldest-first. |
|
hits.sort(key=lambda m: m.get("timestamp", ""), reverse=True) |
|
hits = hits[:a.n] |
|
if not hits: |
|
print(f"No messages matching {a.search!r}" |
|
+ ("" if exhausted else |
|
f" in the {pages * 20} most recent hits (stopped at the page cap; " |
|
f"older matches may exist — narrow with --in, or raise MAX_PAGES)") + ".") |
|
return |
|
for m in reversed(hits): |
|
c = chats.get(m.get("chatID"), {}) |
|
title, net = c.get("title") or "?", c.get("network") or "?" |
|
who = "ME" if m.get("isSender") else "THEM" |
|
body = " ".join(strip_html(m.get("text") or "").split()) |
|
print(f"{m['timestamp'][:16].replace('T',' ')} {net:<9} {title[:24]:<24} {who:<4} {body[:120]}") |
|
|
|
|
|
def main(): |
|
ap = argparse.ArgumentParser(add_help=False) |
|
ap.add_argument("target", nargs="?") |
|
ap.add_argument("body", nargs="?") |
|
ap.add_argument("-n", type=int, default=20) |
|
ap.add_argument("--network") |
|
ap.add_argument("--send", action="store_true") |
|
ap.add_argument("--force", action="store_true", |
|
help="send even though this session has not read the newest messages") |
|
ap.add_argument("--list", action="store_true") |
|
ap.add_argument("--auth", action="store_true") |
|
ap.add_argument("--search") |
|
ap.add_argument("--in", dest="within", help="limit --search to one chat") |
|
ap.add_argument("--from", dest="sender", choices=["me", "others"]) |
|
ap.add_argument("--since", help="ISO date, e.g. 2026-01-01") |
|
ap.add_argument("--until") |
|
ap.add_argument("-h", "--help", action="store_true") |
|
a = ap.parse_args() |
|
|
|
if a.help or (not a.target and not a.list and not a.auth and not a.search): |
|
print(__doc__) |
|
return |
|
if a.auth: |
|
return do_auth() |
|
if a.search: |
|
return do_search(a) |
|
if a.list: |
|
for c in api("/v1/chats/search", params={"limit": 30, "includeMuted": True}).get("items", []): |
|
print(f"{(c.get('lastActivity') or '')[:10]} {c.get('network','?'):<10} " |
|
f"{(c.get('title') or '?')[:34]:<34} {c['id']}") |
|
return |
|
|
|
chats = find_chats(a.target, a.network) |
|
if not chats: |
|
die(f"No chat matching {a.target!r}" + (f" on {a.network}" if a.network else "")) |
|
if len(chats) > 1: |
|
print(f"{len(chats)} chats match {a.target!r} — narrow with --network or pass an id:", |
|
file=sys.stderr) |
|
for c in chats[:12]: |
|
print(f" {c.get('network','?'):<10} {c.get('title')} {c['id']}", file=sys.stderr) |
|
raise SystemExit(1) |
|
|
|
chat = chats[0] |
|
if not a.send: |
|
return show(chat, a.n) |
|
if not a.body: |
|
die("--send needs message text as the second argument") |
|
if chat.get("isReadOnly"): |
|
die(f"{chat.get('title')} is read-only.") |
|
watermark.check("wa", chat["id"], head_cursor(chat["id"]), a.force, |
|
hint=f' with `beeper.py "{chat.get("title")}" -n 5`') |
|
api(f"/v1/chats/{urllib.parse.quote(chat['id'], safe='')}/messages", |
|
method="POST", body={"text": a.body}) |
|
# Our own send advances the thread head; without this the second message of a |
|
# two-part send trips the unread guard on something we wrote ourselves. |
|
watermark.mark("wa", chat["id"], head_cursor(chat["id"])) |
|
print(f"Sent to {chat.get('title')} on {chat.get('network')}.") |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |