Add beeper skill: CLI + docs for local Beeper Desktop API

This commit is contained in:
lakshit verma 2026-08-07 19:34:28 +05:30
commit 73812a1fb0
No known key found for this signature in database
GPG key ID: EB498AFC60A7A01A
3 changed files with 296 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
config.json

110
SKILL.md Normal file
View file

@ -0,0 +1,110 @@
---
name: beeper
description: Read, search, and send messages through the local Beeper Desktop API (WhatsApp, Instagram, Signal, X, Google Chat, Matrix, and more) that runs on this machine. Use when the user asks to message someone, send a link to a chat/group, read or summarize chat history, find a contact or chat, or post to a WhatsApp group via Beeper.
---
# Beeper
Beeper Desktop exposes a fully local REST API on this machine. It bridges
WhatsApp, Instagram, Signal, X, Google Chat, Matrix, and more. Everything is
local — no cloud service is involved in the send.
## Configuration
- Base URL and token live in `config.json` next to this file:
- `BEEPER_BASE_URL` — e.g. `http://localhost:23373`. **The port is dynamic**;
Beeper Desktop can bind to a different port. Verify it before relying on it.
- `BEEPER_ACCESS_TOKEN` — the `bdapi_...` token shown in Beeper Desktop.
- Environment variables `BEEPER_BASE_URL` / `BEEPER_ACCESS_TOKEN` override the
config file (useful for scripts that must not hardcode the token).
> Security: the token in `config.json` can send messages as this user from this
> machine. Treat the file as a secret — do not commit it, paste it in chat, or
> include it in logs. Rotate it from Beeper Desktop if it ever leaks.
## Start Here
1. **Verify the server is up and find the current base URL:**
`python3 <skill-dir>/scripts/beeper.py info`
(`<skill-dir>` = the directory containing this SKILL.md)
If the port changed, update `BEEPER_BASE_URL` in `config.json`.
2. **List connected accounts/networks:**
`python3 <skill-dir>/scripts/beeper.py accounts`
3. **Find the chat you want** (search by name):
`python3 <skill-dir>/scripts/beeper.py chats search "Akhil" --type single`
## Helper CLI
All commands accept a full chat ID, e.g.
`!K-1XQXXXXXXXXXXXXXXXXXXXXXX:ba_XXX-XXXXXXXXXXXXXXXXXXXXXXX.local-whatsapp.localhost`
(chat IDs are opaque; URL-encoding is handled internally).
| Command | Purpose |
|---|---|
| `beeper.py info` | Server info, including the live base URL and port |
| `beeper.py accounts` | Connected accounts and their `accountID`s |
| `beeper.py chats search QUERY [--type single\|group]` | Find chats by name; shows participants and networks |
| `beeper.py chats list [--limit N]` | Recent chats across all accounts |
| `beeper.py messages CHAT_ID [--limit N] [--after-cursor C]` | Read a chat's message history (chronological) |
| `beeper.py send CHAT_ID "text"` | Send a plain-text message |
| `beeper.py search QUERY [--limit N]` | Search messages globally |
`messages` returns a pagination cursor (`oldestCursor`) when `hasMore` is true;
pass it back via `--after-cursor` to page further back. Note the API caps a
single page at 20 items when paginating.
## Raw REST API (when the CLI is not enough)
The API surface is small and stable. Always send `Authorization: Bearer <token>`.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | `/v1/info` | Server info; authoritative base URL/port |
| GET | `/v1/accounts` | Connected accounts (networks) |
| GET | `/v1/chats/search?query=...&type=single&limit=20` | Search chats |
| GET | `/v1/chats?limit=20` | List chats |
| GET | `/v1/chats/{chatID}/messages?limit=20[&cursor=...]` | Read messages (paginate with `oldestCursor`) |
| POST | `/v1/chats/{chatID}/messages` body `{"text": "..."}` | Send a message |
| GET | `/v1/messages/search?query=...` | Search messages |
| POST | `/v1/chats/{chatID}/read` | Mark chat read |
| POST | `/v1/chats/{chatID}/unread` | Mark chat unread |
| POST | `/v1/chats/{chatID}/notify-anyway` | Notify anyway |
| POST | `/v1/chats/{chatID}/archive` | Archive a chat |
| PATCH | `/v1/chats/{chatID}` | Update chat (e.g. set a draft) |
| POST | `/v1/chats/start` | Start a new chat |
| POST | `/v1/chats/{chatID}/messages/{messageID}/reactions` | Add a reaction |
| POST | `/v1/assets/upload` | Upload an attachment |
| GET | `/v1/assets/serve?...` | Fetch an attachment |
## Workflows
### Send a message to a contact or group
1. `beeper.py chats search "<name>"` and pick the right chat (prefer the
direct/single chat over a group unless the user says group).
2. `beeper.py send "<chat id>" "the message"` — response includes a
`pendingMessageID`; HTTP 200 means accepted.
### Summarize a chat
1. Find the chat id (`chats search`).
2. Dump history: `beeper.py messages "<chat id>" --limit 200`.
3. For more history, page with `--after-cursor <oldestCursor>` (repeats until
`hasMore` is false).
4. Sort by timestamp and summarize. Prefer keeping the transcript in files
under the temp dir rather than spamming the conversation context.
### Verify delivery
If the user asks to confirm a send, re-read the chat with
`beeper.py messages "<chat id>" --limit 5` and look for the senderName/timestamp
of the just-sent message.
## Notes
- Message history depth depends on what Beeper has indexed; recent messages are
the most reliable.
- Message `text` may contain HTML link markup (e.g. `<a href=...>@Name</a>`);
strip tags when displaying/parsing.
- `senderID == "@vee1e:beeper.com"` (or the account's self user) marks messages
the user sent.
- Only send messages the user asked for; do not reply on their behalf.

185
scripts/beeper.py Executable file
View file

@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Beeper Desktop API helper.
Reads BEEPER_BASE_URL and BEEPER_ACCESS_TOKEN from config.json next to this
file (or from the environment, which takes precedence). Uses only the stdlib.
Usage:
beeper.py info # server info incl. current base URL
beeper.py accounts # list connected accounts/networks
beeper.py chats search QUERY [--type single|group] [--limit N]
beeper.py chats list [--limit N] # recent chats across all accounts
beeper.py messages CHAT_ID [--limit N] [--after-cursor C]
beeper.py send CHAT_ID "text" # send a plain-text message
beeper.py search QUERY [--limit N] # global message search
"""
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
def load_config():
cfg = {}
if CONFIG_PATH.exists():
cfg = json.loads(CONFIG_PATH.read_text())
base = os.environ.get("BEEPER_BASE_URL") or cfg.get("BEEPER_BASE_URL")
token = os.environ.get("BEEPER_ACCESS_TOKEN") or cfg.get("BEEPER_ACCESS_TOKEN")
if not base or not token:
sys.exit("error: BEEPER_BASE_URL and BEEPER_ACCESS_TOKEN required (config.json or env)")
return base.rstrip("/"), token
def api(base, token, method, path, params=None, body=None, timeout=30):
url = f"{base}{path}"
if params:
qs = urllib.parse.urlencode(params)
url = f"{url}?{qs}"
data = None
headers = {"Authorization": f"Bearer {token}"}
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
return resp.status, json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
return e.code, e.read().decode(errors="replace")
def pretty(obj):
return json.dumps(obj, indent=2, ensure_ascii=False)
def quote_chat_id(chat_id):
return urllib.parse.quote(chat_id, safe="")
def cmd_info(base, token, args):
code, data = api(base, token, "GET", "/v1/info")
print(f"HTTP {code}")
print(pretty(data))
def cmd_accounts(base, token, args):
code, data = api(base, token, "GET", "/v1/accounts")
print(f"HTTP {code}")
if not data:
return
for acc in data:
print(f"- {acc.get('network')!r} accountID={acc.get('accountID')} "
f"user={acc.get('user', {}).get('fullName')} "
f"status={acc.get('status')}")
def cmd_chats_search(base, token, args):
params = {"query": args.query, "limit": args.limit or 20}
if args.type:
params["type"] = args.type
code, data = api(base, token, "GET", "/v1/chats/search", params)
print(f"HTTP {code}")
for it in (data or {}).get("items", []):
print(f"id: {it.get('id')}")
print(f" {it.get('network')} | type={it.get('type')} | title={it.get('title')}")
for p in it.get("participants", {}).get("items", []):
print(f" - {p.get('fullName')} {p.get('phoneNumber') or ''}"
+ (" [self]" if p.get("isSelf") else ""))
print()
def cmd_chats_list(base, token, args):
code, data = api(base, token, "GET", "/v1/chats", {"limit": args.limit or 20})
print(f"HTTP {code}")
for it in (data or {}).get("items", []):
print(f"id: {it.get('id')}")
print(f" {it.get('network')} | type={it.get('type')} | title={it.get('title')}")
print()
def cmd_messages(base, token, args):
params = {"limit": args.limit or 50}
if args.after_cursor:
params["cursor"] = args.after_cursor
code, data = api(base, token, "GET",
f"/v1/chats/{quote_chat_id(args.chat_id)}/messages", params)
print(f"HTTP {code}")
if not data:
return
items = data.get("items", [])
for m in sorted(items, key=lambda x: x.get("timestamp") or ""):
print(f"[{m.get('timestamp')}] {m.get('senderName')}: {m.get('text')}")
print(f"\n{len(items)} messages | hasMore={data.get('hasMore')} "
f"| oldestCursor={data.get('oldestCursor')}")
def cmd_send(base, token, args):
code, data = api(base, token, "POST",
f"/v1/chats/{quote_chat_id(args.chat_id)}/messages",
body={"text": args.text})
print(f"HTTP {code}")
if data:
print(pretty(data))
def cmd_search(base, token, args):
params = {"query": args.query, "limit": args.limit or 20}
code, data = api(base, token, "GET", "/v1/messages/search", params)
print(f"HTTP {code}")
for it in (data or {}).get("items", []):
print(f"[{it.get('timestamp')}] {it.get('chatID')}")
print(f" {it.get('senderName')}: {it.get('text')}")
print()
def main():
base, token = load_config()
p = argparse.ArgumentParser(description="Beeper Desktop API helper")
sub = p.add_subparsers(dest="cmd", required=True)
pi = sub.add_parser("info")
pi.set_defaults(fn=cmd_info)
pa = sub.add_parser("accounts")
pa.set_defaults(fn=cmd_accounts)
s = sub.add_parser("chats")
ssub = s.add_subparsers(dest="sub", required=True)
ps = ssub.add_parser("search")
ps.add_argument("query")
ps.add_argument("--type", choices=["single", "group"])
ps.add_argument("--limit", type=int)
ps.set_defaults(fn=cmd_chats_search)
pl = ssub.add_parser("list")
pl.add_argument("--limit", type=int)
pl.set_defaults(fn=cmd_chats_list)
pm = sub.add_parser("messages")
pm.add_argument("chat_id")
pm.add_argument("--limit", type=int)
pm.add_argument("--after-cursor", dest="after_cursor")
pm.set_defaults(fn=cmd_messages)
ps2 = sub.add_parser("send")
ps2.add_argument("chat_id")
ps2.add_argument("text")
ps2.set_defaults(fn=cmd_send)
pg = sub.add_parser("search")
pg.add_argument("query")
pg.add_argument("--limit", type=int)
pg.set_defaults(fn=cmd_search)
args = p.parse_args()
args.fn(base, token, args)
if __name__ == "__main__":
main()