Compare commits

..

1 Commits

Author SHA1 Message Date
Dinakar Sarbada
602ea1cfe4 feat: add send text mentions 2026-05-04 20:07:39 -07:00
169 changed files with 899 additions and 12848 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

@ -1,55 +0,0 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "scripts/docs-site-render.mjs"
- "package.json"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Build docs site
run: node scripts/build-docs-site.mjs
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

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,78 +1,28 @@
# 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
## 0.7.0 - Unreleased
### Added
- CLI: add `--read-only`/`WACLI_READONLY` to reject commands that write WhatsApp or the local store.
- CLI: add `--lock-wait` to wait for transient store locks before failing write commands.
- CLI: add `--events` to emit machine-readable NDJSON lifecycle events for `auth`, `sync`, and `history backfill`. (#204 — thanks @dinakars777 and @0xatrilla)
- CLI: add `wacli docs` and root help text that point to the hosted docs at `https://wacli.sh`.
- CLI: add `--full` to disable table truncation; piped output now keeps full message IDs. (#13 — thanks @rickhallett)
- CLI: add `presence typing` and `presence paused` commands for WhatsApp composing indicators. (#76 — thanks @redemerco)
- Diagnostics: show linked JID and local store counts in `auth status` and `doctor`. (#149 — thanks @draix)
- Messages: add `messages list --sender`, `--from-me`, `--from-them`, and `--asc` filters. (#153 — thanks @draix)
- Messages: track WhatsApp starred state and add `messages starred` plus `--starred` filters for list/search. (#17 — thanks @dan-dr)
- Messages: track WhatsApp delete-for-me app-state events as local tombstones and add `messages delete --for-me`. (#64 — thanks @vlassance)
- Messages: add `messages edit` and `messages delete` for editing or revoking your own sent messages. (#80 — thanks @frapeti)
- Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej)
- Messages: add JSON export with `messages export --after` and `--before` filters.
- Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm)
- Contacts: add `contacts import-system` to import macOS Contacts display names as local metadata with alias-first precedence. (#33 — thanks @enki and @octaviofroid)
- Auth: add `auth --qr-format text` to print the raw WhatsApp QR payload for external renderers. (#22 — thanks @teren-papercutlabs)
- Auth: add `auth --phone` for WhatsApp's phone-number pairing flow on headless systems. (#148, #184 — thanks @giovanninibarbosa and @KillerSnails)
- Auth: auto-detect a readable linked-device label and default linked-device platform to desktop. (#100 — thanks @pmatheus)
- Chats: add archive/unarchive, pin/unpin, mute/unmute, and mark-read/mark-unread commands, plus list/show state fields. (#46 — thanks @decodiver22)
- Channels: add WhatsApp Channel list/info/join/leave commands, channel chat caching, and text/file sends to `...@newsletter` JIDs. (#72 — thanks @frapeti)
- Groups: persist WhatsApp Community parent/subgroup metadata from group refresh and info. (#207, #39 — thanks @dinakars777 and @TheMazzle)
- History: add `history coverage` and `history fill --dry-run` to inspect local archive anchors before running best-effort backfill. (#111 — thanks @cropsgg)
- Profile: add `profile set-picture` to update the authenticated account profile picture from JPEG or PNG input. (#198 — thanks @gado-ships-it)
- Sync: add signed live-message webhooks with `--webhook` and `--webhook-secret`. (#203 — thanks @dinakars777 and @Melostack)
- Send: add `send react` to add or clear reactions, with group sender validation. (#151 — thanks @draix)
- Send: add opt-in `send text --message-escapes` for `\n`, `\r`, `\t`, `\\`, and `\"` in `--message`. (#206 — thanks @slaveofcode)
- Send: add `send text --mention` for WhatsApp user mentions in group messages. (#16)
- Send: add `send file --reply-to` for quoted media/document replies. (#68 — thanks @vlassance)
- Send: add repeatable `send text --mention` for WhatsApp user mentions in group messages. (#16 — thanks @nicozefrench and @sheepworrier)
- Send: add automatic link previews for text messages with `--no-preview` opt-out. (#94, #95 — thanks @elgatoflaco)
- Send: add `send sticker` for 512x512 WebP stickers, including animated-sticker metadata. (#205, #27 — thanks @dinakars777 and @fm1randa)
- Send: add `send voice` and `send file --ptt` for OGG/Opus WhatsApp voice notes. (#40, #41 — thanks @ricardopolo and @emre6943)
- Send: accept common phone-number formatting in recipient flags while still storing digits-only WhatsApp JIDs. (#130 — thanks @fahmidme and @ImLukeF)
- Send: resolve `send text/file --to` against local contacts, groups, and chats, with `--pick` for non-interactive disambiguation. (#122 — thanks @AndroidPoet)
- Store: add local-only `store stats`, `store cleanup`, `chats cleanup`, and `groups prune` commands with dry-run previews and confirmation gates. (#210, #211 — thanks @thedavidweng)
### Security
@ -85,7 +35,6 @@
### Fixed
- Auth: retry transient websocket drops before QR or phone pairing completes.
- Auth: propagate QR channel setup errors and surface actionable QR pairing failures. (#100 — thanks @pmatheus)
- Build: fail cgo-disabled CLI builds at compile time instead of shipping a go-sqlite3 stub binary. (#194 — thanks @rajgopalv)
- Chats: resolve mapped historical `@lid` chat rows in `chats list/show` output. (#31, #89 — thanks @bhaskoro-muthohar and @alexph-dev)
@ -101,26 +50,20 @@
- Messages: store forwarded-message metadata and add `--forwarded` filters for list/search. (#24 — thanks @bnvyas)
- Doctor: report lock owner PID and distinguish paired stores locked by another process. (#105 — thanks @artemgetmann)
- Media: recover panics per download job so one bad payload no longer drains the worker pool. (#179 — thanks @shaun0927)
- Media: allow explicit download outputs in shared directories like `/tmp` without trying to chmod the parent directory.
- Messages: attribute history messages from LID-addressed groups to the top-level participant sender. (#19 — thanks @entropyy0)
- Messages: show display text for replies, reactions, and media in `messages context`. (#183 — thanks @fuleinist)
- Send: strip a leading `+` from phone-number recipients before building WhatsApp JIDs. (#74 — thanks @FrederickStempfle)
- Search: keep FTS5 enabled after reopening existing databases with already-applied migrations. (#185 — thanks @iamhitarth)
- Send: delegate send commands through a running `sync --follow` process instead of failing on the store lock. (#6, #48, #92)
- Send: add `send text --reply-to` for quoted replies, with sender inference for synced group messages. (#154 — thanks @draix)
- Send: store outgoing `send react` messages locally so `messages list/show/search` can see the sent reaction immediately.
- Send: validate image uploads and include image dimensions plus a JPEG thumbnail for better client rendering.
- Send: keep the connection alive briefly after successful sends so retry receipts can repair first-send session gaps. (#89 — thanks @alexph-dev)
- Send: bound send attempts and reconnect once for stale-session/time-out failures instead of hanging indefinitely. (#115 — thanks @0xatrilla)
- Send: include the Opus codec parameter when sending OGG audio so WhatsApp delivers it as audio. (#41 — thanks @emre6943)
- Send: persist retry-message plaintext so linked devices can decrypt retried messages. (#186 — thanks @SimDamDev)
- Store: use the XDG state directory on Linux by default, while keeping existing `~/.wacli` stores working. (#172, #164 — thanks @txhno)
- Sync: guard lazy WhatsApp client initialization against concurrent `OpenWA` calls. (#62 — thanks @thakoreh)
- Sync: request a whatsmeow app-state recovery snapshot when LTHash verification fails. (#47 — thanks @elpargo)
- Sync: decrypt encrypted reactions delivered through history sync before storing them. (#192 — thanks @matrixise)
- Sync: resolve live `@lid` chat and sender JIDs to phone-number JIDs before storing messages. (#196 — thanks @mahidconseil)
- Sync: warn when encrypted reaction messages cannot be decrypted instead of dropping the failure silently. (#192 — thanks @matrixise and @dinakars777)
- CLI: emit command errors as NDJSON `error` events when `--events` is enabled.
- Sync: keep `sync --once` idle timing focused on message/history events so connection chatter cannot hang exit. (#119 — thanks @jyothepro)
- Sync: start `sync --once` idle timing after the `Connected` event. (#171 — thanks @fuleinist)
- Sync: include event type, stack trace, and recovery count when logging recovered event-handler panics. (#181 — thanks @shaun0927)
@ -131,15 +74,13 @@
- README: add a documentation index and complete command quick reference.
- Docs: add an overview plus one page for every top-level CLI subcommand.
- Docs: add companion integration guidance for safe read-only SQLite, JSON, events, and webhook usage. (#71 — thanks @jaredtribe)
- Maintainers: add CODEOWNERS and maintainer contact info.
- Agents: add AGENTS.md for AI agent guidance. (#190 — thanks @adhitShet)
### Chore
- CI: compile-test the Windows lock package to catch platform regressions. (#188 — thanks @dinakars777)
- CLI: route `version` output through Cobra's configured output stream for easier command tests. (#78 — thanks @nikolasdehor)
- Dependencies: update Go modules including `whatsmeow`, `go-sqlite3`, `x/*`, and related runtime libs; refresh the pinned pnpm toolchain.
- Dependencies: update Go modules including `whatsmeow`, `go-sqlite3`, `x/*`, and related runtime libs.
- Refactor: split WhatsApp message parsing into focused text, media, business, and context helpers.
- Refactor: inject clocks in app/store paths for deterministic tests.
- Version: bump CLI version string to `0.7.0`.

332
README.md
View File

@ -1,139 +1,261 @@
# 🗃️ 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, 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. 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) |
- [Overview](docs/overview.md): store model, global flags, common flow, command index.
- [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/show/context`.
- [Send](docs/send.md): `send text/file/react`, recipient resolution, replies.
- [Media](docs/media.md): `media download`.
- [Contacts](docs/contacts.md): `contacts search/show/refresh`, aliases, tags.
- [Chats](docs/chats.md): `chats list/show`.
- [Groups](docs/groups.md): group list, refresh, info, rename, leave, participants, invites, join.
- [History](docs/history.md): `history backfill`.
- [Presence](docs/presence.md): `presence typing/paused`.
- [Profile](docs/profile.md): `profile set-picture`.
- [Doctor](docs/doctor.md): `doctor [--connect]`.
- [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.
## Configuration
## Major features
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.
- **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, quoted replies, 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**: search/show contacts, local aliases/tags, list/show chats, refresh/list/info/rename groups, manage participants, invite links, join, and leave; left groups are hidden after leave.
- **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.
**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
## Install / Build
**Environment overrides:**
Choose **one** of the following options.
If you install via Homebrew, you can skip the local build step.
| 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`. |
### 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
# 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>
# Backfill older messages for a chat (best-effort; requires your primary device online)
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
# Download media for a message (after syncing)
pnpm wacli media download --chat 1234567890@s.whatsapp.net --id <message-id>
# 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
# 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"
# Mention one or more users in a group text
pnpm wacli send text --to "Family" --message "@alice can you check this?" --mention 15551234567
# 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 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"
# 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]`
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded]`
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded]`
- `wacli messages show --chat JID --id MSG_ID`
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--mention USER]... [--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 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 alias set|rm --jid JID [--alias NAME]`
- `wacli contacts tags add|rm --jid JID --tag TAG`
- `wacli chats list [--query TEXT] [--limit N]`
- `wacli chats show --jid JID`
- `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 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 version`
- `wacli completion bash|zsh|fish|powershell [--no-descriptions]`
- `wacli help [command]`
`RECIPIENT` for `send text/file` accepts a JID, phone number, 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.
- `--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.
- 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 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,71 @@ 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 := signalContext()
defer stop()
a, lk, err := newApp(ctx, flags, true, true)
if err != nil {
return err
}
defer closeApp(a, lk)
mode := appPkg.SyncModeBootstrap
if follow {
mode = appPkg.SyncModeFollow
}
fmt.Fprintln(os.Stderr, "Starting authentication…")
res, err := a.Sync(ctx, appPkg.SyncOptions{
Mode: mode,
AllowQR: true,
DownloadMedia: downloadMedia,
RefreshContacts: true,
RefreshGroups: true,
IdleExit: idleExit,
OnQRCode: authQRWriter(qrFormat, os.Stdout, os.Stderr),
PairPhoneNumber: pairPhone,
OnPairCode: authPairCodeWriter(pairPhone, os.Stderr),
MaxMessages: maxMessages,
MaxDBSizeBytes: maxDBSize,
WarnNoLimits: true,
})
if err != nil {
return err
}
@ -53,7 +90,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 +102,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 == "" {
@ -160,35 +130,24 @@ func normalizeAuthQRFormat(format string) (string, error) {
}
}
func authQRWriter(format string, stdout, stderr io.Writer, events *out.EventWriter) func(string) {
func authQRWriter(format string, stdout, stderr io.Writer) func(string) {
if format == "text" {
return func(code string) {
if events.Enabled() {
_ = events.Emit("qr_code", map[string]any{"code": code})
}
fmt.Fprintln(stdout, code)
}
}
return func(code string) {
if events.Enabled() {
_ = events.Emit("qr_code", map[string]any{"code": code})
return
}
fmt.Fprintln(stderr, "\nScan this QR code with WhatsApp (Linked Devices):")
qrterminal.GenerateHalfBlock(code, qrterminal.M, stderr)
fmt.Fprintln(stderr)
}
}
func authPairCodeWriter(phone string, stderr io.Writer, events *out.EventWriter) func(string) {
func authPairCodeWriter(phone string, stderr io.Writer) func(string) {
if phone == "" {
return nil
}
return func(code string) {
if events.Enabled() {
_ = events.Emit("pair_code", map[string]any{"phone": phone, "code": code})
return
}
fmt.Fprintf(stderr, "\nPairing code for +%s: %s\n", phone, code)
fmt.Fprintln(stderr, "On your phone: WhatsApp > Linked Devices > Link a Device > Link with phone number.")
fmt.Fprintln(stderr, "Enter the code above and keep this command running until authentication completes.")

View File

@ -81,7 +81,7 @@ func TestNormalizeAuthQRFormat(t *testing.T) {
func TestAuthQRWriterText(t *testing.T) {
var stdout, stderr bytes.Buffer
authQRWriter("text", &stdout, &stderr, nil)("2@test-code")
authQRWriter("text", &stdout, &stderr)("2@test-code")
if got := strings.TrimSpace(stdout.String()); got != "2@test-code" {
t.Fatalf("stdout = %q", got)
}
@ -121,7 +121,7 @@ func TestNormalizePairPhone(t *testing.T) {
func TestAuthPairCodeWriter(t *testing.T) {
var stderr bytes.Buffer
writer := authPairCodeWriter("15551234567", &stderr, nil)
writer := authPairCodeWriter("15551234567", &stderr)
if writer == nil {
t.Fatal("expected writer")
}
@ -130,7 +130,7 @@ func TestAuthPairCodeWriter(t *testing.T) {
if !strings.Contains(got, "Pairing code for +15551234567: ABCD-1234") {
t.Fatalf("stderr = %q", got)
}
if authPairCodeWriter("", &stderr, nil) != nil {
if authPairCodeWriter("", &stderr) != nil {
t.Fatal("expected nil writer without phone")
}
}

View File

@ -1,301 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func newChannelsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "channels",
Short: "Manage WhatsApp channels",
}
cmd.AddCommand(newChannelsListCmd(flags))
cmd.AddCommand(newChannelsInfoCmd(flags))
cmd.AddCommand(newChannelsJoinCmd(flags))
cmd.AddCommand(newChannelsLeaveCmd(flags))
return cmd
}
func newChannelsListCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List subscribed channels (live) and update local chats",
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
list, err := a.WA().GetSubscribedNewsletters(ctx)
if err != nil {
return err
}
rows := channelRecords(list)
persistChannelRecords(a.DB(), rows)
if flags.asJSON {
return out.WriteJSON(os.Stdout, rows)
}
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "NAME\tJID\tROLE\tSTATE\tSUBSCRIBERS\tDESCRIPTION")
fullOutput := fullTableOutput(flags.fullOutput)
for _, row := range rows {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n",
tableCell(row.Name, 40, fullOutput),
row.JID,
row.Role,
row.State,
row.Subscribers,
tableCell(strings.ReplaceAll(row.Description, "\n", " "), 50, fullOutput),
)
}
_ = w.Flush()
return nil
},
}
return cmd
}
func newChannelsInfoCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "info",
Short: "Fetch channel info (live) and update local chats",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := parseChannelJID(jidStr)
if err != nil {
return err
}
meta, err := a.WA().GetNewsletterInfo(ctx, jid)
if err != nil {
return err
}
if meta == nil {
return fmt.Errorf("channel not found")
}
row := channelRecordFromMeta(meta)
persistChannelRecords(a.DB(), []channelRecord{row})
if flags.asJSON {
return out.WriteJSON(os.Stdout, row)
}
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nDescription: %s\nState: %s\nSubscribers: %d\n",
row.JID,
row.Name,
row.Description,
row.State,
row.Subscribers,
)
if row.Role != "" {
fmt.Fprintf(os.Stdout, "Role: %s\nMute: %s\n", row.Role, row.Mute)
}
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "channel JID (...@newsletter)")
return cmd
}
func newChannelsJoinCmd(flags *rootFlags) *cobra.Command {
var invite string
cmd := &cobra.Command{
Use: "join",
Short: "Join a channel via invite link or code",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(invite) == "" {
return fmt.Errorf("--invite is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
meta, err := a.WA().GetNewsletterInfoWithInvite(ctx, strings.TrimSpace(invite))
if err != nil {
return err
}
if meta == nil {
return fmt.Errorf("could not resolve channel from invite")
}
if err := a.WA().FollowNewsletter(ctx, meta.ID); err != nil {
return err
}
row := channelRecordFromMeta(meta)
persistChannelRecords(a.DB(), []channelRecord{row})
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"joined": true, "channel": row})
}
fmt.Fprintf(os.Stdout, "Joined channel %s (%s).\n", row.Name, row.JID)
return nil
},
}
cmd.Flags().StringVar(&invite, "invite", "", "invite link or code, e.g. https://whatsapp.com/channel/...")
return cmd
}
func newChannelsLeaveCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "leave",
Short: "Leave (unfollow) a channel",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := parseChannelJID(jidStr)
if err != nil {
return err
}
if err := a.WA().UnfollowNewsletter(ctx, jid); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"left": true, "jid": jid.String()})
}
fmt.Fprintf(os.Stdout, "Left channel %s.\n", jid.String())
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "channel JID (...@newsletter)")
return cmd
}
type channelRecord struct {
JID string `json:"jid"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Role string `json:"role,omitempty"`
Mute string `json:"mute,omitempty"`
State string `json:"state,omitempty"`
Subscribers int `json:"subscribers,omitempty"`
}
func channelRecords(list []*types.NewsletterMetadata) []channelRecord {
rows := make([]channelRecord, 0, len(list))
for _, meta := range list {
if meta == nil {
continue
}
rows = append(rows, channelRecordFromMeta(meta))
}
return rows
}
func channelRecordFromMeta(meta *types.NewsletterMetadata) channelRecord {
row := channelRecord{
JID: meta.ID.String(),
Name: wa.NewsletterName(meta),
Description: strings.TrimSpace(meta.ThreadMeta.Description.Text),
State: string(meta.State.Type),
Subscribers: meta.ThreadMeta.SubscriberCount,
}
if row.Name == "" {
row.Name = row.JID
}
if meta.ViewerMeta != nil {
row.Role = string(meta.ViewerMeta.Role)
row.Mute = string(meta.ViewerMeta.Mute)
}
return row
}
func persistChannelRecords(db *store.DB, rows []channelRecord) {
now := time.Now().UTC()
for _, row := range rows {
_ = db.UpsertChat(row.JID, "newsletter", row.Name, now)
}
}
func parseChannelJID(raw string) (types.JID, error) {
jid, err := types.ParseJID(strings.TrimSpace(raw))
if err != nil {
return types.JID{}, err
}
if jid.Server != types.NewsletterServer {
return types.JID{}, fmt.Errorf("JID must be a channel (...@newsletter)")
}
return jid, nil
}

View File

@ -1,50 +0,0 @@
package main
import (
"testing"
"go.mau.fi/whatsmeow/types"
)
func TestChannelRecordFromMeta(t *testing.T) {
jid := types.JID{User: "123", Server: types.NewsletterServer}
row := channelRecordFromMeta(&types.NewsletterMetadata{
ID: jid,
State: types.WrappedNewsletterState{
Type: types.NewsletterStateActive,
},
ThreadMeta: types.NewsletterThreadMetadata{
Name: types.NewsletterText{Text: " News "},
Description: types.NewsletterText{Text: "Updates"},
SubscriberCount: 42,
},
ViewerMeta: &types.NewsletterViewerMetadata{
Role: types.NewsletterRoleAdmin,
Mute: types.NewsletterMuteOff,
},
})
if row.JID != jid.String() || row.Name != "News" || row.Role != "admin" || row.Mute != "off" || row.State != "active" || row.Subscribers != 42 {
t.Fatalf("unexpected row: %+v", row)
}
}
func TestParseChannelJIDRejectsNonChannel(t *testing.T) {
if _, err := parseChannelJID("123@s.whatsapp.net"); err == nil {
t.Fatal("expected non-channel JID to fail")
}
jid, err := parseChannelJID("123@newsletter")
if err != nil {
t.Fatalf("parseChannelJID: %v", err)
}
if jid.Server != types.NewsletterServer {
t.Fatalf("server = %q", jid.Server)
}
}
func TestChatKindFromJIDNewsletter(t *testing.T) {
got := chatKindFromJID(types.JID{User: "123", Server: types.NewsletterServer})
if got != "newsletter" {
t.Fatalf("chatKindFromJID = %q", got)
}
}

View File

@ -11,56 +11,30 @@ 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"
)
func newChatsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "chats",
Short: "List and manage chats",
Short: "List chats from the local DB",
}
cmd.AddCommand(newChatsListCmd(flags))
cmd.AddCommand(newChatsShowCmd(flags))
cmd.AddCommand(newChatsArchiveCmd(flags, true))
cmd.AddCommand(newChatsArchiveCmd(flags, false))
cmd.AddCommand(newChatsPinCmd(flags, true))
cmd.AddCommand(newChatsPinCmd(flags, false))
cmd.AddCommand(newChatsMuteCmd(flags))
cmd.AddCommand(newChatsUnmuteCmd(flags))
cmd.AddCommand(newChatsMarkReadCmd(flags, true))
cmd.AddCommand(newChatsMarkReadCmd(flags, false))
cmd.AddCommand(newChatsCleanupCmd(flags))
return cmd
}
func newChatsListCmd(flags *rootFlags) *cobra.Command {
var query string
var limit int
var archived, noArchived bool
var pinned, noPinned bool
var muted, noMuted bool
var unread, noUnread bool
cmd := &cobra.Command{
Use: "list",
Short: "List chats",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateBoolFilter("archived", archived, noArchived); err != nil {
return err
}
if err := validateBoolFilter("pinned", pinned, noPinned); err != nil {
return err
}
if err := validateBoolFilter("muted", muted, noMuted); err != nil {
return err
}
if err := validateBoolFilter("unread", unread, noUnread); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
@ -70,15 +44,7 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
filter := store.ChatListFilter{
Query: query,
Limit: limit,
Archived: boolFilter(archived, noArchived),
Pinned: boolFilter(pinned, noPinned),
Muted: boolFilter(muted, noMuted),
Unread: boolFilter(unread, noUnread),
}
chats, err := a.DB().ListChatsFiltered(filter)
chats, err := a.DB().ListChats(query, limit)
if err != nil {
return err
}
@ -89,13 +55,13 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
fullOutput := fullTableOutput(flags.fullOutput)
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST\tFLAGS")
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST")
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", c.Kind, tableCell(name, 28, fullOutput), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"), chatFlagsString(c))
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Kind, tableCell(name, 28, fullOutput), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"))
}
_ = w.Flush()
return nil
@ -103,14 +69,6 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
}
cmd.Flags().StringVar(&query, "query", "", "search query")
cmd.Flags().IntVar(&limit, "limit", 50, "limit")
cmd.Flags().BoolVar(&archived, "archived", false, "show only archived chats")
cmd.Flags().BoolVar(&noArchived, "no-archived", false, "exclude archived chats")
cmd.Flags().BoolVar(&pinned, "pinned", false, "show only pinned chats")
cmd.Flags().BoolVar(&noPinned, "no-pinned", false, "exclude pinned chats")
cmd.Flags().BoolVar(&muted, "muted", false, "show only muted chats")
cmd.Flags().BoolVar(&noMuted, "no-muted", false, "exclude muted chats")
cmd.Flags().BoolVar(&unread, "unread", false, "show only unread chats")
cmd.Flags().BoolVar(&noUnread, "no-unread", false, "exclude unread chats")
return cmd
}
@ -139,8 +97,7 @@ func newChatsShowCmd(flags *rootFlags) *cobra.Command {
if flags.asJSON {
return out.WriteJSON(os.Stdout, c)
}
fmt.Fprintf(os.Stdout, "JID: %s\nKind: %s\nName: %s\nLast: %s\nArchived: %t\nPinned: %t\nMuted: %t\nMuted until: %s\nUnread: %t\n",
c.JID, c.Kind, c.Name, c.LastMessageTS.Local().Format(time.RFC3339), c.Archived, c.Pinned, c.Muted(), formatMutedUntil(c.MutedUntil), c.Unread)
fmt.Fprintf(os.Stdout, "JID: %s\nKind: %s\nName: %s\nLast: %s\n", c.JID, c.Kind, c.Name, c.LastMessageTS.Local().Format(time.RFC3339))
return nil
},
}

View File

@ -1,166 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newChatsCleanupCmd(flags *rootFlags) *cobra.Command {
var days int
var jid string
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up old chats from local storage",
Long: `Clean up chats that have no recent activity.
By default, removes chats with no messages in the last 365 days.
Use --days to adjust the threshold. Use --dry-run to preview what would be deleted.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if jid != "" {
return cleanupSingleChat(ctx, a, jid, dryRun, confirm, flags.asJSON)
}
chats, err := a.DB().ListChatsOlderThan(days)
if err != nil {
return err
}
if len(chats) == 0 {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "no chats to clean up"})
}
fmt.Fprintln(os.Stderr, "No chats to clean up.")
return nil
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_delete": len(chats), "chats": chats})
}
fmt.Fprintf(os.Stderr, "Would delete %d chat(s):\n", len(chats))
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, " - %s (%s)\n", name, c.JID)
}
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d chat(s). This cannot be undone.\n", len(chats))
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deleted int
for _, c := range chats {
msgCount, _ := a.DB().CountChatMessages(c.JID)
if err := a.DB().DeleteChat(c.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete chat %s: %v\n", c.JID, err)
continue
}
deleted++
if !flags.asJSON {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s (%d messages)\n", name, msgCount)
}
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": deleted})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d chat(s).\n", deleted)
return nil
},
}
cmd.Flags().IntVar(&days, "days", 365, "delete chats with no messages in the last N days")
cmd.Flags().StringVar(&jid, "jid", "", "delete a specific chat by JID")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}
func cleanupSingleChat(ctx context.Context, a *app.App, jid string, dryRun, confirm, asJSON bool) error {
chat, err := a.DB().GetChat(jid)
if err != nil {
return fmt.Errorf("chat not found: %s", jid)
}
msgCount, _ := a.DB().CountChatMessages(jid)
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"would_delete": 1,
"chat": chat,
"message_count": msgCount,
})
}
name := chat.Name
if name == "" {
name = chat.JID
}
fmt.Fprintf(os.Stderr, "Would delete chat: %s (%s, %d messages)\n", name, chat.JID, msgCount)
return nil
}
if !confirm {
name := chat.Name
if name == "" {
name = chat.JID
}
fmt.Fprintf(os.Stderr, "About to delete chat: %s (%s, %d messages). This cannot be undone.\n", name, chat.JID, msgCount)
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
if err := a.DB().DeleteChat(jid); err != nil {
return err
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 1, "jid": jid, "messages_deleted": msgCount})
}
fmt.Fprintf(os.Stderr, "Deleted chat %s (%d messages)\n", jid, msgCount)
return nil
}

View File

@ -1,210 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
type chatStateOptions struct {
chat string
pick int
}
func newChatsArchiveCmd(flags *rootFlags, archive bool) *cobra.Command {
use, short := "archive", "Archive a chat"
if !archive {
use, short = "unarchive", "Unarchive a chat"
}
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: use,
Short: short,
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, use, func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.ArchiveChat(ctx, jid, archive)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
func newChatsPinCmd(flags *rootFlags, pin bool) *cobra.Command {
use, short := "pin", "Pin a chat"
if !pin {
use, short = "unpin", "Unpin a chat"
}
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: use,
Short: short,
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, use, func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.PinChat(ctx, jid, pin)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
func newChatsMuteCmd(flags *rootFlags) *cobra.Command {
opts := chatStateOptions{}
var duration time.Duration
cmd := &cobra.Command{
Use: "mute",
Short: "Mute a chat",
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, "mute", func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.MuteChat(ctx, jid, true, duration)
})
},
}
addChatStateFlags(cmd, &opts)
cmd.Flags().DurationVar(&duration, "duration", 0, "mute duration (for example 8h, 24h, 168h); 0 means forever")
return cmd
}
func newChatsUnmuteCmd(flags *rootFlags) *cobra.Command {
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: "unmute",
Short: "Unmute a chat",
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, "unmute", func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.MuteChat(ctx, jid, false, 0)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
func newChatsMarkReadCmd(flags *rootFlags, read bool) *cobra.Command {
use, short := "mark-read", "Mark a chat as read"
if !read {
use, short = "mark-unread", "Mark a chat as unread"
}
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: use,
Short: short,
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, use, func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.MarkChatRead(ctx, jid, read)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
type chatStateApp interface {
ArchiveChat(context.Context, types.JID, bool) error
PinChat(context.Context, types.JID, bool) error
MuteChat(context.Context, types.JID, bool, time.Duration) error
MarkChatRead(context.Context, types.JID, bool) error
}
func runChatState(flags *rootFlags, opts chatStateOptions, action string, run func(context.Context, chatStateApp, types.JID) error) error {
if strings.TrimSpace(opts.chat) == "" {
return fmt.Errorf("--chat is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := resolveRecipient(a, opts.chat, recipientOptions{pick: opts.pick, asJSON: flags.asJSON})
if err != nil {
return err
}
if err := run(ctx, a, jid); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"ok": true,
"action": action,
"chat": jid.String(),
})
}
fmt.Fprintf(os.Stdout, "%s: %s\n", action, jid.String())
return nil
}
func addChatStateFlags(cmd *cobra.Command, opts *chatStateOptions) {
cmd.Flags().StringVar(&opts.chat, "chat", "", "chat name, phone number, or JID")
cmd.Flags().IntVar(&opts.pick, "pick", 0, "choose match N when --chat is ambiguous")
}
func validateBoolFilter(name string, pos, neg bool) error {
if pos && neg {
return fmt.Errorf("--%s and --no-%s are mutually exclusive", name, name)
}
return nil
}
func boolFilter(pos, neg bool) *bool {
if pos {
v := true
return &v
}
if neg {
v := false
return &v
}
return nil
}
func chatFlagsString(c store.Chat) string {
var flags []string
if c.Pinned {
flags = append(flags, "pinned")
}
if c.Archived {
flags = append(flags, "archived")
}
if c.Muted() {
flags = append(flags, "muted")
}
if c.Unread {
flags = append(flags, "unread")
}
return strings.Join(flags, ",")
}
func formatMutedUntil(until int64) string {
switch {
case until == -1:
return "forever"
case until > 0:
return time.Unix(until, 0).Local().Format(time.RFC3339)
default:
return ""
}
}

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"
)
@ -82,16 +82,3 @@ func TestResolveStoredChatsMergesMappedDuplicates(t *testing.T) {
t.Fatalf("merged chat = %+v", got[0])
}
}
func TestChatFlagsString(t *testing.T) {
got := chatFlagsString(store.Chat{Pinned: true, Archived: true, MutedUntil: -1, Unread: true})
if got != "pinned,archived,muted,unread" {
t.Fatalf("flags = %q", got)
}
if err := validateBoolFilter("archived", true, true); err == nil {
t.Fatal("expected mutually exclusive filter error")
}
if err := validateBoolFilter("archived", true, false); err != nil {
t.Fatalf("unexpected filter error: %v", err)
}
}

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 {
@ -18,7 +18,6 @@ func newContactsCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newContactsSearchCmd(flags))
cmd.AddCommand(newContactsShowCmd(flags))
cmd.AddCommand(newContactsRefreshCmd(flags))
cmd.AddCommand(newContactsImportSystemCmd(flags))
cmd.AddCommand(newContactsAliasCmd(flags))
cmd.AddCommand(newContactsTagsCmd(flags))
return cmd
@ -105,9 +104,6 @@ func newContactsShowCmd(flags *rootFlags) *cobra.Command {
if c.Alias != "" {
fmt.Fprintf(os.Stdout, "Alias: %s\n", c.Alias)
}
if c.SystemName != "" {
fmt.Fprintf(os.Stdout, "System Name: %s\n", c.SystemName)
}
if len(c.Tags) > 0 {
fmt.Fprintf(os.Stdout, "Tags: %s\n", strings.Join(c.Tags, ", "))
}

View File

@ -1,182 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/syscontacts"
"github.com/spf13/cobra"
)
type systemContactMatch struct {
JID string `json:"jid"`
Phone string `json:"phone"`
CurrentName string `json:"current_name"`
SystemName string `json:"system_name"`
ExistingValue string `json:"existing_system_name,omitempty"`
}
func newContactsImportSystemCmd(flags *rootFlags) *cobra.Command {
var dryRun bool
var clear bool
var input string
cmd := &cobra.Command{
Use: "import-system",
Short: "Import display names from macOS Contacts",
Long: `Import display names from macOS Contacts and store them as local system names.
System names are local wacli metadata. They do not edit WhatsApp contacts or
macOS Contacts. Display precedence is: alias, system name, WhatsApp names.
On macOS, the default source is the Contacts framework. Use --input to import
from a JSON array or NDJSON file with fields first_name, last_name, full_name,
and phones.`,
RunE: func(cmd *cobra.Command, args []string) error {
if !dryRun {
if err := flags.requireWritable(); err != nil {
return err
}
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, !dryRun, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if clear {
return runContactsSystemClear(a.DB(), dryRun, flags.asJSON)
}
systemContacts, err := readSystemContacts(ctx, input)
if err != nil {
return err
}
phoneToName := syscontacts.PhoneToName(systemContacts)
localContacts, err := a.DB().ListContacts(0)
if err != nil {
return err
}
matches, skippedNoPhone, skippedNoMatch, skippedSame := matchSystemContacts(localContacts, phoneToName)
result := map[string]any{
"matched": len(matches),
"matches": matches,
"skipped_no_phone": skippedNoPhone,
"skipped_no_match": skippedNoMatch,
"skipped_same": skippedSame,
"dry_run": dryRun,
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, result)
}
writeSystemImportPreview(matches, skippedNoPhone, skippedNoMatch, skippedSame)
return nil
}
applied := 0
for _, m := range matches {
if err := a.DB().SetSystemName(m.JID, m.SystemName); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to set system name for %s: %v\n", m.JID, err)
continue
}
applied++
}
result["applied"] = applied
if flags.asJSON {
return out.WriteJSON(os.Stdout, result)
}
fmt.Fprintf(os.Stdout, "Applied %d system contact name(s).\n", applied)
return nil
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be imported without writing")
cmd.Flags().BoolVar(&clear, "clear", false, "clear all imported system names")
cmd.Flags().StringVar(&input, "input", "", "read system contacts from JSON/NDJSON instead of macOS Contacts")
return cmd
}
func readSystemContacts(ctx context.Context, input string) ([]syscontacts.Contact, error) {
if input != "" {
return syscontacts.ReadFile(input)
}
return syscontacts.ReadSystem(ctx)
}
func runContactsSystemClear(db *store.DB, dryRun, asJSON bool) error {
count, err := db.CountSystemNames()
if err != nil {
return err
}
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_clear": count, "dry_run": true})
}
fmt.Fprintf(os.Stdout, "Would clear %d system contact name(s).\n", count)
return nil
}
cleared, err := db.ClearAllSystemNames()
if err != nil {
return err
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"cleared": cleared})
}
fmt.Fprintf(os.Stdout, "Cleared %d system contact name(s).\n", cleared)
return nil
}
func matchSystemContacts(local []store.Contact, phoneToName map[string]string) ([]systemContactMatch, int, int, int) {
var matches []systemContactMatch
var skippedNoPhone, skippedNoMatch, skippedSame int
for _, c := range local {
phone := syscontacts.NormalizePhone(c.Phone)
if phone == "" {
skippedNoPhone++
continue
}
systemName, ok := phoneToName[phone]
if !ok {
skippedNoMatch++
continue
}
if c.SystemName == systemName {
skippedSame++
continue
}
matches = append(matches, systemContactMatch{
JID: c.JID,
Phone: c.Phone,
CurrentName: c.Name,
SystemName: systemName,
ExistingValue: c.SystemName,
})
}
return matches, skippedNoPhone, skippedNoMatch, skippedSame
}
func writeSystemImportPreview(matches []systemContactMatch, skippedNoPhone, skippedNoMatch, skippedSame int) {
fmt.Fprintf(os.Stdout, "Would import %d system contact name(s).\n", len(matches))
fmt.Fprintf(os.Stdout, "Skipped: %d no phone, %d no match, %d already current.\n", skippedNoPhone, skippedNoMatch, skippedSame)
if len(matches) == 0 {
return
}
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "PHONE\tCURRENT\tSYSTEM")
for _, m := range matches {
fmt.Fprintf(w, "%s\t%s\t%s\n",
tableCell(m.Phone, 16, false),
tableCell(m.CurrentName, 24, false),
tableCell(m.SystemName, 24, false),
)
}
_ = w.Flush()
}

View File

@ -1,99 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
)
func TestContactsImportSystemFromInputDryRunDoesNotWrite(t *testing.T) {
storeDir, input := seedSystemImportStore(t)
cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--input", input, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("contacts import-system dry-run: %v", err)
}
db := openSystemImportStore(t, storeDir)
defer db.Close()
c, err := db.GetContact("14157347847@s.whatsapp.net")
if err != nil {
t.Fatalf("GetContact: %v", err)
}
if c.SystemName != "" {
t.Fatalf("dry-run wrote system name: %#v", c)
}
}
func TestContactsImportSystemFromInputWritesAndClears(t *testing.T) {
storeDir, input := seedSystemImportStore(t)
cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--input", input})
if err := cmd.Execute(); err != nil {
t.Fatalf("contacts import-system: %v", err)
}
db := openSystemImportStore(t, storeDir)
c, err := db.GetContact("14157347847@s.whatsapp.net")
if err != nil {
t.Fatalf("GetContact: %v", err)
}
if c.SystemName != "Alice Appleseed" || c.Name != "Alice Appleseed" {
t.Fatalf("contact = %#v", c)
}
_ = db.Close()
clearCmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
clearCmd.SetArgs([]string{"--clear"})
if err := clearCmd.Execute(); err != nil {
t.Fatalf("contacts import-system --clear: %v", err)
}
db = openSystemImportStore(t, storeDir)
defer db.Close()
c, err = db.GetContact("14157347847@s.whatsapp.net")
if err != nil {
t.Fatalf("GetContact after clear: %v", err)
}
if c.SystemName != "" {
t.Fatalf("clear left system name: %#v", c)
}
}
func seedSystemImportStore(t *testing.T) (string, string) {
t.Helper()
storeDir := t.TempDir()
db := openSystemImportStore(t, storeDir)
if err := db.UpsertContact("14157347847@s.whatsapp.net", "14157347847", "WhatsApp Alice", "", "", ""); err != nil {
t.Fatalf("UpsertContact: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
input := filepath.Join(storeDir, "contacts.json")
raw, err := json.Marshal([]map[string]any{
{"full_name": "Alice Appleseed", "phones": []string{"+1 (415) 734-7847"}},
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(input, raw, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return storeDir, input
}
func openSystemImportStore(t *testing.T, storeDir string) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
return db
}

View File

@ -1,23 +0,0 @@
package main
import (
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newDocsCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "docs",
Short: "Print documentation URL",
RunE: func(cmd *cobra.Command, args []string) error {
if flags != nil && flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]string{"url": docsURL})
}
_, err := fmt.Fprintln(os.Stdout, docsURL)
return err
},
}
}

View File

@ -1,55 +0,0 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestDocsCommandPrintsDocsURL(t *testing.T) {
out := captureRootStdout(t, func() {
if err := execute([]string{"docs"}); err != nil {
t.Fatalf("execute docs: %v", err)
}
})
if strings.TrimSpace(out) != docsURL {
t.Fatalf("docs output = %q, want %q", out, docsURL)
}
}
func TestDocsCommandJSON(t *testing.T) {
out := captureRootStdout(t, func() {
if err := execute([]string{"--json", "docs"}); err != nil {
t.Fatalf("execute docs --json: %v", err)
}
})
var got struct {
Success bool `json:"success"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("docs JSON = %q: %v", out, err)
}
if !got.Success || got.Data.URL != docsURL {
t.Fatalf("docs JSON = %+v, want url %q", got, docsURL)
}
}
func TestRootHelpShowsDocsURL(t *testing.T) {
out := captureRootStdout(t, func() {
if err := execute([]string{"--help"}); err != nil {
t.Fatalf("execute --help: %v", err)
}
})
if !strings.Contains(out, docsURL) {
t.Fatalf("root help did not include docs URL: %q", out)
}
if !strings.Contains(out, "docs") {
t.Fatalf("root help did not include docs command: %q", out)
}
}

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

@ -15,6 +15,5 @@ func newGroupsCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newGroupsInviteCmd(flags))
cmd.AddCommand(newGroupsJoinCmd(flags))
cmd.AddCommand(newGroupsLeaveCmd(flags))
cmd.AddCommand(newGroupsPruneCmd(flags))
return cmd
}

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"
)
@ -56,16 +56,10 @@ func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
return out.WriteJSON(os.Stdout, info)
}
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nType: %s\n",
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nCreated: %s\nParticipants: %d\n",
info.JID.String(),
info.GroupName.Name,
info.OwnerJID.String(),
groupKindLabel(info.IsParent, info.LinkedParentJID.String()),
)
if !info.LinkedParentJID.IsEmpty() {
fmt.Fprintf(os.Stdout, "Parent: %s\n", info.LinkedParentJID.String())
}
fmt.Fprintf(os.Stdout, "Created: %s\nParticipants: %d\n",
info.GroupCreated.Local().Format(time.RFC3339),
len(info.Participants),
)

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"
)
@ -16,14 +16,7 @@ func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
if info == nil {
return nil
}
if err := db.UpsertGroupWithHierarchy(
info.JID.String(),
info.GroupName.Name,
info.OwnerJID.String(),
info.GroupCreated,
info.IsParent,
info.LinkedParentJID.String(),
); err != nil {
if err := db.UpsertGroup(info.JID.String(), info.GroupName.Name, info.OwnerJID.String(), info.GroupCreated); err != nil {
return err
}
var ps []store.GroupParticipant
@ -42,13 +35,3 @@ func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
}
return db.ReplaceGroupParticipants(info.JID.String(), ps)
}
func groupKindLabel(isParent bool, linkedParentJID string) string {
if isParent {
return "community"
}
if linkedParentJID != "" {
return "subgroup"
}
return "group"
}

View File

@ -1,135 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
)
func newGroupsPruneCmd(flags *rootFlags) *cobra.Command {
var days int
var leftOnly bool
var includeActive bool
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "prune",
Short: "Remove old or left groups from local storage",
Long: `Clean up groups that you have left or that have been inactive.
By default, removes groups you have left. Use --days to prune only left
groups older than the threshold. Add --include-active to also prune active
groups whose last local message is older than the threshold.
This only deletes local wacli store rows. It does not leave WhatsApp groups
or delete anything from WhatsApp servers. Use --dry-run to preview targets.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
if days < 0 {
return fmt.Errorf("days must not be negative")
}
if !leftOnly {
includeActive = true
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
return pruneGroups(a, days, includeActive, dryRun, confirm, flags.asJSON)
},
}
cmd.Flags().IntVar(&days, "days", 0, "prune groups older than N days (0 = all left groups)")
cmd.Flags().BoolVar(&leftOnly, "left-only", true, "only remove groups you have left")
cmd.Flags().BoolVar(&includeActive, "include-active", false, "also remove active groups with no messages in the last N days")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}
func pruneGroups(a *app.App, days int, includeActive, dryRun, confirm, asJSON bool) error {
groups, err := a.DB().ListPrunableGroups(days, includeActive)
if err != nil {
return err
}
if len(groups) == 0 {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "no groups to prune"})
}
fmt.Fprintln(os.Stderr, "No groups to prune.")
return nil
}
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_delete": len(groups), "groups": groups})
}
writePruneTargets(os.Stderr, "Would delete", groups)
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d group(s) from the local wacli store. This cannot be undone.\n", len(groups))
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deleted int
for _, g := range groups {
if err := a.DB().DeleteGroupLocalData(g.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete group %s: %v\n", g.JID, err)
continue
}
deleted++
if !asJSON {
name := g.Name
if name == "" {
name = g.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s\n", name)
}
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": deleted})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d group(s).\n", deleted)
return nil
}
func writePruneTargets(w *os.File, prefix string, groups []store.Group) {
fmt.Fprintf(w, "%s %d group(s):\n", prefix, len(groups))
for _, g := range groups {
name := g.Name
if name == "" {
name = g.JID
}
state := "left"
if g.LeftAt.IsZero() {
state = "inactive"
}
fmt.Fprintf(w, " - %s (%s, %s)\n", name, g.JID, state)
}
}

View File

@ -1,117 +0,0 @@
package main
import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
)
func TestGroupsPruneExposesSafetyFlags(t *testing.T) {
cmd := newGroupsPruneCmd(&rootFlags{})
for _, name := range []string{"days", "left-only", "include-active", "dry-run", "confirm"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestGroupsPruneRejectsReadOnlyBeforeOpeningStore(t *testing.T) {
cmd := newGroupsPruneCmd(&rootFlags{readOnly: true})
cmd.SetArgs([]string{"--dry-run"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "read-only mode") {
t.Fatalf("error = %v, want read-only", err)
}
}
func TestGroupsPruneDryRunDoesNotDeleteOlderLeftGroups(t *testing.T) {
storeDir := seedPruneStore(t)
cmd := newGroupsPruneCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--days", "180", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("groups prune dry-run: %v", err)
}
db := openPruneStore(t, storeDir)
defer db.Close()
if _, err := db.GetChat("old-left@g.us"); err != nil {
t.Fatalf("old-left chat should survive dry-run: %v", err)
}
left, err := db.ListPrunableGroups(180, false)
if err != nil {
t.Fatalf("ListPrunableGroups: %v", err)
}
if got := len(left); got != 1 {
t.Fatalf("dry-run deleted targets: got %d left, want 1", got)
}
}
func TestGroupsPruneConfirmDeletesOnlyMatchingGroups(t *testing.T) {
storeDir := seedPruneStore(t)
cmd := newGroupsPruneCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--days", "180", "--confirm"})
if err := cmd.Execute(); err != nil {
t.Fatalf("groups prune confirm: %v", err)
}
db := openPruneStore(t, storeDir)
defer db.Close()
if _, err := db.GetChat("old-left@g.us"); err == nil {
t.Fatalf("old-left chat should be deleted")
}
for _, jid := range []string{"recent-left@g.us", "old-active@g.us"} {
if _, err := db.GetChat(jid); err != nil {
t.Fatalf("%s chat should survive: %v", jid, err)
}
}
}
func seedPruneStore(t *testing.T) string {
t.Helper()
storeDir := t.TempDir()
db := openPruneStore(t, storeDir)
defer db.Close()
now := time.Now().UTC()
created := now.AddDate(0, 0, -400)
oldLeft := now.AddDate(0, 0, -200)
recentLeft := now.AddDate(0, 0, -30)
oldActive := now.AddDate(0, 0, -220)
for _, tc := range []struct {
jid string
name string
lastTS time.Time
leftAt time.Time
}{
{"old-left@g.us", "Old Left", oldLeft, oldLeft},
{"recent-left@g.us", "Recent Left", recentLeft, recentLeft},
{"old-active@g.us", "Old Active", oldActive, time.Time{}},
} {
if err := db.UpsertGroup(tc.jid, tc.name, "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup %s: %v", tc.jid, err)
}
if err := db.UpsertChat(tc.jid, "group", tc.name, tc.lastTS); err != nil {
t.Fatalf("UpsertChat %s: %v", tc.jid, err)
}
if !tc.leftAt.IsZero() {
if err := db.MarkGroupLeft(tc.jid, tc.leftAt); err != nil {
t.Fatalf("MarkGroupLeft %s: %v", tc.jid, err)
}
}
}
return storeDir
}
func openPruneStore(t *testing.T, storeDir string) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
return db
}

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 {
@ -88,23 +88,13 @@ func newGroupsListCmd(flags *rootFlags) *cobra.Command {
fullOutput := fullTableOutput(flags.fullOutput)
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "NAME\tJID\tTYPE\tPARENT\tCREATED")
fmt.Fprintln(w, "NAME\tJID\tCREATED")
for _, g := range gs {
name := g.Name
if name == "" {
name = g.JID
}
parent := g.LinkedParentJID
if parent == "" {
parent = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
tableCell(name, 40, fullOutput),
g.JID,
groupKindLabel(g.IsParent, g.LinkedParentJID),
parent,
g.CreatedAt.Local().Format("2006-01-02"),
)
fmt.Fprintf(w, "%s\t%s\t%s\n", tableCell(name, 40, fullOutput), g.JID, g.CreatedAt.Local().Format("2006-01-02"))
}
_ = w.Flush()
return nil

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

@ -2,129 +2,23 @@ package main
import (
"fmt"
"io"
"os"
"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"
)
func newHistoryCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "history",
Short: "History coverage and backfill",
Short: "History backfill (best-effort; requires prior auth)",
}
cmd.AddCommand(newHistoryCoverageCmd(flags))
cmd.AddCommand(newHistoryFillCmd(flags))
cmd.AddCommand(newHistoryBackfillCmd(flags))
return cmd
}
func newHistoryCoverageCmd(flags *rootFlags) *cobra.Command {
var chats []string
var query string
var kind string
var limit int
var includeBlocked bool
var onlyActionable bool
cmd := &cobra.Command{
Use: "coverage",
Short: "Show local archive coverage by chat",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(cmd.Context(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, true)
if err != nil {
return err
}
defer closeApp(a, lk)
coverage, err := a.DB().ListHistoryCoverage(store.ListHistoryCoverageParams{
ChatJIDs: chats,
Query: query,
Kind: kind,
Limit: limit,
IncludeBlocked: includeBlocked,
OnlyActionable: onlyActionable,
})
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"coverage": coverage})
}
return writeHistoryCoverageTable(os.Stdout, coverage, fullTableOutput(flags.fullOutput), false)
},
}
cmd.Flags().StringSliceVar(&chats, "chat", nil, "chat JID to inspect (repeatable)")
cmd.Flags().StringVar(&query, "query", "", "filter chats by local name or JID")
cmd.Flags().StringVar(&kind, "kind", "", "chat kind filter (dm|group|broadcast|newsletter|unknown)")
cmd.Flags().IntVar(&limit, "limit", 100, "limit rows")
cmd.Flags().BoolVar(&includeBlocked, "include-blocked", false, "include chats without a local message anchor")
cmd.Flags().BoolVar(&onlyActionable, "only-actionable", false, "show only chats with a local message anchor")
return cmd
}
func newHistoryFillCmd(flags *rootFlags) *cobra.Command {
var chats []string
var query string
var kind string
var limit int
var dryRun bool
cmd := &cobra.Command{
Use: "fill",
Short: "Plan multi-chat history backfill",
RunE: func(cmd *cobra.Command, args []string) error {
if !dryRun {
return fmt.Errorf("history fill currently supports --dry-run only; use history backfill --chat JID to request history")
}
ctx, cancel := withTimeout(cmd.Context(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, true)
if err != nil {
return err
}
defer closeApp(a, lk)
coverage, err := a.DB().ListHistoryCoverage(store.ListHistoryCoverageParams{
ChatJIDs: chats,
Query: query,
Kind: kind,
Limit: limit,
IncludeBlocked: true,
})
if err != nil {
return err
}
selected := historyFillCandidates(coverage)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"selected": selected,
"coverage": coverage,
})
}
fmt.Fprintf(os.Stdout, "Selected %d chats for fill dry run.\n", len(selected))
return writeHistoryCoverageTable(os.Stdout, coverage, fullTableOutput(flags.fullOutput), true)
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show which chats would be selected without connecting")
cmd.Flags().StringSliceVar(&chats, "chat", nil, "chat JID to consider (repeatable)")
cmd.Flags().StringVar(&query, "query", "", "filter chats by local name or JID")
cmd.Flags().StringVar(&kind, "kind", "", "chat kind filter (dm|group|broadcast|newsletter|unknown)")
cmd.Flags().IntVar(&limit, "limit", 100, "limit rows")
return cmd
}
func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
var chat string
var count int
@ -143,7 +37,7 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
return err
}
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
ctx, stop := signalContext()
defer stop()
a, lk, err := newApp(ctx, flags, true, false)
@ -185,73 +79,3 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().DurationVar(&idleExit, "idle-exit", 5*time.Second, "exit after being idle (after backfill requests)")
return cmd
}
func historyFillCandidates(coverage []store.HistoryCoverage) []store.HistoryCoverage {
out := make([]store.HistoryCoverage, 0, len(coverage))
for _, c := range coverage {
if c.Status == store.HistoryCoverageStatusReady {
out = append(out, c)
}
}
return out
}
func writeHistoryCoverageTable(dst io.Writer, coverage []store.HistoryCoverage, fullOutput, includeSelected bool) error {
w := newTableWriter(dst)
if includeSelected {
fmt.Fprintln(w, "SELECTED\tCHAT\tKIND\tMESSAGES\tOLDEST\tNEWEST\tSTATUS\tDETAIL")
} else {
fmt.Fprintln(w, "CHAT\tKIND\tMESSAGES\tOLDEST\tNEWEST\tSTATUS\tDETAIL")
}
for _, c := range coverage {
name := c.Name
if strings.TrimSpace(name) == "" {
name = c.ChatJID
}
detail := historyCoverageDetail(c)
selected := ""
if includeSelected {
if c.Status == store.HistoryCoverageStatusReady {
selected = "yes"
} else {
selected = "no"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
selected,
tableCell(name, 32, fullOutput),
c.Kind,
c.MessageCount,
formatHistoryDate(c.OldestTS),
formatHistoryDate(c.NewestTS),
c.Status,
tableCell(detail, 36, fullOutput),
)
continue
}
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
tableCell(name, 32, fullOutput),
c.Kind,
c.MessageCount,
formatHistoryDate(c.OldestTS),
formatHistoryDate(c.NewestTS),
c.Status,
tableCell(detail, 36, fullOutput),
)
}
_ = w.Flush()
return nil
}
func historyCoverageDetail(c store.HistoryCoverage) string {
if c.BlockedReason != "" {
return c.BlockedReason
}
return c.ChatJID
}
func formatHistoryDate(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Local().Format("2006-01-02")
}

View File

@ -1,87 +0,0 @@
package main
import (
"strings"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
)
func TestHistoryCoverageCommandListsReadyAndBlockedChats(t *testing.T) {
storeDir := t.TempDir()
db, err := store.Open(storeDir + "/wacli.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
base := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat("ready@s.whatsapp.net", "dm", "Ready", base); err != nil {
t.Fatalf("UpsertChat ready: %v", err)
}
if err := db.UpsertChat("blocked@s.whatsapp.net", "dm", "Blocked", base); err != nil {
t.Fatalf("UpsertChat blocked: %v", err)
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: "ready@s.whatsapp.net",
MsgID: "m1",
Timestamp: base,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
_ = db.Close()
cmd := newHistoryCoverageCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--include-blocked"})
raw := captureRootStdout(t, func() {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(raw, "Ready") || !strings.Contains(raw, "Blocked") || !strings.Contains(raw, "no_local_anchor") {
t.Fatalf("coverage output missing expected rows: %q", raw)
}
}
func TestHistoryFillRequiresDryRun(t *testing.T) {
cmd := newHistoryFillCmd(&rootFlags{})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--dry-run") {
t.Fatalf("expected --dry-run error, got %v", err)
}
}
func TestHistoryFillDryRunSelectsReadyChats(t *testing.T) {
storeDir := t.TempDir()
db, err := store.Open(storeDir + "/wacli.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
base := time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat("ready@s.whatsapp.net", "dm", "Ready", base); err != nil {
t.Fatalf("UpsertChat ready: %v", err)
}
if err := db.UpsertChat("blocked@s.whatsapp.net", "dm", "Blocked", base); err != nil {
t.Fatalf("UpsertChat blocked: %v", err)
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: "ready@s.whatsapp.net",
MsgID: "m1",
Timestamp: base,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
_ = db.Close()
cmd := newHistoryFillCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--dry-run"})
raw := captureRootStdout(t, func() {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(raw, "Selected 1 chats") || !strings.Contains(raw, "yes") || !strings.Contains(raw, "no") {
t.Fatalf("dry-run output missing selection markers: %q", raw)
}
}

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

@ -2,17 +2,19 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"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"
"go.mau.fi/whatsmeow"
"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/types"
)
@ -23,12 +25,8 @@ func newMessagesCmd(flags *rootFlags) *cobra.Command {
}
cmd.AddCommand(newMessagesListCmd(flags))
cmd.AddCommand(newMessagesSearchCmd(flags))
cmd.AddCommand(newMessagesStarredCmd(flags))
cmd.AddCommand(newMessagesShowCmd(flags))
cmd.AddCommand(newMessagesContextCmd(flags))
cmd.AddCommand(newMessagesExportCmd(flags))
cmd.AddCommand(newMessagesDeleteCmd(flags))
cmd.AddCommand(newMessagesEditCmd(flags))
return cmd
}
@ -42,7 +40,6 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
var fromThem bool
var asc bool
var forwarded bool
var starred bool
cmd := &cobra.Command{
Use: "list",
@ -102,7 +99,6 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
FromMe: fromMeFilter,
Asc: asc,
Forwarded: forwarded,
Starred: starred,
})
if err != nil {
return err
@ -129,7 +125,6 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&fromThem, "from-them", false, "only messages received (not sent by me)")
cmd.Flags().BoolVar(&asc, "asc", false, "show oldest messages first (default: newest first)")
cmd.Flags().BoolVar(&forwarded, "forwarded", false, "only forwarded messages")
cmd.Flags().BoolVar(&starred, "starred", false, "only starred messages")
return cmd
}
@ -142,7 +137,6 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
var hasMedia bool
var msgType string
var forwarded bool
var starred bool
cmd := &cobra.Command{
Use: "search <query>",
@ -190,7 +184,6 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
HasMedia: hasMedia,
Type: msgType,
Forwarded: forwarded,
Starred: starred,
})
if err != nil {
return err
@ -222,78 +215,134 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&hasMedia, "has-media", false, "only messages with media")
cmd.Flags().StringVar(&msgType, "type", "", "message type filter (text|image|video|audio|document)")
cmd.Flags().BoolVar(&forwarded, "forwarded", false, "only forwarded messages")
cmd.Flags().BoolVar(&starred, "starred", false, "only starred messages")
return cmd
}
func newMessagesStarredCmd(flags *rootFlags) *cobra.Command {
var chat string
var limit int
var afterStr string
var beforeStr string
var asc bool
cmd := &cobra.Command{
Use: "starred",
Short: "List starred messages",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
var after *time.Time
var before *time.Time
if afterStr != "" {
t, err := parseTime(afterStr)
if err != nil {
return err
}
after = &t
}
if beforeStr != "" {
t, err := parseTime(beforeStr)
if err != nil {
return err
}
before = &t
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().ListStarredMessages(store.ListStarredMessagesParams{
ChatJIDs: chatJIDs,
Limit: limit,
After: after,
Before: before,
Asc: asc,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"messages": msgs,
"fts": a.DB().HasFTS(),
})
}
return writeMessagesStarred(os.Stdout, msgs, fullTableOutput(flags.fullOutput))
},
func messageChatJIDFilter(ctx context.Context, a *app.App, chat string) ([]string, error) {
chat = strings.TrimSpace(chat)
if chat == "" {
return nil, nil
}
cmd.Flags().StringVar(&chat, "chat", "", "filter by chat JID")
cmd.Flags().IntVar(&limit, "limit", 50, "max number of messages to return")
cmd.Flags().StringVar(&afterStr, "after", "", "only messages with stored star time after time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages with stored star time before time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().BoolVar(&asc, "asc", false, "show oldest starred messages first (default: newest starred first)")
return cmd
jid, err := wa.ParseUserOrJID(chat)
if err != nil {
return nil, err
}
jids := []types.JID{canonicalMessageFilterJID(jid)}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return jidStrings(jids), nil
}
if err := a.OpenWA(); err != nil {
return jidStrings(jids), nil
}
client := a.WA()
if client == nil {
return jidStrings(jids), nil
}
switch jid.Server {
case types.DefaultUserServer:
jids = append(jids, canonicalMessageFilterJID(client.ResolvePNToLID(ctx, jid)))
case types.HiddenUserServer:
jids = append(jids, canonicalMessageFilterJID(client.ResolveLIDToPN(ctx, jid)))
}
return jidStrings(jids), nil
}
func canonicalMessageFilterJID(jid types.JID) types.JID {
if jid.Server == types.DefaultUserServer {
return jid.ToNonAD()
}
return jid
}
func jidStrings(jids []types.JID) []string {
out := make([]string, 0, len(jids))
seen := make(map[string]struct{}, len(jids))
for _, jid := range jids {
if jid.IsEmpty() {
continue
}
s := jid.String()
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
type lidSenderResolver interface {
ResolveLIDToPN(context.Context, types.JID) types.JID
}
func resolveMessageSenderNames(ctx context.Context, a *app.App, msgs []store.Message) []store.Message {
if len(msgs) == 0 || !messagesNeedSenderResolution(msgs) {
return msgs
}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return msgs
}
if err := a.OpenWA(); err != nil {
return msgs
}
return resolveMessageSenderNamesWith(ctx, a.DB(), a.WA(), msgs)
}
func messagesNeedSenderResolution(msgs []store.Message) bool {
for _, msg := range msgs {
if !msg.FromMe && strings.TrimSpace(msg.SenderName) == "" && strings.HasSuffix(strings.TrimSpace(msg.SenderJID), "@"+types.HiddenUserServer) {
return true
}
}
return false
}
func resolveMessageSenderNamesWith(ctx context.Context, db *store.DB, resolver lidSenderResolver, msgs []store.Message) []store.Message {
if resolver == nil {
return msgs
}
cache := map[string]string{}
for i := range msgs {
if msgs[i].FromMe || strings.TrimSpace(msgs[i].SenderName) != "" {
continue
}
sender := strings.TrimSpace(msgs[i].SenderJID)
if sender == "" {
continue
}
if name, ok := cache[sender]; ok {
msgs[i].SenderName = name
continue
}
name := resolvedSenderName(ctx, db, resolver, sender)
cache[sender] = name
msgs[i].SenderName = name
}
return msgs
}
func resolvedSenderName(ctx context.Context, db *store.DB, resolver lidSenderResolver, sender string) string {
jid, err := types.ParseJID(sender)
if err != nil || jid.Server != types.HiddenUserServer {
return ""
}
pn := resolver.ResolveLIDToPN(ctx, jid)
if pn.IsEmpty() || pn == jid {
return ""
}
contact, err := db.GetContact(pn.String())
if err == nil {
if contact.Alias != "" {
return contact.Alias
}
if contact.Name != "" {
return contact.Name
}
if contact.Phone != "" {
return contact.Phone
}
}
return pn.String()
}
func newMessagesShowCmd(flags *rootFlags) *cobra.Command {
@ -387,344 +436,42 @@ func newMessagesContextCmd(flags *rootFlags) *cobra.Command {
return cmd
}
func newMessagesExportCmd(flags *rootFlags) *cobra.Command {
var chat string
var limit int
var afterStr string
var beforeStr string
var output string
cmd := &cobra.Command{
Use: "export",
Short: "Export messages as JSON",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
var after *time.Time
var before *time.Time
if afterStr != "" {
t, err := parseTime(afterStr)
if err != nil {
return err
}
after = &t
}
if beforeStr != "" {
t, err := parseTime(beforeStr)
if err != nil {
return err
}
before = &t
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().ListMessages(store.ListMessagesParams{
ChatJIDs: chatJIDs,
Limit: limit,
After: after,
Before: before,
Asc: true,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
dst := os.Stdout
if output != "" {
f, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer f.Close()
dst = f
}
return out.WriteJSON(dst, map[string]any{
"messages": msgs,
"fts": a.DB().HasFTS(),
})
},
}
cmd.Flags().StringVar(&chat, "chat", "", "filter by chat JID")
cmd.Flags().IntVar(&limit, "limit", 1000, "max number of messages to export")
cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&output, "output", "", "write JSON export to file instead of stdout")
return cmd
}
func newMessagesDeleteCmd(flags *rootFlags) *cobra.Command {
var chat string
var id string
var forMe bool
var deleteMedia bool
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "delete",
Short: "Delete a message for everyone or for you",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(chat) == "" || strings.TrimSpace(id) == "" {
return fmt.Errorf("--chat and --id are required")
}
if deleteMedia && !forMe {
return fmt.Errorf("--delete-media requires --for-me")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
msg, chatJID, err := loadMessageMutationTarget(ctx, a, chat, id)
if err != nil {
return err
}
if !forMe {
if err := validateMessageCanRevoke(msg); err != nil {
return err
}
} else if err := validateMessageCanDeleteForMe(msg); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
if forMe {
info, err := messageInfoForDeleteForMe(msg, chatJID)
if err != nil {
return err
}
if _, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (struct{}, error) {
return struct{}{}, a.WA().DeleteMessageForMe(ctx, info, deleteMedia)
}); err != nil {
return err
}
if err := a.DB().MarkMessageDeletedForMe(msg.ChatJID, msg.MsgID, msg.SenderJID, msg.FromMe, time.Now().UTC()); err != nil {
return fmt.Errorf("store deleted-for-me message state: %w", err)
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"deleted_for_me": true,
"to": chatJID.String(),
"target": msg.MsgID,
})
}
fmt.Fprintf(os.Stdout, "Deleted message %s for me in %s\n", msg.MsgID, chatJID.String())
return nil
}
sentID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return a.WA().RevokeMessage(ctx, chatJID, types.MessageID(msg.MsgID))
})
if err != nil {
return err
}
if err := a.DB().MarkMessageRevoked(msg.ChatJID, msg.MsgID); err != nil {
return fmt.Errorf("store deleted message state: %w", err)
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"revoked": true,
"to": chatJID.String(),
"id": sentID,
"target": msg.MsgID,
})
}
fmt.Fprintf(os.Stdout, "Deleted message %s in %s (id %s)\n", msg.MsgID, chatJID.String(), sentID)
return nil
},
}
cmd.Flags().StringVar(&chat, "chat", "", "chat JID, phone number, or contact/group/chat name")
cmd.Flags().StringVar(&id, "id", "", "message ID to delete")
cmd.Flags().BoolVar(&forMe, "for-me", false, "delete the message only for this WhatsApp account")
cmd.Flags().BoolVar(&deleteMedia, "delete-media", false, "also remove local media when used with --for-me")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after delete so retry receipts can be handled (0 disables)")
return cmd
}
func newMessagesEditCmd(flags *rootFlags) *cobra.Command {
var chat string
var id string
var message string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "edit",
Short: "Edit one of your recent sent text messages",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(chat) == "" || strings.TrimSpace(id) == "" || strings.TrimSpace(message) == "" {
return fmt.Errorf("--chat, --id, and --message are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
msg, chatJID, err := loadMessageMutationTarget(ctx, a, chat, id)
if err != nil {
return err
}
if err := validateMessageCanEdit(msg, time.Now().UTC()); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
sentID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return a.WA().EditMessage(ctx, chatJID, types.MessageID(msg.MsgID), message)
})
if err != nil {
return err
}
if err := a.DB().UpdateMessageText(msg.ChatJID, msg.MsgID, message); err != nil {
return fmt.Errorf("store edited message text: %w", err)
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"edited": true,
"to": chatJID.String(),
"id": sentID,
"target": msg.MsgID,
"message": message,
})
}
fmt.Fprintf(os.Stdout, "Edited message %s in %s (id %s)\n", msg.MsgID, chatJID.String(), sentID)
return nil
},
}
cmd.Flags().StringVar(&chat, "chat", "", "chat JID, phone number, or contact/group/chat name")
cmd.Flags().StringVar(&id, "id", "", "message ID to edit")
cmd.Flags().StringVar(&message, "message", "", "new message text")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after edit so retry receipts can be handled (0 disables)")
return cmd
}
func loadMessageMutationTarget(ctx context.Context, a *app.App, chat, id string) (store.Message, types.JID, error) {
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return store.Message{}, types.JID{}, err
}
msg, err := getMessageByChatFilter(a.DB(), chatJIDs, id)
if err != nil {
return store.Message{}, types.JID{}, err
}
chatJID, err := wa.ParseUserOrJID(msg.ChatJID)
if err != nil {
return store.Message{}, types.JID{}, fmt.Errorf("stored chat JID is invalid: %w", err)
}
return msg, chatJID, nil
}
func validateMessageCanRevoke(msg store.Message) error {
if msg.Revoked {
return fmt.Errorf("message %s is already deleted", msg.MsgID)
}
if msg.DeletedForMe {
return fmt.Errorf("message %s was deleted for me", msg.MsgID)
}
if !msg.FromMe {
return fmt.Errorf("message %s was not sent by me", msg.MsgID)
}
return nil
}
func validateMessageCanDeleteForMe(msg store.Message) error {
if msg.Revoked {
return fmt.Errorf("message %s is already deleted", msg.MsgID)
}
if msg.DeletedForMe {
return fmt.Errorf("message %s was deleted for me", msg.MsgID)
}
return nil
}
func messageInfoForDeleteForMe(msg store.Message, chat types.JID) (types.MessageInfo, error) {
sender := types.EmptyJID
if strings.TrimSpace(msg.SenderJID) != "" {
parsed, err := types.ParseJID(msg.SenderJID)
if err != nil {
return types.MessageInfo{}, fmt.Errorf("stored sender JID is invalid: %w", err)
func getMessageByChatFilter(db *store.DB, chatJIDs []string, id string) (store.Message, error) {
var notFound error
for _, chatJID := range chatJIDs {
m, err := db.GetMessage(chatJID, id)
if err == nil {
return m, nil
}
sender = parsed
} else if !msg.FromMe && chat.Server == types.DefaultUserServer {
sender = chat
if !isNoRows(err) {
return store.Message{}, err
}
notFound = err
}
if !msg.FromMe && chat.Server == types.GroupServer && sender.IsEmpty() {
return types.MessageInfo{}, fmt.Errorf("stored sender JID is required to delete a group message for me")
if notFound != nil {
return store.Message{}, notFound
}
return types.MessageInfo{
MessageSource: types.MessageSource{
Chat: chat,
Sender: sender,
IsFromMe: msg.FromMe,
IsGroup: chat.Server == types.GroupServer,
},
ID: types.MessageID(msg.MsgID),
Timestamp: msg.Timestamp,
}, nil
return store.Message{}, sql.ErrNoRows
}
func validateMessageCanEdit(msg store.Message, now time.Time) error {
if err := validateMessageCanRevoke(msg); err != nil {
return err
func getMessageContextByChatFilter(db *store.DB, chatJIDs []string, id string, before, after int) ([]store.Message, error) {
var notFound error
for _, chatJID := range chatJIDs {
msgs, err := db.MessageContext(chatJID, id, before, after)
if err == nil {
return msgs, nil
}
if !isNoRows(err) {
return nil, err
}
notFound = err
}
if strings.TrimSpace(msg.MediaType) != "" {
return fmt.Errorf("only text messages can be edited")
if notFound != nil {
return nil, notFound
}
if strings.TrimSpace(msg.Text) == "" && strings.TrimSpace(msg.DisplayText) == "" {
return fmt.Errorf("only text messages can be edited")
}
if !msg.Timestamp.IsZero() && now.Sub(msg.Timestamp) > whatsmeow.EditWindow {
return fmt.Errorf("message %s is older than WhatsApp's %s edit window", msg.MsgID, whatsmeow.EditWindow)
}
return nil
return nil, sql.ErrNoRows
}
func isNoRows(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}

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 {
@ -51,26 +51,6 @@ func writeMessagesSearch(dst io.Writer, msgs []store.Message, fullOutput bool) e
return w.Flush()
}
func writeMessagesStarred(dst io.Writer, msgs []store.Message, fullOutput bool) error {
w := newTableWriter(dst)
fmt.Fprintln(w, "STARRED\tTIME\tCHAT\tFROM\tID\tTEXT")
for _, m := range msgs {
chatLabel := m.ChatName
if chatLabel == "" {
chatLabel = m.ChatJID
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
m.StarredAt.Local().Format("2006-01-02 15:04:05"),
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
tableCell(chatLabel, 24, fullOutput),
tableCell(messageFrom(m), 18, fullOutput),
tableCell(m.MsgID, 14, fullOutput),
tableCell(messageText(m), 80, fullOutput),
)
}
return w.Flush()
}
func writeMessageShow(dst io.Writer, m store.Message) error {
fmt.Fprintf(dst, "Chat: %s\n", m.ChatJID)
if m.ChatName != "" {
@ -103,18 +83,6 @@ func writeMessageShow(dst io.Writer, m store.Message) error {
fmt.Fprintf(dst, "Forwarding score: %d\n", m.ForwardingScore)
}
}
if m.Starred {
fmt.Fprintln(dst, "Starred: yes")
if !m.StarredAt.IsZero() {
fmt.Fprintf(dst, "Starred at: %s\n", m.StarredAt.Local().Format(time.RFC3339))
}
}
if m.Revoked {
fmt.Fprintln(dst, "Deleted: yes")
}
if m.DeletedForMe {
fmt.Fprintln(dst, "Deleted for me: yes")
}
fmt.Fprintf(dst, "\n%s\n", messageText(m))
if raw := messageRawText(m); raw != "" {
fmt.Fprintf(dst, "\nRaw text:\n%s\n", raw)
@ -169,12 +137,6 @@ func messageFromDetail(m store.Message) string {
}
func messageText(m store.Message) string {
if m.DeletedForMe {
return store.DeletedForMeMessageDisplayText
}
if m.Revoked {
return store.DeletedMessageDisplayText
}
if text := strings.TrimSpace(m.DisplayText); text != "" {
return text
}

View File

@ -1,182 +0,0 @@
package main
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)
func messageChatJIDFilter(ctx context.Context, a *app.App, chat string) ([]string, error) {
chat = strings.TrimSpace(chat)
if chat == "" {
return nil, nil
}
jid, err := wa.ParseUserOrJID(chat)
if err != nil {
return nil, err
}
jids := []types.JID{canonicalMessageFilterJID(jid)}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return jidStrings(jids), nil
}
if err := a.OpenWA(); err != nil {
return jidStrings(jids), nil
}
client := a.WA()
if client == nil {
return jidStrings(jids), nil
}
switch jid.Server {
case types.DefaultUserServer:
jids = append(jids, canonicalMessageFilterJID(client.ResolvePNToLID(ctx, jid)))
case types.HiddenUserServer:
jids = append(jids, canonicalMessageFilterJID(client.ResolveLIDToPN(ctx, jid)))
}
return jidStrings(jids), nil
}
func canonicalMessageFilterJID(jid types.JID) types.JID {
if jid.Server == types.DefaultUserServer {
return jid.ToNonAD()
}
return jid
}
func jidStrings(jids []types.JID) []string {
out := make([]string, 0, len(jids))
seen := make(map[string]struct{}, len(jids))
for _, jid := range jids {
if jid.IsEmpty() {
continue
}
s := jid.String()
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
type lidSenderResolver interface {
ResolveLIDToPN(context.Context, types.JID) types.JID
}
func resolveMessageSenderNames(ctx context.Context, a *app.App, msgs []store.Message) []store.Message {
if len(msgs) == 0 || !messagesNeedSenderResolution(msgs) {
return msgs
}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return msgs
}
if err := a.OpenWA(); err != nil {
return msgs
}
return resolveMessageSenderNamesWith(ctx, a.DB(), a.WA(), msgs)
}
func messagesNeedSenderResolution(msgs []store.Message) bool {
for _, msg := range msgs {
if !msg.FromMe && strings.TrimSpace(msg.SenderName) == "" && strings.HasSuffix(strings.TrimSpace(msg.SenderJID), "@"+types.HiddenUserServer) {
return true
}
}
return false
}
func resolveMessageSenderNamesWith(ctx context.Context, db *store.DB, resolver lidSenderResolver, msgs []store.Message) []store.Message {
if resolver == nil {
return msgs
}
cache := map[string]string{}
for i := range msgs {
if msgs[i].FromMe || strings.TrimSpace(msgs[i].SenderName) != "" {
continue
}
sender := strings.TrimSpace(msgs[i].SenderJID)
if sender == "" {
continue
}
if name, ok := cache[sender]; ok {
msgs[i].SenderName = name
continue
}
name := resolvedSenderName(ctx, db, resolver, sender)
cache[sender] = name
msgs[i].SenderName = name
}
return msgs
}
func resolvedSenderName(ctx context.Context, db *store.DB, resolver lidSenderResolver, sender string) string {
jid, err := types.ParseJID(sender)
if err != nil || jid.Server != types.HiddenUserServer {
return ""
}
pn := resolver.ResolveLIDToPN(ctx, jid)
if pn.IsEmpty() || pn == jid {
return ""
}
contact, err := db.GetContact(pn.String())
if err == nil {
if contact.Alias != "" {
return contact.Alias
}
if contact.Name != "" {
return contact.Name
}
if contact.Phone != "" {
return contact.Phone
}
}
return pn.String()
}
func getMessageByChatFilter(db *store.DB, chatJIDs []string, id string) (store.Message, error) {
var notFound error
for _, chatJID := range chatJIDs {
m, err := db.GetMessage(chatJID, id)
if err == nil {
return m, nil
}
if !isNoRows(err) {
return store.Message{}, err
}
notFound = err
}
if notFound != nil {
return store.Message{}, notFound
}
return store.Message{}, sql.ErrNoRows
}
func getMessageContextByChatFilter(db *store.DB, chatJIDs []string, id string, before, after int) ([]store.Message, error) {
var notFound error
for _, chatJID := range chatJIDs {
msgs, err := db.MessageContext(chatJID, id, before, after)
if err == nil {
return msgs, nil
}
if !isNoRows(err) {
return nil, err
}
notFound = err
}
if notFound != nil {
return nil, notFound
}
return nil, sql.ErrNoRows
}
func isNoRows(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}

View File

@ -3,16 +3,12 @@ package main
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"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 +32,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 {
@ -172,7 +158,7 @@ func TestWriteMessageShowPrefersDisplayTextAndMediaDetails(t *testing.T) {
func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
cmd := newMessagesSearchCmd(&rootFlags{})
for _, name := range []string{"has-media", "type", "forwarded", "starred"} {
for _, name := range []string{"has-media", "type", "forwarded"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
@ -182,167 +168,10 @@ func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
}
}
func TestMessagesListCommandExposesMessageFilters(t *testing.T) {
func TestMessagesListCommandExposesForwardedFilter(t *testing.T) {
cmd := newMessagesListCmd(&rootFlags{})
for _, name := range []string{"forwarded", "starred"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestMessagesStarredCommandExposesFilters(t *testing.T) {
cmd := newMessagesStarredCmd(&rootFlags{})
for _, name := range []string{"chat", "limit", "after", "before", "asc"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestMessagesExportCommandExposesDateFilters(t *testing.T) {
cmd := newMessagesExportCmd(&rootFlags{})
for _, name := range []string{"after", "before", "output"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestMessagesMutationCommandsExposeSafetyFlags(t *testing.T) {
for _, cmd := range []*cobra.Command{
newMessagesDeleteCmd(&rootFlags{}),
newMessagesEditCmd(&rootFlags{}),
} {
for _, name := range []string{"chat", "id", "post-send-wait"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("%s missing --%s", cmd.Name(), name)
}
}
}
if newMessagesEditCmd(&rootFlags{}).Flags().Lookup("message") == nil {
t.Fatalf("edit missing --message")
}
}
func TestMessagesDeleteRejectsReadOnlyBeforeOpeningStore(t *testing.T) {
cmd := newMessagesDeleteCmd(&rootFlags{readOnly: true})
cmd.SetArgs([]string{"--chat", "+15551234567", "--id", "mid"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "read-only mode") {
t.Fatalf("error = %v, want read-only", err)
}
}
func TestMessagesEditValidation(t *testing.T) {
now := time.Date(2024, 1, 1, 12, 30, 0, 0, time.UTC)
msg := store.Message{
MsgID: "mid",
Timestamp: now.Add(-time.Minute),
FromMe: true,
Text: "old",
}
if err := validateMessageCanEdit(msg, now); err != nil {
t.Fatalf("validateMessageCanEdit: %v", err)
}
msg.FromMe = false
if err := validateMessageCanEdit(msg, now); err == nil || !strings.Contains(err.Error(), "not sent by me") {
t.Fatalf("from-them error = %v", err)
}
msg.FromMe = true
msg.DeletedForMe = true
msg.Timestamp = now.Add(-time.Minute)
if err := validateMessageCanEdit(msg, now); err == nil || !strings.Contains(err.Error(), "deleted for me") {
t.Fatalf("deleted-for-me error = %v", err)
}
msg.DeletedForMe = false
msg.Timestamp = now.Add(-21 * time.Minute)
if err := validateMessageCanEdit(msg, now); err == nil || !strings.Contains(err.Error(), "edit window") {
t.Fatalf("old message error = %v", err)
}
}
func TestMessagesDeleteForMeValidation(t *testing.T) {
msg := store.Message{MsgID: "mid", FromMe: false}
if err := validateMessageCanDeleteForMe(msg); err != nil {
t.Fatalf("validateMessageCanDeleteForMe: %v", err)
}
if err := validateMessageCanRevoke(msg); err == nil || !strings.Contains(err.Error(), "not sent by me") {
t.Fatalf("revoke from-them error = %v", err)
}
msg.DeletedForMe = true
if err := validateMessageCanDeleteForMe(msg); err == nil || !strings.Contains(err.Error(), "deleted for me") {
t.Fatalf("deleted-for-me error = %v", err)
}
}
func TestMessagesExportCommandAppliesDateFilters(t *testing.T) {
storeDir := t.TempDir()
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
chat := "chat@s.whatsapp.net"
base := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat(chat, "dm", "Alice", base); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
for _, row := range []store.UpsertMessageParams{
{ChatJID: chat, MsgID: "before", SenderJID: chat, Timestamp: base, Text: "before"},
{ChatJID: chat, MsgID: "inside-1", SenderJID: chat, Timestamp: base.Add(time.Second), Text: "inside 1"},
{ChatJID: chat, MsgID: "inside-2", SenderJID: chat, Timestamp: base.Add(2 * time.Second), Text: "inside 2"},
{ChatJID: chat, MsgID: "after", SenderJID: chat, Timestamp: base.Add(3 * time.Second), Text: "after"},
} {
if err := db.UpsertMessage(row); err != nil {
t.Fatalf("UpsertMessage %s: %v", row.MsgID, err)
}
}
if err := db.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
output := filepath.Join(storeDir, "export.json")
cmd := newMessagesExportCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{
"--chat", chat,
"--after", base.Format(time.RFC3339),
"--before", base.Add(3 * time.Second).Format(time.RFC3339),
"--output", output,
"--limit", "10",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("messages export: %v", err)
}
raw, err := os.ReadFile(output)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
info, err := os.Stat(output)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("output mode = %04o, want 0600", got)
}
var got struct {
Success bool `json:"success"`
Data struct {
Messages []store.Message `json:"messages"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("Unmarshal export: %v\n%s", err, string(raw))
}
if !got.Success {
t.Fatalf("success = false")
}
if gotIDs := messageIDs(got.Data.Messages); gotIDs != "inside-1,inside-2" {
t.Fatalf("exported ids = %s", gotIDs)
if cmd.Flags().Lookup("forwarded") == nil {
t.Fatalf("expected --forwarded flag")
}
}
@ -461,11 +290,3 @@ func mustParseJID(t *testing.T, s string) types.JID {
}
return jid
}
func messageIDs(msgs []store.Message) string {
ids := make([]string, 0, len(msgs))
for _, msg := range msgs {
ids = append(ids, msg.MsgID)
}
return strings.Join(ids, ",")
}

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,23 +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"
const docsURL = "https://wacli.sh"
var version = "0.7.0"
type rootFlags struct {
storeDir string
account string
asJSON bool
fullOutput bool
events bool
timeout time.Duration
readOnly bool
lockWait time.Duration
@ -36,8 +32,6 @@ func execute(args []string) error {
rootCmd := &cobra.Command{
Use: "wacli",
Short: "WhatsApp CLI: sync, search, send",
Long: "wacli is a WhatsApp CLI for syncing, searching, and sending from local scripts.\n\nDocs: " + docsURL,
SilenceUsage: true,
SilenceErrors: true,
Version: version,
@ -45,16 +39,13 @@ 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")
rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 5*time.Minute, "command timeout (non-sync commands)")
rootCmd.PersistentFlags().DurationVar(&flags.lockWait, "lock-wait", 0, "wait for the store lock before failing (write commands)")
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))
@ -64,40 +55,28 @@ func execute(args []string) error {
rootCmd.AddCommand(newContactsCmd(&flags))
rootCmd.AddCommand(newChatsCmd(&flags))
rootCmd.AddCommand(newGroupsCmd(&flags))
rootCmd.AddCommand(newChannelsCmd(&flags))
rootCmd.AddCommand(newHistoryCmd(&flags))
rootCmd.AddCommand(newPresenceCmd(&flags))
rootCmd.AddCommand(newProfileCmd(&flags))
rootCmd.AddCommand(newDocsCmd(&flags))
rootCmd.AddCommand(newStoreCmd(&flags))
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {
writeRootError(flags, err)
_ = out.WriteError(os.Stderr, flags.asJSON, err)
return err
}
return nil
}
func writeRootError(flags rootFlags, err error) {
if err == nil {
return
}
if flags.events {
_ = out.NewEventWriter(os.Stderr, true).Emit("error", map[string]any{"message": err.Error()})
return
}
_ = out.WriteError(os.Stderr, flags.asJSON, err)
}
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 := flags.storeDir
if storeDir == "" {
storeDir = config.DefaultStoreDir()
}
storeDir, _ = filepath.Abs(storeDir)
var lk *lock.Lock
if needLock {
var err error
lk, err = lock.AcquireWithTimeout(ctx, storeDir, flags.lockWait)
if err != nil {
return nil, nil, err
@ -108,7 +87,6 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed
StoreDir: storeDir,
Version: version,
JSON: flags.asJSON,
Events: out.NewEventWriter(os.Stderr, flags.events),
AllowUnauthed: allowUnauthed,
})
if err != nil {
@ -121,45 +99,6 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed
return a, lk, nil
}
func resolveStoreDir(flags *rootFlags) (string, error) {
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) != "":
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
}
func (f *rootFlags) isReadOnly() bool {
if f == nil {
return false

View File

@ -1,82 +1,10 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/wacli/internal/config"
)
func captureRootStderr(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stderr = w
defer func() { os.Stderr = orig }()
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()
fn()
_ = w.Close()
return <-done
}
func captureRootStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stdout = w
defer func() { os.Stdout = orig }()
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()
fn()
_ = w.Close()
return <-done
}
func TestWriteRootErrorEventsUsesNDJSON(t *testing.T) {
raw := captureRootStderr(t, func() {
writeRootError(rootFlags{events: true}, errors.New("boom"))
})
var evt struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
t.Fatalf("root error was not NDJSON: %q: %v", raw, err)
}
if evt.Event != "error" {
t.Fatalf("event = %q, want error", evt.Event)
}
if evt.Data["message"] != "boom" {
t.Fatalf("message = %#v, want boom", evt.Data["message"])
}
}
func TestRootFlagsReadOnlyFlag(t *testing.T) {
flags := &rootFlags{readOnly: true}
@ -96,64 +24,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"
@ -27,7 +27,6 @@ func newSendCmd(flags *rootFlags) *cobra.Command {
}
cmd.AddCommand(newSendTextCmd(flags))
cmd.AddCommand(newSendFileCmd(flags))
cmd.AddCommand(newSendStickerCmd(flags))
cmd.AddCommand(newSendVoiceCmd(flags))
cmd.AddCommand(newSendReactCmd(flags))
return cmd
@ -37,11 +36,10 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var message string
var mentions []string
var replyTo string
var replyToSender string
var mentions []string
var noPreview bool
var messageEscapes bool
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
@ -54,36 +52,12 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
if err := flags.requireWritable(); err != nil {
return err
}
if messageEscapes {
decoded, err := decodeMessageEscapes(message)
if err != nil {
return err
}
message = decoded
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "text",
To: to,
Pick: pick,
Message: message,
Mentions: mentions,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
NoPreview: noPreview,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "text", resp)
}
return err
}
defer closeApp(a, lk)
@ -96,10 +70,6 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
if err != nil {
return err
}
mentionedJIDs, err := parseMentionedJIDs(mentions)
if err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
@ -109,7 +79,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
preview := fetchLinkPreview(ctx, message, noPreview)
msgID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender, preview, mentionedJIDs)
return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender, mentions, preview)
})
if err != nil {
return err
@ -148,11 +118,10 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().StringVar(&to, "to", "", "recipient JID, phone number, or contact/group/chat name")
cmd.Flags().IntVar(&pick, "pick", 0, "when --to is ambiguous, pick the Nth match (1-indexed)")
cmd.Flags().StringVar(&message, "message", "", "message text")
cmd.Flags().StringArrayVar(&mentions, "mention", nil, "phone number or user JID to mention (repeatable)")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "message ID to quote/reply to")
cmd.Flags().StringVar(&replyToSender, "reply-to-sender", "", "sender JID of the quoted message (required for unsynced group replies)")
cmd.Flags().StringArrayVar(&mentions, "mention", nil, "user JID or phone number to mention (repeatable)")
cmd.Flags().BoolVar(&noPreview, "no-preview", false, "disable automatic link previews for the first URL in text")
cmd.Flags().BoolVar(&messageEscapes, "message-escapes", false, `interpret backslash escapes in --message (\n, \r, \t, \\, \")`)
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}
@ -162,8 +131,8 @@ type sendTextApp interface {
DB() *store.DB
}
func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview, mentionedJIDs []string) (types.MessageID, error) {
msg, plainText, err := buildTextMessage(a.DB(), to, text, replyTo, replyToSender, preview, mentionedJIDs)
func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, replyTo, replyToSender string, mentions []string, preview *linkpreview.Preview) (types.MessageID, error) {
msg, plainText, err := buildTextMessage(a.DB(), to, text, replyTo, replyToSender, mentions, preview)
if err != nil {
return "", err
}
@ -190,38 +159,8 @@ func fetchLinkPreview(ctx context.Context, text string, disabled bool) *linkprev
return preview
}
func decodeMessageEscapes(s string) (string, error) {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] != '\\' {
b.WriteByte(s[i])
continue
}
i++
if i >= len(s) {
return "", fmt.Errorf(`unfinished escape sequence in --message; supported escapes: \n, \r, \t, \\, \"`)
}
switch s[i] {
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case '\\':
b.WriteByte('\\')
case '"':
b.WriteByte('"')
default:
return "", fmt.Errorf(`unsupported escape sequence \%c in --message; supported escapes: \n, \r, \t, \\, \"`, s[i])
}
}
return b.String(), nil
}
func buildTextMessage(db *store.DB, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview, mentionedJIDs []string) (*waProto.Message, bool, error) {
info, err := buildTextContextInfo(db, to, replyTo, replyToSender, mentionedJIDs)
func buildTextMessage(db *store.DB, to types.JID, text, replyTo, replyToSender string, mentions []string, preview *linkpreview.Preview) (*waProto.Message, bool, error) {
info, err := buildTextContextInfo(db, to, replyTo, replyToSender, mentions)
if err != nil {
return nil, false, err
}
@ -258,21 +197,51 @@ func attachLinkPreview(msg *waProto.ExtendedTextMessage, preview *linkpreview.Pr
msg.PreviewType = waProto.ExtendedTextMessage_NONE.Enum()
}
func buildTextContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string, mentionedJIDs []string) (*waProto.ContextInfo, error) {
func buildTextContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string, mentions []string) (*waProto.ContextInfo, error) {
info, err := buildReplyContextInfo(db, chat, replyTo, replyToSender)
if err != nil {
return nil, err
}
if len(mentionedJIDs) == 0 {
mentioned, err := normalizeMentionJIDs(mentions)
if err != nil {
return nil, err
}
if len(mentioned) == 0 {
return info, nil
}
if chat.Server != types.GroupServer {
return nil, fmt.Errorf("--mention is only supported for group text messages")
}
if info == nil {
info = &waProto.ContextInfo{}
}
info.MentionedJID = append([]string(nil), mentionedJIDs...)
info.MentionedJID = mentioned
return info, nil
}
func normalizeMentionJIDs(raw []string) ([]string, error) {
seen := map[string]struct{}{}
out := make([]string, 0, len(raw))
for _, input := range raw {
jid, err := wa.ParseUserOrJID(input)
if err != nil {
return nil, fmt.Errorf("invalid --mention %q: %w", input, err)
}
jid = jid.ToNonAD()
if jid.Server != types.DefaultUserServer && jid.Server != types.HiddenUserServer {
return nil, fmt.Errorf("invalid --mention %q: must be a user JID or phone number", input)
}
normalized := jid.String()
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out, nil
}
func buildReplyContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string) (*waProto.ContextInfo, error) {
replyTo = strings.TrimSpace(replyTo)
if replyTo == "" {
@ -319,28 +288,3 @@ func resolveReplySender(db *store.DB, chat types.JID, replyTo, override string)
}
return types.JID{}, nil
}
func parseMentionedJIDs(values []string) ([]string, error) {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
jid, err := wa.ParseUserOrJID(value)
if err != nil {
return nil, fmt.Errorf("invalid --mention: %w", err)
}
if jid.Server == types.GroupServer {
return nil, fmt.Errorf("invalid --mention %q: mentions must target a user phone number or user JID", value)
}
normalized := jid.String()
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out, nil
}

View File

@ -1,16 +1,9 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"math"
"mime"
"net/http"
@ -21,9 +14,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"
@ -31,7 +24,6 @@ import (
)
const maxSendFileSize = 100 * 1024 * 1024
const imageThumbnailMaxDimension = 96
const voiceWaveformSamples = 64
const voiceWaveformMax = 100
@ -81,32 +73,16 @@ func sendFile(ctx context.Context, a interface {
uploadType, _ = wa.MediaTypeFromString("audio")
}
isNewsletter := to.Server == types.NewsletterServer
if isNewsletter && opts.ptt {
return "", nil, fmt.Errorf("voice-note mode is not supported for channels; omit --ptt to send audio")
}
if isNewsletter && (strings.TrimSpace(opts.replyTo) != "" || strings.TrimSpace(opts.replyToSender) != "") {
return "", nil, fmt.Errorf("quoted file replies are not supported for channels")
}
var up whatsmeow.UploadResponse
if isNewsletter {
up, err = a.WA().UploadNewsletter(ctx, data, uploadType)
} else {
up, err = a.WA().Upload(ctx, data, uploadType)
}
up, err := a.WA().Upload(ctx, data, uploadType)
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
msg := &waProto.Message{}
var replyContext *waProto.ContextInfo
if !isNewsletter {
replyContext, err = buildReplyContextInfo(a.DB(), to, opts.replyTo, opts.replyToSender)
if err != nil {
return "", nil, err
}
replyContext, err := buildReplyContextInfo(a.DB(), to, opts.replyTo, opts.replyToSender)
if err != nil {
return "", nil, err
}
voiceMeta := voiceNoteMetadata{}
if opts.ptt {
@ -115,11 +91,16 @@ func sendFile(ctx context.Context, a interface {
switch mediaType {
case "image":
imageMsg, err := newImageMessage(up, mimeType, opts.caption, data)
if err != nil {
return "", nil, err
msg.ImageMessage = &waProto.ImageMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(opts.caption),
}
msg.ImageMessage = imageMsg
case "video":
msg.VideoMessage = &waProto.VideoMessage{
URL: proto.String(up.URL),
@ -149,12 +130,7 @@ func sendFile(ctx context.Context, a interface {
}
attachSendFileReplyContext(msg, replyContext)
var id types.MessageID
if isNewsletter {
id, err = a.WA().SendProtoMessageWithExtra(ctx, to, msg, up.Handle)
} else {
id, err = a.WA().SendProtoMessage(ctx, to, msg)
}
id, err := a.WA().SendProtoMessage(ctx, to, msg)
if err != nil {
return "", nil, err
}
@ -190,83 +166,6 @@ func sendFile(ctx context.Context, a interface {
}, nil
}
func newImageMessage(up whatsmeow.UploadResponse, mimeType, caption string, data []byte) (*waProto.ImageMessage, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("invalid image data: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, fmt.Errorf("invalid image dimensions: %dx%d", cfg.Width, cfg.Height)
}
msg := &waProto.ImageMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(caption),
Height: proto.Uint32(uint32(cfg.Height)),
Width: proto.Uint32(uint32(cfg.Width)),
}
if thumbnail, err := imageJPEGThumbnail(data); err == nil && len(thumbnail) > 0 {
msg.JPEGThumbnail = thumbnail
}
return msg, nil
}
func imageJPEGThumbnail(data []byte) ([]byte, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
bounds := src.Bounds()
srcW, srcH := bounds.Dx(), bounds.Dy()
if srcW <= 0 || srcH <= 0 {
return nil, fmt.Errorf("invalid image dimensions: %dx%d", srcW, srcH)
}
dstW, dstH := scaledDimensions(srcW, srcH, imageThumbnailMaxDimension)
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
srcX := bounds.Min.X + x*srcW/dstW
srcY := bounds.Min.Y + y*srcH/dstH
dst.Set(x, y, src.At(srcX, srcY))
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 75}); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func scaledDimensions(width, height, maxDimension int) (int, int) {
if width <= 0 || height <= 0 {
return 0, 0
}
if maxDimension <= 0 || (width <= maxDimension && height <= maxDimension) {
return width, height
}
if width >= height {
scaledHeight := height * maxDimension / width
if scaledHeight < 1 {
scaledHeight = 1
}
return maxDimension, scaledHeight
}
scaledWidth := width * maxDimension / height
if scaledWidth < 1 {
scaledWidth = 1
}
return scaledWidth, maxDimension
}
func newAudioMessage(up whatsmeow.UploadResponse, mimeType string, ptt bool, meta voiceNoteMetadata) *waProto.AudioMessage {
msg := &waProto.AudioMessage{
URL: proto.String(up.URL),
@ -317,9 +216,6 @@ func attachSendFileReplyContext(msg *waProto.Message, info *waProto.ContextInfo)
}
func chatKindFromJID(j types.JID) string {
if j.Server == types.NewsletterServer {
return "newsletter"
}
if j.Server == types.GroupServer {
return "group"
}

View File

@ -4,11 +4,10 @@ import (
"context"
"fmt"
"os"
"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 {
@ -39,29 +38,6 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
delegateFile := filePath
if abs, absErr := filepath.Abs(filePath); absErr == nil {
delegateFile = abs
}
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "file",
To: to,
Pick: pick,
File: delegateFile,
Filename: filename,
Caption: caption,
MIME: mimeOverride,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
PTT: ptt,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "file", resp)
}
return err
}
defer closeApp(a, lk)

View File

@ -1,13 +1,8 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"os/exec"
"path/filepath"
@ -115,68 +110,6 @@ func TestNewAudioMessageAttachesPTTMetadata(t *testing.T) {
}
}
func TestNewImageMessageAttachesDimensionsAndThumbnail(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 120, 60))
for y := 0; y < 60; y++ {
for x := 0; x < 120; x++ {
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 120, A: 255})
}
}
var data bytes.Buffer
if err := png.Encode(&data, img); err != nil {
t.Fatalf("png.Encode: %v", err)
}
up := whatsmeow.UploadResponse{
URL: "https://upload",
DirectPath: "/path",
MediaKey: []byte("key"),
FileEncSHA256: []byte("enc"),
FileSHA256: []byte("plain"),
FileLength: uint64(data.Len()),
}
msg, err := newImageMessage(up, "image/png", "caption", data.Bytes())
if err != nil {
t.Fatalf("newImageMessage: %v", err)
}
if msg.GetWidth() != 120 || msg.GetHeight() != 60 {
t.Fatalf("dimensions = %dx%d, want 120x60", msg.GetWidth(), msg.GetHeight())
}
if msg.GetCaption() != "caption" {
t.Fatalf("caption = %q", msg.GetCaption())
}
if len(msg.GetJPEGThumbnail()) == 0 {
t.Fatalf("missing JPEG thumbnail")
}
if _, err := jpeg.Decode(bytes.NewReader(msg.GetJPEGThumbnail())); err != nil {
t.Fatalf("thumbnail is not JPEG: %v", err)
}
}
func TestNewImageMessageRejectsInvalidImageData(t *testing.T) {
_, err := newImageMessage(whatsmeow.UploadResponse{}, "image/png", "", []byte("not an image"))
if err == nil || !strings.Contains(err.Error(), "invalid image data") {
t.Fatalf("expected invalid image error, got %v", err)
}
}
func TestScaledDimensions(t *testing.T) {
for _, tc := range []struct {
width, height int
wantW, wantH int
}{
{width: 120, height: 60, wantW: 96, wantH: 48},
{width: 60, height: 120, wantW: 48, wantH: 96},
{width: 40, height: 30, wantW: 40, wantH: 30},
{width: 1, height: 1000, wantW: 1, wantH: 96},
} {
gotW, gotH := scaledDimensions(tc.width, tc.height, imageThumbnailMaxDimension)
if gotW != tc.wantW || gotH != tc.wantH {
t.Fatalf("scaledDimensions(%d,%d) = %dx%d, want %dx%d", tc.width, tc.height, gotW, gotH, tc.wantW, tc.wantH)
}
}
}
func TestWaveformFromPCM16LE(t *testing.T) {
data := make([]byte, voiceWaveformSamples*4)
for i := 0; i < voiceWaveformSamples*2; i++ {

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

@ -1,359 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"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"
"go.mau.fi/whatsmeow/types"
)
const (
sendDelegateVersion = 1
sendDelegateSocketName = ".send.sock"
)
var errSendDelegateUnavailable = errors.New("send delegate unavailable")
type sendDelegateRequest struct {
Version int `json:"version"`
Kind string `json:"kind"`
To string `json:"to,omitempty"`
Pick int `json:"pick,omitempty"`
Message string `json:"message,omitempty"`
Mentions []string `json:"mentions,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
ReplyToSender string `json:"reply_to_sender,omitempty"`
NoPreview bool `json:"no_preview,omitempty"`
File string `json:"file,omitempty"`
Filename string `json:"filename,omitempty"`
Caption string `json:"caption,omitempty"`
MIME string `json:"mime,omitempty"`
PTT bool `json:"ptt,omitempty"`
ID string `json:"id,omitempty"`
Reaction string `json:"reaction,omitempty"`
Sender string `json:"sender,omitempty"`
PostSendWaitMS int64 `json:"post_send_wait_ms,omitempty"`
TimeoutMS int64 `json:"timeout_ms,omitempty"`
}
type sendDelegateResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Sent bool `json:"sent,omitempty"`
To string `json:"to,omitempty"`
ID string `json:"id,omitempty"`
Target string `json:"target,omitempty"`
Reaction string `json:"reaction,omitempty"`
File map[string]string `json:"file,omitempty"`
}
func sendDelegateSocketPath(storeDir string) string {
return filepath.Join(storeDir, sendDelegateSocketName)
}
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)
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", path)
if err != nil {
return sendDelegateResponse{}, fmt.Errorf("%w: %v", errSendDelegateUnavailable, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(commandTimeout(flags)))
if err := json.NewEncoder(conn).Encode(req); err != nil {
return sendDelegateResponse{}, err
}
var resp sendDelegateResponse
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
return sendDelegateResponse{}, err
}
if !resp.OK {
return sendDelegateResponse{}, errors.New(resp.Error)
}
return resp, nil
}
func tryDelegateSend(ctx context.Context, flags *rootFlags, lockErr error, req sendDelegateRequest) (sendDelegateResponse, bool, error) {
if !lock.IsLocked(lockErr) {
return sendDelegateResponse{}, false, lockErr
}
resp, err := delegateSend(ctx, flags, req)
if err != nil {
if errors.Is(err, errSendDelegateUnavailable) {
return sendDelegateResponse{}, false, lockErr
}
return sendDelegateResponse{}, true, err
}
return resp, true, nil
}
func startSendDelegateServer(ctx context.Context, a *app.App) (func(), error) {
path := sendDelegateSocketPath(a.StoreDir())
if err := removeStaleSendDelegateSocket(path); err != nil {
return nil, err
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, err
}
if err := os.Chmod(path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, err
}
done := make(chan struct{})
var sendMu sync.Mutex
go func() {
defer close(done)
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleSendDelegateConn(ctx, conn, a, &sendMu)
}
}()
stop := func() {
_ = ln.Close()
<-done
_ = os.Remove(path)
}
return stop, nil
}
func removeStaleSendDelegateSocket(path string) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("%s exists and is not a socket", path)
}
return os.Remove(path)
}
func handleSendDelegateConn(ctx context.Context, conn net.Conn, a *app.App, sendMu *sync.Mutex) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Minute))
var req sendDelegateRequest
if err := json.NewDecoder(conn).Decode(&req); err != nil {
_ = json.NewEncoder(conn).Encode(sendDelegateResponse{OK: false, Error: err.Error()})
return
}
sendMu.Lock()
defer sendMu.Unlock()
resp, err := executeDelegatedSend(ctx, a, req)
if err != nil {
resp = sendDelegateResponse{OK: false, Error: err.Error()}
}
_ = json.NewEncoder(conn).Encode(resp)
}
func executeDelegatedSend(parent context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
if req.Version != sendDelegateVersion {
return sendDelegateResponse{}, fmt.Errorf("unsupported send delegate version %d", req.Version)
}
ctx, cancel := context.WithTimeout(parent, millisDuration(req.TimeoutMS, 5*time.Minute))
defer cancel()
switch req.Kind {
case "text":
return executeDelegatedText(ctx, a, req)
case "file", "voice":
return executeDelegatedFile(ctx, a, req)
case "sticker":
return executeDelegatedSticker(ctx, a, req)
case "react":
return executeDelegatedReact(ctx, a, req)
default:
return sendDelegateResponse{}, fmt.Errorf("unsupported send kind %q", req.Kind)
}
}
func executeDelegatedText(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
toJID, err := resolveRecipient(a, req.To, recipientOptions{pick: req.Pick, asJSON: true})
if err != nil {
return sendDelegateResponse{}, err
}
mentionedJIDs, err := parseMentionedJIDs(req.Mentions)
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
preview := fetchLinkPreview(ctx, req.Message, req.NoPreview)
msgID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return sendTextMessage(ctx, a, toJID, req.Message, req.ReplyTo, req.ReplyToSender, preview, mentionedJIDs)
})
if err != nil {
return sendDelegateResponse{}, err
}
now := time.Now().UTC()
chatName := a.WA().ResolveChatName(ctx, toJID, "")
_ = a.DB().UpsertChat(toJID.String(), chatKindFromJID(toJID), chatName, now)
_ = a.DB().UpsertMessage(store.UpsertMessageParams{
ChatJID: toJID.String(),
ChatName: chatName,
MsgID: string(msgID),
SenderName: "me",
Timestamp: now,
FromMe: true,
Text: req.Message,
})
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return sendDelegateResponse{OK: true, Sent: true, To: toJID.String(), ID: string(msgID)}, nil
}
func executeDelegatedFile(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
toJID, err := resolveRecipient(a, req.To, recipientOptions{pick: req.Pick, asJSON: true})
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendDelegateResponse, error) {
msgID, meta, err := sendFile(ctx, a, toJID, req.File, sendFileOptions{
filename: req.Filename,
caption: req.Caption,
mimeOverride: req.MIME,
replyTo: req.ReplyTo,
replyToSender: req.ReplyToSender,
ptt: req.PTT || req.Kind == "voice",
})
if err != nil {
return sendDelegateResponse{}, err
}
return sendDelegateResponse{OK: true, Sent: true, To: toJID.String(), ID: msgID, File: meta}, nil
})
if err != nil {
return sendDelegateResponse{}, err
}
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return res, nil
}
func executeDelegatedSticker(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
toJID, err := resolveRecipient(a, req.To, recipientOptions{pick: req.Pick, asJSON: true})
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendDelegateResponse, error) {
msgID, meta, err := sendSticker(ctx, a, toJID, req.File, sendStickerOptions{
replyTo: req.ReplyTo,
replyToSender: req.ReplyToSender,
})
if err != nil {
return sendDelegateResponse{}, err
}
return sendDelegateResponse{OK: true, Sent: true, To: toJID.String(), ID: msgID, File: meta}, nil
})
if err != nil {
return sendDelegateResponse{}, err
}
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return res, nil
}
func executeDelegatedReact(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
chat, senderJID, err := reactionTarget(req.To, req.Sender)
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
sentID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return a.WA().SendReaction(ctx, chat, senderJID, types.MessageID(req.ID), req.Reaction)
})
if err != nil {
return sendDelegateResponse{}, err
}
now := time.Now().UTC()
chatName := a.WA().ResolveChatName(ctx, chat, "")
upsertSentReaction(a.DB(), chat, chatName, sentID, req.ID, req.Reaction, now)
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return sendDelegateResponse{OK: true, Sent: true, To: chat.String(), ID: string(sentID), Target: req.ID, Reaction: req.Reaction}, nil
}
func writeDelegatedSendOutput(flags *rootFlags, kind string, resp sendDelegateResponse) error {
if flags.asJSON {
body := map[string]any{"sent": resp.Sent, "to": resp.To, "id": resp.ID}
if resp.File != nil {
body["file"] = resp.File
}
if kind == "react" {
body["target"] = resp.Target
body["reaction"] = resp.Reaction
}
return out.WriteJSON(os.Stdout, body)
}
switch kind {
case "file":
fmt.Fprintf(os.Stdout, "Sent %s to %s (id %s)\n", resp.File["name"], resp.To, resp.ID)
case "sticker":
fmt.Fprintf(os.Stdout, "Sent sticker to %s (id %s)\n", resp.To, resp.ID)
case "voice":
fmt.Fprintf(os.Stdout, "Sent voice note to %s (id %s)\n", resp.To, resp.ID)
case "react":
if resp.Reaction == "" {
fmt.Fprintf(os.Stdout, "Removed reaction from %s (id %s)\n", resp.Target, resp.ID)
} else {
fmt.Fprintf(os.Stdout, "Reacted %s to %s (id %s)\n", resp.Reaction, resp.Target, resp.ID)
}
default:
fmt.Fprintf(os.Stdout, "Sent to %s (id %s)\n", resp.To, resp.ID)
}
return nil
}
func durationMillis(d time.Duration) int64 {
if d <= 0 {
return 0
}
return int64(d / time.Millisecond)
}
func millisDuration(ms int64, fallback time.Duration) time.Duration {
if ms <= 0 {
return fallback
}
return time.Duration(ms) * time.Millisecond
}
func commandTimeout(flags *rootFlags) time.Duration {
if flags == nil || flags.timeout <= 0 {
return 5 * time.Minute
}
return flags.timeout
}

View File

@ -1,59 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/wacli/internal/lock"
)
func TestTryDelegateSendFallsBackWhenSocketUnavailable(t *testing.T) {
dir := t.TempDir()
flags := &rootFlags{storeDir: dir}
lockErr := fmt.Errorf("held: %w", lock.ErrLocked)
_, delegated, err := tryDelegateSend(context.Background(), flags, lockErr, sendDelegateRequest{Kind: "text"})
if delegated {
t.Fatalf("delegated = true, want false for missing socket")
}
if !errors.Is(err, lock.ErrLocked) {
t.Fatalf("error = %v, want original lock error", err)
}
}
func TestTryDelegateSendDoesNotDelegateNonLockErrors(t *testing.T) {
orig := errors.New("open store")
_, delegated, err := tryDelegateSend(context.Background(), &rootFlags{}, orig, sendDelegateRequest{Kind: "text"})
if delegated {
t.Fatalf("delegated = true, want false")
}
if !errors.Is(err, orig) {
t.Fatalf("error = %v, want original", err)
}
}
func TestExecuteDelegatedSendRejectsBadVersionBeforeAppUse(t *testing.T) {
_, err := executeDelegatedSend(context.Background(), nil, sendDelegateRequest{
Version: sendDelegateVersion + 1,
Kind: "text",
})
if err == nil || !strings.Contains(err.Error(), "unsupported send delegate version") {
t.Fatalf("error = %v", err)
}
}
func TestRemoveStaleSendDelegateSocketRefusesRegularFile(t *testing.T) {
path := filepath.Join(t.TempDir(), sendDelegateSocketName)
if err := os.WriteFile(path, []byte("not a socket"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := removeStaleSendDelegateSocket(path); err == nil || !strings.Contains(err.Error(), "not a socket") {
t.Fatalf("error = %v, want not a socket", err)
}
}

View File

@ -7,10 +7,9 @@ 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/wa"
"go.mau.fi/whatsmeow/types"
)
@ -37,20 +36,6 @@ func newSendReactCmd(flags *rootFlags) *cobra.Command {
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "react",
To: to,
ID: msgID,
Reaction: emoji,
Sender: sender,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "react", resp)
}
return err
}
defer closeApp(a, lk)
@ -76,10 +61,6 @@ func newSendReactCmd(flags *rootFlags) *cobra.Command {
return err
}
now := time.Now().UTC()
chatName := a.WA().ResolveChatName(ctx, chat, "")
upsertSentReaction(a.DB(), chat, chatName, sentID, msgID, emoji, now)
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
@ -125,36 +106,3 @@ func reactionTarget(to, sender string) (types.JID, types.JID, error) {
}
return chat, senderJID, nil
}
func upsertSentReaction(db *store.DB, chat types.JID, chatName string, sentID types.MessageID, targetID, emoji string, now time.Time) {
if db == nil || chat.IsEmpty() || sentID == "" {
return
}
_ = db.UpsertChat(chat.String(), chatKindFromJID(chat), chatName, now)
_ = db.UpsertMessage(store.UpsertMessageParams{
ChatJID: chat.String(),
ChatName: chatName,
MsgID: string(sentID),
SenderName: "me",
Timestamp: now,
FromMe: true,
DisplayText: sentReactionDisplayText(db, chat.String(), targetID, emoji),
ReactionToID: targetID,
ReactionEmoji: emoji,
})
}
func sentReactionDisplayText(db *store.DB, chatJID, targetID, emoji string) string {
display := "message"
if db != nil && strings.TrimSpace(chatJID) != "" && strings.TrimSpace(targetID) != "" {
if msg, err := db.GetMessage(chatJID, targetID); err == nil {
if text := strings.TrimSpace(messageText(msg)); text != "" {
display = text
}
}
}
if strings.TrimSpace(emoji) == "" {
return fmt.Sprintf("Reacted to %s", display)
}
return fmt.Sprintf("Reacted %s to %s", emoji, display)
}

View File

@ -1,217 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
)
const sendStickerMIME = "image/webp"
const (
stickerDimension = 512
maxStaticStickerBytes = 100 * 1024
maxAnimatedStickerByte = 500 * 1024
)
type sendStickerOptions struct {
replyTo string
replyToSender string
}
type webPStickerMetadata struct {
width uint32
height uint32
animated bool
}
func sendSticker(ctx context.Context, a interface {
WA() app.WAClient
DB() *store.DB
}, to types.JID, filePath string, opts sendStickerOptions) (string, map[string]string, error) {
data, err := readSendFileData(filePath)
if err != nil {
return "", nil, err
}
meta, err := validateWebPSticker(data)
if err != nil {
return "", nil, err
}
uploadType, err := wa.MediaTypeFromString("sticker")
if err != nil {
return "", nil, err
}
up, err := a.WA().Upload(ctx, data, uploadType)
if err != nil {
return "", nil, err
}
replyContext, err := buildReplyContextInfo(a.DB(), to, opts.replyTo, opts.replyToSender)
if err != nil {
return "", nil, err
}
msg := newStickerMessage(up, replyContext, meta)
id, err := a.WA().SendProtoMessage(ctx, to, msg)
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
name := filepath.Base(filePath)
chatName := a.WA().ResolveChatName(ctx, to, "")
_ = a.DB().UpsertChat(to.String(), chatKindFromJID(to), chatName, now)
_ = a.DB().UpsertMessage(store.UpsertMessageParams{
ChatJID: to.String(),
ChatName: chatName,
MsgID: id,
SenderJID: "",
SenderName: "me",
Timestamp: now,
FromMe: true,
MediaType: "sticker",
Filename: name,
MimeType: sendStickerMIME,
DirectPath: up.DirectPath,
MediaKey: up.MediaKey,
FileSHA256: up.FileSHA256,
FileEncSHA256: up.FileEncSHA256,
FileLength: up.FileLength,
})
return id, map[string]string{
"name": name,
"mime_type": sendStickerMIME,
"media": "sticker",
}, nil
}
func newStickerMessage(up whatsmeow.UploadResponse, info *waProto.ContextInfo, meta webPStickerMetadata) *waProto.Message {
return &waProto.Message{
StickerMessage: &waProto.StickerMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(sendStickerMIME),
Height: proto.Uint32(meta.height),
Width: proto.Uint32(meta.width),
IsAnimated: proto.Bool(meta.animated),
ContextInfo: info,
},
}
}
func isWebPStickerData(data []byte) bool {
_, err := parseWebPStickerMetadata(data)
return err == nil
}
func validateWebPSticker(data []byte) (webPStickerMetadata, error) {
meta, err := parseWebPStickerMetadata(data)
if err != nil {
return webPStickerMetadata{}, fmt.Errorf("stickers must be valid WebP files")
}
if meta.width != stickerDimension || meta.height != stickerDimension {
return webPStickerMetadata{}, fmt.Errorf("stickers must be %dx%d WebP files (got %dx%d)", stickerDimension, stickerDimension, meta.width, meta.height)
}
maxBytes := maxStaticStickerBytes
kind := "static"
if meta.animated {
maxBytes = maxAnimatedStickerByte
kind = "animated"
}
if len(data) > maxBytes {
return webPStickerMetadata{}, fmt.Errorf("%s stickers must be at most %d KiB (got %d KiB)", kind, maxBytes/1024, (len(data)+1023)/1024)
}
return meta, nil
}
func parseWebPStickerMetadata(data []byte) (webPStickerMetadata, error) {
if len(data) < 12 || !bytes.Equal(data[0:4], []byte("RIFF")) || !bytes.Equal(data[8:12], []byte("WEBP")) {
return webPStickerMetadata{}, fmt.Errorf("missing WebP header")
}
for off := 12; off+8 <= len(data); {
chunkType := string(data[off : off+4])
chunkSize := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
chunkStart := off + 8
chunkEnd := chunkStart + chunkSize
if chunkSize < 0 || chunkEnd > len(data) {
return webPStickerMetadata{}, fmt.Errorf("invalid WebP chunk size")
}
chunk := data[chunkStart:chunkEnd]
switch chunkType {
case "VP8X":
meta, err := parseWebPVP8X(chunk)
if err != nil {
return webPStickerMetadata{}, err
}
return meta, nil
case "VP8L":
meta, err := parseWebPVP8L(chunk)
if err != nil {
return webPStickerMetadata{}, err
}
return meta, nil
case "VP8 ":
meta, err := parseWebPVP8(chunk)
if err != nil {
return webPStickerMetadata{}, err
}
return meta, nil
}
off = chunkEnd
if chunkSize%2 == 1 {
off++
}
}
return webPStickerMetadata{}, fmt.Errorf("missing WebP image chunk")
}
func parseWebPVP8X(chunk []byte) (webPStickerMetadata, error) {
if len(chunk) < 10 {
return webPStickerMetadata{}, fmt.Errorf("short VP8X chunk")
}
width := uint32(chunk[4]) | uint32(chunk[5])<<8 | uint32(chunk[6])<<16
height := uint32(chunk[7]) | uint32(chunk[8])<<8 | uint32(chunk[9])<<16
return webPStickerMetadata{
width: width + 1,
height: height + 1,
animated: chunk[0]&0x02 != 0,
}, nil
}
func parseWebPVP8L(chunk []byte) (webPStickerMetadata, error) {
if len(chunk) < 5 || chunk[0] != 0x2f {
return webPStickerMetadata{}, fmt.Errorf("invalid VP8L chunk")
}
bits := binary.LittleEndian.Uint32(chunk[1:5])
return webPStickerMetadata{
width: (bits & 0x3fff) + 1,
height: ((bits >> 14) & 0x3fff) + 1,
}, nil
}
func parseWebPVP8(chunk []byte) (webPStickerMetadata, error) {
if len(chunk) < 10 || !bytes.Equal(chunk[3:6], []byte{0x9d, 0x01, 0x2a}) {
return webPStickerMetadata{}, fmt.Errorf("invalid VP8 chunk")
}
return webPStickerMetadata{
width: uint32(binary.LittleEndian.Uint16(chunk[6:8]) & 0x3fff),
height: uint32(binary.LittleEndian.Uint16(chunk[8:10]) & 0x3fff),
}, nil
}

View File

@ -1,116 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newSendStickerCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var filePath string
var replyTo string
var replyToSender string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "sticker",
Short: "Send a sticker (WebP image)",
RunE: func(cmd *cobra.Command, args []string) error {
if to == "" || filePath == "" {
return fmt.Errorf("--to and --file are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
delegateFile := filePath
if abs, absErr := filepath.Abs(filePath); absErr == nil {
delegateFile = abs
}
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "sticker",
To: to,
Pick: pick,
File: delegateFile,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "sticker", resp)
}
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
toJID, err := resolveRecipient(a, to, recipientOptions{pick: pick, asJSON: flags.asJSON})
if err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
type sendStickerResult struct {
id string
meta map[string]string
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendStickerResult, error) {
msgID, meta, err := sendSticker(ctx, a, toJID, filePath, sendStickerOptions{
replyTo: replyTo,
replyToSender: replyToSender,
})
if err != nil {
return sendStickerResult{}, err
}
return sendStickerResult{id: msgID, meta: meta}, nil
})
if err != nil {
return err
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
"to": toJID.String(),
"id": res.id,
"file": res.meta,
})
}
fmt.Fprintf(os.Stdout, "Sent sticker to %s (id %s)\n", toJID.String(), res.id)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient JID, phone number, or contact/group/chat name")
cmd.Flags().IntVar(&pick, "pick", 0, "when --to is ambiguous, pick the Nth match (1-indexed)")
cmd.Flags().StringVar(&filePath, "file", "", "path to WebP sticker file")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "message ID to quote/reply to")
cmd.Flags().StringVar(&replyToSender, "reply-to-sender", "", "sender JID of the quoted message (required for unsynced group replies)")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}

View File

@ -1,164 +0,0 @@
package main
import (
"context"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
)
func TestSendCommandIncludesStickerSubcommand(t *testing.T) {
cmd := newSendCmd(&rootFlags{})
for _, sub := range cmd.Commands() {
if sub.Name() == "sticker" {
return
}
}
t.Fatalf("missing send sticker subcommand")
}
func TestSendStickerCommandExposesSharedSendFlags(t *testing.T) {
cmd := newSendStickerCmd(&rootFlags{})
for _, name := range []string{"to", "pick", "file", "reply-to", "reply-to-sender", "post-send-wait"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
func TestIsWebPStickerData(t *testing.T) {
valid := testWebPVP8X(512, 512, false, nil)
if !isWebPStickerData(valid) {
t.Fatalf("valid WebP header was rejected")
}
for _, data := range [][]byte{
nil,
[]byte("RIFF\x10\x00\x00\x00PNG "),
[]byte("not webp"),
} {
if isWebPStickerData(data) {
t.Fatalf("invalid WebP header was accepted: %q", string(data))
}
}
}
func TestValidateWebPSticker(t *testing.T) {
static := testWebPVP8X(512, 512, false, nil)
meta, err := validateWebPSticker(static)
if err != nil {
t.Fatalf("validateWebPSticker: %v", err)
}
if meta.width != 512 || meta.height != 512 || meta.animated {
t.Fatalf("metadata = %+v, want static 512x512", meta)
}
animated := testWebPVP8X(512, 512, true, bytesOfSize(101*1024))
meta, err = validateWebPSticker(animated)
if err != nil {
t.Fatalf("animated sticker should allow >100 KiB: %v", err)
}
if !meta.animated {
t.Fatalf("animated WebP was not detected")
}
for name, tc := range map[string]struct {
data []byte
want string
}{
"wrong dimensions": {testWebPVP8X(256, 512, false, nil), "512x512"},
"static too large": {testWebPVP8X(512, 512, false, bytesOfSize(101*1024)), "static stickers"},
"animated too large": {testWebPVP8X(512, 512, true, bytesOfSize(501*1024)), "animated stickers"},
} {
if _, err := validateWebPSticker(tc.data); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: expected %q error, got %v", name, tc.want, err)
}
}
}
func TestSendStickerRejectsNonWebPBeforeUpload(t *testing.T) {
path := filepath.Join(t.TempDir(), "sticker.png")
if err := os.WriteFile(path, []byte("not-webp"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, _, err := sendSticker(context.Background(), nil, types.JID{}, path, sendStickerOptions{})
if err == nil || !strings.Contains(err.Error(), "stickers must be valid WebP") {
t.Fatalf("expected WebP validation error, got %v", err)
}
}
func TestNewStickerMessageAttachesUploadFieldsAndReply(t *testing.T) {
up := whatsmeow.UploadResponse{
URL: "https://upload",
DirectPath: "/direct",
MediaKey: []byte("key"),
FileEncSHA256: []byte("enc"),
FileSHA256: []byte("plain"),
FileLength: 123,
}
meta := webPStickerMetadata{width: 512, height: 512, animated: true}
info := &waProto.ContextInfo{
StanzaID: proto.String("quoted"),
Participant: proto.String("15551234567@s.whatsapp.net"),
}
msg := newStickerMessage(up, info, meta)
sticker := msg.GetStickerMessage()
if sticker == nil {
t.Fatalf("missing sticker message")
}
if sticker.GetMimetype() != sendStickerMIME {
t.Fatalf("mime = %q, want %q", sticker.GetMimetype(), sendStickerMIME)
}
if sticker.GetURL() != up.URL || sticker.GetDirectPath() != up.DirectPath || sticker.GetFileLength() != up.FileLength {
t.Fatalf("upload fields were not attached")
}
if string(sticker.GetMediaKey()) != string(up.MediaKey) ||
string(sticker.GetFileSHA256()) != string(up.FileSHA256) ||
string(sticker.GetFileEncSHA256()) != string(up.FileEncSHA256) {
t.Fatalf("upload hashes were not attached")
}
if sticker.GetWidth() != meta.width || sticker.GetHeight() != meta.height || !sticker.GetIsAnimated() {
t.Fatalf("sticker metadata was not attached")
}
if sticker.GetContextInfo() != info {
t.Fatalf("reply context was not attached")
}
}
func testWebPVP8X(width, height uint32, animated bool, extra []byte) []byte {
chunk := make([]byte, 10)
if animated {
chunk[0] = 0x02
}
putUint24(chunk[4:7], width-1)
putUint24(chunk[7:10], height-1)
data := make([]byte, 0, 12+8+len(chunk)+len(extra))
data = append(data, []byte("RIFF")...)
data = binary.LittleEndian.AppendUint32(data, uint32(4+8+len(chunk)+len(extra)))
data = append(data, []byte("WEBPVP8X")...)
data = binary.LittleEndian.AppendUint32(data, uint32(len(chunk)))
data = append(data, chunk...)
data = append(data, extra...)
return data
}
func putUint24(dst []byte, v uint32) {
dst[0] = byte(v)
dst[1] = byte(v >> 8)
dst[2] = byte(v >> 16)
}
func bytesOfSize(n int) []byte {
if n <= 0 {
return nil
}
return make([]byte, n)
}

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"
)
@ -188,41 +188,6 @@ func TestResolveReplySenderAllowsDirectMessageWithoutSender(t *testing.T) {
}
}
func TestUpsertSentReactionStoresDisplayText(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
now := time.Date(2026, 5, 5, 6, 30, 0, 0, time.UTC)
if err := db.UpsertChat(chat.String(), "dm", "Alice", now); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: chat.String(),
MsgID: "target",
Timestamp: now.Add(-time.Second),
FromMe: true,
Text: "hello reaction target",
}); err != nil {
t.Fatalf("UpsertMessage target: %v", err)
}
upsertSentReaction(db, chat, "Alice", "react1", "target", "👍", now)
msg, err := db.GetMessage(chat.String(), "react1")
if err != nil {
t.Fatalf("GetMessage reaction: %v", err)
}
if !msg.FromMe || msg.SenderName != "me" {
t.Fatalf("unexpected sender fields: from_me=%v sender=%q", msg.FromMe, msg.SenderName)
}
if msg.ReactionToID != "target" || msg.ReactionEmoji != "👍" {
t.Fatalf("unexpected reaction fields: to=%q emoji=%q", msg.ReactionToID, msg.ReactionEmoji)
}
if msg.DisplayText != "Reacted 👍 to hello reaction target" {
t.Fatalf("display text = %q", msg.DisplayText)
}
}
func TestBuildReplyContextInfo(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
@ -247,65 +212,12 @@ func TestBuildReplyContextInfo(t *testing.T) {
}
}
func TestParseMentionedJIDs(t *testing.T) {
got, err := parseMentionedJIDs([]string{
" +1 (555) 123-4567 ",
"15551234567@s.whatsapp.net",
"15557654321@s.whatsapp.net",
"",
})
if err != nil {
t.Fatalf("parseMentionedJIDs: %v", err)
}
want := []string{"15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("mentions = %v, want %v", got, want)
}
}
func TestParseMentionedJIDsRejectsGroupJID(t *testing.T) {
_, err := parseMentionedJIDs([]string{"12345@g.us"})
if err == nil || !strings.Contains(err.Error(), "mentions must target a user") {
t.Fatalf("expected group mention rejection, got %v", err)
}
}
func TestSendTextCommandExposesNoPreviewFlag(t *testing.T) {
func TestSendTextCommandExposesTextFlags(t *testing.T) {
cmd := newSendTextCmd(&rootFlags{})
if cmd.Flags().Lookup("no-preview") == nil {
t.Fatalf("missing --no-preview flag")
}
}
func TestSendTextCommandExposesMessageEscapesFlag(t *testing.T) {
cmd := newSendTextCmd(&rootFlags{})
if cmd.Flags().Lookup("message-escapes") == nil {
t.Fatalf("missing --message-escapes flag")
}
}
func TestSendTextCommandExposesMentionFlag(t *testing.T) {
cmd := newSendTextCmd(&rootFlags{})
if cmd.Flags().Lookup("mention") == nil {
t.Fatalf("missing --mention flag")
}
}
func TestDecodeMessageEscapes(t *testing.T) {
got, err := decodeMessageEscapes(`line1\nline2\ttab\rcr\\slash\"quote`)
if err != nil {
t.Fatalf("decodeMessageEscapes: %v", err)
}
want := "line1\nline2\ttab\rcr\\slash\"quote"
if got != want {
t.Fatalf("decoded = %q, want %q", got, want)
}
}
func TestDecodeMessageEscapesRejectsUnknownEscape(t *testing.T) {
_, err := decodeMessageEscapes(`hello\q`)
if err == nil || !strings.Contains(err.Error(), `unsupported escape sequence \q`) {
t.Fatalf("error = %v", err)
for _, name := range []string{"no-preview", "mention"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
@ -328,22 +240,47 @@ func TestBuildTextMessageUsesPlainConversationWithoutReplyOrPreview(t *testing.T
func TestBuildTextMessageAttachesMentions(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
mentions := []string{"15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"}
msg, plain, err := buildTextMessage(db, chat, "hey @15551234567", "", "", nil, mentions)
msg, plain, err := buildTextMessage(db, chat, "@alice @bob", "", "", []string{
"+1 (555) 123-4567",
"999123456@lid",
"15551234567@s.whatsapp.net",
}, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
ext := msg.GetExtendedTextMessage()
if ext.GetText() != "hey @15551234567" {
t.Fatalf("text = %q", ext.GetText())
got := msg.GetExtendedTextMessage().GetContextInfo().GetMentionedJID()
want := []string{"15551234567@s.whatsapp.net", "999123456@lid"}
if len(got) != len(want) {
t.Fatalf("mentioned JIDs = %#v, want %#v", got, want)
}
got := ext.GetContextInfo().GetMentionedJID()
if strings.Join(got, ",") != strings.Join(mentions, ",") {
t.Fatalf("mentioned JIDs = %v, want %v", got, mentions)
for i := range want {
if got[i] != want[i] {
t.Fatalf("mentioned JIDs = %#v, want %#v", got, want)
}
}
}
func TestBuildTextMessageRejectsGroupMention(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
_, _, err := buildTextMessage(db, chat, "hello", "", "", []string{"12345@g.us"}, nil)
if err == nil || !strings.Contains(err.Error(), "must be a user JID or phone number") {
t.Fatalf("expected group mention rejection, got %v", err)
}
}
func TestBuildTextMessageRejectsMentionsOutsideGroups(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
_, _, err := buildTextMessage(db, chat, "hello", "", "", []string{"+15557654321"}, nil)
if err == nil || !strings.Contains(err.Error(), "only supported for group text messages") {
t.Fatalf("expected direct-chat mention rejection, got %v", err)
}
}
@ -351,7 +288,7 @@ func TestBuildTextMessageCombinesReplyAndMentions(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
msg, plain, err := buildTextMessage(db, chat, "replying @15551234567", "quoted", "+15557654321", nil, []string{"15551234567@s.whatsapp.net"})
msg, plain, err := buildTextMessage(db, chat, "@alice replying", "quoted", "+15557654321", []string{"+15551234567"}, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
@ -365,8 +302,9 @@ func TestBuildTextMessageCombinesReplyAndMentions(t *testing.T) {
if info.GetParticipant() != "15557654321@s.whatsapp.net" {
t.Fatalf("participant = %q", info.GetParticipant())
}
if got := info.GetMentionedJID(); strings.Join(got, ",") != "15551234567@s.whatsapp.net" {
t.Fatalf("mentioned JIDs = %v", got)
got := info.GetMentionedJID()
if len(got) != 1 || got[0] != "15551234567@s.whatsapp.net" {
t.Fatalf("mentioned JIDs = %#v", got)
}
}
@ -380,7 +318,7 @@ func TestBuildTextMessageAttachesLinkPreview(t *testing.T) {
Thumbnail: []byte("jpeg"),
}
msg, plain, err := buildTextMessage(db, chat, "see https://example.com/post", "", "", preview, nil)
msg, plain, err := buildTextMessage(db, chat, "see https://example.com/post", "", "", nil, preview)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}

View File

@ -4,11 +4,10 @@ import (
"context"
"fmt"
"os"
"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 {
@ -36,26 +35,6 @@ func newSendVoiceCmd(flags *rootFlags) *cobra.Command {
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
delegateFile := filePath
if abs, absErr := filepath.Abs(filePath); absErr == nil {
delegateFile = abs
}
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "voice",
To: to,
Pick: pick,
File: delegateFile,
MIME: mimeOverride,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "voice", resp)
}
return err
}
defer closeApp(a, lk)

View File

@ -6,47 +6,29 @@ import (
"os"
"os/signal"
"syscall"
"github.com/openclaw/wacli/internal/out"
)
// signalContext returns a context that is cancelled on the first SIGINT/SIGTERM.
// A second signal force-kills the process so that a stuck cleanup never leaves
// the user unable to get their terminal back.
func signalContext() (context.Context, context.CancelFunc) {
return signalContextWithEvents(nil)
}
ctx, cancel := context.WithCancel(context.Background())
func signalContextWithEvents(events *out.EventWriter) (context.Context, context.CancelFunc) {
sigCh := make(chan os.Signal, 2)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
return signalContextForChannel(events, sigCh, func() { signal.Stop(sigCh) }, os.Exit)
}
func signalContextForChannel(events *out.EventWriter, sigCh <-chan os.Signal, stopNotify func(), forceExit func(int)) (context.Context, context.CancelFunc) {
ctx, ctxCancel := context.WithCancel(context.Background())
go func() {
sig := <-sigCh
if events.Enabled() {
_ = events.Emit("signal", map[string]any{"signal": sig.String(), "action": "shutdown"})
} else {
fmt.Fprintln(os.Stderr, "\nShutting down (interrupt again to force quit)...")
}
ctxCancel()
<-sigCh
fmt.Fprintln(os.Stderr, "\nShutting down (interrupt again to force quit)...")
cancel()
sig = <-sigCh
if events.Enabled() {
_ = events.Emit("signal", map[string]any{"signal": sig.String(), "action": "force_quit"})
} else {
fmt.Fprintln(os.Stderr, "Force quit.")
}
forceExit(1)
<-sigCh
fmt.Fprintln(os.Stderr, "Force quit.")
os.Exit(1)
}()
return ctx, func() {
if stopNotify != nil {
stopNotify()
}
ctxCancel()
signal.Stop(sigCh)
cancel()
}
}

View File

@ -1,73 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"syscall"
"testing"
"time"
"github.com/openclaw/wacli/internal/out"
)
func TestSignalContextWithEventsKeepsStderrNDJSON(t *testing.T) {
var stderr bytes.Buffer
exits := make(chan int, 1)
sigCh := make(chan os.Signal, 2)
ctx, stop := signalContextForChannel(out.NewEventWriter(&stderr, true), sigCh, nil, func(code int) {
exits <- code
})
defer stop()
sigCh <- os.Interrupt
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("context was not canceled after first signal")
}
sigCh <- syscall.SIGTERM
select {
case code := <-exits:
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
case <-time.After(time.Second):
t.Fatal("force-exit callback was not called after second signal")
}
raw := stderr.String()
if strings.Contains(raw, "Shutting down") || strings.Contains(raw, "Force quit") {
t.Fatalf("human signal text leaked into --events stderr:\n%s", raw)
}
var sawShutdown, sawForceQuit bool
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
var evt struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal([]byte(line), &evt); err != nil {
t.Fatalf("signal line is not JSON %q: %v", line, err)
}
if evt.Event != "signal" {
t.Fatalf("event = %q, want signal", evt.Event)
}
switch evt.Data["action"] {
case "shutdown":
sawShutdown = true
case "force_quit":
sawForceQuit = true
}
}
if !sawShutdown || !sawForceQuit {
t.Fatalf("missing signal events shutdown=%v force_quit=%v in:\n%s", sawShutdown, sawForceQuit, raw)
}
if err := ctx.Err(); err != context.Canceled {
t.Fatalf("ctx.Err() = %v, want context.Canceled", err)
}
}

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

@ -1,13 +0,0 @@
package main
import "github.com/spf13/cobra"
func newStoreCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "store",
Short: "Manage local data store",
}
cmd.AddCommand(newStoreCleanupCmd(flags))
cmd.AddCommand(newStoreStatsCmd(flags))
return cmd
}

View File

@ -1,126 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newStoreCleanupCmd(flags *rootFlags) *cobra.Command {
var days int
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up old data from local store",
Long: `Clean up old messages and chats from local storage.
Removes chats with no recent activity and their associated messages.
Use --days to set the threshold (default: 365 days).
Use --dry-run to preview what would be deleted.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
_ = ctx
chats, err := a.DB().ListChatsOlderThan(days)
if err != nil {
return err
}
if len(chats) == 0 {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "nothing to clean up"})
}
fmt.Fprintln(os.Stderr, "Nothing to clean up.")
return nil
}
var totalMessages int64
for _, c := range chats {
count, _ := a.DB().CountChatMessages(c.JID)
totalMessages += count
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"would_delete_chats": len(chats),
"would_delete_messages": totalMessages,
"days": days,
})
}
fmt.Fprintf(os.Stderr, "Would delete %d chat(s) with %d total message(s) (older than %d days):\n", len(chats), totalMessages, days)
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
count, _ := a.DB().CountChatMessages(c.JID)
fmt.Fprintf(os.Stderr, " - %s (%s, %d messages)\n", name, c.JID, count)
}
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d chat(s) with %d total message(s). This cannot be undone.\n", len(chats), totalMessages)
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deletedChats, deletedMessages int64
for _, c := range chats {
count, _ := a.DB().CountChatMessages(c.JID)
if err := a.DB().DeleteChat(c.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete chat %s: %v\n", c.JID, err)
continue
}
deletedChats++
deletedMessages += count
if !flags.asJSON {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s (%d messages)\n", name, count)
}
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"deleted_chats": deletedChats,
"deleted_messages": deletedMessages,
})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d chat(s) with %d message(s).\n", deletedChats, deletedMessages)
return nil
},
}
cmd.Flags().IntVar(&days, "days", 365, "delete data older than N days")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}

View File

@ -1,68 +0,0 @@
package main
import (
"context"
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newStoreStatsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "stats",
Short: "Show store statistics",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
_ = ctx
chats, err := a.DB().ListChats("", 0)
if err != nil {
return err
}
groups, err := a.DB().ListGroups("", 0)
if err != nil {
return err
}
leftGroups, err := a.DB().ListLeftGroups()
if err != nil {
return err
}
totalMessages, err := a.DB().CountMessages()
if err != nil {
return err
}
stats := map[string]any{
"chats": len(chats),
"groups": len(groups),
"left_groups": len(leftGroups),
"messages": totalMessages,
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, stats)
}
fmt.Fprintf(os.Stdout, "Store Statistics:\n")
fmt.Fprintf(os.Stdout, " Chats: %d\n", len(chats))
fmt.Fprintf(os.Stdout, " Groups: %d\n", len(groups))
fmt.Fprintf(os.Stdout, " Left Groups: %d\n", len(leftGroups))
fmt.Fprintf(os.Stdout, " Messages: %d\n", totalMessages)
return nil
},
}
return cmd
}

View File

@ -1,14 +1,13 @@
package main
import (
"context"
"fmt"
"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 {
@ -19,9 +18,6 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
var downloadMedia bool
var refreshContacts bool
var refreshGroups bool
var refreshChannels bool
var webhookURL string
var webhookSecret string
var storage syncStorageLimitFlags
cmd := &cobra.Command{
@ -31,15 +27,11 @@ 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
}
if webhookSecret != "" && webhookURL == "" {
return fmt.Errorf("--webhook-secret requires --webhook")
}
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
ctx, stop := signalContext()
defer stop()
a, lk, err := newApp(ctx, flags, true, false)
@ -61,39 +53,17 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
mode = appPkg.SyncModeOnce
}
var stopSendDelegate func()
defer func() {
if stopSendDelegate != nil {
stopSendDelegate()
}
}()
var afterConnect func(context.Context) error
if mode == appPkg.SyncModeFollow {
afterConnect = func(ctx context.Context) error {
stop, err := startSendDelegateServer(ctx, a)
if err != nil {
return err
}
stopSendDelegate = stop
return nil
}
}
res, err := a.Sync(ctx, appPkg.SyncOptions{
Mode: mode,
AllowQR: false,
AfterConnect: afterConnect,
DownloadMedia: downloadMedia,
RefreshContacts: refreshContacts,
RefreshGroups: refreshGroups,
RefreshChannels: refreshChannels,
IdleExit: idleExit,
MaxReconnect: maxReconnect,
MaxMessages: maxMessages,
MaxDBSizeBytes: maxDBSize,
WarnNoLimits: true,
WebhookURL: webhookURL,
WebhookSecret: webhookSecret,
})
if err != nil {
return err
@ -117,9 +87,6 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
cmd.Flags().BoolVar(&refreshContacts, "refresh-contacts", false, "refresh contacts from session store into local DB")
cmd.Flags().BoolVar(&refreshGroups, "refresh-groups", false, "refresh joined groups (live) into local DB")
cmd.Flags().BoolVar(&refreshChannels, "refresh-channels", false, "refresh subscribed channels (live) into local DB")
cmd.Flags().StringVar(&webhookURL, "webhook", "", "URL to POST live message JSON")
cmd.Flags().StringVar(&webhookSecret, "webhook-secret", "", "HMAC-SHA256 secret for X-Wacli-Signature header")
cmd.Flags().Int64Var(&storage.maxMessages, "max-messages", 0, "maximum total messages to keep in the local DB before sync stops (0 = unlimited, or WACLI_SYNC_MAX_MESSAGES)")
cmd.Flags().StringVar(&storage.maxDBSize, "max-db-size", "", "maximum wacli.db disk usage before sync stops, e.g. 500MB or 2GB (default: WACLI_SYNC_MAX_DB_SIZE or unlimited)")
return cmd

View File

@ -1,25 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestSyncCommandExposesWebhookFlags(t *testing.T) {
cmd := newSyncCmd(&rootFlags{})
for _, name := range []string{"webhook", "webhook-secret"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
func TestSyncCommandRequiresWebhookForSecret(t *testing.T) {
cmd := newSyncCmd(&rootFlags{})
cmd.SetArgs([]string{"--webhook-secret", "secret"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--webhook-secret requires --webhook") {
t.Fatalf("expected webhook-secret validation error, got %v", err)
}
}

View File

@ -11,7 +11,7 @@ func newVersionCmd() *cobra.Command {
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintln(cmd.OutOrStdout(), version)
fmt.Println(version)
},
}
}

View File

@ -1,20 +0,0 @@
package main
import (
"bytes"
"testing"
)
func TestVersionCommandUsesConfiguredOutput(t *testing.T) {
var out bytes.Buffer
cmd := newVersionCmd()
cmd.SetOut(&out)
cmd.SetArgs(nil)
if err := cmd.Execute(); err != nil {
t.Fatalf("version command: %v", err)
}
if got, want := out.String(), version+"\n"; got != want {
t.Fatalf("version output = %q, want %q", got, want)
}
}

View File

@ -1 +0,0 @@
wacli.sh

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,15 +2,14 @@
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
```bash
wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE] [--events]
wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE]
wacli auth status
wacli auth logout
wacli --account work auth status
```
## Notes
@ -18,13 +17,10 @@ wacli --account work auth status
- Default pairing prints a terminal QR code.
- `--qr-format text` prints the raw QR payload for external renderers.
- `--phone PHONE` uses WhatsApp phone-number pairing instead of QR pairing.
- Transient websocket drops before pairing completes are retried with a fresh QR/code.
- After pairing, auth runs bootstrap sync until idle unless `--follow` is set.
- Bootstrap sync honors `WACLI_SYNC_MAX_MESSAGES` and `WACLI_SYNC_MAX_DB_SIZE` to cap local history growth.
- `--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

@ -1,37 +0,0 @@
# channels
Read when: listing, joining, leaving, inspecting, or sending to WhatsApp Channels.
`wacli channels` manages WhatsApp Channels, which `whatsmeow` calls newsletters. Commands use live WhatsApp APIs and require authentication. Commands that update WhatsApp or the local chat cache require writable mode.
## Commands
```bash
wacli channels list
wacli channels info --jid CHANNEL_JID
wacli channels join --invite LINK_OR_CODE
wacli channels leave --jid CHANNEL_JID
```
## Notes
- Channel JIDs use the `...@newsletter` server.
- `channels list` fetches subscribed channels live and updates local chat rows with kind `newsletter`.
- `channels info` fetches one joined channel live and updates the local chat row.
- `channels join` accepts a full `https://whatsapp.com/channel/...` link or just the invite code.
- `channels leave` unfollows the channel on WhatsApp.
- `sync --refresh-channels` refreshes subscribed channel names into the local chat cache.
- `send text --to ...@newsletter` can send to channels when the authenticated account has permission.
- `send file --to ...@newsletter` uses WhatsApp's unencrypted newsletter media upload path and requires channel posting permission.
- Quoted file replies and `--ptt` voice-note mode are not supported for channels.
## Examples
```bash
wacli channels list
wacli channels info --jid 123456789012345@newsletter
wacli channels join --invite https://whatsapp.com/channel/AbCdEfGhIjK
wacli channels leave --jid 123456789012345@newsletter
wacli send text --to 123456789012345@newsletter --message "Hello channel"
wacli send file --to 123456789012345@newsletter --file ./image.png --caption "Update"
```

View File

@ -1,47 +1,27 @@
# chats
Read when: listing known chats, filtering chat state, archiving/pinning/muting/marking chats, or pruning stale local chat rows.
Read when: listing known chats or resolving one chat from the local store.
`wacli chats` reads chat rows from `wacli.db`. It can use session-backed PN/LID mappings to make historical `@lid` chat rows display as phone-number chats when possible. State commands send WhatsApp app-state patches through the authenticated session and update the local index after WhatsApp accepts the change.
`wacli chats` reads chat rows from `wacli.db`. It can use session-backed PN/LID mappings to make historical `@lid` chat rows display as phone-number chats when possible.
## Commands
```bash
wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]
wacli chats list [--query TEXT] [--limit N]
wacli chats show --jid JID
wacli chats archive --chat CHAT [--pick N]
wacli chats unarchive --chat CHAT [--pick N]
wacli chats pin --chat CHAT [--pick N]
wacli chats 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 --chat CHAT [--pick N]
wacli chats mark-unread --chat CHAT [--pick N]
wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]
```
## Notes
- `list` is local and sorted by pinned chats first, then newest known message timestamp.
- `list` is local and sorted by newest known message timestamp.
- `--query` filters by chat name or JID.
- `list --json` and `show --json` include `archived`, `pinned`, `muted_until`, and `unread`.
- `show` accepts the stored JID. If a phone JID maps to a historical `@lid` row, it can show that row too.
- State commands use `--chat` and resolve names, phone numbers, groups, and JIDs like send commands. Use `--pick N` for ambiguous matches.
- State commands print a compact success line by default and a stable JSON object with `--json`.
- `mute --duration 0` or omitting `--duration` mutes forever. Use `unmute` to clear it.
- Run `wacli sync` to catch up chat-state changes made on other devices; run `wacli contacts refresh` to improve chat names.
- `cleanup` only deletes local `wacli.db` rows. It does not delete chats or messages from WhatsApp.
- `cleanup --days N` skips chats with no known local activity timestamp; use `--jid` for an explicit local row.
- Use `cleanup --dry-run` before deleting and `--confirm` only for scripts that already reviewed the target list.
- Run `wacli sync` or `wacli contacts refresh` to improve chat names.
## Examples
```bash
wacli chats list
wacli chats list --query family --limit 20
wacli chats list --pinned
wacli chats show --jid 1234567890@s.whatsapp.net
wacli chats mute --chat "+1 555 123 4567" --duration 8h
wacli chats mark-read --chat family --pick 1
wacli chats cleanup --days 365 --dry-run
```

View File

@ -1,122 +0,0 @@
# contacts import-system
Read when: importing macOS Contacts names into wacli, previewing matched phone numbers, clearing imported names, or feeding contacts from JSON/NDJSON.
`wacli contacts import-system` matches phone numbers from your system contacts against contacts already stored in `wacli.db`, then stores the system display name as local wacli metadata.
It does not modify WhatsApp, your phone contacts, or macOS Contacts.
## Before Importing
Run a contact refresh first so wacli has the latest WhatsApp-side contact rows:
```bash
wacli contacts refresh
```
Then preview the import:
```bash
wacli contacts import-system --dry-run
```
The dry run prints how many local contacts would receive a system name, plus skipped counts for contacts with no phone number, no system match, or an already-current system name.
## Apply
```bash
wacli contacts import-system
```
On macOS, this reads Contacts.app through the Contacts framework. macOS may prompt for Contacts permission the first time. If access is denied, grant Contacts access in System Settings and run the command again.
The command stores names in `contacts.system_name`. Display and search precedence is:
```text
alias > system_name > WhatsApp full/push/business/first name
```
Manual aliases still win. Use aliases for intentional local nicknames; use system names to mirror your address book display names.
## JSON
Use global `--json` for machine-readable output:
```bash
wacli --json contacts import-system --dry-run
```
The JSON response is wrapped in the standard envelope. Import details live under `.data`:
```json
{
"success": true,
"data": {
"matched": 42,
"matches": [
{
"jid": "1234567890@s.whatsapp.net",
"phone": "1234567890",
"current_name": "WhatsApp Name",
"system_name": "Address Book Name"
}
],
"skipped_no_phone": 0,
"skipped_no_match": 10,
"skipped_same": 5,
"dry_run": true
},
"error": null
}
```
## Import From A File
Use `--input FILE` to import from a JSON array or newline-delimited JSON instead of opening macOS Contacts:
```bash
wacli contacts import-system --input contacts.json --dry-run
wacli contacts import-system --input contacts.ndjson
```
Each contact object can contain `full_name`, `first_name`, `last_name`, and `phones`:
```json
[
{
"full_name": "Alice Appleseed",
"phones": ["+1 (415) 734-7847"]
}
]
```
NDJSON works too:
```json
{"full_name":"Alice Appleseed","phones":["+1 (415) 734-7847"]}
{"first_name":"Bob","last_name":"Builder","phones":["0043 664 104 2436"]}
```
Phone matching strips non-digits. Numbers with a leading international `00` prefix are normalized to the same digits as `+`.
## Clear Imported Names
Preview and clear imported system names:
```bash
wacli contacts import-system --clear --dry-run
wacli contacts import-system --clear
```
Clearing removes only `system_name` values. It does not remove contacts, aliases, tags, messages, WhatsApp data, or macOS Contacts entries.
## Verify
Show a contact and search by its imported system name:
```bash
wacli contacts show --jid 1234567890@s.whatsapp.net
wacli contacts search "Alice Appleseed"
```
`contacts show` includes `System Name:` when one is present. Search matches imported system names in addition to aliases, WhatsApp names, phone numbers, and JIDs.

View File

@ -1,6 +1,6 @@
# contacts
Read when: finding synced contacts, importing macOS Contacts names, or managing local contact metadata.
Read when: finding synced contacts or managing local contact metadata.
`wacli contacts` works with contact metadata stored locally. Aliases and tags are local to `wacli`; they do not edit WhatsApp contacts on the phone.
@ -10,7 +10,6 @@ Read when: finding synced contacts, importing macOS Contacts names, or managing
wacli contacts search <query> [--limit N]
wacli contacts show --jid JID
wacli contacts refresh
wacli contacts import-system [--input FILE] [--dry-run] [--clear]
wacli contacts alias set --jid JID --alias NAME
wacli contacts alias rm --jid JID
wacli contacts tags add --jid JID --tag TAG
@ -21,12 +20,7 @@ wacli contacts tags rm --jid JID --tag TAG
- `search` matches alias, full name, push name, first name, business name, phone, and JID.
- `refresh` imports contacts from the whatsmeow session store into `wacli.db`.
- `import-system` imports display names from macOS Contacts by matching phone numbers against already-synced wacli contacts. Run `contacts refresh` first.
- `import-system --input FILE` reads a JSON array or newline-delimited JSON contacts file with `full_name` and `phones` fields instead of opening macOS Contacts.
- Imported system names are local wacli metadata. They do not edit WhatsApp contacts or macOS Contacts.
- Display precedence is local alias, imported system name, then WhatsApp names.
- Use `import-system --dry-run` before writing. Use `import-system --clear` to remove imported system names.
- See [contacts import-system](contacts-import-system.md) for the full import workflow, JSON shape, file format, and verification steps.
- Local aliases are preferred in contact search and display.
- Tags are local grouping metadata for scripts and future workflows.
## Examples
@ -35,7 +29,6 @@ wacli contacts tags rm --jid JID --tag TAG
wacli contacts search Alice
wacli contacts show --jid 1234567890@s.whatsapp.net
wacli contacts refresh
wacli contacts import-system --dry-run
wacli contacts alias set --jid 1234567890@s.whatsapp.net --alias mom
wacli contacts tags add --jid 1234567890@s.whatsapp.net --tag family
```

View File

@ -1,26 +0,0 @@
# docs
Read when: opening the hosted documentation site from the CLI.
`wacli docs` prints the canonical hosted documentation URL: <https://wacli.sh>.
Use it from scripts or terminal sessions when you need a stable pointer to the
GitHub Pages documentation.
## Command
```bash
wacli docs
```
## JSON
```bash
wacli --json docs
```
## Examples
```bash
wacli docs
open "$(wacli docs)"
```

View File

@ -1,6 +1,6 @@
# groups
Read when: listing, refreshing, inspecting, renaming, joining, leaving, inviting, pruning stale local group rows, or managing group participants.
Read when: listing, refreshing, inspecting, renaming, joining, leaving, inviting, or managing group participants.
`wacli groups` combines local group rows with live WhatsApp operations. Commands that mutate WhatsApp require writable mode.
@ -19,21 +19,15 @@ wacli groups participants demote --jid GROUP_JID --user PHONE_OR_JID [--user ...
wacli groups invite link get --jid GROUP_JID
wacli groups invite link revoke --jid GROUP_JID
wacli groups join --code INVITE_CODE
wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]
```
## Notes
- Group JIDs use the `...@g.us` server.
- `list` reads local rows and hides groups marked left. Human output includes the group type (`group`, `community`, or `subgroup`) and parent community JID when known.
- `list --json` includes `IsParent` for communities and `LinkedParentJID` for subgroups.
- `refresh` fetches joined groups live and updates local rows, including WhatsApp Community hierarchy metadata exposed by whatsmeow.
- `info` fetches one group live and persists it, including whether the chat is a Community parent or linked subgroup.
- `list` reads local rows and hides groups marked left.
- `refresh` fetches joined groups live and updates local rows.
- `info` fetches one group live and persists it.
- `leave` marks the group left locally after WhatsApp confirms.
- `prune` only deletes local group/chat/message rows from `wacli.db`. It does not leave WhatsApp groups or delete anything from WhatsApp servers.
- `prune` defaults to groups marked left locally. `--days N` limits left-group pruning to groups left more than `N` days ago.
- `prune --include-active --days N` also targets active groups whose last known local message is older than `N` days. Groups with no known local activity timestamp are skipped.
- Use `prune --dry-run` before deleting and `--confirm` only after reviewing the target list.
- Participant users accept phone numbers with common formatting or JIDs.
- Invite `revoke` resets the invite link.
@ -47,5 +41,4 @@ wacli groups rename --jid 123456789@g.us --name "New name"
wacli groups participants add --jid 123456789@g.us --user "+1 (234) 567-8900"
wacli groups invite link get --jid 123456789@g.us
wacli groups join --code AbCdEfGhIjK
wacli groups prune --dry-run
```

View File

@ -3,7 +3,6 @@
Read when: discovering command usage from the CLI itself.
`wacli help` is the Cobra-provided help command. Every command also accepts `--help`.
Root help prints the hosted documentation URL, and `wacli docs` prints it directly.
## Commands
@ -17,6 +16,5 @@ wacli [command] --help
```bash
wacli help send
wacli send text --help
wacli docs
wacli groups participants add --help
```

View File

@ -2,23 +2,14 @@
Read when: trying to fetch older messages for a known chat.
`wacli history` inspects local archive coverage and can send on-demand history sync requests to the primary device. Backfill is best-effort and depends on the phone being online and WhatsApp returning older messages.
`wacli history backfill` sends on-demand history sync requests to the primary device. This is best-effort and depends on the phone being online and WhatsApp returning older messages.
## Commands
## Command
```bash
wacli history coverage [--query TEXT] [--kind KIND] [--include-blocked] [--only-actionable]
wacli history fill --dry-run [--query TEXT] [--kind KIND] [--limit 100]
wacli history backfill --chat JID [--count 50] [--requests N] [--wait 1m] [--idle-exit 5s] [--events]
wacli history backfill --chat JID [--count 50] [--requests N] [--wait 1m] [--idle-exit 5s]
```
## Coverage and planning
- `history coverage` reads only the local `wacli.db` store.
- `ready` chats have at least one local message, so `history backfill` has an anchor.
- `blocked` / `no_local_anchor` chats have no local message yet; run `wacli sync` first.
- `history fill --dry-run` lists matching ready chats that would be selected for a future multi-chat fill workflow. It does not connect to WhatsApp or write state.
## Limits
- `--count` defaults to 50 and must be at most 500.
@ -26,14 +17,10 @@ wacli history backfill --chat JID [--count 50] [--requests N] [--wait 1m] [--idl
- Requests are per chat.
- The anchor is the oldest locally stored message in that chat.
- Automatic initial history-sync blob downloads are disabled during backfill; only on-demand responses are processed.
- `--events` emits NDJSON request/response/stop lifecycle events on stderr.
## Examples
```bash
wacli history coverage --include-blocked
wacli history coverage --query family --only-actionable
wacli history fill --dry-run --kind group --limit 20
wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
wacli history backfill --chat 123456789@g.us --requests 3 --wait 90s
```

View File

@ -1,48 +0,0 @@
---
title: Overview
permalink: /
description: "wacli is a single Go CLI that pairs as a linked WhatsApp Web device, mirrors message history into local SQLite with FTS5 search, and exposes send, media, contact, and group workflows for terminals, scripts, and coding agents."
---
# wacli
A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/whatsmeow). One binary pairs as a linked WhatsApp Web device, syncs messages into a local SQLite store, and exposes search, send, media, contact, and group commands with predictable output for terminals, shell pipelines, and coding agents.
## Why wacli
- **Local mirror, fast search.** All synced messages land in a SQLite store with an FTS5 index; offline `messages search` returns hits in milliseconds.
- **Chat state controls.** Archive, pin, mute, and mark chats read/unread from the CLI, then filter `chats list` by those states.
- **Stable output.** Human-readable tables by default, `--json` to stdout for scripts, NDJSON `--events` for long-running commands. Human progress, prompts, and errors stay on stderr so pipes stay clean.
- **Single binary.** No daemon, no plugin host. Run `wacli auth`, then `wacli sync --follow` to keep the store warm.
- **Built for agents.** `--read-only` (or `WACLI_READONLY=1`) blocks every command that mutates WhatsApp or local state. Store locks prevent two instances from racing on the same device identity.
- **Boundable storage.** `sync` warns when storage is uncapped; `--max-messages` / `--max-db-size` cap local growth. Send retries are bounded; media uploads/downloads cap at 100 MiB.
- **Best-effort history.** `history coverage` shows local anchors, `history fill --dry-run` plans candidate chats, and `history backfill` requests older messages per chat from your primary device.
## 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.
- **Sending from scripts.** Read [Send](send.md) for recipient resolution, channels, replies, mentions, files, and reactions.
- **Mirroring address-book names.** Read [Contacts import-system](contacts-import-system.md) to import macOS Contacts display names into local wacli metadata.
- **Wiring up an agent.** Pair `--read-only`, `--json`, and `--events` from [Overview](overview.md); read [Doctor](doctor.md) for self-checks.
- **Building companion tools.** Read [Companion integrations](integrations.md) for safe read-only SQLite and JSON integration patterns.
- **Looking up a flag.** Open the per-command pages from [Overview](overview.md).
## 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.
## Out of scope
- Guaranteed full-history export (WhatsApp Web history is best-effort).
- A daemon, MCP server, web UI, or GUI.
- End-to-end "contact creation" inside WhatsApp; local aliases and tags only.
## Disclaimer
`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).

View File

@ -1,90 +0,0 @@
---
title: Install
description: "Install wacli via Homebrew tap, prebuilt release archives, or a local build with cgo."
---
# Install
`wacli` ships as a single binary. Local builds need cgo (because of `go-sqlite3` with FTS5); release artifacts and the Homebrew tap take care of that for you.
## Homebrew (macOS, Linux)
```bash
brew install steipete/tap/wacli
wacli --version
```
If a Linux install from the tap reports `Binary was compiled with 'CGO_ENABLED=0'`, update the tap and reinstall the formula:
```bash
brew update
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`.
## Build from source
`wacli` uses `go-sqlite3`, so source builds require cgo and a C toolchain:
- macOS: Xcode Command Line Tools.
- Debian / Ubuntu: `sudo apt install build-essential`.
- Fedora / RHEL: `sudo dnf groupinstall "Development Tools"`.
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
cd wacli
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
./dist/wacli --version
```
The `sqlite_fts5` build tag is required for `messages search` to use the FTS5 index. Without it, search falls back to `LIKE`.
GCC 15 has stricter brace-init warnings; the `-Wno-error=missing-braces` flag keeps the `go-sqlite3` build green there. macOS / clang and older GCC do not need it.
If you have `pnpm` installed, `pnpm build` runs the same command and writes `./dist/wacli`.
## Verify the install
```bash
wacli --version
wacli doctor
wacli --help
```
`wacli doctor` checks the store directory, database integrity, FTS5 availability, and (with `--connect`) live connectivity to WhatsApp. See [Doctor](doctor.md).
## Updating
- **Homebrew tap**: `brew upgrade wacli` (or `brew reinstall steipete/tap/wacli`).
- **GitHub release archives**: download the new tarball / ZIP and replace the binary.
- **Source builds**: `git pull && pnpm build` (or the manual `go build` above). Local builds use the version compiled into the source tree; release artifacts inject the tag during GoReleaser builds.
The local store format is forward-compatible across point releases; routine upgrades do not require re-pairing.
## Storage
- Default store directory: `~/.local/state/wacli` on Linux (XDG state dir), `~/.wacli` on macOS / Windows. Existing Linux `~/.wacli` directories keep working.
- Override with `--store DIR` or `WACLI_STORE_DIR`.
- The store contains `session.db` (whatsmeow keys), `wacli.db` (messages + FTS), `media/`, and a `LOCK` file. See [Spec](spec.md#storage-layout) for the layout.
- Permissions are owner-only (`0700` on the directory, `0600` on files). Do not relax these — they protect your WhatsApp session keys.
## Related pages
- [Quickstart](quickstart.md) — pair, sync, and send your first message.
- [Auth](auth.md) — `wacli auth`, `auth status`, `auth logout`.
- [Sync](sync.md) — bootstrap and follow-mode sync, refresh flags.
- [Doctor](doctor.md) — self-checks and connectivity probe.
- [Release](release.md) — release workflow and artifact expectations.

View File

@ -1,137 +0,0 @@
# companion integrations
Read when: building a local analytics, search, CRM, or agent-side companion tool on top of synced `wacli` data.
`wacli` is intentionally useful from scripts without becoming a plugin host. Companion tools should prefer stable CLI output first, then use read-only SQLite access when they need low-latency local queries or their own derived database.
## Integration surfaces
- Use `--json` for one-shot command output from `chats`, `contacts`, `groups`, `messages`, and `doctor`.
- Use `--events` for line-delimited lifecycle events from long-running `auth`, `sync`, and `history backfill` commands.
- Use `sync --webhook` for live-message delivery to another process or service.
- Use a read-only SQLite connection to `<store>/wacli.db` for local analytics that need joins, cursors, or incremental scans.
Prefer the CLI or webhook when possible. Direct SQLite reads are powerful, but the schema can evolve between releases.
## Store paths
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.
The store contains two SQLite databases:
- `session.db`: owned by `whatsmeow`; contains linked-device identity and keys.
- `wacli.db`: owned by `wacli`; contains chats, contacts, groups, messages, media metadata, and local state.
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:
```bash
sqlite3 "file:$HOME/.wacli/wacli.db?mode=ro" \
"SELECT chat_jid, msg_id, datetime(ts, 'unixepoch') AS at, display_text
FROM messages
WHERE revoked = 0 AND deleted_for_me = 0
ORDER BY ts DESC
LIMIT 20"
```
In Python:
```python
from pathlib import Path
import sqlite3
db = Path.home() / ".wacli" / "wacli.db"
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT chat_jid, msg_id, sender_jid, sender_name, ts, display_text
FROM messages
WHERE revoked = 0 AND deleted_for_me = 0
ORDER BY ts DESC
LIMIT ?
""", (50,)).fetchall()
```
Avoid `immutable=1` when `wacli sync --follow` may be writing concurrently; a normal read-only SQLite connection can see WAL updates safely.
## Common queries
Recent human-visible messages:
```sql
SELECT
m.chat_jid,
COALESCE(m.chat_name, c.name, '') AS chat_name,
m.msg_id,
m.sender_jid,
COALESCE(m.sender_name, '') AS sender_name,
m.ts,
COALESCE(m.display_text, m.text, '') AS text
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.revoked = 0
AND m.deleted_for_me = 0
ORDER BY m.ts DESC
LIMIT 100;
```
Incremental scan cursor:
```sql
SELECT rowid, chat_jid, msg_id, sender_jid, ts, display_text
FROM messages
WHERE rowid > ?
ORDER BY rowid ASC
LIMIT 1000;
```
Known chats by newest activity:
```sql
SELECT jid, kind, name, last_message_ts, archived, pinned, muted_until, unread
FROM chats
ORDER BY COALESCE(last_message_ts, 0) DESC
LIMIT 100;
```
Community subgroups:
```sql
SELECT jid, name, linked_parent_jid
FROM groups
WHERE linked_parent_jid IS NOT NULL
ORDER BY name;
```
## Privacy and safety
- Store derived data in your own database, not in `wacli.db`.
- Treat JIDs, display names, message text, media filenames, and local media paths as sensitive.
- Hash JIDs with a tool-local salt if you only need stable identity buckets.
- Provide a delete or opt-out path if the companion tool tracks people.
- Do not copy `session.db`, media keys, or WhatsApp device keys into unrelated systems.
- Use `WACLI_READONLY=1` when shelling out to `wacli` from a tool that should never mutate WhatsApp or the local store.
## Speaker-tracking pattern
A speaker tracker can stay small and non-invasive:
1. Run `wacli sync --follow` separately to keep the store warm.
2. Keep a cursor using the largest processed `messages.rowid`.
3. Read only new rows from `messages` in read-only mode.
4. Skip `from_me` rows if you only want contacts.
5. Hash `sender_jid` before writing to the tool database.
6. Store counts, first/last seen timestamps, and opt-out state in the tool database.
This pattern keeps `wacli` responsible for WhatsApp sync and keeps the companion tool responsible only for its derived local state.

View File

@ -1,20 +1,16 @@
# messages
Read when: listing, searching, exporting, showing, or inspecting local message context.
Read when: listing, searching, showing, or inspecting local message context.
Most `wacli messages` commands read from the local store. `messages edit` and `messages delete` are remote WhatsApp mutations and require an authenticated, writable store.
`wacli messages` reads from the local store. It does not connect to WhatsApp unless a display path needs session-backed LID mapping.
## Commands
```bash
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] [--limit N] [--after DATE] [--before DATE]
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 list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded]
wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded] [--limit N] [--after DATE] [--before DATE]
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 [--for-me] [--delete-media] [--post-send-wait 2s]
```
## Search
@ -22,31 +18,8 @@ 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`.
## Starred
- `messages starred` lists starred messages ordered by star time when app-state events provide it; history-imported rows fall back to message time.
- `--after` and `--before` on `messages starred` filter by that stored star time.
- Starred state is imported from history sync and app-state star/unstar events.
## Export
- `messages export` writes a JSON export envelope with messages ordered oldest first.
- Use `--chat` to export one chat, or omit it to export recent messages across chats.
- Use `--after` and `--before` to bound the exported time window.
- Use `--output` to write the JSON export to a file.
## Edit and Delete
- `messages edit` updates one of your own recent sent text messages. WhatsApp only accepts edits inside its current edit window.
- `messages delete` revokes one of your own sent messages for everyone.
- `messages delete --for-me` removes a stored message only for your WhatsApp account using WhatsApp's `deleteMessageForMe` app-state patch; it can target messages sent by you or by others. `--delete-media` is only valid with `--for-me`.
- Both commands look up the target in the local store first and honor `--read-only`/`WACLI_READONLY`. Delete-for-everyone and edit require a message sent by you.
- Deleted messages and WhatsApp delete-for-me events are kept as local tombstones for direct `messages show`, but are hidden from normal list/search/starred/export results.
## LID mapping
When a phone-number chat JID maps to a stored `@lid` row, list/search/show/context include the mapped rows so historical LID splits do not hide messages.
@ -56,13 +29,7 @@ When a phone-number chat JID maps to a stored `@lid` row, list/search/show/conte
```bash
wacli messages list --chat 1234567890@s.whatsapp.net --asc
wacli messages list --from-me --limit 20
wacli messages starred --limit 20
wacli messages search "invoice" --has-media --type document
wacli messages search "invoice" --starred
wacli messages export --chat 1234567890@s.whatsapp.net --after 2024-01-01 --before 2024-02-01 --output messages.json
wacli messages show --chat 1234567890@s.whatsapp.net --id ABC123
wacli messages context --chat 1234567890@s.whatsapp.net --id ABC123 --before 3 --after 3
wacli messages edit --chat 1234567890@s.whatsapp.net --id ABC123 --message "updated text"
wacli messages delete --chat 1234567890@s.whatsapp.net --id ABC123
wacli messages delete --chat 1234567890@s.whatsapp.net --id ABC123 --for-me
```

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
@ -15,33 +15,25 @@ Read when: you need the user-facing command map, global flags, store model, or l
- Write commands acquire the store lock; use `--lock-wait DURATION` to wait.
- Use `--read-only` or `WACLI_READONLY=1` to reject commands that write WhatsApp or local state.
- Use `sync --max-messages`, `sync --max-db-size`, `WACLI_SYNC_MAX_MESSAGES`, or `WACLI_SYNC_MAX_DB_SIZE` to bound local history growth.
- Use `store cleanup`, `chats cleanup`, and `groups prune` to preview and remove stale local rows after sync has already stored them.
- Authenticated startup resolves historical `@lid` chat/message rows to phone-number JIDs when the WhatsApp session store has the mapping.
- Companion tools should prefer `--json`, `--events`, webhooks, or read-only access to `wacli.db`; see [companion integrations](integrations.md).
## 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.
- [sync](sync.md) - sync messages, contacts, groups, and optional media.
- [messages](messages.md) - list, search, show, and contextualize stored messages.
- [send](send.md) - send text, files, stickers, replies, and reactions.
- [send](send.md) - send text, files, replies, and reactions.
- [media](media.md) - download media attached to stored messages.
- [contacts](contacts.md) - search contacts and manage local aliases/tags.
- [contacts import-system](contacts-import-system.md) - import macOS Contacts names into local contact metadata.
- [chats](chats.md) - list, show, filter, and manage known chat state.
- [chats](chats.md) - list and show known chats.
- [groups](groups.md) - refresh, inspect, rename, leave, join, invite, and manage participants.
- [store](store.md) - inspect local store stats and prune stale local rows.
- [channels](channels.md) - list, inspect, join, leave, and send to WhatsApp Channels.
- [history](history.md) - inspect archive coverage and request older per-chat history from the primary device.
- [history](history.md) - request older per-chat history from the primary device.
- [presence](presence.md) - send typing/paused indicators.
- [profile](profile.md) - set the authenticated account profile picture.
- [doctor](doctor.md) - diagnose store, auth, search, and optional live connectivity.
- [docs](docs.md) - print the hosted documentation URL.
- [version](version.md) - print the CLI version.
- [completion](completion.md) - generate shell completion scripts.
- [help](help.md) - inspect command help from the CLI.
- [companion integrations](integrations.md) - build read-only local tools on top of synced data.
## Common flow
@ -54,11 +46,9 @@ wacli send text --to mom --message "hello"
## Recipient formats
Commands that accept `PHONE_OR_JID` accept a WhatsApp JID like `1234567890@s.whatsapp.net`, a group JID like `123456789@g.us`, a channel JID like `123456789012345@newsletter`, or a phone number with common formatting such as `+1 (234) 567-8900`.
Commands that accept `PHONE_OR_JID` accept a WhatsApp JID like `1234567890@s.whatsapp.net`, a group JID like `123456789@g.us`, or a phone number with common formatting such as `+1 (234) 567-8900`.
`send text`, `send file`, `send sticker`, and `send voice` also accept synced contact, group, or chat names through `RECIPIENT`. If a name is ambiguous, interactive terminals prompt; scripts can use `--pick N`.
`chats archive`, `chats pin`, `chats mute`, and `chats mark-read` use the same synced contact/group/chat resolver through `--chat`. Pass a raw JID when you need an exact target.
`send text`, `send file`, and `send voice` also accept synced contact, group, or chat names through `RECIPIENT`. If a name is ambiguous, interactive terminals prompt; scripts can use `--pick N`.
## History limits

View File

@ -1,131 +0,0 @@
---
title: Quickstart
description: "Pair as a linked WhatsApp Web device, sync, search, and send your first message in under five minutes."
---
# Quickstart
Five minutes from a clean machine to authenticated sync, search, and send. For deeper reading, follow the links at the bottom of each step.
## 1. Install
```bash
brew install steipete/tap/wacli
wacli --version
```
Other options (release archives, source builds, GCC 15 notes) are documented on [Install](install.md).
## 2. Pair as a linked device
```bash
wacli auth
```
`auth` prints a QR code in your terminal. On your phone, open WhatsApp → **Linked devices****Link a device**, scan the QR, and approve. As soon as pairing succeeds, `auth` immediately starts the initial sync — keep it running until it idles out or press `Ctrl+C` once it has caught up.
If the terminal QR does not scan, try `--qr-format text` and render that raw QR payload in another app, or pair via phone-number code with `--phone +15551234567`.
> Refresh tokens last as long as the linked device stays linked on your phone. Unlinking from the phone (or `wacli auth logout`) ends the session and requires a fresh QR.
Verify:
```bash
wacli auth status
```
## 3. Keep the store warm
```bash
wacli sync --follow
```
`sync` never shows a QR; it requires a previously paired session and runs until you stop it. `--once` exits after one idle window; `--follow` reconnects on errors. Both honor `--max-messages` / `--max-db-size` (and the `WACLI_SYNC_MAX_*` env equivalents) so the local store stays bounded.
See [Sync](sync.md) for refresh-contacts/refresh-groups, `--download-media`, and the idle-exit knobs.
## 4. Search and read
```bash
# Full-text search (FTS5 when the binary was built with -tags sqlite_fts5; LIKE otherwise)
wacli messages search "meeting"
# Search media-bearing messages
wacli messages search "meeting" --has-media
# List recent messages from a chat, oldest first
wacli messages list --chat 1234567890@s.whatsapp.net --asc
# Show a single message
wacli messages show --chat 1234567890@s.whatsapp.net --id <message-id>
# Show context around a message
wacli messages context --chat 1234567890@s.whatsapp.net --id <message-id> --before 5 --after 5
```
`--json` produces a stable envelope; `--full` keeps full IDs in tables. See [Messages](messages.md) for every filter.
## 5. Send a message
```bash
# Send a text message by phone number, JID, or synced contact/group/chat name
wacli send text --to mom --message "hello"
# Send a quoted reply
wacli send text --to 1234567890 --message "replying" --reply-to <message-id>
# Send a file with a caption
wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
# Send a 512x512 WebP sticker
wacli send sticker --to 1234567890 --file ./sticker-512.webp
# Send a native voice note (OGG/Opus)
wacli send voice --to 1234567890 --file ./voice.ogg
# React (omit --reaction for the default thumbs-up; use --reaction "" to clear)
wacli send react --to 1234567890 --id <message-id> --reaction "🎉"
```
Recipient resolution and disambiguation (`--pick N`, ambiguous-name prompts), link-preview behavior, and post-send waits are documented in [Send](send.md).
## 6. Backfill older history (optional, best-effort)
`sync` only stores what WhatsApp Web pushes. To request older messages for a specific chat from your **primary device** (your phone), use:
```bash
wacli history coverage --include-blocked
wacli history fill --dry-run --limit 20
wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
```
The phone must be online for `backfill`. WhatsApp may not return full history. See [History](history.md) for coverage planning, limits, and patterns.
## 7. Diagnostics and safety
```bash
wacli doctor
wacli doctor --connect
# Read-only mode for agents / sandboxes
wacli --read-only messages search "invoice"
WACLI_READONLY=1 wacli send text --to mom --message "hi" # exits with a clear error
```
`doctor` checks the store, schema, FTS5 availability, and (with `--connect`) live connectivity. See [Doctor](doctor.md).
## 8. Shell completion (optional)
```bash
wacli completion bash >> ~/.bash_completion
wacli completion zsh > "${fpath[1]}/_wacli"
wacli completion fish > ~/.config/fish/completions/wacli.fish
```
## Where next
- [Overview](overview.md) — global flags, store model, full command map.
- [Send](send.md) — every recipient form, replies, reactions, mentions, link previews.
- [Groups](groups.md) — list, refresh, info, rename, participants, invite links.
- [Spec](spec.md) — design notes, storage layout, locking model, non-goals.
- [Doctor](doctor.md) — self-checks and connectivity probe.

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

@ -1,25 +1,21 @@
# send
Read when: sending text, files, stickers, quoted replies, or reactions.
Read when: sending text, files, 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.
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.
`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.
## Commands
```bash
wacli send text --to RECIPIENT --message TEXT [--message-escapes] [--pick N] [--mention USER] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
wacli send text --to RECIPIENT --message TEXT [--pick N] [--mention USER]... [--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]
```
## Recipients
- `send text`, `send file`, `send sticker`, and `send voice` accept a JID, phone number, or synced contact/group/chat name.
- Channel JIDs use `...@newsletter`; `send text` and `send file` can target channels when the authenticated account has posting permission.
- `send text` and `send file` accept a JID, phone number, or synced contact/group/chat name.
- If a name matches multiple recipients, interactive terminals prompt.
- In scripts, use `--pick N` to choose a displayed match.
- Phone numbers may use common formatting such as `+1 (234) 567-8900`.
@ -29,13 +25,11 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
- `send text` fetches Open Graph metadata for the first `http://` or `https://` URL and sends it as a WhatsApp link preview.
- Preview metadata fetches time out after 10 seconds and fall back to plain text.
- Pass `--no-preview` to disable link-preview fetching.
- `--message` is literal by default. Pass `--message-escapes` to interpret `\n`, `\r`, `\t`, `\\`, and `\"` before sending.
- Use repeatable `--mention USER` with a phone number or user JID to add WhatsApp mentions to `send text`.
- Pass repeatable `--mention` values to mention user JIDs or phone numbers in group text messages.
- `--reply-to` quotes a stored message ID.
- For unsynced group replies, pass `--reply-to-sender`.
- `send react` defaults to thumbs-up.
- Pass `--reaction ""` to clear a reaction.
- Sent reactions are stored locally immediately, including reaction target and display text.
- For group reactions, pass `--sender` for the original message sender.
- Use `--post-send-wait 0` to disable the retry-receipt grace window for latency-sensitive scripts.
@ -45,9 +39,6 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
- MIME type is detected automatically unless `--mime` is set.
- `--filename` changes the displayed document name.
- Captions apply to images, videos, and documents.
- Files sent to channels use WhatsApp's unencrypted newsletter media upload path and include the upstream media handle required by `whatsmeow`.
- Quoted file replies and `--ptt` voice-note mode are not supported for channel sends.
- `send sticker` requires 512x512 WebP input. Static stickers are capped at 100 KiB; animated stickers are capped at 500 KiB and are sent with animation metadata.
- `send voice` is a shortcut for `send file --ptt`.
- Voice notes require OGG/Opus audio (`audio/ogg; codecs=opus`).
- When available, `ffprobe` sets voice-note duration and `ffmpeg` generates the 64-sample waveform from decoded PCM audio.
@ -56,13 +47,11 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
```bash
wacli send text --to mom --message "landed"
wacli send text --to mom --message "line1\nline2" --message-escapes
wacli send text --to "Family" --pick 2 --message "on my way"
wacli send text --to "Family" --message "hey @15551234567" --mention +15551234567
wacli send text --to "Family" --message "@alice can you check this?" --mention 15551234567
wacli send text --to 1234567890 --message "replying" --reply-to ABC123
wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
wacli send file --to 1234567890 --file /tmp/report --filename report.pdf
wacli send sticker --to 1234567890 --file ./sticker-512.webp
wacli send voice --to 1234567890 --file ./voice.ogg
wacli send react --to 1234567890 --id ABC123 --reaction "❤️"
```

View File

@ -22,7 +22,7 @@ This document defines the v1 plan for `wacli`: a WhatsApp CLI that syncs message
## Terminology
- **JID**: WhatsApp Jabber ID, e.g. `1234567890@s.whatsapp.net` (user), `123456789@g.us` (group), or `123456789012345@newsletter` (channel).
- **JID**: WhatsApp Jabber ID, e.g. `1234567890@s.whatsapp.net` (user) or `123456789@g.us` (group).
- **Store directory**: directory containing all local state, default `~/.local/state/wacli` on Linux and `~/.wacli` elsewhere.
## Storage layout
@ -96,14 +96,13 @@ Immediately after QR pairing success, `wacli auth` runs a bootstrap sync:
### Tables (proposed)
- `chats`
- `jid` (PK), `name`, `kind` (`dm|group|broadcast|newsletter|unknown`), `last_message_ts`, `archived`, `pinned`, `muted_until`, `unread`, …
- `jid` (PK), `name`, `kind` (`dm|group|broadcast`), `last_message_ts`, …
- `contacts`
- `jid` (PK), `push_name`, `full_name`, `business_name`, `phone`, …
- `groups`
- `jid` (PK), `name`, `owner_jid`, `created_ts`, `is_parent`, `linked_parent_jid`, …
- `is_parent` marks WhatsApp Communities; `linked_parent_jid` points from a subgroup to its parent Community when WhatsApp exposes that metadata.
- `jid` (PK), `name`, `owner_jid`, `created_ts`, …
- `messages`
- `rowid` (PK), `chat_jid`, `msg_id`, `sender_jid`, `ts`, `from_me`, `text`, `display_text`, `revoked`, `deleted_for_me`, `media_type`, `media_caption`, `filename`, `mime_type`, `direct_path`, hashes/keys, …
- `rowid` (PK), `chat_jid`, `msg_id`, `sender_jid`, `ts`, `from_me`, `text`, `media_type`, `media_caption`, `filename`, `mime_type`, `direct_path`, hashes/keys, …
- unique constraint: (`chat_jid`, `msg_id`)
- `contact_aliases` (local management)
- `jid` (PK/FK), `alias`, `notes`, `tags` (or join table)
@ -120,7 +119,6 @@ Approach:
- media caption
- document filename
- (optionally) denormalized sender/chat names for convenience
- Revoked and delete-for-me tombstones are excluded from list/search/starred/export results and FTS rows, but remain addressable by direct `messages show`.
Query behavior:
@ -137,7 +135,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,65 +152,41 @@ 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]`
- `wacli sync [--once] [--follow] [--download-media]`
Notes:
- `sync` errors if not authenticated (never prints QR).
- `--download-media` runs a bounded/concurrent media downloader for messages that contain downloadable media metadata.
- `--webhook` posts live message JSON after successful local storage on a bounded background worker.
- `--webhook-secret` adds an HMAC-SHA256 `X-Wacli-Signature` header and requires `--webhook`.
- Webhook failures and full-queue drops emit warnings but do not fail sync.
### History backfill (best-effort)
WhatsApp Web history is best-effort. If you want to try fetching *older* messages for a specific chat, `wacli` can send an on-demand history request to your primary device:
- `wacli history backfill --chat JID [--count 50] [--requests N]`
- `wacli history coverage` inspects local chat/message coverage without connecting.
- `wacli history fill --dry-run` plans matching chats with local anchors; it does not write or connect.
- Backfill caps: `--count <= 500`, `--requests <= 100`.
- During backfill, automatic initial history-sync blob downloads are disabled; only on-demand history-sync notifications are downloaded and stored.
### Messages
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--before TS] [--after TS] [--forwarded] [--starred]`
- `wacli messages search <query> [--chat JID] [--from JID] [--limit N] [--before TS] [--after TS] [--type text|image|video|audio|document] [--forwarded] [--starred]`
- `wacli messages starred [--chat JID] [--limit N] [--before TS] [--after TS] [--asc]`
- `wacli messages export [--chat JID] [--limit N] [--before TS] [--after TS] [--output PATH]`
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--before TS] [--after TS]`
- `wacli messages search <query> [--chat JID] [--from JID] [--limit N] [--before TS] [--after TS] [--type text|image|video|audio|document]`
- `wacli messages show --chat JID --id MSG_ID`
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
- `wacli messages edit --chat JID --id MSG_ID --message TEXT [--post-send-wait 2s]`
- `wacli messages delete --chat JID --id MSG_ID [--for-me] [--delete-media] [--post-send-wait 2s]`
### Send
- `wacli send text --to RECIPIENT --message TEXT [--message-escapes] [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID]`
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--mention USER]... [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID]`
- `wacli send file --to RECIPIENT --file PATH [--caption TEXT] [--mime TYPE] [--pick N] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID]`
- `wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]`
- `wacli send voice --to RECIPIENT --file PATH [--mime TYPE] [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]`
- `wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]`
`RECIPIENT` accepts a JID, phone number, channel JID (`...@newsletter`), or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
Sending to channels requires channel posting permission. File sends to channels use WhatsApp's unencrypted newsletter media upload and pass the returned media handle through `whatsmeow.SendRequestExtra`.
`RECIPIENT` accepts a JID, phone number, or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
Text sends automatically include a link preview for the first `http://` or `https://` URL unless `--no-preview` is passed.
Text sends can mention users with repeatable `--mention` values. Each value may be a user JID or phone number.
Voice notes require OGG/Opus audio and use optional `ffprobe`/`ffmpeg` metadata when available.
Stickers require 512x512 WebP input and are stored locally as `sticker` media after sending. Static stickers are capped at 100 KiB; animated stickers are capped at 500 KiB and carry animation metadata in the outgoing proto.
Send-file uploads and media downloads are capped at 100 MiB to avoid reading
or writing unexpectedly large payloads in one command.
@ -229,13 +202,8 @@ or writing unexpectedly large payloads in one command.
### Chats
- `wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]`
- `wacli chats list [--query TEXT]`
- `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]`
### Groups
@ -249,13 +217,6 @@ or writing unexpectedly large payloads in one command.
- `wacli groups join --code INVITE_CODE`
- `wacli groups leave --jid GROUP_JID`
### Channels
- `wacli channels list`
- `wacli channels info --jid CHANNEL_JID`
- `wacli channels join --invite LINK_OR_CODE`
- `wacli channels leave --jid CHANNEL_JID`
## Output formats
Default: human-readable text (tables / aligned columns; TTY-aware wrapping).

View File

@ -1,43 +0,0 @@
# store
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.
## Commands
```bash
wacli store stats
wacli store cleanup [--days N] [--dry-run] [--confirm]
```
Related cleanup commands:
```bash
wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]
wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]
```
## Notes
- `store stats` reads local counts for chats, groups, left groups, and messages.
- `store cleanup` removes chats whose known local activity is older than `--days` and deletes their messages through the SQLite chat/message cascade.
- `chats cleanup --jid JID` removes one local chat row and its local messages.
- `groups prune` removes local group metadata plus the matching local chat/messages for pruned group JIDs.
- `groups prune` defaults to groups you have left. `--days N` limits that to groups left more than `N` days ago.
- `groups prune --include-active --days N` also prunes active groups whose last known local message is older than `N` days. Groups with no known local activity timestamp are skipped.
- 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
```bash
wacli store stats
wacli store cleanup --days 365 --dry-run
wacli chats cleanup --jid 1234567890@s.whatsapp.net --dry-run
wacli groups prune --dry-run
wacli groups prune --days 180 --dry-run
wacli groups prune --include-active --days 365 --dry-run
```

View File

@ -7,7 +7,7 @@ Read when: running continuous capture, one-shot sync, contact/group refresh, or
## Command
```bash
wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-messages N] [--max-db-size SIZE] [--download-media] [--refresh-contacts] [--refresh-groups] [--refresh-channels] [--events] [--webhook URL] [--webhook-secret SECRET]
wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-messages N] [--max-db-size SIZE] [--download-media] [--refresh-contacts] [--refresh-groups]
```
## Modes
@ -21,17 +21,8 @@ wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-mes
- `--download-media` runs a bounded media downloader for sync events.
- `--refresh-contacts` imports contacts from the session store.
- `--refresh-groups` fetches joined groups live and updates the local DB.
- `--refresh-channels` fetches subscribed WhatsApp Channels live and updates local chat rows.
- `--webhook URL` posts successfully stored live message events as JSON on a bounded background worker.
- `--webhook-secret SECRET` signs webhook payloads with `X-Wacli-Signature: sha256=<hmac>`.
- Webhook delivery is best-effort: failures and full-queue drops are logged as warnings and do not stop sync. Retries/backoff are intentionally out of scope for this flag.
- If neither storage cap is configured, sync prints one warning because WhatsApp history can grow the local database substantially.
- `WACLI_SYNC_MAX_MESSAGES` and `WACLI_SYNC_MAX_DB_SIZE` apply the same caps to `auth` bootstrap sync and `sync`.
- 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
@ -39,8 +30,6 @@ wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-mes
wacli sync --once
wacli sync --follow --max-reconnect 10m
wacli sync --follow --max-messages 250000 --max-db-size 2GB
wacli sync --once --refresh-contacts --refresh-groups --refresh-channels
wacli sync --once --refresh-contacts --refresh-groups
wacli sync --follow --download-media
wacli sync --once --events 2>events.ndjson
wacli sync --follow --webhook https://example.com/wacli --webhook-secret "$WACLI_WEBHOOK_SECRET"
```

7
go.mod
View File

@ -1,4 +1,4 @@
module github.com/openclaw/wacli
module github.com/steipete/wacli
go 1.25.0
@ -6,12 +6,11 @@ require (
github.com/mattn/go-sqlite3 v1.14.44
github.com/mdp/qrterminal/v3 v3.2.1
github.com/spf13/cobra v1.10.2
go.mau.fi/whatsmeow v0.0.0-20260505142014-6dd3d24c1ca6
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7
golang.org/x/net v0.53.0
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
)

7
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=
@ -59,8 +57,8 @@ go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8=
go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
go.mau.fi/whatsmeow v0.0.0-20260505142014-6dd3d24c1ca6 h1:Wn0o3TEJygn++2n4AdEU0MbEQyeYIbvgrZGhTsXXjc4=
go.mau.fi/whatsmeow v0.0.0-20260505142014-6dd3d24c1ca6/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg=
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7 h1:jEOI4I7kU+MYUNI1L94rhYXhUg8N9+YUNHVY525aYTc=
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
@ -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,13 +7,11 @@ 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/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/proto/waCommon"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/proto/waHistorySync"
"go.mau.fi/whatsmeow/proto/waWeb"
@ -45,25 +43,10 @@ type WAClient interface {
JoinGroupWithLink(ctx context.Context, code string) (types.JID, error)
LeaveGroup(ctx context.Context, group types.JID) error
GetNewsletterInfoWithInvite(ctx context.Context, key string) (*types.NewsletterMetadata, error)
FollowNewsletter(ctx context.Context, jid types.JID) error
UnfollowNewsletter(ctx context.Context, jid types.JID) error
GetSubscribedNewsletters(ctx context.Context) ([]*types.NewsletterMetadata, error)
GetNewsletterInfo(ctx context.Context, jid types.JID) (*types.NewsletterMetadata, error)
SendText(ctx context.Context, to types.JID, text string) (types.MessageID, error)
SendProtoMessage(ctx context.Context, to types.JID, msg *waProto.Message) (types.MessageID, error)
SendProtoMessageWithExtra(ctx context.Context, to types.JID, msg *waProto.Message, mediaHandle string) (types.MessageID, error)
SendReaction(ctx context.Context, chat, sender types.JID, targetID types.MessageID, reaction string) (types.MessageID, error)
RevokeMessage(ctx context.Context, chat types.JID, targetID types.MessageID) (types.MessageID, error)
DeleteMessageForMe(ctx context.Context, info types.MessageInfo, deleteMedia bool) error
EditMessage(ctx context.Context, chat types.JID, targetID types.MessageID, text string) (types.MessageID, error)
ArchiveChat(ctx context.Context, target types.JID, archive bool, lastMsgTS time.Time, lastMsgKey *waCommon.MessageKey) error
PinChat(ctx context.Context, target types.JID, pin bool) error
MuteChat(ctx context.Context, target types.JID, mute bool, duration time.Duration) error
MarkChatAsRead(ctx context.Context, target types.JID, read bool, lastMsgTS time.Time, lastMsgKey *waCommon.MessageKey) error
Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
UploadNewsletter(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
DownloadMediaToFile(ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength uint64, mediaType, mmsType string, targetPath string) (int64, error)
SendChatPresence(ctx context.Context, jid types.JID, state types.ChatPresence, media types.ChatPresenceMedia) error
@ -72,8 +55,6 @@ type WAClient interface {
SetManualHistorySyncDownload(enabled bool)
DownloadHistorySync(ctx context.Context, notif *waE2E.HistorySyncNotification) (*waHistorySync.HistorySync, error)
RequestHistorySyncOnDemand(ctx context.Context, lastKnown types.MessageInfo, count int) (types.MessageID, error)
FetchAppState(ctx context.Context, name string, fullSync, onlyIfNotSynced bool) error
RequestAppStateRecovery(ctx context.Context, name string) (types.MessageID, error)
Logout(ctx context.Context) error
LinkedJID() string
@ -84,17 +65,14 @@ type Options struct {
StoreDir string
Version string
JSON bool
Events *out.EventWriter
AllowUnauthed bool
}
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) {
@ -160,10 +138,7 @@ func (a *App) WA() WAClient {
defer a.waMu.Unlock()
return a.wa
}
func (a *App) DB() *store.DB { return a.db }
func (a *App) Events() *out.EventWriter {
return a.opts.Events
}
func (a *App) DB() *store.DB { return a.db }
func (a *App) StoreDir() string { return a.opts.StoreDir }
func (a *App) Version() string { return a.opts.Version }
func (a *App) AllowUnauthed() bool { return a.opts.AllowUnauthed }

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"os"
"strings"
"sync"
"sync/atomic"
@ -113,11 +114,7 @@ func (a *App) BackfillHistory(ctx context.Context, opts BackfillOptions) (Backfi
}
data, err := a.wa.DownloadHistorySync(ctx, notif)
if err != nil {
a.emitWarning(
"on_demand_history_download_failed",
fmt.Sprintf("warning: failed to download on-demand history sync: %v", err),
map[string]any{"error": err.Error()},
)
fmt.Fprintf(os.Stderr, "\rwarning: failed to download on-demand history sync: %v\n", err)
return
}
if data.GetSyncType() != waHistorySync.HistorySync_ON_DEMAND {
@ -162,11 +159,7 @@ func (a *App) BackfillHistory(ctx context.Context, opts BackfillOptions) (Backfi
mu.Unlock()
requestsSent++
a.emitOrPrint("backfill_requesting", map[string]any{
"chat_jid": chatStr,
"count": opts.Count,
"request": requestsSent,
}, "Requesting %d older messages for %s...\n", opts.Count, chatStr)
fmt.Fprintf(os.Stderr, "Requesting %d older messages for %s...\n", opts.Count, chatStr)
if _, err := a.wa.RequestHistorySyncOnDemand(ctx, reqInfo, opts.Count); err != nil {
return err
}
@ -187,33 +180,19 @@ func (a *App) BackfillHistory(ctx context.Context, opts BackfillOptions) (Backfi
}
mu.Unlock()
a.emitOrPrint("backfill_response", map[string]any{
"chat_jid": chatStr,
"conversations": resp.conversations,
"messages": resp.messages,
"responses_seen": responsesSeen,
}, "On-demand history sync: %d conversations, %d messages.\n", resp.conversations, resp.messages)
fmt.Fprintf(os.Stderr, "On-demand history sync: %d conversations, %d messages.\n", resp.conversations, resp.messages)
newOldest, err := a.db.GetOldestMessageInfo(chatStr)
if err == nil && newOldest.MsgID == oldest.MsgID {
a.emitOrPrint("backfill_stopped", map[string]any{
"chat_jid": chatStr,
"reason": "no_older_messages_added",
}, "No older messages were added (stopping).\n")
fmt.Fprintln(os.Stderr, "No older messages were added (stopping).")
return nil
}
if resp.messages <= 0 {
a.emitOrPrint("backfill_stopped", map[string]any{
"chat_jid": chatStr,
"reason": "no_messages_returned",
}, "No messages returned (stopping).\n")
fmt.Fprintln(os.Stderr, "No messages returned (stopping).")
return nil
}
if resp.endType == waHistorySync.Conversation_COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY {
a.emitOrPrint("backfill_stopped", map[string]any{
"chat_jid": chatStr,
"reason": "start_of_history_reached",
}, "Reached start of chat history (stopping).\n")
fmt.Fprintln(os.Stderr, "Reached start of chat history (stopping).")
return nil
}
}

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

@ -1,10 +1,6 @@
package app
import (
"context"
"github.com/openclaw/wacli/internal/wa"
)
import "context"
func (a *App) refreshContacts(ctx context.Context) error {
if err := a.OpenWA(); err != nil {
@ -43,30 +39,8 @@ func (a *App) refreshGroups(ctx context.Context) error {
continue
}
joined[g.JID.String()] = true
_ = a.db.UpsertGroupWithHierarchy(g.JID.String(), g.GroupName.Name, g.OwnerJID.String(), g.GroupCreated, g.IsParent, g.LinkedParentJID.String())
_ = a.db.UpsertGroup(g.JID.String(), g.GroupName.Name, g.OwnerJID.String(), g.GroupCreated)
_ = a.db.UpsertChat(g.JID.String(), "group", g.GroupName.Name, now)
}
return a.db.MarkGroupsMissingFrom(joined, now)
}
func (a *App) refreshNewsletters(ctx context.Context) error {
if err := a.OpenWA(); err != nil {
return err
}
list, err := a.wa.GetSubscribedNewsletters(ctx)
if err != nil {
return err
}
now := nowUTC()
for _, meta := range list {
if meta == nil {
continue
}
name := wa.NewsletterName(meta)
if name == "" {
name = meta.ID.String()
}
_ = a.db.UpsertChat(meta.ID.String(), "newsletter", name, now)
}
return nil
}

View File

@ -41,11 +41,10 @@ func TestRefreshGroupsStoresGroupsAndChats(t *testing.T) {
gid := types.JID{User: "12345", Server: types.GroupServer}
created := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
f.groups[gid] = &types.GroupInfo{
JID: gid,
OwnerJID: types.JID{User: "999", Server: types.DefaultUserServer},
GroupName: types.GroupName{Name: "MyGroup"},
GroupCreated: created,
GroupLinkedParent: types.GroupLinkedParent{LinkedParentJID: types.JID{User: "parent", Server: types.GroupServer}},
JID: gid,
OwnerJID: types.JID{User: "999", Server: types.DefaultUserServer},
GroupName: types.GroupName{Name: "MyGroup"},
GroupCreated: created,
}
if err := a.refreshGroups(context.Background()); err != nil {
@ -58,9 +57,6 @@ func TestRefreshGroupsStoresGroupsAndChats(t *testing.T) {
if len(gs) != 1 || gs[0].JID != gid.String() {
t.Fatalf("expected group to be stored, got %+v", gs)
}
if gs[0].LinkedParentJID != "parent@g.us" {
t.Fatalf("expected linked parent to be stored, got %+v", gs[0])
}
c, err := a.db.GetChat(gid.String())
if err != nil {
t.Fatalf("GetChat: %v", err)
@ -70,31 +66,6 @@ func TestRefreshGroupsStoresGroupsAndChats(t *testing.T) {
}
}
func TestRefreshNewslettersStoresChats(t *testing.T) {
a := newTestApp(t)
f := newFakeWA()
a.wa = f
jid := types.JID{User: "12345", Server: types.NewsletterServer}
f.news[jid] = &types.NewsletterMetadata{
ID: jid,
ThreadMeta: types.NewsletterThreadMetadata{
Name: types.NewsletterText{Text: "Launch Notes"},
},
}
if err := a.refreshNewsletters(context.Background()); err != nil {
t.Fatalf("refreshNewsletters: %v", err)
}
c, err := a.db.GetChat(jid.String())
if err != nil {
t.Fatalf("GetChat: %v", err)
}
if c.Kind != "newsletter" || c.Name != "Launch Notes" {
t.Fatalf("expected newsletter chat, got %+v", c)
}
}
func TestRefreshGroupsMarksMissingGroupsLeft(t *testing.T) {
a := newTestApp(t)
f := newFakeWA()

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