Compare commits

...

90 Commits

Author SHA1 Message Date
Peter Steinberger
a5f1861547
chore: release 0.8.1
Some checks failed
CI / test (push) Has been cancelled
CI / linux-release-builds (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-05-08 02:44:14 +01:00
Peter Steinberger
42ce6831bf
ci: pin goreleaser action version 2026-05-08 02:37:05 +01:00
Peter Steinberger
2a9193e91c
ci: add linux release build checks 2026-05-08 02:34:48 +01:00
Peter Steinberger
a0166a88ea
ci: update goreleaser archive schema 2026-05-08 02:29:56 +01:00
Peter Steinberger
3677b5b3cd
docs: update unreleased changelog 2026-05-08 02:13:20 +01:00
Dinakar Sarbada
cdddf110ef
chore: migrate module path to openclaw 2026-05-08 02:12:21 +01:00
Dinakar Sarbada
f7cbace0e3
chore: remove stale codeowners entry 2026-05-08 02:12:11 +01:00
Dinakar Sarbada
1f7c6fa19a
ci: harden release tap handoff after move 2026-05-08 02:12:07 +01:00
Dinakar Sarbada
0e1a4d08f8
fix: route whatsmeow diagnostics to stderr
Refs #212
2026-05-08 02:12:04 +01:00
Dinakar Sarbada
3909781d7a
fix: apply history coverage filters before limit 2026-05-08 02:12:00 +01:00
Dinakar Sarbada
30150518f2
fix: let max-messages zero override env 2026-05-08 02:11:56 +01:00
Dinakar Sarbada
5a6fce1e41
fix: truncate table output by rune 2026-05-08 02:11:39 +01:00
Peter Steinberger
4102a04e38
docs: quote wacli skill description 2026-05-08 02:07:12 +01:00
Peter Steinberger
76d2414433
docs: point wacli site at openclaw repo
Some checks are pending
CI / test (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-07 14:26:00 +01:00
Peter Steinberger
a5ed16b922
docs: clarify wacli skill trigger 2026-05-07 12:49:08 +01:00
Peter Steinberger
b9ba3b371d
fix: extract shared contact card text 2026-05-07 12:15:36 +01:00
Peter Steinberger
e3c4ea61e6
docs: highlight docs site code blocks 2026-05-07 04:20:45 +01:00
Peter Steinberger
c68d285400
docs: add wacli agent skill 2026-05-07 01:22:13 +01:00
Peter Steinberger
0796db5ff9
feat: improve interactive sync status
Some checks are pending
CI / test (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-07 00:18:13 +01:00
Peter Steinberger
b1dad5b156
chore: release 0.8.0 2026-05-07 00:08:13 +01:00
Peter Steinberger
55a7955c56
chore: start 0.8.0 development 2026-05-06 23:50:49 +01:00
Peter Steinberger
6f3ba57935
feat: add named accounts 2026-05-06 23:28:01 +01:00
Peter Steinberger
4949423af4
chore: start 0.7.1 development 2026-05-06 06:42:53 +01:00
Peter Steinberger
33aa0ae767
docs: simplify readme 2026-05-06 06:35:28 +01:00
Peter Steinberger
b64cf3c049
chore: finalize 0.7.0 changelog 2026-05-06 06:26:41 +01:00
Peter Steinberger
2433188017
chore: update dependencies 2026-05-06 06:22:12 +01:00
Peter Steinberger
90bb4a3b8c
docs: document system contacts import 2026-05-06 06:16:18 +01:00
Peter Steinberger
403fda0fe7
feat: import system contacts
Co-authored-by: Paul Bohm <29411+enki@users.noreply.github.com>
Co-authored-by: Octavio Froid <froid@bohm.com>
2026-05-06 06:12:59 +01:00
Peter Steinberger
4b84b90a66
docs: clarify raw QR fallback 2026-05-06 05:55:58 +01:00
Peter Steinberger
af671e16a9
feat: add local store cleanup commands
Add local-only cleanup commands for store stats, chat cleanup, group pruning, and age-based store cleanup. Rework group pruning so targets are listed first, dry-run never deletes, confirmation gates every destructive path, and active stale groups require an explicit include flag.

Document the cleanup workflow across README and docs/, including the local-only semantics.

Closes #210.
Co-authored-by: Davy <95214375+thedavidweng@users.noreply.github.com>
2026-05-06 04:50:20 +01:00
Dinakar Sarbada
c912668b21
test: make version output captureable
Route the version subcommand through Cobra's configured output stream and cover it with a focused command-output test.

Extracted as a narrow, low-risk slice from #78 by @nikolasdehor.

Co-authored-by: Nikolas de Hor <116851567+nikolasdehor@users.noreply.github.com>
2026-05-06 01:57:07 +01:00
Peter Steinberger
a2c78030f6
feat: add history coverage dry-run planning 2026-05-06 00:39:37 +01:00
Dinakar Sarbada
d973482dea
docs: add companion integration guide
Document safe ways to build local companion tools on top of wacli data using JSON output, NDJSON events, webhooks, and read-only SQLite queries.

Reworks the useful integration pattern from #71 without adding a maintained sidecar Python app.

Co-authored-by: jaredtribe <261839835+jaredtribe@users.noreply.github.com>
2026-05-06 00:03:31 +01:00
Peter Steinberger
2c9fe08dd8
feat: add WhatsApp channels support 2026-05-05 23:51:33 +01:00
Peter Steinberger
9856075b49
feat: persist group community hierarchy
Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
Co-authored-by: Willem-Jan <wjj@productfunction.com>
2026-05-05 22:01:53 +01:00
Peter Steinberger
4aa3ef3afc
feat: add chat state commands
Some checks failed
CI / test (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
Co-authored-by: Erich Weszelits <e.weszelits@gmail.com>
2026-05-05 21:21:47 +01:00
Peter Steinberger
de84bd2a68
feat: add opt-in message escape decoding 2026-05-05 20:52:19 +01:00
Peter Steinberger
31504a8110
feat: support delete-for-me tombstones 2026-05-05 20:45:17 +01:00
Dinakar Sarbada
b0b7786bb8
feat(sync): add webhook signatures
Add live-message webhook delivery after local storage, with optional HMAC-SHA256 signatures in X-Wacli-Signature. Deliver webhooks on a bounded background worker so slow endpoints cannot block whatsmeow event dispatch.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
Co-authored-by: matheusmelo-cabelo <backup.matheusmelo@gmail.com>
2026-05-05 11:33:06 +01:00
Peter Steinberger
4f45138ad2
fix: align docs home CTA heights 2026-05-05 10:49:30 +01:00
Peter Steinberger
da9134e6ae
feat: add sent message edit and delete 2026-05-05 10:41:36 +01:00
Peter Steinberger
d1b4bd7527
docs: add hosted docs command and pages site 2026-05-05 10:41:23 +01:00
Dinakar Sarbada
f1cb39fe8a
feat(send): add sticker messages
Add wacli send sticker for 512x512 WebP sticker files, including recipient resolution, quoted replies, sync-process delegation, local media metadata, and stricter sticker payload validation.

Verified with local full gate, GitHub CI, and a live sticker send.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
Co-authored-by: Filipe <35941797+fm1randa@users.noreply.github.com>
2026-05-05 09:54:31 +01:00
Dan
cd311e86c4
feat: add starred message filters
Co-authored-by: Dan Rosenshain <danrosenshain@gmail.com>
2026-05-05 09:44:57 +01:00
Peter Steinberger
9fff67cd3b
build: add docs site deployment 2026-05-05 08:24:33 +01:00
Peter Steinberger
b974645fdb
docs: add hosted documentation pages 2026-05-05 08:24:28 +01:00
Peter Steinberger
0826cd4aa1
fix(cli): emit event errors under events mode 2026-05-05 07:55:30 +01:00
Dinakar Sarbada
108da989f7
feat(cli): add NDJSON lifecycle events
Adds a global --events flag for machine-readable lifecycle telemetry on auth, sync, and history backfill. Keeps stderr parseable as NDJSON, including progress, idle, reconnect, warning, and interrupt-signal paths.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
Co-authored-by: acxtrilla <cazz9584@gmail.com>
2026-05-05 07:50:14 +01:00
Peter Steinberger
03e53644f9
fix(send): store sent reactions locally 2026-05-05 07:26:47 +01:00
Peter Steinberger
1b464909ca
fix(sync): request app-state recovery on lthash mismatch 2026-05-05 07:06:04 +01:00
Peter Steinberger
eaa7a1b979
fix(send): delegate sends during sync 2026-05-05 06:57:29 +01:00
Dinakar Sarbada
b24bfc1315
feat(messages): add JSON export filters
Adds messages export with chat/date/limit filters and private 0600 output files.\n\nCo-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-05-05 06:50:58 +01:00
Peter Steinberger
40330f623b
fix: allow media downloads to shared output dirs 2026-05-05 06:48:11 +01:00
Peter Steinberger
b4ca2e35b0
fix: include image metadata for sends 2026-05-05 06:17:30 +01:00
Peter Steinberger
3031a34ff2
fix: retry transient auth pairing drops 2026-05-05 05:34:33 +01:00
Peter Steinberger
09b2efbcaa
feat: add send text mentions 2026-05-05 05:11:27 +01:00
Peter Steinberger
d0752dbc2c
feat: add voice note sending 2026-05-05 03:09:02 +01:00
Peter Steinberger
ed4df0bf3a
feat: add send text link previews 2026-05-05 02:00:49 +01:00
Peter Steinberger
ad1f47740b
fix: keep send alive for retry receipts 2026-05-05 00:38:28 +01:00
Dinakar Sarbada
352caa88d8
feat(store): migrate historical LID rows to phone numbers
Migrate historical @lid chat/message rows to mapped phone-number JIDs after auth/session access is available. Keep FTS in sync via existing triggers and document the data repair.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-05-04 23:06:00 +01:00
Peter Steinberger
d410e9f76e
security: cap sync storage growth 2026-05-04 10:10:48 +01:00
gado-ships-it
d08620abf9
feat: add profile set-picture command
Add profile picture upload support with image normalization, docs, changelog, and regression coverage.

Co-authored-by: gado-ships-it <175593376+gado-ships-it@users.noreply.github.com>
2026-05-04 09:33:46 +01:00
Peter Steinberger
2f294e2bb6
fix: warn on rapid send commands 2026-05-04 09:32:56 +01:00
Peter Steinberger
6199cff6cb
docs: add command documentation index 2026-05-04 09:11:44 +01:00
Peter Steinberger
56f9c26746
feat: resolve send recipients by name
Co-authored-by: Ranbir Singh <poetdroid2@gmail.com>
2026-05-04 09:02:18 +01:00
Peter Steinberger
83d89da341
security: cap media transfer sizes 2026-05-04 08:30:36 +01:00
Peter Steinberger
515cd43b9f
fix: avoid initial history sync during backfill 2026-05-04 08:27:58 +01:00
Peter Steinberger
1e8342fbe7
feat: improve auth identity and recipient parsing
Co-authored-by: Paulo <pmatheus.nsx@gmail.com>
Co-authored-by: Fahmid Uddin <fahmid.me@gmail.com>
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
2026-05-04 08:21:09 +01:00
Peter Steinberger
6fac72ee4d
fix: decrypt history sync reactions 2026-05-04 07:52:07 +01:00
Peter Steinberger
66c6d41ff6
fix: resolve mapped LID chats in chat output 2026-05-04 07:48:21 +01:00
Peter Steinberger
5613c07f79
docs: document cgo build requirement 2026-05-04 07:45:19 +01:00
Peter Steinberger
3a633d5712
fix: reject cgo-disabled cli builds 2026-05-04 07:39:43 +01:00
Peter Steinberger
3ce15af17e
feat: support quoted file sends 2026-05-04 07:37:27 +01:00
Peter Steinberger
70e5a20cff
feat: store structured reaction metadata 2026-05-04 07:27:24 +01:00
Peter Steinberger
e2bebf6eed
feat: support phone pairing auth 2026-05-04 07:25:36 +01:00
Peter Steinberger
eabf8d6eec
feat: print raw auth QR payload 2026-05-04 07:23:34 +01:00
Peter Steinberger
6400f5d4a7
fix: show rich message details 2026-05-04 07:22:04 +01:00
Peter Steinberger
787cfd599c
fix: resolve historical LID sender names 2026-05-04 07:15:46 +01:00
Peter Steinberger
87095bd48a
fix: resolve mapped chat aliases for message show 2026-05-04 07:14:11 +01:00
Peter Steinberger
02f98c3ed0
fix: show stored sender names in messages 2026-05-04 07:13:08 +01:00
Peter Steinberger
4481fc8d61
fix: include mapped LID chats in message filters 2026-05-04 07:11:26 +01:00
Peter Steinberger
7533e4bef9
feat: expose forwarded message metadata 2026-05-04 07:05:28 +01:00
Peter Steinberger
fca5b96138
fix: resolve live LID messages before storage 2026-05-04 07:00:30 +01:00
Matthias Vallentin
78794f9757
ci: sync the Homebrew tap on release
Dispatch the tap-owned formula updater after publishing the macOS release artifact, pass a unique request_id, and wait for the exact tap workflow run to finish with exit-status propagation.\n\nVerified tap-side updater after merging steipete/homebrew-tap#29: wacli v0.6.0 preserves interpolated URLs and reproduces current checksums with no formula diff.
2026-05-04 04:28:41 +01:00
Peter Steinberger
9568cbfb42
fix: surface QR pairing failures 2026-05-04 04:16:15 +01:00
Peter Steinberger
3077e626a3
fix: send OGG audio with WhatsApp codec 2026-05-04 04:14:45 +01:00
Peter Steinberger
1fb01707e5
docs: record sync bug fixes 2026-05-04 04:07:40 +01:00
Peter Steinberger
f8ce9eedd1
fix: warn on encrypted reaction decrypt failures 2026-05-04 04:06:57 +01:00
Peter Steinberger
7c42182505
fix: guard WA client lazy initialization 2026-05-04 04:05:10 +01:00
Peter Steinberger
ecbf902e3a
chore: update Go dependencies 2026-05-04 01:37:14 +01:00
189 changed files with 18333 additions and 686 deletions

View File

@ -0,0 +1,120 @@
---
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
View File

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

View File

@ -41,3 +41,38 @@ 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

55
.github/workflows/pages.yml vendored Normal file
View File

@ -0,0 +1,55 @@
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,17 +36,82 @@ jobs:
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout -- "$RELEASE_TAG"
- name: GoReleaser (macOS universal)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
version: "~> v2"
args: release --clean --config /tmp/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
runs-on: ubuntu-latest
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"
else
echo "RELEASE_TAG=$REF_NAME" >> "$GITHUB_ENV"
fi
- name: Dispatch tap formula update
env:
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
fi
request_id="wacli-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update wacli for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=wacli \
-f tag="$RELEASE_TAG" \
-f repository=openclaw/wacli \
-f macos_artifact=wacli-macos-universal.tar.gz \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10
goreleaser-linux-windows:
runs-on: ubuntu-latest
needs: goreleaser-darwin
@ -67,23 +132,29 @@ jobs:
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout -- "$RELEASE_TAG"
- name: GoReleaser (linux/windows)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
version: "~> v2"
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 [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> $GITHUB_ENV
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=$INPUT_TAG" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
echo "RELEASE_TAG=$REF_NAME" >> "$GITHUB_ENV"
fi
- name: Upload linux/windows artifacts

View File

@ -58,12 +58,14 @@ builds:
archives:
- id: default
format: tar.gz
formats:
- tar.gz
name_template: >-
{{ .ProjectName }}-{{ if eq .Os "darwin" }}macos{{ else }}{{ .Os }}{{ end }}-{{ if eq .Arch "all" }}universal{{ else }}{{ .Arch }}{{ end }}
format_overrides:
- goos: windows
format: zip
formats:
- zip
files:
- LICENSE
- README.md

View File

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

View File

@ -1,40 +1,126 @@
# Changelog
## 0.7.0 - Unreleased
## 0.8.1 - 2026-05-08
### Changed
- Module: migrate the canonical Go module/import path to `github.com/openclaw/wacli`. (#217 - thanks @dinakars777)
- Sync: collapse routine interactive TTY progress into a single updating status line while keeping warnings visible as normal stderr lines.
### Chore
- CI: make the Homebrew tap handoff use `openclaw/wacli` and skip gracefully when the tap token is missing. (#216 - thanks @dinakars777)
- Maintainers: remove the stale personal CODEOWNERS rule after the OpenClaw move. (#218 - thanks @dinakars777)
- Release: update GoReleaser archive config to the current v2 schema so release-config checks stay green.
### Fixed
- CLI: truncate table output by rune so emoji and other non-ASCII text stay valid UTF-8. (#222 - thanks @dinakars777)
- History: apply coverage/actionable filters before `LIMIT` so newer blocked chats do not hide ready chats. (#219 - thanks @dinakars777)
- Messages: extract display/search text from shared WhatsApp contact cards, including vCard phone numbers. (#214)
- Send: route whatsmeow diagnostics to stderr and clarify that `sent: true` means WhatsApp accepted the send request. (#215 - thanks @dinakars777)
- Sync: let explicit `--max-messages=0` override `WACLI_SYNC_MAX_MESSAGES`. (#220 - thanks @dinakars777)
## 0.8.0 - 2026-05-07
### Added
- Accounts: add first-class named WhatsApp accounts with isolated stores, `--account NAME`, and `wacli accounts list/add/use/show/remove`.
### Fixed
- Store: fix migration of legacy databases whose `groups` table existed before group hierarchy columns were introduced.
### Docs
- Docs: add a dedicated accounts page covering YAML config, store selection precedence, and multi-account usage.
## 0.7.0 - 2026-05-06
### Added
- 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 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
- Auth: reject `?` and `#` in whatsmeow session store paths to avoid SQLite URI parameter injection. (#180 — thanks @shaun0927)
- Media: reject send-file uploads and media downloads larger than 100 MiB before reading or writing the payload. (#63 — thanks @alexander-morris)
- Send: warn when send commands are invoked in rapid succession so automation rate-limit/account-risk is visible. (#53 — thanks @alexander-morris)
- Send: validate phone-number recipients before constructing WhatsApp JIDs. (#144 — thanks @draix)
- Sync: add message-count and database-size caps plus uncapped-sync warnings to avoid unbounded local history growth. (#54 — thanks @alexander-morris)
- Store: restrict index and session SQLite database files to owner-only permissions. (#147 — thanks @draix)
### 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)
- Groups: hide groups after `groups leave`, mark missing joined groups as left during refresh, and show them again if a later refresh reports membership. (#125, #129 — thanks @SeifBenayed and @ImLukeF)
- History: cap on-demand backfill at 500 messages per request and 100 requests per run.
- History: skip automatic initial history-sync blob downloads during on-demand backfill to avoid OOM on constrained Linux/ARM devices. (#84 — thanks @jyothepro)
- Messages: normalize device-specific `@s.whatsapp.net` JIDs before storing chats, contacts, and senders.
- Messages: include mapped `@lid` rows when listing, searching, showing, or contextualizing by phone-number chat JID.
- Messages: read stored sender names back from SQLite and resolve blank historical `@lid` senders at display time.
- Store: migrate historical `@lid` chat and message rows to mapped phone-number JIDs during authenticated startup. (#31, #89 — thanks @bhaskoro-muthohar, @alexph-dev, and @dinakars777)
- Messages: make `messages show` prefer stored display text and include stored media/download details.
- Messages: store structured reaction target IDs and emoji in SQLite. (#67 — thanks @vlassance)
- 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)
@ -43,13 +129,17 @@
### Docs
- 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)
- Dependencies: update Go modules including `whatsmeow`, `go-sqlite3`, `x/*`, and related runtime libs.
- 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.
- 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`.

237
README.md
View File

@ -1,204 +1,139 @@
# 🗃️ wacli — WhatsApp CLI: sync, search, send
WhatsApp CLI built on top of `whatsmeow`, focused on:
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.
- Best-effort local sync of message history + continuous capture
- Fast offline search
- Sending text, quoted replies, and files
- Contact + group management
- Scriptable JSON output
> Third-party tool. Uses the WhatsApp Web protocol via `whatsmeow`. Not affiliated with WhatsApp.
This is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow` and is not affiliated with WhatsApp.
Full documentation: **<https://wacli.sh>**
## Status
## Features
Core implementation is in place. See `docs/spec.md` for design notes.
- **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.
## Major features
## Install
- **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 text replies, and image/video/audio/document files with captions, MIME override, and custom display filenames.
- **Media**: download synced message media on demand, or download in the background during auth/sync.
- **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.
- **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.
### Homebrew (recommended)
## Install / Build
```bash
brew install steipete/tap/wacli
```
Choose **one** of the following options.
If you install via Homebrew, you can skip the local build step.
If a Linux install reports `Binary was compiled with 'CGO_ENABLED=0'`, run `brew update && brew reinstall steipete/tap/wacli`.
### Option A: Install via Homebrew (tap)
### Build from source
- `brew install steipete/tap/wacli`
`wacli` uses `go-sqlite3`, so cgo + a C compiler are required.
### Option B: Build locally
- macOS: Xcode Command Line Tools.
- Debian/Ubuntu: `sudo apt install build-essential`.
- `go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli`
```bash
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
```
Run (local build only):
For local development:
- `./dist/wacli --help`
```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
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
# 1. Pair (shows QR), then bootstrap sync
wacli auth
# 2) Keep syncing (never shows QR; requires prior auth)
pnpm wacli sync --follow
# 2. Keep syncing in the background (no QR; needs prior auth)
wacli sync --follow
# Diagnostics
pnpm wacli doctor
# 3. Search
wacli messages search "meeting"
# Search messages
pnpm wacli messages search "meeting"
# 4. Send
wacli send text --to 1234567890 --message "hello"
wacli send file --to mom --file ./pic.jpg --caption "hi"
# 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
pnpm wacli send text --to 1234567890 --message "hello"
# Send a quoted reply
pnpm wacli send text --to 1234567890 --message "replying" --reply-to <message-id>
# Send a file
pnpm wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
# Or override display name
pnpm wacli send file --to 1234567890 --file /tmp/abc123 --filename report.pdf
# 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
# 5. Diagnostics
wacli doctor
```
## High-level UX
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.
- `wacli auth`: interactive login (shows QR code), then immediately performs initial data sync.
- `wacli sync`: non-interactive sync loop (never shows QR; errors if not authenticated).
- Output is human-readable by default; pass `--json` for machine-readable output.
- 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.
More recipes — replies, mentions, stickers, voice, reactions, channels, history backfill, chat management — live in the [docs](https://wacli.sh).
## Command surface
## Documentation
- `wacli auth [--follow] [--idle-exit 30s] [--download-media]`
- `wacli auth status`
- `wacli auth logout`
- `wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--download-media] [--refresh-contacts] [--refresh-groups]`
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE]`
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--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 send text --to PHONE_OR_JID --message TEXT [--reply-to MSG_ID] [--reply-to-sender JID]`
- `wacli send file --to PHONE_OR_JID --file PATH [--caption TEXT] [--filename NAME] [--mime TYPE]`
- `wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]`
- `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 doctor [--connect]`
- `wacli version`
| 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) |
## Storage
## Configuration
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`.
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.
Global flags:
**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
- `--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:**
## Environment overrides
- `WACLI_DEVICE_LABEL`: set the linked device label (shown in WhatsApp).
- `WACLI_DEVICE_PLATFORM`: override the linked device platform (defaults to `CHROME` if unset or invalid).
- `WACLI_READONLY`: set to `1`, `true`, `yes`, or `on` to enable read-only mode.
- `WACLI_STORE_DIR`: override the default store directory.
| 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`. |
## Backfilling older history
`wacli sync` stores whatever WhatsApp Web sends opportunistically. To try to fetch *older* messages, use on-demand history sync requests to your **primary device** (your phone).
`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.
Important notes:
- This is **best-effort**: WhatsApp may not return full history.
- Your **primary device must be online**.
- Requests are **per chat** (DM or group). `wacli` uses the *oldest locally stored message* in that chat as the anchor.
- Recommended `--count` is `50` per request; maximum is `500`.
- Maximum `--requests` per run is `100`.
### Backfill one chat
- 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.
```bash
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
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
```
### Backfill all chats (script)
This loops through chats already known in your local DB:
Loop over every known chat:
```bash
pnpm -s wacli -- --json chats list --limit 100000 \
| jq -r '.[].JID' \
wacli --json chats list --limit 100000 \
| jq -r '.data[].JID' \
| while read -r jid; do
pnpm -s wacli -- history backfill --chat "$jid" --requests 3 --count 50
wacli history backfill --chat "$jid" --requests 3 --count 50
done
```
## Prior art / credit
## Credits
This project is heavily inspired by (and learns from) the excellent `whatsapp-cli` by Vicente Reig:
Heavily inspired by [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli) by Vicente Reig.
- [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli)
## Maintainers
- Created by [@steipete](https://github.com/steipete)
- Currently maintained by [@dinakars777](https://github.com/dinakars777)
## License
See `LICENSE`.
## Maintainers
- Created by [@steipete](https://github.com/steipete)
- Currently maintained by [@dinakars777](https://github.com/dinakars777)
See [`LICENSE`](LICENSE).

289
cmd/wacli/accounts.go Normal file
View File

@ -0,0 +1,289 @@
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,
}
}

117
cmd/wacli/accounts_test.go Normal file
View File

@ -0,0 +1,117 @@
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,51 +9,34 @@ 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"
"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 {
var follow bool
var idleExit time.Duration
var downloadMedia bool
opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with WhatsApp (QR) and bootstrap sync",
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); 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: func(code string) {
fmt.Fprintln(os.Stderr, "\nScan this QR code with WhatsApp (Linked Devices):")
qrterminal.GenerateHalfBlock(code, qrterminal.M, os.Stderr)
fmt.Fprintln(os.Stderr)
},
})
res, err := runAuth(flags, opts)
if err != nil {
return err
}
@ -70,9 +53,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
},
}
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")
addAuthFlags(cmd, &opts)
cmd.AddCommand(newAuthStatusCmd(flags))
cmd.AddCommand(newAuthLogoutCmd(flags))
@ -80,6 +61,140 @@ 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 == "" {
return "", nil
}
jid, err := wa.ParseUserOrJID(phone)
if err != nil {
return "", fmt.Errorf("invalid --phone: %w", err)
}
if jid.Server != types.DefaultUserServer || jid.Device != 0 {
return "", fmt.Errorf("invalid --phone: must be an international phone number")
}
return jid.User, nil
}
func normalizeAuthQRFormat(format string) (string, error) {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "terminal"
}
switch format {
case "terminal", "text":
return format, nil
default:
return "", fmt.Errorf("unsupported --qr-format %q (want terminal or text)", format)
}
}
func authQRWriter(format string, stdout, stderr io.Writer, events *out.EventWriter) 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) {
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.")
}
}
func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "status",

View File

@ -51,6 +51,104 @@ func TestWriteAuthStatus(t *testing.T) {
}
}
func TestNormalizeAuthQRFormat(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{input: "", want: "terminal"},
{input: " TERMINAL ", want: "terminal"},
{input: "text", want: "text"},
{input: "png", wantErr: true},
}
for _, tc := range tests {
got, err := normalizeAuthQRFormat(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("normalizeAuthQRFormat(%q) expected error", tc.input)
}
continue
}
if err != nil {
t.Fatalf("normalizeAuthQRFormat(%q): %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("normalizeAuthQRFormat(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestAuthQRWriterText(t *testing.T) {
var stdout, stderr bytes.Buffer
authQRWriter("text", &stdout, &stderr, nil)("2@test-code")
if got := strings.TrimSpace(stdout.String()); got != "2@test-code" {
t.Fatalf("stdout = %q", got)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestNormalizePairPhone(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{input: "", want: ""},
{input: "+15551234567", want: "15551234567"},
{input: "15551234567", want: "15551234567"},
{input: "123@g.us", wantErr: true},
{input: "123abc", wantErr: true},
}
for _, tc := range tests {
got, err := normalizePairPhone(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("normalizePairPhone(%q) expected error", tc.input)
}
continue
}
if err != nil {
t.Fatalf("normalizePairPhone(%q): %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("normalizePairPhone(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestAuthPairCodeWriter(t *testing.T) {
var stderr bytes.Buffer
writer := authPairCodeWriter("15551234567", &stderr, nil)
if writer == nil {
t.Fatal("expected writer")
}
writer("ABCD-1234")
got := stderr.String()
if !strings.Contains(got, "Pairing code for +15551234567: ABCD-1234") {
t.Fatalf("stderr = %q", got)
}
if authPairCodeWriter("", &stderr, nil) != nil {
t.Fatal("expected nil writer without phone")
}
}
func TestAuthCommandExposesQRFormat(t *testing.T) {
cmd := newAuthCmd(&rootFlags{})
flag := cmd.Flags().Lookup("qr-format")
if flag == nil {
t.Fatal("expected --qr-format flag")
}
if flag.DefValue != "terminal" {
t.Fatalf("qr-format default = %q", flag.DefValue)
}
if cmd.Flags().Lookup("phone") == nil {
t.Fatal("expected --phone flag")
}
}
func TestPhoneFromLinkedJID(t *testing.T) {
if got := phoneFromLinkedJID("123@s.whatsapp.net"); got != "123" {
t.Fatalf("phoneFromLinkedJID = %q", got)

View File

@ -0,0 +1,5 @@
//go:build !cgo
package main
import _ "wacli_requires_cgo_enabled_1_for_go_sqlite3"

301
cmd/wacli/channels.go Normal file
View File

@ -0,0 +1,301 @@
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

@ -0,0 +1,50 @@
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

@ -2,31 +2,65 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"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/out"
"go.mau.fi/whatsmeow/types"
)
func newChatsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "chats",
Short: "List chats from the local DB",
Short: "List and manage chats",
}
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()
@ -36,23 +70,32 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
chats, err := a.DB().ListChats(query, limit)
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)
if err != nil {
return err
}
chats = resolveStoredChats(ctx, a, chats)
if flags.asJSON {
return out.WriteJSON(os.Stdout, chats)
}
fullOutput := fullTableOutput(flags.fullOutput)
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST")
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST\tFLAGS")
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Kind, tableCell(name, 28, fullOutput), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"))
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))
}
_ = w.Flush()
return nil
@ -60,6 +103,14 @@ 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
}
@ -81,17 +132,170 @@ func newChatsShowCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
c, err := a.DB().GetChat(jid)
c, err := getChatForDisplay(ctx, a, jid)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, c)
}
fmt.Fprintf(os.Stdout, "JID: %s\nKind: %s\nName: %s\nLast: %s\n", c.JID, c.Kind, c.Name, c.LastMessageTS.Local().Format(time.RFC3339))
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)
return nil
},
}
cmd.Flags().StringVar(&jid, "jid", "", "chat JID")
return cmd
}
type chatDisplayResolver interface {
ResolveChatName(context.Context, types.JID, string) string
ResolveLIDToPN(context.Context, types.JID) types.JID
ResolvePNToLID(context.Context, types.JID) types.JID
}
func resolveStoredChats(ctx context.Context, a *app.App, chats []store.Chat) []store.Chat {
if len(chats) == 0 || !chatsNeedLIDResolution(chats) {
return chats
}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return chats
}
if err := a.OpenWA(); err != nil {
return chats
}
return resolveStoredChatsWith(ctx, a.WA(), chats)
}
func chatsNeedLIDResolution(chats []store.Chat) bool {
for _, chat := range chats {
if strings.HasSuffix(strings.TrimSpace(chat.JID), "@"+types.HiddenUserServer) {
return true
}
}
return false
}
func resolveStoredChatsWith(ctx context.Context, resolver chatDisplayResolver, chats []store.Chat) []store.Chat {
out := make([]store.Chat, 0, len(chats))
seen := make(map[string]int, len(chats))
for _, chat := range chats {
chat = resolveStoredChatWith(ctx, resolver, chat)
if idx, ok := seen[chat.JID]; ok {
out[idx] = mergeDisplayChats(out[idx], chat)
continue
}
seen[chat.JID] = len(out)
out = append(out, chat)
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].LastMessageTS.After(out[j].LastMessageTS)
})
return out
}
func resolveStoredChatWith(ctx context.Context, resolver chatDisplayResolver, chat store.Chat) store.Chat {
jid, err := types.ParseJID(strings.TrimSpace(chat.JID))
if err != nil || jid.Server != types.HiddenUserServer {
return chat
}
pn := resolver.ResolveLIDToPN(ctx, jid)
if pn.IsEmpty() || pn.Server != types.DefaultUserServer {
return chat
}
out := chat
out.JID = pn.ToNonAD().String()
if out.Kind == "" || out.Kind == "unknown" {
out.Kind = "dm"
}
if chatNameRank(out.Name, chat.JID) < 2 {
if name := strings.TrimSpace(resolver.ResolveChatName(ctx, pn, "")); name != "" {
out.Name = name
}
}
if strings.TrimSpace(out.Name) == "" || strings.TrimSpace(out.Name) == strings.TrimSpace(chat.JID) {
out.Name = out.JID
}
return out
}
func mergeDisplayChats(a, b store.Chat) store.Chat {
out := a
if b.LastMessageTS.After(out.LastMessageTS) {
out.LastMessageTS = b.LastMessageTS
}
if out.Kind == "" || out.Kind == "unknown" || b.Kind == "dm" {
out.Kind = b.Kind
}
if chatNameRank(b.Name, b.JID) > chatNameRank(out.Name, out.JID) {
out.Name = b.Name
}
return out
}
func chatNameRank(name, jid string) int {
name = strings.TrimSpace(name)
switch {
case name == "":
return 0
case name == strings.TrimSpace(jid), strings.Contains(name, "@"):
return 1
default:
return 2
}
}
func getChatForDisplay(ctx context.Context, a *app.App, rawJID string) (store.Chat, error) {
chat, err := a.DB().GetChat(rawJID)
if err == nil {
return resolveStoredChatForDisplay(ctx, a, chat), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return store.Chat{}, err
}
chatJIDs := mappedChatJIDs(ctx, a, rawJID)
for _, chatJID := range chatJIDs {
if chatJID == rawJID {
continue
}
chat, err = a.DB().GetChat(chatJID)
if err == nil {
return resolveStoredChatForDisplay(ctx, a, chat), nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return store.Chat{}, err
}
}
return store.Chat{}, sql.ErrNoRows
}
func resolveStoredChatForDisplay(ctx context.Context, a *app.App, chat store.Chat) store.Chat {
return resolveStoredChats(ctx, a, []store.Chat{chat})[0]
}
func mappedChatJIDs(ctx context.Context, a *app.App, rawJID string) []string {
jid, err := types.ParseJID(strings.TrimSpace(rawJID))
if err != nil {
return []string{rawJID}
}
jids := []types.JID{jid}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return jidStrings(jids)
}
if err := a.OpenWA(); err != nil {
return jidStrings(jids)
}
client := a.WA()
if client == nil {
return jidStrings(jids)
}
switch jid.Server {
case types.DefaultUserServer:
jids = append(jids, client.ResolvePNToLID(ctx, jid))
case types.HiddenUserServer:
jids = append(jids, client.ResolveLIDToPN(ctx, jid))
}
return jidStrings(jids)
}

166
cmd/wacli/chats_cleanup.go Normal file
View File

@ -0,0 +1,166 @@
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
}

210
cmd/wacli/chats_state.go Normal file
View File

@ -0,0 +1,210 @@
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 ""
}
}

97
cmd/wacli/chats_test.go Normal file
View File

@ -0,0 +1,97 @@
package main
import (
"context"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
type fakeChatResolver struct {
lidToPN map[types.JID]types.JID
names map[types.JID]string
}
func (f fakeChatResolver) ResolveChatName(ctx context.Context, chat types.JID, pushName string) string {
if name, ok := f.names[chat.ToNonAD()]; ok {
return name
}
return chat.String()
}
func (f fakeChatResolver) ResolveLIDToPN(ctx context.Context, jid types.JID) types.JID {
if pn, ok := f.lidToPN[jid.ToNonAD()]; ok {
pn.Device = jid.Device
return pn
}
return jid
}
func (f fakeChatResolver) ResolvePNToLID(ctx context.Context, jid types.JID) types.JID {
for lid, pn := range f.lidToPN {
if pn == jid.ToNonAD() {
lid.Device = jid.Device
return lid
}
}
return jid
}
func TestResolveStoredChatsMapsLIDRows(t *testing.T) {
lid := mustParseJID(t, "999123456789@lid")
pn := mustParseJID(t, "15551234567@s.whatsapp.net")
resolver := fakeChatResolver{
lidToPN: map[types.JID]types.JID{lid: pn},
names: map[types.JID]string{pn: "Alice"},
}
got := resolveStoredChatsWith(context.Background(), resolver, []store.Chat{{
JID: lid.String(),
Kind: "unknown",
Name: lid.String(),
LastMessageTS: time.Unix(10, 0),
}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %+v", len(got), got)
}
if got[0].JID != pn.String() || got[0].Kind != "dm" || got[0].Name != "Alice" {
t.Fatalf("resolved chat = %+v", got[0])
}
}
func TestResolveStoredChatsMergesMappedDuplicates(t *testing.T) {
lid := mustParseJID(t, "999123456789@lid")
pn := mustParseJID(t, "15551234567@s.whatsapp.net")
resolver := fakeChatResolver{
lidToPN: map[types.JID]types.JID{lid: pn},
names: map[types.JID]string{pn: "Alice"},
}
old := time.Unix(10, 0)
newer := time.Unix(20, 0)
got := resolveStoredChatsWith(context.Background(), resolver, []store.Chat{
{JID: lid.String(), Kind: "unknown", Name: lid.String(), LastMessageTS: newer},
{JID: pn.String(), Kind: "dm", Name: "", LastMessageTS: old},
})
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %+v", len(got), got)
}
if got[0].JID != pn.String() || got[0].Name != "Alice" || !got[0].LastMessageTS.Equal(newer) {
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,6 +18,7 @@ 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
@ -104,6 +105,9 @@ 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

@ -0,0 +1,182 @@
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

@ -0,0 +1,99 @@
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
}

23
cmd/wacli/docs.go Normal file
View File

@ -0,0 +1,23 @@
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
},
}
}

55
cmd/wacli/docs_test.go Normal file
View File

@ -0,0 +1,55 @@
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,11 +10,10 @@ 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 {
@ -116,11 +115,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
storeDir := flags.storeDir
if storeDir == "" {
storeDir = config.DefaultStoreDir()
storeDir, err := resolveStoreDir(flags)
if err != nil {
return err
}
storeDir, _ = filepath.Abs(storeDir)
var lockHeld bool
var lockInfo string

View File

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

View File

@ -15,5 +15,6 @@ 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,10 +56,16 @@ func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
return out.WriteJSON(os.Stdout, info)
}
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nCreated: %s\nParticipants: %d\n",
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nType: %s\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"
)
@ -82,6 +82,6 @@ func newGroupsParticipantsActionCmd(flags *rootFlags, action string) *cobra.Comm
},
}
cmd.Flags().StringVar(&group, "jid", "", "group JID (…@g.us)")
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number or JID (repeatable)")
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number (+E164 and formatting ok) or JID (repeatable)")
return cmd
}

