- Emacs Lisp 36%
- Rust 34.9%
- Scheme 24%
- Go 2.7%
- Shell 1.7%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| assets | ||
| contrib | ||
| pqenv | ||
| .dir-locals.el | ||
| .gitignore | ||
| CHANGELOG.md | ||
| env.example | ||
| LICENSE | ||
| Makefile | ||
| manifest.scm | ||
| README.md | ||
| setup.sh | ||
| whatsapp-org.el | ||
| whatsapp.el | ||
| whatsappel.scm | ||
| whatsappel.service | ||
whatsapp.el — telega-style Emacs WhatsApp client
Guile bridge · wuzapi engine · no Baileys · no JavaScript
Official: git.securityops.co/cristiancmoises/whatsappel · Mirror: Codeberg · Mirror: GitHub
A WhatsApp client for Emacs whose UI/UX follows telega.el. The former Node/Baileys backend is gone; every line here is Guile Scheme and Emacs Lisp. The WhatsApp multi-device protocol itself is delegated to wuzapi (Go/whatsmeow) over its local REST API — that protocol (Noise + the Signal double-ratchet + WhatsApp protobufs) has no Guile implementation and a hand-rolled one would be a homemade-crypto liability. No JavaScript anywhere.
Architecture
Emacs (whatsapp.el) Guile (whatsappel.scm) wuzapi (Go/whatsmeow)
┌───────────────────────┐ HTTP+tok ┌──────────────────────────┐ HTTP ┌─────────────────────┐
│ root chat-list buffer │ ────────► │ /chats /chat /send │ ─────► │ /chat/send/text|... │ ──► WhatsApp
│ per-chat buffers │ ◄──────── │ /send/{image,video,...} │ ◄───── │ /chat/download* │
│ inline media │ :7337 │ /download /connect /qr │ │ /session/* │
│ │ │ /hook/<token> ◄─────────┼────────┤ webhook (inbound) │
└───────────────────────┘ loopback └──────────────────────────┘ POST └─────────────────────┘
The bridge keeps a per-chat message store fed by inbound webhooks, records your sent messages, and unifies 1:1 and group keys so each conversation is one chat.
Quick start
./setup.sh # checks deps, builds+installs pqenv, makes config, generates a token
# start wuzapi (below), then put its user token in .env (WUZAPI_TOKEN)
make run # loads .env and starts the bridge
setup.sh is idempotent — it never overwrites an existing .env or PQ identity.
It installs pqenv to ~/.local/bin and creates ~/.config/whatsappel/pq. A
Makefile (make check|pqenv|install|run|clean) and a hardened systemd user
unit (whatsappel.service) are included. Then load whatsapp.el in Emacs (see
Emacs setup).
UI/UX (telega-style)
- Root buffer (
*WhatsApp*): the chat list, with unread counts and last message.RETopens a chat. - Chat buffer (
*WhatsApp: <jid>*): read-only history above an editable input prompt at the bottom. Type andRETto send;C-jfor a newline. - Media model, same as telega: images and stickers (incl. WebP) render
inline, scaled to a sane width (
whatsapp-image-max-width/whatsapp-sticker-max-width); audio, video, GIFs and documents open in an external player. Inbound images/stickers are downloaded and cached automatically (toggle withwhatsapp-auto-load-images); any media can be saved to a file (s). All wuzapi media kinds are supported end to end (image/sticker/video/gif/audio/document), with the correct download endpoint andDirectPathper kind. - Telega-style actions on the message at point (
C-c C-mmenu, or direct keys): react with an emoji, native quoted reply (threaded server-side via WhatsApp'sContextInfo), forward to another chat, copy text, save media, delete (for everyone), mark read. Inbound replies show the quoted text inline. - Expired media (older than WhatsApp's CDN retention — common for history that
predates the link) cannot be re-downloaded directly. The media-retry action
(
Ron the media line) asks the sender to re-upload it; this needs the wuzapi patch incontrib/wuzapi/and is best-effort (the sender must be online and still have the media). Media received after linking downloads and renders normally.
Keybindings
Root buffer (whatsapp-root-mode):
| Key | Action |
|---|---|
RET |
open chat at point |
n / p |
next / previous chat |
g |
refresh chat list |
j |
jump to a chat by number |
q |
bury buffer |
Chat buffer (whatsapp-chat-mode):
| Key | Action |
|---|---|
RET |
send the input |
C-j |
newline in input |
C-c C-a then i v a f s g |
attach image / video / audio / file / sticker / gif |
RET or o on a media line |
download + open that media |
s on a media line |
save that media to a file |
R on a media line |
retry (re-request) expired media |
C-c C-m (or m on media) |
message action menu (react / reply / forward / copy / save / open / retry / delete / mark read) |
C-c r |
react to the message at point (emoji) |
C-c C-r |
reply to the message at point (native quoted reply) |
C-c C-k |
cancel the pending reply |
C-c C-f |
forward the message at point to another chat |
C-c C-w |
copy the message text |
C-c C-s |
save the message's media |
C-c C-d |
delete the message (for everyone, if yours) |
C-c C-l |
refresh this chat |
C-c C-e |
compose + send a post-quantum encrypted message |
C-c C-q |
bury buffer |
Global prefix (whatsapp-prefix-map, suggested C-c w): w chat list,
j open chat, c connect, s status, S re-sync from wuzapi, Q QR,
k PQ keygen, i PQ import
contact key, f show PQ fingerprint.
Dependencies
- Guile 3.0 + guile-json — the bridge.
- wuzapi (Go) — the WhatsApp engine: https://github.com/asternic/wuzapi.
- Emacs ≥ 28 — the client. An external player (
mpv,xdg-open, …) for audio/video/GIF.
Install Guile + guile-json
sudo apt install guile-3.0 guile-json # Debian/Ubuntu
sudo pacman -S guile guile-json # Arch (guile-json may be AUR)
sudo dnf install guile guile-json # Fedora
guix shell -m manifest.scm # Guix
wuzapi
git clone https://github.com/asternic/wuzapi.git && cd wuzapi && go build .
WUZAPI_ADMIN_TOKEN=$(openssl rand -hex 16) ./wuzapi -address 127.0.0.1 -port 8080
curl -s -X POST -H "Authorization: $WUZAPI_ADMIN_TOKEN" -H 'Content-Type: application/json' \
--data '{"name":"me","token":"YOUR_WUZAPI_USER_TOKEN","events":"Message"}' \
http://127.0.0.1:8080/admin/users
wuzapi's user-auth header is
Tokenin its examples butAuthorizationin its API reference (varies by build). The bridge defaults toToken; override withWUZAPI_TOKEN_HEADER=Authorizationif needed.
Run the bridge
cp env.example .env && $EDITOR .env # set WHATSAPPEL_TOKEN and WUZAPI_TOKEN
set -a; . ./.env; set +a
guile whatsappel.scm
WHATSAPPEL_TOKEN and WUZAPI_TOKEN are required; the bridge refuses to start
without them. Generate the bridge token with openssl rand -hex 32.
| Variable | Default | Meaning |
|---|---|---|
WHATSAPPEL_TOKEN |
(required) | Emacs↔bridge auth and webhook path secret |
WHATSAPPEL_HOST |
127.0.0.1 |
bind address |
WHATSAPPEL_PORT |
7337 |
bind port |
WHATSAPPEL_PUBLIC_URL |
http://HOST:PORT |
URL wuzapi calls back for webhooks |
WHATSAPPEL_SUBSCRIBE |
Message |
wuzapi events to subscribe |
WHATSAPPEL_CHAT_CAP |
500 |
messages retained per chat |
WHATSAPPEL_HISTORY |
200 |
per-chat history depth pulled from wuzapi |
WHATSAPPEL_LIDMAP_DB |
(empty) | path to wuzapi's main.db; read read-only to resolve @lid chats to phone/name (empty = off) |
WUZAPI_BASE_URL |
http://127.0.0.1:8080 |
wuzapi base URL |
WUZAPI_TOKEN |
(required) | wuzapi per-user token |
WUZAPI_TOKEN_HEADER |
Token |
header carrying the user token |
Autostart
Run wuzapi and the bridge at login so they're always up (the bridge depends on wuzapi, so start it second / declare the dependency).
systemd (most distros). A hardened user unit for the bridge ships as
whatsappel.service:
cp whatsappel.service ~/.config/systemd/user/
systemctl --user daemon-reload && systemctl --user enable --now whatsappel
Run wuzapi under its own unit (wuzapi ships a wuzapi.service), or add one that
Before=/Wants= the bridge.
Guix System / Guix Home (shepherd). Splice the two services in
contrib/guix-home-whatsappel.scm into the
services list of your home-environment, then:
guix home reconfigure ~/.config/guix/home.scm
herd start whatsappel-bridge # pulls in wuzapi via (requirement '(wuzapi))
herd status # both should be running
Both daemons bind to loopback only; secrets are read at runtime from
~/wuzapi/.env and ~/whatsappel/.env (mode 600), never placed in the store.
The wuzapi session persists under ~/.config/whatsappel/wuzapi-data, so the phone
link survives restarts — no re-scan.
Emacs setup
(add-to-list 'load-path "/path/to/whatsappel")
(require 'whatsapp)
(require 'whatsapp-org) ; optional — Org-mode integration
(setq whatsapp-bridge-url "http://127.0.0.1:7337"
whatsapp-bridge-token "the-same-value-as-WHATSAPPEL_TOKEN")
(global-set-key (kbd "C-c w") whatsapp-prefix-map)
M-x whatsapp-connect → M-x whatsapp-qr (scan via WhatsApp ▸ Linked devices) →
M-x whatsapp (chat list). M-x whatsapp-toggle-polling for live updates.
The client's whatsapp-bridge-token must equal the bridge's WHATSAPPEL_TOKEN.
Instead of copying the secret into your config, read it from .env at startup so
the two can never drift — this is the recommended wiring:
(defun my/whatsapp-load-bridge-env ()
"Set bridge URL + token from ~/whatsappel/.env (single source of truth)."
(let ((env (expand-file-name "~/whatsappel/.env")) (host "127.0.0.1") (port "7337"))
(when (file-readable-p env)
(with-temp-buffer
(insert-file-contents env)
(dolist (pair '(("WHATSAPPEL_HOST" . host) ("WHATSAPPEL_PORT" . port)))
(goto-char (point-min))
(when (re-search-forward
(format "^[ \t]*\\(?:export[ \t]+\\)?%s=\"?\\([^\"\n]+\\)\"?" (car pair)) nil t)
(set (cdr pair) (match-string 1))))
(goto-char (point-min))
(when (re-search-forward
"^[ \t]*\\(?:export[ \t]+\\)?WHATSAPPEL_TOKEN=\"?\\([^\"\n]+\\)\"?" nil t)
(setq whatsapp-bridge-token (match-string 1)))))
(setq whatsapp-bridge-url (format "http://%s:%s" host port))))
(with-eval-after-load 'whatsapp (my/whatsapp-load-bridge-env))
Org-mode integration (optional)
whatsapp-org.el is a separate, opt-in module — the core client never loads Org.
(require 'whatsapp-org) adds three seams:
whatsapp:links.org-store-link(C-c l) in a chat buffer or on a root-list line yields[[whatsapp:<jid>][WhatsApp: <name>]]; following the link opens that chat.C-c C-lcompletion offers your known chats.- Capture.
whatsapp-org-capture(C-c C-oin a chat, oroinwhatsapp-prefix-map) files the message at point throughorg-capturewith the sender, an inactive timestamp, a jump-back link, and the text as a quote block. PQ-safe: an undecryptedWAPQ1:envelope is captured as its placeholder, never silently revealed. - Send from Org.
whatsapp-org-modeaddsC-c C-w s(send the current subtree),C-c C-w r(send the region) andC-c C-w g(jump entry → chat). The target resolves from the entry's inheritedWHATSAPP_JIDproperty or a prompt; every send asks for confirmation first.
Behaviour notes (honest limits)
- Existing chats on link. After you link a device, the bridge imports your
conversations from wuzapi's history store on startup (and on demand via
POST /sync), so the chat list and past messages appear without waiting for a live message. This needs wuzapi history retention enabled for the user (POST /session/history {"history": N}). @lidnames. Chats addressed by WhatsApp's anonymous linked-ID (@lid) have no phone number in the history rows. PointWHATSAPPEL_LIDMAP_DBat wuzapi'smain.dband the bridge reads itswhatsmeow_lid_map(read-only) to resolve them to a saved contact name, or failing that the real phone number. Without it, such chats show their raw id until a live message supplies a name.- Outbound media is shown in the conversation as a
[kind] captionplaceholder — the bytes you sent are not stored back, so they are not re-rendered inline. - Inbound media rendering is best-effort: the bridge extracts wuzapi's media download fields from the webhook event defensively across key-casing variants, and always preserves the raw event. If a build's event schema differs, the message still appears as a labelled placeholder rather than failing.
- GIFs are sent through the video endpoint (WhatsApp represents GIFs as looping MP4 videos).
- The interactive buffer behaviour is not exercised by the build's automated tests (headless, no display); the client byte-compiles clean and its pure helpers are unit-tested, and the bridge is integration-tested end to end.
Security model
Protects
- The Emacs-facing API requires
X-Whatsappel-Token; the inbound webhook is reachable only at/hook/<token>. Token comparison is constant-time. Bridge and wuzapi both bind to loopback. Transport inherits WhatsApp's own E2EE.
Does NOT protect against
- A compromised host: the bridge and wuzapi see plaintext locally.
- wuzapi's on-disk session is a linked-device credential — anyone who can read it can impersonate your WhatsApp. Restrict its directory; use full-disk encryption.
- Plaintext secrets in
.envand Emacs config — protect both files. - Metadata (who you message, when, group membership) — visible to Meta.
- Account bans: wuzapi/whatsmeow is an unofficial client; use may violate WhatsApp's Terms of Service.
Post-quantum messages (opt-in, 1:1)
An optional end-to-end post-quantum envelope rides inside a normal WhatsApp
text message as a WAPQ1: blob, built by the bundled pqenv
tool (ML-KEM-1024 + ML-DSA-87 + ChaCha20-Poly1305, HKDF-SHA256; primitives from
the formally verified libcrux). It protects content only between two whatsappel
users who have exchanged public keys — to any normal contact it is an opaque
blob, and it does not hide metadata from Meta. It is not "post-quantum WhatsApp."
Usage:
C-c w k(whatsapp-pq-keygen) — once, to create your identity. Share~/.config/whatsappel/pq/identity.publicwith your contact.C-c w i(whatsapp-pq-import-contact) — import their.publicand associate it with the chat. Verify the printed fingerprint out of band. The fingerprint is pinned on first import (TOFU): a later import of a different key for the same chat is refused — a silent change can mean key substitution — unless you re-verify and override withC-u C-c w i.- In the chat, type and press
C-c C-eto send encrypted. InboundWAPQ1:messages are verified and decrypted on view, shown with a[PQ]marker; messages outside the freshness window show[encrypted — stale/replayed: outside freshness window]and other failures show[encrypted — decrypt/verify FAILED]. The window iswhatsapp-pq-max-age(default 7 days; 0 disables it).
Limits (carried from the pqenv threat model): 1:1 only (group keying is future
work); the in-chat C-c C-e path uses the single-shot envelope, which has no
forward secrecy (long-term KEM identity keys). A forward-secure session layer
(WAPQR: ephemeral-prekey bootstrap + symmetric ratchet) now exists in pqenv
(ratchet-prekey/-init/-accept/-send/-recv); wiring stateful per-chat
sessions into this client is the next step. Replay of old captured envelopes is
blocked by the freshness window on view; per-message replay-on-receive across
restarts is not wired. See pqenv/README.md for the full threat
model, wire formats, and the WAPQR handshake.
Repositories
| Role | URL |
|---|---|
| Official (Forgejo) | https://git.securityops.co/cristiancmoises/whatsappel |
| Mirror (Codeberg) | https://codeberg.org/berkeley/whatsappel |
| Mirror (GitHub) | https://github.com/cristiancmoises/whatsappel |
Issues and pull requests are tracked on the Forgejo repo; the mirrors are read-only copies kept in sync at each release.
Author
Cristian Cezar Moisés — https://securityops.co
License
AGPL-3.0-only. A network-facing bridge is the textbook AGPL case: if
you run a modified version as a service, the AGPL requires you to offer that
modified source to its users. The bundled pqenv crate and the Guile bridge carry
the same SPDX-License-Identifier: AGPL-3.0-only headers.