Compare commits

..

No commits in common. "main" and "v0.7.0" have entirely different histories.
main ... v0.7.0

115 changed files with 532 additions and 2172 deletions

View File

@ -1,120 +0,0 @@
---
name: wacli
description: "Use when explicitly working with wacli: linked-device WhatsApp accounts, local stores, sync/auth/send behavior, and wacli repo/release work."
---
# Wacli
Use this for `wacli` repo work and local WhatsApp linked-device stores. Prefer read-only commands for inspection unless the user explicitly asks to auth, sync, send, mutate chats/groups, or release.
## Sources
- Repo: `~/Projects/wacli`
- CLI in repo: `./dist/wacli` after `pnpm build`
- Installed CLI: `wacli`
- Default config: `~/.wacli/config.yaml`
- Default macOS store: `~/.wacli`
- Named account stores: `~/.wacli/accounts/<name>`
- App DB: `<store>/wacli.db`
- WhatsApp session DB: `<store>/session.db`
## Safety
- Use `--read-only` or `WACLI_READONLY=1` for inspection.
- Use `--json` for parsing.
- Do not send messages unless explicitly asked.
- Do not write `session.db` directly.
- Do not merge account data into one `wacli.db`; named accounts are isolated stores.
- Watch dirty worktrees; leave unrelated files alone.
## Account Workflow
List accounts and store paths:
```bash
wacli accounts list --json
```
Inspect one account without connecting:
```bash
wacli --account me doctor --read-only --json
wacli --account me auth status --read-only --json
```
Use `--account NAME` for normal multi-account work. Use `--store DIR` only for one-off legacy/manual store debugging.
## Message/Store Checks
Prefer CLI first:
```bash
wacli --account me messages list --read-only --json --limit 20
wacli --account me messages search --read-only --json "query"
wacli --account me chats list --read-only --json
```
For DB health or aggregate checks, use SQLite read-only where possible:
```bash
sqlite3 "$HOME/.wacli/accounts/me/wacli.db" "pragma integrity_check;"
sqlite3 "$HOME/.wacli/accounts/me/wacli.db" \
"select count(*) from messages;
select count(*) from messages_fts;"
```
Useful consistency checks:
```sql
select count(*) from (
select chat_jid, msg_id, count(*) c
from messages
group by chat_jid, msg_id
having c > 1
);
select count(*)
from messages m
left join chats c on c.jid = m.chat_jid
where c.jid is null;
select count(*) from messages where revoked = 0 and deleted_for_me = 0;
select count(*) from messages_fts;
```
## Sync/Auth UX
`auth` pairs and then bootstraps sync. `sync` never shows QR and requires an authenticated store.
Common commands:
```bash
wacli --account me auth
wacli --account me sync --once
wacli --account me sync --follow
wacli --account me sync --once --events 2>events.ndjson
```
Interactive TTY sync progress should be concise; warnings must remain visible. `--events` must keep stderr as NDJSON.
## Repo Workflow
Read docs before coding when behavior changes:
```bash
pnpm -s docs:list || bin/docs-list || true
```
Focused tests first, then full gate:
```bash
go test ./internal/app
go test ./internal/store
pnpm docs:site && pnpm format:check && pnpm lint && pnpm test && pnpm build && git diff --check
```
User-facing changes need docs and `CHANGELOG.md`. Use `committer` with explicit file paths.
## Release
Read `docs/release.md` before release work. Release is tag-driven; verify workflow state with `gh run list/view`. If a release workflow is cancelled or partially failed, state exactly which jobs completed and which did not.

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @dinakars777

View File

@ -41,38 +41,3 @@ jobs:
env:
CGO_ENABLED: "1"
run: pnpm -s build
linux-release-builds:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup CI Environment
uses: ./.github/actions/setup-ci-env
with:
go-version-file: go.mod
apt-packages: "build-essential gcc-aarch64-linux-gnu libc6-dev-arm64-cross"
- name: GoReleaser check (macOS)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check --config .goreleaser.yaml
- name: GoReleaser check (linux/windows)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check --config .goreleaser-linux-windows.yaml
- name: GoReleaser build (linux amd64/arm64)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: build --snapshot --clean --config .goreleaser-linux-windows.yaml --id wacli_linux_amd64 --id wacli_linux_arm64

View File

@ -36,15 +36,13 @@ jobs:
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout -- "$RELEASE_TAG"
run: git checkout ${{ inputs.tag }}
- name: GoReleaser (macOS universal)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
version: latest
args: release --clean --config /tmp/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@ -54,15 +52,11 @@ jobs:
needs: goreleaser-darwin
steps:
- name: Resolve release tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=$INPUT_TAG" >> "$GITHUB_ENV"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> $GITHUB_ENV
else
echo "RELEASE_TAG=$REF_NAME" >> "$GITHUB_ENV"
echo "RELEASE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
fi
- name: Dispatch tap formula update
@ -70,8 +64,8 @@ jobs:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::warning::Skipping Homebrew tap update because HOMEBREW_TAP_TOKEN is not configured with workflow access to steipete/homebrew-tap"
exit 0
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
exit 1
fi
request_id="wacli-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
@ -82,7 +76,7 @@ jobs:
--ref main \
-f formula=wacli \
-f tag="$RELEASE_TAG" \
-f repository=openclaw/wacli \
-f repository=steipete/wacli \
-f macos_artifact=wacli-macos-universal.tar.gz \
-f request_id="$request_id"
@ -132,29 +126,23 @@ jobs:
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout -- "$RELEASE_TAG"
run: git checkout ${{ inputs.tag }}
- name: GoReleaser (linux/windows)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
version: latest
args: release --clean --skip=publish --config /tmp/.goreleaser-linux-windows.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve release tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=$INPUT_TAG" >> "$GITHUB_ENV"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> $GITHUB_ENV
else
echo "RELEASE_TAG=$REF_NAME" >> "$GITHUB_ENV"
echo "RELEASE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
fi
- name: Upload linux/windows artifacts

View File

@ -58,14 +58,12 @@ builds:
archives:
- id: default
formats:
- tar.gz
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
formats:
- zip
format: zip
files:
- LICENSE
- README.md

View File

@ -29,8 +29,7 @@ universal_binaries:
archives:
- id: default
formats:
- tar.gz
format: tar.gz
name_template: >-
{{ .ProjectName }}-{{ if eq .Os "darwin" }}macos{{ else }}{{ .Os }}{{ end }}-{{ if eq .Arch "all" }}universal{{ else }}{{ .Arch }}{{ end }}
files:

View File