View File

@ -1,7 +1,7 @@
package main
import (
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
@ -16,7 +16,14 @@ func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
if info == nil {
return nil
}
if err := db.UpsertGroup(info.JID.String(), info.GroupName.Name, info.OwnerJID.String(), info.GroupCreated); err != nil {
if err := db.UpsertGroupWithHierarchy(
info.JID.String(),
info.GroupName.Name,
info.OwnerJID.String(),
info.GroupCreated,
info.IsParent,
info.LinkedParentJID.String(),
); err != nil {
return err
}
var ps []store.GroupParticipant
@ -35,3 +42,13 @@ 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"
}

135
cmd/wacli/groups_prune.go Normal file
View File

@ -0,0 +1,135 @@
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

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

View File

@ -13,6 +13,10 @@ func isTTY() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
func isInteractive() bool {
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stderr.Fd()))
}
func parseTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
@ -34,13 +38,17 @@ func sanitize(s string) string {
func truncate(s string, max int) string {
s = sanitize(s)
if max <= 0 || len(s) <= max {
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return s[:max]
return string(runes[:max])
}
return s[:max-1] + "…"
return string(runes[:max-1]) + "…"
}
func fullTableOutput(forceFull bool) bool {

View File

@ -2,23 +2,129 @@ 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 backfill (best-effort; requires prior auth)",
Short: "History coverage and backfill",
}
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
@ -37,7 +143,7 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
return err
}
ctx, stop := signalContext()
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
defer stop()
a, lk, err := newApp(ctx, flags, true, false)
@ -79,3 +185,73 @@ 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")
}

