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: - - .: {}