diff --git a/.dockerignore b/.dockerignore
index 84588ab..731c62b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,9 +9,6 @@ __pycache__/
data/
tailwindcss/
tests/
-tools/wacli/.turbo/
-tools/wacli/.next/
-tools/wacli/node_modules/
*.md
!skills/*.md
.tmux-session
diff --git a/.env.example b/.env.example
index eebc895..a6c502e 100644
--- a/.env.example
+++ b/.env.example
@@ -15,11 +15,8 @@ ADMIN_API_KEY=
# Web search (Tavily) — get a free API key at https://app.tavily.com
TAVILY_API_KEY=
-# Himalaya email passwords (used by cli-configs/himalaya.toml via `printenv`)
-# For Gmail: generate at https://myaccount.google.com/apppasswords
-# For Fastmail: generate at https://www.fastmail.com/settings/security/devicekeys
-HIMALAYA_PERSONAL_PASSWORD=
-#HIMALAYA_WORK_PASSWORD=
+# Email accounts are configured via the Admin UI (stored in the config DB and
+# materialized to a Himalaya config at runtime) — no env vars needed here.
# CalDAV calendar credentials (used by config.yml calendar providers)
# For Google: use your Gmail + App Password (https://myaccount.google.com/apppasswords)
diff --git a/.gitignore b/.gitignore
index f95f9a7..49f47b0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,8 +24,6 @@ build/
config.yml
character.md
personalia.md
-cli-configs/*
-!cli-configs/*.example
# Data (runtime state, DB, models)
data/
@@ -40,6 +38,3 @@ tailwindcss
# Tailwind CSS build output
api/static/style.css
node_modules
-
-# WhatsApp CLI auth/session data
-tools/wacli/.wacli/
diff --git a/Dockerfile b/Dockerfile
index 7056e3f..6f5d29e 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,11 +3,11 @@ FROM python:3.14-slim
WORKDIR /app
# System deps (ffmpeg for voice pipeline, curl for health checks, sqlite3 for memory)
+# golang + build-essential build wacli (CGO, sqlite_fts5) from the pinned upstream tag.
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg curl ca-certificates jq sqlite3 \
bash tar gzip xz-utils \
golang build-essential pkg-config \
- nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# Install Himalaya CLI (pre-built Rust binary for email management)
@@ -22,6 +22,14 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*
+# Install wacli from pinned upstream tag (github.com/openclaw/wacli).
+# Bump WACLI_VERSION to cross WhatsApp protocol breaks (e.g. 405 Client Outdated).
+ARG WACLI_VERSION=v0.11.0
+RUN CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
+ GOBIN=/usr/local/bin \
+ go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@${WACLI_VERSION} \
+ && wacli version
+
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
@@ -29,8 +37,6 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --no-dev --no-install-project
-# Contacts tooling handled by native CLI in /app/tools
-
# Create non-root user
RUN groupadd --gid 10001 mpa && \
useradd --uid 10001 --gid 10001 --create-home --shell /bin/bash mpa
@@ -52,17 +58,14 @@ RUN ARCH=$(dpkg --print-architecture) && \
/tmp/tailwindcss --input api/static/input.css --output api/static/style.css --minify && \
rm /tmp/tailwindcss
-# CLI config directories
-RUN mkdir -p /home/mpa/.config/himalaya /app/data \
+# Data directory
+RUN mkdir -p /app/data \
&& chown -R mpa:mpa /home/mpa /app
-# Build wacli
-RUN corepack enable && \
- corepack prepare pnpm@9.15.2 --activate && \
- cd tools/wacli && \
- pnpm -s build
-
USER mpa
+# Identify the linked WhatsApp device as "MPA" (native since wacli 0.2.0).
+ENV WACLI_DEVICE_LABEL=MPA
+
EXPOSE 8000
CMD ["uv", "run", "python", "-m", "core.main"]
diff --git a/Makefile b/Makefile
index 6925ebe..d83b7de 100644
--- a/Makefile
+++ b/Makefile
@@ -6,6 +6,9 @@ TAILWIND := ./tailwindcss
CSS_IN := api/static/input.css
CSS_OUT := api/static/style.css
+# Pinned upstream wacli (github.com/openclaw/wacli). Keep in sync with Dockerfile.
+WACLI_VERSION := v0.11.0
+
# Show available targets
help:
@echo ""
@@ -23,7 +26,7 @@ help:
@echo " make dev Show instructions for running dev services"
@echo " make dev-agent Run agent with auto-reload"
@echo " make dev-css Run Tailwind CSS watcher"
- @echo " make dev-wa Build WhatsApp CLI (wacli)"
+ @echo " make dev-wa Install WhatsApp CLI (wacli) from upstream"
@echo " make docs-dev Run docs site with hot reload"
@echo ""
@echo " Quality:"
@@ -94,11 +97,9 @@ dev:
@echo " 2. Tailwind CSS watcher:"
@echo " make dev-css"
@echo ""
- @if [ -d tools/wacli ]; then \
- echo " 3. WhatsApp (wacli build):"; \
- echo " make dev-wa"; \
- echo ""; \
- fi
+ @echo " 3. WhatsApp (wacli install):"
+ @echo " make dev-wa"
+ @echo ""
@echo " Docs (optional):"
@echo " make docs-dev"
@echo ""
@@ -114,12 +115,16 @@ dev-agent:
dev-css:
$(TAILWIND) --input $(CSS_IN) --output $(CSS_OUT) --watch
-# Dev: WhatsApp (wacli)
+# Dev: WhatsApp (wacli) — install the pinned upstream binary into $GOBIN/~/go/bin.
+# Needs Go + a C toolchain (CGO, sqlite_fts5). Override WACLI_BIN to point the
+# agent elsewhere; otherwise core/wacli.py resolves it from PATH or ~/go/bin.
dev-wa:
- @if [ ! -x tools/wacli/dist/wacli ]; then \
- echo "Building wacli..."; \
- cd tools/wacli && pnpm -s build; \
- fi
+ @command -v wacli >/dev/null 2>&1 || test -x "$(HOME)/go/bin/wacli" || { \
+ echo "Installing wacli $(WACLI_VERSION) from github.com/openclaw/wacli..."; \
+ CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
+ go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@$(WACLI_VERSION); \
+ }
+ @wacli version 2>/dev/null || "$(HOME)/go/bin/wacli" version
# Remove venv and caches
clean:
diff --git a/README.md b/README.md
index 77d0a15..302b21a 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,6 @@ MPA uses a dual-layer config system:
| `character.md` | Agent personality and communication style (editable) |
| `personalia.md` | Agent identity facts — name, owner, context (append-only) |
| `skills/*.md` | Skill documents that teach the agent how to use tools |
-| `cli-configs/` | Configuration for Himalaya |
## Project structure
@@ -163,8 +162,8 @@ Behavior and identity are configured in `character.md.example` and `personalia.m
## WhatsApp
-MPA uses wacli to authenticate and sync WhatsApp locally. The admin UI starts auth, displays the QR code, and manages sync.
-See `tools/wacli/` for the vendored CLI source and build instructions.
+MPA uses [wacli](https://github.com/openclaw/wacli) to authenticate and sync WhatsApp locally. The admin UI starts auth, displays the QR code, and manages sync.
+The binary is installed from a pinned upstream tag (`WACLI_VERSION` in the `Dockerfile`; `make dev-wa` for local dev) — not vendored. See `docs/content/docs/channels.mdx` for upgrade/re-auth notes.
## Tech stack
diff --git a/api/admin.py b/api/admin.py
index cfdd7e9..5cf7346 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -1750,7 +1750,7 @@ async def test_channel_whatsapp(body: WhatsAppTestIn) -> dict:
if not available:
result["error"] = (
"wacli binary not found. "
- "Run 'make dev-wa' or 'cd tools/wacli && pnpm build' to compile it."
+ "Run 'make dev-wa' to install it, or set WACLI_BIN to its path."
)
return result
@@ -1761,8 +1761,15 @@ async def whatsapp_auth_status() -> dict:
@app.post("/channels/whatsapp/auth/start", dependencies=[Depends(auth)])
async def whatsapp_auth_start() -> dict:
+ if not wacli.available():
+ return {
+ "ok": False,
+ "available": False,
+ "error": "wacli binary not found. Run 'make dev-wa' to install it, "
+ "or set WACLI_BIN to its path.",
+ }
await wacli.start_auth()
- return {"ok": True}
+ return {"ok": True, "available": True}
@app.post("/channels/whatsapp/auth/stop", dependencies=[Depends(auth)])
async def whatsapp_auth_stop() -> dict:
diff --git a/api/templates/dashboard.html b/api/templates/dashboard.html
index 49a33fb..e1ad3e7 100644
--- a/api/templates/dashboard.html
+++ b/api/templates/dashboard.html
@@ -445,6 +445,10 @@
Agent Control
if (!r.ok) {
throw new Error('Auth start failed');
}
+ const d = await r.json();
+ if (d && d.ok === false) {
+ throw new Error(d.error || 'Auth start failed');
+ }
await pollWhatsAppAuth();
} catch (e) {
result.textContent = e.message || 'Auth start failed.';
diff --git a/cli-configs/himalaya.toml.example b/cli-configs/himalaya.toml.example
deleted file mode 100644
index b5b317e..0000000
--- a/cli-configs/himalaya.toml.example
+++ /dev/null
@@ -1,64 +0,0 @@
-# Himalaya configuration for the personal agent.
-#
-# Copy this file to cli-configs/himalaya.toml and fill in your credentials.
-# Passwords are read from environment variables via the `cmd` auth method,
-# which calls `printenv` at runtime. Set these in your .env file:
-#
-# HIMALAYA_PERSONAL_PASSWORD=your-app-password
-# HIMALAYA_WORK_PASSWORD=your-app-password
-#
-# For Gmail: generate an App Password at https://myaccount.google.com/apppasswords
-# For Fastmail: generate an App Password at https://www.fastmail.com/settings/security/devicekeys
-#
-# See https://github.com/pimalaya/himalaya/blob/master/config.sample.toml
-# for the full list of options.
-
-# --------------------------------------------------------------------------
-# Personal account (Gmail example)
-# --------------------------------------------------------------------------
-
-[accounts.personal]
-default = true
-email = "you@gmail.com"
-display-name = "Your Name"
-
-folder.aliases.inbox = "INBOX"
-folder.aliases.sent = "[Gmail]/Sent Mail"
-folder.aliases.drafts = "[Gmail]/Drafts"
-folder.aliases.trash = "[Gmail]/Trash"
-
-backend.type = "imap"
-backend.host = "imap.gmail.com"
-backend.port = 993
-backend.login = "you@gmail.com"
-backend.auth.type = "password"
-backend.auth.cmd = "printenv HIMALAYA_PERSONAL_PASSWORD"
-
-message.send.backend.type = "smtp"
-message.send.backend.host = "smtp.gmail.com"
-message.send.backend.port = 465
-message.send.backend.login = "you@gmail.com"
-message.send.backend.auth.type = "password"
-message.send.backend.auth.cmd = "printenv HIMALAYA_PERSONAL_PASSWORD"
-
-# --------------------------------------------------------------------------
-# Work account (Fastmail example)
-# --------------------------------------------------------------------------
-
-#[accounts.work]
-#email = "you@work.com"
-#display-name = "Your Name"
-#
-#backend.type = "imap"
-#backend.host = "imap.fastmail.com"
-#backend.port = 993
-#backend.login = "you@work.com"
-#backend.auth.type = "password"
-#backend.auth.cmd = "printenv HIMALAYA_WORK_PASSWORD"
-#
-#message.send.backend.type = "smtp"
-#message.send.backend.host = "smtp.fastmail.com"
-#message.send.backend.port = 465
-#message.send.backend.login = "you@work.com"
-#message.send.backend.auth.type = "password"
-#message.send.backend.auth.cmd = "printenv HIMALAYA_WORK_PASSWORD"
diff --git a/core/executor.py b/core/executor.py
index 185e15b..a43f440 100644
--- a/core/executor.py
+++ b/core/executor.py
@@ -19,14 +19,10 @@ def _find_wacli_bin() -> str:
from_path = shutil.which("wacli")
if from_path:
return from_path
- # Docker: /app/tools/wacli/dist/wacli
- docker_path = Path("/app/tools/wacli/dist/wacli")
- if docker_path.exists():
- return str(docker_path)
- # Local dev: /tools/wacli/dist/wacli
- local_path = Path(__file__).resolve().parents[1] / "tools" / "wacli" / "dist" / "wacli"
- if local_path.exists():
- return str(local_path)
+ # Local dev: `go install` / `make dev-wa` drops the binary in ~/go/bin.
+ go_bin = Path.home() / "go" / "bin" / "wacli"
+ if go_bin.exists():
+ return str(go_bin)
return "wacli" # fallback — let the shell try PATH
@@ -95,10 +91,14 @@ async def run_command_trusted(self, command: str, timeout: int = 30) -> dict:
async def _exec(self, command: str, timeout: int) -> dict:
"""Run a shell command and capture output."""
env = None
- if "himalaya" in command or self.tool_env:
+ wants_wacli_label = "wacli" in command and "WACLI_DEVICE_LABEL" not in os.environ
+ if "himalaya" in command or self.tool_env or wants_wacli_label:
env = os.environ.copy()
if "himalaya" in command:
env.update(himalaya_env())
+ # wacli: identify the linked device as MPA (matches the Docker ENV).
+ if wants_wacli_label:
+ env.setdefault("WACLI_DEVICE_LABEL", "MPA")
# Tool auth (e.g. GH_TOKEN) — only set when a tool is enabled.
env.update(self.tool_env)
proc = await asyncio.create_subprocess_shell(
diff --git a/core/wacli.py b/core/wacli.py
index 2cf0748..d094123 100644
--- a/core/wacli.py
+++ b/core/wacli.py
@@ -18,18 +18,28 @@ def default_wacli_bin() -> str:
from_path = shutil.which("wacli")
if from_path:
return from_path
- root = Path(__file__).resolve().parents[1]
- return str(root / "tools" / "wacli" / "dist" / "wacli")
+ # Fallback for local dev: `go install` drops the binary in GOBIN / ~/go/bin.
+ return str(Path.home() / "go" / "bin" / "wacli")
def default_wacli_store() -> str:
return os.getenv("WACLI_STORE", str(Path.home() / ".wacli"))
+def default_device_label() -> str:
+ return os.getenv("WACLI_DEVICE_LABEL", "MPA")
+
+
+# Lock-wait window for write commands: wait for the store lock instead of
+# failing fast when another wacli process (e.g. sync --follow) holds it.
+LOCK_WAIT = "30s"
+
+
@dataclass
class WacliManager:
bin_path: str = field(default_factory=default_wacli_bin)
store_dir: str = field(default_factory=default_wacli_store)
+ device_label: str = field(default_factory=default_device_label)
auth_proc: asyncio.subprocess.Process | None = None
latest_qr: str = ""
latest_qr_at: float = 0.0
@@ -38,19 +48,34 @@ class WacliManager:
def available(self) -> bool:
return Path(self.bin_path).exists()
- async def _run_json(self, args: list[str], *, timeout: float = 30) -> dict[str, Any]:
+ def _env(self) -> dict[str, str]:
+ """Subprocess env carrying the linked-device label (native since 0.2.0)."""
+ return {**os.environ, "WACLI_DEVICE_LABEL": self.device_label}
+
+ async def _run_json(
+ self, args: list[str], *, timeout: float = 30, read_only: bool = False
+ ) -> dict[str, Any]:
if not self.available():
return {"success": False, "error": "wacli not found"}
- proc = await asyncio.create_subprocess_exec(
- self.bin_path,
+ flags = [
"--store",
self.store_dir,
"--json",
"--timeout",
f"{int(timeout)}s",
+ "--lock-wait",
+ LOCK_WAIT,
+ ]
+ if read_only:
+ # Skip the session-store lock entirely for pure reads.
+ flags.append("--read-only")
+ proc = await asyncio.create_subprocess_exec(
+ self.bin_path,
+ *flags,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
+ env=self._env(),
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout + 5)
@@ -75,7 +100,7 @@ async def _run_json(self, args: list[str], *, timeout: float = 30) -> dict[str,
return {"success": False, "error": last}
async def auth_status(self) -> dict[str, Any]:
- res = await self._run_json(["auth", "status"])
+ res = await self._run_json(["auth", "status"], read_only=True)
authed = bool(res.get("data", {}).get("authenticated")) if res.get("success") else False
return {
"authenticated": authed,
@@ -93,23 +118,28 @@ async def start_auth(self) -> None:
return
self.latest_qr = ""
self.latest_qr_at = 0.0
+ # wacli >=0.8 streams the QR as an NDJSON `qr_code` event on stderr
+ # (with --events), not as JSON on stdout. Parse stderr accordingly.
self.auth_proc = await asyncio.create_subprocess_exec(
self.bin_path,
"--store",
self.store_dir,
- "--json",
+ "--events",
+ "--lock-wait",
+ LOCK_WAIT,
"auth",
"--idle-exit",
"30s",
- stdout=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
+ env=self._env(),
)
asyncio.create_task(self._consume_auth_output(self.auth_proc))
async def _consume_auth_output(self, proc: asyncio.subprocess.Process) -> None:
- if proc.stdout is None:
+ if proc.stderr is None:
return
- async for raw in proc.stdout:
+ async for raw in proc.stderr:
line = raw.decode().strip()
if not line:
continue
@@ -117,9 +147,12 @@ async def _consume_auth_output(self, proc: asyncio.subprocess.Process) -> None:
parsed = json.loads(line)
except json.JSONDecodeError:
continue
- if parsed.get("success") is True and parsed.get("data", {}).get("qr"):
- self.latest_qr = parsed["data"]["qr"]
- self.latest_qr_at = time.time()
+ # NDJSON envelope: {"event": "qr_code", "data": {"code": "..."}}
+ if parsed.get("event") == "qr_code":
+ code = parsed.get("data", {}).get("code")
+ if code:
+ self.latest_qr = code
+ self.latest_qr_at = time.time()
await proc.wait()
if self.auth_proc is proc:
self.auth_proc = None
@@ -132,14 +165,11 @@ async def stop_auth(self) -> None:
self.auth_proc = None
async def fetch_latest_qr(self) -> None:
- if self.latest_qr:
- return
- res = await self._run_json(["auth", "--idle-exit", "1s"])
- if res.get("success") is True:
- qr = res.get("data", {}).get("qr")
- if qr:
- self.latest_qr = qr
- self.latest_qr_at = time.time()
+ # The QR is streamed by the long-lived `auth` process started in
+ # start_auth() (see _consume_auth_output). Spawning a second `auth`
+ # here would contend for the store lock, so this is a no-op: callers
+ # read self.latest_qr, which the streaming consumer keeps current.
+ return
async def sync_once(self) -> dict[str, Any]:
"""Run a single sync pass (non-blocking, no long-lived process)."""
@@ -157,7 +187,7 @@ async def send_text(self, to: str, text: str) -> dict[str, Any]:
return await self._run_json(["send", "text", "--to", to, "--message", text])
async def list_messages(self, limit: int = 100) -> list[dict[str, Any]]:
- res = await self._run_json(["messages", "list", "--limit", str(limit)])
+ res = await self._run_json(["messages", "list", "--limit", str(limit)], read_only=True)
if res.get("success") is not True:
return []
return list(res.get("data", {}).get("messages") or [])
diff --git a/docker-compose.yml b/docker-compose.yml
index 4c5ecc2..6fe46e4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,5 +11,4 @@ services:
- ./character.md:/app/character.md:ro
- ./personalia.md:/app/personalia.md
- ./skills:/app/skills:ro
- - ./cli-configs/himalaya.toml:/home/mpa/.config/himalaya/config.toml:ro
env_file: .env
diff --git a/docs/content/docs/channels.mdx b/docs/content/docs/channels.mdx
index 49b3d4d..95e7f30 100644
--- a/docs/content/docs/channels.mdx
+++ b/docs/content/docs/channels.mdx
@@ -47,7 +47,9 @@ Subject: Meeting tomorrow
## WhatsApp
-MPA uses [wacli](https://github.com/nickelc/wacli) — a local WhatsApp CLI written in Go — for WhatsApp integration. No cloud APIs or business verification needed.
+MPA uses [wacli](https://github.com/openclaw/wacli) — a local WhatsApp CLI written in Go — for WhatsApp integration. No cloud APIs or business verification needed.
+
+The binary is not vendored: the Docker image installs a pinned upstream tag (`WACLI_VERSION` in the `Dockerfile`, currently `v0.11.0`) with `go install`. For local dev, `make dev-wa` installs the same tag into `~/go/bin`. Point MPA at a custom binary with the `WACLI_BIN` env var. The linked device shows up as **MPA** via `WACLI_DEVICE_LABEL`.
### Setup
@@ -73,10 +75,18 @@ channels:
- Messages are received via a webhook from wacli to the agent
- Outgoing messages are sent via wacli CLI commands
+### Upgrading wacli
+
+WhatsApp force-unlinks outdated clients (`405 Client Outdated`), which shows up
+as repeated auth loss. The fix is a version bump:
+
+1. Bump `WACLI_VERSION` in the `Dockerfile` (and `Makefile`) to the new upstream tag and rebuild.
+2. Crossing a protocol break invalidates the session, so **re-authenticate once**: in the admin UI go to Channels > WhatsApp and rescan the QR code. Only `session.db` changes; the message store (`wacli.db`) is preserved — back it up first to be safe.
+
### Limitations
- Uses WhatsApp Web protocol (unofficial)
-- Session may need re-authentication periodically
+- Session may need re-authentication periodically (and after a wacli version bump)
- WhatsApp may restrict accounts using unofficial clients
## Channel architecture
diff --git a/docs/content/docs/deployment.mdx b/docs/content/docs/deployment.mdx
index f48d9cd..d7d8311 100644
--- a/docs/content/docs/deployment.mdx
+++ b/docs/content/docs/deployment.mdx
@@ -77,7 +77,6 @@ The Docker Compose file mounts these paths:
| `./character.md` | `/app/character.md` | Agent personality |
| `./personalia.md` | `/app/personalia.md` | Agent identity |
| `./skills` | `/app/skills` | Skill files (read-only) |
-| `./cli-configs/himalaya.toml` | `/root/.config/himalaya/config.toml` | Himalaya config |
The `data/` directory is the only stateful volume. Back it up regularly.
diff --git a/pa.md b/pa.md
index 193c775..de03625 100644
--- a/pa.md
+++ b/pa.md
@@ -1278,7 +1278,7 @@ personal-agent/
├── api/
│ └── admin.py # FastAPI admin endpoints
│
-├── tools/wacli/ # WhatsApp CLI (Go, vendored)
+│ # wacli (WhatsApp CLI) is installed from pinned upstream, not vendored
│
├── cli-configs/ # Config files for CLI tools (mounted into container)
│ ├── himalaya.toml # → ~/.config/himalaya/config.toml
diff --git a/skills/himalaya-email.md b/skills/himalaya-email.md
index a3064e2..b135906 100644
--- a/skills/himalaya-email.md
+++ b/skills/himalaya-email.md
@@ -1,17 +1,19 @@
# Himalaya Email CLI
-You have access to the `himalaya` CLI to manage emails. Himalaya is a stateless CLI
-email client — each command is independent, no session state.
+You have access to the `himalaya` CLI (**v1.2.0**) to manage emails. Himalaya is a
+stateless CLI email client — each command is independent, no session state.
## Configuration
Himalaya is pre-configured. The available accounts are defined in its TOML config file.
Always specify the account with `-a `.
-To see which accounts are available:
-
```bash
+# List configured accounts
himalaya account list
+
+# List folders for an account (use exact names from here for move/copy)
+himalaya folder list -a personal -o json
```
## Reading emails
@@ -23,90 +25,119 @@ himalaya account list
himalaya envelope list -a personal -s 10 -o json
# List emails in a specific folder
-himalaya envelope list -a work --folder "Archives" -s 20 -o json
+himalaya envelope list -a personal --folder "Archives" -s 20 -o json
# Page through results (page 2)
himalaya envelope list -a personal -s 10 -p 2 -o json
```
-The JSON output is an array of envelope objects with fields like id, subject, from, date, and flags.
+JSON output is an array of envelope objects with fields like `id`, `subject`, `from`,
+`date`, and `flags`.
### Read a specific email
```bash
-# Read email by ID (returns plain text body with headers)
+# Read email by ID — AUTO-MARKS the message as Seen
himalaya message read -a personal 123
+# Preview WITHOUT marking as Seen
+himalaya message read -a personal -p 123
+
# Read specific headers only
himalaya message read -a personal 123 --header From --header Subject --header Date
```
-### Search emails
+### Search emails (query DSL)
+
+v1.x uses a **positional query DSL** — no `--`, no raw IMAP syntax.
-Himalaya uses IMAP search queries after `--`:
+- Conditions: `date`, `before`, `after`, `from`, `to`, `subject`, `body`, `flag`
+- Operators: `not`, `and`, `or`
+- Ordering: `order by [asc|desc]`
```bash
-# Search by subject
-himalaya envelope list -a work -o json -- "subject invoice"
+# Unread emails
+himalaya envelope list -a personal -o json "not flag seen"
-# Search by sender
-himalaya envelope list -a personal -o json -- "from alice@example.com"
+# By subject
+himalaya envelope list -a personal -o json "subject invoice"
-# Search for unseen emails
-himalaya envelope list -a personal -o json -- "not flag seen"
+# By sender
+himalaya envelope list -a personal -o json "from alice@example.com"
-# Combined search
-himalaya envelope list -a work -o json -- "from ikea subject contract unseen"
+# Combined, newest first
+himalaya envelope list -a personal -o json "from ikea and subject contract and not flag seen order by date desc"
```
## Sending emails
-**IMPORTANT:** Do NOT use `run_command` with piped `printf` / `echo` commands to send or reply to
-emails. Instead, always use the dedicated structured tools:
+`message read/reply/forward/write/edit` open `$EDITOR` interactively — **not**
+automation-safe. For automation use the non-interactive `template` path, or pipe a raw
+message into `message send`.
-- **`send_email`** — for composing and sending a new email (pass account, to, subject, body, etc.)
-- **`reply_email`** — for replying to an existing email by message ID (pass account, message_id, body)
+### Send a new email
-These tools handle shell quoting and piping internally. Using `run_command` with `printf ... | himalaya`
-will fail because `printf` is not an allowed command prefix.
+```bash
+printf 'From: matteo@merola.co\nTo: bob@example.com\nSubject: Hello\n\nBody text here.\n' \
+ | himalaya message send -a personal
+```
-### Forward an email
+### Reply (and reply-all)
-Forwarding is not available as a structured tool, so use `run_command` with himalaya as the first
-command in the pipe:
+```bash
+# Reply to sender
+himalaya template reply -a personal 123 "Thanks, got it." | himalaya template send -a personal
+
+# Reply-all: add -A
+himalaya template reply -a personal -A 123 "Thanks all." | himalaya template send -a personal
+```
+
+### Forward
```bash
-# Forward with added text
-himalaya -a work message forward 123 <<< 'FYI — see below.'
+himalaya template forward -a personal 123 "FYI — see below." | himalaya template send -a personal
```
+Always include a correct `From:` header matching the account email. Before sending,
+present the draft to the user for approval.
+
## Managing emails
+`message move`/`copy` take the **TARGET folder FIRST**, then the ID. `-f` sets the
+SOURCE folder.
+
```bash
-# Move to a folder
-himalaya message move 123 "Archives" -a personal
+# Move to a folder (TARGET first)
+himalaya message move -a personal Archives 123
-# Copy to a folder
-himalaya message copy 123 "Important" -a personal
+# Move from a non-INBOX source folder
+himalaya message move -a personal -f Spam INBOX 123
-# Delete (moves to Trash)
-himalaya message delete 123 -a personal
+# Copy to a folder (TARGET first)
+himalaya message copy -a personal Important 123
+
+# Mark as spam — no spam flag exists; move to the Spam folder
+# (run `folder list` for the exact name)
+himalaya message move -a personal Spam 123
-# Add a flag
-himalaya flag add 123 Seen -a personal
-himalaya flag add 123 Flagged -a personal
+# Delete (moves to Trash)
+himalaya message delete -a personal 123
-# Remove a flag
-himalaya flag remove 123 Seen -a personal
+# Mark read / unread
+himalaya flag add -a personal 123 Seen
+himalaya flag remove -a personal 123 Seen
-# List all folders
-himalaya folder list -o json -a personal
+# Other flags
+himalaya flag add -a personal 123 Flagged
```
## Important notes
-- Always use `-o json` when you need to parse results programmatically.
-- Email IDs are folder-relative — always specify `--folder` when not using INBOX.
-- Use `printf` with `\n` for newlines when constructing email messages to pipe to himalaya. Never use bare `echo` with literal newlines as it can break in shell.
-- When sending on behalf of the user, always include the correct `From:` header matching the account's email address.
-- Before sending, always present the draft to the user for approval.
+- Use `-o json` whenever you need to parse results programmatically.
+- Email IDs are folder-relative — specify `--folder` when not operating on INBOX.
+- `message read` marks Seen; use `-p`/`--preview` to avoid changing flags.
+- Sending uses SMTP auth from the account config. The personal account reuses
+ `$MAIL_MEROLA_CO_APP_PASSWORD` for both IMAP and SMTP — if sending fails with an auth
+ error, confirm that env var is exported.
+- Use `printf` with `\n` for newlines when building raw messages; never use bare `echo`
+ with literal newlines.
diff --git a/skills/wacli-whatsapp.md b/skills/wacli-whatsapp.md
index 89921e4..9e1b36d 100644
--- a/skills/wacli-whatsapp.md
+++ b/skills/wacli-whatsapp.md
@@ -172,6 +172,29 @@ These require user confirmation before running:
- Always use `--json` when you need to parse results programmatically.
- Read commands (`messages`, `contacts search/show`, `chats`, `groups list`) query the local DB and do not require a WhatsApp connection.
-- Write commands (`sync`, `contacts refresh`, `groups refresh/info/rename`) connect to WhatsApp and acquire an exclusive file lock.
-- Only one wacli process can hold the lock at a time. If another is running, the command will fail immediately.
+- Write commands (`sync`, `contacts refresh`, `groups refresh/info/rename`) connect to WhatsApp and acquire an exclusive store lock.
+- Only one wacli process can write the store at a time. Add `--lock-wait 30s` to wait for the lock instead of failing fast when a `sync` is running; add `--read-only` to a read command to skip the lock entirely.
- When the user says "check my WhatsApp" or "any new messages", **always sync first**, then list recent messages.
+
+## Global flags (wacli v0.11)
+
+- `--read-only` — reject any command that would write WhatsApp or the local store, and open the store without taking the session lock. Use for pure reads (`messages`, `chats`, `contacts search/show`).
+- `--lock-wait DUR` — wait up to `DUR` (e.g. `30s`) for the store lock before failing. Use on write commands when a background sync may hold the lock.
+- `--account NAME` — select a named account from `config.yaml` (multi-account setups).
+- `--events` — emit machine-readable NDJSON lifecycle events on stderr.
+
+## Newer commands (v0.7–0.11)
+
+```bash
+# Forward a stored message to another chat
+wacli --json messages forward --chat --id --to
+
+# Revoke (delete for everyone) a message you sent
+wacli --json messages revoke --chat --id
+
+# Post a WhatsApp status broadcast
+wacli --json send status --message "hello"
+
+# List recent calls
+wacli --json calls list --limit 20
+```
diff --git a/tools/wacli/.github/actions/setup-ci-env/action.yml b/tools/wacli/.github/actions/setup-ci-env/action.yml
deleted file mode 100644
index e5a799a..0000000
--- a/tools/wacli/.github/actions/setup-ci-env/action.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: Setup CI Environment
-description: Shared toolchain/bootstrap for CI and release jobs.
-
-inputs:
- go-version-file:
- description: Path to go.mod/go.work file.
- required: false
- default: go.mod
- setup-node:
- description: Whether to install Node.js.
- required: false
- default: "false"
- node-version:
- description: Node.js version to install when setup-node is true.
- required: false
- default: "20"
- setup-pnpm:
- description: Whether to enable corepack and activate pnpm.
- required: false
- default: "false"
- apt-packages:
- description: Space-separated apt packages to install (ubuntu only).
- required: false
- default: ""
-
-runs:
- using: composite
- steps:
- - name: Setup Node
- if: ${{ inputs.setup-node == 'true' }}
- uses: actions/setup-node@v4
- with:
- node-version: ${{ inputs.node-version }}
-
- - name: Setup Go
- uses: actions/setup-go@v5
- with:
- go-version-file: ${{ inputs.go-version-file }}
- cache: true
-
- - name: Setup pnpm
- if: ${{ inputs.setup-pnpm == 'true' }}
- shell: bash
- run: |
- corepack enable
- corepack prepare pnpm@10.23.0 --activate
- pnpm --version
-
- - name: Install apt packages
- if: ${{ inputs.apt-packages != '' }}
- shell: bash
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends ${{ inputs.apt-packages }}
diff --git a/tools/wacli/.github/workflows/ci.yml b/tools/wacli/.github/workflows/ci.yml
deleted file mode 100644
index 246521d..0000000
--- a/tools/wacli/.github/workflows/ci.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: CI
-
-on:
- push:
- branches: ["main"]
- pull_request:
-
-permissions:
- contents: read
-
-jobs:
- test:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup CI Environment
- uses: ./.github/actions/setup-ci-env
- with:
- setup-node: "true"
- node-version: "20"
- setup-pnpm: "true"
- apt-packages: "build-essential"
-
- - name: pnpm format:check
- run: pnpm -s format:check
-
- - name: pnpm lint
- run: pnpm -s lint
-
- - name: pnpm test
- env:
- CGO_ENABLED: "1"
- run: pnpm -s test
-
- - name: pnpm build
- env:
- CGO_ENABLED: "1"
- run: pnpm -s build
diff --git a/tools/wacli/.github/workflows/release.yml b/tools/wacli/.github/workflows/release.yml
deleted file mode 100644
index 509a539..0000000
--- a/tools/wacli/.github/workflows/release.yml
+++ /dev/null
@@ -1,94 +0,0 @@
-name: release
-
-on:
- push:
- tags:
- - "v*"
- workflow_dispatch:
- inputs:
- tag:
- description: "Tag to (re)release (e.g. v0.1.0)"
- required: true
- type: string
-
-permissions:
- contents: write
-
-jobs:
- goreleaser-darwin:
- runs-on: macos-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Setup Go
- uses: ./.github/actions/setup-ci-env
- with:
- go-version-file: go.mod
-
- - name: Stash GoReleaser config
- run: cp .goreleaser.yaml /tmp/.goreleaser.yaml
-
- - name: Checkout release tag
- if: ${{ github.event_name == 'workflow_dispatch' }}
- run: git checkout ${{ inputs.tag }}
-
- - name: GoReleaser (macOS universal)
- uses: goreleaser/goreleaser-action@v6
- with:
- distribution: goreleaser
- version: latest
- args: release --clean --config /tmp/.goreleaser.yaml
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- goreleaser-linux-windows:
- runs-on: ubuntu-latest
- needs: goreleaser-darwin
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Setup Go
- uses: ./.github/actions/setup-ci-env
- with:
- go-version-file: go.mod
- apt-packages: "build-essential gcc-aarch64-linux-gnu libc6-dev-arm64-cross mingw-w64"
-
- - name: Stash GoReleaser config
- run: cp .goreleaser-linux-windows.yaml /tmp/.goreleaser-linux-windows.yaml
-
- - name: Checkout release tag
- if: ${{ github.event_name == 'workflow_dispatch' }}
- run: git checkout ${{ inputs.tag }}
-
- - name: GoReleaser (linux/windows)
- uses: goreleaser/goreleaser-action@v6
- with:
- distribution: goreleaser
- version: latest
- args: release --clean --skip=publish --config /tmp/.goreleaser-linux-windows.yaml
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Resolve release tag
- run: |
- if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
- echo "RELEASE_TAG=${{ inputs.tag }}" >> $GITHUB_ENV
- else
- echo "RELEASE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
- fi
-
- - name: Upload linux/windows artifacts
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- gh release upload "$RELEASE_TAG" \
- dist/*.tar.gz \
- dist/*.zip \
- dist/checksums-linux-windows.txt \
- --clobber
diff --git a/tools/wacli/.gitignore b/tools/wacli/.gitignore
deleted file mode 100644
index 6ca6454..0000000
--- a/tools/wacli/.gitignore
+++ /dev/null
@@ -1,44 +0,0 @@
-# If you prefer the allow list template instead of the deny list, see community template:
-# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
-#
-# Binaries for programs and plugins
-*.exe
-*.exe~
-*.dll
-*.so
-*.dylib
-
-# Test binary, built with `go test -c`
-*.test
-
-# Code coverage profiles and other test artifacts
-*.out
-coverage.*
-*.coverprofile
-profile.cov
-
-# Dependency directories (remove the comment below to include it)
-# vendor/
-
-# Go workspace file
-go.work
-go.work.sum
-
-# env file
-.env
-
-# Editor/IDE
-# .idea/
-# .vscode/
-
-# Local wacli state (if you point --store into the repo)
-*.db
-*.db-*
-/media/
-/store/
-
-# Built CLI binary
-/wacli
-
-# pnpm build output
-/dist/
diff --git a/tools/wacli/.goreleaser-linux-windows.yaml b/tools/wacli/.goreleaser-linux-windows.yaml
deleted file mode 100644
index ce0ad38..0000000
--- a/tools/wacli/.goreleaser-linux-windows.yaml
+++ /dev/null
@@ -1,75 +0,0 @@
-version: 2
-
-project_name: wacli
-
-builds:
- - id: wacli_linux_amd64
- main: ./cmd/wacli
- binary: wacli
- env:
- - CGO_ENABLED=1
- - CGO_CFLAGS=-Wno-error=missing-braces
- flags:
- - -trimpath
- tags:
- - sqlite_fts5
- ldflags:
- - -s -w -X main.version={{.Version}}
- goos:
- - linux
- goarch:
- - amd64
-
- - id: wacli_linux_arm64
- main: ./cmd/wacli
- binary: wacli
- env:
- - CGO_ENABLED=1
- - CC=aarch64-linux-gnu-gcc
- - CGO_CFLAGS=-Wno-error=missing-braces
- flags:
- - -trimpath
- tags:
- - sqlite_fts5
- ldflags:
- - -s -w -X main.version={{.Version}}
- goos:
- - linux
- goarch:
- - arm64
-
- - id: wacli_windows_amd64
- main: ./cmd/wacli
- binary: wacli
- env:
- - CGO_ENABLED=1
- - CC=x86_64-w64-mingw32-gcc
- - CGO_CFLAGS=-Wno-error=missing-braces
- flags:
- - -trimpath
- tags:
- - sqlite_fts5
- ldflags:
- - -s -w -X main.version={{.Version}}
- goos:
- - windows
- goarch:
- - amd64
-
-archives:
- - id: default
- format: tar.gz
- name_template: >-
- {{ .ProjectName }}-{{ if eq .Os "darwin" }}macos{{ else }}{{ .Os }}{{ end }}-{{ if eq .Arch "all" }}universal{{ else }}{{ .Arch }}{{ end }}
- format_overrides:
- - goos: windows
- format: zip
- files:
- - LICENSE
- - README.md
-
-checksum:
- name_template: checksums-linux-windows.txt
-
-changelog:
- disable: true
diff --git a/tools/wacli/.goreleaser.yaml b/tools/wacli/.goreleaser.yaml
deleted file mode 100644
index 9faea95..0000000
--- a/tools/wacli/.goreleaser.yaml
+++ /dev/null
@@ -1,48 +0,0 @@
-version: 2
-
-project_name: wacli
-
-builds:
- - id: wacli
- main: ./cmd/wacli
- binary: wacli
- env:
- - CGO_ENABLED=1
- flags:
- - -trimpath
- tags:
- - sqlite_fts5
- ldflags:
- - -s -w -X main.version={{.Version}}
- goos:
- - darwin
- goarch:
- - amd64
- - arm64
-
-universal_binaries:
- - id: wacli
- ids:
- - wacli
- name_template: wacli
- replace: true
-
-archives:
- - id: default
- format: tar.gz
- name_template: >-
- {{ .ProjectName }}-{{ if eq .Os "darwin" }}macos{{ else }}{{ .Os }}{{ end }}-{{ if eq .Arch "all" }}universal{{ else }}{{ .Arch }}{{ end }}
- files:
- - LICENSE
- - README.md
-
-checksum:
- name_template: checksums.txt
-
-changelog:
- sort: asc
- filters:
- exclude:
- - "^docs:"
- - "^test:"
- - "^chore:"
diff --git a/tools/wacli/CHANGELOG.md b/tools/wacli/CHANGELOG.md
deleted file mode 100644
index afd084c..0000000
--- a/tools/wacli/CHANGELOG.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# Changelog
-
-## 0.5.0 - Unreleased
-
-### Changed
-
-- Internal architecture: split store and groups command logic into focused modules for cleaner maintenance and safer follow-up changes.
-
-### Build
-
-- CI: extract a shared setup action and reuse it across CI and release workflows.
-- Release: install arm64 libc headers in release workflow to improve ARM build reliability.
-
-### Docs
-
-- README: update usage/docs for the 0.2.0 release baseline.
-- Changelog: roll unreleased tracking from `0.2.1` to `0.5.0`.
-
-### Chore
-
-- Version: bump CLI version string to `0.5.0` (unreleased).
-
-## 0.2.0 - 2026-01-23
-
-### Added
-
-- Messages: store display text for reactions, replies, and media; include in search output.
-- Send: `wacli send file --filename` to override display name for uploads. (#7 — thanks @plattenschieber)
-- Auth: allow `WACLI_DEVICE_LABEL` and `WACLI_DEVICE_PLATFORM` overrides for linked device identity. (#4 — thanks @zats)
-
-### Fixed
-
-- Build: preserve existing `CGO_CFLAGS` when adding GCC 15+ workaround. (#8 — thanks @ramarivera)
-- Messages: keep captions in list/search output.
-
-### Build
-
-- Release: multi-OS GoReleaser configs and workflow for macOS, linux, and windows artifacts.
-
-## 0.1.0 - 2026-01-01
-
-### Added
-
-- Auth: `wacli auth` QR login, bootstrap sync, optional follow, idle-exit, background media download, contacts/groups refresh.
-- Sync: non-interactive `wacli sync` once/follow, never shows QR, idle-exit, background media download, optional contacts/groups refresh.
-- Messages: list/search/show/context with chat/sender/time/media filters; FTS5 search with LIKE fallback and snippets.
-- Send: text and file (image/video/audio/document) with caption and MIME override.
-- Media: download by chat/id, resolves output paths, and records downloaded media in the DB.
-- History: on-demand backfill per chat with request count, wait, and idle-exit.
-- Contacts: search/show; import from WhatsApp store; local alias and tag management.
-- Chats: list/show with kind and last message timestamp.
-- Groups: list/refresh/info/rename; participants add/remove/promote/demote; invite link get/revoke; join/leave.
-- Diagnostics: `wacli doctor` for store path, lock status/info, auth/connection check, and FTS status.
-- CLI UX: human-readable output by default with `--json`, global `--store`/`--timeout`, plus `wacli version`.
-- Storage: default `~/.wacli`, lock file for single-instance safety, SQLite DB with FTS5, WhatsApp session store, and media directory.
diff --git a/tools/wacli/LICENSE b/tools/wacli/LICENSE
deleted file mode 100644
index 9930a7c..0000000
--- a/tools/wacli/LICENSE
+++ /dev/null
@@ -1,21 +0,0 @@
-MIT License
-
-\g<1>2026 Peter Steinberger
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
diff --git a/tools/wacli/README.md b/tools/wacli/README.md
deleted file mode 100644
index 0da2fcf..0000000
--- a/tools/wacli/README.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# 🗃️ wacli — WhatsApp CLI: sync, search, send.
-
-WhatsApp CLI built on top of `whatsmeow`, focused on:
-
-- Best-effort local sync of message history + continuous capture
-- Fast offline search
-- Sending messages
-- Contact + group management
-
-This is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow` and is not affiliated with WhatsApp.
-
-## Status
-
-Core implementation is in place. See `docs/spec.md` for the full design notes.
-
-## Recent updates (0.2.0)
-
-- Messages: search/list includes display text for reactions, replies, and media types.
-- Send: `wacli send file --filename` to override the display name.
-- Auth: optional `WACLI_DEVICE_LABEL` / `WACLI_DEVICE_PLATFORM` env overrides.
-
-## Install / Build
-
-Choose **one** of the following options.
-If you install via Homebrew, you can skip the local build step.
-
-### Option A: Install via Homebrew (tap)
-
-- `brew install steipete/tap/wacli`
-
-### Option B: Build locally
-
-- `go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli`
-
-Run (local build only):
-
-- `./dist/wacli --help`
-
-## Quick start
-
-Default store directory is `~/.wacli` (override with `--store DIR`).
-
-```bash
-# 1) Authenticate (shows QR), then bootstrap sync
-pnpm wacli auth
-# or: ./dist/wacli auth (after pnpm build)
-
-# 2) Keep syncing (never shows QR; requires prior auth)
-pnpm wacli sync --follow
-
-# Diagnostics
-pnpm wacli doctor
-
-# Search messages
-pnpm wacli messages search "meeting"
-
-# Backfill older messages for a chat (best-effort; requires your primary device online)
-pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
-
-# Download media for a message (after syncing)
-./wacli media download --chat 1234567890@s.whatsapp.net --id
-
-# Send a message
-pnpm wacli send text --to 1234567890 --message "hello"
-
-# Send a file
-./wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
-# Or override display name
-./wacli send file --to 1234567890 --file /tmp/abc123 --filename report.pdf
-
-# List groups and manage participants
-pnpm wacli groups list
-pnpm wacli groups rename --jid 123456789@g.us --name "New name"
-```
-
-## Prior Art / Credit
-
-This project is heavily inspired by (and learns from) the excellent `whatsapp-cli` by Vicente Reig:
-
-- [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli)
-
-## High-level UX
-
-- `wacli auth`: interactive login (shows QR code), then immediately performs initial data sync.
-- `wacli sync`: non-interactive sync loop (never shows QR; errors if not authenticated).
-- Output is human-readable by default; pass `--json` for machine-readable output.
-
-## Storage
-
-Defaults to `~/.wacli` (override with `--store DIR`).
-
-## Environment overrides
-
-- `WACLI_DEVICE_LABEL`: set the linked device label (shown in WhatsApp).
-- `WACLI_DEVICE_PLATFORM`: override the linked device platform (defaults to `CHROME` if unset or invalid).
-
-## Backfilling older history
-
-`wacli sync` stores whatever WhatsApp Web sends opportunistically. To try to fetch *older* messages, use on-demand history sync requests to your **primary device** (your phone).
-
-Important notes:
-
-- This is **best-effort**: WhatsApp may not return full history.
-- Your **primary device must be online**.
-- Requests are **per chat** (DM or group). `wacli` uses the *oldest locally stored message* in that chat as the anchor.
-- Recommended `--count` is `50` per request.
-
-### Backfill one chat
-
-```bash
-pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
-```
-
-### Backfill all chats (script)
-
-This loops through chats already known in your local DB:
-
-```bash
-pnpm -s wacli -- --json chats list --limit 100000 \
- | jq -r '.[].JID' \
- | while read -r jid; do
- pnpm -s wacli -- history backfill --chat "$jid" --requests 3 --count 50
- done
-```
-
-## License
-
-See `LICENSE`.
diff --git a/tools/wacli/cmd/wacli/auth.go b/tools/wacli/cmd/wacli/auth.go
deleted file mode 100644
index ab41286..0000000
--- a/tools/wacli/cmd/wacli/auth.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "os/signal"
- "syscall"
- "time"
-
- "github.com/mdp/qrterminal/v3"
- "github.com/spf13/cobra"
- appPkg "github.com/steipete/wacli/internal/app"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newAuthCmd(flags *rootFlags) *cobra.Command {
- var follow bool
- var idleExit time.Duration
- var downloadMedia bool
-
- cmd := &cobra.Command{
- Use: "auth",
- Short: "Authenticate with WhatsApp (QR) and bootstrap sync",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
-
- a, lk, err := newApp(ctx, flags, true, true)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- mode := appPkg.SyncModeBootstrap
- if follow {
- mode = appPkg.SyncModeFollow
- }
-
- fmt.Fprintln(os.Stderr, "Starting authentication…")
- res, err := a.Sync(ctx, appPkg.SyncOptions{
- Mode: mode,
- AllowQR: true,
- DownloadMedia: downloadMedia,
- RefreshContacts: true,
- RefreshGroups: true,
- IdleExit: idleExit,
- OnQRCode: func(code string) {
- if flags.asJSON {
- _ = out.WriteJSON(os.Stdout, map[string]interface{}{
- "qr": code,
- })
- return
- }
- fmt.Fprintln(os.Stderr, "\nScan this QR code with WhatsApp (Linked Devices):")
- qrterminal.GenerateHalfBlock(code, qrterminal.M, os.Stderr)
- fmt.Fprintln(os.Stderr)
- },
- })
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]interface{}{
- "authenticated": true,
- "messages_stored": res.MessagesStored,
- })
- }
-
- fmt.Fprintf(os.Stdout, "Authenticated. Messages stored: %d\n", res.MessagesStored)
- return nil
- },
- }
-
- cmd.Flags().BoolVar(&follow, "follow", false, "keep syncing after auth")
- cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)")
- cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
-
- cmd.AddCommand(newAuthStatusCmd(flags))
- cmd.AddCommand(newAuthLogoutCmd(flags))
-
- return cmd
-}
-
-func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
- return &cobra.Command{
- Use: "status",
- Short: "Show authentication status",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, true)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.OpenWA(); err != nil {
- return err
- }
- authed := a.WA().IsAuthed()
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{
- "authenticated": authed,
- })
- }
- if authed {
- fmt.Fprintln(os.Stdout, "Authenticated.")
- } else {
- fmt.Fprintln(os.Stdout, "Not authenticated. Run `wacli auth`.")
- }
- return nil
- },
- }
-}
-
-func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
- return &cobra.Command{
- Use: "logout",
- Short: "Logout (invalidate session)",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, true)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
- if err := a.WA().Logout(ctx); err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"logged_out": true})
- }
- fmt.Fprintln(os.Stdout, "Logged out.")
- return nil
- },
- }
-}
diff --git a/tools/wacli/cmd/wacli/chats.go b/tools/wacli/cmd/wacli/chats.go
deleted file mode 100644
index 2ba569a..0000000
--- a/tools/wacli/cmd/wacli/chats.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "text/tabwriter"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newChatsCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "chats",
- Short: "List chats from the local DB",
- }
- cmd.AddCommand(newChatsListCmd(flags))
- cmd.AddCommand(newChatsShowCmd(flags))
- return cmd
-}
-
-func newChatsListCmd(flags *rootFlags) *cobra.Command {
- var query string
- var limit int
- cmd := &cobra.Command{
- Use: "list",
- Short: "List chats",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- chats, err := a.DB().ListChats(query, limit)
- if err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, chats)
- }
-
- w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
- fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST")
- for _, c := range chats {
- name := c.Name
- if name == "" {
- name = c.JID
- }
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Kind, truncate(name, 28), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"))
- }
- _ = w.Flush()
- return nil
- },
- }
- cmd.Flags().StringVar(&query, "query", "", "search query")
- cmd.Flags().IntVar(&limit, "limit", 50, "limit")
- return cmd
-}
-
-func newChatsShowCmd(flags *rootFlags) *cobra.Command {
- var jid string
- cmd := &cobra.Command{
- Use: "show",
- Short: "Show one chat",
- RunE: func(cmd *cobra.Command, args []string) error {
- if jid == "" {
- return fmt.Errorf("--jid is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- c, err := a.DB().GetChat(jid)
- if err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, c)
- }
- fmt.Fprintf(os.Stdout, "JID: %s\nKind: %s\nName: %s\nLast: %s\n", c.JID, c.Kind, c.Name, c.LastMessageTS.Local().Format(time.RFC3339))
- return nil
- },
- }
- cmd.Flags().StringVar(&jid, "jid", "", "chat JID")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/contacts.go b/tools/wacli/cmd/wacli/contacts.go
deleted file mode 100644
index f7207c1..0000000
--- a/tools/wacli/cmd/wacli/contacts.go
+++ /dev/null
@@ -1,285 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "text/tabwriter"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newContactsCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "contacts",
- Short: "Search and manage local contact metadata",
- }
- cmd.AddCommand(newContactsSearchCmd(flags))
- cmd.AddCommand(newContactsShowCmd(flags))
- cmd.AddCommand(newContactsRefreshCmd(flags))
- cmd.AddCommand(newContactsAliasCmd(flags))
- cmd.AddCommand(newContactsTagsCmd(flags))
- return cmd
-}
-
-func newContactsSearchCmd(flags *rootFlags) *cobra.Command {
- var limit int
- cmd := &cobra.Command{
- Use: "search ",
- Short: "Search contacts (from synced metadata)",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- cs, err := a.DB().SearchContacts(args[0], limit)
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, cs)
- }
-
- w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
- fmt.Fprintln(w, "ALIAS\tNAME\tPHONE\tJID")
- for _, c := range cs {
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
- truncate(c.Alias, 18),
- truncate(c.Name, 24),
- truncate(c.Phone, 14),
- c.JID,
- )
- }
- _ = w.Flush()
- return nil
- },
- }
- cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
- return cmd
-}
-
-func newContactsShowCmd(flags *rootFlags) *cobra.Command {
- var jid string
- cmd := &cobra.Command{
- Use: "show",
- Short: "Show one contact",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(jid) == "" {
- return fmt.Errorf("--jid is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- c, err := a.DB().GetContact(jid)
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, c)
- }
-
- fmt.Fprintf(os.Stdout, "JID: %s\n", c.JID)
- if c.Phone != "" {
- fmt.Fprintf(os.Stdout, "Phone: %s\n", c.Phone)
- }
- if c.Name != "" {
- fmt.Fprintf(os.Stdout, "Name: %s\n", c.Name)
- }
- if c.Alias != "" {
- fmt.Fprintf(os.Stdout, "Alias: %s\n", c.Alias)
- }
- if len(c.Tags) > 0 {
- fmt.Fprintf(os.Stdout, "Tags: %s\n", strings.Join(c.Tags, ", "))
- }
- return nil
- },
- }
- cmd.Flags().StringVar(&jid, "jid", "", "contact JID")
- return cmd
-}
-
-func newContactsRefreshCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "refresh",
- Short: "Import contacts from whatsmeow store into local DB",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, true)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.OpenWA(); err != nil {
- return err
- }
- cs, err := a.WA().GetAllContacts(ctx)
- if err != nil {
- return err
- }
-
- var count int
- for jid, info := range cs {
- _ = a.DB().UpsertContact(
- jid.String(),
- jid.User,
- info.PushName,
- info.FullName,
- info.FirstName,
- info.BusinessName,
- )
- count++
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"contacts": count})
- }
- fmt.Fprintf(os.Stdout, "Imported %d contacts.\n", count)
- return nil
- },
- }
- return cmd
-}
-
-func newContactsAliasCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "alias",
- Short: "Manage local aliases",
- }
- cmd.AddCommand(&cobra.Command{
- Use: "set",
- Short: "Set alias",
- RunE: func(cmd *cobra.Command, args []string) error {
- jid, _ := cmd.Flags().GetString("jid")
- alias, _ := cmd.Flags().GetString("alias")
- if strings.TrimSpace(jid) == "" || strings.TrimSpace(alias) == "" {
- return fmt.Errorf("--jid and --alias are required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
- if err := a.DB().SetAlias(jid, alias); err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": jid, "alias": alias})
- }
- fmt.Fprintln(os.Stdout, "OK")
- return nil
- },
- })
- cmd.AddCommand(&cobra.Command{
- Use: "rm",
- Short: "Remove alias",
- RunE: func(cmd *cobra.Command, args []string) error {
- jid, _ := cmd.Flags().GetString("jid")
- if strings.TrimSpace(jid) == "" {
- return fmt.Errorf("--jid is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
- if err := a.DB().RemoveAlias(jid); err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": jid, "removed": true})
- }
- fmt.Fprintln(os.Stdout, "OK")
- return nil
- },
- })
-
- _ = cmd.PersistentFlags().String("jid", "", "contact JID")
- _ = cmd.PersistentFlags().String("alias", "", "alias")
- return cmd
-}
-
-func newContactsTagsCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "tags",
- Short: "Manage local tags",
- }
- cmd.AddCommand(&cobra.Command{
- Use: "add",
- Short: "Add tag",
- RunE: func(cmd *cobra.Command, args []string) error {
- jid, _ := cmd.Flags().GetString("jid")
- tag, _ := cmd.Flags().GetString("tag")
- if strings.TrimSpace(jid) == "" || strings.TrimSpace(tag) == "" {
- return fmt.Errorf("--jid and --tag are required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
- if err := a.DB().AddTag(jid, tag); err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": jid, "tag": tag})
- }
- fmt.Fprintln(os.Stdout, "OK")
- return nil
- },
- })
- cmd.AddCommand(&cobra.Command{
- Use: "rm",
- Short: "Remove tag",
- RunE: func(cmd *cobra.Command, args []string) error {
- jid, _ := cmd.Flags().GetString("jid")
- tag, _ := cmd.Flags().GetString("tag")
- if strings.TrimSpace(jid) == "" || strings.TrimSpace(tag) == "" {
- return fmt.Errorf("--jid and --tag are required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
- if err := a.DB().RemoveTag(jid, tag); err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": jid, "tag": tag, "removed": true})
- }
- fmt.Fprintln(os.Stdout, "OK")
- return nil
- },
- })
-
- _ = cmd.PersistentFlags().String("jid", "", "contact JID")
- _ = cmd.PersistentFlags().String("tag", "", "tag")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/doctor.go b/tools/wacli/cmd/wacli/doctor.go
deleted file mode 100644
index f656724..0000000
--- a/tools/wacli/cmd/wacli/doctor.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "text/tabwriter"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/config"
- "github.com/steipete/wacli/internal/lock"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newDoctorCmd(flags *rootFlags) *cobra.Command {
- var connect bool
-
- cmd := &cobra.Command{
- Use: "doctor",
- Short: "Diagnostics for store/auth/search",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- storeDir := flags.storeDir
- if storeDir == "" {
- storeDir = config.DefaultStoreDir()
- }
- storeDir, _ = filepath.Abs(storeDir)
-
- var lockHeld bool
- var lockInfo string
- if b, err := os.ReadFile(filepath.Join(storeDir, "LOCK")); err == nil {
- lockInfo = strings.TrimSpace(string(b))
- }
- if lk, err := lock.Acquire(storeDir); err == nil {
- _ = lk.Release()
- } else {
- lockHeld = true
- }
-
- a, lk, err := newApp(ctx, flags, connect, true)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- var authed bool
- var connected bool
- if err := a.OpenWA(); err == nil {
- authed = a.WA().IsAuthed()
- }
- if connect && authed {
- if err := a.Connect(ctx, false, nil); err == nil {
- connected = true
- }
- }
-
- type report struct {
- StoreDir string `json:"store_dir"`
- LockHeld bool `json:"lock_held"`
- LockInfo string `json:"lock_info,omitempty"`
- Authed bool `json:"authenticated"`
- Connected bool `json:"connected"`
- FTSEnabled bool `json:"fts_enabled"`
- }
-
- rep := report{
- StoreDir: storeDir,
- LockHeld: lockHeld,
- LockInfo: lockInfo,
- Authed: authed,
- Connected: connected,
- FTSEnabled: a.DB().HasFTS(),
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, rep)
- }
-
- w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
- fmt.Fprintf(w, "STORE\t%s\n", rep.StoreDir)
- fmt.Fprintf(w, "LOCKED\t%v\n", rep.LockHeld)
- if rep.LockHeld && rep.LockInfo != "" {
- fmt.Fprintf(w, "LOCK_INFO\t%s\n", rep.LockInfo)
- }
- fmt.Fprintf(w, "AUTHENTICATED\t%v\n", rep.Authed)
- fmt.Fprintf(w, "CONNECTED\t%v\n", rep.Connected)
- fmt.Fprintf(w, "FTS5\t%v\n", rep.FTSEnabled)
- _ = w.Flush()
-
- if rep.LockHeld {
- fmt.Fprintln(os.Stdout, "\nTip: stop the running `wacli sync` before running write operations.")
- }
- return nil
- },
- }
-
- cmd.Flags().BoolVar(&connect, "connect", false, "try connecting to WhatsApp (requires store lock)")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/groups.go b/tools/wacli/cmd/wacli/groups.go
deleted file mode 100644
index 30c333a..0000000
--- a/tools/wacli/cmd/wacli/groups.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package main
-
-import "github.com/spf13/cobra"
-
-func newGroupsCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "groups",
- Short: "Group management",
- }
- cmd.AddCommand(newGroupsListCmd(flags))
- cmd.AddCommand(newGroupsRefreshCmd(flags))
- cmd.AddCommand(newGroupsInfoCmd(flags))
- cmd.AddCommand(newGroupsRenameCmd(flags))
- cmd.AddCommand(newGroupsParticipantsCmd(flags))
- cmd.AddCommand(newGroupsInviteCmd(flags))
- cmd.AddCommand(newGroupsJoinCmd(flags))
- cmd.AddCommand(newGroupsLeaveCmd(flags))
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/groups_info_rename.go b/tools/wacli/cmd/wacli/groups_info_rename.go
deleted file mode 100644
index 4aba5da..0000000
--- a/tools/wacli/cmd/wacli/groups_info_rename.go
+++ /dev/null
@@ -1,158 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
- "go.mau.fi/whatsmeow/types"
-)
-
-func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
- var jidStr string
- cmd := &cobra.Command{
- Use: "info",
- Short: "Fetch group info (live) and update local DB",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(jidStr) == "" {
- return fmt.Errorf("--jid is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
-
- gjid, err := types.ParseJID(jidStr)
- if err != nil {
- return err
- }
- info, err := a.WA().GetGroupInfo(ctx, gjid)
- if err != nil {
- return err
- }
- if info != nil {
- _ = persistGroupInfo(a.DB(), info)
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, info)
- }
-
- fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nCreated: %s\nParticipants: %d\n",
- info.JID.String(),
- info.GroupName.Name,
- info.OwnerJID.String(),
- info.GroupCreated.Local().Format(time.RFC3339),
- len(info.Participants),
- )
- return nil
- },
- }
- cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
- return cmd
-}
-
-func newGroupsRenameCmd(flags *rootFlags) *cobra.Command {
- var jidStr string
- var name string
- cmd := &cobra.Command{
- Use: "rename",
- Short: "Rename group",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(jidStr) == "" || strings.TrimSpace(name) == "" {
- return fmt.Errorf("--jid and --name are required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
-
- gjid, err := types.ParseJID(jidStr)
- if err != nil {
- return err
- }
- if err := a.WA().SetGroupName(ctx, gjid, name); err != nil {
- return err
- }
- if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
- _ = persistGroupInfo(a.DB(), info)
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "name": name})
- }
- fmt.Fprintln(os.Stdout, "OK")
- return nil
- },
- }
- cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
- cmd.Flags().StringVar(&name, "name", "", "new name")
- return cmd
-}
-
-func newGroupsLeaveCmd(flags *rootFlags) *cobra.Command {
- var jidStr string
- cmd := &cobra.Command{
- Use: "leave",
- Short: "Leave a group",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(jidStr) == "" {
- return fmt.Errorf("--jid is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
- gjid, err := types.ParseJID(jidStr)
- if err != nil {
- return err
- }
- if err := a.WA().LeaveGroup(ctx, gjid); err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "left": true})
- }
- fmt.Fprintln(os.Stdout, "OK")
- return nil
- },
- }
- cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/groups_invite_join.go b/tools/wacli/cmd/wacli/groups_invite_join.go
deleted file mode 100644
index 4080b68..0000000
--- a/tools/wacli/cmd/wacli/groups_invite_join.go
+++ /dev/null
@@ -1,159 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
- "go.mau.fi/whatsmeow/types"
-)
-
-func newGroupsInviteCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "invite",
- Short: "Manage group invite links",
- }
- cmd.AddCommand(newGroupsInviteLinkCmd(flags))
- return cmd
-}
-
-func newGroupsInviteLinkCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "link",
- Short: "Get or revoke invite links",
- }
- cmd.AddCommand(newGroupsInviteLinkGetCmd(flags))
- cmd.AddCommand(newGroupsInviteLinkRevokeCmd(flags))
- return cmd
-}
-
-func newGroupsInviteLinkGetCmd(flags *rootFlags) *cobra.Command {
- var jidStr string
- cmd := &cobra.Command{
- Use: "get",
- Short: "Get invite link",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(jidStr) == "" {
- return fmt.Errorf("--jid is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
- gjid, err := types.ParseJID(jidStr)
- if err != nil {
- return err
- }
- link, err := a.WA().GetGroupInviteLink(ctx, gjid, false)
- if err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link})
- }
- fmt.Fprintln(os.Stdout, link)
- return nil
- },
- }
- cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
- return cmd
-}
-
-func newGroupsInviteLinkRevokeCmd(flags *rootFlags) *cobra.Command {
- var jidStr string
- cmd := &cobra.Command{
- Use: "revoke",
- Short: "Revoke/reset invite link",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(jidStr) == "" {
- return fmt.Errorf("--jid is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
- gjid, err := types.ParseJID(jidStr)
- if err != nil {
- return err
- }
- link, err := a.WA().GetGroupInviteLink(ctx, gjid, true)
- if err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link, "revoked": true})
- }
- fmt.Fprintln(os.Stdout, link)
- return nil
- },
- }
- cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
- return cmd
-}
-
-func newGroupsJoinCmd(flags *rootFlags) *cobra.Command {
- var code string
- cmd := &cobra.Command{
- Use: "join",
- Short: "Join group by invite code",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(code) == "" {
- return fmt.Errorf("--code is required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
- jid, err := a.WA().JoinGroupWithLink(ctx, code)
- if err != nil {
- return err
- }
- if info, err := a.WA().GetGroupInfo(ctx, jid); err == nil && info != nil {
- _ = persistGroupInfo(a.DB(), info)
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"jid": jid.String(), "joined": true})
- }
- fmt.Fprintf(os.Stdout, "Joined: %s\n", jid.String())
- return nil
- },
- }
- cmd.Flags().StringVar(&code, "code", "", "invite code (from link)")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/groups_participants.go b/tools/wacli/cmd/wacli/groups_participants.go
deleted file mode 100644
index 64d73e9..0000000
--- a/tools/wacli/cmd/wacli/groups_participants.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
- "github.com/steipete/wacli/internal/wa"
- "go.mau.fi/whatsmeow/types"
-)
-
-func newGroupsParticipantsCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "participants",
- Short: "Manage group participants",
- }
- cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "add"))
- cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "remove"))
- cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "promote"))
- cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "demote"))
- return cmd
-}
-
-func newGroupsParticipantsActionCmd(flags *rootFlags, action string) *cobra.Command {
- var group string
- var users []string
- cmd := &cobra.Command{
- Use: action,
- Short: action + " participants",
- RunE: func(cmd *cobra.Command, args []string) error {
- if strings.TrimSpace(group) == "" || len(users) == 0 {
- return fmt.Errorf("--jid and at least one --user are required")
- }
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
-
- gjid, err := types.ParseJID(group)
- if err != nil {
- return err
- }
- var jids []types.JID
- for _, u := range users {
- j, err := wa.ParseUserOrJID(u)
- if err != nil {
- return err
- }
- jids = append(jids, j)
- }
-
- updated, err := a.WA().UpdateGroupParticipants(ctx, gjid, jids, wa.GroupParticipantAction(action))
- if err != nil {
- return err
- }
- if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
- _ = persistGroupInfo(a.DB(), info)
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, updated)
- }
- fmt.Fprintln(os.Stdout, "OK")
- return nil
- },
- }
- cmd.Flags().StringVar(&group, "jid", "", "group JID (…@g.us)")
- cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number or JID (repeatable)")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/groups_persist.go b/tools/wacli/cmd/wacli/groups_persist.go
deleted file mode 100644
index 3bdf75f..0000000
--- a/tools/wacli/cmd/wacli/groups_persist.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package main
-
-import (
- "github.com/steipete/wacli/internal/store"
- "go.mau.fi/whatsmeow/types"
-)
-
-func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
- if info == nil {
- return nil
- }
- if err := db.UpsertGroup(info.JID.String(), info.GroupName.Name, info.OwnerJID.String(), info.GroupCreated); err != nil {
- return err
- }
- var ps []store.GroupParticipant
- for _, p := range info.Participants {
- role := "member"
- if p.IsSuperAdmin {
- role = "superadmin"
- } else if p.IsAdmin {
- role = "admin"
- }
- ps = append(ps, store.GroupParticipant{
- GroupJID: info.JID.String(),
- UserJID: p.JID.String(),
- Role: role,
- })
- }
- return db.ReplaceGroupParticipants(info.JID.String(), ps)
-}
diff --git a/tools/wacli/cmd/wacli/groups_refresh_list.go b/tools/wacli/cmd/wacli/groups_refresh_list.go
deleted file mode 100644
index 1101a69..0000000
--- a/tools/wacli/cmd/wacli/groups_refresh_list.go
+++ /dev/null
@@ -1,97 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "text/tabwriter"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "refresh",
- Short: "Fetch joined groups (live) and update local DB",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
-
- gs, err := a.WA().GetJoinedGroups(ctx)
- if err != nil {
- return err
- }
- for _, g := range gs {
- if g == nil {
- continue
- }
- _ = persistGroupInfo(a.DB(), g)
- _ = a.DB().UpsertChat(g.JID.String(), "group", g.GroupName.Name, time.Now())
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{"groups": len(gs)})
- }
- fmt.Fprintf(os.Stdout, "Imported %d groups.\n", len(gs))
- return nil
- },
- }
- return cmd
-}
-
-func newGroupsListCmd(flags *rootFlags) *cobra.Command {
- var query string
- var limit int
- cmd := &cobra.Command{
- Use: "list",
- Short: "List known groups (from local DB; run sync to populate)",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- gs, err := a.DB().ListGroups(query, limit)
- if err != nil {
- return err
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, gs)
- }
-
- w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
- fmt.Fprintln(w, "NAME\tJID\tCREATED")
- for _, g := range gs {
- name := g.Name
- if name == "" {
- name = g.JID
- }
- fmt.Fprintf(w, "%s\t%s\t%s\n", truncate(name, 40), g.JID, g.CreatedAt.Local().Format("2006-01-02"))
- }
- _ = w.Flush()
- return nil
- },
- }
- cmd.Flags().StringVar(&query, "query", "", "search query")
- cmd.Flags().IntVar(&limit, "limit", 50, "limit")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/helpers.go b/tools/wacli/cmd/wacli/helpers.go
deleted file mode 100644
index bc5592e..0000000
--- a/tools/wacli/cmd/wacli/helpers.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package main
-
-import (
- "fmt"
- "os"
- "strings"
- "time"
-
- "golang.org/x/term"
-)
-
-func isTTY() bool {
- return term.IsTerminal(int(os.Stdout.Fd()))
-}
-
-func parseTime(s string) (time.Time, error) {
- s = strings.TrimSpace(s)
- if s == "" {
- return time.Time{}, fmt.Errorf("time is required")
- }
- if t, err := time.Parse(time.RFC3339, s); err == nil {
- return t, nil
- }
- if t, err := time.Parse("2006-01-02", s); err == nil {
- return t, nil
- }
- return time.Time{}, fmt.Errorf("unsupported time format %q (use RFC3339 or YYYY-MM-DD)", s)
-}
-
-func truncate(s string, max int) string {
- s = strings.ReplaceAll(s, "\n", " ")
- s = strings.TrimSpace(s)
- if max <= 0 || len(s) <= max {
- return s
- }
- if max <= 1 {
- return s[:max]
- }
- return s[:max-1] + "…"
-}
diff --git a/tools/wacli/cmd/wacli/history.go b/tools/wacli/cmd/wacli/history.go
deleted file mode 100644
index 73c87c3..0000000
--- a/tools/wacli/cmd/wacli/history.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "os/signal"
- "syscall"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/app"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newHistoryCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "history",
- Short: "History backfill (best-effort; requires prior auth)",
- }
- cmd.AddCommand(newHistoryBackfillCmd(flags))
- return cmd
-}
-
-func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
- var chat string
- var count int
- var requests int
- var wait time.Duration
- var idleExit time.Duration
-
- cmd := &cobra.Command{
- Use: "backfill",
- Short: "Request older messages for a chat from your primary device (on-demand history sync)",
- RunE: func(cmd *cobra.Command, args []string) error {
- if chat == "" {
- return fmt.Errorf("--chat is required")
- }
-
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- res, err := a.BackfillHistory(ctx, app.BackfillOptions{
- ChatJID: chat,
- Count: count,
- Requests: requests,
- WaitPerRequest: wait,
- IdleExit: idleExit,
- })
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{
- "chat": res.ChatJID,
- "requests_sent": res.RequestsSent,
- "responses_seen": res.ResponsesSeen,
- "messages_added": res.MessagesAdded,
- "messages_synced": res.MessagesSynced,
- })
- }
-
- fmt.Fprintf(os.Stdout, "Backfill complete for %s. Added %d messages (%d requests).\n", res.ChatJID, res.MessagesAdded, res.RequestsSent)
- return nil
- },
- }
-
- cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
- cmd.Flags().IntVar(&count, "count", 50, "number of messages to request per on-demand sync (recommended: 50)")
- cmd.Flags().IntVar(&requests, "requests", 1, "number of on-demand requests to attempt")
- cmd.Flags().DurationVar(&wait, "wait", 60*time.Second, "time to wait for an on-demand response per request")
- cmd.Flags().DurationVar(&idleExit, "idle-exit", 5*time.Second, "exit after being idle (after backfill requests)")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/main.go b/tools/wacli/cmd/wacli/main.go
deleted file mode 100644
index df59fc3..0000000
--- a/tools/wacli/cmd/wacli/main.go
+++ /dev/null
@@ -1,44 +0,0 @@
-package main
-
-import (
- "os"
- "strings"
-
- "go.mau.fi/whatsmeow/proto/waCompanionReg"
- "go.mau.fi/whatsmeow/store"
- "google.golang.org/protobuf/proto"
-)
-
-func main() {
- applyDeviceLabel()
- if err := execute(os.Args[1:]); err != nil {
- os.Exit(1)
- }
-}
-
-func applyDeviceLabel() {
- label := strings.TrimSpace(os.Getenv("WACLI_DEVICE_LABEL"))
- platformRaw := strings.TrimSpace(os.Getenv("WACLI_DEVICE_PLATFORM"))
- if platformRaw != "" {
- platform := parsePlatformType(platformRaw)
- store.DeviceProps.PlatformType = platform.Enum()
- }
- if label == "" {
- label = "MPA"
- }
- store.SetOSInfo(label, [3]uint32{0, 1, 0})
- store.BaseClientPayload.UserAgent.Device = proto.String(label)
- store.BaseClientPayload.UserAgent.Manufacturer = proto.String(label)
-}
-
-func parsePlatformType(raw string) waCompanionReg.DeviceProps_PlatformType {
- value := strings.TrimSpace(raw)
- if value == "" {
- return waCompanionReg.DeviceProps_CHROME
- }
- value = strings.ToUpper(value)
- if enumValue, ok := waCompanionReg.DeviceProps_PlatformType_value[value]; ok {
- return waCompanionReg.DeviceProps_PlatformType(enumValue)
- }
- return waCompanionReg.DeviceProps_CHROME
-}
diff --git a/tools/wacli/cmd/wacli/media.go b/tools/wacli/cmd/wacli/media.go
deleted file mode 100644
index 0967b4d..0000000
--- a/tools/wacli/cmd/wacli/media.go
+++ /dev/null
@@ -1,96 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newMediaCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "media",
- Short: "Media download",
- }
- cmd.AddCommand(newMediaDownloadCmd(flags))
- return cmd
-}
-
-func newMediaDownloadCmd(flags *rootFlags) *cobra.Command {
- var chat string
- var id string
- var outputPath string
-
- cmd := &cobra.Command{
- Use: "download",
- Short: "Download media for a message",
- RunE: func(cmd *cobra.Command, args []string) error {
- if chat == "" || id == "" {
- return fmt.Errorf("--chat and --id are required")
- }
-
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
-
- info, err := a.DB().GetMediaDownloadInfo(chat, id)
- if err != nil {
- return err
- }
- if info.MediaType == "" || info.DirectPath == "" || len(info.MediaKey) == 0 {
- return fmt.Errorf("message has no downloadable media metadata (run `wacli sync` first)")
- }
-
- target, err := a.ResolveMediaOutputPath(info, outputPath)
- if err != nil {
- return err
- }
-
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
-
- bytes, err := a.WA().DownloadMediaToFile(ctx, info.DirectPath, info.FileEncSHA256, info.FileSHA256, info.MediaKey, info.FileLength, info.MediaType, "", target)
- if err != nil {
- return err
- }
- now := time.Now().UTC()
- _ = a.DB().MarkMediaDownloaded(info.ChatJID, info.MsgID, target, now)
-
- resp := map[string]any{
- "chat": info.ChatJID,
- "id": info.MsgID,
- "path": target,
- "bytes": bytes,
- "media_type": info.MediaType,
- "mime_type": info.MimeType,
- "downloaded": true,
- "downloaded_at": now.Format(time.RFC3339Nano),
- }
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, resp)
- }
- fmt.Fprintf(os.Stdout, "%s (%d bytes)\n", target, bytes)
- return nil
- },
- }
-
- cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
- cmd.Flags().StringVar(&id, "id", "", "message ID")
- cmd.Flags().StringVar(&outputPath, "output", "", "output file or directory (default: store media dir)")
- _ = cmd.MarkFlagRequired("chat")
- _ = cmd.MarkFlagRequired("id")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/messages.go b/tools/wacli/cmd/wacli/messages.go
deleted file mode 100644
index 50e2a22..0000000
--- a/tools/wacli/cmd/wacli/messages.go
+++ /dev/null
@@ -1,334 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "text/tabwriter"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
- "github.com/steipete/wacli/internal/store"
-)
-
-func newMessagesCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "messages",
- Short: "List and search messages from the local DB",
- }
- cmd.AddCommand(newMessagesListCmd(flags))
- cmd.AddCommand(newMessagesSearchCmd(flags))
- cmd.AddCommand(newMessagesShowCmd(flags))
- cmd.AddCommand(newMessagesContextCmd(flags))
- return cmd
-}
-
-func newMessagesListCmd(flags *rootFlags) *cobra.Command {
- var chat string
- var limit int
- var afterStr string
- var beforeStr string
-
- cmd := &cobra.Command{
- Use: "list",
- Short: "List messages",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- var after *time.Time
- var before *time.Time
- if afterStr != "" {
- t, err := parseTime(afterStr)
- if err != nil {
- return err
- }
- after = &t
- }
- if beforeStr != "" {
- t, err := parseTime(beforeStr)
- if err != nil {
- return err
- }
- before = &t
- }
-
- msgs, err := a.DB().ListMessages(store.ListMessagesParams{
- ChatJID: chat,
- Limit: limit,
- After: after,
- Before: before,
- })
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{
- "messages": msgs,
- "fts": a.DB().HasFTS(),
- })
- }
-
- w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
- fmt.Fprintln(w, "TIME\tCHAT\tFROM\tID\tTEXT")
- for _, m := range msgs {
- from := m.SenderJID
- if m.FromMe {
- from = "me"
- }
- chatLabel := m.ChatName
- if chatLabel == "" {
- chatLabel = m.ChatJID
- }
- text := strings.TrimSpace(m.DisplayText)
- if text == "" {
- text = strings.TrimSpace(m.Text)
- }
- if m.MediaType != "" && text == "" {
- text = "Sent " + m.MediaType
- }
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
- m.Timestamp.Local().Format("2006-01-02 15:04:05"),
- truncate(chatLabel, 24),
- truncate(from, 18),
- truncate(m.MsgID, 14),
- truncate(text, 80),
- )
- }
- _ = w.Flush()
- return nil
- },
- }
-
- cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
- cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
- cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
- cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
- return cmd
-}
-
-func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
- var chat string
- var from string
- var limit int
- var afterStr string
- var beforeStr string
- var msgType string
-
- cmd := &cobra.Command{
- Use: "search ",
- Short: "Search messages (FTS5 if available; otherwise LIKE)",
- Args: cobra.ExactArgs(1),
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- var after *time.Time
- var before *time.Time
- if afterStr != "" {
- t, err := parseTime(afterStr)
- if err != nil {
- return err
- }
- after = &t
- }
- if beforeStr != "" {
- t, err := parseTime(beforeStr)
- if err != nil {
- return err
- }
- before = &t
- }
-
- msgs, err := a.DB().SearchMessages(store.SearchMessagesParams{
- Query: args[0],
- ChatJID: chat,
- From: from,
- Limit: limit,
- After: after,
- Before: before,
- Type: msgType,
- })
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{
- "messages": msgs,
- "fts": a.DB().HasFTS(),
- })
- }
-
- w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
- fmt.Fprintf(w, "TIME\tCHAT\tFROM\tID\tMATCH\n")
- for _, m := range msgs {
- fromLabel := m.SenderJID
- if m.FromMe {
- fromLabel = "me"
- }
- chatLabel := m.ChatName
- if chatLabel == "" {
- chatLabel = m.ChatJID
- }
- match := m.Snippet
- if match == "" {
- match = strings.TrimSpace(m.DisplayText)
- }
- if match == "" {
- match = m.Text
- }
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
- m.Timestamp.Local().Format("2006-01-02 15:04:05"),
- truncate(chatLabel, 24),
- truncate(fromLabel, 18),
- truncate(m.MsgID, 14),
- truncate(match, 90),
- )
- }
- _ = w.Flush()
- if !a.DB().HasFTS() {
- fmt.Fprintln(os.Stderr, "Note: FTS5 not enabled; search is using LIKE (slow).")
- }
- return nil
- },
- }
-
- cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
- cmd.Flags().StringVar(&from, "from", "", "sender JID")
- cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
- cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
- cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
- cmd.Flags().StringVar(&msgType, "type", "", "media type filter (image|video|audio|document)")
- return cmd
-}
-
-func newMessagesShowCmd(flags *rootFlags) *cobra.Command {
- var chat string
- var id string
-
- cmd := &cobra.Command{
- Use: "show",
- Short: "Show one message",
- RunE: func(cmd *cobra.Command, args []string) error {
- if chat == "" || id == "" {
- return fmt.Errorf("--chat and --id are required")
- }
-
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- m, err := a.DB().GetMessage(chat, id)
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, m)
- }
-
- fmt.Fprintf(os.Stdout, "Chat: %s\n", m.ChatJID)
- if m.ChatName != "" {
- fmt.Fprintf(os.Stdout, "Chat name: %s\n", m.ChatName)
- }
- fmt.Fprintf(os.Stdout, "ID: %s\n", m.MsgID)
- fmt.Fprintf(os.Stdout, "Time: %s\n", m.Timestamp.Local().Format(time.RFC3339))
- if m.FromMe {
- fmt.Fprintf(os.Stdout, "From: me\n")
- } else {
- fmt.Fprintf(os.Stdout, "From: %s\n", m.SenderJID)
- }
- if m.MediaType != "" {
- fmt.Fprintf(os.Stdout, "Media: %s\n", m.MediaType)
- }
- fmt.Fprintf(os.Stdout, "\n%s\n", m.Text)
- return nil
- },
- }
-
- cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
- cmd.Flags().StringVar(&id, "id", "", "message ID")
- return cmd
-}
-
-func newMessagesContextCmd(flags *rootFlags) *cobra.Command {
- var chat string
- var id string
- var before int
- var after int
-
- cmd := &cobra.Command{
- Use: "context",
- Short: "Show message context around a message ID",
- RunE: func(cmd *cobra.Command, args []string) error {
- if chat == "" || id == "" {
- return fmt.Errorf("--chat and --id are required")
- }
-
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, false, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- msgs, err := a.DB().MessageContext(chat, id, before, after)
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, msgs)
- }
-
- w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
- fmt.Fprintln(w, "TIME\tFROM\tID\tTEXT")
- for _, m := range msgs {
- from := m.SenderJID
- if m.FromMe {
- from = "me"
- }
- line := m.Text
- if m.MsgID == id {
- line = ">> " + line
- }
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
- m.Timestamp.Local().Format("2006-01-02 15:04:05"),
- truncate(from, 18),
- truncate(m.MsgID, 14),
- truncate(line, 100),
- )
- }
- _ = w.Flush()
- return nil
- },
- }
- cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
- cmd.Flags().StringVar(&id, "id", "", "message ID")
- cmd.Flags().IntVar(&before, "before", 5, "messages before")
- cmd.Flags().IntVar(&after, "after", 5, "messages after")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/root.go b/tools/wacli/cmd/wacli/root.go
deleted file mode 100644
index 10732c6..0000000
--- a/tools/wacli/cmd/wacli/root.go
+++ /dev/null
@@ -1,117 +0,0 @@
-package main
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "path/filepath"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/app"
- "github.com/steipete/wacli/internal/config"
- "github.com/steipete/wacli/internal/lock"
- "github.com/steipete/wacli/internal/out"
-)
-
-var version = "0.5.0"
-
-type rootFlags struct {
- storeDir string
- asJSON bool
- timeout time.Duration
-}
-
-func execute(args []string) error {
- var flags rootFlags
-
- rootCmd := &cobra.Command{
- Use: "wacli",
- SilenceUsage: true,
- SilenceErrors: true,
- Version: version,
- }
- rootCmd.SetVersionTemplate("wacli {{.Version}}\n")
-
- rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: ~/.wacli)")
- rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "output JSON instead of human-readable text")
- rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 5*time.Minute, "command timeout (non-sync commands)")
-
- rootCmd.AddCommand(newVersionCmd())
- rootCmd.AddCommand(newDoctorCmd(&flags))
- rootCmd.AddCommand(newAuthCmd(&flags))
- rootCmd.AddCommand(newSyncCmd(&flags))
- rootCmd.AddCommand(newMessagesCmd(&flags))
- rootCmd.AddCommand(newSendCmd(&flags))
- rootCmd.AddCommand(newMediaCmd(&flags))
- rootCmd.AddCommand(newContactsCmd(&flags))
- rootCmd.AddCommand(newChatsCmd(&flags))
- rootCmd.AddCommand(newGroupsCmd(&flags))
- rootCmd.AddCommand(newHistoryCmd(&flags))
-
- rootCmd.SetArgs(args)
- if err := rootCmd.Execute(); err != nil {
- _ = out.WriteError(os.Stderr, flags.asJSON, err)
- return err
- }
- return nil
-}
-
-func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) {
- storeDir := flags.storeDir
- if storeDir == "" {
- storeDir = config.DefaultStoreDir()
- }
- storeDir, _ = filepath.Abs(storeDir)
-
- var lk *lock.Lock
- if needLock {
- var err error
- lk, err = lock.Acquire(storeDir)
- if err != nil {
- return nil, nil, err
- }
- }
-
- a, err := app.New(app.Options{
- StoreDir: storeDir,
- Version: version,
- JSON: flags.asJSON,
- AllowUnauthed: allowUnauthed,
- })
- if err != nil {
- if lk != nil {
- _ = lk.Release()
- }
- return nil, nil, err
- }
-
- return a, lk, nil
-}
-
-func withTimeout(ctx context.Context, flags *rootFlags) (context.Context, context.CancelFunc) {
- if flags.timeout <= 0 {
- return context.WithCancel(ctx)
- }
- return context.WithTimeout(ctx, flags.timeout)
-}
-
-func closeApp(a *app.App, lk *lock.Lock) {
- if a != nil {
- a.Close()
- }
- if lk != nil {
- _ = lk.Release()
- }
-}
-
-func wrapErr(err error, msg string) error {
- if err == nil {
- return nil
- }
- if errors.Is(err, context.Canceled) {
- return err
- }
- return fmt.Errorf("%s: %w", msg, err)
-}
diff --git a/tools/wacli/cmd/wacli/send.go b/tools/wacli/cmd/wacli/send.go
deleted file mode 100644
index 54e8a84..0000000
--- a/tools/wacli/cmd/wacli/send.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "time"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
- "github.com/steipete/wacli/internal/store"
- "github.com/steipete/wacli/internal/wa"
-)
-
-func newSendCmd(flags *rootFlags) *cobra.Command {
- cmd := &cobra.Command{
- Use: "send",
- Short: "Send messages",
- }
- cmd.AddCommand(newSendTextCmd(flags))
- cmd.AddCommand(newSendFileCmd(flags))
- return cmd
-}
-
-func newSendTextCmd(flags *rootFlags) *cobra.Command {
- var to string
- var message string
-
- cmd := &cobra.Command{
- Use: "text",
- Short: "Send a text message",
- RunE: func(cmd *cobra.Command, args []string) error {
- if to == "" || message == "" {
- return fmt.Errorf("--to and --message are required")
- }
-
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
-
- toJID, err := wa.ParseUserOrJID(to)
- if err != nil {
- return err
- }
-
- msgID, err := a.WA().SendText(ctx, toJID, message)
- if err != nil {
- return err
- }
-
- now := time.Now().UTC()
- chat := toJID
- chatName := a.WA().ResolveChatName(ctx, chat, "")
- kind := chatKindFromJID(chat)
- _ = a.DB().UpsertChat(chat.String(), kind, chatName, now)
- _ = a.DB().UpsertMessage(store.UpsertMessageParams{
- ChatJID: chat.String(),
- ChatName: chatName,
- MsgID: string(msgID),
- SenderJID: "",
- SenderName: "me",
- Timestamp: now,
- FromMe: true,
- Text: message,
- })
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{
- "sent": true,
- "to": chat.String(),
- "id": msgID,
- })
- }
- fmt.Fprintf(os.Stdout, "Sent to %s (id %s)\n", chat.String(), msgID)
- return nil
- },
- }
-
- cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
- cmd.Flags().StringVar(&message, "message", "", "message text")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/send_file.go b/tools/wacli/cmd/wacli/send_file.go
deleted file mode 100644
index 9625a5e..0000000
--- a/tools/wacli/cmd/wacli/send_file.go
+++ /dev/null
@@ -1,163 +0,0 @@
-package main
-
-import (
- "context"
- "mime"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/steipete/wacli/internal/app"
- "github.com/steipete/wacli/internal/store"
- "github.com/steipete/wacli/internal/wa"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/types"
- "google.golang.org/protobuf/proto"
-)
-
-func sendFile(ctx context.Context, a interface {
- WA() app.WAClient
- DB() *store.DB
-}, to types.JID, filePath, filename, caption, mimeOverride string) (string, map[string]string, error) {
- data, err := os.ReadFile(filePath)
- if err != nil {
- return "", nil, err
- }
-
- name := strings.TrimSpace(filename)
- if name == "" {
- name = filepath.Base(filePath)
- }
- mimeType := strings.TrimSpace(mimeOverride)
- if mimeType == "" {
- // Use filePath for MIME detection, not the display name override
- mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))
- }
- if mimeType == "" {
- sniff := data
- if len(sniff) > 512 {
- sniff = sniff[:512]
- }
- mimeType = http.DetectContentType(sniff)
- }
-
- mediaType := "document"
- uploadType, _ := wa.MediaTypeFromString("document")
- switch {
- case strings.HasPrefix(mimeType, "image/"):
- mediaType = "image"
- uploadType, _ = wa.MediaTypeFromString("image")
- case strings.HasPrefix(mimeType, "video/"):
- mediaType = "video"
- uploadType, _ = wa.MediaTypeFromString("video")
- case strings.HasPrefix(mimeType, "audio/"):
- mediaType = "audio"
- uploadType, _ = wa.MediaTypeFromString("audio")
- }
-
- up, err := a.WA().Upload(ctx, data, uploadType)
- if err != nil {
- return "", nil, err
- }
-
- now := time.Now().UTC()
- msg := &waProto.Message{}
-
- switch mediaType {
- case "image":
- msg.ImageMessage = &waProto.ImageMessage{
- URL: proto.String(up.URL),
- DirectPath: proto.String(up.DirectPath),
- MediaKey: up.MediaKey,
- FileEncSHA256: up.FileEncSHA256,
- FileSHA256: up.FileSHA256,
- FileLength: proto.Uint64(up.FileLength),
- Mimetype: proto.String(mimeType),
- Caption: proto.String(caption),
- }
- case "video":
- msg.VideoMessage = &waProto.VideoMessage{
- URL: proto.String(up.URL),
- DirectPath: proto.String(up.DirectPath),
- MediaKey: up.MediaKey,
- FileEncSHA256: up.FileEncSHA256,
- FileSHA256: up.FileSHA256,
- FileLength: proto.Uint64(up.FileLength),
- Mimetype: proto.String(mimeType),
- Caption: proto.String(caption),
- }
- case "audio":
- msg.AudioMessage = &waProto.AudioMessage{
- URL: proto.String(up.URL),
- DirectPath: proto.String(up.DirectPath),
- MediaKey: up.MediaKey,
- FileEncSHA256: up.FileEncSHA256,
- FileSHA256: up.FileSHA256,
- FileLength: proto.Uint64(up.FileLength),
- Mimetype: proto.String(mimeType),
- PTT: proto.Bool(false),
- }
- default:
- msg.DocumentMessage = &waProto.DocumentMessage{
- URL: proto.String(up.URL),
- DirectPath: proto.String(up.DirectPath),
- MediaKey: up.MediaKey,
- FileEncSHA256: up.FileEncSHA256,
- FileSHA256: up.FileSHA256,
- FileLength: proto.Uint64(up.FileLength),
- Mimetype: proto.String(mimeType),
- FileName: proto.String(name),
- Caption: proto.String(caption),
- Title: proto.String(name),
- }
- }
-
- id, err := a.WA().SendProtoMessage(ctx, to, msg)
- if err != nil {
- return "", nil, err
- }
-
- chatName := a.WA().ResolveChatName(ctx, to, "")
- kind := chatKindFromJID(to)
- _ = a.DB().UpsertChat(to.String(), kind, chatName, now)
- _ = a.DB().UpsertMessage(store.UpsertMessageParams{
- ChatJID: to.String(),
- ChatName: chatName,
- MsgID: id,
- SenderJID: "",
- SenderName: "me",
- Timestamp: now,
- FromMe: true,
- Text: caption,
- MediaType: mediaType,
- MediaCaption: caption,
- Filename: name,
- MimeType: mimeType,
- DirectPath: up.DirectPath,
- MediaKey: up.MediaKey,
- FileSHA256: up.FileSHA256,
- FileEncSHA256: up.FileEncSHA256,
- FileLength: up.FileLength,
- })
-
- return id, map[string]string{
- "name": name,
- "mime_type": mimeType,
- "media": mediaType,
- }, nil
-}
-
-func chatKindFromJID(j types.JID) string {
- if j.Server == types.GroupServer {
- return "group"
- }
- if j.IsBroadcastList() {
- return "broadcast"
- }
- if j.Server == types.DefaultUserServer {
- return "dm"
- }
- return "unknown"
-}
diff --git a/tools/wacli/cmd/wacli/send_file_cmd.go b/tools/wacli/cmd/wacli/send_file_cmd.go
deleted file mode 100644
index ad18202..0000000
--- a/tools/wacli/cmd/wacli/send_file_cmd.go
+++ /dev/null
@@ -1,73 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
-
- "github.com/spf13/cobra"
- "github.com/steipete/wacli/internal/out"
- "github.com/steipete/wacli/internal/wa"
-)
-
-func newSendFileCmd(flags *rootFlags) *cobra.Command {
- var to string
- var filePath string
- var filename string
- var caption string
- var mimeOverride string
-
- cmd := &cobra.Command{
- Use: "file",
- Short: "Send a file (image/video/audio/document)",
- RunE: func(cmd *cobra.Command, args []string) error {
- if to == "" || filePath == "" {
- return fmt.Errorf("--to and --file are required")
- }
-
- ctx, cancel := withTimeout(context.Background(), flags)
- defer cancel()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
- if err := a.Connect(ctx, false, nil); err != nil {
- return err
- }
-
- toJID, err := wa.ParseUserOrJID(to)
- if err != nil {
- return err
- }
-
- msgID, meta, err := sendFile(ctx, a, toJID, filePath, filename, caption, mimeOverride)
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{
- "sent": true,
- "to": toJID.String(),
- "id": msgID,
- "file": meta,
- })
- }
- fmt.Fprintf(os.Stdout, "Sent %s to %s (id %s)\n", meta["name"], toJID.String(), msgID)
- return nil
- },
- }
-
- cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
- cmd.Flags().StringVar(&filePath, "file", "", "path to file")
- cmd.Flags().StringVar(&filename, "filename", "", "display name for the file (defaults to basename of --file)")
- cmd.Flags().StringVar(&caption, "caption", "", "caption (images/videos/documents)")
- cmd.Flags().StringVar(&mimeOverride, "mime", "", "override detected mime type")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/sync.go b/tools/wacli/cmd/wacli/sync.go
deleted file mode 100644
index 3bf1a97..0000000
--- a/tools/wacli/cmd/wacli/sync.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "os/signal"
- "syscall"
- "time"
-
- "github.com/spf13/cobra"
- appPkg "github.com/steipete/wacli/internal/app"
- "github.com/steipete/wacli/internal/out"
-)
-
-func newSyncCmd(flags *rootFlags) *cobra.Command {
- var once bool
- var follow bool
- var idleExit time.Duration
- var downloadMedia bool
- var refreshContacts bool
- var refreshGroups bool
-
- cmd := &cobra.Command{
- Use: "sync",
- Short: "Sync messages (requires prior auth; never shows QR)",
- RunE: func(cmd *cobra.Command, args []string) error {
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
-
- a, lk, err := newApp(ctx, flags, true, false)
- if err != nil {
- return err
- }
- defer closeApp(a, lk)
-
- if err := a.EnsureAuthed(); err != nil {
- return err
- }
-
- mode := appPkg.SyncModeFollow
- if once {
- mode = appPkg.SyncModeOnce
- } else if follow {
- mode = appPkg.SyncModeFollow
- } else {
- mode = appPkg.SyncModeOnce
- }
-
- res, err := a.Sync(ctx, appPkg.SyncOptions{
- Mode: mode,
- AllowQR: false,
- DownloadMedia: downloadMedia,
- RefreshContacts: refreshContacts,
- RefreshGroups: refreshGroups,
- IdleExit: idleExit,
- })
- if err != nil {
- return err
- }
-
- if flags.asJSON {
- return out.WriteJSON(os.Stdout, map[string]any{
- "synced": true,
- "messages_stored": res.MessagesStored,
- })
- }
- fmt.Fprintf(os.Stdout, "Messages stored: %d\n", res.MessagesStored)
- return nil
- },
- }
-
- cmd.Flags().BoolVar(&once, "once", false, "sync until idle and exit")
- cmd.Flags().BoolVar(&follow, "follow", true, "keep syncing until Ctrl+C")
- cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (once mode)")
- cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
- cmd.Flags().BoolVar(&refreshContacts, "refresh-contacts", false, "refresh contacts from session store into local DB")
- cmd.Flags().BoolVar(&refreshGroups, "refresh-groups", false, "refresh joined groups (live) into local DB")
- return cmd
-}
diff --git a/tools/wacli/cmd/wacli/version.go b/tools/wacli/cmd/wacli/version.go
deleted file mode 100644
index b8b048a..0000000
--- a/tools/wacli/cmd/wacli/version.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package main
-
-import (
- "fmt"
-
- "github.com/spf13/cobra"
-)
-
-func newVersionCmd() *cobra.Command {
- return &cobra.Command{
- Use: "version",
- Short: "Print version",
- Run: func(cmd *cobra.Command, args []string) {
- fmt.Println(version)
- },
- }
-}
diff --git a/tools/wacli/docs/release.md b/tools/wacli/docs/release.md
deleted file mode 100644
index f486959..0000000
--- a/tools/wacli/docs/release.md
+++ /dev/null
@@ -1,33 +0,0 @@
-# Release
-
-## GitHub Release Artifacts
-
-`wacli` uses GoReleaser (`.goreleaser.yaml` for macOS, `.goreleaser-linux-windows.yaml` for linux/windows) and the GitHub Actions workflow `.github/workflows/release.yml`.
-
-To cut a release:
-
-1. Tag and push:
- - `git tag vX.Y.Z`
- - `git push origin vX.Y.Z`
-2. Wait for the GitHub Actions “release” workflow to publish the release artifacts.
-
-To re-release an existing tag, run the workflow manually and pass the tag (e.g. `v0.1.0`).
-
-Expected macOS artifact name (used by the tap updater):
-
-- `wacli-macos-universal.tar.gz`
-
-Other artifacts:
-
-- `wacli-linux-.tar.gz`
-- `wacli-windows-.zip`
-
-## Homebrew Tap
-
-The tap formula lives in `../homebrew-tap/Formula/wacli.rb`.
-
-Once a release exists, update the tap formula by running the `Update Formula` workflow in the tap repo with:
-
-- `formula`: `wacli`
-- `tag`: `vX.Y.Z`
-- `repository`: `steipete/wacli`
diff --git a/tools/wacli/docs/spec.md b/tools/wacli/docs/spec.md
deleted file mode 100644
index 549c13d..0000000
--- a/tools/wacli/docs/spec.md
+++ /dev/null
@@ -1,254 +0,0 @@
-# wacli specification (plan)
-
-This document defines the v1 plan for `wacli`: a WhatsApp CLI that syncs messages locally, supports fast search, sending, and contact/group management. Implementation will use `whatsmeow` under the hood.
-
-## Goals
-
-- **Explicit authentication step**: `wacli auth` shows a QR code and completes login.
-- **Auth starts syncing immediately**: after successful QR pairing, `wacli auth` begins initial sync (history + metadata).
-- **Non-interactive sync**: `wacli sync` never displays a QR code; it fails with a clear error if not authenticated.
-- **Fast offline message search**: local SQLite + FTS5 index.
-- **Human-first output**: readable tables by default, `--json` opt-in for scripting.
-- **Single-instance safety**: store locking to avoid multi-instance session conflicts (device/session replacement issues).
-- **Group management**: list groups, inspect, rename, manage participants, invites.
-
-## Non-goals (v1)
-
-- Guaranteed full-history export (WhatsApp/WhatsApp Web history is best-effort).
-- End-to-end “contact creation” in WhatsApp (we can manage local aliases/notes; WhatsApp contacts are sourced from the account/device).
-- Full message-type parity (polls, reactions, ephemeral nuances, etc.) in v1.
-
-## Terminology
-
-- **JID**: WhatsApp Jabber ID, e.g. `1234567890@s.whatsapp.net` (user) or `123456789@g.us` (group).
-- **Store directory**: directory containing all local state, default `~/.wacli`.
-
-## Storage layout
-
-Default store: `~/.wacli` (override with `--store DIR`).
-
-Proposed files:
-
-- `~/.wacli/session.db` — `whatsmeow` SQL store (device identity, keys, app-state).
-- `~/.wacli/wacli.db` — our SQLite DB (messages/chats, FTS, local metadata).
-- `~/.wacli/media/...` — downloaded media (optional, on-demand or background).
-- `~/.wacli/LOCK` — store lock to prevent concurrent access.
-
-Rationale for two SQLite files: reduce coupling and keep the `whatsmeow`-owned schema separate from `wacli`’s local schema. It’s still “one store directory” for the user.
-
-## Concurrency + locking
-
-Every command that accesses the WhatsApp session must acquire an exclusive lock in the store dir.
-
-Behavior:
-
-- If lock is held: fail fast with a clear message (include PID and start time if available).
-- This prevents running multiple `wacli` instances against the same WhatsApp device identity, which can cause disconnects or “device replaced” style failures.
-
-## Authentication model
-
-### Commands
-
-- `wacli auth` (interactive)
- - If not authenticated: connect, show QR code, wait for success.
- - After success: start initial sync (bootstrap) immediately.
- - Exits after initial sync “goes idle” (configurable), unless `--follow` is set.
-
-- `wacli sync` (non-interactive)
- - Requires an existing authenticated session in `session.db`.
- - Never displays QR; if not authenticated, prints “run `wacli auth`”.
- - `--once` performs a bounded sync and exits.
- - Default (or `--follow`) stays connected and continues capturing messages.
-
-### UX principle
-
-Only `wacli auth` is expected to show a QR code. `wacli sync` should be safe to run in scripts/daemons without surprising interactivity.
-
-## Sync model (best-effort)
-
-`wacli` captures messages via `whatsmeow` event handlers:
-
-- `events.HistorySync`: initial/batch history sync delivered by WhatsApp Web.
-- `events.Message`: new incoming/outgoing messages while connected.
-- Connection lifecycle events (`Connected`, `Disconnected`) for logging/reconnect.
-
-### Bootstrap sync (after auth)
-
-Immediately after QR pairing success, `wacli auth` runs a bootstrap sync:
-
-- Processes history sync events and stores message metadata.
-- Updates chats, names, and contact-derived names as available.
-- Optionally starts media download worker (off by default, behind a flag).
-- Exits once “idle for N seconds” (no new history events) unless `--follow`.
-
-### Continuous sync
-
-`wacli sync --follow` keeps running:
-
-- persists new messages as they arrive
-- performs safe reconnect with backoff on disconnect
-- continues best-effort history catch-up when WhatsApp emits it
-
-## Database schema (wacli.db)
-
-### Tables (proposed)
-
-- `chats`
- - `jid` (PK), `name`, `kind` (`dm|group|broadcast`), `last_message_ts`, …
-- `contacts`
- - `jid` (PK), `push_name`, `full_name`, `business_name`, `phone`, …
-- `groups`
- - `jid` (PK), `name`, `owner_jid`, `created_ts`, …
-- `messages`
- - `rowid` (PK), `chat_jid`, `msg_id`, `sender_jid`, `ts`, `from_me`, `text`, `media_type`, `media_caption`, `filename`, `mime_type`, `direct_path`, hashes/keys, …
- - unique constraint: (`chat_jid`, `msg_id`)
-- `contact_aliases` (local management)
- - `jid` (PK/FK), `alias`, `notes`, `tags` (or join table)
-
-### Message search (FTS5)
-
-Use SQLite **FTS5** for fast full-text search.
-
-Approach:
-
-- Maintain canonical data in `messages`.
-- Maintain an FTS5 virtual table `messages_fts` (external content) indexing:
- - message body text
- - media caption
- - document filename
- - (optionally) denormalized sender/chat names for convenience
-
-Query behavior:
-
-- Default: `MATCH` queries (FTS syntax) with ranking via `bm25`.
-- Filters implemented in SQL: `--chat`, `--from`, `--after`, `--before`, `--has-media`, `--type`.
-- Human output includes snippets/highlights; `--json` returns structured matches + offsets/snippet string.
-
-Fallback:
-
-- If FTS5 is unavailable, fall back to `LIKE` with an explicit warning (slower).
-
-## CLI command surface (v1)
-
-Global flags:
-
-- `--store DIR` (default `~/.wacli`)
-- `--json` (default: human text)
-- `--timeout DURATION` (non-sync commands; e.g. `5m`)
-- `--version` (prints version and exits)
-
-### Doctor
-
-- `wacli doctor [--connect]`
-
-### Auth
-
-- `wacli auth [--follow] [--idle-exit 30s]`
-- `wacli auth status`
-- `wacli auth logout`
-
-### Sync
-
-- `wacli sync [--once] [--follow] [--download-media]`
-
-Notes:
-
-- `sync` errors if not authenticated (never prints QR).
-- `--download-media` runs a bounded/concurrent media downloader for messages that contain downloadable media metadata.
-
-### History backfill (best-effort)
-
-WhatsApp Web history is best-effort. If you want to try fetching *older* messages for a specific chat, `wacli` can send an on-demand history request to your primary device:
-
-- `wacli history backfill --chat JID [--count 50] [--requests N]`
-
-### Messages
-
-- `wacli messages list [--chat JID] [--limit N] [--before TS] [--after TS]`
-- `wacli messages search [--chat JID] [--from JID] [--limit N] [--before TS] [--after TS] [--type text|image|video|audio|document]`
-- `wacli messages show --chat JID --id MSG_ID`
-- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
-
-### Send
-
-- `wacli send text --to PHONE_OR_JID --message TEXT`
-- `wacli send file --to PHONE_OR_JID --file PATH [--caption TEXT] [--mime TYPE]`
-
-### Contacts (read + local management)
-
-- `wacli contacts search `
-- `wacli contacts show --jid JID`
-- `wacli contacts refresh`
-- `wacli contacts alias set --jid JID --alias "Name"`
-- `wacli contacts alias rm --jid JID`
-- `wacli contacts tags add|rm --jid JID --tag TAG`
-
-### Chats
-
-- `wacli chats list [--query TEXT]`
-- `wacli chats show --jid JID`
-
-### Groups
-
-- `wacli groups list [--query TEXT]`
-- `wacli groups refresh`
-- `wacli groups info --jid GROUP_JID`
-- `wacli groups rename --jid GROUP_JID --name "New Name"`
-- `wacli groups participants add|remove --jid GROUP_JID --user PHONE_OR_JID [--user ...]`
-- `wacli groups participants promote|demote --jid GROUP_JID --user PHONE_OR_JID [--user ...]`
-- `wacli groups invite link get|revoke --jid GROUP_JID`
-- `wacli groups join --code INVITE_CODE`
-- `wacli groups leave --jid GROUP_JID`
-
-## Output formats
-
-Default: human-readable text (tables / aligned columns; TTY-aware wrapping).
-
-Optional:
-
-- `--json` prints `{"success":true,"data":...,"error":null}`-style responses.
-
-Recommendation:
-
-- Write logs/progress (sync counters, reconnect notices) to stderr.
-- Write primary command output to stdout.
-
-## Reliability considerations
-
-- **Session conflicts**: running multiple instances can cause disconnects or “device replaced” behavior; locking is mandatory.
-- **Reconnect**: on disconnect, retry with exponential backoff and respect context cancellation.
-- **Idempotency**: message inserts are upserts keyed by (`chat_jid`, `msg_id`) so replays/history sync don’t duplicate data.
-
-## Security considerations
-
-- Store contains encryption keys/session data; protect permissions:
- - store dir `0700`
- - DB files `0600`
-- Avoid printing sensitive identifiers in logs unless needed for debugging (`--verbose`).
-
-## Implementation milestones
-
-### v0.1 (MVP)
-
-- `auth` (QR + bootstrap sync)
-- `sync` (non-interactive, follow mode)
-- `messages list/search` with FTS5
-- `send text`
-- store locking, default `~/.wacli`
-
-### v0.2
-
-- contacts: show + local alias/notes/tags
-- chats list/show with better naming resolution
-- groups list/info/rename/participants
-
-### v0.3
-
-- media download command + optional background downloader
-- `messages show/context` polish
-
-## Prior art / credit
-
-This spec borrows ideas and lessons learned from:
-
-- `https://github.com/vicentereig/whatsapp-cli`
diff --git a/tools/wacli/go.mod b/tools/wacli/go.mod
deleted file mode 100644
index 6c97975..0000000
--- a/tools/wacli/go.mod
+++ /dev/null
@@ -1,35 +0,0 @@
-module github.com/steipete/wacli
-
-go 1.25.0
-
-require (
- github.com/mattn/go-sqlite3 v1.14.34
- github.com/mdp/qrterminal/v3 v3.2.1
- github.com/spf13/cobra v1.10.2
- go.mau.fi/whatsmeow v0.0.0-20260211193157-7b33f6289f98
- golang.org/x/term v0.40.0
- google.golang.org/protobuf v1.36.11
-)
-
-require (
- filippo.io/edwards25519 v1.1.0 // indirect
- github.com/beeper/argo-go v1.1.2 // indirect
- github.com/coder/websocket v1.8.14 // indirect
- github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
- github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
- github.com/rs/zerolog v1.34.0 // indirect
- github.com/spf13/pflag v1.0.10 // indirect
- github.com/vektah/gqlparser/v2 v2.5.31 // indirect
- go.mau.fi/libsignal v0.2.1 // indirect
- go.mau.fi/util v0.9.5 // indirect
- golang.org/x/crypto v0.48.0 // indirect
- golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
- golang.org/x/net v0.50.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
- golang.org/x/text v0.34.0 // indirect
- rsc.io/qr v0.2.0 // indirect
-)
diff --git a/tools/wacli/go.sum b/tools/wacli/go.sum
deleted file mode 100644
index b3e5c45..0000000
--- a/tools/wacli/go.sum
+++ /dev/null
@@ -1,85 +0,0 @@
-filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
-filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
-github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
-github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
-github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
-github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
-github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
-github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
-github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
-github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
-github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
-github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
-github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
-github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
-github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
-github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
-github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
-github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
-github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
-github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
-github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
-github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
-github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
-github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
-github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
-github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
-github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
-github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
-github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
-github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
-github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
-github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
-github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
-github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
-github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
-go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
-go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
-go.mau.fi/util v0.9.5 h1:7AoWPCIZJGv4jvtFEuCe3GhAbI7uF9ckIooaXvwlIR4=
-go.mau.fi/util v0.9.5/go.mod h1:g1uvZ03VQhtTt2BgaRGVytS/Zj67NV0YNIECch0sQCQ=
-go.mau.fi/whatsmeow v0.0.0-20260211193157-7b33f6289f98 h1:4ePal8sykeD3vUcUWvECtfqoGyNr5UHYn8pPwrBittY=
-go.mau.fi/whatsmeow v0.0.0-20260211193157-7b33f6289f98/go.mod h1:jDLOQLLiYXcm4vMB6vtPcBLU387sRY+P3vOElxX8srA=
-go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
-golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
-golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
-golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
-golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
-golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
-golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
-golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
-golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
-golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
-gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
-rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/tools/wacli/internal/app/app.go b/tools/wacli/internal/app/app.go
deleted file mode 100644
index 90a5b7c..0000000
--- a/tools/wacli/internal/app/app.go
+++ /dev/null
@@ -1,130 +0,0 @@
-package app
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "time"
-
- "github.com/steipete/wacli/internal/store"
- "github.com/steipete/wacli/internal/wa"
- "go.mau.fi/whatsmeow"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
-)
-
-type WAClient interface {
- Close()
- IsAuthed() bool
- IsConnected() bool
- Connect(ctx context.Context, opts wa.ConnectOptions) error
-
- AddEventHandler(handler func(interface{})) uint32
- RemoveEventHandler(id uint32)
- ReconnectWithBackoff(ctx context.Context, minDelay, maxDelay time.Duration) error
-
- ResolveChatName(ctx context.Context, chat types.JID, pushName string) string
- GetContact(ctx context.Context, jid types.JID) (types.ContactInfo, error)
- GetAllContacts(ctx context.Context) (map[types.JID]types.ContactInfo, error)
-
- GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error)
- GetGroupInfo(ctx context.Context, jid types.JID) (*types.GroupInfo, error)
- SetGroupName(ctx context.Context, jid types.JID, name string) error
- UpdateGroupParticipants(ctx context.Context, group types.JID, users []types.JID, action wa.GroupParticipantAction) ([]types.GroupParticipant, error)
- GetGroupInviteLink(ctx context.Context, group types.JID, reset bool) (string, error)
- JoinGroupWithLink(ctx context.Context, code string) (types.JID, error)
- LeaveGroup(ctx context.Context, group types.JID) error
-
- SendText(ctx context.Context, to types.JID, text string) (types.MessageID, error)
- SendProtoMessage(ctx context.Context, to types.JID, msg *waProto.Message) (types.MessageID, error)
- Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
- DownloadMediaToFile(ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength uint64, mediaType, mmsType string, targetPath string) (int64, error)
-
- DecryptReaction(ctx context.Context, reaction *events.Message) (*waProto.ReactionMessage, error)
- RequestHistorySyncOnDemand(ctx context.Context, lastKnown types.MessageInfo, count int) (types.MessageID, error)
- Logout(ctx context.Context) error
-}
-
-type Options struct {
- StoreDir string
- Version string
- JSON bool
- AllowUnauthed bool
-}
-
-type App struct {
- opts Options
- wa WAClient
- db *store.DB
-}
-
-func New(opts Options) (*App, error) {
- if opts.StoreDir == "" {
- return nil, fmt.Errorf("store dir is required")
- }
- if err := os.MkdirAll(opts.StoreDir, 0700); err != nil {
- return nil, fmt.Errorf("create store dir: %w", err)
- }
-
- indexPath := filepath.Join(opts.StoreDir, "wacli.db")
-
- db, err := store.Open(indexPath)
- if err != nil {
- return nil, err
- }
-
- return &App{opts: opts, db: db}, nil
-}
-
-func (a *App) OpenWA() error {
- if a.wa != nil {
- return nil
- }
- sessionPath := filepath.Join(a.opts.StoreDir, "session.db")
- cli, err := wa.New(wa.Options{
- StorePath: sessionPath,
- })
- if err != nil {
- return err
- }
-
- a.wa = cli
- return nil
-}
-
-func (a *App) Close() {
- if a.wa != nil {
- a.wa.Close()
- }
- if a.db != nil {
- _ = a.db.Close()
- }
-}
-
-func (a *App) EnsureAuthed() error {
- if err := a.OpenWA(); err != nil {
- return err
- }
- if a.wa.IsAuthed() {
- return nil
- }
- return fmt.Errorf("not authenticated; run `wacli auth`")
-}
-
-func (a *App) WA() WAClient { return a.wa }
-func (a *App) DB() *store.DB { return a.db }
-func (a *App) StoreDir() string { return a.opts.StoreDir }
-func (a *App) Version() string { return a.opts.Version }
-func (a *App) AllowUnauthed() bool { return a.opts.AllowUnauthed }
-
-func (a *App) Connect(ctx context.Context, allowQR bool, qrWriter func(string)) error {
- if err := a.OpenWA(); err != nil {
- return err
- }
- return a.wa.Connect(ctx, wa.ConnectOptions{
- AllowQR: allowQR,
- OnQRCode: qrWriter,
- })
-}
diff --git a/tools/wacli/internal/app/app_test.go b/tools/wacli/internal/app/app_test.go
deleted file mode 100644
index 10006ca..0000000
--- a/tools/wacli/internal/app/app_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package app
-
-import (
- "testing"
-)
-
-func newTestApp(t *testing.T) *App {
- t.Helper()
- dir := t.TempDir()
- a, err := New(Options{StoreDir: dir})
- if err != nil {
- t.Fatalf("New: %v", err)
- }
- t.Cleanup(func() { a.Close() })
- return a
-}
diff --git a/tools/wacli/internal/app/backfill.go b/tools/wacli/internal/app/backfill.go
deleted file mode 100644
index 0e59fbd..0000000
--- a/tools/wacli/internal/app/backfill.go
+++ /dev/null
@@ -1,192 +0,0 @@
-package app
-
-import (
- "context"
- "database/sql"
- "fmt"
- "os"
- "strings"
- "sync"
- "time"
-
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
-)
-
-type BackfillOptions struct {
- ChatJID string
- Count int
- Requests int
- WaitPerRequest time.Duration
- IdleExit time.Duration
-}
-
-type BackfillResult struct {
- ChatJID string
- RequestsSent int
- ResponsesSeen int
- MessagesAdded int64
- MessagesSynced int64
-}
-
-type onDemandResponse struct {
- conversations int
- messages int
- endType waHistorySync.Conversation_EndOfHistoryTransferType
-}
-
-func (a *App) BackfillHistory(ctx context.Context, opts BackfillOptions) (BackfillResult, error) {
- chatStr := strings.TrimSpace(opts.ChatJID)
- if chatStr == "" {
- return BackfillResult{}, fmt.Errorf("--chat is required")
- }
- chat, err := types.ParseJID(chatStr)
- if err != nil {
- return BackfillResult{}, fmt.Errorf("parse chat JID: %w", err)
- }
- chatStr = chat.String()
-
- if opts.Count <= 0 {
- opts.Count = 50
- }
- if opts.Requests <= 0 {
- opts.Requests = 1
- }
- if opts.WaitPerRequest <= 0 {
- opts.WaitPerRequest = 60 * time.Second
- }
- if opts.IdleExit <= 0 {
- opts.IdleExit = 5 * time.Second
- }
-
- if err := a.EnsureAuthed(); err != nil {
- return BackfillResult{}, err
- }
- if err := a.OpenWA(); err != nil {
- return BackfillResult{}, err
- }
-
- beforeCount, _ := a.db.CountMessages()
-
- var mu sync.Mutex
- var waitCh chan onDemandResponse
- handlerID := a.wa.AddEventHandler(func(evt interface{}) {
- hs, ok := evt.(*events.HistorySync)
- if !ok || hs == nil || hs.Data == nil {
- return
- }
- if hs.Data.GetSyncType() != waHistorySync.HistorySync_ON_DEMAND {
- return
- }
-
- for _, conv := range hs.Data.GetConversations() {
- if strings.TrimSpace(conv.GetID()) != chatStr {
- continue
- }
- mu.Lock()
- ch := waitCh
- mu.Unlock()
- if ch == nil {
- return
- }
- resp := onDemandResponse{
- conversations: len(hs.Data.GetConversations()),
- messages: len(conv.GetMessages()),
- endType: conv.GetEndOfHistoryTransferType(),
- }
- select {
- case ch <- resp:
- default:
- }
- return
- }
- })
- defer a.wa.RemoveEventHandler(handlerID)
-
- var requestsSent int
- var responsesSeen int
-
- syncRes, err := a.Sync(ctx, SyncOptions{
- Mode: SyncModeOnce,
- AllowQR: false,
- IdleExit: opts.IdleExit,
- AfterConnect: func(ctx context.Context) error {
- for i := 0; i < opts.Requests; i++ {
- oldest, err := a.db.GetOldestMessageInfo(chatStr)
- if err != nil {
- if err == sql.ErrNoRows {
- return fmt.Errorf("no messages for %s in local DB; run `wacli sync` first", chatStr)
- }
- return err
- }
-
- reqInfo := types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- IsFromMe: oldest.FromMe,
- },
- ID: types.MessageID(oldest.MsgID),
- Timestamp: oldest.Timestamp,
- }
-
- ch := make(chan onDemandResponse, 4)
- mu.Lock()
- waitCh = ch
- mu.Unlock()
-
- requestsSent++
- fmt.Fprintf(os.Stderr, "Requesting %d older messages for %s...\n", opts.Count, chatStr)
- if _, err := a.wa.RequestHistorySyncOnDemand(ctx, reqInfo, opts.Count); err != nil {
- return err
- }
-
- var resp onDemandResponse
- select {
- case <-ctx.Done():
- return ctx.Err()
- case resp = <-ch:
- responsesSeen++
- case <-time.After(opts.WaitPerRequest):
- return fmt.Errorf("timed out waiting for on-demand history sync response")
- }
-
- mu.Lock()
- if waitCh == ch {
- waitCh = nil
- }
- mu.Unlock()
-
- fmt.Fprintf(os.Stderr, "On-demand history sync: %d conversations, %d messages.\n", resp.conversations, resp.messages)
-
- newOldest, err := a.db.GetOldestMessageInfo(chatStr)
- if err == nil && newOldest.MsgID == oldest.MsgID {
- fmt.Fprintln(os.Stderr, "No older messages were added (stopping).")
- return nil
- }
- if resp.messages <= 0 {
- fmt.Fprintln(os.Stderr, "No messages returned (stopping).")
- return nil
- }
- if resp.endType == waHistorySync.Conversation_COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY {
- fmt.Fprintln(os.Stderr, "Reached start of chat history (stopping).")
- return nil
- }
- }
- return nil
- },
- })
- if err != nil {
- return BackfillResult{}, err
- }
-
- afterCount, _ := a.db.CountMessages()
-
- return BackfillResult{
- ChatJID: chatStr,
- RequestsSent: requestsSent,
- ResponsesSeen: responsesSeen,
- MessagesAdded: afterCount - beforeCount,
- MessagesSynced: syncRes.MessagesStored,
- }, nil
-}
diff --git a/tools/wacli/internal/app/backfill_test.go b/tools/wacli/internal/app/backfill_test.go
deleted file mode 100644
index 36a36aa..0000000
--- a/tools/wacli/internal/app/backfill_test.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package app
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/steipete/wacli/internal/store"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waWeb"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "google.golang.org/protobuf/proto"
-)
-
-func TestBackfillHistoryAddsOlderMessages(t *testing.T) {
- a := newTestApp(t)
- f := newFakeWA()
- a.wa = f
-
- chat := types.JID{User: "123", Server: types.DefaultUserServer}
- chatStr := chat.String()
- base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
-
- if err := a.db.UpsertChat(chatStr, "dm", "Alice", base); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
- if err := a.db.UpsertMessage(storeUpsertMessage(chatStr, "m2", base.Add(2*time.Second), "newer")); err != nil {
- t.Fatalf("UpsertMessage: %v", err)
- }
-
- f.onDemandHistory = func(lastKnown types.MessageInfo, count int) *events.HistorySync {
- older := &waWeb.WebMessageInfo{
- Key: &waCommon.MessageKey{
- RemoteJID: proto.String(chatStr),
- FromMe: proto.Bool(false),
- ID: proto.String("m1"),
- },
- MessageTimestamp: proto.Uint64(uint64(base.Add(1 * time.Second).Unix())),
- Message: &waProto.Message{Conversation: proto.String("older")},
- }
- return &events.HistorySync{
- Data: &waHistorySync.HistorySync{
- SyncType: waHistorySync.HistorySync_ON_DEMAND.Enum(),
- Conversations: []*waHistorySync.Conversation{{
- ID: proto.String(chatStr),
- EndOfHistoryTransfer: proto.Bool(true),
- EndOfHistoryTransferType: waHistorySync.Conversation_COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY.Enum(),
- Messages: []*waHistorySync.HistorySyncMsg{{Message: older}},
- }},
- },
- }
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- res, err := a.BackfillHistory(ctx, BackfillOptions{
- ChatJID: chatStr,
- Count: 50,
- Requests: 1,
- WaitPerRequest: 1 * time.Second,
- IdleExit: 200 * time.Millisecond,
- })
- if err != nil {
- t.Fatalf("BackfillHistory: %v", err)
- }
- if res.MessagesAdded <= 0 {
- t.Fatalf("expected messages to be added, got %d", res.MessagesAdded)
- }
-
- oldest, err := a.db.GetOldestMessageInfo(chatStr)
- if err != nil {
- t.Fatalf("GetOldestMessageInfo: %v", err)
- }
- if oldest.MsgID != "m1" {
- t.Fatalf("expected oldest m1, got %q", oldest.MsgID)
- }
-}
-
-func storeUpsertMessage(chatJID, id string, ts time.Time, text string) store.UpsertMessageParams {
- return store.UpsertMessageParams{
- ChatJID: chatJID,
- MsgID: id,
- SenderJID: chatJID,
- SenderName: "Alice",
- Timestamp: ts,
- FromMe: false,
- Text: text,
- }
-}
diff --git a/tools/wacli/internal/app/bootstrap.go b/tools/wacli/internal/app/bootstrap.go
deleted file mode 100644
index 1ddca9e..0000000
--- a/tools/wacli/internal/app/bootstrap.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package app
-
-import (
- "context"
- "time"
-)
-
-func (a *App) refreshContacts(ctx context.Context) error {
- if err := a.OpenWA(); err != nil {
- return err
- }
- contacts, err := a.wa.GetAllContacts(ctx)
- if err != nil {
- return err
- }
- for jid, info := range contacts {
- _ = a.db.UpsertContact(
- jid.String(),
- jid.User,
- info.PushName,
- info.FullName,
- info.FirstName,
- info.BusinessName,
- )
- }
- return nil
-}
-
-func (a *App) refreshGroups(ctx context.Context) error {
- if err := a.OpenWA(); err != nil {
- return err
- }
- groups, err := a.wa.GetJoinedGroups(ctx)
- if err != nil {
- return err
- }
- now := time.Now().UTC()
- for _, g := range groups {
- if g == nil {
- continue
- }
- _ = a.db.UpsertGroup(g.JID.String(), g.GroupName.Name, g.OwnerJID.String(), g.GroupCreated)
- _ = a.db.UpsertChat(g.JID.String(), "group", g.GroupName.Name, now)
- }
- return nil
-}
diff --git a/tools/wacli/internal/app/bootstrap_test.go b/tools/wacli/internal/app/bootstrap_test.go
deleted file mode 100644
index 36a60fd..0000000
--- a/tools/wacli/internal/app/bootstrap_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package app
-
-import (
- "context"
- "testing"
- "time"
-
- "go.mau.fi/whatsmeow/types"
-)
-
-func TestRefreshContactsStoresContacts(t *testing.T) {
- a := newTestApp(t)
- f := newFakeWA()
- a.wa = f
-
- jid := types.JID{User: "111", Server: types.DefaultUserServer}
- f.contacts[jid] = types.ContactInfo{
- Found: true,
- PushName: "Push",
- FullName: "Full Name",
- FirstName: "First",
- }
-
- if err := a.refreshContacts(context.Background()); err != nil {
- t.Fatalf("refreshContacts: %v", err)
- }
- c, err := a.db.GetContact(jid.String())
- if err != nil {
- t.Fatalf("GetContact: %v", err)
- }
- if c.Name == "" {
- t.Fatalf("expected stored contact name, got empty")
- }
-}
-
-func TestRefreshGroupsStoresGroupsAndChats(t *testing.T) {
- a := newTestApp(t)
- f := newFakeWA()
- a.wa = f
-
- gid := types.JID{User: "12345", Server: types.GroupServer}
- created := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
- f.groups[gid] = &types.GroupInfo{
- JID: gid,
- OwnerJID: types.JID{User: "999", Server: types.DefaultUserServer},
- GroupName: types.GroupName{Name: "MyGroup"},
- GroupCreated: created,
- }
-
- if err := a.refreshGroups(context.Background()); err != nil {
- t.Fatalf("refreshGroups: %v", err)
- }
- gs, err := a.db.ListGroups("MyGroup", 10)
- if err != nil {
- t.Fatalf("ListGroups: %v", err)
- }
- if len(gs) != 1 || gs[0].JID != gid.String() {
- t.Fatalf("expected group to be stored, got %+v", gs)
- }
- c, err := a.db.GetChat(gid.String())
- if err != nil {
- t.Fatalf("GetChat: %v", err)
- }
- if c.Kind != "group" {
- t.Fatalf("expected chat kind group, got %q", c.Kind)
- }
-}
diff --git a/tools/wacli/internal/app/fake_wa_test.go b/tools/wacli/internal/app/fake_wa_test.go
deleted file mode 100644
index a8ade10..0000000
--- a/tools/wacli/internal/app/fake_wa_test.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package app
-
-import (
- "context"
- "fmt"
- "os"
- "path/filepath"
- "sync"
- "time"
-
- "github.com/steipete/wacli/internal/wa"
- "go.mau.fi/whatsmeow"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
-)
-
-type fakeWA struct {
- mu sync.Mutex
-
- authed bool
- connected bool
-
- nextHandlerID uint32
- handlers map[uint32]func(interface{})
-
- connectEvents []interface{}
-
- contacts map[types.JID]types.ContactInfo
- groups map[types.JID]*types.GroupInfo
-
- onDemandHistory func(lastKnown types.MessageInfo, count int) *events.HistorySync
-}
-
-func newFakeWA() *fakeWA {
- return &fakeWA{
- authed: true,
- handlers: map[uint32]func(interface{}){},
- contacts: map[types.JID]types.ContactInfo{},
- groups: map[types.JID]*types.GroupInfo{},
- nextHandlerID: 1,
- }
-}
-
-func (f *fakeWA) emit(evt interface{}) {
- f.mu.Lock()
- handlers := make([]func(interface{}), 0, len(f.handlers))
- for _, h := range f.handlers {
- handlers = append(handlers, h)
- }
- f.mu.Unlock()
- for _, h := range handlers {
- h(evt)
- }
-}
-
-func (f *fakeWA) Close() { f.mu.Lock(); f.connected = false; f.mu.Unlock() }
-
-func (f *fakeWA) IsAuthed() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.authed }
-func (f *fakeWA) IsConnected() bool {
- f.mu.Lock()
- defer f.mu.Unlock()
- return f.connected
-}
-
-func (f *fakeWA) Connect(ctx context.Context, opts wa.ConnectOptions) error {
- f.mu.Lock()
- authed := f.authed
- f.connected = true
- eventsToEmit := append([]interface{}{}, f.connectEvents...)
- f.mu.Unlock()
-
- if !authed && !opts.AllowQR {
- return fmt.Errorf("not authenticated; run `wacli auth`")
- }
- f.emit(&events.Connected{})
- for _, e := range eventsToEmit {
- f.emit(e)
- }
- return nil
-}
-
-func (f *fakeWA) AddEventHandler(handler func(interface{})) uint32 {
- f.mu.Lock()
- defer f.mu.Unlock()
- id := f.nextHandlerID
- f.nextHandlerID++
- f.handlers[id] = handler
- return id
-}
-
-func (f *fakeWA) RemoveEventHandler(id uint32) {
- f.mu.Lock()
- defer f.mu.Unlock()
- delete(f.handlers, id)
-}
-
-func (f *fakeWA) ReconnectWithBackoff(ctx context.Context, minDelay, maxDelay time.Duration) error {
- return f.Connect(ctx, wa.ConnectOptions{AllowQR: false})
-}
-
-func (f *fakeWA) ResolveChatName(ctx context.Context, chat types.JID, pushName string) string {
- if pushName != "" && pushName != "-" {
- return pushName
- }
- if chat.Server == types.GroupServer {
- if gi, _ := f.GetGroupInfo(ctx, chat); gi != nil && gi.GroupName.Name != "" {
- return gi.GroupName.Name
- }
- }
- if info, _ := f.GetContact(ctx, chat.ToNonAD()); info.Found {
- if name := wa.BestContactName(info); name != "" {
- return name
- }
- }
- return chat.String()
-}
-
-func (f *fakeWA) GetContact(ctx context.Context, jid types.JID) (types.ContactInfo, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- if v, ok := f.contacts[jid]; ok {
- return v, nil
- }
- return types.ContactInfo{Found: false}, nil
-}
-
-func (f *fakeWA) GetAllContacts(ctx context.Context) (map[types.JID]types.ContactInfo, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- out := make(map[types.JID]types.ContactInfo, len(f.contacts))
- for k, v := range f.contacts {
- out[k] = v
- }
- return out, nil
-}
-
-func (f *fakeWA) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- out := make([]*types.GroupInfo, 0, len(f.groups))
- for _, g := range f.groups {
- out = append(out, g)
- }
- return out, nil
-}
-
-func (f *fakeWA) GetGroupInfo(ctx context.Context, jid types.JID) (*types.GroupInfo, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- return f.groups[jid], nil
-}
-
-func (f *fakeWA) SetGroupName(ctx context.Context, jid types.JID, name string) error {
- f.mu.Lock()
- defer f.mu.Unlock()
- g := f.groups[jid]
- if g == nil {
- g = &types.GroupInfo{JID: jid}
- f.groups[jid] = g
- }
- g.GroupName.Name = name
- return nil
-}
-
-func (f *fakeWA) UpdateGroupParticipants(ctx context.Context, group types.JID, users []types.JID, action wa.GroupParticipantAction) ([]types.GroupParticipant, error) {
- f.mu.Lock()
- defer f.mu.Unlock()
- g := f.groups[group]
- if g == nil {
- g = &types.GroupInfo{JID: group}
- f.groups[group] = g
- }
- switch action {
- case wa.GroupParticipantAdd:
- for _, u := range users {
- g.Participants = append(g.Participants, types.GroupParticipant{JID: u})
- }
- case wa.GroupParticipantRemove:
- var kept []types.GroupParticipant
- rm := map[types.JID]bool{}
- for _, u := range users {
- rm[u] = true
- }
- for _, p := range g.Participants {
- if !rm[p.JID] {
- kept = append(kept, p)
- }
- }
- g.Participants = kept
- default:
- // promote/demote ignored for tests
- }
- return g.Participants, nil
-}
-
-func (f *fakeWA) GetGroupInviteLink(ctx context.Context, group types.JID, reset bool) (string, error) {
- return "https://chat.whatsapp.com/invite/test", nil
-}
-
-func (f *fakeWA) JoinGroupWithLink(ctx context.Context, code string) (types.JID, error) {
- return types.ParseJID("12345@g.us")
-}
-
-func (f *fakeWA) LeaveGroup(ctx context.Context, group types.JID) error { return nil }
-
-func (f *fakeWA) SendText(ctx context.Context, to types.JID, text string) (types.MessageID, error) {
- return types.MessageID("msgid"), nil
-}
-
-func (f *fakeWA) SendProtoMessage(ctx context.Context, to types.JID, msg *waProto.Message) (types.MessageID, error) {
- return types.MessageID("msgid"), nil
-}
-
-func (f *fakeWA) Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error) {
- return whatsmeow.UploadResponse{}, nil
-}
-
-func (f *fakeWA) DecryptReaction(ctx context.Context, reaction *events.Message) (*waProto.ReactionMessage, error) {
- return nil, fmt.Errorf("not supported")
-}
-
-func (f *fakeWA) DownloadMediaToFile(ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength uint64, mediaType, mmsType string, targetPath string) (int64, error) {
- if err := os.MkdirAll(filepath.Dir(targetPath), 0o700); err != nil {
- return 0, err
- }
- if err := os.WriteFile(targetPath, []byte("test"), 0o600); err != nil {
- return 0, err
- }
- st, err := os.Stat(targetPath)
- if err != nil {
- return 0, err
- }
- return st.Size(), nil
-}
-
-func (f *fakeWA) RequestHistorySyncOnDemand(ctx context.Context, lastKnown types.MessageInfo, count int) (types.MessageID, error) {
- f.mu.Lock()
- cb := f.onDemandHistory
- f.mu.Unlock()
- if cb != nil {
- f.emit(cb(lastKnown, count))
- }
- return types.MessageID("req"), nil
-}
-
-func (f *fakeWA) Logout(ctx context.Context) error {
- f.mu.Lock()
- defer f.mu.Unlock()
- f.authed = false
- return nil
-}
diff --git a/tools/wacli/internal/app/media.go b/tools/wacli/internal/app/media.go
deleted file mode 100644
index 6417aaf..0000000
--- a/tools/wacli/internal/app/media.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package app
-
-import (
- "context"
- "database/sql"
- "fmt"
- "mime"
- "os"
- "path/filepath"
- "strings"
- "sync"
- "time"
-
- "github.com/steipete/wacli/internal/pathutil"
- "github.com/steipete/wacli/internal/store"
-)
-
-type mediaJob struct {
- chatJID string
- msgID string
-}
-
-func (a *App) ResolveMediaOutputPath(info store.MediaDownloadInfo, requested string) (string, error) {
- filename := mediaFilename(info)
-
- if strings.TrimSpace(requested) != "" {
- out := requested
- if !filepath.IsAbs(out) {
- if abs, err := filepath.Abs(out); err == nil {
- out = abs
- }
- }
- if st, err := os.Stat(out); err == nil && st.IsDir() {
- return filepath.Join(out, filename), nil
- }
- if strings.HasSuffix(out, string(os.PathSeparator)) {
- return filepath.Join(out, filename), nil
- }
- return out, nil
- }
-
- baseDir := filepath.Join(a.opts.StoreDir, "media", pathutil.SanitizeSegment(info.ChatJID), pathutil.SanitizeSegment(info.MsgID))
- if info.MediaType != "" {
- baseDir = filepath.Join(baseDir, pathutil.SanitizeSegment(info.MediaType))
- }
- if abs, err := filepath.Abs(baseDir); err == nil {
- baseDir = abs
- }
- return filepath.Join(baseDir, filename), nil
-}
-
-func mediaFilename(info store.MediaDownloadInfo) string {
- name := strings.TrimSpace(info.Filename)
- ext := ""
- if strings.TrimSpace(info.MimeType) != "" {
- if exts, err := mime.ExtensionsByType(info.MimeType); err == nil && len(exts) > 0 {
- ext = exts[0]
- }
- }
-
- if name == "" {
- base := "message-" + pathutil.SanitizeSegment(info.MsgID)
- if ext == "" {
- ext = ".bin"
- }
- return pathutil.SanitizeFilename(base + ext)
- }
-
- name = pathutil.SanitizeFilename(name)
- if ext != "" && filepath.Ext(name) == "" {
- name += ext
- }
- return name
-}
-
-func (a *App) runMediaWorkers(ctx context.Context, jobs <-chan mediaJob, workers int) (func(), error) {
- if workers <= 0 {
- workers = 2
- }
- if jobs == nil {
- return func() {}, nil
- }
-
- ctx, cancel := context.WithCancel(ctx)
- var wg sync.WaitGroup
- for i := 0; i < workers; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- for {
- select {
- case <-ctx.Done():
- return
- case job := <-jobs:
- if strings.TrimSpace(job.chatJID) == "" || strings.TrimSpace(job.msgID) == "" {
- continue
- }
- if err := a.downloadMediaJob(ctx, job); err != nil {
- fmt.Fprintf(os.Stderr, "media download failed for %s/%s: %v\n", job.chatJID, job.msgID, err)
- }
- }
- }
- }()
- }
-
- stop := func() {
- cancel()
- wg.Wait()
- }
- return stop, nil
-}
-
-func (a *App) downloadMediaJob(ctx context.Context, job mediaJob) error {
- info, err := a.db.GetMediaDownloadInfo(job.chatJID, job.msgID)
- if err != nil {
- if err == sql.ErrNoRows {
- return nil
- }
- return err
- }
- if strings.TrimSpace(info.LocalPath) != "" {
- return nil
- }
- if strings.TrimSpace(info.MediaType) == "" || strings.TrimSpace(info.DirectPath) == "" || len(info.MediaKey) == 0 {
- return nil
- }
-
- targetPath, err := a.ResolveMediaOutputPath(info, "")
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil {
- return err
- }
-
- if _, err := a.wa.DownloadMediaToFile(ctx, info.DirectPath, info.FileEncSHA256, info.FileSHA256, info.MediaKey, info.FileLength, info.MediaType, "", targetPath); err != nil {
- return err
- }
-
- now := time.Now().UTC()
- return a.db.MarkMediaDownloaded(info.ChatJID, info.MsgID, targetPath, now)
-}
diff --git a/tools/wacli/internal/app/media_test.go b/tools/wacli/internal/app/media_test.go
deleted file mode 100644
index 1c0857c..0000000
--- a/tools/wacli/internal/app/media_test.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package app
-
-import (
- "context"
- "os"
- "testing"
- "time"
-
- "github.com/steipete/wacli/internal/store"
-)
-
-func TestDownloadMediaJobMarksDownloaded(t *testing.T) {
- a := newTestApp(t)
- f := newFakeWA()
- a.wa = f
-
- chat := "123@s.whatsapp.net"
- if err := a.db.UpsertChat(chat, "dm", "Alice", time.Now()); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
- if err := a.db.UpsertMessage(store.UpsertMessageParams{
- ChatJID: chat,
- MsgID: "mid",
- SenderJID: chat,
- SenderName: "Alice",
- Timestamp: time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC),
- FromMe: false,
- Text: "",
- MediaType: "image",
- MediaCaption: "cap",
- Filename: "pic.jpg",
- MimeType: "image/jpeg",
- DirectPath: "/direct/path",
- MediaKey: []byte{1, 2, 3},
- FileSHA256: []byte{4, 5},
- FileEncSHA256: []byte{6, 7},
- FileLength: 123,
- }); err != nil {
- t.Fatalf("UpsertMessage: %v", err)
- }
-
- if err := a.downloadMediaJob(context.Background(), mediaJob{chatJID: chat, msgID: "mid"}); err != nil {
- t.Fatalf("downloadMediaJob: %v", err)
- }
-
- info, err := a.db.GetMediaDownloadInfo(chat, "mid")
- if err != nil {
- t.Fatalf("GetMediaDownloadInfo: %v", err)
- }
- if info.LocalPath == "" {
- t.Fatalf("expected LocalPath to be set")
- }
- if _, err := os.Stat(info.LocalPath); err != nil {
- t.Fatalf("expected downloaded file to exist: %v", err)
- }
-}
diff --git a/tools/wacli/internal/app/sync.go b/tools/wacli/internal/app/sync.go
deleted file mode 100644
index e1e8bdc..0000000
--- a/tools/wacli/internal/app/sync.go
+++ /dev/null
@@ -1,427 +0,0 @@
-package app
-
-import (
- "context"
- "fmt"
- "os"
- "strings"
- "sync/atomic"
- "time"
-
- "github.com/steipete/wacli/internal/store"
- "github.com/steipete/wacli/internal/wa"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
-)
-
-type SyncMode string
-
-const (
- SyncModeBootstrap SyncMode = "bootstrap"
- SyncModeOnce SyncMode = "once"
- SyncModeFollow SyncMode = "follow"
-)
-
-type SyncOptions struct {
- Mode SyncMode
- AllowQR bool
- OnQRCode func(string)
- AfterConnect func(context.Context) error
- DownloadMedia bool
- RefreshContacts bool
- RefreshGroups bool
- IdleExit time.Duration // only used for bootstrap/once
- Verbosity int // future
-}
-
-type SyncResult struct {
- MessagesStored int64
-}
-
-func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
- if opts.Mode == "" {
- opts.Mode = SyncModeFollow
- }
- if (opts.Mode == SyncModeBootstrap || opts.Mode == SyncModeOnce) && opts.IdleExit <= 0 {
- opts.IdleExit = 30 * time.Second
- }
-
- if err := a.OpenWA(); err != nil {
- return SyncResult{}, err
- }
-
- var messagesStored atomic.Int64
- lastEvent := atomic.Int64{}
- lastEvent.Store(time.Now().UTC().UnixNano())
-
- disconnected := make(chan struct{}, 1)
-
- var stopMedia func()
- var mediaJobs chan mediaJob
- enqueueMedia := func(chatJID, msgID string) {}
- if opts.DownloadMedia {
- mediaJobs = make(chan mediaJob, 512)
- enqueueMedia = func(chatJID, msgID string) {
- if strings.TrimSpace(chatJID) == "" || strings.TrimSpace(msgID) == "" {
- return
- }
- select {
- case mediaJobs <- mediaJob{chatJID: chatJID, msgID: msgID}:
- default:
- // Avoid blocking the event handler.
- go func() {
- select {
- case mediaJobs <- mediaJob{chatJID: chatJID, msgID: msgID}:
- case <-ctx.Done():
- }
- }()
- }
- }
- }
-
- handlerID := a.wa.AddEventHandler(func(evt interface{}) {
- lastEvent.Store(time.Now().UTC().UnixNano())
-
- switch v := evt.(type) {
- case *events.Message:
- pm := wa.ParseLiveMessage(v)
- if pm.ReactionToID != "" && pm.ReactionEmoji == "" && v.Message != nil && v.Message.GetEncReactionMessage() != nil {
- if reaction, err := a.wa.DecryptReaction(ctx, v); err == nil && reaction != nil {
- pm.ReactionEmoji = reaction.GetText()
- if pm.ReactionToID == "" {
- if key := reaction.GetKey(); key != nil {
- pm.ReactionToID = key.GetID()
- }
- }
- }
- }
- if err := a.storeParsedMessage(ctx, pm); err == nil {
- messagesStored.Add(1)
- }
- if opts.DownloadMedia && pm.Media != nil && pm.ID != "" {
- enqueueMedia(pm.Chat.String(), pm.ID)
- }
- if messagesStored.Load()%25 == 0 {
- fmt.Fprintf(os.Stderr, "\rSynced %d messages...", messagesStored.Load())
- }
- case *events.HistorySync:
- fmt.Fprintf(os.Stderr, "\nProcessing history sync (%d conversations)...\n", len(v.Data.Conversations))
- for _, conv := range v.Data.Conversations {
- lastEvent.Store(time.Now().UTC().UnixNano())
- chatID := strings.TrimSpace(conv.GetID())
- if chatID == "" {
- continue
- }
- for _, m := range conv.Messages {
- lastEvent.Store(time.Now().UTC().UnixNano())
- if m.Message == nil {
- continue
- }
- pm := wa.ParseHistoryMessage(chatID, m.Message)
- if pm.ID == "" || pm.Chat.IsEmpty() {
- continue
- }
- if err := a.storeParsedMessage(ctx, pm); err == nil {
- messagesStored.Add(1)
- }
- if opts.DownloadMedia && pm.Media != nil && pm.ID != "" {
- enqueueMedia(pm.Chat.String(), pm.ID)
- }
- }
- }
- fmt.Fprintf(os.Stderr, "\rSynced %d messages...", messagesStored.Load())
- case *events.Connected:
- fmt.Fprintln(os.Stderr, "\nConnected.")
- case *events.Disconnected:
- fmt.Fprintln(os.Stderr, "\nDisconnected.")
- select {
- case disconnected <- struct{}{}:
- default:
- }
- }
- })
- defer a.wa.RemoveEventHandler(handlerID)
-
- if err := a.Connect(ctx, opts.AllowQR, opts.OnQRCode); err != nil {
- return SyncResult{}, err
- }
-
- if opts.DownloadMedia {
- var err error
- stopMedia, err = a.runMediaWorkers(ctx, mediaJobs, 4)
- if err != nil {
- return SyncResult{}, err
- }
- defer stopMedia()
- }
-
- // Optional: bootstrap imports (helps contacts/groups management without waiting for events).
- if opts.RefreshContacts {
- _ = a.refreshContacts(ctx)
- }
- if opts.RefreshGroups {
- _ = a.refreshGroups(ctx)
- }
- if opts.AfterConnect != nil {
- if err := opts.AfterConnect(ctx); err != nil {
- return SyncResult{MessagesStored: messagesStored.Load()}, err
- }
- }
-
- if opts.Mode == SyncModeFollow {
- for {
- select {
- case <-ctx.Done():
- fmt.Fprintln(os.Stderr, "\nStopping sync.")
- return SyncResult{MessagesStored: messagesStored.Load()}, nil
- case <-disconnected:
- fmt.Fprintln(os.Stderr, "Reconnecting...")
- if err := a.wa.ReconnectWithBackoff(ctx, 2*time.Second, 30*time.Second); err != nil {
- return SyncResult{MessagesStored: messagesStored.Load()}, err
- }
- }
- }
- }
-
- // Bootstrap/once: exit after idle.
- poll := 250 * time.Millisecond
- if opts.IdleExit >= 2*time.Second {
- poll = 1 * time.Second
- }
- ticker := time.NewTicker(poll)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- fmt.Fprintln(os.Stderr, "\nStopping sync.")
- return SyncResult{MessagesStored: messagesStored.Load()}, nil
- case <-disconnected:
- fmt.Fprintln(os.Stderr, "Reconnecting...")
- if err := a.wa.ReconnectWithBackoff(ctx, 2*time.Second, 30*time.Second); err != nil {
- return SyncResult{MessagesStored: messagesStored.Load()}, err
- }
- case <-ticker.C:
- last := time.Unix(0, lastEvent.Load())
- if time.Since(last) >= opts.IdleExit {
- fmt.Fprintf(os.Stderr, "\nIdle for %s, exiting.\n", opts.IdleExit)
- return SyncResult{MessagesStored: messagesStored.Load()}, nil
- }
- }
- }
-}
-
-func chatKind(chat types.JID) string {
- if chat.Server == types.GroupServer {
- return "group"
- }
- if chat.IsBroadcastList() {
- return "broadcast"
- }
- if chat.Server == types.DefaultUserServer {
- return "dm"
- }
- return "unknown"
-}
-
-func (a *App) storeParsedMessage(ctx context.Context, pm wa.ParsedMessage) error {
- chatJID := pm.Chat.String()
- chatName := a.wa.ResolveChatName(ctx, pm.Chat, pm.PushName)
- if err := a.db.UpsertChat(chatJID, chatKind(pm.Chat), chatName, pm.Timestamp); err != nil {
- return err
- }
-
- // Best-effort: store contact info for DMs.
- if pm.Chat.Server == types.DefaultUserServer {
- if info, err := a.wa.GetContact(ctx, pm.Chat.ToNonAD()); err == nil {
- _ = a.db.UpsertContact(
- pm.Chat.String(),
- pm.Chat.User,
- info.PushName,
- info.FullName,
- info.FirstName,
- info.BusinessName,
- )
- }
- }
-
- senderName := ""
- if pm.FromMe {
- senderName = "me"
- } else if s := strings.TrimSpace(pm.PushName); s != "" && s != "-" {
- senderName = s
- }
- if pm.SenderJID != "" {
- if jid, err := types.ParseJID(pm.SenderJID); err == nil {
- if info, err := a.wa.GetContact(ctx, jid.ToNonAD()); err == nil {
- if name := wa.BestContactName(info); name != "" {
- senderName = name
- }
- _ = a.db.UpsertContact(
- jid.String(),
- jid.User,
- info.PushName,
- info.FullName,
- info.FirstName,
- info.BusinessName,
- )
- }
- }
- }
-
- // Best-effort: store group metadata (and participants) when available.
- if pm.Chat.Server == types.GroupServer {
- if gi, err := a.wa.GetGroupInfo(ctx, pm.Chat); err == nil && gi != nil {
- _ = a.db.UpsertGroup(gi.JID.String(), gi.GroupName.Name, gi.OwnerJID.String(), gi.GroupCreated)
- var ps []store.GroupParticipant
- for _, p := range gi.Participants {
- role := "member"
- if p.IsSuperAdmin {
- role = "superadmin"
- } else if p.IsAdmin {
- role = "admin"
- }
- ps = append(ps, store.GroupParticipant{
- GroupJID: pm.Chat.String(),
- UserJID: p.JID.String(),
- Role: role,
- })
- }
- _ = a.db.ReplaceGroupParticipants(pm.Chat.String(), ps)
- }
- }
-
- var mediaType, caption, filename, mimeType, directPath string
- var mediaKey, fileSha, fileEncSha []byte
- var fileLen uint64
- if pm.Media != nil {
- mediaType = pm.Media.Type
- caption = pm.Media.Caption
- filename = pm.Media.Filename
- mimeType = pm.Media.MimeType
- directPath = pm.Media.DirectPath
- mediaKey = pm.Media.MediaKey
- fileSha = pm.Media.FileSHA256
- fileEncSha = pm.Media.FileEncSHA256
- fileLen = pm.Media.FileLength
- }
-
- displayText := a.buildDisplayText(ctx, pm)
-
- return a.db.UpsertMessage(store.UpsertMessageParams{
- ChatJID: chatJID,
- ChatName: chatName,
- MsgID: pm.ID,
- SenderJID: pm.SenderJID,
- SenderName: senderName,
- Timestamp: pm.Timestamp,
- FromMe: pm.FromMe,
- Text: pm.Text,
- DisplayText: displayText,
- MediaType: mediaType,
- MediaCaption: caption,
- Filename: filename,
- MimeType: mimeType,
- DirectPath: directPath,
- MediaKey: mediaKey,
- FileSHA256: fileSha,
- FileEncSHA256: fileEncSha,
- FileLength: fileLen,
- })
-}
-
-func (a *App) buildDisplayText(ctx context.Context, pm wa.ParsedMessage) string {
- base := baseDisplayText(pm)
-
- if pm.ReactionToID != "" || strings.TrimSpace(pm.ReactionEmoji) != "" {
- target := strings.TrimSpace(pm.ReactionToID)
- display := ""
- if target != "" {
- display = a.lookupMessageDisplayText(pm.Chat.String(), target)
- }
- if display == "" {
- display = "message"
- }
- emoji := strings.TrimSpace(pm.ReactionEmoji)
- if emoji != "" {
- return fmt.Sprintf("Reacted %s to %s", emoji, display)
- }
- return fmt.Sprintf("Reacted to %s", display)
- }
-
- if pm.ReplyToID != "" {
- quoted := strings.TrimSpace(pm.ReplyToDisplay)
- if quoted == "" {
- quoted = a.lookupMessageDisplayText(pm.Chat.String(), pm.ReplyToID)
- }
- if quoted == "" {
- quoted = "message"
- }
- if base == "" {
- base = "(message)"
- }
- return fmt.Sprintf("> %s\n%s", quoted, base)
- }
-
- if base == "" {
- base = "(message)"
- }
- return base
-}
-
-func baseDisplayText(pm wa.ParsedMessage) string {
- if pm.Media != nil {
- return "Sent " + mediaLabel(pm.Media.Type)
- }
- if text := strings.TrimSpace(pm.Text); text != "" {
- return text
- }
- return ""
-}
-
-func (a *App) lookupMessageDisplayText(chatJID, msgID string) string {
- if strings.TrimSpace(chatJID) == "" || strings.TrimSpace(msgID) == "" {
- return ""
- }
- msg, err := a.db.GetMessage(chatJID, msgID)
- if err != nil {
- return ""
- }
- if text := strings.TrimSpace(msg.DisplayText); text != "" {
- return text
- }
- if text := strings.TrimSpace(msg.Text); text != "" {
- return text
- }
- if strings.TrimSpace(msg.MediaType) != "" {
- return "Sent " + mediaLabel(msg.MediaType)
- }
- return ""
-}
-
-func mediaLabel(mediaType string) string {
- mt := strings.ToLower(strings.TrimSpace(mediaType))
- switch mt {
- case "gif":
- return "gif"
- case "image":
- return "image"
- case "video":
- return "video"
- case "audio":
- return "audio"
- case "sticker":
- return "sticker"
- case "document":
- return "document"
- case "location":
- return "location"
- case "contact":
- return "contact"
- case "contacts":
- return "contacts"
- case "":
- return "message"
- default:
- return mt
- }
-}
diff --git a/tools/wacli/internal/app/sync_test.go b/tools/wacli/internal/app/sync_test.go
deleted file mode 100644
index 73a90a5..0000000
--- a/tools/wacli/internal/app/sync_test.go
+++ /dev/null
@@ -1,258 +0,0 @@
-package app
-
-import (
- "context"
- "testing"
- "time"
-
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/proto/waCommon"
- "go.mau.fi/whatsmeow/proto/waHistorySync"
- "go.mau.fi/whatsmeow/proto/waWeb"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "google.golang.org/protobuf/proto"
-)
-
-func TestSyncStoresLiveAndHistoryMessages(t *testing.T) {
- a := newTestApp(t)
- f := newFakeWA()
- a.wa = f
-
- chat := types.JID{User: "123", Server: types.DefaultUserServer}
- f.contacts[chat.ToNonAD()] = types.ContactInfo{
- Found: true,
- FullName: "Alice",
- FirstName: "Alice",
- PushName: "Alice",
- }
-
- base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
-
- live := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: chat,
- IsFromMe: false,
- IsGroup: false,
- },
- ID: "m-live",
- Timestamp: base.Add(2 * time.Second),
- PushName: "Alice",
- },
- Message: &waProto.Message{Conversation: proto.String("hello")},
- }
-
- histMsg := &waWeb.WebMessageInfo{
- Key: &waCommon.MessageKey{
- RemoteJID: proto.String(chat.String()),
- FromMe: proto.Bool(false),
- ID: proto.String("m-hist"),
- },
- MessageTimestamp: proto.Uint64(uint64(base.Add(1 * time.Second).Unix())),
- Message: &waProto.Message{Conversation: proto.String("older")},
- }
- history := &events.HistorySync{
- Data: &waHistorySync.HistorySync{
- SyncType: waHistorySync.HistorySync_FULL.Enum(),
- Conversations: []*waHistorySync.Conversation{{
- ID: proto.String(chat.String()),
- Messages: []*waHistorySync.HistorySyncMsg{{Message: histMsg}},
- }},
- },
- }
-
- f.connectEvents = []interface{}{live, history}
-
- ctx, cancel := context.WithCancel(context.Background())
- go func() {
- time.Sleep(100 * time.Millisecond)
- cancel()
- }()
- res, err := a.Sync(ctx, SyncOptions{
- Mode: SyncModeFollow,
- AllowQR: false,
- })
- if err != nil {
- t.Fatalf("Sync: %v", err)
- }
- if res.MessagesStored != 2 {
- t.Fatalf("expected 2 MessagesStored, got %d", res.MessagesStored)
- }
- if n, err := a.db.CountMessages(); err != nil || n != 2 {
- t.Fatalf("expected 2 messages in DB, got %d (err=%v)", n, err)
- }
-}
-
-func TestSyncStoresDisplayText(t *testing.T) {
- a := newTestApp(t)
- f := newFakeWA()
- a.wa = f
-
- chat := types.JID{User: "123", Server: types.DefaultUserServer}
- f.contacts[chat.ToNonAD()] = types.ContactInfo{
- Found: true,
- FullName: "Alice",
- FirstName: "Alice",
- PushName: "Alice",
- }
-
- base := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
-
- textMsg := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: chat,
- IsFromMe: false,
- IsGroup: false,
- },
- ID: "m-text",
- Timestamp: base.Add(1 * time.Second),
- PushName: "Alice",
- },
- Message: &waProto.Message{Conversation: proto.String("hello")},
- }
-
- imageMsg := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: chat,
- IsFromMe: false,
- IsGroup: false,
- },
- ID: "m-image",
- Timestamp: base.Add(2 * time.Second),
- PushName: "Alice",
- },
- Message: &waProto.Message{
- ImageMessage: &waProto.ImageMessage{
- Mimetype: proto.String("image/jpeg"),
- DirectPath: proto.String("/direct"),
- MediaKey: []byte{1},
- FileSHA256: []byte{2},
- FileEncSHA256: []byte{3},
- FileLength: proto.Uint64(10),
- },
- },
- }
-
- replyMsg := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: chat,
- IsFromMe: false,
- IsGroup: false,
- },
- ID: "m-reply",
- Timestamp: base.Add(3 * time.Second),
- PushName: "Alice",
- },
- Message: &waProto.Message{
- ExtendedTextMessage: &waProto.ExtendedTextMessage{
- Text: proto.String("reply text"),
- ContextInfo: &waProto.ContextInfo{
- StanzaID: proto.String("m-text"),
- QuotedMessage: &waProto.Message{
- Conversation: proto.String("quoted text"),
- },
- },
- },
- },
- }
-
- reactionMsg := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: chat,
- IsFromMe: false,
- IsGroup: false,
- },
- ID: "m-react",
- Timestamp: base.Add(4 * time.Second),
- PushName: "Alice",
- },
- Message: &waProto.Message{
- ReactionMessage: &waProto.ReactionMessage{
- Text: proto.String("👍"),
- Key: &waProto.MessageKey{ID: proto.String("m-text")},
- },
- },
- }
-
- f.connectEvents = []interface{}{textMsg, imageMsg, replyMsg, reactionMsg}
-
- ctx, cancel := context.WithCancel(context.Background())
- go func() {
- time.Sleep(100 * time.Millisecond)
- cancel()
- }()
- res, err := a.Sync(ctx, SyncOptions{
- Mode: SyncModeFollow,
- AllowQR: false,
- })
- if err != nil {
- t.Fatalf("Sync: %v", err)
- }
- if res.MessagesStored != 4 {
- t.Fatalf("expected 4 MessagesStored, got %d", res.MessagesStored)
- }
-
- msg, err := a.db.GetMessage(chat.String(), "m-text")
- if err != nil {
- t.Fatalf("GetMessage text: %v", err)
- }
- if msg.DisplayText != "hello" {
- t.Fatalf("expected display text 'hello', got %q", msg.DisplayText)
- }
-
- msg, err = a.db.GetMessage(chat.String(), "m-image")
- if err != nil {
- t.Fatalf("GetMessage image: %v", err)
- }
- if msg.DisplayText != "Sent image" {
- t.Fatalf("expected display text 'Sent image', got %q", msg.DisplayText)
- }
-
- msg, err = a.db.GetMessage(chat.String(), "m-reply")
- if err != nil {
- t.Fatalf("GetMessage reply: %v", err)
- }
- if msg.DisplayText != "> quoted text\nreply text" {
- t.Fatalf("unexpected reply display text: %q", msg.DisplayText)
- }
-
- msg, err = a.db.GetMessage(chat.String(), "m-react")
- if err != nil {
- t.Fatalf("GetMessage react: %v", err)
- }
- if msg.DisplayText != "Reacted 👍 to hello" {
- t.Fatalf("unexpected reaction display text: %q", msg.DisplayText)
- }
-}
-
-func TestSyncOnceIdleExit(t *testing.T) {
- a := newTestApp(t)
- f := newFakeWA()
- a.wa = f
-
- ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
- defer cancel()
-
- start := time.Now()
- _, err := a.Sync(ctx, SyncOptions{
- Mode: SyncModeOnce,
- AllowQR: false,
- IdleExit: 200 * time.Millisecond,
- })
- if err != nil {
- t.Fatalf("Sync: %v", err)
- }
- if time.Since(start) > 1500*time.Millisecond {
- t.Fatalf("expected to exit quickly on idle, took %s", time.Since(start))
- }
-}
diff --git a/tools/wacli/internal/config/config.go b/tools/wacli/internal/config/config.go
deleted file mode 100644
index dbd8eef..0000000
--- a/tools/wacli/internal/config/config.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package config
-
-import (
- "os"
- "path/filepath"
-)
-
-func DefaultStoreDir() string {
- home, err := os.UserHomeDir()
- if err != nil || home == "" {
- return ".wacli"
- }
- return filepath.Join(home, ".wacli")
-}
diff --git a/tools/wacli/internal/lock/lock.go b/tools/wacli/internal/lock/lock.go
deleted file mode 100644
index f9e8c7a..0000000
--- a/tools/wacli/internal/lock/lock.go
+++ /dev/null
@@ -1,54 +0,0 @@
-package lock
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "syscall"
- "time"
-)
-
-type Lock struct {
- path string
- f *os.File
-}
-
-func Acquire(storeDir string) (*Lock, error) {
- if err := os.MkdirAll(storeDir, 0700); err != nil {
- return nil, fmt.Errorf("create store dir: %w", err)
- }
- path := filepath.Join(storeDir, "LOCK")
- f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
- if err != nil {
- return nil, fmt.Errorf("open lock file: %w", err)
- }
-
- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
- _, _ = f.Seek(0, 0)
- b, _ := os.ReadFile(path)
- _ = f.Close()
- info := strings.TrimSpace(string(b))
- if info != "" {
- return nil, fmt.Errorf("store is locked (another wacli is running?): %w (%s)", err, info)
- }
- return nil, fmt.Errorf("store is locked (another wacli is running?): %w", err)
- }
-
- _ = f.Truncate(0)
- _, _ = f.Seek(0, 0)
- _, _ = fmt.Fprintf(f, "pid=%d\nacquired_at=%s\n", os.Getpid(), time.Now().Format(time.RFC3339Nano))
- _ = f.Sync()
-
- return &Lock{path: path, f: f}, nil
-}
-
-func (l *Lock) Release() error {
- if l == nil || l.f == nil {
- return nil
- }
- _ = syscall.Flock(int(l.f.Fd()), syscall.LOCK_UN)
- err := l.f.Close()
- l.f = nil
- return err
-}
diff --git a/tools/wacli/internal/lock/lock_test.go b/tools/wacli/internal/lock/lock_test.go
deleted file mode 100644
index 53e5ebd..0000000
--- a/tools/wacli/internal/lock/lock_test.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package lock
-
-import (
- "context"
- "fmt"
- "os"
- "os/exec"
- "strings"
- "testing"
- "time"
-)
-
-func TestLockBlocksOtherProcess(t *testing.T) {
- if os.Getenv("WACLI_LOCK_HELPER") == "1" {
- dir := os.Getenv("WACLI_LOCK_DIR")
- lk, err := Acquire(dir)
- if err == nil {
- _ = lk.Release()
- _, _ = os.Stdout.WriteString("UNEXPECTED_OK\n")
- os.Exit(2)
- }
- if !strings.Contains(err.Error(), "store is locked") {
- _, _ = fmt.Fprintf(os.Stdout, "UNEXPECTED_ERR:%v\n", err)
- os.Exit(3)
- }
- _, _ = os.Stdout.WriteString("EXPECTED_LOCKED\n")
- return
- }
-
- dir := t.TempDir()
-
- lk, err := Acquire(dir)
- if err != nil {
- t.Fatalf("acquire: %v", err)
- }
- defer lk.Release()
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestLockBlocksOtherProcess")
- cmd.Env = append(os.Environ(),
- "WACLI_LOCK_HELPER=1",
- "WACLI_LOCK_DIR="+dir,
- )
- out, err := cmd.CombinedOutput()
- if err != nil {
- t.Fatalf("helper failed: %v output=%s", err, strings.TrimSpace(string(out)))
- }
- got := string(out)
- if strings.Contains(got, "UNEXPECTED_OK") || strings.Contains(got, "UNEXPECTED_ERR:") {
- t.Fatalf("unexpected helper output: %q", strings.TrimSpace(got))
- }
- if !strings.Contains(got, "EXPECTED_LOCKED") {
- t.Fatalf("expected helper to report locked; output=%q", strings.TrimSpace(got))
- }
-}
diff --git a/tools/wacli/internal/out/out.go b/tools/wacli/internal/out/out.go
deleted file mode 100644
index 1d795f6..0000000
--- a/tools/wacli/internal/out/out.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package out
-
-import (
- "encoding/json"
- "fmt"
- "io"
-)
-
-type envelope struct {
- Success bool `json:"success"`
- Data interface{} `json:"data"`
- Error *string `json:"error"`
-}
-
-func WriteJSON(w io.Writer, data interface{}) error {
- b, err := json.Marshal(envelope{Success: true, Data: data})
- if err != nil {
- return err
- }
- _, err = fmt.Fprintln(w, string(b))
- return err
-}
-
-func WriteError(w io.Writer, asJSON bool, err error) error {
- if err == nil {
- return nil
- }
- if asJSON {
- msg := err.Error()
- b, _ := json.Marshal(envelope{Success: false, Data: nil, Error: &msg})
- _, _ = fmt.Fprintln(w, string(b))
- return nil
- }
- _, _ = fmt.Fprintln(w, err.Error())
- return nil
-}
diff --git a/tools/wacli/internal/out/out_test.go b/tools/wacli/internal/out/out_test.go
deleted file mode 100644
index ab8a236..0000000
--- a/tools/wacli/internal/out/out_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package out
-
-import (
- "bytes"
- "encoding/json"
- "errors"
- "strings"
- "testing"
-)
-
-func TestWriteJSONEnvelope(t *testing.T) {
- var b bytes.Buffer
- if err := WriteJSON(&b, map[string]any{"ok": true}); err != nil {
- t.Fatalf("WriteJSON: %v", err)
- }
- var got map[string]any
- if err := json.Unmarshal([]byte(strings.TrimSpace(b.String())), &got); err != nil {
- t.Fatalf("unmarshal: %v", err)
- }
- if got["success"] != true {
- t.Fatalf("expected success=true, got %v", got["success"])
- }
- if got["error"] != nil {
- t.Fatalf("expected error=nil, got %v", got["error"])
- }
-}
-
-func TestWriteErrorJSONAndText(t *testing.T) {
- var b bytes.Buffer
- _ = WriteError(&b, true, errors.New("boom"))
- if !strings.Contains(b.String(), "\"success\":false") || !strings.Contains(b.String(), "boom") {
- t.Fatalf("unexpected json error output: %q", b.String())
- }
-
- b.Reset()
- _ = WriteError(&b, false, errors.New("boom"))
- if strings.TrimSpace(b.String()) != "boom" {
- t.Fatalf("unexpected text error output: %q", b.String())
- }
-}
diff --git a/tools/wacli/internal/pathutil/sanitize.go b/tools/wacli/internal/pathutil/sanitize.go
deleted file mode 100644
index 91fab89..0000000
--- a/tools/wacli/internal/pathutil/sanitize.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package pathutil
-
-import (
- "path/filepath"
- "strings"
-)
-
-var replacer = strings.NewReplacer(
- "/", "_",
- "\\", "_",
- ":", "_",
- "@", "_",
- "?", "_",
- "*", "_",
- "<", "_",
- ">", "_",
- "|", "_",
-)
-
-func SanitizeSegment(seg string) string {
- seg = strings.TrimSpace(seg)
- if seg == "" {
- return "unknown"
- }
- seg = replacer.Replace(seg)
- seg = strings.ReplaceAll(seg, "..", "_")
- seg = strings.ReplaceAll(seg, string(filepath.Separator), "_")
- return seg
-}
-
-func SanitizeFilename(name string) string {
- name = strings.TrimSpace(name)
- if name == "" {
- return "file"
- }
- name = replacer.Replace(name)
- name = strings.ReplaceAll(name, "..", "_")
- name = strings.ReplaceAll(name, string(filepath.Separator), "_")
- return name
-}
diff --git a/tools/wacli/internal/pathutil/sanitize_test.go b/tools/wacli/internal/pathutil/sanitize_test.go
deleted file mode 100644
index e061b4e..0000000
--- a/tools/wacli/internal/pathutil/sanitize_test.go
+++ /dev/null
@@ -1,27 +0,0 @@
-package pathutil
-
-import "testing"
-
-func TestSanitizeSegment(t *testing.T) {
- if got := SanitizeSegment(""); got != "unknown" {
- t.Fatalf("expected unknown, got %q", got)
- }
- if got := SanitizeSegment(" ../a/b:c@d "); got == "" || got == " ../a/b:c@d " {
- t.Fatalf("unexpected sanitize result: %q", got)
- }
- if got := SanitizeSegment("a/b"); got != "a_b" {
- t.Fatalf("expected a_b, got %q", got)
- }
-}
-
-func TestSanitizeFilename(t *testing.T) {
- if got := SanitizeFilename(""); got != "file" {
- t.Fatalf("expected file, got %q", got)
- }
- if got := SanitizeFilename(".."); got == ".." {
- t.Fatalf("expected .. to be sanitized, got %q", got)
- }
- if got := SanitizeFilename("a/b"); got != "a_b" {
- t.Fatalf("expected a_b, got %q", got)
- }
-}
diff --git a/tools/wacli/internal/store/chats_contacts_groups.go b/tools/wacli/internal/store/chats_contacts_groups.go
deleted file mode 100644
index cc0ac2a..0000000
--- a/tools/wacli/internal/store/chats_contacts_groups.go
+++ /dev/null
@@ -1,278 +0,0 @@
-package store
-
-import (
- "fmt"
- "strings"
- "time"
-)
-
-func (d *DB) UpsertChat(jid, kind, name string, lastTS time.Time) error {
- if strings.TrimSpace(kind) == "" {
- kind = "unknown"
- }
- _, err := d.sql.Exec(`
- INSERT INTO chats(jid, kind, name, last_message_ts)
- VALUES(?, ?, ?, ?)
- ON CONFLICT(jid) DO UPDATE SET
- kind=excluded.kind,
- name=CASE WHEN excluded.name IS NOT NULL AND excluded.name != '' THEN excluded.name ELSE chats.name END,
- last_message_ts=CASE WHEN excluded.last_message_ts > COALESCE(chats.last_message_ts, 0) THEN excluded.last_message_ts ELSE chats.last_message_ts END
- `, jid, kind, name, unix(lastTS))
- return err
-}
-
-func (d *DB) ListChats(query string, limit int) ([]Chat, error) {
- if limit <= 0 {
- limit = 50
- }
- q := `SELECT jid, kind, COALESCE(name,''), COALESCE(last_message_ts,0) FROM chats WHERE 1=1`
- var args []interface{}
- if strings.TrimSpace(query) != "" {
- q += ` AND (LOWER(name) LIKE LOWER(?) OR LOWER(jid) LIKE LOWER(?))`
- needle := "%" + query + "%"
- args = append(args, needle, needle)
- }
- q += ` ORDER BY last_message_ts DESC LIMIT ?`
- args = append(args, limit)
-
- rows, err := d.sql.Query(q, args...)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var out []Chat
- for rows.Next() {
- var c Chat
- var ts int64
- if err := rows.Scan(&c.JID, &c.Kind, &c.Name, &ts); err != nil {
- return nil, err
- }
- c.LastMessageTS = fromUnix(ts)
- out = append(out, c)
- }
- return out, rows.Err()
-}
-
-func (d *DB) GetChat(jid string) (Chat, error) {
- row := d.sql.QueryRow(`SELECT jid, kind, COALESCE(name,''), COALESCE(last_message_ts,0) FROM chats WHERE jid = ?`, jid)
- var c Chat
- var ts int64
- if err := row.Scan(&c.JID, &c.Kind, &c.Name, &ts); err != nil {
- return Chat{}, err
- }
- c.LastMessageTS = fromUnix(ts)
- return c, nil
-}
-
-func (d *DB) SearchContacts(query string, limit int) ([]Contact, error) {
- if strings.TrimSpace(query) == "" {
- return nil, fmt.Errorf("query is required")
- }
- if limit <= 0 {
- limit = 50
- }
- q := `
- SELECT c.jid,
- COALESCE(c.phone,''),
- COALESCE(NULLIF(a.alias,''), ''),
- COALESCE(NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''),
- c.updated_at
- FROM contacts c
- LEFT JOIN contact_aliases a ON a.jid = c.jid
- WHERE LOWER(COALESCE(a.alias,'')) LIKE LOWER(?) OR LOWER(COALESCE(c.full_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(c.push_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(c.phone,'')) LIKE LOWER(?) OR LOWER(c.jid) LIKE LOWER(?)
- ORDER BY COALESCE(NULLIF(a.alias,''), NULLIF(c.full_name,''), NULLIF(c.push_name,''), c.jid)
- LIMIT ?`
- needle := "%" + query + "%"
- rows, err := d.sql.Query(q, needle, needle, needle, needle, needle, limit)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var out []Contact
- for rows.Next() {
- var c Contact
- var updated int64
- if err := rows.Scan(&c.JID, &c.Phone, &c.Alias, &c.Name, &updated); err != nil {
- return nil, err
- }
- c.UpdatedAt = fromUnix(updated)
- out = append(out, c)
- }
- return out, rows.Err()
-}
-
-func (d *DB) GetContact(jid string) (Contact, error) {
- row := d.sql.QueryRow(`
- SELECT c.jid,
- COALESCE(c.phone,''),
- COALESCE(NULLIF(a.alias,''), ''),
- COALESCE(NULLIF(c.full_name,''), NULLIF(c.push_name,''), NULLIF(c.business_name,''), NULLIF(c.first_name,''), ''),
- c.updated_at
- FROM contacts c
- LEFT JOIN contact_aliases a ON a.jid = c.jid
- WHERE c.jid = ?
- `, jid)
- var c Contact
- var updated int64
- if err := row.Scan(&c.JID, &c.Phone, &c.Alias, &c.Name, &updated); err != nil {
- return Contact{}, err
- }
- c.UpdatedAt = fromUnix(updated)
- tags, _ := d.ListTags(jid)
- c.Tags = tags
- return c, nil
-}
-
-func (d *DB) ListTags(jid string) ([]string, error) {
- rows, err := d.sql.Query(`SELECT tag FROM contact_tags WHERE jid = ? ORDER BY tag`, jid)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var tags []string
- for rows.Next() {
- var tag string
- if err := rows.Scan(&tag); err != nil {
- return nil, err
- }
- tags = append(tags, tag)
- }
- return tags, rows.Err()
-}
-
-func (d *DB) UpsertContact(jid, phone, pushName, fullName, firstName, businessName string) error {
- now := time.Now().UTC().Unix()
- _, err := d.sql.Exec(`
- INSERT INTO contacts(jid, phone, push_name, full_name, first_name, business_name, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(jid) DO UPDATE SET
- phone=COALESCE(NULLIF(excluded.phone,''), contacts.phone),
- push_name=COALESCE(NULLIF(excluded.push_name,''), contacts.push_name),
- full_name=COALESCE(NULLIF(excluded.full_name,''), contacts.full_name),
- first_name=COALESCE(NULLIF(excluded.first_name,''), contacts.first_name),
- business_name=COALESCE(NULLIF(excluded.business_name,''), contacts.business_name),
- updated_at=excluded.updated_at
- `, jid, phone, pushName, fullName, firstName, businessName, now)
- return err
-}
-
-func (d *DB) UpsertGroup(jid, name, ownerJID string, created time.Time) error {
- now := time.Now().UTC().Unix()
- _, err := d.sql.Exec(`
- INSERT INTO groups(jid, name, owner_jid, created_ts, updated_at)
- VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(jid) DO UPDATE SET
- name=COALESCE(NULLIF(excluded.name,''), groups.name),
- owner_jid=COALESCE(NULLIF(excluded.owner_jid,''), groups.owner_jid),
- created_ts=COALESCE(NULLIF(excluded.created_ts,0), groups.created_ts),
- updated_at=excluded.updated_at
- `, jid, name, ownerJID, unix(created), now)
- return err
-}
-
-func (d *DB) ReplaceGroupParticipants(groupJID string, participants []GroupParticipant) (err error) {
- tx, err := d.sql.Begin()
- if err != nil {
- return err
- }
- defer func() {
- if err != nil {
- _ = tx.Rollback()
- }
- }()
-
- if _, err = tx.Exec(`DELETE FROM group_participants WHERE group_jid = ?`, groupJID); err != nil {
- return err
- }
- stmt, err := tx.Prepare(`INSERT INTO group_participants(group_jid, user_jid, role, updated_at) VALUES(?, ?, ?, ?)`)
- if err != nil {
- return err
- }
- defer stmt.Close()
-
- now := time.Now().UTC()
- for _, participant := range participants {
- role := strings.TrimSpace(participant.Role)
- if role == "" {
- role = "member"
- }
- if _, err = stmt.Exec(groupJID, participant.UserJID, role, unix(now)); err != nil {
- return err
- }
- }
- return tx.Commit()
-}
-
-func (d *DB) ListGroups(query string, limit int) ([]Group, error) {
- if limit <= 0 {
- limit = 50
- }
- q := `SELECT jid, COALESCE(name,''), COALESCE(owner_jid,''), COALESCE(created_ts,0), updated_at FROM groups WHERE 1=1`
- var args []interface{}
- if strings.TrimSpace(query) != "" {
- needle := "%" + query + "%"
- q += ` AND (LOWER(name) LIKE LOWER(?) OR LOWER(jid) LIKE LOWER(?))`
- args = append(args, needle, needle)
- }
- q += ` ORDER BY COALESCE(created_ts,0) DESC LIMIT ?`
- args = append(args, limit)
-
- rows, err := d.sql.Query(q, args...)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var out []Group
- for rows.Next() {
- var g Group
- var created, updated int64
- if err := rows.Scan(&g.JID, &g.Name, &g.OwnerJID, &created, &updated); err != nil {
- return nil, err
- }
- g.CreatedAt = fromUnix(created)
- g.UpdatedAt = fromUnix(updated)
- out = append(out, g)
- }
- return out, rows.Err()
-}
-
-func (d *DB) SetAlias(jid, alias string) error {
- alias = strings.TrimSpace(alias)
- if alias == "" {
- return fmt.Errorf("alias is required")
- }
- now := time.Now().UTC().Unix()
- _, err := d.sql.Exec(`
- INSERT INTO contact_aliases(jid, alias, notes, updated_at)
- VALUES (?, ?, NULL, ?)
- ON CONFLICT(jid) DO UPDATE SET alias=excluded.alias, updated_at=excluded.updated_at
- `, jid, alias, now)
- return err
-}
-
-func (d *DB) RemoveAlias(jid string) error {
- _, err := d.sql.Exec(`DELETE FROM contact_aliases WHERE jid = ?`, jid)
- return err
-}
-
-func (d *DB) AddTag(jid, tag string) error {
- tag = strings.TrimSpace(tag)
- if tag == "" {
- return fmt.Errorf("tag is required")
- }
- now := time.Now().UTC().Unix()
- _, err := d.sql.Exec(`
- INSERT INTO contact_tags(jid, tag, updated_at) VALUES(?, ?, ?)
- ON CONFLICT(jid, tag) DO UPDATE SET updated_at=excluded.updated_at
- `, jid, tag, now)
- return err
-}
-
-func (d *DB) RemoveTag(jid, tag string) error {
- _, err := d.sql.Exec(`DELETE FROM contact_tags WHERE jid = ? AND tag = ?`, jid, tag)
- return err
-}
diff --git a/tools/wacli/internal/store/db.go b/tools/wacli/internal/store/db.go
deleted file mode 100644
index a74261f..0000000
--- a/tools/wacli/internal/store/db.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package store
-
-import (
- "database/sql"
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- _ "github.com/mattn/go-sqlite3"
-)
-
-type DB struct {
- path string
- sql *sql.DB
- ftsEnabled bool
-}
-
-func Open(path string) (*DB, error) {
- if strings.TrimSpace(path) == "" {
- return nil, fmt.Errorf("db path is required")
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
- return nil, fmt.Errorf("create db directory: %w", err)
- }
-
- db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on&_busy_timeout=5000", path))
- if err != nil {
- return nil, fmt.Errorf("open sqlite: %w", err)
- }
-
- s := &DB{path: path, sql: db}
- if err := s.init(); err != nil {
- _ = db.Close()
- return nil, err
- }
- return s, nil
-}
-
-func (d *DB) Close() error {
- if d == nil || d.sql == nil {
- return nil
- }
- return d.sql.Close()
-}
-
-func (d *DB) init() error {
- // Pragmas: keep consistent for writers/readers.
- _, _ = d.sql.Exec("PRAGMA journal_mode=WAL;")
- _, _ = d.sql.Exec("PRAGMA synchronous=NORMAL;")
- _, _ = d.sql.Exec("PRAGMA temp_store=MEMORY;")
- _, _ = d.sql.Exec("PRAGMA foreign_keys=ON;")
-
- return d.ensureSchema()
-}
diff --git a/tools/wacli/internal/store/media.go b/tools/wacli/internal/store/media.go
deleted file mode 100644
index 14956e0..0000000
--- a/tools/wacli/internal/store/media.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package store
-
-import (
- "database/sql"
- "time"
-)
-
-func (d *DB) GetMediaDownloadInfo(chatJID, msgID string) (MediaDownloadInfo, error) {
- row := d.sql.QueryRow(`
- SELECT m.chat_jid,
- COALESCE(c.name,''),
- m.msg_id,
- COALESCE(m.media_type,''),
- COALESCE(m.filename,''),
- COALESCE(m.mime_type,''),
- COALESCE(m.direct_path,''),
- m.media_key,
- m.file_sha256,
- m.file_enc_sha256,
- COALESCE(m.file_length,0),
- COALESCE(m.local_path,''),
- COALESCE(m.downloaded_at,0)
- FROM messages m
- LEFT JOIN chats c ON c.jid = m.chat_jid
- WHERE m.chat_jid = ? AND m.msg_id = ?
- `, chatJID, msgID)
-
- var info MediaDownloadInfo
- var fileLen sql.NullInt64
- var downloadedAt int64
- if err := row.Scan(
- &info.ChatJID,
- &info.ChatName,
- &info.MsgID,
- &info.MediaType,
- &info.Filename,
- &info.MimeType,
- &info.DirectPath,
- &info.MediaKey,
- &info.FileSHA256,
- &info.FileEncSHA256,
- &fileLen,
- &info.LocalPath,
- &downloadedAt,
- ); err != nil {
- return MediaDownloadInfo{}, err
- }
- if fileLen.Valid && fileLen.Int64 > 0 {
- info.FileLength = uint64(fileLen.Int64)
- }
- info.DownloadedAt = fromUnix(downloadedAt)
- return info, nil
-}
-
-func (d *DB) MarkMediaDownloaded(chatJID, msgID, localPath string, downloadedAt time.Time) error {
- _, err := d.sql.Exec(`
- UPDATE messages
- SET local_path = ?, downloaded_at = ?
- WHERE chat_jid = ? AND msg_id = ?
- `, localPath, unix(downloadedAt), chatJID, msgID)
- return err
-}
diff --git a/tools/wacli/internal/store/messages.go b/tools/wacli/internal/store/messages.go
deleted file mode 100644
index d8f5b97..0000000
--- a/tools/wacli/internal/store/messages.go
+++ /dev/null
@@ -1,213 +0,0 @@
-package store
-
-import (
- "fmt"
- "strings"
- "time"
-)
-
-type UpsertMessageParams struct {
- ChatJID string
- ChatName string
- MsgID string
- SenderJID string
- SenderName string
- Timestamp time.Time
- FromMe bool
- Text string
- DisplayText string
- MediaType string
- MediaCaption string
- Filename string
- MimeType string
- DirectPath string
- MediaKey []byte
- FileSHA256 []byte
- FileEncSHA256 []byte
- FileLength uint64
-}
-
-func (d *DB) UpsertMessage(p UpsertMessageParams) error {
- _, err := d.sql.Exec(`
- INSERT INTO messages(
- chat_jid, chat_name, msg_id, sender_jid, sender_name, ts, from_me, text, display_text,
- media_type, media_caption, filename, mime_type, direct_path,
- media_key, file_sha256, file_enc_sha256, file_length
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- ON CONFLICT(chat_jid, msg_id) DO UPDATE SET
- chat_name=COALESCE(NULLIF(excluded.chat_name,''), messages.chat_name),
- sender_jid=excluded.sender_jid,
- sender_name=COALESCE(NULLIF(excluded.sender_name,''), messages.sender_name),
- ts=excluded.ts,
- from_me=excluded.from_me,
- text=excluded.text,
- display_text=CASE WHEN excluded.display_text IS NOT NULL AND excluded.display_text != '' THEN excluded.display_text ELSE messages.display_text END,
- media_type=excluded.media_type,
- media_caption=excluded.media_caption,
- filename=COALESCE(NULLIF(excluded.filename,''), messages.filename),
- mime_type=COALESCE(NULLIF(excluded.mime_type,''), messages.mime_type),
- direct_path=COALESCE(NULLIF(excluded.direct_path,''), messages.direct_path),
- media_key=CASE WHEN excluded.media_key IS NOT NULL AND length(excluded.media_key)>0 THEN excluded.media_key ELSE messages.media_key END,
- file_sha256=CASE WHEN excluded.file_sha256 IS NOT NULL AND length(excluded.file_sha256)>0 THEN excluded.file_sha256 ELSE messages.file_sha256 END,
- file_enc_sha256=CASE WHEN excluded.file_enc_sha256 IS NOT NULL AND length(excluded.file_enc_sha256)>0 THEN excluded.file_enc_sha256 ELSE messages.file_enc_sha256 END,
- file_length=CASE WHEN excluded.file_length>0 THEN excluded.file_length ELSE messages.file_length END
- `, p.ChatJID, nullIfEmpty(p.ChatName), p.MsgID, nullIfEmpty(p.SenderJID), nullIfEmpty(p.SenderName), unix(p.Timestamp), boolToInt(p.FromMe), nullIfEmpty(p.Text), nullIfEmpty(p.DisplayText),
- nullIfEmpty(p.MediaType), nullIfEmpty(p.MediaCaption), nullIfEmpty(p.Filename), nullIfEmpty(p.MimeType), nullIfEmpty(p.DirectPath),
- p.MediaKey, p.FileSHA256, p.FileEncSHA256, int64(p.FileLength),
- )
- return err
-}
-
-type ListMessagesParams struct {
- ChatJID string
- Limit int
- Before *time.Time
- After *time.Time
-}
-
-func (d *DB) ListMessages(p ListMessagesParams) ([]Message, error) {
- if p.Limit <= 0 {
- p.Limit = 50
- }
- query := `
- SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
- FROM messages m
- LEFT JOIN chats c ON c.jid = m.chat_jid
- WHERE 1=1`
- var args []interface{}
- if strings.TrimSpace(p.ChatJID) != "" {
- query += " AND m.chat_jid = ?"
- args = append(args, p.ChatJID)
- }
- if p.After != nil {
- query += " AND m.ts > ?"
- args = append(args, unix(*p.After))
- }
- if p.Before != nil {
- query += " AND m.ts < ?"
- args = append(args, unix(*p.Before))
- }
- query += " ORDER BY m.ts DESC LIMIT ?"
- args = append(args, p.Limit)
- return d.scanMessages(query, args...)
-}
-
-func (d *DB) GetMessage(chatJID, msgID string) (Message, error) {
- row := d.sql.QueryRow(`
- SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
- FROM messages m
- LEFT JOIN chats c ON c.jid = m.chat_jid
- WHERE m.chat_jid = ? AND m.msg_id = ?
- `, chatJID, msgID)
- var m Message
- var ts int64
- var fromMe int
- if err := row.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType, &m.Snippet); err != nil {
- return Message{}, err
- }
- m.Timestamp = fromUnix(ts)
- m.FromMe = fromMe != 0
- return m, nil
-}
-
-func (d *DB) CountMessages() (int64, error) {
- row := d.sql.QueryRow(`SELECT COUNT(1) FROM messages`)
- var n int64
- if err := row.Scan(&n); err != nil {
- return 0, err
- }
- return n, nil
-}
-
-func (d *DB) GetOldestMessageInfo(chatJID string) (MessageInfo, error) {
- chatJID = strings.TrimSpace(chatJID)
- if chatJID == "" {
- return MessageInfo{}, fmt.Errorf("chat JID is required")
- }
- row := d.sql.QueryRow(`
- SELECT m.chat_jid, m.msg_id, m.ts, m.from_me, COALESCE(m.sender_jid,''), COALESCE(m.sender_name,'')
- FROM messages m
- WHERE m.chat_jid = ?
- ORDER BY m.ts ASC
- LIMIT 1
- `, chatJID)
- var out MessageInfo
- var ts int64
- var fromMe int
- if err := row.Scan(&out.ChatJID, &out.MsgID, &ts, &fromMe, &out.SenderJID, &out.SenderName); err != nil {
- return MessageInfo{}, err
- }
- out.Timestamp = fromUnix(ts)
- out.FromMe = fromMe != 0
- return out, nil
-}
-
-func (d *DB) MessageContext(chatJID, msgID string, before, after int) ([]Message, error) {
- if before < 0 {
- before = 0
- }
- if after < 0 {
- after = 0
- }
- target, err := d.GetMessage(chatJID, msgID)
- if err != nil {
- return nil, err
- }
-
- beforeRows, err := d.scanMessages(`
- SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
- FROM messages m
- LEFT JOIN chats c ON c.jid = m.chat_jid
- WHERE m.chat_jid = ? AND m.ts < ?
- ORDER BY m.ts DESC
- LIMIT ?
- `, chatJID, unix(target.Timestamp), before)
- if err != nil {
- return nil, err
- }
-
- afterRows, err := d.scanMessages(`
- SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
- FROM messages m
- LEFT JOIN chats c ON c.jid = m.chat_jid
- WHERE m.chat_jid = ? AND m.ts > ?
- ORDER BY m.ts ASC
- LIMIT ?
- `, chatJID, unix(target.Timestamp), after)
- if err != nil {
- return nil, err
- }
-
- // Reverse before rows back to chronological order.
- for i, j := 0, len(beforeRows)-1; i < j; i, j = i+1, j-1 {
- beforeRows[i], beforeRows[j] = beforeRows[j], beforeRows[i]
- }
-
- out := make([]Message, 0, len(beforeRows)+1+len(afterRows))
- out = append(out, beforeRows...)
- out = append(out, target)
- out = append(out, afterRows...)
- return out, nil
-}
-
-func (d *DB) scanMessages(query string, args ...interface{}) ([]Message, error) {
- rows, err := d.sql.Query(query, args...)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- var out []Message
- for rows.Next() {
- var m Message
- var ts int64
- var fromMe int
- if err := rows.Scan(&m.ChatJID, &m.ChatName, &m.MsgID, &m.SenderJID, &ts, &fromMe, &m.Text, &m.DisplayText, &m.MediaType, &m.Snippet); err != nil {
- return nil, err
- }
- m.Timestamp = fromUnix(ts)
- m.FromMe = fromMe != 0
- out = append(out, m)
- }
- return out, rows.Err()
-}
diff --git a/tools/wacli/internal/store/migrations.go b/tools/wacli/internal/store/migrations.go
deleted file mode 100644
index 41e1e3e..0000000
--- a/tools/wacli/internal/store/migrations.go
+++ /dev/null
@@ -1,289 +0,0 @@
-package store
-
-import (
- "database/sql"
- "errors"
- "fmt"
- "strings"
- "time"
-)
-
-type migration struct {
- version int
- name string
- up func(*DB) error
-}
-
-var schemaMigrations = []migration{
- {version: 1, name: "core schema", up: migrateCoreSchema},
- {version: 2, name: "messages display_text column", up: migrateMessagesDisplayText},
- {version: 3, name: "messages fts", up: migrateMessagesFTS},
-}
-
-func (d *DB) ensureSchema() error {
- if _, err := d.sql.Exec(`
- CREATE TABLE IF NOT EXISTS schema_migrations (
- version INTEGER PRIMARY KEY,
- name TEXT NOT NULL,
- applied_at INTEGER NOT NULL
- )
- `); err != nil {
- return fmt.Errorf("create schema_migrations table: %w", err)
- }
-
- applied := map[int]bool{}
- rows, err := d.sql.Query(`SELECT version FROM schema_migrations`)
- if err != nil {
- return fmt.Errorf("load applied migrations: %w", err)
- }
- defer rows.Close()
-
- for rows.Next() {
- var version int
- if err := rows.Scan(&version); err != nil {
- return fmt.Errorf("scan applied migration: %w", err)
- }
- applied[version] = true
- }
- if err := rows.Err(); err != nil {
- return fmt.Errorf("iterate applied migrations: %w", err)
- }
-
- for _, m := range schemaMigrations {
- if applied[m.version] {
- continue
- }
- if err := m.up(d); err != nil {
- return fmt.Errorf("apply migration %03d %s: %w", m.version, m.name, err)
- }
- if _, err := d.sql.Exec(
- `INSERT INTO schema_migrations(version, name, applied_at) VALUES(?, ?, ?)`,
- m.version,
- m.name,
- time.Now().UTC().Unix(),
- ); err != nil {
- return fmt.Errorf("record migration %03d: %w", m.version, err)
- }
- }
-
- return nil
-}
-
-func migrateCoreSchema(d *DB) error {
- if _, err := d.sql.Exec(`
- CREATE TABLE IF NOT EXISTS chats (
- jid TEXT PRIMARY KEY,
- kind TEXT NOT NULL, -- dm|group|broadcast|unknown
- name TEXT,
- last_message_ts INTEGER
- );
-
- CREATE TABLE IF NOT EXISTS contacts (
- jid TEXT PRIMARY KEY,
- phone TEXT,
- push_name TEXT,
- full_name TEXT,
- first_name TEXT,
- business_name TEXT,
- updated_at INTEGER NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS groups (
- jid TEXT PRIMARY KEY,
- name TEXT,
- owner_jid TEXT,
- created_ts INTEGER,
- updated_at INTEGER NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS group_participants (
- group_jid TEXT NOT NULL,
- user_jid TEXT NOT NULL,
- role TEXT,
- updated_at INTEGER NOT NULL,
- PRIMARY KEY (group_jid, user_jid),
- FOREIGN KEY (group_jid) REFERENCES groups(jid) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS contact_aliases (
- jid TEXT PRIMARY KEY,
- alias TEXT NOT NULL,
- notes TEXT,
- updated_at INTEGER NOT NULL
- );
-
- CREATE TABLE IF NOT EXISTS contact_tags (
- jid TEXT NOT NULL,
- tag TEXT NOT NULL,
- updated_at INTEGER NOT NULL,
- PRIMARY KEY (jid, tag)
- );
-
- CREATE TABLE IF NOT EXISTS messages (
- rowid INTEGER PRIMARY KEY AUTOINCREMENT,
- chat_jid TEXT NOT NULL,
- chat_name TEXT,
- msg_id TEXT NOT NULL,
- sender_jid TEXT,
- sender_name TEXT,
- ts INTEGER NOT NULL,
- from_me INTEGER NOT NULL,
- text TEXT,
- display_text TEXT,
- media_type TEXT,
- media_caption TEXT,
- filename TEXT,
- mime_type TEXT,
- direct_path TEXT,
- media_key BLOB,
- file_sha256 BLOB,
- file_enc_sha256 BLOB,
- file_length INTEGER,
- local_path TEXT,
- downloaded_at INTEGER,
- UNIQUE(chat_jid, msg_id),
- FOREIGN KEY (chat_jid) REFERENCES chats(jid) ON DELETE CASCADE
- );
-
- CREATE INDEX IF NOT EXISTS idx_messages_chat_ts ON messages(chat_jid, ts);
- CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts);
- `); err != nil {
- return fmt.Errorf("create tables: %w", err)
- }
- return nil
-}
-
-func migrateMessagesDisplayText(d *DB) error {
- hasDisplayText, err := d.tableHasColumn("messages", "display_text")
- if err != nil {
- return err
- }
- if hasDisplayText {
- return nil
- }
- if _, err := d.sql.Exec(`ALTER TABLE messages ADD COLUMN display_text TEXT`); err != nil {
- return fmt.Errorf("add display_text column: %w", err)
- }
- return nil
-}
-
-func migrateMessagesFTS(d *DB) error {
- ftsExists, err := d.tableExists("messages_fts")
- if err != nil {
- return err
- }
- if ftsExists {
- hasDisplay, err := d.tableHasColumn("messages_fts", "display_text")
- if err != nil {
- return err
- }
- if !hasDisplay {
- if _, err := d.sql.Exec(`DROP TABLE IF EXISTS messages_fts`); err != nil {
- return fmt.Errorf("drop messages_fts: %w", err)
- }
- ftsExists = false
- }
- }
-
- created := false
- if !ftsExists {
- if _, err := d.sql.Exec(`
- CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
- text,
- media_caption,
- filename,
- chat_name,
- sender_name,
- display_text
- )
- `); err != nil {
- // Continue without FTS (fallback to LIKE).
- d.ftsEnabled = false
- return nil
- }
- created = true
- }
-
- // Ensure triggers match expected semantics.
- if _, err := d.sql.Exec(`
- DROP TRIGGER IF EXISTS messages_ai;
- DROP TRIGGER IF EXISTS messages_ad;
- DROP TRIGGER IF EXISTS messages_au;
-
- CREATE TRIGGER messages_ai AFTER INSERT ON messages BEGIN
- INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name, display_text)
- VALUES (new.rowid, COALESCE(new.text,''), COALESCE(new.media_caption,''), COALESCE(new.filename,''), COALESCE(new.chat_name,''), COALESCE(new.sender_name,''), COALESCE(new.display_text,''));
- END;
-
- CREATE TRIGGER messages_ad AFTER DELETE ON messages BEGIN
- DELETE FROM messages_fts WHERE rowid = old.rowid;
- END;
-
- CREATE TRIGGER messages_au AFTER UPDATE ON messages BEGIN
- DELETE FROM messages_fts WHERE rowid = old.rowid;
- INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name, display_text)
- VALUES (new.rowid, COALESCE(new.text,''), COALESCE(new.media_caption,''), COALESCE(new.filename,''), COALESCE(new.chat_name,''), COALESCE(new.sender_name,''), COALESCE(new.display_text,''));
- END;
- `); err != nil {
- d.ftsEnabled = false
- return nil
- }
-
- if created {
- if _, err := d.sql.Exec(`
- INSERT INTO messages_fts(rowid, text, media_caption, filename, chat_name, sender_name, display_text)
- SELECT rowid,
- COALESCE(text,''),
- COALESCE(media_caption,''),
- COALESCE(filename,''),
- COALESCE(chat_name,''),
- COALESCE(sender_name,''),
- COALESCE(display_text,'')
- FROM messages
- `); err != nil {
- d.ftsEnabled = false
- return nil
- }
- }
-
- d.ftsEnabled = true
- return nil
-}
-
-func (d *DB) tableExists(table string) (bool, error) {
- row := d.sql.QueryRow(`SELECT 1 FROM sqlite_master WHERE name = ? AND type IN ('table','view')`, table)
- var one int
- if err := row.Scan(&one); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return false, nil
- }
- return false, err
- }
- return true, nil
-}
-
-func (d *DB) tableHasColumn(table, column string) (bool, error) {
- rows, err := d.sql.Query("PRAGMA table_info(" + table + ")")
- if err != nil {
- return false, err
- }
- defer rows.Close()
-
- for rows.Next() {
- var (
- cid int
- name string
- colType string
- notNull int
- pk int
- dflt sql.NullString
- )
- if err := rows.Scan(&cid, &name, &colType, ¬Null, &dflt, &pk); err != nil {
- return false, err
- }
- if strings.EqualFold(name, column) {
- return true, nil
- }
- }
- return false, rows.Err()
-}
diff --git a/tools/wacli/internal/store/schema_test.go b/tools/wacli/internal/store/schema_test.go
deleted file mode 100644
index d094f47..0000000
--- a/tools/wacli/internal/store/schema_test.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package store
-
-import (
- "database/sql"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestOpenCreatesExpectedSchema(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "wacli.db")
-
- db, err := Open(path)
- if err != nil {
- t.Fatalf("Open: %v", err)
- }
- defer db.Close()
-
- cols, err := tableColumns(db.sql, "messages")
- if err != nil {
- t.Fatalf("tableColumns: %v", err)
- }
-
- for _, want := range []string{
- "chat_name",
- "sender_name",
- "display_text",
- "local_path",
- "downloaded_at",
- } {
- if !cols[want] {
- t.Fatalf("expected messages column %q to exist", want)
- }
- }
-}
-
-func tableColumns(db *sql.DB, table string) (map[string]bool, error) {
- rows, err := db.Query("PRAGMA table_info(" + table + ")")
- if err != nil {
- return nil, err
- }
- defer rows.Close()
-
- cols := map[string]bool{}
- for rows.Next() {
- var cid int
- var name string
- var colType string
- var notNull int
- var pk int
- var dflt sql.NullString
- if err := rows.Scan(&cid, &name, &colType, ¬Null, &dflt, &pk); err != nil {
- return nil, err
- }
- cols[strings.ToLower(name)] = true
- }
- return cols, rows.Err()
-}
diff --git a/tools/wacli/internal/store/search.go b/tools/wacli/internal/store/search.go
deleted file mode 100644
index 61ded75..0000000
--- a/tools/wacli/internal/store/search.go
+++ /dev/null
@@ -1,84 +0,0 @@
-package store
-
-import (
- "fmt"
- "strings"
- "time"
-)
-
-type SearchMessagesParams struct {
- Query string
- ChatJID string
- From string
- Limit int
- Before *time.Time
- After *time.Time
- Type string
-}
-
-func (d *DB) SearchMessages(p SearchMessagesParams) ([]Message, error) {
- if strings.TrimSpace(p.Query) == "" {
- return nil, fmt.Errorf("query is required")
- }
- if p.Limit <= 0 {
- p.Limit = 50
- }
-
- if d.ftsEnabled {
- return d.searchFTS(p)
- }
- return d.searchLIKE(p)
-}
-
-func (d *DB) searchLIKE(p SearchMessagesParams) ([]Message, error) {
- query := `
- SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''), ''
- FROM messages m
- LEFT JOIN chats c ON c.jid = m.chat_jid
- WHERE (LOWER(m.text) LIKE LOWER(?) OR LOWER(m.display_text) LIKE LOWER(?) OR LOWER(m.media_caption) LIKE LOWER(?) OR LOWER(m.filename) LIKE LOWER(?) OR LOWER(COALESCE(m.chat_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(m.sender_name,'')) LIKE LOWER(?) OR LOWER(COALESCE(c.name,'')) LIKE LOWER(?))`
- needle := "%" + p.Query + "%"
- args := []interface{}{needle, needle, needle, needle, needle, needle, needle}
- query, args = applyMessageFilters(query, args, p)
- query += " ORDER BY m.ts DESC LIMIT ?"
- args = append(args, p.Limit)
- return d.scanMessages(query, args...)
-}
-
-func (d *DB) searchFTS(p SearchMessagesParams) ([]Message, error) {
- query := `
- SELECT m.chat_jid, COALESCE(c.name,''), m.msg_id, COALESCE(m.sender_jid,''), m.ts, m.from_me, COALESCE(m.text,''), COALESCE(m.display_text,''), COALESCE(m.media_type,''),
- snippet(messages_fts, 0, '[', ']', '…', 12)
- FROM messages_fts
- JOIN messages m ON messages_fts.rowid = m.rowid
- LEFT JOIN chats c ON c.jid = m.chat_jid
- WHERE messages_fts MATCH ?`
- args := []interface{}{p.Query}
- query, args = applyMessageFilters(query, args, p)
- query += " ORDER BY bm25(messages_fts) LIMIT ?"
- args = append(args, p.Limit)
- return d.scanMessages(query, args...)
-}
-
-func applyMessageFilters(query string, args []interface{}, p SearchMessagesParams) (string, []interface{}) {
- if strings.TrimSpace(p.ChatJID) != "" {
- query += " AND m.chat_jid = ?"
- args = append(args, p.ChatJID)
- }
- if strings.TrimSpace(p.From) != "" {
- query += " AND m.sender_jid = ?"
- args = append(args, p.From)
- }
- if p.After != nil {
- query += " AND m.ts > ?"
- args = append(args, unix(*p.After))
- }
- if p.Before != nil {
- query += " AND m.ts < ?"
- args = append(args, unix(*p.Before))
- }
- if strings.TrimSpace(p.Type) != "" {
- query += " AND COALESCE(m.media_type,'') = ?"
- args = append(args, p.Type)
- }
- return query, args
-}
diff --git a/tools/wacli/internal/store/search_fts_test.go b/tools/wacli/internal/store/search_fts_test.go
deleted file mode 100644
index 9b875b8..0000000
--- a/tools/wacli/internal/store/search_fts_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-//go:build sqlite_fts5
-
-package store
-
-import (
- "testing"
- "time"
-)
-
-func TestSearchMessagesUsesFTSWhenEnabled(t *testing.T) {
- db := openTestDB(t)
- if !db.HasFTS() {
- t.Fatalf("expected HasFTS=true in sqlite_fts5 build")
- }
-
- chat := "123@s.whatsapp.net"
- if err := db.UpsertChat(chat, "dm", "Alice", time.Now()); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
- if err := db.UpsertMessage(UpsertMessageParams{
- ChatJID: chat,
- ChatName: "Alice",
- MsgID: "m1",
- SenderJID: chat,
- SenderName: "Alice",
- Timestamp: time.Now(),
- FromMe: false,
- Text: "hello world",
- }); err != nil {
- t.Fatalf("UpsertMessage: %v", err)
- }
-
- ms, err := db.SearchMessages(SearchMessagesParams{Query: "hello", Limit: 10})
- if err != nil {
- t.Fatalf("SearchMessages: %v", err)
- }
- if len(ms) != 1 {
- t.Fatalf("expected 1 result, got %d", len(ms))
- }
- if ms[0].Snippet == "" {
- t.Fatalf("expected snippet for FTS search, got empty")
- }
-}
diff --git a/tools/wacli/internal/store/search_nonfts_test.go b/tools/wacli/internal/store/search_nonfts_test.go
deleted file mode 100644
index 06c91cf..0000000
--- a/tools/wacli/internal/store/search_nonfts_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-//go:build !sqlite_fts5
-
-package store
-
-import (
- "testing"
- "time"
-)
-
-func TestSearchMessagesUsesLIKEWhenFTSDisabled(t *testing.T) {
- db := openTestDB(t)
- if db.HasFTS() {
- t.Fatalf("expected HasFTS=false in !sqlite_fts5 build")
- }
-
- chat := "123@s.whatsapp.net"
- if err := db.UpsertChat(chat, "dm", "Alice", time.Now()); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
- if err := db.UpsertMessage(UpsertMessageParams{
- ChatJID: chat,
- ChatName: "Alice",
- MsgID: "m1",
- SenderJID: chat,
- SenderName: "Alice",
- Timestamp: time.Now(),
- FromMe: false,
- Text: "hello world",
- }); err != nil {
- t.Fatalf("UpsertMessage: %v", err)
- }
-
- ms, err := db.SearchMessages(SearchMessagesParams{Query: "hello", Limit: 10})
- if err != nil {
- t.Fatalf("SearchMessages: %v", err)
- }
- if len(ms) != 1 {
- t.Fatalf("expected 1 result, got %d", len(ms))
- }
- if ms[0].Snippet != "" {
- t.Fatalf("expected empty snippet for LIKE search, got %q", ms[0].Snippet)
- }
-}
diff --git a/tools/wacli/internal/store/store_test.go b/tools/wacli/internal/store/store_test.go
deleted file mode 100644
index 7d405df..0000000
--- a/tools/wacli/internal/store/store_test.go
+++ /dev/null
@@ -1,321 +0,0 @@
-package store
-
-import (
- "database/sql"
- "path/filepath"
- "testing"
- "time"
-)
-
-func openTestDB(t *testing.T) *DB {
- t.Helper()
- dir := t.TempDir()
- path := filepath.Join(dir, "wacli.db")
- db, err := Open(path)
- if err != nil {
- t.Fatalf("Open: %v", err)
- }
- t.Cleanup(func() { _ = db.Close() })
- return db
-}
-
-func countRows(t *testing.T, db *sql.DB, q string, args ...any) int {
- t.Helper()
- row := db.QueryRow(q, args...)
- var n int
- if err := row.Scan(&n); err != nil {
- t.Fatalf("countRows scan: %v", err)
- }
- return n
-}
-
-func TestUpsertChatNameAndLastMessageTS(t *testing.T) {
- db := openTestDB(t)
-
- t1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
- t2 := time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)
-
- if err := db.UpsertChat("123@s.whatsapp.net", "dm", "Alice", t1); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
- // Empty name should not clobber.
- if err := db.UpsertChat("123@s.whatsapp.net", "dm", "", t2); err != nil {
- t.Fatalf("UpsertChat empty name: %v", err)
- }
- c, err := db.GetChat("123@s.whatsapp.net")
- if err != nil {
- t.Fatalf("GetChat: %v", err)
- }
- if c.Name != "Alice" {
- t.Fatalf("expected name to stay Alice, got %q", c.Name)
- }
- if !c.LastMessageTS.Equal(t2) {
- t.Fatalf("expected LastMessageTS=%s, got %s", t2, c.LastMessageTS)
- }
-
- // Older timestamp should not override.
- if err := db.UpsertChat("123@s.whatsapp.net", "dm", "Alice2", t1); err != nil {
- t.Fatalf("UpsertChat older ts: %v", err)
- }
- c, err = db.GetChat("123@s.whatsapp.net")
- if err != nil {
- t.Fatalf("GetChat: %v", err)
- }
- if !c.LastMessageTS.Equal(t2) {
- t.Fatalf("expected LastMessageTS to remain %s, got %s", t2, c.LastMessageTS)
- }
-}
-
-func TestMessageUpsertIdempotentAndContext(t *testing.T) {
- db := openTestDB(t)
-
- chat := "123@s.whatsapp.net"
- if err := db.UpsertChat(chat, "dm", "Alice", time.Now()); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
-
- base := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
- msgs := []struct {
- id string
- ts time.Time
- text string
- }{
- {"m1", base.Add(1 * time.Second), "first"},
- {"m2", base.Add(2 * time.Second), "second"},
- {"m3", base.Add(3 * time.Second), "third"},
- }
- for _, m := range msgs {
- if err := db.UpsertMessage(UpsertMessageParams{
- ChatJID: chat,
- ChatName: "Alice",
- MsgID: m.id,
- SenderJID: chat,
- SenderName: "Alice",
- Timestamp: m.ts,
- FromMe: false,
- Text: m.text,
- }); err != nil {
- t.Fatalf("UpsertMessage %s: %v", m.id, err)
- }
- }
-
- // Upsert same message again should not create duplicates.
- if err := db.UpsertMessage(UpsertMessageParams{
- ChatJID: chat,
- ChatName: "Alice",
- MsgID: "m2",
- SenderJID: chat,
- SenderName: "Alice",
- Timestamp: base.Add(2 * time.Second),
- FromMe: false,
- Text: "second",
- }); err != nil {
- t.Fatalf("UpsertMessage again: %v", err)
- }
- if got := countRows(t, db.sql, "SELECT COUNT(*) FROM messages WHERE chat_jid = ?", chat); got != 3 {
- t.Fatalf("expected 3 messages, got %d", got)
- }
-
- ctx, err := db.MessageContext(chat, "m2", 1, 1)
- if err != nil {
- t.Fatalf("MessageContext: %v", err)
- }
- if len(ctx) != 3 {
- t.Fatalf("expected 3 context messages, got %d", len(ctx))
- }
- if ctx[0].MsgID != "m1" || ctx[1].MsgID != "m2" || ctx[2].MsgID != "m3" {
- t.Fatalf("unexpected context order: %v, %v, %v", ctx[0].MsgID, ctx[1].MsgID, ctx[2].MsgID)
- }
-}
-
-func TestMediaDownloadInfoAndMarkDownloaded(t *testing.T) {
- db := openTestDB(t)
-
- chat := "123@s.whatsapp.net"
- if err := db.UpsertChat(chat, "dm", "Alice", time.Now()); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
- ts := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)
- if err := db.UpsertMessage(UpsertMessageParams{
- ChatJID: chat,
- ChatName: "Alice",
- MsgID: "mid",
- SenderJID: chat,
- SenderName: "Alice",
- Timestamp: ts,
- FromMe: false,
- Text: "",
- MediaType: "image",
- MediaCaption: "cap",
- Filename: "pic.jpg",
- MimeType: "image/jpeg",
- DirectPath: "/direct/path",
- MediaKey: []byte{1, 2, 3},
- FileSHA256: []byte{4, 5},
- FileEncSHA256: []byte{6, 7},
- FileLength: 123,
- }); err != nil {
- t.Fatalf("UpsertMessage: %v", err)
- }
-
- info, err := db.GetMediaDownloadInfo(chat, "mid")
- if err != nil {
- t.Fatalf("GetMediaDownloadInfo: %v", err)
- }
- if info.MediaType != "image" || info.MimeType != "image/jpeg" || info.DirectPath != "/direct/path" {
- t.Fatalf("unexpected media info: %+v", info)
- }
- if info.FileLength != 123 {
- t.Fatalf("expected FileLength=123, got %d", info.FileLength)
- }
-
- when := time.Date(2024, 3, 1, 0, 0, 1, 0, time.UTC)
- if err := db.MarkMediaDownloaded(chat, "mid", "/tmp/file", when); err != nil {
- t.Fatalf("MarkMediaDownloaded: %v", err)
- }
- info, err = db.GetMediaDownloadInfo(chat, "mid")
- if err != nil {
- t.Fatalf("GetMediaDownloadInfo: %v", err)
- }
- if info.LocalPath != "/tmp/file" {
- t.Fatalf("expected LocalPath set, got %q", info.LocalPath)
- }
- if !info.DownloadedAt.Equal(when) {
- t.Fatalf("expected DownloadedAt=%s, got %s", when, info.DownloadedAt)
- }
-}
-
-func TestContactsAliasTagsAndSearch(t *testing.T) {
- db := openTestDB(t)
-
- jid := "111@s.whatsapp.net"
- if err := db.UpsertContact(jid, "111", "Push", "Full Name", "First", "Biz"); err != nil {
- t.Fatalf("UpsertContact: %v", err)
- }
- if err := db.SetAlias(jid, "Ali"); err != nil {
- t.Fatalf("SetAlias: %v", err)
- }
- if err := db.AddTag(jid, "friends"); err != nil {
- t.Fatalf("AddTag: %v", err)
- }
- if err := db.AddTag(jid, "work"); err != nil {
- t.Fatalf("AddTag: %v", err)
- }
-
- c, err := db.GetContact(jid)
- if err != nil {
- t.Fatalf("GetContact: %v", err)
- }
- if c.Alias != "Ali" {
- t.Fatalf("expected alias Ali, got %q", c.Alias)
- }
- if len(c.Tags) != 2 {
- t.Fatalf("expected 2 tags, got %v", c.Tags)
- }
-
- found, err := db.SearchContacts("Ali", 10)
- if err != nil {
- t.Fatalf("SearchContacts: %v", err)
- }
- if len(found) != 1 || found[0].JID != jid {
- t.Fatalf("expected to find contact by alias, got %+v", found)
- }
-
- if err := db.RemoveTag(jid, "work"); err != nil {
- t.Fatalf("RemoveTag: %v", err)
- }
- if err := db.RemoveAlias(jid); err != nil {
- t.Fatalf("RemoveAlias: %v", err)
- }
- c, err = db.GetContact(jid)
- if err != nil {
- t.Fatalf("GetContact: %v", err)
- }
- if c.Alias != "" {
- t.Fatalf("expected alias removed, got %q", c.Alias)
- }
- if len(c.Tags) != 1 || c.Tags[0] != "friends" {
- t.Fatalf("expected remaining tag friends, got %v", c.Tags)
- }
-}
-
-func TestCountMessagesAndOldestMessageInfo(t *testing.T) {
- db := openTestDB(t)
-
- chat := "123@s.whatsapp.net"
- if err := db.UpsertChat(chat, "dm", "Alice", time.Now()); err != nil {
- t.Fatalf("UpsertChat: %v", err)
- }
-
- if n, err := db.CountMessages(); err != nil || n != 0 {
- t.Fatalf("CountMessages expected 0, got %d (err=%v)", n, err)
- }
-
- base := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
- _ = db.UpsertMessage(UpsertMessageParams{
- ChatJID: chat,
- MsgID: "m2",
- Timestamp: base.Add(2 * time.Second),
- FromMe: true,
- SenderJID: chat,
- SenderName: "Alice",
- Text: "second",
- })
- _ = db.UpsertMessage(UpsertMessageParams{
- ChatJID: chat,
- MsgID: "m1",
- Timestamp: base.Add(1 * time.Second),
- FromMe: false,
- SenderJID: chat,
- SenderName: "Alice",
- Text: "first",
- })
-
- oldest, err := db.GetOldestMessageInfo(chat)
- if err != nil {
- t.Fatalf("GetOldestMessageInfo: %v", err)
- }
- if oldest.MsgID != "m1" {
- t.Fatalf("expected oldest m1, got %q", oldest.MsgID)
- }
- if !oldest.Timestamp.Equal(base.Add(1 * time.Second)) {
- t.Fatalf("unexpected oldest timestamp: %s", oldest.Timestamp)
- }
- if oldest.FromMe {
- t.Fatalf("expected oldest.FromMe=false")
- }
-
- if n, err := db.CountMessages(); err != nil || n != 2 {
- t.Fatalf("CountMessages expected 2, got %d (err=%v)", n, err)
- }
-}
-
-func TestGroupsUpsertListAndParticipantsReplace(t *testing.T) {
- db := openTestDB(t)
-
- gid := "123@g.us"
- created := time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC)
- if err := db.UpsertGroup(gid, "Group", "owner@s.whatsapp.net", created); err != nil {
- t.Fatalf("UpsertGroup: %v", err)
- }
- if err := db.ReplaceGroupParticipants(gid, []GroupParticipant{
- {GroupJID: gid, UserJID: "a@s.whatsapp.net", Role: "admin"},
- {GroupJID: gid, UserJID: "b@s.whatsapp.net", Role: ""},
- }); err != nil {
- t.Fatalf("ReplaceGroupParticipants: %v", err)
- }
-
- gs, err := db.ListGroups("Gro", 10)
- if err != nil {
- t.Fatalf("ListGroups: %v", err)
- }
- if len(gs) != 1 || gs[0].JID != gid {
- t.Fatalf("expected group in list, got %+v", gs)
- }
-
- admins := countRows(t, db.sql, "SELECT COUNT(*) FROM group_participants WHERE group_jid=? AND role='admin'", gid)
- members := countRows(t, db.sql, "SELECT COUNT(*) FROM group_participants WHERE group_jid=? AND role='member'", gid)
- if admins != 1 || members != 1 {
- t.Fatalf("expected roles admin=1 member=1, got admin=%d member=%d", admins, members)
- }
-}
diff --git a/tools/wacli/internal/store/types.go b/tools/wacli/internal/store/types.go
deleted file mode 100644
index ba1d4d9..0000000
--- a/tools/wacli/internal/store/types.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package store
-
-import (
- "database/sql"
- "errors"
- "strings"
- "time"
-)
-
-type Chat struct {
- JID string
- Kind string
- Name string
- LastMessageTS time.Time
-}
-
-type Group struct {
- JID string
- Name string
- OwnerJID string
- CreatedAt time.Time
- UpdatedAt time.Time
-}
-
-type GroupParticipant struct {
- GroupJID string
- UserJID string
- Role string
- UpdatedAt time.Time
-}
-
-type MediaDownloadInfo struct {
- ChatJID string
- ChatName string
- MsgID string
- MediaType string
- Filename string
- MimeType string
- DirectPath string
- MediaKey []byte
- FileSHA256 []byte
- FileEncSHA256 []byte
- FileLength uint64
- LocalPath string
- DownloadedAt time.Time
-}
-
-type Message struct {
- ChatJID string
- ChatName string
- MsgID string
- SenderJID string
- Timestamp time.Time
- FromMe bool
- Text string
- DisplayText string
- MediaType string
- Snippet string
-}
-
-type MessageInfo struct {
- ChatJID string
- MsgID string
- Timestamp time.Time
- FromMe bool
- SenderJID string
- SenderName string
-}
-
-type Contact struct {
- JID string
- Phone string
- Name string
- Alias string
- Tags []string
- UpdatedAt time.Time
-}
-
-func unix(t time.Time) int64 {
- if t.IsZero() {
- return 0
- }
- return t.UTC().Unix()
-}
-
-func fromUnix(sec int64) time.Time {
- if sec <= 0 {
- return time.Time{}
- }
- return time.Unix(sec, 0).UTC()
-}
-
-func boolToInt(b bool) int {
- if b {
- return 1
- }
- return 0
-}
-
-func nullIfEmpty(s string) interface{} {
- s = strings.TrimSpace(s)
- if s == "" {
- return nil
- }
- return s
-}
-
-func (d *DB) HasFTS() bool { return d.ftsEnabled }
-
-func IsNotFound(err error) bool {
- return errors.Is(err, sql.ErrNoRows)
-}
diff --git a/tools/wacli/internal/wa/client.go b/tools/wacli/internal/wa/client.go
deleted file mode 100644
index 10cb529..0000000
--- a/tools/wacli/internal/wa/client.go
+++ /dev/null
@@ -1,398 +0,0 @@
-package wa
-
-import (
- "context"
- "database/sql"
- "fmt"
- "os"
- "strings"
- "sync"
- "time"
-
- "github.com/mdp/qrterminal/v3"
- "go.mau.fi/whatsmeow"
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/store/sqlstore"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- waLog "go.mau.fi/whatsmeow/util/log"
-)
-
-type Options struct {
- StorePath string
-}
-
-type Client struct {
- opts Options
-
- mu sync.Mutex
- client *whatsmeow.Client
-}
-
-func New(opts Options) (*Client, error) {
- if strings.TrimSpace(opts.StorePath) == "" {
- return nil, fmt.Errorf("StorePath is required")
- }
- c := &Client{opts: opts}
- if err := c.init(); err != nil {
- return nil, err
- }
- return c, nil
-}
-
-func (c *Client) init() error {
- c.mu.Lock()
- defer c.mu.Unlock()
-
- ctx := context.Background()
- dbLog := waLog.Stdout("Database", "ERROR", true)
- container, err := sqlstore.New(ctx, "sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", c.opts.StorePath), dbLog)
- if err != nil {
- return fmt.Errorf("open whatsmeow store: %w", err)
- }
-
- deviceStore, err := container.GetFirstDevice(ctx)
- if err != nil {
- if err == sql.ErrNoRows {
- deviceStore = container.NewDevice()
- } else {
- return fmt.Errorf("get device store: %w", err)
- }
- }
-
- logger := waLog.Stdout("Client", "ERROR", true)
- c.client = whatsmeow.NewClient(deviceStore, logger)
- return nil
-}
-
-func (c *Client) Close() {
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.client != nil {
- c.client.Disconnect()
- }
-}
-
-func (c *Client) IsAuthed() bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.client != nil && c.client.Store != nil && c.client.Store.ID != nil
-}
-
-func (c *Client) IsConnected() bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.client != nil && c.client.IsConnected()
-}
-
-type ConnectOptions struct {
- AllowQR bool
- OnQRCode func(code string)
- OnQR func(code string)
-}
-
-func (c *Client) Connect(ctx context.Context, opts ConnectOptions) error {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil {
- return fmt.Errorf("whatsapp client is not initialized")
- }
-
- if cli.IsConnected() {
- return nil
- }
-
- authed := cli.Store != nil && cli.Store.ID != nil
- if !authed && !opts.AllowQR {
- return fmt.Errorf("not authenticated; run `wacli auth`")
- }
-
- var qrChan <-chan whatsmeow.QRChannelItem
- if !authed {
- ch, _ := cli.GetQRChannel(ctx)
- qrChan = ch
- }
-
- // For already-authenticated sessions, register an event handler to
- // detect when the connection handshake completes before we return.
- connReady := make(chan struct{}, 1)
- if authed {
- handlerID := cli.AddEventHandler(func(evt interface{}) {
- if _, ok := evt.(*events.Connected); ok {
- select {
- case connReady <- struct{}{}:
- default:
- }
- }
- })
- defer cli.RemoveEventHandler(handlerID)
- }
-
- if err := cli.ConnectContext(ctx); err != nil {
- return err
- }
-
- if authed {
- // Wait until the Connected event fires or context expires.
- select {
- case <-connReady:
- case <-ctx.Done():
- return ctx.Err()
- }
- return nil
- }
-
- // Wait for QR flow to succeed or fail.
- for {
- select {
- case <-ctx.Done():
- return ctx.Err()
- case evt, ok := <-qrChan:
- if !ok {
- return fmt.Errorf("QR channel closed")
- }
- switch evt.Event {
- case "code":
- if opts.OnQRCode != nil {
- opts.OnQRCode(evt.Code)
- } else {
- qrterminal.GenerateHalfBlock(evt.Code, qrterminal.M, os.Stdout)
- }
- if opts.OnQR != nil {
- opts.OnQR(evt.Code)
- }
- case "success":
- return nil
- case "timeout":
- return fmt.Errorf("QR code timed out")
- case "error":
- return fmt.Errorf("QR error")
- }
- }
- }
-}
-
-func (c *Client) AddEventHandler(handler func(interface{})) uint32 {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil {
- return 0
- }
- return cli.AddEventHandler(handler)
-}
-
-func (c *Client) RemoveEventHandler(id uint32) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil {
- return
- }
- cli.RemoveEventHandler(id)
-}
-
-func (c *Client) SendText(ctx context.Context, to types.JID, text string) (types.MessageID, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return "", fmt.Errorf("not connected")
- }
- msg := &waProto.Message{Conversation: &text}
- resp, err := cli.SendMessage(ctx, to, msg)
- if err != nil {
- return "", err
- }
- return resp.ID, nil
-}
-
-func (c *Client) SendProtoMessage(ctx context.Context, to types.JID, msg *waProto.Message) (types.MessageID, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return "", fmt.Errorf("not connected")
- }
- resp, err := cli.SendMessage(ctx, to, msg)
- if err != nil {
- return "", err
- }
- return resp.ID, nil
-}
-
-func (c *Client) Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return whatsmeow.UploadResponse{}, fmt.Errorf("not connected")
- }
- return cli.Upload(ctx, data, mediaType)
-}
-
-func (c *Client) DecryptReaction(ctx context.Context, reaction *events.Message) (*waProto.ReactionMessage, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return nil, fmt.Errorf("not connected")
- }
- return cli.DecryptReaction(ctx, reaction)
-}
-
-func (c *Client) RequestHistorySyncOnDemand(ctx context.Context, lastKnown types.MessageInfo, count int) (types.MessageID, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return "", fmt.Errorf("not connected")
- }
- if count <= 0 {
- count = 50
- }
- if lastKnown.Chat.IsEmpty() || strings.TrimSpace(string(lastKnown.ID)) == "" || lastKnown.Timestamp.IsZero() {
- return "", fmt.Errorf("invalid last known message info")
- }
-
- ownID := types.JID{}
- if cli.Store != nil && cli.Store.ID != nil {
- ownID = cli.Store.ID.ToNonAD()
- }
- if ownID.IsEmpty() {
- return "", fmt.Errorf("not authenticated; run `wacli auth`")
- }
-
- msg := cli.BuildHistorySyncRequest(&lastKnown, count)
- resp, err := cli.SendMessage(ctx, ownID, msg, whatsmeow.SendRequestExtra{Peer: true})
- if err != nil {
- return "", err
- }
- return resp.ID, nil
-}
-
-func ParseUserOrJID(s string) (types.JID, error) {
- s = strings.TrimSpace(s)
- if s == "" {
- return types.JID{}, fmt.Errorf("recipient is required")
- }
- if strings.Contains(s, "@") {
- return types.ParseJID(s)
- }
- s = strings.TrimPrefix(s, "+")
- return types.JID{User: s, Server: types.DefaultUserServer}, nil
-}
-
-func IsGroupJID(jid types.JID) bool {
- return jid.Server == types.GroupServer
-}
-
-func (c *Client) GetContact(ctx context.Context, jid types.JID) (types.ContactInfo, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || cli.Store == nil || cli.Store.Contacts == nil {
- return types.ContactInfo{}, fmt.Errorf("contacts store not available")
- }
- return cli.Store.Contacts.GetContact(ctx, jid)
-}
-
-func (c *Client) GetAllContacts(ctx context.Context) (map[types.JID]types.ContactInfo, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || cli.Store == nil || cli.Store.Contacts == nil {
- return nil, fmt.Errorf("contacts store not available")
- }
- return cli.Store.Contacts.GetAllContacts(ctx)
-}
-
-func BestContactName(info types.ContactInfo) string {
- if !info.Found {
- return ""
- }
- if s := strings.TrimSpace(info.FullName); s != "" {
- return s
- }
- if s := strings.TrimSpace(info.FirstName); s != "" {
- return s
- }
- if s := strings.TrimSpace(info.BusinessName); s != "" {
- return s
- }
- if s := strings.TrimSpace(info.PushName); s != "" && s != "-" {
- return s
- }
- if s := strings.TrimSpace(info.RedactedPhone); s != "" {
- return s
- }
- return ""
-}
-
-func (c *Client) ResolveChatName(ctx context.Context, chat types.JID, pushName string) string {
- fallback := chat.String()
-
- if chat.Server == types.GroupServer || chat.IsBroadcastList() {
- info, err := c.GetGroupInfo(ctx, chat)
- if err == nil && info != nil {
- if name := strings.TrimSpace(info.GroupName.Name); name != "" {
- return name
- }
- }
- } else {
- info, err := c.GetContact(ctx, chat.ToNonAD())
- if err == nil {
- if name := BestContactName(info); name != "" {
- return name
- }
- }
- }
-
- if name := strings.TrimSpace(pushName); name != "" && name != "-" {
- return name
- }
- return fallback
-}
-
-func (c *Client) GetGroupInfo(ctx context.Context, jid types.JID) (*types.GroupInfo, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return nil, fmt.Errorf("not connected")
- }
- return cli.GetGroupInfo(ctx, jid)
-}
-
-func (c *Client) Logout(ctx context.Context) error {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil {
- return fmt.Errorf("not initialized")
- }
- return cli.Logout(ctx)
-}
-
-// Reconnect loop helper.
-func (c *Client) ReconnectWithBackoff(ctx context.Context, minDelay, maxDelay time.Duration) error {
- delay := minDelay
- for {
- if ctx.Err() != nil {
- return ctx.Err()
- }
- if err := c.Connect(ctx, ConnectOptions{AllowQR: false}); err == nil {
- return nil
- }
- select {
- case <-ctx.Done():
- return ctx.Err()
- case <-time.After(delay):
- }
- delay *= 2
- if delay > maxDelay {
- delay = maxDelay
- }
- }
-}
diff --git a/tools/wacli/internal/wa/client_test.go b/tools/wacli/internal/wa/client_test.go
deleted file mode 100644
index 60f6d5d..0000000
--- a/tools/wacli/internal/wa/client_test.go
+++ /dev/null
@@ -1,51 +0,0 @@
-package wa
-
-import (
- "testing"
-
- "go.mau.fi/whatsmeow/types"
-)
-
-func TestParseUserOrJID(t *testing.T) {
- j, err := ParseUserOrJID("1234567890")
- if err != nil {
- t.Fatalf("ParseUserOrJID: %v", err)
- }
- if j.Server != types.DefaultUserServer || j.User != "1234567890" {
- t.Fatalf("unexpected jid: %+v", j)
- }
-
- j, err = ParseUserOrJID("123@g.us")
- if err != nil {
- t.Fatalf("ParseUserOrJID group: %v", err)
- }
- if !IsGroupJID(j) {
- t.Fatalf("expected group jid, got %+v", j)
- }
-
- j, err = ParseUserOrJID("+41772909259")
- if err != nil {
- t.Fatalf("ParseUserOrJID with + prefix: %v", err)
- }
- if j.User != "41772909259" || j.Server != types.DefaultUserServer {
- t.Fatalf("expected + stripped, got %+v", j)
- }
-}
-
-func TestBestContactName(t *testing.T) {
- if BestContactName(types.ContactInfo{Found: false, FullName: "x"}) != "" {
- t.Fatalf("expected empty for not found")
- }
- if BestContactName(types.ContactInfo{Found: true, FullName: "Full"}) != "Full" {
- t.Fatalf("expected full name")
- }
- if BestContactName(types.ContactInfo{Found: true, FirstName: "First"}) != "First" {
- t.Fatalf("expected first name")
- }
- if BestContactName(types.ContactInfo{Found: true, BusinessName: "Biz"}) != "Biz" {
- t.Fatalf("expected business name")
- }
- if BestContactName(types.ContactInfo{Found: true, PushName: "Push"}) != "Push" {
- t.Fatalf("expected push name")
- }
-}
diff --git a/tools/wacli/internal/wa/groups.go b/tools/wacli/internal/wa/groups.go
deleted file mode 100644
index c177b0e..0000000
--- a/tools/wacli/internal/wa/groups.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package wa
-
-import (
- "context"
- "fmt"
-
- "go.mau.fi/whatsmeow"
- "go.mau.fi/whatsmeow/types"
-)
-
-func (c *Client) GetJoinedGroups(ctx context.Context) ([]*types.GroupInfo, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return nil, fmt.Errorf("not connected")
- }
- return cli.GetJoinedGroups(ctx)
-}
-
-func (c *Client) SetGroupName(ctx context.Context, jid types.JID, name string) error {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return fmt.Errorf("not connected")
- }
- return cli.SetGroupName(ctx, jid, name)
-}
-
-type GroupParticipantAction string
-
-const (
- GroupParticipantAdd GroupParticipantAction = "add"
- GroupParticipantRemove GroupParticipantAction = "remove"
- GroupParticipantPromote GroupParticipantAction = "promote"
- GroupParticipantDemote GroupParticipantAction = "demote"
-)
-
-func (c *Client) UpdateGroupParticipants(ctx context.Context, group types.JID, users []types.JID, action GroupParticipantAction) ([]types.GroupParticipant, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return nil, fmt.Errorf("not connected")
- }
-
- var a whatsmeow.ParticipantChange
- switch action {
- case GroupParticipantAdd:
- a = whatsmeow.ParticipantChangeAdd
- case GroupParticipantRemove:
- a = whatsmeow.ParticipantChangeRemove
- case GroupParticipantPromote:
- a = whatsmeow.ParticipantChangePromote
- case GroupParticipantDemote:
- a = whatsmeow.ParticipantChangeDemote
- default:
- return nil, fmt.Errorf("unknown participant action: %s", action)
- }
-
- return cli.UpdateGroupParticipants(ctx, group, users, a)
-}
-
-func (c *Client) GetGroupInviteLink(ctx context.Context, group types.JID, reset bool) (string, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return "", fmt.Errorf("not connected")
- }
- return cli.GetGroupInviteLink(ctx, group, reset)
-}
-
-func (c *Client) JoinGroupWithLink(ctx context.Context, code string) (types.JID, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return types.JID{}, fmt.Errorf("not connected")
- }
- return cli.JoinGroupWithLink(ctx, code)
-}
-
-func (c *Client) LeaveGroup(ctx context.Context, group types.JID) error {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return fmt.Errorf("not connected")
- }
- return cli.LeaveGroup(ctx, group)
-}
diff --git a/tools/wacli/internal/wa/locking_regression_test.go b/tools/wacli/internal/wa/locking_regression_test.go
deleted file mode 100644
index aff6393..0000000
--- a/tools/wacli/internal/wa/locking_regression_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package wa
-
-import (
- "go/ast"
- "go/parser"
- "go/token"
- "os"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-func TestEventHandlerRegistrationDoesNotCallUnderLock(t *testing.T) {
- _, thisFile, _, ok := runtime.Caller(0)
- if !ok {
- t.Fatalf("runtime.Caller failed")
- }
- clientPath := filepath.Join(filepath.Dir(thisFile), "client.go")
- src, err := os.ReadFile(clientPath)
- if err != nil {
- t.Fatalf("read %s: %v", clientPath, err)
- }
-
- fset := token.NewFileSet()
- f, err := parser.ParseFile(fset, clientPath, src, 0)
- if err != nil {
- t.Fatalf("parse %s: %v", clientPath, err)
- }
-
- check := func(funcName, callName string) {
- t.Helper()
-
- var fn *ast.FuncDecl
- for _, d := range f.Decls {
- fd, ok := d.(*ast.FuncDecl)
- if !ok || fd.Recv == nil || fd.Name == nil {
- continue
- }
- if fd.Name.Name == funcName {
- fn = fd
- break
- }
- }
- if fn == nil || fn.Body == nil {
- t.Fatalf("could not find function %s", funcName)
- }
-
- lockDepth := 0
- foundCall := false
-
- for _, st := range fn.Body.List {
- // Detect "defer c.mu.Unlock()" which is a strong smell here.
- if ds, ok := st.(*ast.DeferStmt); ok {
- if isCallToMuUnlock(ds.Call) {
- t.Fatalf("%s uses defer mu.Unlock(); expected unlock before calling %s", funcName, callName)
- }
- }
-
- // Track c.mu.Lock()/Unlock() depth at statement granularity.
- ast.Inspect(st, func(n ast.Node) bool {
- ce, ok := n.(*ast.CallExpr)
- if !ok {
- return true
- }
- if isCallToMuLock(ce) {
- lockDepth++
- }
- if isCallToMuUnlock(ce) {
- if lockDepth > 0 {
- lockDepth--
- }
- }
- if isCallToMethod(ce, callName) {
- foundCall = true
- if lockDepth != 0 {
- pos := fset.Position(ce.Pos())
- t.Fatalf("%s calls %s while holding mu (depth=%d) at %s", funcName, callName, lockDepth, pos)
- }
- }
- return true
- })
- }
-
- if !foundCall {
- t.Fatalf("%s: expected to find a call to %s", funcName, callName)
- }
- }
-
- check("AddEventHandler", "AddEventHandler")
- check("RemoveEventHandler", "RemoveEventHandler")
-}
-
-func isCallToMethod(call *ast.CallExpr, method string) bool {
- if call == nil {
- return false
- }
- sel, ok := call.Fun.(*ast.SelectorExpr)
- return ok && sel.Sel != nil && sel.Sel.Name == method
-}
-
-func isCallToMuLock(call *ast.CallExpr) bool {
- return isCallToSelector(call, "mu", "Lock")
-}
-
-func isCallToMuUnlock(call *ast.CallExpr) bool {
- return isCallToSelector(call, "mu", "Unlock")
-}
-
-func isCallToSelector(call *ast.CallExpr, field, method string) bool {
- if call == nil {
- return false
- }
- sel, ok := call.Fun.(*ast.SelectorExpr)
- if !ok || sel.Sel == nil || sel.Sel.Name != method {
- return false
- }
- xsel, ok := sel.X.(*ast.SelectorExpr)
- if !ok || xsel.Sel == nil || xsel.Sel.Name != field {
- return false
- }
- // We don't care whether it's c.mu or something.mu; the rule is "don't call into whatsmeow while holding mu".
- return true
-}
diff --git a/tools/wacli/internal/wa/media.go b/tools/wacli/internal/wa/media.go
deleted file mode 100644
index d5d55fc..0000000
--- a/tools/wacli/internal/wa/media.go
+++ /dev/null
@@ -1,87 +0,0 @@
-package wa
-
-import (
- "context"
- "fmt"
- "math"
- "os"
- "path/filepath"
- "strings"
-
- "go.mau.fi/whatsmeow"
-)
-
-func MediaTypeFromString(mediaType string) (whatsmeow.MediaType, error) {
- switch strings.ToLower(strings.TrimSpace(mediaType)) {
- case "image":
- return whatsmeow.MediaImage, nil
- case "video":
- return whatsmeow.MediaVideo, nil
- case "audio":
- return whatsmeow.MediaAudio, nil
- case "document":
- return whatsmeow.MediaDocument, nil
- case "sticker":
- return whatsmeow.MediaImage, nil
- default:
- return "", fmt.Errorf("unsupported media type: %s", mediaType)
- }
-}
-
-func (c *Client) DownloadMediaToFile(ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength uint64, mediaType, mmsType string, targetPath string) (int64, error) {
- c.mu.Lock()
- cli := c.client
- c.mu.Unlock()
- if cli == nil || !cli.IsConnected() {
- return 0, fmt.Errorf("not connected")
- }
- if strings.TrimSpace(directPath) == "" {
- return 0, fmt.Errorf("direct path is required")
- }
- mt, err := MediaTypeFromString(mediaType)
- if err != nil {
- return 0, err
- }
-
- if err := os.MkdirAll(filepath.Dir(targetPath), 0700); err != nil {
- return 0, fmt.Errorf("create output dir: %w", err)
- }
-
- tmpFile, err := os.CreateTemp(filepath.Dir(targetPath), ".wacli-download-*")
- if err != nil {
- return 0, fmt.Errorf("create temp file: %w", err)
- }
- tmpName := tmpFile.Name()
- success := false
- defer func() {
- _ = tmpFile.Close()
- if !success {
- _ = os.Remove(tmpName)
- }
- }()
-
- length := -1
- if fileLength > 0 && fileLength < math.MaxInt32 {
- length = int(fileLength)
- }
-
- if err := cli.DownloadMediaWithPathToFile(ctx, directPath, encFileHash, fileHash, mediaKey, length, mt, mmsType, tmpFile); err != nil {
- return 0, err
- }
- if err := tmpFile.Sync(); err != nil {
- return 0, fmt.Errorf("flush temp file: %w", err)
- }
- if err := tmpFile.Close(); err != nil {
- return 0, fmt.Errorf("close temp file: %w", err)
- }
- if err := os.Rename(tmpName, targetPath); err != nil {
- return 0, fmt.Errorf("move media file: %w", err)
- }
- success = true
-
- info, err := os.Stat(targetPath)
- if err != nil {
- return 0, fmt.Errorf("stat output file: %w", err)
- }
- return info.Size(), nil
-}
diff --git a/tools/wacli/internal/wa/media_test.go b/tools/wacli/internal/wa/media_test.go
deleted file mode 100644
index 8bf21dc..0000000
--- a/tools/wacli/internal/wa/media_test.go
+++ /dev/null
@@ -1,14 +0,0 @@
-package wa
-
-import "testing"
-
-func TestMediaTypeFromString(t *testing.T) {
- for _, tc := range []string{"image", "video", "audio", "document"} {
- if _, err := MediaTypeFromString(tc); err != nil {
- t.Fatalf("expected %s to be supported: %v", tc, err)
- }
- }
- if _, err := MediaTypeFromString("nope"); err == nil {
- t.Fatalf("expected error for unsupported type")
- }
-}
diff --git a/tools/wacli/internal/wa/messages.go b/tools/wacli/internal/wa/messages.go
deleted file mode 100644
index 976378a..0000000
--- a/tools/wacli/internal/wa/messages.go
+++ /dev/null
@@ -1,279 +0,0 @@
-package wa
-
-import (
- "strings"
- "time"
-
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
-)
-
-type Media struct {
- Type string
- Caption string
- Filename string
- MimeType string
- DirectPath string
- MediaKey []byte
- FileSHA256 []byte
- FileEncSHA256 []byte
- FileLength uint64
-}
-
-type ParsedMessage struct {
- Chat types.JID
- ID string
- SenderJID string
- Timestamp time.Time
- FromMe bool
- Text string
- Media *Media
- PushName string
- ReplyToID string
- ReplyToDisplay string
- ReactionToID string
- ReactionEmoji string
-}
-
-func ParseLiveMessage(evt *events.Message) ParsedMessage {
- msg := ParsedMessage{
- Chat: evt.Info.Chat,
- ID: evt.Info.ID,
- Timestamp: evt.Info.Timestamp,
- FromMe: evt.Info.IsFromMe,
- PushName: evt.Info.PushName,
- }
- if s := evt.Info.Sender.String(); s != "" {
- msg.SenderJID = s
- }
-
- extractWAProto(evt.Message, &msg)
- return msg
-}
-
-func ParseHistoryMessage(chatJID string, hist *waProto.WebMessageInfo) ParsedMessage {
- var chat types.JID
- if parsed, err := types.ParseJID(chatJID); err == nil {
- chat = parsed
- }
-
- pm := ParsedMessage{
- Chat: chat,
- ID: hist.GetKey().GetID(),
- Timestamp: time.Unix(int64(hist.GetMessageTimestamp()), 0).UTC(),
- FromMe: hist.GetKey().GetFromMe(),
- }
-
- sender := strings.TrimSpace(hist.GetKey().GetParticipant())
- if sender == "" {
- sender = strings.TrimSpace(hist.GetKey().GetRemoteJID())
- }
- pm.SenderJID = sender
-
- if hist.GetMessage() != nil {
- extractWAProto(hist.GetMessage(), &pm)
- }
- return pm
-}
-
-func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
- if m == nil || pm == nil {
- return
- }
-
- if reaction := m.GetReactionMessage(); reaction != nil {
- pm.ReactionEmoji = reaction.GetText()
- if key := reaction.GetKey(); key != nil {
- pm.ReactionToID = key.GetID()
- }
- } else if encReaction := m.GetEncReactionMessage(); encReaction != nil {
- if key := encReaction.GetTargetMessageKey(); key != nil {
- pm.ReactionToID = key.GetID()
- }
- }
-
- switch {
- case m.GetConversation() != "":
- pm.Text = m.GetConversation()
- case m.GetExtendedTextMessage() != nil:
- pm.Text = m.GetExtendedTextMessage().GetText()
- }
-
- if img := m.GetImageMessage(); img != nil {
- if pm.Text == "" {
- pm.Text = img.GetCaption()
- }
- pm.Media = &Media{
- Type: "image",
- Caption: img.GetCaption(),
- MimeType: img.GetMimetype(),
- DirectPath: img.GetDirectPath(),
- MediaKey: clone(img.GetMediaKey()),
- FileSHA256: clone(img.GetFileSHA256()),
- FileEncSHA256: clone(img.GetFileEncSHA256()),
- FileLength: img.GetFileLength(),
- }
- }
-
- if vid := m.GetVideoMessage(); vid != nil {
- if pm.Text == "" {
- pm.Text = vid.GetCaption()
- }
- mediaType := "video"
- if vid.GetGifPlayback() {
- mediaType = "gif"
- }
- pm.Media = &Media{
- Type: mediaType,
- Caption: vid.GetCaption(),
- MimeType: vid.GetMimetype(),
- DirectPath: vid.GetDirectPath(),
- MediaKey: clone(vid.GetMediaKey()),
- FileSHA256: clone(vid.GetFileSHA256()),
- FileEncSHA256: clone(vid.GetFileEncSHA256()),
- FileLength: vid.GetFileLength(),
- }
- }
-
- if aud := m.GetAudioMessage(); aud != nil {
- if pm.Text == "" {
- pm.Text = "[Audio]"
- }
- pm.Media = &Media{
- Type: "audio",
- Caption: pm.Text,
- MimeType: aud.GetMimetype(),
- DirectPath: aud.GetDirectPath(),
- MediaKey: clone(aud.GetMediaKey()),
- FileSHA256: clone(aud.GetFileSHA256()),
- FileEncSHA256: clone(aud.GetFileEncSHA256()),
- FileLength: aud.GetFileLength(),
- }
- }
-
- if doc := m.GetDocumentMessage(); doc != nil {
- if pm.Text == "" {
- pm.Text = doc.GetCaption()
- }
- pm.Media = &Media{
- Type: "document",
- Caption: doc.GetCaption(),
- Filename: doc.GetFileName(),
- MimeType: doc.GetMimetype(),
- DirectPath: doc.GetDirectPath(),
- MediaKey: clone(doc.GetMediaKey()),
- FileSHA256: clone(doc.GetFileSHA256()),
- FileEncSHA256: clone(doc.GetFileEncSHA256()),
- FileLength: doc.GetFileLength(),
- }
- }
-
- if sticker := m.GetStickerMessage(); sticker != nil {
- pm.Media = &Media{
- Type: "sticker",
- MimeType: sticker.GetMimetype(),
- DirectPath: sticker.GetDirectPath(),
- MediaKey: clone(sticker.GetMediaKey()),
- FileSHA256: clone(sticker.GetFileSHA256()),
- FileEncSHA256: clone(sticker.GetFileEncSHA256()),
- FileLength: sticker.GetFileLength(),
- }
- }
-
- if ctx := contextInfoForMessage(m); ctx != nil {
- if id := strings.TrimSpace(ctx.GetStanzaID()); id != "" {
- pm.ReplyToID = id
- }
- if quoted := ctx.GetQuotedMessage(); quoted != nil {
- pm.ReplyToDisplay = strings.TrimSpace(displayTextForProto(quoted))
- }
- }
-}
-
-func clone(b []byte) []byte {
- if len(b) == 0 {
- return nil
- }
- out := make([]byte, len(b))
- copy(out, b)
- return out
-}
-
-func contextInfoForMessage(m *waProto.Message) *waProto.ContextInfo {
- if m == nil {
- return nil
- }
- if ext := m.GetExtendedTextMessage(); ext != nil {
- return ext.GetContextInfo()
- }
- if img := m.GetImageMessage(); img != nil {
- return img.GetContextInfo()
- }
- if vid := m.GetVideoMessage(); vid != nil {
- return vid.GetContextInfo()
- }
- if aud := m.GetAudioMessage(); aud != nil {
- return aud.GetContextInfo()
- }
- if doc := m.GetDocumentMessage(); doc != nil {
- return doc.GetContextInfo()
- }
- if sticker := m.GetStickerMessage(); sticker != nil {
- return sticker.GetContextInfo()
- }
- if loc := m.GetLocationMessage(); loc != nil {
- return loc.GetContextInfo()
- }
- if contact := m.GetContactMessage(); contact != nil {
- return contact.GetContextInfo()
- }
- if contacts := m.GetContactsArrayMessage(); contacts != nil {
- return contacts.GetContextInfo()
- }
- return nil
-}
-
-func displayTextForProto(m *waProto.Message) string {
- if m == nil {
- return ""
- }
-
- if img := m.GetImageMessage(); img != nil {
- return "Sent image"
- }
- if vid := m.GetVideoMessage(); vid != nil {
- if vid.GetGifPlayback() {
- return "Sent gif"
- }
- return "Sent video"
- }
- if aud := m.GetAudioMessage(); aud != nil {
- return "Sent audio"
- }
- if doc := m.GetDocumentMessage(); doc != nil {
- return "Sent document"
- }
- if sticker := m.GetStickerMessage(); sticker != nil {
- return "Sent sticker"
- }
- if loc := m.GetLocationMessage(); loc != nil {
- return "Sent location"
- }
- if contact := m.GetContactMessage(); contact != nil {
- return "Sent contact"
- }
- if contacts := m.GetContactsArrayMessage(); contacts != nil {
- return "Sent contacts"
- }
-
- if text := strings.TrimSpace(m.GetConversation()); text != "" {
- return text
- }
- if ext := m.GetExtendedTextMessage(); ext != nil {
- if text := strings.TrimSpace(ext.GetText()); text != "" {
- return text
- }
- }
- return ""
-}
diff --git a/tools/wacli/internal/wa/messages_test.go b/tools/wacli/internal/wa/messages_test.go
deleted file mode 100644
index e02ba6c..0000000
--- a/tools/wacli/internal/wa/messages_test.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package wa
-
-import (
- "testing"
- "time"
-
- waProto "go.mau.fi/whatsmeow/binary/proto"
- "go.mau.fi/whatsmeow/types"
- "go.mau.fi/whatsmeow/types/events"
- "google.golang.org/protobuf/proto"
-)
-
-func TestParseHistoryMessageTextAndSender(t *testing.T) {
- h := &waProto.WebMessageInfo{
- Key: &waProto.MessageKey{
- ID: proto.String("msgid"),
- FromMe: proto.Bool(false),
- Participant: proto.String("sender@s.whatsapp.net"),
- },
- MessageTimestamp: proto.Uint64(uint64(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC).Unix())),
- Message: &waProto.Message{Conversation: proto.String("hello")},
- }
- pm := ParseHistoryMessage("123@s.whatsapp.net", h)
- if pm.ID != "msgid" || pm.Text != "hello" {
- t.Fatalf("unexpected parsed msg: %+v", pm)
- }
- if pm.SenderJID != "sender@s.whatsapp.net" {
- t.Fatalf("unexpected sender: %q", pm.SenderJID)
- }
- if pm.Chat.String() != "123@s.whatsapp.net" {
- t.Fatalf("unexpected chat: %q", pm.Chat.String())
- }
-}
-
-func TestParseLiveMessageImageClonesBytes(t *testing.T) {
- chat, _ := types.ParseJID("123@s.whatsapp.net")
- sender, _ := types.ParseJID("sender@s.whatsapp.net")
-
- key := []byte{1, 2, 3}
- img := &waProto.ImageMessage{
- Caption: proto.String("cap"),
- Mimetype: proto.String("image/jpeg"),
- DirectPath: proto.String("/direct"),
- MediaKey: key,
- FileSHA256: []byte{4},
- FileEncSHA256: []byte{5},
- FileLength: proto.Uint64(10),
- }
- ev := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: sender,
- IsFromMe: false,
- },
- ID: "mid",
- Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
- PushName: "Sender",
- },
- Message: &waProto.Message{ImageMessage: img},
- }
-
- pm := ParseLiveMessage(ev)
- if pm.ID != "mid" || pm.Media == nil || pm.Media.Type != "image" {
- t.Fatalf("unexpected parsed: %+v", pm)
- }
- if pm.Text != "cap" {
- t.Fatalf("expected text from caption, got %q", pm.Text)
- }
-
- // Ensure clone() was used (pm.Media.MediaKey should not alias key).
- key[0] = 9
- if pm.Media.MediaKey[0] == 9 {
- t.Fatalf("expected MediaKey to be cloned")
- }
-}
-
-func TestParseLiveMessageReaction(t *testing.T) {
- chat, _ := types.ParseJID("123@s.whatsapp.net")
- sender, _ := types.ParseJID("sender@s.whatsapp.net")
-
- ev := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: sender,
- IsFromMe: false,
- },
- ID: "mid",
- Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
- PushName: "Sender",
- },
- Message: &waProto.Message{
- ReactionMessage: &waProto.ReactionMessage{
- Text: proto.String("👍"),
- Key: &waProto.MessageKey{ID: proto.String("orig")},
- },
- },
- }
-
- pm := ParseLiveMessage(ev)
- if pm.ReactionEmoji != "👍" || pm.ReactionToID != "orig" {
- t.Fatalf("unexpected reaction parse: %+v", pm)
- }
-}
-
-func TestParseLiveMessageReply(t *testing.T) {
- chat, _ := types.ParseJID("123@s.whatsapp.net")
- sender, _ := types.ParseJID("sender@s.whatsapp.net")
-
- ev := &events.Message{
- Info: types.MessageInfo{
- MessageSource: types.MessageSource{
- Chat: chat,
- Sender: sender,
- IsFromMe: false,
- },
- ID: "mid",
- Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
- PushName: "Sender",
- },
- Message: &waProto.Message{
- ExtendedTextMessage: &waProto.ExtendedTextMessage{
- Text: proto.String("reply text"),
- ContextInfo: &waProto.ContextInfo{
- StanzaID: proto.String("orig"),
- QuotedMessage: &waProto.Message{
- Conversation: proto.String("quoted"),
- },
- },
- },
- },
- }
-
- pm := ParseLiveMessage(ev)
- if pm.ReplyToID != "orig" {
- t.Fatalf("expected ReplyToID to be orig, got %q", pm.ReplyToID)
- }
- if pm.ReplyToDisplay != "quoted" {
- t.Fatalf("expected ReplyToDisplay to be quoted, got %q", pm.ReplyToDisplay)
- }
-}
diff --git a/tools/wacli/package.json b/tools/wacli/package.json
deleted file mode 100644
index 85bd633..0000000
--- a/tools/wacli/package.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "name": "wacli",
- "private": true,
- "packageManager": "pnpm@10.23.0",
- "scripts": {
- "wacli": "bash -lc 'pnpm -s build >/dev/null && exec ./dist/wacli \"$@\"' _",
- "build": "mkdir -p dist && CGO_CFLAGS=\"${CGO_CFLAGS:+$CGO_CFLAGS }-Wno-error=missing-braces\" go build -tags sqlite_fts5 -o dist/wacli ./cmd/wacli",
- "start": "pnpm -s build && ./dist/wacli",
- "test": "pnpm -s test:go && pnpm -s test:fts",
- "test:go": "go test ./...",
- "test:fts": "go test -tags sqlite_fts5 ./...",
- "lint": "go vet ./...",
- "format": "gofmt -w .",
- "format:check": "bash -lc 'out=$(gofmt -l .); if [ -n \"$out\" ]; then echo \"$out\"; exit 1; fi'"
- }
-}
diff --git a/tools/wacli/pnpm-lock.yaml b/tools/wacli/pnpm-lock.yaml
deleted file mode 100644
index 9b60ae1..0000000
--- a/tools/wacli/pnpm-lock.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-lockfileVersion: '9.0'
-
-settings:
- autoInstallPeers: true
- excludeLinksFromLockfile: false
-
-importers:
-
- .: {}