87
cmd/wacli/history_test.go Normal file
View File

@ -0,0 +1,87 @@
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

@ -2,6 +2,7 @@ package main
import (
"os"
"runtime"
"strings"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
@ -19,13 +20,18 @@ func main() {
func applyDeviceLabel() {
label := strings.TrimSpace(os.Getenv("WACLI_DEVICE_LABEL"))
platformRaw := strings.TrimSpace(os.Getenv("WACLI_DEVICE_PLATFORM"))
if platformRaw != "" {
platform := parsePlatformType(platformRaw)
store.DeviceProps.PlatformType = platform.Enum()
if platformRaw == "" {
platformRaw = "DESKTOP"
}
if label == "" {
return
label = detectDeviceLabel(runtime.GOOS, os.Hostname, os.ReadFile)
}
platform := parsePlatformType(platformRaw)
store.DeviceProps.PlatformType = platform.Enum()
if label == "" {
label = "wacli"
}
store.SetOSInfo(label, [3]uint32{0, 1, 0})
store.BaseClientPayload.UserAgent.Device = proto.String(label)
store.BaseClientPayload.UserAgent.Manufacturer = proto.String(label)
@ -42,3 +48,50 @@ func parsePlatformType(raw string) waCompanionReg.DeviceProps_PlatformType {
}
return waCompanionReg.DeviceProps_CHROME
}
func detectDeviceLabel(goos string, hostname func() (string, error), readFile func(string) ([]byte, error)) string {
host, _ := hostname()
host = strings.TrimSpace(host)
osName := friendlyOSName(goos, readFile)
switch {
case host != "" && osName != "":
return "wacli - " + osName + " (" + host + ")"
case host != "":
return "wacli - " + host
case osName != "":
return "wacli - " + osName
default:
return "wacli"
}
}
func friendlyOSName(goos string, readFile func(string) ([]byte, error)) string {
switch goos {
case "darwin":
return "macOS"
case "linux":
return linuxDistroName(readFile)
case "windows":
return "Windows"
default:
return goos
}
}
func linuxDistroName(readFile func(string) ([]byte, error)) string {
data, err := readFile("/etc/os-release")
if err != nil {
return "Linux"
}
for _, line := range strings.Split(string(data), "\n") {
key, value, ok := strings.Cut(line, "=")
if !ok || key != "PRETTY_NAME" {
continue
}
value = strings.Trim(strings.TrimSpace(value), `"`)
if value != "" {
return value
}
}
return "Linux"
}

38
cmd/wacli/main_test.go Normal file
View File

@ -0,0 +1,38 @@
package main
import (
"errors"
"testing"
"go.mau.fi/whatsmeow/proto/waCompanionReg"
)
func TestParsePlatformType(t *testing.T) {
if got := parsePlatformType("desktop"); got != waCompanionReg.DeviceProps_DESKTOP {
t.Fatalf("desktop parsed as %v", got)
}
if got := parsePlatformType("bogus"); got != waCompanionReg.DeviceProps_CHROME {
t.Fatalf("bogus parsed as %v", got)
}
}
func TestDetectDeviceLabel(t *testing.T) {
host := func() (string, error) { return "workstation", nil }
readFile := func(string) ([]byte, error) { return []byte(`PRETTY_NAME="Ubuntu 24.04 LTS"`), nil }
if got := detectDeviceLabel("linux", host, readFile); got != "wacli - Ubuntu 24.04 LTS (workstation)" {
t.Fatalf("detectDeviceLabel = %q", got)
}
}
func TestDetectDeviceLabelFallbacks(t *testing.T) {
noHost := func() (string, error) { return "", errors.New("no hostname") }
noFile := func(string) ([]byte, error) { return nil, errors.New("missing") }
if got := detectDeviceLabel("darwin", noHost, noFile); got != "wacli - macOS" {
t.Fatalf("darwin label = %q", got)
}
if got := detectDeviceLabel("", noHost, noFile); got != "wacli" {
t.Fatalf("empty label = %q", got)
}
}

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

@ -4,11 +4,16 @@ import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
)
func newMessagesCmd(flags *rootFlags) *cobra.Command {
@ -18,8 +23,12 @@ 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
}
@ -32,6 +41,8 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
var fromMe bool
var fromThem bool
var asc bool
var forwarded bool
var starred bool
cmd := &cobra.Command{
Use: "list",
@ -77,18 +88,26 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
fromMeFilter = &v
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().ListMessages(store.ListMessagesParams{
ChatJID: chat,
ChatJIDs: chatJIDs,
SenderJID: sender,
Limit: limit,
After: after,
Before: before,
FromMe: fromMeFilter,
Asc: asc,
Forwarded: forwarded,
Starred: starred,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
@ -109,6 +128,8 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&fromMe, "from-me", false, "only messages sent by me")
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
}
@ -120,6 +141,8 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
var beforeStr string
var hasMedia bool
var msgType string
var forwarded bool
var starred bool
cmd := &cobra.Command{
Use: "search <query>",
@ -152,19 +175,27 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
before = &t
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().SearchMessages(store.SearchMessagesParams{
Query: args[0],
ChatJID: chat,
From: from,
Limit: limit,
After: after,
Before: before,
HasMedia: hasMedia,
Type: msgType,
Query: args[0],
ChatJIDs: chatJIDs,
From: from,
Limit: limit,
After: after,
Before: before,
HasMedia: hasMedia,
Type: msgType,
Forwarded: forwarded,
Starred: starred,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
@ -190,6 +221,78 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
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))
},
}
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
}
@ -214,10 +317,15 @@ func newMessagesShowCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
m, err := a.DB().GetMessage(chat, id)
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
m, err := getMessageByChatFilter(a.DB(), chatJIDs, id)
if err != nil {
return err
}
m = resolveMessageSenderNames(ctx, a, []store.Message{m})[0]
if flags.asJSON {
return out.WriteJSON(os.Stdout, m)
@ -255,10 +363,15 @@ func newMessagesContextCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
msgs, err := a.DB().MessageContext(chat, id, before, after)
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := getMessageContextByChatFilter(a.DB(), chatJIDs, id, before, after)
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, msgs)
@ -273,3 +386,345 @@ func newMessagesContextCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().IntVar(&after, "after", 5, "messages after")
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)
}
sender = parsed
} else if !msg.FromMe && chat.Server == types.DefaultUserServer {
sender = chat
}
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")
}
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
}
func validateMessageCanEdit(msg store.Message, now time.Time) error {
if err := validateMessageCanRevoke(msg); err != nil {
return err
}
if strings.TrimSpace(msg.MediaType) != "" {
return fmt.Errorf("only text messages can be edited")
}
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
}