@ -1,40 +1,5 @@
# Changelog
## 0.8.1 - 2026-05-08
### Changed
- Module: migrate the canonical Go module/import path to `github.com/openclaw/wacli`. (#217 - thanks @dinakars777)
- Sync: collapse routine interactive TTY progress into a single updating status line while keeping warnings visible as normal stderr lines.
### Chore
- CI: make the Homebrew tap handoff use `openclaw/wacli` and skip gracefully when the tap token is missing. (#216 - thanks @dinakars777)
- Maintainers: remove the stale personal CODEOWNERS rule after the OpenClaw move. (#218 - thanks @dinakars777)
- Release: update GoReleaser archive config to the current v2 schema so release-config checks stay green.
### Fixed
- CLI: truncate table output by rune so emoji and other non-ASCII text stay valid UTF-8. (#222 - thanks @dinakars777)
- History: apply coverage/actionable filters before `LIMIT` so newer blocked chats do not hide ready chats. (#219 - thanks @dinakars777)
- Messages: extract display/search text from shared WhatsApp contact cards, including vCard phone numbers. (#214)
- Send: route whatsmeow diagnostics to stderr and clarify that `sent: true` means WhatsApp accepted the send request. (#215 - thanks @dinakars777)
- Sync: let explicit `--max-messages=0` override `WACLI_SYNC_MAX_MESSAGES`. (#220 - thanks @dinakars777)
## 0.8.0 - 2026-05-07
### Added
- Accounts: add first-class named WhatsApp accounts with isolated stores, `--account NAME`, and `wacli accounts list/add/use/show/remove`.
### Fixed
- Store: fix migration of legacy databases whose `groups` table existed before group hierarchy columns were introduced.
### Docs
- Docs: add a dedicated accounts page covering YAML config, store selection precedence, and multi-account usage.
## 0.7.0 - 2026-05-06
### Added

393
README.md
View File

@ -1,139 +1,322 @@
# 🗃️ wacli — WhatsApp CLI: sync, search, send
A scriptable WhatsApp client built on [`whatsmeow`](https://github.com/tulir/whatsmeow). Pairs as a linked WhatsApp Web device, mirrors your messages into a local SQLite store, and gives you offline search, sending, and chat/group/contact management from the command line.
WhatsApp CLI built on top of `whatsmeow`, focused on:
> Third-party tool. Uses the WhatsApp Web protocol via `whatsmeow`. Not affiliated with WhatsApp.
- Best-effort local sync of message history + continuous capture
- Fast offline search
- Sending text, mentions, quoted replies, and files
- Contact + group management
- Scriptable JSON output
Full documentation: **<https://wacli.sh>**
This is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow` and is not affiliated with WhatsApp.
## Features
## Status
- **Auth + sync** — QR pairing, one-shot or follow-mode sync, optional media downloads, optional signed webhook fan-out.
- **Offline message store** — SQLite with FTS5 search (LIKE fallback), filterable by chat, sender, direction, time, and media type.
- **Sending** — text with mentions/replies/link-previews, files (image/video/audio/document, ≤100 MiB), stickers, voice notes, reactions; rapid-send guardrails and retry-receipt grace.
- **History backfill** — best-effort per-chat requests to your primary device for older messages.
- **Contacts / chats / groups / channels** — search, alias, tag, archive, pin, mute, mark-read, rename, prune, manage participants and invite links, send to channels.
- **Diagnostics + safety**`doctor`, read-only mode, store locks with owner reporting, panic recovery, bounded media queue, owner-only DB perms.
- **Scriptable**`--json` everywhere, `--events` NDJSON lifecycle stream, deterministic exit codes.
## Install
### Homebrew (recommended)
```bash
brew install steipete/tap/wacli
```
If a Linux install reports `Binary was compiled with 'CGO_ENABLED=0'`, run `brew update && brew reinstall steipete/tap/wacli`.
### Build from source
`wacli` uses `go-sqlite3`, so cgo + a C compiler are required.
- macOS: Xcode Command Line Tools.
- Debian/Ubuntu: `sudo apt install build-essential`.
```bash
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
```
For local development:
```bash
git clone https://github.com/openclaw/wacli.git
cd wacli
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
./dist/wacli --help
```
## Quick start
```bash
# 1. Pair (shows QR), then bootstrap sync
wacli auth
# 2. Keep syncing in the background (no QR; needs prior auth)
wacli sync --follow
# 3. Search
wacli messages search "meeting"
# 4. Send
wacli send text --to 1234567890 --message "hello"
wacli send file --to mom --file ./pic.jpg --caption "hi"
# 5. Diagnostics
wacli doctor
```
Recipients accept a JID, phone number (E.164 or formatted), channel JID, or a synced contact/group/chat name. Ambiguous names prompt in a TTY; pass `--pick N` in scripts.
More recipes — replies, mentions, stickers, voice, reactions, channels, history backfill, chat management — live in the [docs](https://wacli.sh).
Core implementation is in place. The full documentation site lives at [wacli.sh](https://wacli.sh). Start with [docs/overview.md](docs/overview.md) for the command map and [docs/spec.md](docs/spec.md) for design notes.
## Documentation
| Area | Pages |
| --- | --- |
| **Setup** | [overview](docs/overview.md) · [auth](docs/auth.md) · [accounts](docs/accounts.md) · [sync](docs/sync.md) · [doctor](docs/doctor.md) |
| **Messaging** | [messages](docs/messages.md) · [send](docs/send.md) · [media](docs/media.md) · [presence](docs/presence.md) |
| **Address book** | [contacts](docs/contacts.md) · [chats](docs/chats.md) · [groups](docs/groups.md) · [channels](docs/channels.md) |
| **History** | [history coverage / fill / backfill](docs/history.md) |
| **Local store** | [store](docs/store.md) · [companion integrations](docs/integrations.md) |
| **Misc** | [profile](docs/profile.md) · [version](docs/version.md) · [completion](docs/completion.md) · [release](docs/release.md) |
Full docs site: <https://wacli.sh>.
## Configuration
- [Overview](docs/overview.md): store model, global flags, common flow, command index.
- [Companion integrations](docs/integrations.md): safe read-only SQLite, JSON, events, and webhook integration patterns.
- [Auth](docs/auth.md): `auth`, `auth status`, `auth logout`.
- [Sync](docs/sync.md): `sync --once`, `sync --follow`, refresh, media download.
- [Messages](docs/messages.md): `messages list/search/starred/show/context/export/edit/delete`.
- [Send](docs/send.md): `send text/file/sticker/voice/react`, recipient resolution, replies.
- [Media](docs/media.md): `media download`.
- [Contacts](docs/contacts.md): `contacts search/show/refresh/import-system`, aliases, tags.
- [Chats](docs/chats.md): `chats list/show`, archive, pin, mute, mark read.
- [Groups](docs/groups.md): group list, refresh, info, rename, leave, participants, invites, join.
- [Store](docs/store.md): local store stats and cleanup commands.
- [Channels](docs/channels.md): `channels list/info/join/leave`, plus sending to channel JIDs.
- [History](docs/history.md): `history coverage`, `history fill --dry-run`, `history backfill`.
- [Presence](docs/presence.md): `presence typing/paused`.
- [Profile](docs/profile.md): `profile set-picture`.
- [Doctor](docs/doctor.md): `doctor [--connect]`.
- [Docs](docs/docs.md): print the hosted documentation URL.
- [Version](docs/version.md): `version`, `--version`.
- [Completion](docs/completion.md): generated shell completions.
- [Help](docs/help.md): `help`, per-command `--help`.
- [Release](docs/release.md): release workflow and artifact expectations.
Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere. Existing `~/.wacli` directories on Linux keep working. Use `wacli accounts add NAME` and `--account NAME` for first-class multi-account stores.
## Major features
**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
- **Auth + sync**: `auth` shows QR login and bootstraps sync; `sync` is non-interactive, can run once or follow continuously, and can refresh contacts/groups.
- **Offline message store**: local SQLite store with FTS5 search when available and LIKE fallback.
- **Message tools**: list/search/show/context with chat, sender, direction, time, order, and media-type filters.
- **Sending**: send text, mentions, quoted replies, stickers, and image/video/audio/document files with captions, MIME override, and custom display filenames. Sends keep a short retry-receipt grace window, and rapid repeated sends warn on stderr.
- **Media**: download synced message media on demand, or download in the background during auth/sync; send-file uploads and downloads are capped at 100 MiB.
- **Contacts/chats/groups/store/channels**: search/show contacts, import macOS Contacts names, local aliases/tags, list/show/filter chats, archive/pin/mute/mark-read chats, refresh/list/info/rename/prune groups, inspect/prune the local store, manage participants, invite links, join, leave, and manage WhatsApp Channels.
- **Presence**: send typing/paused indicators.
- **Profile**: set the authenticated account profile picture from JPEG or PNG input.
- **Diagnostics + safety**: `doctor`, read-only mode, store locks with lock-owner reporting, lock waiting, owner-only database permissions, panic recovery, reconnect bounds, and bounded media queue backpressure.
- **CLI UX**: human-readable tables by default; `--json` for scripts; `--full` to avoid truncation.
**Environment overrides:**
## Install / Build
| Variable | Effect |
| --- | --- |
| `WACLI_STORE_DIR` | Default store directory. |
| `WACLI_READONLY` | `1`/`true`/`yes`/`on` enables read-only mode. |
| `WACLI_DEVICE_LABEL` | Linked-device label shown in WhatsApp. Defaults to `wacli - <OS> (<host>)`. |
| `WACLI_DEVICE_PLATFORM` | Linked-device platform. Defaults to `DESKTOP`; invalid values fall back to `CHROME`. |
| `WACLI_SYNC_MAX_MESSAGES` | Stop sync once total local messages exceed this count. |
| `WACLI_SYNC_MAX_DB_SIZE` | Stop sync once `wacli.db` + sidecars reach a size like `500MB` or `2GB`. |
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`
If a Linux install from the tap reports `Binary was compiled with 'CGO_ENABLED=0'`,
update the tap and rebuild the formula:
- `brew update`
- `brew reinstall steipete/tap/wacli`
### Option B: Build locally
`wacli` uses `go-sqlite3`, so local builds require cgo and a C compiler:
- macOS: Xcode Command Line Tools are enough.
- Debian/Ubuntu: `sudo apt install build-essential`
Build:
- `CGO_ENABLED=1 go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli`
Run (local build only):
- `./dist/wacli --help`
## Quick start
Default store directory is the XDG state directory on Linux (`~/.local/state/wacli`) and `~/.wacli` elsewhere. Existing Linux `~/.wacli` stores keep working; override with `--store DIR` or `WACLI_STORE_DIR`.
```bash
# 1) Authenticate (shows QR), then bootstrap sync
pnpm wacli auth
# or, after building locally: ./dist/wacli auth
# 2) Keep syncing (never shows QR; requires prior auth)
pnpm wacli sync --follow
# Optionally POST live messages to a signed webhook
pnpm wacli sync --follow --webhook https://example.com/wacli --webhook-secret "$WACLI_WEBHOOK_SECRET"
# Diagnostics
pnpm wacli doctor
# Search messages
pnpm wacli messages search "meeting"
# List recent messages from a chat, oldest first
pnpm wacli messages list --chat 1234567890@s.whatsapp.net --asc
# Show context around a message
pnpm wacli messages context --chat 1234567890@s.whatsapp.net --id <message-id>
# Export messages to JSON with a time window
pnpm wacli messages export --chat 1234567890@s.whatsapp.net --after 2024-01-01 --before 2024-02-01 --output messages.json
# Edit or delete your own sent messages
pnpm wacli messages edit --chat 1234567890@s.whatsapp.net --id <message-id> --message "updated text"
pnpm wacli messages delete --chat 1234567890@s.whatsapp.net --id <message-id>
# Backfill older messages for a chat (best-effort; requires your primary device online)
pnpm wacli history coverage --include-blocked
pnpm wacli history fill --dry-run --query family
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
# Download media for a message (after syncing)
pnpm wacli media download --chat 1234567890@s.whatsapp.net --id <message-id>
# Filter and manage chat state
pnpm wacli chats list --pinned
pnpm wacli chats mute --chat mom --duration 8h
pnpm wacli chats mark-read --chat 1234567890@s.whatsapp.net
# Send a message by phone/JID, or by a synced contact/group/chat name
pnpm wacli send text --to 1234567890 --message "hello"
# Link previews are added automatically for the first http(s) URL; use --no-preview to skip.
pnpm wacli send text --to 1234567890 --message "https://example.com" --no-preview
# Mention one or more users in a group text.
pnpm wacli send text --to "Family" --message "hey @15551234567" --mention +15551234567
# Phone numbers can also be passed as +E164 or formatted input like "+1 (234) 567-8900"
pnpm wacli send text --to mom --message "hello"
pnpm wacli send text --to "Family" --pick 2 --message "hello"
# Send a quoted reply
pnpm wacli send text --to 1234567890 --message "replying" --reply-to <message-id>
# Send a file
pnpm wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
# Send a quoted reply with a file
pnpm wacli send file --to 1234567890 --file ./pic.jpg --caption "replying" --reply-to <message-id>
# Or override display name
pnpm wacli send file --to 1234567890 --file /tmp/abc123 --filename report.pdf
# Send a 512x512 WebP sticker
pnpm wacli send sticker --to 1234567890 --file ./sticker-512.webp
# Send an OGG/Opus audio file as a native WhatsApp voice note
pnpm wacli send voice --to 1234567890 --file ./voice.ogg
# React to a message (omit --reaction for the default; use --reaction "" to clear)
pnpm wacli send react --to 1234567890 --id <message-id>
# List groups and manage them
pnpm wacli groups list
pnpm wacli groups rename --jid 123456789@g.us --name "New name"
# Preview local cleanup before deleting stale local rows
pnpm wacli store stats
pnpm wacli groups prune --dry-run
pnpm wacli store cleanup --days 365 --dry-run
# List/join channels and send to a channel JID
pnpm wacli channels list
pnpm wacli channels join --invite "https://whatsapp.com/channel/AbCdEfGhIjK"
pnpm wacli send text --to 123456789012345@newsletter --message "Hello channel"
# Send presence indicators
pnpm wacli presence typing --to 1234567890
pnpm wacli presence paused --to 1234567890
```
## 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).
- `wacli sync` warns when local storage is uncapped; use `--max-messages` or `--max-db-size` to bound history growth.
- Output is human-readable by default; pass `--json` for machine-readable output.
- Pass `--full` to keep full IDs in table output; non-TTY output keeps full IDs automatically.
- Pass `--read-only` or set `WACLI_READONLY=1` to block commands that intentionally mutate WhatsApp or the local store.
## Command surface
Full command docs live under [docs/overview.md](docs/overview.md). Quick reference:
- `wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE]`
- `wacli auth status`
- `wacli auth logout`
- `wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-messages N] [--max-db-size SIZE] [--download-media] [--refresh-contacts] [--refresh-groups] [--webhook URL] [--webhook-secret SECRET]`
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded] [--starred]`
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded] [--starred]`
- `wacli messages starred [--chat JID] [--limit N] [--after DATE] [--before DATE] [--asc]`
- `wacli messages export [--chat JID] [--limit N] [--after DATE] [--before DATE] [--output PATH]`
- `wacli messages show --chat JID --id MSG_ID`
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
- `wacli messages edit --chat JID --id MSG_ID --message TEXT [--post-send-wait 2s]`
- `wacli messages delete --chat JID --id MSG_ID [--post-send-wait 2s]`
- `wacli send text --to RECIPIENT --message TEXT [--message-escapes] [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
- `wacli send file --to RECIPIENT --file PATH [--pick N] [--caption TEXT] [--filename NAME] [--mime TYPE] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
- `wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
- `wacli send voice --to RECIPIENT --file PATH [--pick N] [--mime TYPE] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
- `wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID] [--post-send-wait 2s]`
- `wacli media download --chat JID --id MSG_ID [--output PATH]`
- `wacli contacts search <query>`
- `wacli contacts show --jid JID`
- `wacli contacts refresh`
- `wacli contacts import-system [--input FILE] [--dry-run] [--clear]`
- `wacli contacts alias set|rm --jid JID [--alias NAME]`
- `wacli contacts tags add|rm --jid JID --tag TAG`
- `wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]`
- `wacli chats show --jid JID`
- `wacli chats archive|unarchive --chat CHAT [--pick N]`
- `wacli chats pin|unpin --chat CHAT [--pick N]`
- `wacli chats mute --chat CHAT [--duration DURATION] [--pick N]`
- `wacli chats unmute --chat CHAT [--pick N]`
- `wacli chats mark-read|mark-unread --chat CHAT [--pick N]`
- `wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]`
- `wacli groups list [--query TEXT] [--limit N]`
- `wacli groups refresh`
- `wacli groups info --jid GROUP_JID`
- `wacli groups rename --jid GROUP_JID --name NAME`
- `wacli groups leave --jid GROUP_JID`
- `wacli groups participants add|remove|promote|demote --jid GROUP_JID --user PHONE_OR_JID`
- `wacli groups invite link get|revoke --jid GROUP_JID`
- `wacli groups join --code INVITE_CODE`
- `wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]`
- `wacli store stats`
- `wacli store cleanup [--days N] [--dry-run] [--confirm]`
- `wacli channels list`
- `wacli channels info --jid CHANNEL_JID`
- `wacli channels join --invite LINK_OR_CODE`
- `wacli channels leave --jid CHANNEL_JID`
- `wacli history coverage [--include-blocked] [--only-actionable]`
- `wacli history fill --dry-run [--query TEXT] [--kind KIND]`
- `wacli history backfill --chat JID [--count 50] [--requests N]`
- `wacli presence typing --to PHONE_OR_JID [--media audio]`
- `wacli presence paused --to PHONE_OR_JID`
- `wacli profile set-picture IMAGE`
- `wacli doctor [--connect]`
- `wacli docs`
- `wacli version`
- `wacli completion bash|zsh|fish|powershell [--no-descriptions]`
- `wacli help [command]`
`RECIPIENT` for `send text/file/sticker/voice` accepts a JID, phone number, channel JID, or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
## Storage
Defaults to `~/.local/state/wacli` on Linux and `~/.wacli` elsewhere. Existing Linux `~/.wacli` stores are reused when the XDG state store does not exist. Override with `--store DIR`.
Global flags:
- `--store DIR`: store directory.
- `--json`: JSON output.
- `--events`: emit machine-readable NDJSON lifecycle events on stderr for long-running commands, including interrupt signals and command errors.
- `--full`: disable table truncation.
- `--timeout DURATION`: timeout for non-sync commands.
- `--lock-wait DURATION`: wait for the store lock before failing write commands.
- `--read-only`: reject commands that intentionally write WhatsApp or the local store.
## Environment overrides
- `WACLI_DEVICE_LABEL`: override the linked device label shown in WhatsApp (defaults to `wacli - <OS> (<hostname>)` when detectable).
- `WACLI_DEVICE_PLATFORM`: override the linked device platform (defaults to `DESKTOP`; invalid values fall back to `CHROME`).
- `WACLI_READONLY`: set to `1`, `true`, `yes`, or `on` to enable read-only mode.
- `WACLI_SYNC_MAX_MESSAGES`: stop `auth` bootstrap sync or `sync` before storing more than this many total local messages.
- `WACLI_SYNC_MAX_DB_SIZE`: stop `auth` bootstrap sync or `sync` when `wacli.db` plus SQLite sidecars reaches a size such as `500MB` or `2GB`.
- `WACLI_STORE_DIR`: override the default store directory.
## Backfilling older history
`wacli sync` only stores what WhatsApp Web sends opportunistically. To fetch *older* messages, `wacli` issues on-demand history requests to your **primary device** (your phone), which must be online.
`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).
- Best-effort: WhatsApp may not return full history.
- One request anchors on the **oldest locally stored message** in that chat — run `sync` first.
- Recommended `--count 50` per request (max 500). Max `--requests 100` per run.
- `history coverage` shows which chats are eligible. `history fill --dry-run` plans without connecting.
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.
- `history coverage` shows which chats have a local message anchor; blocked chats need `wacli sync` before backfill can request older messages.
- `history fill --dry-run` plans matching chats only. It does not connect to WhatsApp or mutate the store.
- Backfill skips automatic initial history-sync blob downloads and only processes on-demand responses, which keeps memory use bounded on small Linux/ARM devices.
- Recommended `--count` is `50` per request; maximum is `500`.
- Maximum `--requests` per run is `100`.
### Backfill one chat
```bash
wacli history coverage --include-blocked
wacli history fill --dry-run --kind group --limit 20
wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
pnpm wacli history coverage --include-blocked
pnpm wacli history fill --dry-run --kind group --limit 20
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
```
Loop over every known chat:
### Backfill all chats (script)
This loops through chats already known in your local DB:
```bash
wacli --json chats list --limit 100000 \
pnpm -s wacli -- --json chats list --limit 100000 \
| jq -r '.data[].JID' \
| while read -r jid; do
wacli history backfill --chat "$jid" --requests 3 --count 50
pnpm -s wacli -- history backfill --chat "$jid" --requests 3 --count 50
done
```
## Credits
## Prior art / credit
Heavily inspired by [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli) by Vicente Reig.
This project is heavily inspired by (and learns from) the excellent `whatsapp-cli` by Vicente Reig:
## Maintainers
- Created by [@steipete](https://github.com/steipete)
- Currently maintained by [@dinakars777](https://github.com/dinakars777)
- [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli)
## License
See [`LICENSE`](LICENSE).
See `LICENSE`.
## Maintainers
- Created by [@steipete](https://github.com/steipete)
- Currently maintained by [@dinakars777](https://github.com/dinakars777)

View File

@ -1,289 +0,0 @@
package main
import (
"fmt"
"os"
"sort"
"time"
"github.com/openclaw/wacli/internal/config"
"github.com/openclaw/wacli/internal/fsutil"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
type accountPayload struct {
Name string `json:"name"`
Label string `json:"label,omitempty"`
ConfiguredStore string `json:"configured_store"`
StoreDir string `json:"store_dir"`
Default bool `json:"default"`
}
func newAccountsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "accounts",
Short: "Manage named WhatsApp accounts",
}
cmd.AddCommand(newAccountsListCmd(flags))
cmd.AddCommand(newAccountsAddCmd(flags))
cmd.AddCommand(newAccountsUseCmd(flags))
cmd.AddCommand(newAccountsShowCmd(flags))
cmd.AddCommand(newAccountsRemoveCmd(flags))
return cmd
}
func newAccountsListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List configured accounts",
RunE: func(cmd *cobra.Command, args []string) error {
path := config.DefaultConfigPath()
cfg, _, err := config.LoadAccountsConfigIfExists(path)
if err != nil {
return err
}
accounts := sortedAccounts(path, cfg)
payloads := accountPayloads(accounts)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"config_path": path,
"default_account": cfg.DefaultAccount,
"accounts": payloads,
})
}
if len(accounts) == 0 {
fmt.Fprintln(os.Stdout, "No accounts configured. Run `wacli accounts add personal`.")
return nil
}
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "DEFAULT\tNAME\tSTORE")
for _, account := range accounts {
mark := ""
if account.Default {
mark = "*"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", mark, account.Name, account.StoreDir)
}
_ = w.Flush()
return nil
},
}
}
func newAccountsAddCmd(flags *rootFlags) *cobra.Command {
opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
var noAuth bool
cmd := &cobra.Command{
Use: "add NAME",
Short: "Add an account and authenticate it",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
name := args[0]
if err := config.ValidateAccountName(name); err != nil {
return err
}
if !noAuth {
if _, err := validateAuthOptions(flags, opts); err != nil {
return err
}
}
path := config.DefaultConfigPath()
cfg, _, err := config.LoadAccountsConfigIfExists(path)
if err != nil {
return err
}
if _, ok := cfg.Accounts[name]; ok {
return fmt.Errorf("account %q already exists", name)
}
cfg.Accounts[name] = config.AccountEntry{Store: config.DefaultAccountStore(name)}
if cfg.DefaultAccount == "" {
cfg.DefaultAccount = name
}
storeDir := config.ListAccounts(path, cfg)
var added config.Account
for _, account := range storeDir {
if account.Name == name {
added = account
break
}
}
if err := fsutil.EnsurePrivateDir(added.StoreDir); err != nil {
return fmt.Errorf("create account store: %w", err)
}
if err := config.SaveAccountsConfig(path, cfg); err != nil {
return err
}
if noAuth {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"config_path": path,
"account": accountPayloadFromAccount(added),
})
}
fmt.Fprintf(os.Stdout, "Account %s added at %s. Run `wacli --account %s auth` to authenticate.\n", name, added.StoreDir, name)
return nil
}
oldAccount := flags.account
oldStore := flags.storeDir
flags.account = name
flags.storeDir = ""
defer func() {
flags.account = oldAccount
flags.storeDir = oldStore
}()
if !flags.asJSON {
fmt.Fprintf(os.Stdout, "Account %s added at %s\n", name, added.StoreDir)
}
res, err := runAuth(flags, opts)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"account": accountPayloadFromAccount(added),
"authenticated": true,
"messages_stored": res.MessagesStored,
})
}
fmt.Fprintf(os.Stdout, "Account %s authenticated. Messages stored: %d\n", name, res.MessagesStored)
return nil
},
}
addAuthFlags(cmd, &opts)
cmd.Flags().BoolVar(&noAuth, "no-auth", false, "create the account without running auth")
return cmd
}
func newAccountsUseCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "use NAME",
Short: "Set the default account",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
name := args[0]
if err := config.ValidateAccountName(name); err != nil {
return err
}
path := config.DefaultConfigPath()
cfg, err := config.LoadAccountsConfig(path)
if err != nil {
return err
}
if _, ok := cfg.Accounts[name]; !ok {
return fmt.Errorf("account %q is not configured", name)
}
cfg.DefaultAccount = name
if err := config.SaveAccountsConfig(path, cfg); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"default_account": name})
}
fmt.Fprintf(os.Stdout, "Default account: %s\n", name)
return nil
},
}
}
func newAccountsShowCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "show NAME",
Short: "Show one configured account",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
_, account, err := config.ResolveAccountStore(config.DefaultConfigPath(), args[0])
if err != nil {
return err
}
payload := accountPayloadFromAccount(account)
if flags.asJSON {
return out.WriteJSON(os.Stdout, payload)
}
fmt.Fprintf(os.Stdout, "Name: %s\nStore: %s\nDefault: %t\n", payload.Name, payload.StoreDir, payload.Default)
if payload.Label != "" {
fmt.Fprintf(os.Stdout, "Label: %s\n", payload.Label)
}
return nil
},
}
}
func newAccountsRemoveCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "remove NAME",
Short: "Remove an account from config without deleting its store",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
name := args[0]
if err := config.ValidateAccountName(name); err != nil {
return err
}
path := config.DefaultConfigPath()
cfg, err := config.LoadAccountsConfig(path)
if err != nil {
return err
}
entry, ok := cfg.Accounts[name]
if !ok {
return fmt.Errorf("account %q is not configured", name)
}
storeDir := config.ListAccounts(path, &config.AccountsConfig{
DefaultAccount: cfg.DefaultAccount,
Accounts: map[string]config.AccountEntry{name: entry},
})[0].StoreDir
delete(cfg.Accounts, name)
if cfg.DefaultAccount == name {
cfg.DefaultAccount = ""
}
if err := config.SaveAccountsConfig(path, cfg); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"removed": name,
"store_dir_kept": storeDir,
})
}
fmt.Fprintf(os.Stdout, "Removed account %s. Store kept at %s\n", name, storeDir)
return nil
},
}
}
func sortedAccounts(path string, cfg *config.AccountsConfig) []config.Account {
accounts := config.ListAccounts(path, cfg)
sort.Slice(accounts, func(i, j int) bool {
return accounts[i].Name < accounts[j].Name
})
return accounts
}
func accountPayloads(accounts []config.Account) []accountPayload {
payloads := make([]accountPayload, 0, len(accounts))
for _, account := range accounts {
payloads = append(payloads, accountPayloadFromAccount(account))
}
return payloads
}
func accountPayloadFromAccount(account config.Account) accountPayload {
return accountPayload{
Name: account.Name,
Label: account.Label,
ConfiguredStore: account.ConfiguredStore,
StoreDir: account.StoreDir,
Default: account.Default,
}
}

View File

@ -1,117 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/wacli/internal/config"
)
func TestAccountsAddNoAuthCreatesConfig(t *testing.T) {
isolateAccountConfigHome(t)
var stdout string
stderr := captureRootStderr(t, func() {
stdout = captureRootStdout(t, func() {
if err := execute([]string{"accounts", "add", "personal", "--no-auth"}); err != nil {
t.Fatalf("execute accounts add: %v", err)
}
})
})
if stderr != "" {
t.Fatalf("stderr = %q, want empty", stderr)
}
if !strings.Contains(stdout, "Account personal added") {
t.Fatalf("stdout = %q, want account added", stdout)
}
cfg, err := config.LoadAccountsConfig(config.DefaultConfigPath())
if err != nil {
t.Fatalf("LoadAccountsConfig: %v", err)
}
if cfg.DefaultAccount != "personal" {
t.Fatalf("DefaultAccount = %q, want personal", cfg.DefaultAccount)
}
account, ok := cfg.Accounts["personal"]
if !ok {
t.Fatal("personal account missing")
}
if account.Store != "accounts/personal" {
t.Fatalf("Store = %q, want accounts/personal", account.Store)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal")); err != nil {
t.Fatalf("account store not created: %v", err)
}
}
func TestAccountsAddValidatesAuthFlagsBeforeSaving(t *testing.T) {
isolateAccountConfigHome(t)
err := execute([]string{"--json", "accounts", "add", "personal", "--qr-format", "text"})
if err == nil || !strings.Contains(err.Error(), "--qr-format=text cannot be combined with --json") {
t.Fatalf("execute error = %v, want QR/json validation error", err)
}
if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) {
t.Fatalf("config stat error = %v, want not exist", statErr)
}
storeDir := filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal")
if _, statErr := os.Stat(storeDir); !os.IsNotExist(statErr) {
t.Fatalf("store stat error = %v, want not exist", statErr)
}
}
func TestAccountsAddRejectsWhitespaceName(t *testing.T) {
isolateAccountConfigHome(t)
err := execute([]string{"accounts", "add", " work ", "--no-auth"})
if err == nil || !strings.Contains(err.Error(), "whitespace") {
t.Fatalf("execute error = %v, want whitespace validation error", err)
}
if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) {
t.Fatalf("config stat error = %v, want not exist", statErr)
}
}
func TestAccountsListJSON(t *testing.T) {
isolateAccountConfigHome(t)
cfgPath := config.DefaultConfigPath()
cfg := &config.AccountsConfig{
DefaultAccount: "work",
Accounts: map[string]config.AccountEntry{
"personal": {Store: "accounts/personal"},
"work": {Store: "accounts/work"},
},
}
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
t.Fatal(err)
}
var stdout string
stdout = captureRootStdout(t, func() {
if err := execute([]string{"--json", "accounts", "list"}); err != nil {
t.Fatalf("execute accounts list: %v", err)
}
})
var payload struct {
Data struct {
DefaultAccount string `json:"default_account"`
Accounts []struct {
Name string `json:"name"`
Default bool `json:"default"`
} `json:"accounts"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("json.Unmarshal(%q): %v", stdout, err)
}
if payload.Data.DefaultAccount != "work" || len(payload.Data.Accounts) != 2 {
t.Fatalf("payload = %+v, want work and 2 accounts", payload)
}
if payload.Data.Accounts[0].Name != "personal" || payload.Data.Accounts[1].Name != "work" || !payload.Data.Accounts[1].Default {
t.Fatalf("accounts = %+v, want sorted personal/work with work default", payload.Data.Accounts)
}
}

View File

@ -9,34 +9,76 @@ import (
"time"
"github.com/mdp/qrterminal/v3"
appPkg "github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
appPkg "github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)
type authOptions struct {
follow bool
idleExit time.Duration
downloadMedia bool
qrFormat string
phone string
}
type validatedAuthOptions struct {
qrFormat string
pairPhone string
}
func newAuthCmd(flags *rootFlags) *cobra.Command {
opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
var follow bool
var idleExit time.Duration
var downloadMedia bool
var qrFormat string
var phone string
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with WhatsApp (QR) and bootstrap sync",
RunE: func(cmd *cobra.Command, args []string) error {
res, err := runAuth(flags, opts)
if err := flags.requireWritable(); err != nil {
return err
}
qrFormat, err := normalizeAuthQRFormat(qrFormat)
if err != nil {
return err
}
if flags.asJSON && qrFormat == "text" {
return fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout")
}
pairPhone, err := normalizePairPhone(phone)
if err != nil {
return err
}
maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{})
if err != nil {
return err
}
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
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
}
if a.Events().Enabled() {
_ = a.Events().Emit("auth_starting", nil)
} else {
fmt.Fprintln(os.Stderr, "Starting authentication…")
}
res, err := a.Sync(ctx, appPkg.SyncOptions{
Mode: mode,
AllowQR: true,
DownloadMedia: downloadMedia,
RefreshContacts: true,
RefreshGroups: true,
RefreshChannels: true,
IdleExit: idleExit,
OnQRCode: authQRWriter(qrFormat, os.Stdout, os.Stderr, a.Events()),
PairPhoneNumber: pairPhone,
OnPairCode: authPairCodeWriter(pairPhone, os.Stderr, a.Events()),
MaxMessages: maxMessages,
MaxDBSizeBytes: maxDBSize,
WarnNoLimits: true,
})
if err != nil {
return err
}
@ -53,7 +95,11 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
},
}
addAuthFlags(cmd, &opts)
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.Flags().StringVar(&qrFormat, "qr-format", "terminal", "QR output format: terminal or text")
cmd.Flags().StringVar(&phone, "phone", "", "pair by phone number instead of QR code")
cmd.AddCommand(newAuthStatusCmd(flags))
cmd.AddCommand(newAuthLogoutCmd(flags))
@ -61,77 +107,6 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
return cmd
}
func addAuthFlags(cmd *cobra.Command, opts *authOptions) {
cmd.Flags().BoolVar(&opts.follow, "follow", false, "keep syncing after auth")
cmd.Flags().DurationVar(&opts.idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)")
cmd.Flags().BoolVar(&opts.downloadMedia, "download-media", false, "download media in the background during sync")
cmd.Flags().StringVar(&opts.qrFormat, "qr-format", "terminal", "QR output format: terminal or text")
cmd.Flags().StringVar(&opts.phone, "phone", "", "pair by phone number instead of QR code")
}
func runAuth(flags *rootFlags, opts authOptions) (appPkg.SyncResult, error) {
if err := flags.requireWritable(); err != nil {
return appPkg.SyncResult{}, err
}
validated, err := validateAuthOptions(flags, opts)
if err != nil {
return appPkg.SyncResult{}, err
}
maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{})
if err != nil {
return appPkg.SyncResult{}, err
}
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
defer stop()
a, lk, err := newApp(ctx, flags, true, true)
if err != nil {
return appPkg.SyncResult{}, err
}
defer closeApp(a, lk)
mode := appPkg.SyncModeBootstrap
if opts.follow {
mode = appPkg.SyncModeFollow
}
if a.Events().Enabled() {
_ = a.Events().Emit("auth_starting", nil)
} else {
fmt.Fprintln(os.Stderr, "Starting authentication…")
}
return a.Sync(ctx, appPkg.SyncOptions{
Mode: mode,
AllowQR: true,
DownloadMedia: opts.downloadMedia,
RefreshContacts: true,
RefreshGroups: true,
RefreshChannels: true,
IdleExit: opts.idleExit,
OnQRCode: authQRWriter(validated.qrFormat, os.Stdout, os.Stderr, a.Events()),
PairPhoneNumber: validated.pairPhone,
OnPairCode: authPairCodeWriter(validated.pairPhone, os.Stderr, a.Events()),
MaxMessages: maxMessages,
MaxDBSizeBytes: maxDBSize,
WarnNoLimits: true,
})
}
func validateAuthOptions(flags *rootFlags, opts authOptions) (validatedAuthOptions, error) {
qrFormat, err := normalizeAuthQRFormat(opts.qrFormat)
if err != nil {
return validatedAuthOptions{}, err
}
if flags.asJSON && qrFormat == "text" {
return validatedAuthOptions{}, fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout")
}
pairPhone, err := normalizePairPhone(opts.phone)
if err != nil {
return validatedAuthOptions{}, err
}
return validatedAuthOptions{qrFormat: qrFormat, pairPhone: pairPhone}, nil
}
func normalizePairPhone(phone string) (string, error) {
phone = strings.TrimSpace(phone)
if phone == "" {

View File

@ -7,10 +7,10 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -11,10 +11,10 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -7,9 +7,9 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
)
func newChatsCleanupCmd(flags *rootFlags) *cobra.Command {

View File

@ -7,9 +7,9 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -6,8 +6,8 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newContactsCmd(flags *rootFlags) *cobra.Command {

View File

@ -5,10 +5,10 @@ import (
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/syscontacts"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/syscontacts"
)
type systemContactMatch struct {

View File

@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
func TestContactsImportSystemFromInputDryRunDoesNotWrite(t *testing.T) {

View File

@ -4,8 +4,8 @@ import (
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newDocsCmd(flags *rootFlags) *cobra.Command {

View File

@ -10,10 +10,11 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/lock"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/config"
"github.com/steipete/wacli/internal/lock"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
)
func parseLockOwnerPID(lockInfo string) int {
@ -115,10 +116,11 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
storeDir, err := resolveStoreDir(flags)
if err != nil {
return err
storeDir := flags.storeDir
if storeDir == "" {
storeDir = config.DefaultStoreDir()
}
storeDir, _ = filepath.Abs(storeDir)
var lockHeld bool
var lockInfo string

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
func TestParseLockOwnerPID(t *testing.T) {

View File

@ -7,8 +7,8 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"go.mau.fi/whatsmeow/types"
)

View File

@ -6,8 +6,8 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"go.mau.fi/whatsmeow/types"
)

View File

@ -6,9 +6,9 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -1,7 +1,7 @@
package main
import (
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -7,10 +7,10 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
)
func newGroupsPruneCmd(flags *rootFlags) *cobra.Command {

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
func TestGroupsPruneExposesSafetyFlags(t *testing.T) {

View File

@ -6,8 +6,8 @@ import (
"os"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {

View File

@ -38,17 +38,13 @@ func sanitize(s string) string {
func truncate(s string, max int) string {
s = sanitize(s)
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
if max <= 0 || len(s) <= max {
return s
}
if max <= 1 {
return string(runes[:max])
return s[:max]
}
return string(runes[:max-1]) + "…"
return s[:max-1] + "…"
}
func fullTableOutput(forceFull bool) bool {

View File

@ -7,10 +7,10 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
)
func newHistoryCmd(flags *rootFlags) *cobra.Command {

View File

@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
func TestHistoryCoverageCommandListsReadyAndBlockedChats(t *testing.T) {

View File

@ -6,8 +6,8 @@ import (
"os"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newMediaCmd(flags *rootFlags) *cobra.Command {

View File

@ -7,11 +7,11 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
)

View File

@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
func writeMessagesList(dst io.Writer, msgs []store.Message, fullOutput bool) error {

View File

@ -8,9 +8,9 @@ import (
"path/filepath"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -9,10 +9,9 @@ import (
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
@ -36,16 +35,6 @@ func TestTruncate(t *testing.T) {
}
}
func TestTruncatePreservesUTF8(t *testing.T) {
got := truncate("🙂🙂🙂", 2)
if got != "🙂…" {
t.Fatalf("truncate emoji = %q, want first rune plus ellipsis", got)
}
if !utf8.ValidString(got) {
t.Fatalf("truncate produced invalid UTF-8: %q", got)
}
}
func TestTruncateForDisplay(t *testing.T) {
const longID = "3EB0B0E8A1B2C3D4E5F6A7B8C9D0"
if got := tableCell(longID, 14, true); got != longID {

View File

@ -6,9 +6,9 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -11,8 +11,8 @@ import (
_ "image/png"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
// profileMaxPx is the max dimension WhatsApp accepts for profile pictures.

View File

@ -8,9 +8,9 @@ import (
"strconv"
"strings"
"github.com/openclaw/wacli/internal/resolve"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/resolve"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -9,20 +9,19 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/config"
"github.com/openclaw/wacli/internal/lock"
"github.com/openclaw/wacli/internal/out"
"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.8.1"
var version = "0.7.0"
const docsURL = "https://wacli.sh"
type rootFlags struct {
storeDir string
account string
asJSON bool
fullOutput bool
events bool
@ -45,7 +44,6 @@ func execute(args []string) error {
rootCmd.SetVersionTemplate("wacli {{.Version}}\n")
rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: $WACLI_STORE_DIR, XDG state dir on Linux, or ~/.wacli)")
rootCmd.PersistentFlags().StringVar(&flags.account, "account", "", "named account from config.yaml")
rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "output JSON instead of human-readable text")
rootCmd.PersistentFlags().BoolVar(&flags.fullOutput, "full", false, "disable truncation in table output")
rootCmd.PersistentFlags().BoolVar(&flags.events, "events", false, "emit machine-readable NDJSON lifecycle events on stderr")
@ -54,7 +52,6 @@ func execute(args []string) error {
rootCmd.PersistentFlags().BoolVar(&flags.readOnly, "read-only", false, "reject commands that intentionally write WhatsApp or the local store (or set WACLI_READONLY=1)")
rootCmd.AddCommand(newVersionCmd())
rootCmd.AddCommand(newAccountsCmd(&flags))
rootCmd.AddCommand(newDoctorCmd(&flags))
rootCmd.AddCommand(newAuthCmd(&flags))
rootCmd.AddCommand(newSyncCmd(&flags))
@ -91,13 +88,11 @@ func writeRootError(flags rootFlags, err error) {
}
func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) {
storeDir, err := resolveStoreDir(flags)
if err != nil {
return nil, nil, err
}
storeDir := resolveStoreDir(flags)
var lk *lock.Lock
if needLock {
var err error
lk, err = lock.AcquireWithTimeout(ctx, storeDir, flags.lockWait)
if err != nil {
return nil, nil, err
@ -121,43 +116,16 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed
return a, lk, nil
}
func resolveStoreDir(flags *rootFlags) (string, error) {
func resolveStoreDir(flags *rootFlags) string {
storeDir := ""
account := ""
if flags != nil {
storeDir = flags.storeDir
account = strings.TrimSpace(flags.account)
}
if storeDir != "" && account != "" {
return "", fmt.Errorf("--store and --account cannot be combined")
}
switch {
case storeDir != "":
case account != "":
resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), account)
if err != nil {
return "", err
}
storeDir = resolved
case os.Getenv(config.EnvStoreDir) != "":
if storeDir == "" {
storeDir = config.DefaultStoreDir()
default:
cfg, found, err := config.LoadAccountsConfigIfExists(config.DefaultConfigPath())
if err != nil {
return "", err
}
if found && strings.TrimSpace(cfg.DefaultAccount) != "" {
resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), cfg.DefaultAccount)
if err != nil {
return "", err
}
storeDir = resolved
} else {
storeDir = config.DefaultStoreDir()
}
}
storeDir, _ = filepath.Abs(storeDir)
return storeDir, nil
return storeDir
}
func (f *rootFlags) isReadOnly() bool {

View File

@ -6,11 +6,8 @@ import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/wacli/internal/config"
)
func captureRootStderr(t *testing.T, fn func()) string {
@ -96,64 +93,3 @@ func TestRootFlagsReadOnlyEnv(t *testing.T) {
t.Fatal("isReadOnly = false, want true")
}
}
func TestResolveStoreDirAccount(t *testing.T) {
isolateAccountConfigHome(t)
cfgPath := config.DefaultConfigPath()
cfg := &config.AccountsConfig{
Accounts: map[string]config.AccountEntry{
"work": {Store: "accounts/work"},
},
}
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
t.Fatal(err)
}
got, err := resolveStoreDir(&rootFlags{account: "work"})
if err != nil {
t.Fatalf("resolveStoreDir: %v", err)
}
want := filepath.Join(filepath.Dir(cfgPath), "accounts", "work")
if got != want {
t.Fatalf("storeDir = %q, want %q", got, want)
}
}
func TestResolveStoreDirStoreAndAccountConflict(t *testing.T) {
_, err := resolveStoreDir(&rootFlags{storeDir: "/tmp/wacli", account: "work"})
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
t.Fatalf("resolveStoreDir error = %v, want conflict", err)
}
}
func TestResolveStoreDirEnvBeatsDefaultAccount(t *testing.T) {
isolateAccountConfigHome(t)
cfgPath := config.DefaultConfigPath()
cfg := &config.AccountsConfig{
DefaultAccount: "work",
Accounts: map[string]config.AccountEntry{
"work": {Store: "accounts/work"},
},
}
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
t.Fatal(err)
}
envStore := filepath.Join(t.TempDir(), "env-store")
t.Setenv(config.EnvStoreDir, envStore)
got, err := resolveStoreDir(&rootFlags{})
if err != nil {
t.Fatalf("resolveStoreDir: %v", err)
}
if got != envStore {
t.Fatalf("storeDir = %q, want %q", got, envStore)
}
}
func isolateAccountConfigHome(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
t.Setenv(config.EnvStoreDir, "")
}

View File

@ -9,12 +9,12 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/linkpreview"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/linkpreview"
"github.com/steipete/wacli/internal/out"
"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"

View File

@ -21,9 +21,9 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/app"
"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"

View File

@ -7,8 +7,8 @@ import (
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newSendFileCmd(flags *rootFlags) *cobra.Command {

View File

@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/steipete/wacli/internal/app"
"go.mau.fi/whatsmeow"
)

View File

@ -11,10 +11,10 @@ import (
"sync"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/lock"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/lock"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
@ -65,11 +65,7 @@ func sendDelegateSocketPath(storeDir string) string {
func delegateSend(ctx context.Context, flags *rootFlags, req sendDelegateRequest) (sendDelegateResponse, error) {
req.Version = sendDelegateVersion
req.TimeoutMS = durationMillis(flags.timeout)
storeDir, err := resolveStoreDir(flags)
if err != nil {
return sendDelegateResponse{}, err
}
path := sendDelegateSocketPath(storeDir)
path := sendDelegateSocketPath(resolveStoreDir(flags))
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", path)

View File

@ -9,7 +9,7 @@ import (
"strings"
"testing"
"github.com/openclaw/wacli/internal/lock"
"github.com/steipete/wacli/internal/lock"
)
func TestTryDelegateSendFallsBackWhenSocketUnavailable(t *testing.T) {

View File

@ -7,10 +7,10 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -8,9 +8,9 @@ import (
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/app"
"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"

View File

@ -7,8 +7,8 @@ import (
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newSendStickerCmd(flags *rootFlags) *cobra.Command {

View File

@ -5,8 +5,8 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/linkpreview"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/linkpreview"
"github.com/steipete/wacli/internal/store"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
)

View File

@ -7,8 +7,8 @@ import (
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newSendVoiceCmd(flags *rootFlags) *cobra.Command {

View File

@ -7,7 +7,7 @@ import (
"os/signal"
"syscall"
"github.com/openclaw/wacli/internal/out"
"github.com/steipete/wacli/internal/out"
)
// signalContext returns a context that is cancelled on the first SIGINT/SIGTERM.

View File

@ -10,7 +10,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/steipete/wacli/internal/out"
)
func TestSignalContextWithEventsKeepsStderrNDJSON(t *testing.T) {

View File

@ -13,14 +13,13 @@ const (
)
type syncStorageLimitFlags struct {
maxMessages int64
maxMessagesSet bool
maxDBSize string
maxMessages int64
maxDBSize string
}
func resolveSyncStorageLimits(flags syncStorageLimitFlags) (int64, int64, error) {
maxMessages := flags.maxMessages
if !flags.maxMessagesSet && maxMessages <= 0 {
if maxMessages <= 0 {
raw := strings.TrimSpace(os.Getenv(envSyncMaxMessages))
if raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)

View File

@ -64,18 +64,3 @@ func TestResolveSyncStorageLimitsFlagsOverrideEnv(t *testing.T) {
t.Fatalf("maxDBSize = %d, want 4MiB", maxDBSize)
}
}
func TestResolveSyncStorageLimitsExplicitZeroMaxMessagesOverridesEnv(t *testing.T) {
t.Setenv(envSyncMaxMessages, "123")
maxMessages, _, err := resolveSyncStorageLimits(syncStorageLimitFlags{
maxMessages: 0,
maxMessagesSet: true,
})
if err != nil {
t.Fatalf("resolveSyncStorageLimits: %v", err)
}
if maxMessages != 0 {
t.Fatalf("maxMessages = %d, want explicit unlimited", maxMessages)
}
}

View File

@ -7,8 +7,8 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newStoreCleanupCmd(flags *rootFlags) *cobra.Command {

View File

@ -5,8 +5,8 @@ import (
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newStoreStatsCmd(flags *rootFlags) *cobra.Command {

View File

@ -6,9 +6,9 @@ import (
"os"
"time"
appPkg "github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
appPkg "github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
)
func newSyncCmd(flags *rootFlags) *cobra.Command {
@ -31,7 +31,6 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
if err := flags.requireWritable(); err != nil {
return err
}
storage.maxMessagesSet = cmd.Flags().Changed("max-messages")
maxMessages, maxDBSize, err := resolveSyncStorageLimits(storage)
if err != nil {
return err

View File

@ -1,57 +0,0 @@
# accounts
Read when: using more than one WhatsApp account, choosing the active account, or migrating from manual `--store` directories.
`wacli accounts` manages named accounts. Each account is an isolated store directory with its own WhatsApp linked-device session, local mirror database, media files, and lock.
## Commands
```bash
wacli accounts list
wacli accounts add NAME [--no-auth]
wacli accounts use NAME
wacli accounts show NAME
wacli accounts remove NAME
```
Use a named account with any command:
```bash
wacli --account work chats list
wacli --account personal send text --to 1234567890 --message "hi"
```
## Config
The default config path is `<base>/config.yaml`, where `<base>` is the default store root (`~/.wacli` on macOS and existing legacy Linux installs, otherwise `~/.local/state/wacli` on Linux).
```yaml
default_account: personal
accounts:
personal:
store: accounts/personal
work:
store: accounts/work
```
Relative `store` paths resolve from the config directory. Absolute paths are allowed for custom layouts.
## Selection Rules
Store selection is intentionally explicit:
1. `--store DIR` uses that exact store and cannot be combined with `--account`.
2. `--account NAME` resolves `NAME` from `config.yaml`.
3. `WACLI_STORE_DIR` keeps its existing override behavior for scripts and one-off stores.
4. If `default_account` is set, commands use that account.
5. Otherwise existing single-store behavior remains: XDG state dir on Linux, or `~/.wacli` elsewhere.
Account names may contain letters, digits, `.`, `_`, and `-`, and must start with a letter or digit.
## Notes
- `accounts add NAME` creates the isolated store and then runs the normal auth/bootstrap flow for that account. Use `--no-auth` to only write config and create the store.
- Locks are per account store, so `wacli --account personal sync --follow` and `wacli --account work chats list` do not block each other unless they share the same store path.
- Cross-account search or status should be explicit aggregate commands, not accidental shared database queries.
- Use `--store DIR` for one-off migration/debugging against an old manual store.

View File

@ -2,7 +2,7 @@
Read when: pairing a store, checking auth state, logging out, or choosing QR vs phone pairing.
`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store or named account.
`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store.
## Commands
@ -10,7 +10,6 @@ Read when: pairing a store, checking auth state, logging out, or choosing QR vs
wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE] [--events]
wacli auth status
wacli auth logout
wacli --account work auth status
```
## Notes
@ -24,7 +23,6 @@ wacli --account work auth status
- `--events` emits NDJSON lifecycle events on stderr, including raw QR and phone-pairing codes for external renderers.
- `auth status` reports whether the local store is authenticated.
- `auth logout` invalidates the linked-device session and requires writable mode.
- For multiple accounts, prefer `wacli accounts add NAME`; it creates an isolated account store and runs the same auth/bootstrap flow.
## Examples

View File

@ -21,7 +21,6 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w
## Pick your path
- **Trying it.** Read [Install](install.md), then [Quickstart](quickstart.md). Pair, sync, and send your first message in under five minutes.
- **Using multiple WhatsApp accounts.** Read [Accounts](accounts.md) for named account stores and `--account`.
- **Searching old chats.** Read [Sync](sync.md) for the sync model and [History](history.md) for coverage planning and on-demand backfill.
- **Managing chat state.** Read [Chats](chats.md) for archive, pin, mute, and read/unread commands.
- **Managing local storage.** Read [Store](store.md) for stats, dry-run cleanup, and local-only pruning.
@ -33,7 +32,7 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w
## Status
Core implementation is in place. The [CHANGELOG](https://github.com/openclaw/wacli/blob/main/CHANGELOG.md) tracks shipped behavior. WhatsApp Web is not a published API; expect occasional breakage from upstream protocol changes — `wacli` follows `whatsmeow` upstream.
Core implementation is in place. The [CHANGELOG](https://github.com/steipete/wacli/blob/main/CHANGELOG.md) tracks shipped behavior. WhatsApp Web is not a published API; expect occasional breakage from upstream protocol changes — `wacli` follows `whatsmeow` upstream.
## Out of scope
@ -45,4 +44,4 @@ Core implementation is in place. The [CHANGELOG](https://github.com/openclaw/wac
`wacli` is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow`. It is **not affiliated with WhatsApp or Meta**. Use at your own risk; pairing as a linked device is subject to WhatsApp's terms.
Released under the [MIT license](https://github.com/openclaw/wacli/blob/main/LICENSE).
Released under the [MIT license](https://github.com/steipete/wacli/blob/main/LICENSE).

View File

@ -23,7 +23,7 @@ brew reinstall steipete/tap/wacli
## GitHub releases (raw binaries)
Download the matching archive from the [latest release](https://github.com/openclaw/wacli/releases) and put `wacli` (or `wacli.exe` on Windows) on your `PATH`.
Download the matching archive from the [latest release](https://github.com/steipete/wacli/releases) and put `wacli` (or `wacli.exe` on Windows) on your `PATH`.
## Build from source
@ -36,14 +36,7 @@ Download the matching archive from the [latest release](https://github.com/openc
Then:
```bash
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
```
For local development:
```bash
git clone https://github.com/openclaw/wacli.git
git clone https://github.com/steipete/wacli.git
cd wacli
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli

View File

@ -20,7 +20,7 @@ The default store is:
- Linux: `~/.local/state/wacli`, with legacy `~/.wacli` reused when present.
- macOS and other platforms: `~/.wacli`.
Override with `--store DIR` or `WACLI_STORE_DIR`. Named accounts live in `config.yaml` and resolve with `--account NAME`; each account points at a normal isolated store directory.
Override with `--store DIR` or `WACLI_STORE_DIR`.
The store contains two SQLite databases:
@ -29,8 +29,6 @@ The store contains two SQLite databases:
Companion tools should not read or write `session.db` unless they are explicitly working on WhatsApp session internals. Never write to `wacli.db` from a companion tool.
For multi-account tools, iterate configured accounts explicitly and annotate derived rows with the account name in the companion tool's own database. Do not merge account data into `wacli.db`.
## Read-only SQLite
Open the database in SQLite read-only mode:

View File

@ -22,7 +22,6 @@ wacli messages delete --chat JID --id MSG_ID [--for-me] [--delete-media] [--post
- Uses SQLite FTS5 when the binary was built with `-tags sqlite_fts5`.
- Falls back to `LIKE` if FTS5 is not available.
- `--type` accepts `text`, `image`, `video`, `audio`, or `document`.
- Shared WhatsApp contact cards are stored as searchable text with contact names and phone numbers when WhatsApp includes a vCard payload.
- `--starred` restricts list/search results to messages marked as starred by WhatsApp.
- Time filters accept RFC3339 or `YYYY-MM-DD`.

View File

@ -2,7 +2,7 @@
Read when: you need the user-facing command map, global flags, store model, or links to command-specific docs.
`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans. Named accounts let multiple WhatsApp identities use isolated stores via `--account`.
`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans.
## Store and output
@ -22,7 +22,6 @@ Read when: you need the user-facing command map, global flags, store model, or l
## Command pages
- [auth](auth.md) - pair, inspect auth status, logout.
- [accounts](accounts.md) - create and select named account stores.
- [sync](sync.md) - sync messages, contacts, groups, channels, and optional media.
- [messages](messages.md) - list, search, show, and contextualize stored messages.
- [send](send.md) - send text, files, stickers, replies, and reactions.

View File

@ -30,12 +30,10 @@ early if someone tries to compile it with `CGO_ENABLED=0`.
## Homebrew Tap
The release workflow dispatches the `Update Formula` workflow in `steipete/homebrew-tap` after the macOS artifact is published when the tap token is configured. The tap workflow owns the formula-editing logic and updates both the macOS artifact SHA256 and the Linux source archive SHA256 in `Formula/wacli.rb`.
The release workflow dispatches the `Update Formula` workflow in `steipete/homebrew-tap` after the macOS artifact is published. The tap workflow owns the formula-editing logic and updates both the macOS artifact SHA256 and the Linux source archive SHA256 in `Formula/wacli.rb`.
Optional repository secret:
Required repository secret:
- `HOMEBREW_TAP_TOKEN`: token with permission to run workflows in `steipete/homebrew-tap`
If `HOMEBREW_TAP_TOKEN` is missing, release artifacts are still published and the tap update is skipped with a workflow warning.
To backfill an existing release, rerun the `release` workflow manually with `tag: vX.Y.Z`.

View File

@ -2,7 +2,7 @@
Read when: sending text, files, stickers, quoted replies, or reactions.
`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. `Sent to ...` and JSON `sent: true` mean WhatsApp accepted the send request and returned a message ID; they do not confirm recipient delivery. After a successful send, wacli keeps the connection alive briefly so whatsmeow can handle retry receipts from devices that could not decrypt the first copy. Repeated send commands within 5 seconds print a stderr warning so tight loops make WhatsApp rate-limit/account-risk visible.
`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. After a successful send, wacli keeps the connection alive briefly so whatsmeow can handle retry receipts from devices that could not decrypt the first copy. Repeated send commands within 5 seconds print a stderr warning so tight loops make WhatsApp rate-limit/account-risk visible.
When `sync --follow` is already running for the same store, send commands delegate the send to that running process instead of opening a second WhatsApp session. This keeps scripts usable while continuous sync owns the store lock.

View File

@ -137,7 +137,6 @@ Fallback:
Global flags:
- `--store DIR` (default: XDG state dir on Linux, `~/.wacli` elsewhere)
- `--account NAME` (named account from `config.yaml`; mutually exclusive with `--store`)
- `--json` (default: human text)
- `--full` (disable table truncation; non-TTY output keeps full IDs)
- `--timeout DURATION` (non-sync commands; e.g. `5m`)
@ -155,19 +154,6 @@ Global flags:
- `wacli auth status`
- `wacli auth logout`
### Accounts
- `wacli accounts list`
- `wacli accounts add NAME [--no-auth]`
- `wacli accounts use NAME`
- `wacli accounts show NAME`
- `wacli accounts remove NAME`
Named accounts resolve to isolated store directories. Account config lives in
`<base>/config.yaml`; relative account store paths resolve from that config
directory. `--store` remains the direct manual-store escape hatch and cannot be
combined with `--account`.
### Sync
- `wacli sync [--once] [--follow] [--download-media] [--webhook URL] [--webhook-secret SECRET]`

View File

@ -2,7 +2,7 @@
Read when: inspecting local SQLite size/counts or pruning old local chat/group rows.
`wacli store` manages the selected account's local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers.
`wacli store` manages the local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers.
## Commands
@ -29,7 +29,6 @@ wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [
- Destructive cleanup commands require confirmation unless `--confirm` is passed.
- Use `--dry-run` first; it lists what would be deleted without changing the local store.
- Use `--read-only` or `WACLI_READONLY=1` to make cleanup commands fail before opening the store for writes.
- Use `--account NAME` to target a named account store. Use `--store DIR` for manual stores or migration debugging; it cannot be combined with `--account`.
## Examples

View File

@ -30,7 +30,6 @@ wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-mes
- While `sync --follow` is running, `send text`, `send file`, `send sticker`, `send voice`, and `send react` commands for the same store are delegated to the running sync process so they do not fail on the store lock.
- After connecting, sync fetches WhatsApp chat app-state deltas (`regular_high` and `regular_low`) so starred, delete-for-me, mute, archive, pin, and mark-read changes made while `wacli` was offline are caught up instead of relying only on live push notifications.
- If whatsmeow reports an app-state LTHash mismatch, sync asks the primary device for the official recovery snapshot once for that app-state collection. If recovery also fails, the warning is printed and sync keeps handling normal message/history events.
- In an interactive terminal, routine connected/history/progress updates share one updating stderr status line. Warnings and errors still print as separate lines so they remain visible.
- `--events` emits one NDJSON lifecycle event per stderr line for machine consumers. Routine human progress/status lines, interrupt prompts, and command errors are emitted as events while events are enabled.
## Examples

5
go.mod
View File

@ -1,4 +1,4 @@
module github.com/openclaw/wacli
module github.com/steipete/wacli
go 1.25.0
@ -11,7 +11,6 @@ require (
golang.org/x/sys v0.43.0
golang.org/x/term v0.42.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
@ -21,7 +20,6 @@ require (
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/kr/pretty v0.1.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
@ -35,6 +33,5 @@ require (
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
rsc.io/qr v0.2.0 // indirect
)

3
go.sum
View File

@ -22,10 +22,8 @@ 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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@ -79,7 +77,6 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@ -7,10 +7,10 @@ import (
"sync"
"time"
"github.com/openclaw/wacli/internal/fsutil"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/fsutil"
"github.com/steipete/wacli/internal/out"
"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/proto/waCommon"
@ -89,12 +89,10 @@ type Options struct {
}
type App struct {
opts Options
waMu sync.Mutex
wa WAClient
db *store.DB
statusMu sync.Mutex
status *syncStatus
opts Options
waMu sync.Mutex
wa WAClient
db *store.DB
}
func New(opts Options) (*App, error) {

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waE2E"

View File

@ -3,7 +3,7 @@ package app
import (
"context"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/wa"
)
func (a *App) refreshContacts(ctx context.Context) error {

View File

@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waSyncAction"
"go.mau.fi/whatsmeow/types"

View File

@ -21,21 +21,6 @@ func (a *App) emitOrPrint(event string, data map[string]any, format string, args
a.emitEvent(event, data)
return
}
if st := a.currentSyncStatus(); st != nil {
switch event {
case "connected":
st.Connected()
case "history_sync":
conversations, _ := data["conversations"].(int)
st.HistorySync(conversations)
case "progress":
messages, _ := data["messages_synced"].(int64)
st.Progress(messages)
default:
st.PrintLine(fmt.Sprintf(format, args...))
}
return
}
fmt.Fprintf(os.Stderr, format, args...)
}
@ -49,9 +34,5 @@ func (a *App) emitWarning(code, message string, data map[string]any) {
a.emitEvent("warning", data)
return
}
if st := a.currentSyncStatus(); st != nil {
st.WarnLine(message)
return
}
fmt.Fprintln(os.Stderr, message)
}

View File

@ -9,7 +9,7 @@ import (
"sync"
"time"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/proto/waCommon"

View File

@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -11,9 +11,9 @@ import (
"strings"
"sync"
"github.com/openclaw/wacli/internal/fsutil"
"github.com/openclaw/wacli/internal/pathutil"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/fsutil"
"github.com/steipete/wacli/internal/pathutil"
"github.com/steipete/wacli/internal/store"
)
type mediaJob struct {

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
// panicFirstWA wraps a fakeWA but panics on the first DownloadMediaToFile

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
func TestDownloadMediaJobMarksDownloaded(t *testing.T) {

View File

@ -10,8 +10,8 @@ import (
"sync/atomic"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/types"
)
@ -52,9 +52,6 @@ type SyncResult struct {
}
func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
status := a.beginSyncStatus()
defer a.endSyncStatus(status)
if opts.Mode == "" {
opts.Mode = SyncModeFollow
}

View File

@ -11,8 +11,8 @@ import (
"sync/atomic"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/appstate"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
@ -246,7 +246,7 @@ func (a *App) handleHistorySync(ctx context.Context, opts SyncOptions, v *events
}
}
if !a.eventsEnabled() {
a.emitOrPrint("progress", map[string]any{"messages_synced": messagesStored.Load()}, "\rSynced %d messages...", messagesStored.Load())
fmt.Fprintf(os.Stderr, "\rSynced %d messages...", messagesStored.Load())
}
}

View File

@ -3,13 +3,12 @@ package app
import (
"context"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/steipete/wacli/internal/out"
"go.mau.fi/whatsmeow/types"
)
@ -66,83 +65,3 @@ func TestSyncEventsOutputStaysNDJSONDuringProgress(t *testing.T) {
t.Fatalf("expected progress event in:\n%s", raw)
}
}
func TestSyncTTYProgressUsesSingleStatusLine(t *testing.T) {
oldTerminal := syncStatusTerminal
syncStatusTerminal = func() bool { return true }
t.Cleanup(func() { syncStatusTerminal = oldTerminal })
a := newTestApp(t)
f := newFakeWA()
a.wa = f
chat := types.JID{User: "123", Server: types.DefaultUserServer}
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
ids := make([]string, 30)
for i := range ids {
ids[i] = "m" + string(rune('a'+i))
}
f.connectEvents = []interface{}{historySyncWithTextMessages(chat, base, ids...)}
raw := captureStderr(t, func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := a.Sync(ctx, SyncOptions{
Mode: SyncModeOnce,
AllowQR: false,
IdleExit: time.Millisecond,
WarnNoLimits: false,
})
if err != nil {
t.Fatalf("Sync: %v", err)
}
})
if strings.Contains(raw, "\nProcessing history sync") || strings.Contains(raw, "\nSynced 25 messages") {
t.Fatalf("TTY progress should update one status line, got:\n%q", raw)
}
if !strings.Contains(raw, "\rConnected. Waiting for history sync...") {
t.Fatalf("missing connected status in:\n%q", raw)
}
if !strings.Contains(raw, "\rSyncing history: 1 conversations, 25 messages stored") {
t.Fatalf("missing history progress status in:\n%q", raw)
}
if !strings.Contains(raw, "\rSyncing history: 1 conversations, 30 messages stored") {
t.Fatalf("missing final history status in:\n%q", raw)
}
}
func TestSyncTTYWarningBreaksThroughStatusLine(t *testing.T) {
oldTerminal := syncStatusTerminal
syncStatusTerminal = func() bool { return true }
t.Cleanup(func() { syncStatusTerminal = oldTerminal })
a := newTestApp(t)
f := newFakeWA()
f.appStateFetchErr = errors.New("not connected")
a.wa = f
raw := captureStderr(t, func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := a.Sync(ctx, SyncOptions{
Mode: SyncModeOnce,
AllowQR: false,
IdleExit: time.Millisecond,
WarnNoLimits: false,
})
if err != nil {
t.Fatalf("Sync: %v", err)
}
})
if !strings.Contains(raw, "warning: failed to sync WhatsApp app state regular_high: not connected\n") {
t.Fatalf("missing regular_high warning in:\n%q", raw)
}
if !strings.Contains(raw, "warning: failed to sync WhatsApp app state regular_low: not connected\n") {
t.Fatalf("missing regular_low warning in:\n%q", raw)
}
if strings.Contains(raw, "\nConnected.\n") {
t.Fatalf("connected status should not become a separate noisy line:\n%q", raw)
}
}

View File

@ -4,7 +4,7 @@ import (
"context"
"sync"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/wa"
)
type syncStorageLimits struct {

View File

@ -1,159 +0,0 @@
package app
import (
"fmt"
"io"
"os"
"strings"
"sync"
"golang.org/x/term"
)
var syncStatusTerminal = func() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}
type syncStatus struct {
mu sync.Mutex
w io.Writer
last string
lastLen int
conversations int
messages int64
}
func newSyncStatus(w io.Writer) *syncStatus {
return &syncStatus{w: w}
}
func (a *App) beginSyncStatus() *syncStatus {
if a == nil || a.eventsEnabled() || !syncStatusTerminal() {
return nil
}
st := newSyncStatus(os.Stderr)
a.statusMu.Lock()
a.status = st
a.statusMu.Unlock()
return st
}
func (a *App) endSyncStatus(st *syncStatus) {
if st == nil {
return
}
st.Clear()
a.statusMu.Lock()
if a.status == st {
a.status = nil
}
a.statusMu.Unlock()
}
func (a *App) currentSyncStatus() *syncStatus {
if a == nil {
return nil
}
a.statusMu.Lock()
defer a.statusMu.Unlock()
return a.status
}
func (s *syncStatus) Connected() {
s.Set("Connected. Waiting for history sync...")
}
func (s *syncStatus) HistorySync(conversations int) {
s.mu.Lock()
s.conversations = conversations
msg := s.historyMessageLocked()
s.mu.Unlock()
s.Set(msg)
}
func (s *syncStatus) Progress(messages int64) {
s.mu.Lock()
s.messages = messages
msg := s.historyMessageLocked()
s.mu.Unlock()
s.Set(msg)
}
func (s *syncStatus) historyMessageLocked() string {
if s.conversations > 0 {
return fmt.Sprintf("Syncing history: %d conversations, %d messages stored", s.conversations, s.messages)
}
return fmt.Sprintf("Synced %d messages", s.messages)
}
func (s *syncStatus) Set(message string) {
if s == nil || s.w == nil {
return
}
message = strings.TrimSpace(message)
if message == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.renderLocked(message)
}
func (s *syncStatus) PrintLine(message string) {
if s == nil || s.w == nil {
return
}
message = strings.TrimSpace(message)
if message == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.clearLocked()
fmt.Fprintln(s.w, message)
}
func (s *syncStatus) WarnLine(message string) {
if s == nil || s.w == nil {
return
}
message = strings.TrimSpace(message)
if message == "" {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.clearLocked()
fmt.Fprintln(s.w, message)
if s.last != "" {
s.renderLocked(s.last)
}
}
func (s *syncStatus) Clear() {
if s == nil || s.w == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.clearLocked()
s.last = ""
}
func (s *syncStatus) renderLocked(message string) {
padding := ""
if s.lastLen > len(message) {
padding = strings.Repeat(" ", s.lastLen-len(message))
}
fmt.Fprintf(s.w, "\r%s%s", message, padding)
s.last = message
s.lastLen = len(message)
}
func (s *syncStatus) clearLocked() {
if s.lastLen <= 0 {
return
}
fmt.Fprintf(s.w, "\r%s\r", strings.Repeat(" ", s.lastLen))
s.lastLen = 0
}

View File

@ -11,8 +11,8 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/appstate"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/proto/waCommon"

View File

@ -15,7 +15,7 @@ import (
"sync"
"time"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/wa"
)
var syncWebhookHTTPClient = &http.Client{Timeout: 10 * time.Second}

View File

@ -10,7 +10,7 @@ import (
"testing"
"time"
"github.com/openclaw/wacli/internal/wa"
"github.com/steipete/wacli/internal/wa"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"

View File

@ -1,17 +1,9 @@
package config
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"gopkg.in/yaml.v3"
)
// EnvStoreDir is the environment variable that overrides the default store
@ -20,28 +12,6 @@ import (
// every invocation.
const EnvStoreDir = "WACLI_STORE_DIR"
const ConfigFileName = "config.yaml"
var accountNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
type AccountsConfig struct {
DefaultAccount string `yaml:"default_account,omitempty"`
Accounts map[string]AccountEntry `yaml:"accounts,omitempty"`
}
type AccountEntry struct {
Store string `yaml:"store"`
Label string `yaml:"label,omitempty"`
}
type Account struct {
Name string
Label string
ConfiguredStore string
StoreDir string
Default bool
}
// DefaultStoreDir returns the store directory to use when --store is not
// supplied. It checks WACLI_STORE_DIR first, then falls back to the XDG state
// directory on Linux or ~/.wacli on other platforms.
@ -49,13 +19,6 @@ func DefaultStoreDir() string {
if dir := os.Getenv(EnvStoreDir); dir != "" {
return dir
}
return DefaultBaseDir()
}
// DefaultBaseDir returns wacli's platform default state root without honoring
// WACLI_STORE_DIR. Account config lives here so a temporary store override
// does not hide the account registry.
func DefaultBaseDir() string {
xdgStateHome := os.Getenv("XDG_STATE_HOME")
home, err := os.UserHomeDir()
if err != nil || home == "" {
@ -67,153 +30,6 @@ func DefaultBaseDir() string {
return defaultStoreDirFor(runtime.GOOS, home, xdgStateHome, pathExists)
}
func DefaultConfigPath() string {
return filepath.Join(DefaultBaseDir(), ConfigFileName)
}
func ValidateAccountName(name string) error {
if name == "" {
return fmt.Errorf("account name is required")
}
if strings.TrimSpace(name) != name {
return fmt.Errorf("invalid account name %q: leading or trailing whitespace is not allowed", name)
}
if !accountNameRE.MatchString(name) {
return fmt.Errorf("invalid account name %q: use letters, digits, '.', '_', or '-', starting with a letter or digit", name)
}
return nil
}
func LoadAccountsConfig(path string) (*AccountsConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg AccountsConfig
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&cfg); err != nil {
if errors.Is(err, io.EOF) {
cfg = AccountsConfig{}
} else {
return nil, fmt.Errorf("parse account config %s: %w", path, err)
}
}
if cfg.Accounts == nil {
cfg.Accounts = map[string]AccountEntry{}
}
for name, entry := range cfg.Accounts {
if err := ValidateAccountName(name); err != nil {
return nil, err
}
if strings.TrimSpace(entry.Store) == "" {
return nil, fmt.Errorf("account %q store is required", name)
}
if strings.ContainsAny(entry.Store, "?#") {
return nil, fmt.Errorf("account %q store must not contain '?' or '#'", name)
}
}
if cfg.DefaultAccount != "" {
if err := ValidateAccountName(cfg.DefaultAccount); err != nil {
return nil, fmt.Errorf("default_account: %w", err)
}
if _, ok := cfg.Accounts[cfg.DefaultAccount]; !ok {
return nil, fmt.Errorf("default_account %q is not defined", cfg.DefaultAccount)
}
}
return &cfg, nil
}
func LoadAccountsConfigIfExists(path string) (*AccountsConfig, bool, error) {
cfg, err := LoadAccountsConfig(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &AccountsConfig{Accounts: map[string]AccountEntry{}}, false, nil
}
return nil, false, err
}
return cfg, true, nil
}
func SaveAccountsConfig(path string, cfg *AccountsConfig) error {
if cfg == nil {
cfg = &AccountsConfig{}
}
if cfg.Accounts == nil {
cfg.Accounts = map[string]AccountEntry{}
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("encode account config: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return fmt.Errorf("write account config: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace account config: %w", err)
}
_ = os.Chmod(path, 0o600)
return nil
}
func ResolveAccountStore(path, name string) (string, Account, error) {
if err := ValidateAccountName(name); err != nil {
return "", Account{}, err
}
cfg, err := LoadAccountsConfig(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "", Account{}, fmt.Errorf("account config not found at %s; run `wacli accounts add %s`", path, name)
}
return "", Account{}, err
}
entry, ok := cfg.Accounts[name]
if !ok {
return "", Account{}, fmt.Errorf("account %q is not configured; run `wacli accounts list`", name)
}
storeDir := resolveConfiguredStore(filepath.Dir(path), entry.Store)
return storeDir, Account{
Name: name,
Label: entry.Label,
ConfiguredStore: entry.Store,
StoreDir: storeDir,
Default: cfg.DefaultAccount == name,
}, nil
}
func ListAccounts(path string, cfg *AccountsConfig) []Account {
if cfg == nil || len(cfg.Accounts) == 0 {
return nil
}
out := make([]Account, 0, len(cfg.Accounts))
for name, entry := range cfg.Accounts {
out = append(out, Account{
Name: name,
Label: entry.Label,
ConfiguredStore: entry.Store,
StoreDir: resolveConfiguredStore(filepath.Dir(path), entry.Store),
Default: cfg.DefaultAccount == name,
})
}
return out
}
func DefaultAccountStore(name string) string {
return filepath.ToSlash(filepath.Join("accounts", name))
}
func resolveConfiguredStore(baseDir, store string) string {
if filepath.IsAbs(store) {
return filepath.Clean(store)
}
return filepath.Clean(filepath.Join(baseDir, store))
}
func defaultStoreDirFor(goos, home, xdgStateHome string, exists func(string) bool) string {
legacy := filepath.Join(home, ".wacli")
if goos != "linux" {

View File

@ -4,7 +4,6 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@ -78,64 +77,3 @@ func TestDefaultStoreDirFor(t *testing.T) {
}
})
}
func TestAccountsConfigRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
cfg := &AccountsConfig{
DefaultAccount: "personal",
Accounts: map[string]AccountEntry{
"personal": {Store: "accounts/personal"},
"work": {Store: "/tmp/wacli-work", Label: "Work"},
},
}
if err := SaveAccountsConfig(path, cfg); err != nil {
t.Fatalf("SaveAccountsConfig: %v", err)
}
loaded, err := LoadAccountsConfig(path)
if err != nil {
t.Fatalf("LoadAccountsConfig: %v", err)
}
if loaded.DefaultAccount != "personal" {
t.Fatalf("DefaultAccount = %q, want personal", loaded.DefaultAccount)
}
store, account, err := ResolveAccountStore(path, "personal")
if err != nil {
t.Fatalf("ResolveAccountStore: %v", err)
}
wantStore := filepath.Join(filepath.Dir(path), "accounts", "personal")
if store != wantStore || account.StoreDir != wantStore {
t.Fatalf("store = %q/%q, want %q", store, account.StoreDir, wantStore)
}
if !account.Default {
t.Fatal("account.Default = false, want true")
}
}
func TestAccountsConfigKnownFields(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("unknown: true\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := LoadAccountsConfig(path)
if err == nil || !strings.Contains(err.Error(), "field unknown not found") {
t.Fatalf("LoadAccountsConfig error = %v, want unknown field error", err)
}
}
func TestValidateAccountName(t *testing.T) {
valid := []string{"personal", "work-2", "client.foo", "a_b"}
for _, name := range valid {
if err := ValidateAccountName(name); err != nil {
t.Fatalf("ValidateAccountName(%q): %v", name, err)
}
}
invalid := []string{"", " work", "work ", ".hidden", "-work", "bad/name", "bad?name", "bad name"}
for _, name := range invalid {
if err := ValidateAccountName(name); err == nil {
t.Fatalf("ValidateAccountName(%q) succeeded, want error", name)
}
}
}

View File

@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/fsutil"
"github.com/steipete/wacli/internal/fsutil"
)
type Lock struct {

View File

@ -5,7 +5,7 @@ import (
"sort"
"strings"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
type Kind string

View File

@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/openclaw/wacli/internal/store"
"github.com/steipete/wacli/internal/store"
)
type fakeSource struct {

View File

@ -7,8 +7,8 @@ import (
"strings"
_ "github.com/mattn/go-sqlite3"
"github.com/openclaw/wacli/internal/fsutil"
"github.com/openclaw/wacli/internal/sqliteutil"
"github.com/steipete/wacli/internal/fsutil"
"github.com/steipete/wacli/internal/sqliteutil"
)
type DB struct {

View File

@ -68,9 +68,6 @@ func (d *DB) ListHistoryCoverage(p ListHistoryCoverageParams) ([]HistoryCoverage
if len(p.ChatJIDs) > 0 {
query, args = appendStringFilter(query, args, "c.jid", "", p.ChatJIDs)
}
if !p.IncludeBlocked || p.OnlyActionable {
query += ` AND COALESCE(ms.message_count, 0) > 0`
}
query += ` ORDER BY COALESCE(c.last_message_ts, 0) DESC, c.jid LIMIT ?`
args = append(args, p.Limit)

View File

@ -89,44 +89,6 @@ func TestListHistoryCoverage(t *testing.T) {
}
}
func TestListHistoryCoverageAppliesBlockedFilterBeforeLimit(t *testing.T) {
db := openTestDB(t)
base := time.Date(2024, 5, 3, 0, 0, 0, 0, time.UTC)
blocked := "blocked@s.whatsapp.net"
ready := "ready@s.whatsapp.net"
if err := db.UpsertChat(blocked, "dm", "Blocked", base.Add(2*time.Minute)); err != nil {
t.Fatalf("UpsertChat blocked: %v", err)
}
if err := db.UpsertChat(ready, "dm", "Ready", base.Add(time.Minute)); err != nil {
t.Fatalf("UpsertChat ready: %v", err)
}
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: ready,
MsgID: "m1",
Timestamp: base.Add(time.Second),
Text: "ready",
}); err != nil {
t.Fatalf("UpsertMessage ready: %v", err)
}
coverage, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Limit: 1})
if err != nil {
t.Fatalf("ListHistoryCoverage: %v", err)
}
if len(coverage) != 1 || coverage[0].ChatJID != ready {
t.Fatalf("coverage = %+v, want ready chat despite newer blocked row", coverage)
}
withBlocked, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Limit: 1, IncludeBlocked: true})
if err != nil {
t.Fatalf("ListHistoryCoverage IncludeBlocked: %v", err)
}
if len(withBlocked) != 1 || withBlocked[0].ChatJID != blocked {
t.Fatalf("withBlocked = %+v, want newer blocked chat when requested", withBlocked)
}
}
func TestListHistoryCoverageEscapesQueryWildcards(t *testing.T) {
db := openTestDB(t)
when := time.Date(2024, 5, 2, 0, 0, 0, 0, time.UTC)

Some files were not shown because too many files have changed in this diff Show More