View File

@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
)
func writeMessagesList(dst io.Writer, msgs []store.Message, fullOutput bool) error {
@ -51,6 +51,26 @@ 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 != "" {
@ -58,11 +78,47 @@ func writeMessageShow(dst io.Writer, m store.Message) error {
}
fmt.Fprintf(dst, "ID: %s\n", m.MsgID)
fmt.Fprintf(dst, "Time: %s\n", m.Timestamp.Local().Format(time.RFC3339))
fmt.Fprintf(dst, "From: %s\n", messageFrom(m))
fmt.Fprintf(dst, "From: %s\n", messageFromDetail(m))
if m.MediaType != "" {
fmt.Fprintf(dst, "Media: %s\n", m.MediaType)
}
fmt.Fprintf(dst, "\n%s\n", m.Text)
if m.MediaCaption != "" {
fmt.Fprintf(dst, "Caption: %s\n", m.MediaCaption)
}
if m.Filename != "" {
fmt.Fprintf(dst, "Filename: %s\n", m.Filename)
}
if m.MimeType != "" {
fmt.Fprintf(dst, "MIME type: %s\n", m.MimeType)
}
if m.LocalPath != "" {
fmt.Fprintf(dst, "Downloaded: %s\n", m.LocalPath)
if !m.DownloadedAt.IsZero() {
fmt.Fprintf(dst, "Downloaded at: %s\n", m.DownloadedAt.Local().Format(time.RFC3339))
}
}
if m.IsForwarded {
fmt.Fprintln(dst, "Forwarded: yes")
if m.ForwardingScore > 0 {
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)
}
return nil
}
@ -88,10 +144,37 @@ func messageFrom(m store.Message) string {
if m.FromMe {
return "me"
}
if name := strings.TrimSpace(m.SenderName); name != "" {
return name
}
return m.SenderJID
}
func messageFromDetail(m store.Message) string {
if m.FromMe {
return "me"
}
name := strings.TrimSpace(m.SenderName)
jid := strings.TrimSpace(m.SenderJID)
switch {
case name != "" && jid != "" && name != jid:
return fmt.Sprintf("%s (%s)", name, jid)
case name != "":
return name
case jid != "":
return jid
default:
return "(unknown)"
}
}
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
}
@ -104,6 +187,14 @@ func messageText(m store.Message) string {
return ""
}
func messageRawText(m store.Message) string {
raw := strings.TrimSpace(m.Text)
if raw == "" || raw == messageText(m) {
return ""
}
return raw
}
func messageContextLine(m store.Message) string {
return messageText(m)
}

View File

@ -0,0 +1,182 @@
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

@ -2,11 +2,18 @@ package main
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func TestTruncate(t *testing.T) {
@ -29,6 +36,16 @@ 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 {
@ -66,6 +83,26 @@ func TestMessageContextLineFallsBackToMedia(t *testing.T) {
}
}
func TestMessageFromPrefersSenderName(t *testing.T) {
got := messageFrom(store.Message{
SenderJID: "123456789@lid",
SenderName: "Alice",
})
if got != "Alice" {
t.Fatalf("messageFrom() = %q, want Alice", got)
}
}
func TestMessageFromDetailIncludesJID(t *testing.T) {
got := messageFromDetail(store.Message{
SenderJID: "123@s.whatsapp.net",
SenderName: "Alice",
})
if got != "Alice (123@s.whatsapp.net)" {
t.Fatalf("messageFromDetail() = %q", got)
}
}
func TestWriteMessagesListFullOutput(t *testing.T) {
msg := store.Message{
ChatJID: "chat@s.whatsapp.net",
@ -96,9 +133,46 @@ func TestWriteMessagesListFullOutput(t *testing.T) {
}
}
func TestWriteMessageShowPrefersDisplayTextAndMediaDetails(t *testing.T) {
msg := store.Message{
ChatJID: "chat@s.whatsapp.net",
SenderJID: "sender@s.whatsapp.net",
SenderName: "Alice",
MsgID: "mid",
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Text: "raw payload",
DisplayText: "Reacted 👍 to hello",
MediaType: "image",
MediaCaption: "caption",
Filename: "pic.jpg",
MimeType: "image/jpeg",
LocalPath: "/tmp/pic.jpg",
DownloadedAt: time.Date(2024, 1, 1, 12, 1, 0, 0, time.UTC),
}
var out bytes.Buffer
if err := writeMessageShow(&out, msg); err != nil {
t.Fatalf("writeMessageShow: %v", err)
}
got := out.String()
for _, want := range []string{
"From: Alice (sender@s.whatsapp.net)",
"Caption: caption",
"Filename: pic.jpg",
"MIME type: image/jpeg",
"Downloaded: /tmp/pic.jpg",
"Reacted 👍 to hello",
"Raw text:\nraw payload",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
cmd := newMessagesSearchCmd(&rootFlags{})
for _, name := range []string{"has-media", "type"} {
for _, name := range []string{"has-media", "type", "forwarded", "starred"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
@ -107,3 +181,291 @@ func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
t.Fatalf("type usage = %q", got)
}
}
func TestMessagesListCommandExposesMessageFilters(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)
}
}
func TestWriteMessageShowIncludesForwardedMetadata(t *testing.T) {
msg := store.Message{
ChatJID: "chat@s.whatsapp.net",
SenderJID: "sender@s.whatsapp.net",
MsgID: "mid",
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Text: "hello",
IsForwarded: true,
ForwardingScore: 3,
}
var out bytes.Buffer
if err := writeMessageShow(&out, msg); err != nil {
t.Fatalf("writeMessageShow: %v", err)
}
if !strings.Contains(out.String(), "Forwarded: yes") {
t.Fatalf("expected forwarded marker, got:\n%s", out.String())
}
if !strings.Contains(out.String(), "Forwarding score: 3") {
t.Fatalf("expected forwarding score, got:\n%s", out.String())
}
}
func TestGetMessageByChatFilterTriesMappedChatJIDs(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
pn := "15551234567@s.whatsapp.net"
lid := "123456789@lid"
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
for _, jid := range []string{pn, lid} {
if err := db.UpsertChat(jid, "dm", jid, now); err != nil {
t.Fatalf("UpsertChat %s: %v", jid, err)
}
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: lid,
MsgID: "mid",
SenderJID: lid,
Timestamp: now,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
msg, err := getMessageByChatFilter(db, []string{pn, lid}, "mid")
if err != nil {
t.Fatalf("getMessageByChatFilter: %v", err)
}
if msg.ChatJID != lid {
t.Fatalf("ChatJID = %q, want %q", msg.ChatJID, lid)
}
msgs, err := getMessageContextByChatFilter(db, []string{pn, lid}, "mid", 1, 1)
if err != nil {
t.Fatalf("getMessageContextByChatFilter: %v", err)
}
if len(msgs) != 1 || msgs[0].ChatJID != lid {
t.Fatalf("context = %+v", msgs)
}
}
func TestResolveMessageSenderNamesUsesLIDMappingAndContacts(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
pn := "15551234567@s.whatsapp.net"
lid := "123456789@lid"
if err := db.UpsertContact(pn, "+15551234567", "", "Alice", "", ""); err != nil {
t.Fatalf("UpsertContact: %v", err)
}
resolver := fakeLIDResolver{lid: mustParseJID(t, lid), pn: mustParseJID(t, pn)}
msgs := resolveMessageSenderNamesWith(context.Background(), db, resolver, []store.Message{
{SenderJID: lid, Text: "hello"},
{SenderJID: "someone@s.whatsapp.net", Text: "plain"},
{SenderJID: lid, SenderName: "Existing", Text: "kept"},
})
if msgs[0].SenderName != "Alice" {
t.Fatalf("resolved SenderName = %q, want Alice", msgs[0].SenderName)
}
if msgs[1].SenderName != "" {
t.Fatalf("non-LID SenderName = %q, want empty", msgs[1].SenderName)
}
if msgs[2].SenderName != "Existing" {
t.Fatalf("existing SenderName = %q", msgs[2].SenderName)
}
}
type fakeLIDResolver struct {
lid types.JID
pn types.JID
}
func (f fakeLIDResolver) ResolveLIDToPN(ctx context.Context, jid types.JID) types.JID {
if jid == f.lid {
return f.pn
}
return jid
}
func mustParseJID(t *testing.T, s string) types.JID {
t.Helper()
jid, err := types.ParseJID(s)
if err != nil {
t.Fatalf("ParseJID(%q): %v", s, err)
}
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"
)
@ -34,7 +34,7 @@ func newPresenceTypingCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
cmd.Flags().StringVar(&media, "media", "", "media type: 'audio' for recording indicator (default: typing text)")
return cmd
}
@ -50,7 +50,7 @@ func newPresencePausedCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
return cmd
}

136
cmd/wacli/profile.go Normal file
View File

@ -0,0 +1,136 @@
package main
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
stdraw "image/draw"
"image/jpeg"
_ "image/png"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
// profileMaxPx is the max dimension WhatsApp accepts for profile pictures.
const profileMaxPx = 640
func newProfileCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "Manage your WhatsApp profile",
}
cmd.AddCommand(newProfileSetPictureCmd(flags))
return cmd
}
func newProfileSetPictureCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "set-picture <image>",
Short: "Set your WhatsApp profile picture (JPEG or PNG, auto-resized to <=640px)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
imgBytes, err := readAsJPEG(args[0])
if err != nil {
return fmt.Errorf("read image: %w", 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
}
pictureID, err := a.WA().SetProfilePicture(ctx, imgBytes)
if err != nil {
return fmt.Errorf("set profile picture: %w", err)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"picture_id": pictureID})
}
fmt.Fprintf(os.Stdout, "Profile picture updated (id: %s)\n", pictureID)
return nil
},
}
return cmd
}
// readAsJPEG reads the file at path, decodes it, resizes to <=profileMaxPx if
// needed, and returns JPEG-encoded bytes suitable for WhatsApp.
func readAsJPEG(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("unsupported image format: %w", err)
}
img = resizeIfNeeded(img, profileMaxPx)
// Composite onto white background to flatten any alpha channel.
bounds := img.Bounds()
rgba := image.NewRGBA(bounds)
stdraw.Draw(rgba, bounds, &image.Uniform{color.White}, image.Point{}, stdraw.Src)
stdraw.Draw(rgba, bounds, img, bounds.Min, stdraw.Over)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, rgba, &jpeg.Options{Quality: 85}); err != nil {
return nil, fmt.Errorf("encode jpeg: %w", err)
}
return buf.Bytes(), nil
}
// resizeIfNeeded returns a nearest-neighbour scaled copy of src when either
// dimension exceeds maxPx, otherwise returns src unchanged.
func resizeIfNeeded(src image.Image, maxPx int) image.Image {
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if w <= maxPx && h <= maxPx {
return src
}
larger := w
if h > larger {
larger = h
}
nw := w * maxPx / larger
nh := h * maxPx / larger
if nw < 1 {
nw = 1
}
if nh < 1 {
nh = 1
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
scaleX := float64(w) / float64(nw)
scaleY := float64(h) / float64(nh)
for y := 0; y < nh; y++ {
for x := 0; x < nw; x++ {
srcX := b.Min.X + int(float64(x)*scaleX)
srcY := b.Min.Y + int(float64(y)*scaleY)
dst.Set(x, y, src.At(srcX, srcY))
}
}
return dst
}

59
cmd/wacli/profile_test.go Normal file
View File

@ -0,0 +1,59 @@
package main
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"testing"
)
func TestReadAsJPEGResizesAndFlattensAlpha(t *testing.T) {
src := image.NewNRGBA(image.Rect(0, 0, 800, 400))
for y := 0; y < src.Bounds().Dy(); y++ {
for x := 0; x < src.Bounds().Dx(); x++ {
src.SetNRGBA(x, y, color.NRGBA{R: 255, A: 0})
}
}
src.SetNRGBA(799, 399, color.NRGBA{R: 200, G: 20, B: 20, A: 255})
path := filepath.Join(t.TempDir(), "avatar.png")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, src); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
data, err := readAsJPEG(path)
if err != nil {
t.Fatalf("readAsJPEG: %v", err)
}
out, err := jpeg.Decode(bytes.NewReader(data))
if err != nil {
t.Fatalf("decode output JPEG: %v", err)
}
if got := out.Bounds().Size(); got.X != profileMaxPx || got.Y != 320 {
t.Fatalf("size = %dx%d, want %dx320", got.X, got.Y, profileMaxPx)
}
r, g, b, _ := out.At(0, 0).RGBA()
if r>>8 < 240 || g>>8 < 240 || b>>8 < 240 {
t.Fatalf("transparent pixel was not flattened onto white, got rgb=(%d,%d,%d)", r>>8, g>>8, b>>8)
}
}
func TestResizeIfNeededKeepsSmallImage(t *testing.T) {
img := image.NewRGBA(image.Rect(0, 0, 32, 16))
if got := resizeIfNeeded(img, profileMaxPx); got != img {
t.Fatal("resizeIfNeeded should return small images unchanged")
}
}

145
cmd/wacli/recipient.go Normal file
View File

@ -0,0 +1,145 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/openclaw/wacli/internal/resolve"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)
type recipientOptions struct {
pick int
asJSON bool
}
type recipientResolverApp interface {
DB() *store.DB
}
func resolveRecipient(a recipientResolverApp, input string, opts recipientOptions) (types.JID, error) {
input = strings.TrimSpace(input)
if input == "" {
return types.JID{}, fmt.Errorf("--to is required")
}
if opts.pick < 0 {
return types.JID{}, fmt.Errorf("--pick must be a positive integer, got %d", opts.pick)
}
if strings.Contains(input, "@") {
return wa.ParseUserOrJID(input)
}
phoneShaped := resolve.LooksLikePhone(input)
candidates, err := resolve.Resolve(a.DB(), input, 10)
if err != nil {
return types.JID{}, err
}
if phoneShaped {
candidates = exactPhoneCandidates(candidates)
}
if len(candidates) == 0 {
if phoneShaped {
return wa.ParseUserOrJID(resolve.NormalizePhone(input))
}
return types.JID{}, fmt.Errorf("no contacts, groups, or chats match %q (try `wacli contacts search` or pass a JID)", input)
}
if opts.pick > 0 {
if opts.pick > len(candidates) {
return types.JID{}, fmt.Errorf("--pick %d is out of range (only %d match%s for %q)", opts.pick, len(candidates), plural(len(candidates)), input)
}
return parseCandidateJID(candidates[opts.pick-1])
}
if len(candidates) == 1 {
return parseCandidateJID(candidates[0])
}
if opts.asJSON || !isInteractive() {
return types.JID{}, ambiguousRecipientError(input, candidates)
}
pick, err := promptCandidate(os.Stderr, os.Stdin, input, candidates)
if err != nil {
return types.JID{}, err
}
return parseCandidateJID(candidates[pick])
}
func exactPhoneCandidates(candidates []resolve.Candidate) []resolve.Candidate {
exact := candidates[:0:0]
for _, c := range candidates {
if c.Score < resolve.ScoreExact {
continue
}
if c.Kind == resolve.KindChat && !strings.HasSuffix(c.JID, "@g.us") {
continue
}
exact = append(exact, c)
}
return exact
}
func parseCandidateJID(c resolve.Candidate) (types.JID, error) {
jid, err := wa.ParseUserOrJID(c.JID)
if err != nil {
return types.JID{}, fmt.Errorf("parse resolved JID %q: %w", c.JID, err)
}
return jid, nil
}
func ambiguousRecipientError(input string, candidates []resolve.Candidate) error {
var b strings.Builder
fmt.Fprintf(&b, "%q matches %d recipients; pass a JID or use --pick N:\n", input, len(candidates))
for i, c := range candidates {
fmt.Fprintf(&b, " %d) %s\n", i+1, formatCandidate(c))
}
return fmt.Errorf("%s", strings.TrimRight(b.String(), "\n"))
}
func formatCandidate(c resolve.Candidate) string {
name := strings.TrimSpace(c.Name)
if name == "" {
name = c.JID
}
detail := strings.TrimSpace(c.Detail)
kind := string(c.Kind)
if detail != "" && detail != kind {
return fmt.Sprintf("%-30s %-16s [%s] %s", name, detail, kind, c.JID)
}
return fmt.Sprintf("%-30s %-16s [%s] %s", name, "", kind, c.JID)
}
func promptCandidate(w io.Writer, r io.Reader, input string, candidates []resolve.Candidate) (int, error) {
fmt.Fprintf(w, "%q matches %d recipients:\n", input, len(candidates))
for i, c := range candidates {
fmt.Fprintf(w, " %d) %s\n", i+1, formatCandidate(c))
}
fmt.Fprintf(w, "Pick [1-%d] (or q to cancel): ", len(candidates))
scanner := bufio.NewScanner(r)
if !scanner.Scan() {
return 0, fmt.Errorf("no selection made")
}
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.EqualFold(line, "q") {
return 0, fmt.Errorf("cancelled")
}
n, err := strconv.Atoi(line)
if err != nil || n < 1 || n > len(candidates) {
return 0, fmt.Errorf("invalid selection %q", line)
}
return n - 1, nil
}
func plural(n int) string {
if n == 1 {
return ""
}
return "es"
}

View File

@ -9,19 +9,23 @@ 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.7.0"
var version = "0.8.1"
const docsURL = "https://wacli.sh"
type rootFlags struct {
storeDir string
account string
asJSON bool
fullOutput bool
events bool
timeout time.Duration
readOnly bool
lockWait time.Duration
@ -32,6 +36,8 @@ 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,
@ -39,13 +45,16 @@ 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))
@ -55,27 +64,40 @@ 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 {
_ = out.WriteError(os.Stderr, flags.asJSON, err)
writeRootError(flags, err)
return err
}
return nil
}
func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) {
storeDir := flags.storeDir
if storeDir == "" {
storeDir = config.DefaultStoreDir()
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, _ = 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
@ -86,6 +108,7 @@ 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 {
@ -98,6 +121,45 @@ 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,10 +1,82 @@
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}
@ -24,3 +96,64 @@ 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,11 +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/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"
@ -26,15 +27,22 @@ 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
}
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 noPreview bool
var messageEscapes bool
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "text",
@ -46,12 +54,36 @@ 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)
@ -59,17 +91,25 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
toJID, err := wa.ParseUserOrJID(to)
toJID, err := resolveRecipient(a, to, recipientOptions{pick: pick, asJSON: flags.asJSON})
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
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
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)
return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender, preview, mentionedJIDs)
})
if err != nil {
return err
@ -91,6 +131,8 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
Text: message,
})
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
@ -103,10 +145,15 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
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().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
}
@ -115,16 +162,127 @@ type sendTextApp interface {
DB() *store.DB
}
func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, replyTo, replyToSender string) (types.MessageID, error) {
replyTo = strings.TrimSpace(replyTo)
if replyTo == "" {
return a.WA().SendText(ctx, to, text)
}
sender, err := resolveReplySender(a.DB(), to, replyTo, replyToSender)
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)
if err != nil {
return "", err
}
if plainText {
return a.WA().SendText(ctx, to, text)
}
return a.WA().SendProtoMessage(ctx, to, msg)
}
func fetchLinkPreview(ctx context.Context, text string, disabled bool) *linkpreview.Preview {
if disabled {
return nil
}
rawURL := linkpreview.FindFirstHTTPURL(text)
if rawURL == "" {
return nil
}
previewCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
preview, err := linkpreview.Fetch(previewCtx, nil, rawURL)
if err != nil {
return nil
}
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)
if err != nil {
return nil, false, err
}
if info == nil && preview == nil {
return nil, true, nil
}
ext := &waProto.ExtendedTextMessage{
Text: proto.String(text),
ContextInfo: info,
}
attachLinkPreview(ext, preview)
return &waProto.Message{ExtendedTextMessage: ext}, false, nil
}
func attachLinkPreview(msg *waProto.ExtendedTextMessage, preview *linkpreview.Preview) {
if preview == nil {
return
}
if preview.URL != "" {
msg.MatchedText = proto.String(preview.URL)
}
if preview.Title != "" {
msg.Title = proto.String(preview.Title)
}
if preview.Description != "" {
msg.Description = proto.String(preview.Description)
}
if len(preview.Thumbnail) > 0 {
msg.PreviewType = waProto.ExtendedTextMessage_IMAGE.Enum()
msg.JPEGThumbnail = preview.Thumbnail
return
}
msg.PreviewType = waProto.ExtendedTextMessage_NONE.Enum()
}
func buildTextContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string, mentionedJIDs []string) (*waProto.ContextInfo, error) {
info, err := buildReplyContextInfo(db, chat, replyTo, replyToSender)
if err != nil {
return nil, err
}
if len(mentionedJIDs) == 0 {
return info, nil
}
if info == nil {
info = &waProto.ContextInfo{}
}
info.MentionedJID = append([]string(nil), mentionedJIDs...)
return info, nil
}
func buildReplyContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string) (*waProto.ContextInfo, error) {
replyTo = strings.TrimSpace(replyTo)
if replyTo == "" {
return nil, nil
}
sender, err := resolveReplySender(db, chat, replyTo, replyToSender)
if err != nil {
return nil, err
}
stanzaID := replyTo
info := &waProto.ContextInfo{StanzaID: proto.String(stanzaID)}
@ -132,13 +290,7 @@ func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, rep
participant := sender.String()
info.Participant = proto.String(participant)
}
return a.WA().SendProtoMessage(ctx, to, &waProto.Message{
ExtendedTextMessage: &waProto.ExtendedTextMessage{
Text: proto.String(text),
ContextInfo: info,
},
})
return info, nil
}
func resolveReplySender(db *store.DB, chat types.JID, replyTo, override string) (types.JID, error) {
@ -167,3 +319,28 @@ 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,46 +1,70 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"math"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"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 maxSendFileSize = 100 * 1024 * 1024
const imageThumbnailMaxDimension = 96
const voiceWaveformSamples = 64
const voiceWaveformMax = 100
type sendFileOptions struct {
filename string
caption string
mimeOverride string
replyTo string
replyToSender string
ptt bool
}
type voiceNoteMetadata struct {
seconds uint32
waveform []byte
}
func sendFile(ctx context.Context, a interface {
WA() app.WAClient
DB() *store.DB
}, to types.JID, filePath, filename, caption, mimeOverride string) (string, map[string]string, error) {
data, err := os.ReadFile(filePath)
}, to types.JID, filePath string, opts sendFileOptions) (string, map[string]string, error) {
data, err := readSendFileData(filePath)
if err != nil {
return "", nil, err
}
name := strings.TrimSpace(filename)
name := strings.TrimSpace(opts.filename)
if name == "" {
name = filepath.Base(filePath)
}
mimeType := strings.TrimSpace(mimeOverride)
if mimeType == "" {
// Use filePath for MIME detection, not the display name override
mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))
}
if mimeType == "" {
sniff := data
if len(sniff) > 512 {
sniff = sniff[:512]
}
mimeType = http.DetectContentType(sniff)
mimeType := detectSendFileMIME(filePath, opts.mimeOverride, data)
if opts.ptt && !isOggOpusMIME(mimeType) {
return "", nil, fmt.Errorf("voice notes require OGG Opus audio; got %s", mimeType)
}
mediaType := "document"
@ -57,26 +81,45 @@ func sendFile(ctx context.Context, a interface {
uploadType, _ = wa.MediaTypeFromString("audio")
}
up, err := a.WA().Upload(ctx, data, uploadType)
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)
}
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
}
}
voiceMeta := voiceNoteMetadata{}
if opts.ptt {
voiceMeta = loadVoiceNoteMetadata(ctx, filePath)
}
switch mediaType {
case "image":
msg.ImageMessage = &waProto.ImageMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(caption),
imageMsg, err := newImageMessage(up, mimeType, opts.caption, data)
if err != nil {
return "", nil, err
}
msg.ImageMessage = imageMsg
case "video":
msg.VideoMessage = &waProto.VideoMessage{
URL: proto.String(up.URL),
@ -86,19 +129,10 @@ func sendFile(ctx context.Context, a interface {
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(caption),
Caption: proto.String(opts.caption),
}
case "audio":
msg.AudioMessage = &waProto.AudioMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
PTT: proto.Bool(false),
}
msg.AudioMessage = newAudioMessage(up, mimeType, opts.ptt, voiceMeta)
default:
msg.DocumentMessage = &waProto.DocumentMessage{
URL: proto.String(up.URL),
@ -109,12 +143,18 @@ func sendFile(ctx context.Context, a interface {
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
FileName: proto.String(name),
Caption: proto.String(caption),
Caption: proto.String(opts.caption),
Title: proto.String(name),
}
}
attachSendFileReplyContext(msg, replyContext)
id, err := a.WA().SendProtoMessage(ctx, to, msg)
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)
}
if err != nil {
return "", nil, err
}
@ -130,9 +170,9 @@ func sendFile(ctx context.Context, a interface {
SenderName: "me",
Timestamp: now,
FromMe: true,
Text: caption,
Text: opts.caption,
MediaType: mediaType,
MediaCaption: caption,
MediaCaption: opts.caption,
Filename: name,
MimeType: mimeType,
DirectPath: up.DirectPath,
@ -146,10 +186,140 @@ func sendFile(ctx context.Context, a interface {
"name": name,
"mime_type": mimeType,
"media": mediaType,
"ptt": strconv.FormatBool(opts.ptt),
}, 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),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
PTT: proto.Bool(ptt),
}
if ptt {
if meta.seconds > 0 {
msg.Seconds = proto.Uint32(meta.seconds)
}
if len(meta.waveform) == voiceWaveformSamples {
msg.Waveform = meta.waveform
}
}
return msg
}
func readSendFileData(filePath string) ([]byte, error) {
info, err := os.Stat(filePath)
if err != nil {
return nil, err
}
if info.Size() > maxSendFileSize {
return nil, fmt.Errorf("file too large (%d bytes); maximum send file size is %d bytes", info.Size(), maxSendFileSize)
}
return os.ReadFile(filePath)
}
func attachSendFileReplyContext(msg *waProto.Message, info *waProto.ContextInfo) {
if info == nil {
return
}
switch {
case msg.GetImageMessage() != nil:
msg.ImageMessage.ContextInfo = info
case msg.GetVideoMessage() != nil:
msg.VideoMessage.ContextInfo = info
case msg.GetAudioMessage() != nil:
msg.AudioMessage.ContextInfo = info
case msg.GetDocumentMessage() != nil:
msg.DocumentMessage.ContextInfo = info
}
}
func chatKindFromJID(j types.JID) string {
if j.Server == types.NewsletterServer {
return "newsletter"
}
if j.Server == types.GroupServer {
return "group"
}
@ -161,3 +331,122 @@ func chatKindFromJID(j types.JID) string {
}
return "unknown"
}
func detectSendFileMIME(filePath, mimeOverride string, data []byte) string {
mimeType := strings.TrimSpace(mimeOverride)
if mimeType == "" {
// Use filePath for MIME detection, not the display name override.
mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))
}
if mimeType == "" {
sniff := data
if len(sniff) > 512 {
sniff = sniff[:512]
}
mimeType = http.DetectContentType(sniff)
}
if mimeType == "audio/ogg" || mimeType == "application/ogg" {
return "audio/ogg; codecs=opus"
}
return mimeType
}
func isOggOpusMIME(mimeType string) bool {
mediaType, params, err := mime.ParseMediaType(mimeType)
if err != nil {
return false
}
codecs := strings.ToLower(params["codecs"])
return mediaType == "audio/ogg" && strings.Contains(codecs, "opus")
}
func loadVoiceNoteMetadata(ctx context.Context, filePath string) voiceNoteMetadata {
return voiceNoteMetadata{
seconds: probeAudioSeconds(ctx, filePath),
waveform: probeAudioWaveform(ctx, filePath),
}
}
func probeAudioSeconds(ctx context.Context, filePath string) uint32 {
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
).Output()
if err != nil {
return 0
}
seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || seconds <= 0 {
return 0
}
return uint32(math.Ceil(seconds))
}
func probeAudioWaveform(ctx context.Context, filePath string) []byte {
probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, "ffmpeg",
"-v", "error",
"-i", filePath,
"-ac", "1",
"-ar", "8000",
"-f", "s16le",
"-acodec", "pcm_s16le",
"-",
).Output()
if err != nil {
return nil
}
return waveformFromPCM16LE(out)
}
func waveformFromPCM16LE(data []byte) []byte {
waveform := make([]byte, voiceWaveformSamples)
sampleCount := len(data) / 2
if sampleCount == 0 {
return waveform
}
bucketSize := int(math.Ceil(float64(sampleCount) / voiceWaveformSamples))
levels := make([]float64, voiceWaveformSamples)
var maxLevel float64
for i := 0; i < voiceWaveformSamples; i++ {
start := i * bucketSize
if start >= sampleCount {
break
}
end := start + bucketSize
if end > sampleCount {
end = sampleCount
}
var sum float64
for sampleIndex := start; sampleIndex < end; sampleIndex++ {
offset := sampleIndex * 2
sample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))
sum += math.Abs(float64(sample))
}
levels[i] = sum / float64(end-start)
if levels[i] > maxLevel {
maxLevel = levels[i]
}
}
if maxLevel == 0 {
return waveform
}
for i, level := range levels {
normalized := math.Round((level / maxLevel) * voiceWaveformMax)
if normalized > voiceWaveformMax {
normalized = voiceWaveformMax
}
waveform[i] = byte(normalized)
}
return waveform
}

View File

@ -4,18 +4,24 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
)
func newSendFileCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var filePath string
var filename string
var caption string
var mimeOverride string
var replyTo string
var replyToSender string
var ptt bool
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "file",
@ -33,6 +39,29 @@ 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)
@ -40,12 +69,15 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
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
}
toJID, err := wa.ParseUserOrJID(to)
if err != nil {
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
@ -54,7 +86,14 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
meta map[string]string
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendFileResult, error) {
msgID, meta, err := sendFile(ctx, a, toJID, filePath, filename, caption, mimeOverride)
msgID, meta, err := sendFile(ctx, a, toJID, filePath, sendFileOptions{
filename: filename,
caption: caption,
mimeOverride: mimeOverride,
replyTo: replyTo,
replyToSender: replyToSender,
ptt: ptt,
})
if err != nil {
return sendFileResult{}, err
}
@ -65,6 +104,8 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
}
msgID, meta := res.id, res.meta
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
@ -78,10 +119,15 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
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 file")
cmd.Flags().StringVar(&filename, "filename", "", "display name for the file (defaults to basename of --file)")
cmd.Flags().StringVar(&caption, "caption", "", "caption (images/videos/documents)")
cmd.Flags().StringVar(&mimeOverride, "mime", "", "override detected mime type")
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().BoolVar(&ptt, "ptt", false, "send OGG/Opus audio as a WhatsApp voice note")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}

280
cmd/wacli/send_file_test.go Normal file
View File

@ -0,0 +1,280 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"google.golang.org/protobuf/proto"
)
func TestDetectSendFileMIMEAddsOpusCodecForOgg(t *testing.T) {
for _, tc := range []struct {
name string
filePath string
mimeOverride string
want string
}{
{name: "extension", filePath: "voice.ogg", want: "audio/ogg; codecs=opus"},
{name: "audio override", filePath: "voice.bin", mimeOverride: "audio/ogg", want: "audio/ogg; codecs=opus"},
{name: "application override", filePath: "voice.bin", mimeOverride: "application/ogg", want: "audio/ogg; codecs=opus"},
{name: "already has codec", filePath: "voice.bin", mimeOverride: "audio/ogg; codecs=opus", want: "audio/ogg; codecs=opus"},
} {
t.Run(tc.name, func(t *testing.T) {
got := detectSendFileMIME(tc.filePath, tc.mimeOverride, nil)
if got != tc.want {
t.Fatalf("mime = %q, want %q", got, tc.want)
}
})
}
}
func TestReadSendFileDataRejectsOversizedFile(t *testing.T) {
path := t.TempDir() + "/huge.bin"
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.Truncate(path, maxSendFileSize+1); err != nil {
t.Fatalf("Truncate: %v", err)
}
_, err := readSendFileData(path)
if err == nil || !strings.Contains(err.Error(), "file too large") {
t.Fatalf("expected file too large error, got %v", err)
}
}
func TestSendFileCommandExposesReplyFlags(t *testing.T) {
cmd := newSendFileCmd(&rootFlags{})
for _, name := range []string{"reply-to", "reply-to-sender", "ptt"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
func TestSendVoiceCommandExposesSharedSendFlags(t *testing.T) {
cmd := newSendVoiceCmd(&rootFlags{})
for _, name := range []string{"to", "pick", "file", "mime", "reply-to", "reply-to-sender", "post-send-wait"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
func TestIsOggOpusMIME(t *testing.T) {
for _, tc := range []struct {
mime string
want bool
}{
{mime: "audio/ogg; codecs=opus", want: true},
{mime: "audio/ogg; codecs=\"opus\"", want: true},
{mime: "audio/ogg", want: false},
{mime: "audio/mpeg", want: false},
} {
if got := isOggOpusMIME(tc.mime); got != tc.want {
t.Fatalf("isOggOpusMIME(%q) = %v, want %v", tc.mime, got, tc.want)
}
}
}
func TestNewAudioMessageAttachesPTTMetadata(t *testing.T) {
up := whatsmeow.UploadResponse{
URL: "https://upload",
DirectPath: "/path",
MediaKey: []byte("key"),
FileEncSHA256: []byte("enc"),
FileSHA256: []byte("plain"),
FileLength: 123,
}
waveform := make([]byte, voiceWaveformSamples)
for i := range waveform {
waveform[i] = byte(i)
}
msg := newAudioMessage(up, "audio/ogg; codecs=opus", true, voiceNoteMetadata{seconds: 7, waveform: waveform})
if !msg.GetPTT() {
t.Fatalf("PTT = false, want true")
}
if msg.GetSeconds() != 7 {
t.Fatalf("seconds = %d, want 7", msg.GetSeconds())
}
if string(msg.GetWaveform()) != string(waveform) {
t.Fatalf("waveform not attached")
}
}
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++ {
sample := int16(100)
if i >= voiceWaveformSamples {
sample = 1000
}
binary.LittleEndian.PutUint16(data[i*2:i*2+2], uint16(sample))
}
waveform := waveformFromPCM16LE(data)
if len(waveform) != voiceWaveformSamples {
t.Fatalf("waveform length = %d, want %d", len(waveform), voiceWaveformSamples)
}
if waveform[0] == 0 {
t.Fatalf("first sample = 0, want non-zero")
}
if waveform[len(waveform)-1] != voiceWaveformMax {
t.Fatalf("last sample = %d, want %d", waveform[len(waveform)-1], voiceWaveformMax)
}
}
func TestProbeAudioMetadataWithFFmpeg(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed")
}
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not installed")
}
path := filepath.Join(t.TempDir(), "voice.ogg")
err := exec.Command("ffmpeg",
"-hide_banner",
"-loglevel", "error",
"-f", "lavfi",
"-i", "sine=frequency=440:duration=0.7",
"-c:a", "libopus",
path,
).Run()
if err != nil {
t.Skipf("ffmpeg could not generate Opus fixture: %v", err)
}
if seconds := probeAudioSeconds(context.Background(), path); seconds != 1 {
t.Fatalf("seconds = %d, want 1", seconds)
}
waveform := probeAudioWaveform(context.Background(), path)
if len(waveform) != voiceWaveformSamples {
t.Fatalf("waveform length = %d, want %d", len(waveform), voiceWaveformSamples)
}
hasNonZero := false
for _, sample := range waveform {
if sample > 0 {
hasNonZero = true
break
}
}
if !hasNonZero {
t.Fatalf("waveform is all zero")
}
}
func TestAttachSendFileReplyContext(t *testing.T) {
for _, tc := range []struct {
name string
msg *waProto.Message
got func(*waProto.Message) *waProto.ContextInfo
}{
{
name: "image",
msg: &waProto.Message{ImageMessage: &waProto.ImageMessage{}},
got: func(msg *waProto.Message) *waProto.ContextInfo { return msg.GetImageMessage().GetContextInfo() },
},
{
name: "video",
msg: &waProto.Message{VideoMessage: &waProto.VideoMessage{}},
got: func(msg *waProto.Message) *waProto.ContextInfo { return msg.GetVideoMessage().GetContextInfo() },
},
{
name: "audio",
msg: &waProto.Message{AudioMessage: &waProto.AudioMessage{}},
got: func(msg *waProto.Message) *waProto.ContextInfo { return msg.GetAudioMessage().GetContextInfo() },
},
{
name: "document",
msg: &waProto.Message{DocumentMessage: &waProto.DocumentMessage{}},
got: func(msg *waProto.Message) *waProto.ContextInfo { return msg.GetDocumentMessage().GetContextInfo() },
},
} {
t.Run(tc.name, func(t *testing.T) {
info := &waProto.ContextInfo{
StanzaID: proto.String("quoted"),
Participant: proto.String("15551234567@s.whatsapp.net"),
}
attachSendFileReplyContext(tc.msg, info)
if tc.got(tc.msg) != info {
t.Fatalf("context info was not attached")
}
})
}
}

View File

@ -4,14 +4,20 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/steipete/wacli/internal/app"
"github.com/openclaw/wacli/internal/app"
"go.mau.fi/whatsmeow"
)
const sendAttemptTimeout = 45 * time.Second
const postSendRetryReceiptWait = 2 * time.Second
const rapidSendWarningThreshold = 5 * time.Second
const lastSendAttemptFile = ".last-send-at"
func runSendOperation[T any](
ctx context.Context,
@ -88,3 +94,38 @@ func reconnectForSend(a interface {
return a.Connect(ctx, false, nil)
}
}
func waitForPostSendRetryReceipts(ctx context.Context, d time.Duration) {
if d <= 0 {
return
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
func warnRapidSendIfNeeded(storeDir string, now time.Time, stderr io.Writer) error {
path := filepath.Join(storeDir, lastSendAttemptFile)
data, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read last send marker: %w", err)
}
if err == nil {
last, parseErr := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(data)))
if parseErr == nil {
if elapsed := now.Sub(last); elapsed >= 0 && elapsed < rapidSendWarningThreshold {
fmt.Fprintf(stderr, "warning: send command was invoked %s after the previous send; rapid automated sends may trigger WhatsApp rate limits or account restrictions\n", elapsed.Round(time.Second))
}
}
}
if err := os.WriteFile(path, []byte(now.Format(time.RFC3339Nano)+"\n"), 0o600); err != nil {
return fmt.Errorf("write last send marker: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod last send marker: %w", err)
}
return nil
}

View File

@ -1,9 +1,13 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
@ -65,6 +69,28 @@ func TestRunSendAttemptTimesOut(t *testing.T) {
}
}
func TestWaitForPostSendRetryReceipts(t *testing.T) {
start := time.Now()
waitForPostSendRetryReceipts(context.Background(), 10*time.Millisecond)
if elapsed := time.Since(start); elapsed < 10*time.Millisecond {
t.Fatalf("wait elapsed %s, want at least 10ms", elapsed)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
start = time.Now()
waitForPostSendRetryReceipts(ctx, time.Minute)
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("canceled wait elapsed %s, want quick return", elapsed)
}
start = time.Now()
waitForPostSendRetryReceipts(context.Background(), 0)
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("disabled wait elapsed %s, want quick return", elapsed)
}
}
func TestIsRetryableSendError(t *testing.T) {
if !isRetryableSendError(fmt.Errorf("wrapped: %w", whatsmeow.ErrIQTimedOut)) {
t.Fatalf("expected ErrIQTimedOut to be retryable")
@ -76,3 +102,58 @@ func TestIsRetryableSendError(t *testing.T) {
t.Fatalf("did not expect arbitrary error to be retryable")
}
}
func TestWarnRapidSendIfNeededWarnsAndUpdatesMarker(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
var stderr bytes.Buffer
if err := warnRapidSendIfNeeded(dir, now, &stderr); err != nil {
t.Fatalf("first warning check: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("first send warned: %q", stderr.String())
}
if err := warnRapidSendIfNeeded(dir, now.Add(time.Second), &stderr); err != nil {
t.Fatalf("second warning check: %v", err)
}
if got := stderr.String(); !strings.Contains(got, "warning: send command was invoked 1s after the previous send") {
t.Fatalf("expected rapid-send warning, got %q", got)
}
info, err := os.Stat(filepath.Join(dir, lastSendAttemptFile))
if err != nil {
t.Fatalf("stat marker: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("marker mode = %04o, want 0600", got)
}
}
func TestWarnRapidSendIfNeededSkipsOldOrInvalidMarker(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
path := filepath.Join(dir, lastSendAttemptFile)
if err := os.WriteFile(path, []byte(now.Add(-rapidSendWarningThreshold).Format(time.RFC3339Nano)), 0o600); err != nil {
t.Fatalf("write old marker: %v", err)
}
var stderr bytes.Buffer
if err := warnRapidSendIfNeeded(dir, now, &stderr); err != nil {
t.Fatalf("old marker warning check: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("old marker warned: %q", stderr.String())
}
if err := os.WriteFile(path, []byte("not a timestamp"), 0o600); err != nil {
t.Fatalf("write invalid marker: %v", err)
}
if err := warnRapidSendIfNeeded(dir, now.Add(time.Second), &stderr); err != nil {
t.Fatalf("invalid marker warning check: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("invalid marker warned: %q", stderr.String())
}
}

359
cmd/wacli/send_ipc.go Normal file
View File

@ -0,0 +1,359 @@
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

@ -0,0 +1,59 @@
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

@ -5,10 +5,12 @@ import (
"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"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)
@ -17,6 +19,7 @@ func newSendReactCmd(flags *rootFlags) *cobra.Command {
var msgID string
var emoji string
var sender string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "react",
@ -34,6 +37,20 @@ 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)
@ -49,6 +66,9 @@ func newSendReactCmd(flags *rootFlags) *cobra.Command {
if 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().SendReaction(ctx, chat, senderJID, types.MessageID(msgID), emoji)
})
@ -56,6 +76,12 @@ 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 {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
@ -74,10 +100,11 @@ func newSendReactCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
cmd.Flags().StringVar(&msgID, "id", "", "target message ID")
cmd.Flags().StringVar(&emoji, "reaction", "\U0001f44d", "reaction emoji (pass an empty string to remove)")
cmd.Flags().StringVar(&sender, "sender", "", "message sender JID (required for group messages)")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}
@ -98,3 +125,36 @@ 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)
}

217
cmd/wacli/send_sticker.go Normal file
View File

@ -0,0 +1,217 @@
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

@ -0,0 +1,116 @@
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

@ -0,0 +1,164 @@
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,7 +5,9 @@ import (
"testing"
"time"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/linkpreview"
"github.com/openclaw/wacli/internal/store"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
)
@ -19,6 +21,110 @@ func openSendTestDB(t *testing.T) *store.DB {
return db
}
type recipientTestApp struct {
db *store.DB
}
func (a recipientTestApp) DB() *store.DB {
return a.db
}
func TestResolveRecipientFallsBackToFormattedPhone(t *testing.T) {
db := openSendTestDB(t)
got, err := resolveRecipient(recipientTestApp{db: db}, "+1 (555) 123-4567", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "15551234567@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientUsesContactAlias(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertContact("15551234567@s.whatsapp.net", "15551234567", "Alice", "", "", ""); err != nil {
t.Fatalf("UpsertContact: %v", err)
}
if err := db.SetAlias("15551234567@s.whatsapp.net", "mom"); err != nil {
t.Fatalf("SetAlias: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "mom", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "15551234567@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientNumericGroupNameBeatsPhoneFallback(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertGroup("12345@g.us", "12345", "", time.Now()); err != nil {
t.Fatalf("UpsertGroup: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "12345", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "12345@g.us" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientNumericDirectChatDoesNotHijackPhone(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertChat("999@s.whatsapp.net", "dm", "1234567", time.Now()); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "1234567", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "1234567@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientAmbiguousRequiresPickWhenNonInteractive(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertContact("1@s.whatsapp.net", "1", "", "John", "", ""); err != nil {
t.Fatalf("UpsertContact 1: %v", err)
}
if err := db.UpsertContact("2@s.whatsapp.net", "2", "", "Johnny", "", ""); err != nil {
t.Fatalf("UpsertContact 2: %v", err)
}
_, err := resolveRecipient(recipientTestApp{db: db}, "John", recipientOptions{})
if err == nil || !strings.Contains(err.Error(), "use --pick N") {
t.Fatalf("expected --pick ambiguity, got %v", err)
}
if !strings.Contains(err.Error(), "1)") || !strings.Contains(err.Error(), "2)") {
t.Fatalf("expected numbered candidates, got %v", err)
}
}
func TestResolveRecipientPickSelectsCandidate(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertContact("1@s.whatsapp.net", "1", "", "John", "", ""); err != nil {
t.Fatalf("UpsertContact 1: %v", err)
}
if err := db.UpsertContact("2@s.whatsapp.net", "2", "", "Johnny", "", ""); err != nil {
t.Fatalf("UpsertContact 2: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "John", recipientOptions{pick: 2})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "2@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveReplySenderFromStore(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
@ -81,3 +187,223 @@ func TestResolveReplySenderAllowsDirectMessageWithoutSender(t *testing.T) {
t.Fatalf("expected empty sender for direct reply, got %q", got.String())
}
}
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}
got, err := buildReplyContextInfo(db, chat, "quoted", "+15551234567")
if err != nil {
t.Fatalf("buildReplyContextInfo: %v", err)
}
if got.GetStanzaID() != "quoted" {
t.Fatalf("stanza ID = %q, want quoted", got.GetStanzaID())
}
if got.GetParticipant() != "15551234567@s.whatsapp.net" {
t.Fatalf("participant = %q", got.GetParticipant())
}
got, err = buildReplyContextInfo(db, chat, "", "+15551234567")
if err != nil {
t.Fatalf("empty buildReplyContextInfo: %v", err)
}
if got != nil {
t.Fatalf("empty reply context = %v, want nil", got)
}
}
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) {
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)
}
}
func TestBuildTextMessageUsesPlainConversationWithoutReplyOrPreview(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
msg, plain, err := buildTextMessage(db, chat, "hello", "", "", nil, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if !plain {
t.Fatalf("plain = false, want true")
}
if msg != nil {
t.Fatalf("msg = %v, want nil", msg)
}
}
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)
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 := ext.GetContextInfo().GetMentionedJID()
if strings.Join(got, ",") != strings.Join(mentions, ",") {
t.Fatalf("mentioned JIDs = %v, want %v", got, mentions)
}
}
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"})
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
info := msg.GetExtendedTextMessage().GetContextInfo()
if info.GetStanzaID() != "quoted" {
t.Fatalf("stanza ID = %q, want quoted", info.GetStanzaID())
}
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)
}
}
func TestBuildTextMessageAttachesLinkPreview(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
preview := &linkpreview.Preview{
URL: "https://example.com/post",
Title: "Example",
Description: "Description",
Thumbnail: []byte("jpeg"),
}
msg, plain, err := buildTextMessage(db, chat, "see https://example.com/post", "", "", preview, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
ext := msg.GetExtendedTextMessage()
if ext.GetText() != "see https://example.com/post" {
t.Fatalf("text = %q", ext.GetText())
}
if ext.GetMatchedText() != preview.URL {
t.Fatalf("matched text = %q", ext.GetMatchedText())
}
if ext.GetTitle() != preview.Title {
t.Fatalf("title = %q", ext.GetTitle())
}
if ext.GetDescription() != preview.Description {
t.Fatalf("description = %q", ext.GetDescription())
}
if ext.GetPreviewType() != waProto.ExtendedTextMessage_IMAGE {
t.Fatalf("preview type = %v", ext.GetPreviewType())
}
if string(ext.GetJPEGThumbnail()) != "jpeg" {
t.Fatalf("thumbnail = %q", string(ext.GetJPEGThumbnail()))
}
}

121
cmd/wacli/send_voice_cmd.go Normal file
View File

@ -0,0 +1,121 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newSendVoiceCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var filePath string
var mimeOverride string
var replyTo string
var replyToSender string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "voice",
Short: "Send a voice note",
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: "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)
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 sendVoiceResult struct {
id string
meta map[string]string
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendVoiceResult, error) {
msgID, meta, err := sendFile(ctx, a, toJID, filePath, sendFileOptions{
mimeOverride: mimeOverride,
replyTo: replyTo,
replyToSender: replyToSender,
ptt: true,
})
if err != nil {
return sendVoiceResult{}, err
}
return sendVoiceResult{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 voice note 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 OGG/Opus audio file")
cmd.Flags().StringVar(&mimeOverride, "mime", "", "override detected mime type")
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

@ -6,29 +6,47 @@ 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) {
ctx, cancel := context.WithCancel(context.Background())
return signalContextWithEvents(nil)
}
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() {
<-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": "shutdown"})
} else {
fmt.Fprintln(os.Stderr, "\nShutting down (interrupt again to force quit)...")
}
ctxCancel()
<-sigCh
fmt.Fprintln(os.Stderr, "Force quit.")
os.Exit(1)
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)
}()
return ctx, func() {
signal.Stop(sigCh)
cancel()
if stopNotify != nil {
stopNotify()
}
ctxCancel()
}
}

73
cmd/wacli/signal_test.go Normal file
View File

@ -0,0 +1,73 @@
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

@ -0,0 +1,78 @@
package main
import (
"fmt"
"os"
"strconv"
"strings"
)
const (
envSyncMaxMessages = "WACLI_SYNC_MAX_MESSAGES"
envSyncMaxDBSize = "WACLI_SYNC_MAX_DB_SIZE"
)
type syncStorageLimitFlags struct {
maxMessages int64
maxMessagesSet bool
maxDBSize string
}
func resolveSyncStorageLimits(flags syncStorageLimitFlags) (int64, int64, error) {
maxMessages := flags.maxMessages
if !flags.maxMessagesSet && maxMessages <= 0 {
raw := strings.TrimSpace(os.Getenv(envSyncMaxMessages))
if raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
return 0, 0, fmt.Errorf("%s must be a non-negative integer", envSyncMaxMessages)
}
maxMessages = n
}
}
maxDBSizeRaw := strings.TrimSpace(flags.maxDBSize)
if maxDBSizeRaw == "" {
maxDBSizeRaw = strings.TrimSpace(os.Getenv(envSyncMaxDBSize))
}
maxDBSize, err := parseByteSize(maxDBSizeRaw)
if err != nil {
return 0, 0, err
}
return maxMessages, maxDBSize, nil
}
func parseByteSize(raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "0" {
return 0, nil
}
s := strings.ToUpper(raw)
multiplier := int64(1)
for _, suffix := range []struct {
s string
m int64
}{
{"KIB", 1024},
{"KB", 1024},
{"K", 1024},
{"MIB", 1024 * 1024},
{"MB", 1024 * 1024},
{"M", 1024 * 1024},
{"GIB", 1024 * 1024 * 1024},
{"GB", 1024 * 1024 * 1024},
{"G", 1024 * 1024 * 1024},
{"B", 1},
} {
if strings.HasSuffix(s, suffix.s) {
multiplier = suffix.m
s = strings.TrimSpace(strings.TrimSuffix(s, suffix.s))
break
}
}
value, err := strconv.ParseFloat(s, 64)
if err != nil || value < 0 {
return 0, fmt.Errorf("invalid byte size %q", raw)
}
return int64(value * float64(multiplier)), nil
}

View File

@ -0,0 +1,81 @@
package main
import "testing"
func TestParseByteSize(t *testing.T) {
tests := map[string]int64{
"": 0,
"0": 0,
"512": 512,
"1kb": 1024,
"2 MB": 2 * 1024 * 1024,
"1.5GB": int64(1.5 * 1024 * 1024 * 1024),
}
for raw, want := range tests {
got, err := parseByteSize(raw)
if err != nil {
t.Fatalf("parseByteSize(%q): %v", raw, err)
}
if got != want {
t.Fatalf("parseByteSize(%q) = %d, want %d", raw, got, want)
}
}
}
func TestParseByteSizeRejectsInvalid(t *testing.T) {
for _, raw := range []string{"abc", "-1", "1XB"} {
if _, err := parseByteSize(raw); err == nil {
t.Fatalf("parseByteSize(%q) expected error", raw)
}
}
}
func TestResolveSyncStorageLimitsReadsEnv(t *testing.T) {
t.Setenv(envSyncMaxMessages, "123")
t.Setenv(envSyncMaxDBSize, "2MB")
maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{})
if err != nil {
t.Fatalf("resolveSyncStorageLimits: %v", err)
}
if maxMessages != 123 {
t.Fatalf("maxMessages = %d, want 123", maxMessages)
}
if maxDBSize != 2*1024*1024 {
t.Fatalf("maxDBSize = %d, want 2MiB", maxDBSize)
}
}
func TestResolveSyncStorageLimitsFlagsOverrideEnv(t *testing.T) {
t.Setenv(envSyncMaxMessages, "123")
t.Setenv(envSyncMaxDBSize, "2MB")
maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{
maxMessages: 5,
maxDBSize: "4MB",
})
if err != nil {
t.Fatalf("resolveSyncStorageLimits: %v", err)
}
if maxMessages != 5 {
t.Fatalf("maxMessages = %d, want 5", maxMessages)
}
if maxDBSize != 4*1024*1024 {
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)
}
}

13
cmd/wacli/store.go Normal file
View File

@ -0,0 +1,13 @@
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
}

126
cmd/wacli/store_cleanup.go Normal file
View File

@ -0,0 +1,126 @@
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
}

68
cmd/wacli/store_stats.go Normal file
View File

@ -0,0 +1,68 @@
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,13 +1,14 @@
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 {
@ -18,6 +19,10 @@ 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{
Use: "sync",
@ -26,7 +31,15 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, stop := signalContext()
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))
defer stop()
a, lk, err := newApp(ctx, flags, true, false)
@ -48,14 +61,39 @@ 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
@ -79,5 +117,10 @@ 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
}

25
cmd/wacli/sync_test.go Normal file
View File

@ -0,0 +1,25 @@
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.Println(version)
fmt.Fprintln(cmd.OutOrStdout(), version)
},
}
}

20
cmd/wacli/version_test.go Normal file
View File

@ -0,0 +1,20 @@
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)
}
}

1
docs/CNAME Normal file
View File

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

57
docs/accounts.md Normal file
View File

@ -0,0 +1,57 @@
# 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.

38
docs/auth.md Normal file
View File

@ -0,0 +1,38 @@
# auth
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.
## Commands
```bash
wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE] [--events]
wacli auth status
wacli auth logout
wacli --account work auth status
```
## Notes
- 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
```bash
wacli auth
wacli auth --qr-format text
wacli auth --phone "+1 (234) 567-8900"
wacli auth --download-media
wacli auth status --json
wacli auth logout
```

37
docs/channels.md Normal file
View File

@ -0,0 +1,37 @@
# 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"
```

47
docs/chats.md Normal file
View File

@ -0,0 +1,47 @@
# chats
Read when: listing known chats, filtering chat state, archiving/pinning/muting/marking chats, or pruning stale local chat rows.
`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.
## Commands
```bash
wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]
wacli chats show --jid JID
wacli chats archive --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.
- `--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.
## 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
```

25
docs/completion.md Normal file
View File

@ -0,0 +1,25 @@
# completion
Read when: installing shell completions.
`wacli completion` emits shell completion scripts generated by Cobra.
## Commands
```bash
wacli completion bash [--no-descriptions]
wacli completion zsh [--no-descriptions]
wacli completion fish [--no-descriptions]
wacli completion powershell [--no-descriptions]
```
## Examples
```bash
source <(wacli completion bash)
source <(wacli completion zsh)
wacli completion fish | source
wacli completion powershell | Out-String | Invoke-Expression
```
For persistent installation paths, run the specific command with `--help`; Cobra prints shell-specific setup instructions.

View File

@ -0,0 +1,122 @@
# 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.

41
docs/contacts.md Normal file
View File

@ -0,0 +1,41 @@
# contacts
Read when: finding synced contacts, importing macOS Contacts names, 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.
## Commands
```bash
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
wacli contacts tags rm --jid JID --tag TAG
```
## Notes
- `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.
- Tags are local grouping metadata for scripts and future workflows.
## Examples
```bash
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
```

26
docs/docs.md Normal file
View File

@ -0,0 +1,26 @@
# 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)"
```

26
docs/doctor.md Normal file
View File

@ -0,0 +1,26 @@
# doctor
Read when: diagnosing store layout, auth state, FTS/search support, locks, or optional live connectivity.
`wacli doctor` reports local health information and can optionally connect to WhatsApp.
## Command
```bash
wacli doctor [--connect]
```
## Notes
- Without `--connect`, doctor avoids live WhatsApp connection.
- `--connect` requires auth and the store lock.
- Output includes local store counts, auth identity when available, FTS/search state, and lock details.
- Use `--json` for machine-readable diagnostics.
## Examples
```bash
wacli doctor
wacli doctor --json
wacli doctor --connect
```

51
docs/groups.md Normal file
View File

@ -0,0 +1,51 @@
# groups
Read when: listing, refreshing, inspecting, renaming, joining, leaving, inviting, pruning stale local group rows, or managing group participants.
`wacli groups` combines local group rows with live WhatsApp operations. Commands that mutate WhatsApp require writable mode.
## Commands
```bash
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 --jid GROUP_JID --user PHONE_OR_JID [--user ...]
wacli groups participants remove --jid GROUP_JID --user PHONE_OR_JID [--user ...]
wacli groups participants promote --jid GROUP_JID --user PHONE_OR_JID [--user ...]
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.
- `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.
## Examples
```bash
wacli groups list --query family
wacli groups refresh
wacli groups info --jid 123456789@g.us
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
```

22
docs/help.md Normal file
View File

@ -0,0 +1,22 @@
# help
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
```bash
wacli help [command]
wacli [command] --help
```
## Examples
```bash
wacli help send
wacli send text --help
wacli docs
wacli groups participants add --help
```

39
docs/history.md Normal file
View File

@ -0,0 +1,39 @@
# history
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.
## Commands
```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]
```
## 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.
- `--requests` defaults to 1 and must be at most 100.
- 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
```

48
docs/index.md Normal file
View File

@ -0,0 +1,48 @@
---
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).

90
docs/install.md Normal file
View File

@ -0,0 +1,90 @@
---
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.

137
docs/integrations.md Normal file
View File

@ -0,0 +1,137 @@
# 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.

26
docs/media.md Normal file
View File

@ -0,0 +1,26 @@
# media
Read when: downloading media from a synced message.
`wacli media` downloads media referenced by messages already stored in `wacli.db`.
## Command
```bash
wacli media download --chat JID --id MSG_ID [--output PATH]
```
## Notes
- The target message must already be synced.
- Media downloads are capped at 100 MiB.
- `--output` may be a file path or directory.
- If `--output` is omitted, media is written under the store media directory.
## Examples
```bash
wacli media download --chat 1234567890@s.whatsapp.net --id ABC123
wacli media download --chat 1234567890@s.whatsapp.net --id ABC123 --output ./downloads
wacli media download --chat 1234567890@s.whatsapp.net --id ABC123 --output ./photo.jpg
```

68
docs/messages.md Normal file
View File

@ -0,0 +1,68 @@
# messages
Read when: listing, searching, exporting, 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.
## 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 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
- 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.
## Examples
```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
```

65
docs/overview.md Normal file
View File

@ -0,0 +1,65 @@
# wacli overview
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`.
## Store and output
- Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere.
- Existing Linux `~/.wacli` stores are reused when no XDG store exists.
- Override the store with `--store DIR` or `WACLI_STORE_DIR`.
- Human-readable tables are the default.
- Use `--json` for scriptable output.
- Use `--full` to avoid table truncation.
- 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.
- [messages](messages.md) - list, search, show, and contextualize stored messages.
- [send](send.md) - send text, files, stickers, 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.
- [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.
- [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
```bash
wacli auth
wacli sync --follow
wacli messages search "meeting"
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`.
`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.
## History limits
WhatsApp Web history is best-effort. `wacli sync` stores events WhatsApp provides, and `wacli history backfill` can ask the primary phone for older messages per chat. It cannot guarantee a full account export.

27
docs/presence.md Normal file
View File

@ -0,0 +1,27 @@
# presence
Read when: sending typing, recording, or paused chat indicators.
`wacli presence` sends ephemeral WhatsApp chat-state updates. It does not send a message.
## Commands
```bash
wacli presence typing --to PHONE_OR_JID [--media audio]
wacli presence paused --to PHONE_OR_JID
```
## Notes
- `typing` sends a composing indicator.
- `typing --media audio` sends a recording indicator.
- `paused` clears the composing indicator.
- Recipients accept phone numbers with common formatting or JIDs.
## Examples
```bash
wacli presence typing --to 1234567890
wacli presence typing --to 1234567890 --media audio
wacli presence paused --to 1234567890
```

26
docs/profile.md Normal file
View File

@ -0,0 +1,26 @@
# profile
Read when: setting the profile picture for the authenticated WhatsApp account.
`wacli profile` manages account-level WhatsApp profile settings for the linked account.
## Command
```bash
wacli profile set-picture <image>
```
## Notes
- `set-picture` requires authentication, a live connection, and writable mode.
- Input can be JPEG or PNG.
- PNG transparency is flattened onto a white background before upload.
- Images larger than 640 px on either side are resized before upload.
- The command prints the picture ID returned by WhatsApp; use `--json` for machine-readable output.
## Examples
```bash
wacli profile set-picture ./avatar.jpg
wacli profile set-picture ./avatar.png --json
```

131
docs/quickstart.md Normal file
View File

@ -0,0 +1,131 @@
---
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

@ -1,5 +1,7 @@
# Release
Read when: cutting a release, debugging release artifacts, or updating the Homebrew tap handoff.
## GitHub Release Artifacts
`wacli` uses GoReleaser (`.goreleaser.yaml` for macOS, `.goreleaser-linux-windows.yaml` for linux/windows) and the GitHub Actions workflow `.github/workflows/release.yml`.
@ -22,12 +24,18 @@ Other artifacts:
- `wacli-linux-<arch>.tar.gz`
- `wacli-windows-<arch>.zip`
All release builds must use `CGO_ENABLED=1`. `wacli` depends on `go-sqlite3`,
which provides only a runtime stub when cgo is disabled; the CLI build now fails
early if someone tries to compile it with `CGO_ENABLED=0`.
## Homebrew Tap
The tap formula lives in `../homebrew-tap/Formula/wacli.rb`.
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`.
Once a release exists, update the tap formula by running the `Update Formula` workflow in the tap repo with:
Optional repository secret:
- `formula`: `wacli`
- `tag`: `vX.Y.Z`
- `repository`: `steipete/wacli`
- `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`.

68
docs/send.md Normal file
View File

@ -0,0 +1,68 @@
# send
Read when: sending text, files, stickers, quoted replies, or reactions.
`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. `Sent to ...` and JSON `sent: true` mean WhatsApp accepted the send request and returned a message ID; they do not confirm recipient delivery. After a successful send, wacli keeps the connection alive briefly so whatsmeow can handle retry receipts from devices that could not decrypt the first copy. Repeated send commands within 5 seconds print a stderr warning so tight loops make WhatsApp rate-limit/account-risk visible.
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.
## 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 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.
- 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`.
## Replies and reactions
- `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`.
- `--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.
## Files
- File uploads are capped at 100 MiB.
- 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.
## Examples
```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 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 "❤️"
```

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