Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f1861547 | ||
|
|
42ce6831bf | ||
|
|
2a9193e91c | ||
|
|
a0166a88ea | ||
|
|
3677b5b3cd | ||
|
|
cdddf110ef | ||
|
|
f7cbace0e3 | ||
|
|
1f7c6fa19a | ||
|
|
0e1a4d08f8 | ||
|
|
3909781d7a | ||
|
|
30150518f2 | ||
|
|
5a6fce1e41 | ||
|
|
4102a04e38 | ||
|
|
76d2414433 | ||
|
|
a5ed16b922 | ||
|
|
b9ba3b371d | ||
|
|
e3c4ea61e6 | ||
|
|
c68d285400 | ||
|
|
0796db5ff9 | ||
|
|
b1dad5b156 | ||
|
|
55a7955c56 | ||
|
|
6f3ba57935 | ||
|
|
4949423af4 | ||
|
|
33aa0ae767 | ||
|
|
b64cf3c049 | ||
|
|
2433188017 | ||
|
|
90bb4a3b8c | ||
|
|
403fda0fe7 | ||
|
|
4b84b90a66 | ||
|
|
af671e16a9 | ||
|
|
c912668b21 | ||
|
|
a2c78030f6 | ||
|
|
d973482dea | ||
|
|
2c9fe08dd8 | ||
|
|
9856075b49 | ||
|
|
4aa3ef3afc | ||
|
|
de84bd2a68 | ||
|
|
31504a8110 | ||
|
|
b0b7786bb8 | ||
|
|
4f45138ad2 | ||
|
|
da9134e6ae | ||
|
|
d1b4bd7527 | ||
|
|
f1cb39fe8a | ||
|
|
cd311e86c4 | ||
|
|
9fff67cd3b | ||
|
|
b974645fdb | ||
|
|
0826cd4aa1 | ||
|
|
108da989f7 | ||
|
|
03e53644f9 | ||
|
|
1b464909ca | ||
|
|
eaa7a1b979 | ||
|
|
b24bfc1315 | ||
|
|
40330f623b | ||
|
|
b4ca2e35b0 | ||
|
|
3031a34ff2 | ||
|
|
09b2efbcaa | ||
|
|
d0752dbc2c | ||
|
|
ed4df0bf3a | ||
|
|
ad1f47740b | ||
|
|
352caa88d8 | ||
|
|
d410e9f76e | ||
|
|
d08620abf9 | ||
|
|
2f294e2bb6 | ||
|
|
6199cff6cb | ||
|
|
56f9c26746 | ||
|
|
83d89da341 | ||
|
|
515cd43b9f | ||
|
|
1e8342fbe7 | ||
|
|
6fac72ee4d | ||
|
|
66c6d41ff6 | ||
|
|
5613c07f79 | ||
|
|
3a633d5712 | ||
|
|
3ce15af17e | ||
|
|
70e5a20cff | ||
|
|
e2bebf6eed | ||
|
|
eabf8d6eec | ||
|
|
6400f5d4a7 | ||
|
|
787cfd599c | ||
|
|
87095bd48a | ||
|
|
02f98c3ed0 | ||
|
|
4481fc8d61 | ||
|
|
7533e4bef9 | ||
|
|
fca5b96138 | ||
|
|
78794f9757 | ||
|
|
9568cbfb42 | ||
|
|
3077e626a3 | ||
|
|
1fb01707e5 | ||
|
|
f8ce9eedd1 | ||
|
|
7c42182505 | ||
|
|
ecbf902e3a | ||
|
|
d9fb426867 | ||
|
|
866e1b53cf | ||
|
|
0dc5e2e546 | ||
|
|
b122dfefc9 | ||
|
|
517e008763 | ||
|
|
a2a693d9b4 | ||
|
|
8a0e13bffc | ||
|
|
91d240a658 | ||
|
|
95f2136887 | ||
|
|
fa1c800e72 | ||
|
|
653533928e | ||
|
|
21063d810a | ||
|
|
f6a94547d6 | ||
|
|
86a43def24 | ||
|
|
ec608225f9 | ||
|
|
318421d415 | ||
|
|
54d44b34fc | ||
|
|
aa00e0a58e | ||
|
|
782c29078c | ||
|
|
120805d9d9 | ||
|
|
a3a620ac73 | ||
|
|
12e8e1a934 | ||
|
|
894bc5d1ac | ||
|
|
5c24523978 | ||
|
|
4517528d57 | ||
|
|
f28583d44c | ||
|
|
cd98a9d46d | ||
|
|
6076deba26 | ||
|
|
b2935c1eac | ||
|
|
999461ce0e | ||
|
|
716e7a8496 | ||
|
|
dffcda4481 | ||
|
|
372e1fc257 | ||
|
|
0ce920839e | ||
|
|
e73dba0ecb | ||
|
|
3c8de4d9b1 | ||
|
|
bd92cc49a7 | ||
|
|
fae308a2b9 | ||
|
|
a03b0e9dda | ||
|
|
668d7e5762 | ||
|
|
5f897ee277 | ||
|
|
ff6334885f | ||
|
|
3e17ff0ae2 | ||
|
|
ee3f28bb12 | ||
|
|
3dfd4f98a7 | ||
|
|
65c9be4e17 | ||
|
|
a022c49399 | ||
|
|
259bcf22a8 | ||
|
|
a59b960d1c | ||
|
|
1b529708c7 | ||
|
|
42eb6260d7 | ||
|
|
f77044b67a | ||
|
|
2c09f129c3 | ||
|
|
ea4ca18438 | ||
|
|
4eaad5f85f | ||
|
|
70f2afd470 | ||
|
|
eb562ce875 | ||
|
|
666f77caed | ||
|
|
a684ff03ae | ||
|
|
f02ce5d301 | ||
|
|
2fc5abeb11 | ||
|
|
ffddc91f92 | ||
|
|
9ff22a5ecf | ||
|
|
ff2d74de9b | ||
|
|
7a2b323098 | ||
|
|
519cd53eb4 | ||
|
|
77c38d3a19 | ||
|
|
59a2c6cdc6 | ||
|
|
f1914c3f5b | ||
|
|
16947198bc | ||
|
|
963f8dee1c | ||
|
|
19a9f43350 | ||
|
|
5ca42035de | ||
|
|
18a10bd64f | ||
|
|
cc6a7a3cd9 | ||
|
|
39e986cbf5 | ||
|
|
2e69de90ea |
120
.agents/skills/wacli/SKILL.md
Normal file
120
.agents/skills/wacli/SKILL.md
Normal 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.
|
||||
54
.github/actions/setup-ci-env/action.yml
vendored
Normal file
54
.github/actions/setup-ci-env/action.yml
vendored
Normal file
@ -0,0 +1,54 @@
|
||||
name: Setup CI Environment
|
||||
description: Shared toolchain/bootstrap for CI and release jobs.
|
||||
|
||||
inputs:
|
||||
go-version-file:
|
||||
description: Path to go.mod/go.work file.
|
||||
required: false
|
||||
default: go.mod
|
||||
setup-node:
|
||||
description: Whether to install Node.js.
|
||||
required: false
|
||||
default: "false"
|
||||
node-version:
|
||||
description: Node.js version to install when setup-node is true.
|
||||
required: false
|
||||
default: "24"
|
||||
setup-pnpm:
|
||||
description: Whether to enable corepack and activate pnpm.
|
||||
required: false
|
||||
default: "false"
|
||||
apt-packages:
|
||||
description: Space-separated apt packages to install (ubuntu only).
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
if: ${{ inputs.setup-node == 'true' }}
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: ${{ inputs.go-version-file }}
|
||||
cache: true
|
||||
|
||||
- name: Setup pnpm
|
||||
if: ${{ inputs.setup-pnpm == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm --version
|
||||
|
||||
- name: Install apt packages
|
||||
if: ${{ inputs.apt-packages != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ${{ inputs.apt-packages }}
|
||||
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
@ -8,34 +8,23 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- name: Setup CI Environment
|
||||
uses: ./.github/actions/setup-ci-env
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Setup pnpm
|
||||
run: |
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.23.0 --activate
|
||||
pnpm --version
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends build-essential
|
||||
setup-node: "true"
|
||||
node-version: "24"
|
||||
setup-pnpm: "true"
|
||||
apt-packages: "build-essential"
|
||||
|
||||
- name: pnpm format:check
|
||||
run: pnpm -s format:check
|
||||
@ -52,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
55
.github/workflows/pages.yml
vendored
Normal 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
|
||||
107
.github/workflows/release.yml
vendored
107
.github/workflows/release.yml
vendored
@ -14,82 +14,147 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
goreleaser-darwin:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: ./.github/actions/setup-ci-env
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Stash GoReleaser config
|
||||
run: cp .goreleaser.yaml /tmp/.goreleaser.yaml
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: ./.github/actions/setup-ci-env
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
gcc-aarch64-linux-gnu \
|
||||
mingw-w64
|
||||
apt-packages: "build-essential gcc-aarch64-linux-gnu libc6-dev-arm64-cross mingw-w64"
|
||||
|
||||
- name: Stash GoReleaser config
|
||||
run: cp .goreleaser-linux-windows.yaml /tmp/.goreleaser-linux-windows.yaml
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
56
AGENTS.md
Normal file
56
AGENTS.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure
|
||||
- `cmd/wacli/`: CLI command wiring (auth, sync, messages, send, media, contacts, chats, groups, history, presence, doctor).
|
||||
- `internal/app/`: core app, whatsmeow event handling, backfill, sync idle logic.
|
||||
- `internal/store/`: SQLite schema, migrations, FTS5 search, chats/contacts/groups/messages/media queries.
|
||||
- `internal/wa/`: whatsmeow client wrapper, JID resolution, message parsing (text, business, media, context).
|
||||
- `internal/config/`: store-dir resolution (`WACLI_STORE_DIR` env → XDG state dir on Linux → `~/.wacli`).
|
||||
- `internal/lock/`: platform-specific LOCK-file locking; acquired before all write commands.
|
||||
- `internal/out/`: JSON + table output helpers; all human text goes through here.
|
||||
- `internal/fsutil/`: enforces 0700/0600 owner-only permissions on store files.
|
||||
- `internal/pathutil/`: sanitises StorePath; rejects `?` and `#` to prevent URI injection.
|
||||
- `internal/sqliteutil/`: sqlite file helpers.
|
||||
- Tests sit next to the code they cover (`*_test.go`).
|
||||
|
||||
## Key Architectural Facts
|
||||
- **Two databases**: `session.db` (managed by whatsmeow) + `wacli.db` (app data, FTS5 search).
|
||||
- **FTS5 table** is a separate trigger-synced table in `wacli.db`; requires `-tags sqlite_fts5` at build time.
|
||||
- **Store lock**: a `LOCK` file in the store dir is acquired before any write operation; `--lock-wait` controls the wait timeout.
|
||||
- **Read-only mode**: `--read-only` flag or `WACLI_READONLY=1` env; write commands exit immediately with a clear error.
|
||||
- **Send retry**: bounded 45 s attempt timeout; retries once after reconnect for stale-session / usync-timeout errors.
|
||||
- **Store path precedence**: `--store` flag → `WACLI_STORE_DIR` env → XDG `~/.local/state/wacli` on Linux (legacy `~/.wacli` fallback) → `~/.wacli` elsewhere.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- Build: `pnpm build` — compiles with `-tags sqlite_fts5` and `CGO_CFLAGS=-Wno-error=missing-braces` (required for GCC 15+).
|
||||
- Run: `pnpm wacli -- <args>` — rebuilds then runs.
|
||||
- Test: `pnpm test` — runs `go test ./...` (plain), `go test -tags sqlite_fts5 ./...` (FTS), and a Windows lock cross-compile check.
|
||||
- Lint: `pnpm lint` — `go vet ./...`.
|
||||
- Format fix: `pnpm format` — `gofmt -w .`.
|
||||
- Format check: `pnpm format:check` — fails if any file would change.
|
||||
- **Full gate** (must pass before every PR): `pnpm format:check && pnpm lint && pnpm test && pnpm build && git diff --check`.
|
||||
|
||||
## Coding Style
|
||||
- Standard `gofmt` formatting; run `pnpm format` before committing.
|
||||
- Output: send structured data to stdout (`--json` / table); send human hints, progress, and errors to stderr via `internal/out`.
|
||||
- Prefer explicit error returns over panics; write short, early-return functions.
|
||||
- No build-time CGO beyond sqlite3; keep the dependency tree minimal.
|
||||
|
||||
## Testing Guidelines
|
||||
- Every bug fix should ship with a regression test.
|
||||
- FTS-sensitive tests must run under `-tags sqlite_fts5`; non-FTS path tests must also pass without the tag.
|
||||
- Use `fake_wa_test.go` / table-driven tests for whatsmeow interaction; avoid hitting real WhatsApp in unit tests.
|
||||
- Integration tests that need a live account are opt-in and not part of the standard gate.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Follow Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `security:`, `ci:` with an imperative summary.
|
||||
- Keep commits focused; avoid bundling unrelated changes.
|
||||
- PRs should state: what changed, why, how it was tested, and any new flags or env vars.
|
||||
- Run the full gate locally before opening a PR; CI runs the same commands.
|
||||
- New contributors: add `Co-authored-by:` trailers when building on their work.
|
||||
|
||||
## Agent Notes
|
||||
- This repo uses `AGENTS.md` as its agent-instruction source; `CLAUDE.md` is explicitly ignored.
|
||||
- For agent-safe execution, pass `--read-only` (or set `WACLI_READONLY=1`) to prevent writes.
|
||||
- Prefer `--json` output for machine-readable parsing.
|
||||
- Do not add dependencies or change build tooling without confirming with the maintainer.
|
||||
201
CHANGELOG.md
201
CHANGELOG.md
@ -1,10 +1,194 @@
|
||||
# Changelog
|
||||
|
||||
## 0.3.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
|
||||
|
||||
- TBD.
|
||||
- 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)
|
||||
- Sync: apply bounded backpressure to media download enqueueing instead of spawning unbounded overflow goroutines. (#121 — thanks @jyothepro)
|
||||
- Windows: split store locking by platform so the lock package compiles on Windows. (#188 — thanks @dinakars777)
|
||||
|
||||
### 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)
|
||||
- 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`.
|
||||
|
||||
## 0.6.0 - 2026-04-14
|
||||
|
||||
### Security
|
||||
|
||||
- Search: sanitize FTS5 user queries and escape LIKE wildcards to avoid query-syntax injection.
|
||||
- Store: reject SQLite URI path injection via `?` and `#`, guard empty table names, and strip null/control chars from sanitized paths.
|
||||
- Sync: recover panics in event handlers and media workers instead of crashing the process.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sync: bound reconnect duration so long-running commands do not hold the store lock forever.
|
||||
- CLI: force exit on a second SIGINT during long-running commands.
|
||||
|
||||
### Added
|
||||
|
||||
- Store: add `WACLI_STORE_DIR` to configure the default store directory.
|
||||
|
||||
### Chore
|
||||
|
||||
- Dependencies: bump `filippo.io/edwards25519`.
|
||||
|
||||
## 0.5.0 - 2026-04-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- WhatsApp connectivity: update `whatsmeow` for the current WhatsApp protocol and fix `405 (Client Outdated)` failures.
|
||||
|
||||
### Changed
|
||||
|
||||
- Internal architecture: split store and groups command logic into focused modules for cleaner maintenance and safer follow-up changes.
|
||||
- Dependencies: bump core Go modules including `whatsmeow`, `go-sqlite3`, and `x/*` runtime libs.
|
||||
|
||||
### Build
|
||||
|
||||
- CI: extract a shared setup action and reuse it across CI and release workflows.
|
||||
- Release: install arm64 libc headers in release workflow to improve ARM build reliability.
|
||||
|
||||
### Docs
|
||||
|
||||
- README: update usage/docs for the 0.2.0 release baseline.
|
||||
- Changelog: sync unreleased notes with all commits since `v0.2.0`.
|
||||
|
||||
### Chore
|
||||
|
||||
- Version: bump CLI version string to `0.5.0`.
|
||||
|
||||
## 0.2.0 - 2026-01-23
|
||||
|
||||
@ -23,7 +207,18 @@
|
||||
|
||||
- Release: multi-OS GoReleaser configs and workflow for macOS, linux, and windows artifacts.
|
||||
|
||||
## 0.1.0 - 2026-01-01
|
||||
### Docs
|
||||
|
||||
- Install: clarify Homebrew vs local build paths.
|
||||
- Changelog: introduce project changelog and prep `0.2.0` release notes.
|
||||
|
||||
## 0.1.1 - 2025-12-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Release: fix workflow for CGO builds.
|
||||
|
||||
## 0.1.0 - 2025-12-12
|
||||
|
||||
### Added
|
||||
|
||||
|
||||
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
\g<1>2026 Peter Steinberger
|
||||
Copyright (c) 2026 Peter Steinberger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
164
README.md
164
README.md
@ -1,115 +1,139 @@
|
||||
# 🗃️ wacli — WhatsApp CLI: sync, search, send.
|
||||
# 🗃️ 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 messages
|
||||
- Contact + group management
|
||||
> 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 the full 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.
|
||||
|
||||
## Install / Build
|
||||
## Install
|
||||
|
||||
Choose **one** of the following options.
|
||||
If you install via Homebrew, you can skip the local build step.
|
||||
### Homebrew (recommended)
|
||||
|
||||
### Option A: Install via Homebrew (tap)
|
||||
```bash
|
||||
brew install steipete/tap/wacli
|
||||
```
|
||||
|
||||
- `brew install steipete/tap/wacli`
|
||||
If a Linux install reports `Binary was compiled with 'CGO_ENABLED=0'`, run `brew update && brew reinstall steipete/tap/wacli`.
|
||||
|
||||
### Option B: Build locally
|
||||
### Build from source
|
||||
|
||||
- `go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli`
|
||||
`wacli` uses `go-sqlite3`, so cgo + a C compiler are required.
|
||||
|
||||
Run (local build only):
|
||||
- macOS: Xcode Command Line Tools.
|
||||
- Debian/Ubuntu: `sudo apt install build-essential`.
|
||||
|
||||
- `./dist/wacli --help`
|
||||
```bash
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
|
||||
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
|
||||
```
|
||||
|
||||
For local development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/wacli.git
|
||||
cd wacli
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
|
||||
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
|
||||
./dist/wacli --help
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
Default store directory is `~/.wacli` (override with `--store DIR`).
|
||||
|
||||
```bash
|
||||
# 1) Authenticate (shows QR), then bootstrap sync
|
||||
pnpm wacli auth
|
||||
# or: ./dist/wacli auth (after pnpm build)
|
||||
# 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"
|
||||
|
||||
# Backfill older messages for a chat (best-effort; requires your primary device online)
|
||||
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
|
||||
|
||||
# Download media for a message (after syncing)
|
||||
./wacli media download --chat 1234567890@s.whatsapp.net --id <message-id>
|
||||
|
||||
# Send a message
|
||||
pnpm wacli send text --to 1234567890 --message "hello"
|
||||
|
||||
# Send a file
|
||||
./wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
|
||||
|
||||
# List groups and manage participants
|
||||
pnpm wacli groups list
|
||||
pnpm wacli groups rename --jid 123456789@g.us --name "New name"
|
||||
# 5. Diagnostics
|
||||
wacli doctor
|
||||
```
|
||||
|
||||
## Prior Art / Credit
|
||||
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.
|
||||
|
||||
This project is heavily inspired by (and learns from) the excellent `whatsapp-cli` by Vicente Reig:
|
||||
More recipes — replies, mentions, stickers, voice, reactions, channels, history backfill, chat management — live in the [docs](https://wacli.sh).
|
||||
|
||||
- [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli)
|
||||
## Documentation
|
||||
|
||||
## High-level UX
|
||||
| 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) |
|
||||
|
||||
- `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.
|
||||
## Configuration
|
||||
|
||||
## Storage
|
||||
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.
|
||||
|
||||
Defaults to `~/.wacli` (override with `--store DIR`).
|
||||
**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
|
||||
|
||||
**Environment overrides:**
|
||||
|
||||
| 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.
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
## Credits
|
||||
|
||||
Heavily inspired by [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli) by Vicente Reig.
|
||||
|
||||
## Maintainers
|
||||
|
||||
- Created by [@steipete](https://github.com/steipete)
|
||||
- Currently maintained by [@dinakars777](https://github.com/dinakars777)
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE`.
|
||||
See [`LICENSE`](LICENSE).
|
||||
|
||||
289
cmd/wacli/accounts.go
Normal file
289
cmd/wacli/accounts.go
Normal 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
117
cmd/wacli/accounts_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -3,54 +3,40 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"strings"
|
||||
"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 {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
mode := appPkg.SyncModeBootstrap
|
||||
if follow {
|
||||
mode = appPkg.SyncModeFollow
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr, "Starting authentication…")
|
||||
res, err := a.Sync(ctx, appPkg.SyncOptions{
|
||||
Mode: mode,
|
||||
AllowQR: true,
|
||||
DownloadMedia: downloadMedia,
|
||||
RefreshContacts: true,
|
||||
RefreshGroups: true,
|
||||
IdleExit: idleExit,
|
||||
OnQRCode: func(code string) {
|
||||
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
|
||||
}
|
||||
@ -67,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))
|
||||
@ -77,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",
|
||||
@ -95,27 +213,60 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
authed := a.WA().IsAuthed()
|
||||
var linkedJID string
|
||||
if authed {
|
||||
linkedJID = a.WA().LinkedJID()
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{
|
||||
"authenticated": authed,
|
||||
})
|
||||
}
|
||||
if authed {
|
||||
fmt.Fprintln(os.Stdout, "Authenticated.")
|
||||
} else {
|
||||
fmt.Fprintln(os.Stdout, "Not authenticated. Run `wacli auth`.")
|
||||
return out.WriteJSON(os.Stdout, authStatusPayload(authed, linkedJID))
|
||||
}
|
||||
writeAuthStatus(os.Stdout, authed, linkedJID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func authStatusPayload(authed bool, linkedJID string) map[string]any {
|
||||
data := map[string]any{"authenticated": authed}
|
||||
if !authed || linkedJID == "" {
|
||||
return data
|
||||
}
|
||||
data["linked_jid"] = linkedJID
|
||||
if phone := phoneFromLinkedJID(linkedJID); phone != "" {
|
||||
data["phone"] = phone
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func writeAuthStatus(w io.Writer, authed bool, linkedJID string) {
|
||||
if !authed {
|
||||
fmt.Fprintln(w, "Not authenticated. Run `wacli auth`.")
|
||||
return
|
||||
}
|
||||
if linkedJID != "" {
|
||||
fmt.Fprintf(w, "Authenticated as %s\n", linkedJID)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, "Authenticated.")
|
||||
}
|
||||
|
||||
func phoneFromLinkedJID(linkedJID string) string {
|
||||
phone, _, ok := strings.Cut(linkedJID, "@")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return phone
|
||||
}
|
||||
|
||||
func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "logout",
|
||||
Short: "Logout (invalidate session)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
|
||||
159
cmd/wacli/auth_test.go
Normal file
159
cmd/wacli/auth_test.go
Normal file
@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthStatusPayloadIncludesLinkedJID(t *testing.T) {
|
||||
got := authStatusPayload(true, "1234567890@s.whatsapp.net")
|
||||
if got["authenticated"] != true {
|
||||
t.Fatalf("authenticated = %v", got["authenticated"])
|
||||
}
|
||||
if got["linked_jid"] != "1234567890@s.whatsapp.net" {
|
||||
t.Fatalf("linked_jid = %v", got["linked_jid"])
|
||||
}
|
||||
if got["phone"] != "1234567890" {
|
||||
t.Fatalf("phone = %v", got["phone"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusPayloadOmitsLinkedJIDWhenUnauthed(t *testing.T) {
|
||||
got := authStatusPayload(false, "1234567890@s.whatsapp.net")
|
||||
if _, ok := got["linked_jid"]; ok {
|
||||
t.Fatalf("linked_jid should be omitted: %+v", got)
|
||||
}
|
||||
if _, ok := got["phone"]; ok {
|
||||
t.Fatalf("phone should be omitted: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAuthStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authed bool
|
||||
linkedJID string
|
||||
want string
|
||||
}{
|
||||
{name: "linked", authed: true, linkedJID: "1234567890@s.whatsapp.net", want: "Authenticated as 1234567890@s.whatsapp.net"},
|
||||
{name: "authed no jid", authed: true, want: "Authenticated."},
|
||||
{name: "not authed", want: "Not authenticated. Run `wacli auth`."},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
writeAuthStatus(&b, tc.authed, tc.linkedJID)
|
||||
if got := strings.TrimSpace(b.String()); got != tc.want {
|
||||
t.Fatalf("status = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if got := phoneFromLinkedJID("not-a-jid"); got != "" {
|
||||
t.Fatalf("phoneFromLinkedJID invalid = %q", got)
|
||||
}
|
||||
}
|
||||
5
cmd/wacli/cgo_required.go
Normal file
5
cmd/wacli/cgo_required.go
Normal 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
301
cmd/wacli/channels.go
Normal 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
|
||||
}
|
||||
50
cmd/wacli/channels_test.go
Normal file
50
cmd/wacli/channels_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -2,32 +2,65 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"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()
|
||||
|
||||
@ -37,22 +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)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST")
|
||||
fullOutput := fullTableOutput(flags.fullOutput)
|
||||
w := newTableWriter(os.Stdout)
|
||||
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, truncate(name, 28), 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
166
cmd/wacli/chats_cleanup.go
Normal 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
210
cmd/wacli/chats_state.go
Normal 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
97
cmd/wacli/chats_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,9 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newContactsCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -19,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
|
||||
@ -49,13 +49,14 @@ func newContactsSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
return out.WriteJSON(os.Stdout, cs)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fullOutput := fullTableOutput(flags.fullOutput)
|
||||
w := newTableWriter(os.Stdout)
|
||||
fmt.Fprintln(w, "ALIAS\tNAME\tPHONE\tJID")
|
||||
for _, c := range cs {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
truncate(c.Alias, 18),
|
||||
truncate(c.Name, 24),
|
||||
truncate(c.Phone, 14),
|
||||
tableCell(c.Alias, 18, fullOutput),
|
||||
tableCell(c.Name, 24, fullOutput),
|
||||
tableCell(c.Phone, 14, fullOutput),
|
||||
c.JID,
|
||||
)
|
||||
}
|
||||
@ -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, ", "))
|
||||
}
|
||||
@ -119,6 +123,9 @@ func newContactsRefreshCmd(flags *rootFlags) *cobra.Command {
|
||||
Use: "refresh",
|
||||
Short: "Import contacts from whatsmeow store into local DB",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
@ -138,6 +145,7 @@ func newContactsRefreshCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
var count int
|
||||
for jid, info := range cs {
|
||||
jid = canonicalCLIJID(jid)
|
||||
_ = a.DB().UpsertContact(
|
||||
jid.String(),
|
||||
jid.User,
|
||||
@ -173,6 +181,9 @@ func newContactsAliasCmd(flags *rootFlags) *cobra.Command {
|
||||
if strings.TrimSpace(jid) == "" || strings.TrimSpace(alias) == "" {
|
||||
return fmt.Errorf("--jid and --alias are required")
|
||||
}
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
a, lk, err := newApp(ctx, flags, false, false)
|
||||
@ -198,6 +209,9 @@ func newContactsAliasCmd(flags *rootFlags) *cobra.Command {
|
||||
if strings.TrimSpace(jid) == "" {
|
||||
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, false, false)
|
||||
@ -235,6 +249,9 @@ func newContactsTagsCmd(flags *rootFlags) *cobra.Command {
|
||||
if strings.TrimSpace(jid) == "" || strings.TrimSpace(tag) == "" {
|
||||
return fmt.Errorf("--jid and --tag are required")
|
||||
}
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
a, lk, err := newApp(ctx, flags, false, false)
|
||||
@ -261,6 +278,9 @@ func newContactsTagsCmd(flags *rootFlags) *cobra.Command {
|
||||
if strings.TrimSpace(jid) == "" || strings.TrimSpace(tag) == "" {
|
||||
return fmt.Errorf("--jid and --tag are required")
|
||||
}
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
a, lk, err := newApp(ctx, flags, false, false)
|
||||
|
||||
182
cmd/wacli/contacts_import_system.go
Normal file
182
cmd/wacli/contacts_import_system.go
Normal 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()
|
||||
}
|
||||
99
cmd/wacli/contacts_import_system_test.go
Normal file
99
cmd/wacli/contacts_import_system_test.go
Normal 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
23
cmd/wacli/docs.go
Normal 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
55
cmd/wacli/docs_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -3,17 +3,108 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"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"
|
||||
)
|
||||
|
||||
func parseLockOwnerPID(lockInfo string) int {
|
||||
for _, line := range strings.Split(lockInfo, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "pid=") {
|
||||
continue
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "pid=")))
|
||||
if err == nil && pid > 0 {
|
||||
return pid
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func doctorConnectionState(authed, connected, lockHeld, connect bool) string {
|
||||
switch {
|
||||
case connected:
|
||||
return "connected"
|
||||
case authed && lockHeld && !connect:
|
||||
return "locked_by_other_process"
|
||||
default:
|
||||
return "disconnected"
|
||||
}
|
||||
}
|
||||
|
||||
type doctorStoreStats struct {
|
||||
Messages int64 `json:"messages"`
|
||||
Chats int64 `json:"chats"`
|
||||
Contacts int64 `json:"contacts"`
|
||||
Groups int64 `json:"groups"`
|
||||
LastSyncAt string `json:"last_sync_at,omitempty"`
|
||||
}
|
||||
|
||||
type doctorReport struct {
|
||||
StoreDir string `json:"store_dir"`
|
||||
LockHeld bool `json:"lock_held"`
|
||||
LockInfo string `json:"lock_info,omitempty"`
|
||||
LockOwnerPID int `json:"lock_owner_pid,omitempty"`
|
||||
Authed bool `json:"authenticated"`
|
||||
LinkedJID string `json:"linked_jid,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
ConnectionState string `json:"connection_state"`
|
||||
FTSEnabled bool `json:"fts_enabled"`
|
||||
Store *doctorStoreStats `json:"store,omitempty"`
|
||||
StoreError string `json:"store_error,omitempty"`
|
||||
}
|
||||
|
||||
func doctorStoreStatsFromStoreStats(stats store.StoreStats) doctorStoreStats {
|
||||
out := doctorStoreStats{
|
||||
Messages: stats.Messages,
|
||||
Chats: stats.Chats,
|
||||
Contacts: stats.Contacts,
|
||||
Groups: stats.Groups,
|
||||
}
|
||||
if stats.LastMessageTS > 0 {
|
||||
out.LastSyncAt = time.Unix(stats.LastMessageTS, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeDoctorReport(w io.Writer, rep doctorReport) {
|
||||
tw := newTableWriter(w)
|
||||
fmt.Fprintf(tw, "STORE\t%s\n", rep.StoreDir)
|
||||
fmt.Fprintf(tw, "LOCKED\t%v\n", rep.LockHeld)
|
||||
if rep.LockHeld && rep.LockInfo != "" {
|
||||
fmt.Fprintf(tw, "LOCK_INFO\t%s\n", rep.LockInfo)
|
||||
}
|
||||
if rep.LockOwnerPID > 0 {
|
||||
fmt.Fprintf(tw, "LOCK_OWNER_PID\t%d\n", rep.LockOwnerPID)
|
||||
}
|
||||
fmt.Fprintf(tw, "AUTHENTICATED\t%v\n", rep.Authed)
|
||||
if rep.LinkedJID != "" {
|
||||
fmt.Fprintf(tw, "LINKED_JID\t%s\n", rep.LinkedJID)
|
||||
}
|
||||
fmt.Fprintf(tw, "CONNECTED\t%v\n", rep.Connected)
|
||||
fmt.Fprintf(tw, "CONNECTION_STATE\t%s\n", rep.ConnectionState)
|
||||
fmt.Fprintf(tw, "FTS5\t%v\n", rep.FTSEnabled)
|
||||
if rep.Store != nil {
|
||||
fmt.Fprintf(tw, "MESSAGES\t%d\n", rep.Store.Messages)
|
||||
fmt.Fprintf(tw, "CHATS\t%d\n", rep.Store.Chats)
|
||||
fmt.Fprintf(tw, "CONTACTS\t%d\n", rep.Store.Contacts)
|
||||
fmt.Fprintf(tw, "GROUPS\t%d\n", rep.Store.Groups)
|
||||
if rep.Store.LastSyncAt != "" {
|
||||
fmt.Fprintf(tw, "LAST_SYNC\t%s\n", rep.Store.LastSyncAt)
|
||||
}
|
||||
}
|
||||
_ = tw.Flush()
|
||||
}
|
||||
|
||||
func newDoctorCmd(flags *rootFlags) *cobra.Command {
|
||||
var connect bool
|
||||
|
||||
@ -24,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
|
||||
@ -41,56 +131,64 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
|
||||
lockHeld = true
|
||||
}
|
||||
|
||||
var storeErr string
|
||||
a, lk, err := newApp(ctx, flags, connect, true)
|
||||
if err != nil {
|
||||
return err
|
||||
storeErr = err.Error()
|
||||
} else {
|
||||
defer closeApp(a, lk)
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
var authed bool
|
||||
var connected bool
|
||||
if err := a.OpenWA(); err == nil {
|
||||
authed = a.WA().IsAuthed()
|
||||
var linkedJID string
|
||||
if a != nil {
|
||||
if err := a.OpenWA(); err == nil {
|
||||
authed = a.WA().IsAuthed()
|
||||
if authed {
|
||||
linkedJID = a.WA().LinkedJID()
|
||||
}
|
||||
}
|
||||
if connect && authed {
|
||||
if err := a.Connect(ctx, false, nil); err == nil {
|
||||
connected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if connect && authed {
|
||||
if err := a.Connect(ctx, false, nil); err == nil {
|
||||
connected = true
|
||||
lockOwnerPID := parseLockOwnerPID(lockInfo)
|
||||
|
||||
var stats *doctorStoreStats
|
||||
if a != nil {
|
||||
if raw, err := a.DB().Stats(); err == nil {
|
||||
converted := doctorStoreStatsFromStoreStats(raw)
|
||||
stats = &converted
|
||||
}
|
||||
}
|
||||
|
||||
type report struct {
|
||||
StoreDir string `json:"store_dir"`
|
||||
LockHeld bool `json:"lock_held"`
|
||||
LockInfo string `json:"lock_info,omitempty"`
|
||||
Authed bool `json:"authenticated"`
|
||||
Connected bool `json:"connected"`
|
||||
FTSEnabled bool `json:"fts_enabled"`
|
||||
}
|
||||
|
||||
rep := report{
|
||||
StoreDir: storeDir,
|
||||
LockHeld: lockHeld,
|
||||
LockInfo: lockInfo,
|
||||
Authed: authed,
|
||||
Connected: connected,
|
||||
FTSEnabled: a.DB().HasFTS(),
|
||||
rep := doctorReport{
|
||||
StoreDir: storeDir,
|
||||
LockHeld: lockHeld,
|
||||
LockInfo: lockInfo,
|
||||
LockOwnerPID: lockOwnerPID,
|
||||
Authed: authed,
|
||||
LinkedJID: linkedJID,
|
||||
Connected: connected,
|
||||
ConnectionState: doctorConnectionState(authed, connected, lockHeld, connect),
|
||||
FTSEnabled: a != nil && a.DB().HasFTS(),
|
||||
Store: stats,
|
||||
StoreError: storeErr,
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, rep)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "STORE\t%s\n", rep.StoreDir)
|
||||
fmt.Fprintf(w, "LOCKED\t%v\n", rep.LockHeld)
|
||||
if rep.LockHeld && rep.LockInfo != "" {
|
||||
fmt.Fprintf(w, "LOCK_INFO\t%s\n", rep.LockInfo)
|
||||
}
|
||||
fmt.Fprintf(w, "AUTHENTICATED\t%v\n", rep.Authed)
|
||||
fmt.Fprintf(w, "CONNECTED\t%v\n", rep.Connected)
|
||||
fmt.Fprintf(w, "FTS5\t%v\n", rep.FTSEnabled)
|
||||
_ = w.Flush()
|
||||
writeDoctorReport(os.Stdout, rep)
|
||||
|
||||
if rep.StoreError != "" {
|
||||
fmt.Fprintf(os.Stdout, "\nERROR: store could not be opened: %s\n", rep.StoreError)
|
||||
fmt.Fprintln(os.Stdout, "Tip: check that the store directory exists and is not corrupted.")
|
||||
}
|
||||
if rep.LockHeld {
|
||||
fmt.Fprintln(os.Stdout, "\nTip: stop the running `wacli sync` before running write operations.")
|
||||
}
|
||||
|
||||
110
cmd/wacli/doctor_test.go
Normal file
110
cmd/wacli/doctor_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestParseLockOwnerPID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
info string
|
||||
want int
|
||||
}{
|
||||
{name: "pid line", info: "pid=50394\nacquired_at=2026-04-05T12:30:11Z", want: 50394},
|
||||
{name: "trimmed pid", info: " pid= 42 ", want: 42},
|
||||
{name: "missing pid", info: "acquired_at=2026-04-05T12:30:11Z"},
|
||||
{name: "invalid pid", info: "pid=abc"},
|
||||
{name: "zero pid", info: "pid=0"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := parseLockOwnerPID(tc.info); got != tc.want {
|
||||
t.Fatalf("parseLockOwnerPID() = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorConnectionState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authed bool
|
||||
connected bool
|
||||
lockHeld bool
|
||||
connect bool
|
||||
want string
|
||||
}{
|
||||
{name: "connected wins", authed: true, connected: true, lockHeld: true, want: "connected"},
|
||||
{name: "locked paired session", authed: true, lockHeld: true, want: "locked_by_other_process"},
|
||||
{name: "connect requested stays disconnected", authed: true, lockHeld: true, connect: true, want: "disconnected"},
|
||||
{name: "plain disconnected", authed: true, want: "disconnected"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := doctorConnectionState(tc.authed, tc.connected, tc.lockHeld, tc.connect)
|
||||
if got != tc.want {
|
||||
t.Fatalf("doctorConnectionState() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorStoreStatsFromStoreStats(t *testing.T) {
|
||||
when := time.Date(2024, 4, 1, 12, 30, 0, 0, time.FixedZone("offset", 2*60*60))
|
||||
got := doctorStoreStatsFromStoreStats(store.StoreStats{
|
||||
Messages: 4,
|
||||
Chats: 3,
|
||||
Contacts: 2,
|
||||
Groups: 1,
|
||||
LastMessageTS: when.Unix(),
|
||||
})
|
||||
if got.Messages != 4 || got.Chats != 3 || got.Contacts != 2 || got.Groups != 1 {
|
||||
t.Fatalf("unexpected counts: %+v", got)
|
||||
}
|
||||
if got.LastSyncAt != "2024-04-01T10:30:00Z" {
|
||||
t.Fatalf("LastSyncAt = %q", got.LastSyncAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDoctorReportIncludesLinkedJIDAndStats(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
writeDoctorReport(&b, doctorReport{
|
||||
StoreDir: "/tmp/wacli",
|
||||
Authed: true,
|
||||
LinkedJID: "1234567890@s.whatsapp.net",
|
||||
ConnectionState: "disconnected",
|
||||
FTSEnabled: true,
|
||||
Store: &doctorStoreStats{
|
||||
Messages: 9,
|
||||
Chats: 8,
|
||||
Contacts: 7,
|
||||
Groups: 6,
|
||||
LastSyncAt: "2024-04-01T10:30:00Z",
|
||||
},
|
||||
})
|
||||
|
||||
out := b.String()
|
||||
for _, want := range []string{
|
||||
"LINKED_JID",
|
||||
"1234567890@s.whatsapp.net",
|
||||
"MESSAGES",
|
||||
"9",
|
||||
"CHATS",
|
||||
"8",
|
||||
"CONTACTS",
|
||||
"7",
|
||||
"GROUPS",
|
||||
"6",
|
||||
"LAST_SYNC",
|
||||
"2024-04-01T10:30:00Z",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("doctor output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
func newGroupsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
@ -28,477 +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
|
||||
}
|
||||
|
||||
func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Fetch joined groups (live) and update local DB",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gs, err := a.WA().GetJoinedGroups(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range gs {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
_ = persistGroupInfo(a.DB(), g)
|
||||
_ = a.DB().UpsertChat(g.JID.String(), "group", g.GroupName.Name, time.Now())
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"groups": len(gs)})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Imported %d groups.\n", len(gs))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsListCmd(flags *rootFlags) *cobra.Command {
|
||||
var query string
|
||||
var limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List known groups (from local DB; run sync to populate)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
gs, err := a.DB().ListGroups(query, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, gs)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "NAME\tJID\tCREATED")
|
||||
for _, g := range gs {
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.JID
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", truncate(name, 40), g.JID, g.CreatedAt.Local().Format("2006-01-02"))
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&query, "query", "", "search query")
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "limit")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "Fetch group info (live) and update local DB",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := a.WA().GetGroupInfo(ctx, gjid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, info)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nCreated: %s\nParticipants: %d\n",
|
||||
info.JID.String(),
|
||||
info.GroupName.Name,
|
||||
info.OwnerJID.String(),
|
||||
info.GroupCreated.Local().Format(time.RFC3339),
|
||||
len(info.Participants),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsRenameCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
var name string
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename",
|
||||
Short: "Rename group",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" || strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("--jid and --name are required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.WA().SetGroupName(ctx, gjid, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "name": name})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "OK")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
cmd.Flags().StringVar(&name, "name", "", "new name")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsParticipantsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "participants",
|
||||
Short: "Manage group participants",
|
||||
}
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "add"))
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "remove"))
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "promote"))
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "demote"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsParticipantsActionCmd(flags *rootFlags, action string) *cobra.Command {
|
||||
var group string
|
||||
var users []string
|
||||
cmd := &cobra.Command{
|
||||
Use: action,
|
||||
Short: action + " participants",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(group) == "" || len(users) == 0 {
|
||||
return fmt.Errorf("--jid and at least one --user are required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gjid, err := types.ParseJID(group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jids []types.JID
|
||||
for _, u := range users {
|
||||
j, err := wa.ParseUserOrJID(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jids = append(jids, j)
|
||||
}
|
||||
|
||||
updated, err := a.WA().UpdateGroupParticipants(ctx, gjid, jids, wa.GroupParticipantAction(action))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, updated)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "OK")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&group, "jid", "", "group JID (…@g.us)")
|
||||
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number or JID (repeatable)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInviteCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "invite",
|
||||
Short: "Manage group invite links",
|
||||
}
|
||||
cmd.AddCommand(newGroupsInviteLinkCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInviteLinkCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "link",
|
||||
Short: "Get or revoke invite links",
|
||||
}
|
||||
cmd.AddCommand(newGroupsInviteLinkGetCmd(flags))
|
||||
cmd.AddCommand(newGroupsInviteLinkRevokeCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInviteLinkGetCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "Get invite link",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link, err := a.WA().GetGroupInviteLink(ctx, gjid, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, link)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInviteLinkRevokeCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "revoke",
|
||||
Short: "Revoke/reset invite link",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link, err := a.WA().GetGroupInviteLink(ctx, gjid, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link, "revoked": true})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, link)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsJoinCmd(flags *rootFlags) *cobra.Command {
|
||||
var code string
|
||||
cmd := &cobra.Command{
|
||||
Use: "join",
|
||||
Short: "Join group by invite code",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return fmt.Errorf("--code is required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
jid, err := a.WA().JoinGroupWithLink(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := a.WA().GetGroupInfo(ctx, jid); err == nil && info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": jid.String(), "joined": true})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Joined: %s\n", jid.String())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&code, "code", "", "invite code (from link)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsLeaveCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "leave",
|
||||
Short: "Leave a group",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.WA().LeaveGroup(ctx, gjid); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "left": true})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "OK")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
if err := db.UpsertGroup(info.JID.String(), info.GroupName.Name, info.OwnerJID.String(), info.GroupCreated); err != nil {
|
||||
return err
|
||||
}
|
||||
var ps []store.GroupParticipant
|
||||
for _, p := range info.Participants {
|
||||
role := "member"
|
||||
if p.IsSuperAdmin {
|
||||
role = "superadmin"
|
||||
} else if p.IsAdmin {
|
||||
role = "admin"
|
||||
}
|
||||
ps = append(ps, store.GroupParticipant{
|
||||
GroupJID: info.JID.String(),
|
||||
UserJID: p.JID.String(),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
return db.ReplaceGroupParticipants(info.JID.String(), ps)
|
||||
}
|
||||
|
||||
174
cmd/wacli/groups_info_rename.go
Normal file
174
cmd/wacli/groups_info_rename.go
Normal file
@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "info",
|
||||
Short: "Fetch group info (live) and update local DB",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := a.WA().GetGroupInfo(ctx, gjid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, info)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\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),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsRenameCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
var name string
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename",
|
||||
Short: "Rename group",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" || strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("--jid and --name are required")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.WA().SetGroupName(ctx, gjid, name); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "name": name})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "OK")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
cmd.Flags().StringVar(&name, "name", "", "new name")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsLeaveCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "leave",
|
||||
Short: "Leave a group",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
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
|
||||
}
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.WA().LeaveGroup(ctx, gjid); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = a.DB().MarkGroupLeft(gjid.String(), time.Now().UTC())
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "left": true})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "OK")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
165
cmd/wacli/groups_invite_join.go
Normal file
165
cmd/wacli/groups_invite_join.go
Normal file
@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func newGroupsInviteCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "invite",
|
||||
Short: "Manage group invite links",
|
||||
}
|
||||
cmd.AddCommand(newGroupsInviteLinkCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInviteLinkCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "link",
|
||||
Short: "Get or revoke invite links",
|
||||
}
|
||||
cmd.AddCommand(newGroupsInviteLinkGetCmd(flags))
|
||||
cmd.AddCommand(newGroupsInviteLinkRevokeCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInviteLinkGetCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "Get invite link",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link, err := a.WA().GetGroupInviteLink(ctx, gjid, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, link)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsInviteLinkRevokeCmd(flags *rootFlags) *cobra.Command {
|
||||
var jidStr string
|
||||
cmd := &cobra.Command{
|
||||
Use: "revoke",
|
||||
Short: "Revoke/reset invite link",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(jidStr) == "" {
|
||||
return fmt.Errorf("--jid is required")
|
||||
}
|
||||
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
|
||||
}
|
||||
gjid, err := types.ParseJID(jidStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link, err := a.WA().GetGroupInviteLink(ctx, gjid, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link, "revoked": true})
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, link)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsJoinCmd(flags *rootFlags) *cobra.Command {
|
||||
var code string
|
||||
cmd := &cobra.Command{
|
||||
Use: "join",
|
||||
Short: "Join group by invite code",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return fmt.Errorf("--code is required")
|
||||
}
|
||||
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 := a.WA().JoinGroupWithLink(ctx, code)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := a.WA().GetGroupInfo(ctx, jid); err == nil && info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"jid": jid.String(), "joined": true})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Joined: %s\n", jid.String())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&code, "code", "", "invite code (from link)")
|
||||
return cmd
|
||||
}
|
||||
87
cmd/wacli/groups_participants.go
Normal file
87
cmd/wacli/groups_participants.go
Normal file
@ -0,0 +1,87 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func newGroupsParticipantsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "participants",
|
||||
Short: "Manage group participants",
|
||||
}
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "add"))
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "remove"))
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "promote"))
|
||||
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "demote"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsParticipantsActionCmd(flags *rootFlags, action string) *cobra.Command {
|
||||
var group string
|
||||
var users []string
|
||||
cmd := &cobra.Command{
|
||||
Use: action,
|
||||
Short: action + " participants",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(group) == "" || len(users) == 0 {
|
||||
return fmt.Errorf("--jid and at least one --user are required")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
gjid, err := types.ParseJID(group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var jids []types.JID
|
||||
for _, u := range users {
|
||||
j, err := wa.ParseUserOrJID(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jids = append(jids, j)
|
||||
}
|
||||
|
||||
updated, err := a.WA().UpdateGroupParticipants(ctx, gjid, jids, wa.GroupParticipantAction(action))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
|
||||
_ = persistGroupInfo(a.DB(), info)
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, updated)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "OK")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&group, "jid", "", "group JID (…@g.us)")
|
||||
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number (+E164 and formatting ok) or JID (repeatable)")
|
||||
return cmd
|
||||
}
|
||||
54
cmd/wacli/groups_persist.go
Normal file
54
cmd/wacli/groups_persist.go
Normal file
@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func canonicalCLIJID(jid types.JID) types.JID {
|
||||
if jid.Server == types.DefaultUserServer {
|
||||
return jid.ToNonAD()
|
||||
}
|
||||
return jid
|
||||
}
|
||||
|
||||
func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
if err := db.UpsertGroupWithHierarchy(
|
||||
info.JID.String(),
|
||||
info.GroupName.Name,
|
||||
info.OwnerJID.String(),
|
||||
info.GroupCreated,
|
||||
info.IsParent,
|
||||
info.LinkedParentJID.String(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
var ps []store.GroupParticipant
|
||||
for _, p := range info.Participants {
|
||||
role := "member"
|
||||
if p.IsSuperAdmin {
|
||||
role = "superadmin"
|
||||
} else if p.IsAdmin {
|
||||
role = "admin"
|
||||
}
|
||||
ps = append(ps, store.GroupParticipant{
|
||||
GroupJID: info.JID.String(),
|
||||
UserJID: canonicalCLIJID(p.JID).String(),
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
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
135
cmd/wacli/groups_prune.go
Normal 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)
|
||||
}
|
||||
}
|
||||
117
cmd/wacli/groups_prune_test.go
Normal file
117
cmd/wacli/groups_prune_test.go
Normal 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
|
||||
}
|
||||
116
cmd/wacli/groups_refresh_list.go
Normal file
116
cmd/wacli/groups_refresh_list.go
Normal file
@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "refresh",
|
||||
Short: "Fetch joined groups (live) and update local DB",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
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
|
||||
}
|
||||
|
||||
gs, err := a.WA().GetJoinedGroups(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
joined := map[string]bool{}
|
||||
now := time.Now().UTC()
|
||||
for _, g := range gs {
|
||||
if g == nil {
|
||||
continue
|
||||
}
|
||||
joined[g.JID.String()] = true
|
||||
_ = persistGroupInfo(a.DB(), g)
|
||||
_ = a.DB().UpsertChat(g.JID.String(), "group", g.GroupName.Name, now)
|
||||
}
|
||||
if err := a.DB().MarkGroupsMissingFrom(joined, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"groups": len(gs)})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Imported %d groups.\n", len(gs))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newGroupsListCmd(flags *rootFlags) *cobra.Command {
|
||||
var query string
|
||||
var limit int
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List known groups (from local DB; run sync to populate)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
gs, err := a.DB().ListGroups(query, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, gs)
|
||||
}
|
||||
|
||||
fullOutput := fullTableOutput(flags.fullOutput)
|
||||
w := newTableWriter(os.Stdout)
|
||||
fmt.Fprintln(w, "NAME\tJID\tTYPE\tPARENT\tCREATED")
|
||||
for _, g := range gs {
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.JID
|
||||
}
|
||||
parent := g.LinkedParentJID
|
||||
if parent == "" {
|
||||
parent = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
tableCell(name, 40, fullOutput),
|
||||
g.JID,
|
||||
groupKindLabel(g.IsParent, g.LinkedParentJID),
|
||||
parent,
|
||||
g.CreatedAt.Local().Format("2006-01-02"),
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&query, "query", "", "search query")
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "limit")
|
||||
return cmd
|
||||
}
|
||||
@ -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 == "" {
|
||||
@ -27,14 +31,30 @@ func parseTime(s string) (time.Time, error) {
|
||||
return time.Time{}, fmt.Errorf("unsupported time format %q (use RFC3339 or YYYY-MM-DD)", s)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
func sanitize(s string) string {
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
if max <= 0 || len(s) <= max {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
s = sanitize(s)
|
||||
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 {
|
||||
return fullTableOutputWithTTY(forceFull, isTTY())
|
||||
}
|
||||
|
||||
func fullTableOutputWithTTY(forceFull, tty bool) bool {
|
||||
return forceFull || !tty
|
||||
}
|
||||
|
||||
@ -1,27 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"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
|
||||
@ -36,8 +139,11 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
|
||||
if chat == "" {
|
||||
return fmt.Errorf("--chat is required")
|
||||
}
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
|
||||
defer stop()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
@ -73,9 +179,79 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
|
||||
cmd.Flags().IntVar(&count, "count", 50, "number of messages to request per on-demand sync (recommended: 50)")
|
||||
cmd.Flags().IntVar(&requests, "requests", 1, "number of on-demand requests to attempt")
|
||||
cmd.Flags().IntVar(&count, "count", app.DefaultBackfillCount, "number of messages to request per on-demand sync")
|
||||
cmd.Flags().IntVar(&requests, "requests", app.DefaultBackfillRequests, "number of on-demand requests to attempt")
|
||||
cmd.Flags().DurationVar(&wait, "wait", 60*time.Second, "time to wait for an on-demand response per request")
|
||||
cmd.Flags().DurationVar(&idleExit, "idle-exit", 5*time.Second, "exit after being idle (after backfill requests)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
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
87
cmd/wacli/history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
38
cmd/wacli/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
@ -31,6 +31,9 @@ func newMediaDownloadCmd(flags *rootFlags) *cobra.Command {
|
||||
if chat == "" || id == "" {
|
||||
return fmt.Errorf("--chat and --id are required")
|
||||
}
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
@ -5,12 +5,15 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"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 {
|
||||
@ -20,16 +23,26 @@ 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
|
||||
}
|
||||
|
||||
func newMessagesListCmd(flags *rootFlags) *cobra.Command {
|
||||
var chat string
|
||||
var sender string
|
||||
var limit int
|
||||
var afterStr string
|
||||
var beforeStr string
|
||||
var fromMe bool
|
||||
var fromThem bool
|
||||
var asc bool
|
||||
var forwarded bool
|
||||
var starred bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@ -38,6 +51,10 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
if fromMe && fromThem {
|
||||
return fmt.Errorf("--from-me and --from-them are mutually exclusive")
|
||||
}
|
||||
|
||||
a, lk, err := newApp(ctx, flags, false, false)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -61,15 +78,36 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
|
||||
before = &t
|
||||
}
|
||||
|
||||
var fromMeFilter *bool
|
||||
switch {
|
||||
case fromMe:
|
||||
v := true
|
||||
fromMeFilter = &v
|
||||
case fromThem:
|
||||
v := false
|
||||
fromMeFilter = &v
|
||||
}
|
||||
|
||||
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msgs, err := a.DB().ListMessages(store.ListMessagesParams{
|
||||
ChatJID: chat,
|
||||
Limit: limit,
|
||||
After: after,
|
||||
Before: before,
|
||||
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{
|
||||
@ -78,41 +116,20 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
|
||||
})
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "TIME\tCHAT\tFROM\tID\tTEXT")
|
||||
for _, m := range msgs {
|
||||
from := m.SenderJID
|
||||
if m.FromMe {
|
||||
from = "me"
|
||||
}
|
||||
chatLabel := m.ChatName
|
||||
if chatLabel == "" {
|
||||
chatLabel = m.ChatJID
|
||||
}
|
||||
text := strings.TrimSpace(m.DisplayText)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(m.Text)
|
||||
}
|
||||
if m.MediaType != "" && text == "" {
|
||||
text = "Sent " + m.MediaType
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
|
||||
truncate(chatLabel, 24),
|
||||
truncate(from, 18),
|
||||
truncate(m.MsgID, 14),
|
||||
truncate(text, 80),
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
return writeMessagesList(os.Stdout, msgs, fullTableOutput(flags.fullOutput))
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
|
||||
cmd.Flags().StringVar(&chat, "chat", "", "filter by chat JID")
|
||||
cmd.Flags().StringVar(&sender, "sender", "", "filter by sender JID")
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "max number of messages to return")
|
||||
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().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
|
||||
}
|
||||
|
||||
@ -122,7 +139,10 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
var limit int
|
||||
var afterStr string
|
||||
var beforeStr string
|
||||
var hasMedia bool
|
||||
var msgType string
|
||||
var forwarded bool
|
||||
var starred bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "search <query>",
|
||||
@ -155,18 +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,
|
||||
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{
|
||||
@ -175,33 +204,9 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
})
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintf(w, "TIME\tCHAT\tFROM\tID\tMATCH\n")
|
||||
for _, m := range msgs {
|
||||
fromLabel := m.SenderJID
|
||||
if m.FromMe {
|
||||
fromLabel = "me"
|
||||
}
|
||||
chatLabel := m.ChatName
|
||||
if chatLabel == "" {
|
||||
chatLabel = m.ChatJID
|
||||
}
|
||||
match := m.Snippet
|
||||
if match == "" {
|
||||
match = strings.TrimSpace(m.DisplayText)
|
||||
}
|
||||
if match == "" {
|
||||
match = m.Text
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
|
||||
truncate(chatLabel, 24),
|
||||
truncate(fromLabel, 18),
|
||||
truncate(m.MsgID, 14),
|
||||
truncate(match, 90),
|
||||
)
|
||||
if err := writeMessagesSearch(os.Stdout, msgs, fullTableOutput(flags.fullOutput)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = w.Flush()
|
||||
if !a.DB().HasFTS() {
|
||||
fmt.Fprintln(os.Stderr, "Note: FTS5 not enabled; search is using LIKE (slow).")
|
||||
}
|
||||
@ -214,7 +219,80 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
|
||||
cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&msgType, "type", "", "media type filter (image|video|audio|document)")
|
||||
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
|
||||
}
|
||||
|
||||
@ -239,31 +317,21 @@ 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)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "Chat: %s\n", m.ChatJID)
|
||||
if m.ChatName != "" {
|
||||
fmt.Fprintf(os.Stdout, "Chat name: %s\n", m.ChatName)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "ID: %s\n", m.MsgID)
|
||||
fmt.Fprintf(os.Stdout, "Time: %s\n", m.Timestamp.Local().Format(time.RFC3339))
|
||||
if m.FromMe {
|
||||
fmt.Fprintf(os.Stdout, "From: me\n")
|
||||
} else {
|
||||
fmt.Fprintf(os.Stdout, "From: %s\n", m.SenderJID)
|
||||
}
|
||||
if m.MediaType != "" {
|
||||
fmt.Fprintf(os.Stdout, "Media: %s\n", m.MediaType)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "\n%s\n", m.Text)
|
||||
return nil
|
||||
return writeMessageShow(os.Stdout, m)
|
||||
},
|
||||
}
|
||||
|
||||
@ -295,35 +363,21 @@ 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)
|
||||
}
|
||||
|
||||
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "TIME\tFROM\tID\tTEXT")
|
||||
for _, m := range msgs {
|
||||
from := m.SenderJID
|
||||
if m.FromMe {
|
||||
from = "me"
|
||||
}
|
||||
line := m.Text
|
||||
if m.MsgID == id {
|
||||
line = ">> " + line
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
|
||||
truncate(from, 18),
|
||||
truncate(m.MsgID, 14),
|
||||
truncate(line, 100),
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
return writeMessageContext(os.Stdout, msgs, id, fullTableOutput(flags.fullOutput))
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
|
||||
@ -332,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
|
||||
}
|
||||
|
||||
208
cmd/wacli/messages_format.go
Normal file
208
cmd/wacli/messages_format.go
Normal file
@ -0,0 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func writeMessagesList(dst io.Writer, msgs []store.Message, fullOutput bool) error {
|
||||
w := newTableWriter(dst)
|
||||
fmt.Fprintln(w, "TIME\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\n",
|
||||
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 writeMessagesSearch(dst io.Writer, msgs []store.Message, fullOutput bool) error {
|
||||
w := newTableWriter(dst)
|
||||
fmt.Fprintf(w, "TIME\tCHAT\tFROM\tID\tMATCH\n")
|
||||
for _, m := range msgs {
|
||||
chatLabel := m.ChatName
|
||||
if chatLabel == "" {
|
||||
chatLabel = m.ChatJID
|
||||
}
|
||||
match := m.Snippet
|
||||
if match == "" {
|
||||
match = messageText(m)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
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(match, 90, fullOutput),
|
||||
)
|
||||
}
|
||||
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 != "" {
|
||||
fmt.Fprintf(dst, "Chat name: %s\n", m.ChatName)
|
||||
}
|
||||
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", messageFromDetail(m))
|
||||
if m.MediaType != "" {
|
||||
fmt.Fprintf(dst, "Media: %s\n", m.MediaType)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func writeMessageContext(dst io.Writer, msgs []store.Message, selectedID string, fullOutput bool) error {
|
||||
w := newTableWriter(dst)
|
||||
fmt.Fprintln(w, "TIME\tFROM\tID\tTEXT")
|
||||
for _, m := range msgs {
|
||||
line := messageContextLine(m)
|
||||
if m.MsgID == selectedID {
|
||||
line = ">> " + line
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
|
||||
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
|
||||
tableCell(messageFrom(m), 18, fullOutput),
|
||||
tableCell(m.MsgID, 14, fullOutput),
|
||||
tableCell(line, 100, fullOutput),
|
||||
)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if text := strings.TrimSpace(m.Text); text != "" {
|
||||
return text
|
||||
}
|
||||
if strings.TrimSpace(m.MediaType) != "" {
|
||||
return "Sent " + messageMediaLabel(m.MediaType)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
func messageMediaLabel(mediaType string) string {
|
||||
mt := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
if mt == "" {
|
||||
return "message"
|
||||
}
|
||||
return mt
|
||||
}
|
||||
182
cmd/wacli/messages_helpers.go
Normal file
182
cmd/wacli/messages_helpers.go
Normal 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)
|
||||
}
|
||||
471
cmd/wacli/messages_test.go
Normal file
471
cmd/wacli/messages_test.go
Normal file
@ -0,0 +1,471 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{input: "hello", max: 10, want: "hello"},
|
||||
{input: "hello world", max: 5, want: "hell…"},
|
||||
{input: "hello", max: 0, want: "hello"},
|
||||
{input: "ab", max: 1, want: "a"},
|
||||
{input: "hello\nworld", max: 20, want: "hello world"},
|
||||
{input: " hello ", max: 20, want: "hello"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := truncate(tc.input, tc.max); got != tc.want {
|
||||
t.Fatalf("truncate(%q, %d) = %q, want %q", tc.input, tc.max, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("force full = %q, want %q", got, longID)
|
||||
}
|
||||
if got := fullTableOutputWithTTY(false, false); !got {
|
||||
t.Fatalf("non-TTY should request full output")
|
||||
}
|
||||
if got := tableCell(longID, 14, false); got != "3EB0B0E8A1B2C…" {
|
||||
t.Fatalf("tty truncation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContextLinePrefersDisplayText(t *testing.T) {
|
||||
got := messageContextLine(store.Message{
|
||||
Text: "raw reaction payload",
|
||||
DisplayText: "Reacted 👍 to hello",
|
||||
})
|
||||
if got != "Reacted 👍 to hello" {
|
||||
t.Fatalf("messageContextLine() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContextLineFallsBackToText(t *testing.T) {
|
||||
got := messageContextLine(store.Message{Text: "hello"})
|
||||
if got != "hello" {
|
||||
t.Fatalf("messageContextLine() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContextLineFallsBackToMedia(t *testing.T) {
|
||||
got := messageContextLine(store.Message{MediaType: "IMAGE"})
|
||||
if got != "Sent image" {
|
||||
t.Fatalf("messageContextLine() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
SenderJID: "sender@s.whatsapp.net",
|
||||
MsgID: "3EB0B0E8A1B2C3D4E5F6A7B8C9D0",
|
||||
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
|
||||
DisplayText: "Reacted 👍 to hello",
|
||||
Text: "raw",
|
||||
}
|
||||
|
||||
var truncated bytes.Buffer
|
||||
if err := writeMessagesList(&truncated, []store.Message{msg}, false); err != nil {
|
||||
t.Fatalf("writeMessagesList truncated: %v", err)
|
||||
}
|
||||
if strings.Contains(truncated.String(), msg.MsgID) {
|
||||
t.Fatalf("expected truncated ID, got output:\n%s", truncated.String())
|
||||
}
|
||||
|
||||
var full bytes.Buffer
|
||||
if err := writeMessagesList(&full, []store.Message{msg}, true); err != nil {
|
||||
t.Fatalf("writeMessagesList full: %v", err)
|
||||
}
|
||||
if !strings.Contains(full.String(), msg.MsgID) {
|
||||
t.Fatalf("expected full ID, got output:\n%s", full.String())
|
||||
}
|
||||
if !strings.Contains(full.String(), "Reacted 👍 to hello") {
|
||||
t.Fatalf("expected display text, got output:\n%s", full.String())
|
||||
}
|
||||
}
|
||||
|
||||
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", "forwarded", "starred"} {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("expected --%s flag", name)
|
||||
}
|
||||
}
|
||||
if got := cmd.Flags().Lookup("type").Usage; !strings.Contains(got, "text|image|video|audio|document") {
|
||||
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, ",")
|
||||
}
|
||||
115
cmd/wacli/presence.go
Normal file
115
cmd/wacli/presence.go
Normal file
@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func newPresenceCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "presence",
|
||||
Short: "Send presence indicators (typing, paused)",
|
||||
}
|
||||
cmd.AddCommand(newPresenceTypingCmd(flags))
|
||||
cmd.AddCommand(newPresencePausedCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newPresenceTypingCmd(flags *rootFlags) *cobra.Command {
|
||||
var to string
|
||||
var media string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "typing",
|
||||
Short: "Send a 'composing' (typing) indicator to a chat",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPresence(flags, to, types.ChatPresenceComposing, media)
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func newPresencePausedCmd(flags *rootFlags) *cobra.Command {
|
||||
var to string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "paused",
|
||||
Short: "Send a 'paused' indicator (stop typing) to a chat",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runPresence(flags, to, types.ChatPresencePaused, "")
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runPresence(flags *rootFlags, to string, state types.ChatPresence, media string) error {
|
||||
if strings.TrimSpace(to) == "" {
|
||||
return fmt.Errorf("--to 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
|
||||
}
|
||||
|
||||
toJID, err := wa.ParseUserOrJID(to)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chatMedia, err := presenceMediaFromString(media)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := a.WA().SendChatPresence(ctx, toJID, state, chatMedia); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{
|
||||
"sent": true,
|
||||
"to": toJID.String(),
|
||||
"state": string(state),
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Presence '%s' sent to %s\n", state, toJID.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
func presenceMediaFromString(media string) (types.ChatPresenceMedia, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(media)) {
|
||||
case "":
|
||||
return "", nil
|
||||
case "audio":
|
||||
return types.ChatPresenceMediaAudio, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported --media %q (supported: audio)", media)
|
||||
}
|
||||
}
|
||||
39
cmd/wacli/presence_test.go
Normal file
39
cmd/wacli/presence_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func TestPresenceMediaFromString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want types.ChatPresenceMedia
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty", input: "", want: ""},
|
||||
{name: "audio", input: "audio", want: types.ChatPresenceMediaAudio},
|
||||
{name: "trimmed case", input: " Audio ", want: types.ChatPresenceMediaAudio},
|
||||
{name: "unknown", input: "video", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := presenceMediaFromString(tt.input)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("presenceMediaFromString(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
136
cmd/wacli/profile.go
Normal file
136
cmd/wacli/profile.go
Normal 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
59
cmd/wacli/profile_test.go
Normal 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
145
cmd/wacli/recipient.go
Normal 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"
|
||||
}
|
||||
@ -6,21 +6,29 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 = "dev"
|
||||
var version = "0.8.1"
|
||||
|
||||
const docsURL = "https://wacli.sh"
|
||||
|
||||
type rootFlags struct {
|
||||
storeDir string
|
||||
asJSON bool
|
||||
timeout time.Duration
|
||||
storeDir string
|
||||
account string
|
||||
asJSON bool
|
||||
fullOutput bool
|
||||
events bool
|
||||
timeout time.Duration
|
||||
readOnly bool
|
||||
lockWait time.Duration
|
||||
}
|
||||
|
||||
func execute(args []string) error {
|
||||
@ -28,17 +36,25 @@ 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,
|
||||
}
|
||||
rootCmd.SetVersionTemplate("wacli {{.Version}}\n")
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: ~/.wacli)")
|
||||
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))
|
||||
@ -48,27 +64,41 @@ 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.Acquire(storeDir)
|
||||
lk, err = lock.AcquireWithTimeout(ctx, storeDir, flags.lockWait)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@ -78,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 {
|
||||
@ -90,6 +121,67 @@ 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
|
||||
}
|
||||
if f.readOnly {
|
||||
return true
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("WACLI_READONLY"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (f *rootFlags) requireWritable() error {
|
||||
if f.isReadOnly() {
|
||||
return fmt.Errorf("read-only mode: command would intentionally modify WhatsApp or the local store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func withTimeout(ctx context.Context, flags *rootFlags) (context.Context, context.CancelFunc) {
|
||||
if flags.timeout <= 0 {
|
||||
return context.WithCancel(ctx)
|
||||
|
||||
159
cmd/wacli/root_test.go
Normal file
159
cmd/wacli/root_test.go
Normal file
@ -0,0 +1,159 @@
|
||||
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}
|
||||
|
||||
if !flags.isReadOnly() {
|
||||
t.Fatal("isReadOnly = false, want true")
|
||||
}
|
||||
err := flags.requireWritable()
|
||||
if err == nil || !strings.Contains(err.Error(), "read-only mode") {
|
||||
t.Fatalf("requireWritable error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootFlagsReadOnlyEnv(t *testing.T) {
|
||||
t.Setenv("WACLI_READONLY", "yes")
|
||||
|
||||
if !(&rootFlags{}).isReadOnly() {
|
||||
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, "")
|
||||
}
|
||||
@ -2,14 +2,22 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"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/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"
|
||||
)
|
||||
|
||||
func newSendCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -19,12 +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",
|
||||
@ -33,12 +51,39 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
|
||||
if to == "" || message == "" {
|
||||
return fmt.Errorf("--to and --message are required")
|
||||
}
|
||||
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)
|
||||
@ -46,16 +91,26 @@ 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
|
||||
}
|
||||
|
||||
msgID, err := a.WA().SendText(ctx, toJID, message)
|
||||
preview := fetchLinkPreview(ctx, message, noPreview)
|
||||
msgID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
|
||||
return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender, preview, mentionedJIDs)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -76,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,
|
||||
@ -88,7 +145,202 @@ 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
|
||||
}
|
||||
|
||||
type sendTextApp interface {
|
||||
WA() app.WAClient
|
||||
DB() *store.DB
|
||||
}
|
||||
|
||||
func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview, mentionedJIDs []string) (types.MessageID, error) {
|
||||
msg, plainText, err := buildTextMessage(a.DB(), to, text, replyTo, replyToSender, preview, mentionedJIDs)
|
||||
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)}
|
||||
if !sender.IsEmpty() {
|
||||
participant := sender.String()
|
||||
info.Participant = proto.String(participant)
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func resolveReplySender(db *store.DB, chat types.JID, replyTo, override string) (types.JID, error) {
|
||||
if strings.TrimSpace(override) != "" {
|
||||
jid, err := wa.ParseUserOrJID(override)
|
||||
if err != nil {
|
||||
return types.JID{}, fmt.Errorf("invalid --reply-to-sender: %w", err)
|
||||
}
|
||||
return jid, nil
|
||||
}
|
||||
|
||||
msg, err := db.GetMessage(chat.String(), replyTo)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return types.JID{}, fmt.Errorf("lookup quoted message: %w", err)
|
||||
}
|
||||
if err == nil && strings.TrimSpace(msg.SenderJID) != "" {
|
||||
jid, err := types.ParseJID(msg.SenderJID)
|
||||
if err != nil {
|
||||
return types.JID{}, fmt.Errorf("stored quoted sender is invalid: %w", err)
|
||||
}
|
||||
return jid, nil
|
||||
}
|
||||
|
||||
if chat.Server == types.GroupServer {
|
||||
return types.JID{}, fmt.Errorf("--reply-to-sender is required for unsynced group replies")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
@ -24,12 +30,38 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
|
||||
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: "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)
|
||||
@ -37,19 +69,42 @@ 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
|
||||
}
|
||||
|
||||
msgID, meta, err := sendFile(ctx, a, toJID, filePath, filename, caption, mimeOverride)
|
||||
type sendFileResult struct {
|
||||
id string
|
||||
meta map[string]string
|
||||
}
|
||||
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendFileResult, error) {
|
||||
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
|
||||
}
|
||||
return sendFileResult{id: msgID, meta: meta}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msgID, meta := res.id, res.meta
|
||||
|
||||
waitForPostSendRetryReceipts(ctx, postSendWait)
|
||||
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{
|
||||
@ -64,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
280
cmd/wacli/send_file_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
131
cmd/wacli/send_helpers.go
Normal file
131
cmd/wacli/send_helpers.go
Normal file
@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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,
|
||||
reconnect func(context.Context) error,
|
||||
op func(context.Context) (T, error),
|
||||
) (T, error) {
|
||||
result, err := runSendAttempt(ctx, sendAttemptTimeout, op)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
var zero T
|
||||
if !isRetryableSendError(err) || ctx.Err() != nil {
|
||||
return zero, err
|
||||
}
|
||||
if reconnectErr := reconnect(ctx); reconnectErr != nil {
|
||||
return zero, fmt.Errorf("%w; reconnect failed: %v", err, reconnectErr)
|
||||
}
|
||||
return runSendAttempt(ctx, sendAttemptTimeout, op)
|
||||
}
|
||||
|
||||
func runSendAttempt[T any](ctx context.Context, timeout time.Duration, op func(context.Context) (T, error)) (T, error) {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
value T
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
value, err := op(attemptCtx)
|
||||
ch <- result{value: value, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
if errors.Is(res.err, context.DeadlineExceeded) && errors.Is(attemptCtx.Err(), context.DeadlineExceeded) {
|
||||
var zero T
|
||||
return zero, fmt.Errorf("send timed out after %s", timeout)
|
||||
}
|
||||
return res.value, res.err
|
||||
case <-attemptCtx.Done():
|
||||
var zero T
|
||||
if errors.Is(attemptCtx.Err(), context.DeadlineExceeded) {
|
||||
return zero, fmt.Errorf("send timed out after %s", timeout)
|
||||
}
|
||||
return zero, attemptCtx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func isRetryableSendError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, whatsmeow.ErrIQTimedOut) {
|
||||
return true
|
||||
}
|
||||
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "failed to send usync query") ||
|
||||
strings.Contains(msg, "failed to get user info") ||
|
||||
strings.Contains(msg, "failed to get device list") ||
|
||||
strings.Contains(msg, "info query timed out") ||
|
||||
strings.Contains(msg, "not connected")
|
||||
}
|
||||
|
||||
func reconnectForSend(a interface {
|
||||
WA() app.WAClient
|
||||
Connect(context.Context, bool, func(string)) error
|
||||
}) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
a.WA().Close()
|
||||
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
|
||||
}
|
||||
159
cmd/wacli/send_helpers_test.go
Normal file
159
cmd/wacli/send_helpers_test.go
Normal file
@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mau.fi/whatsmeow"
|
||||
)
|
||||
|
||||
func TestRunSendOperationRetriesRetryableError(t *testing.T) {
|
||||
var reconnects int
|
||||
attempts := 0
|
||||
|
||||
got, err := runSendOperation(context.Background(), func(ctx context.Context) error {
|
||||
reconnects++
|
||||
return nil
|
||||
}, func(ctx context.Context) (string, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return "", fmt.Errorf("failed to get device list: failed to send usync query: %w", whatsmeow.ErrIQTimedOut)
|
||||
}
|
||||
return "ok", nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runSendOperation: %v", err)
|
||||
}
|
||||
if got != "ok" {
|
||||
t.Fatalf("expected ok, got %q", got)
|
||||
}
|
||||
if reconnects != 1 {
|
||||
t.Fatalf("expected 1 reconnect, got %d", reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSendOperationDoesNotRetryValidationError(t *testing.T) {
|
||||
var reconnects int
|
||||
|
||||
_, err := runSendOperation(context.Background(), func(ctx context.Context) error {
|
||||
reconnects++
|
||||
return nil
|
||||
}, func(ctx context.Context) (string, error) {
|
||||
return "", errors.New("permission denied")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
if reconnects != 0 {
|
||||
t.Fatalf("expected no reconnect, got %d", reconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSendAttemptTimesOut(t *testing.T) {
|
||||
_, err := runSendAttempt(context.Background(), 20*time.Millisecond, func(ctx context.Context) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected timeout error")
|
||||
}
|
||||
if err.Error() != "send timed out after 20ms" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
if !isRetryableSendError(errors.New("failed to get user info for 123@s.whatsapp.net to fill LID cache: failed to send usync query: info query timed out")) {
|
||||
t.Fatalf("expected wrapped usync timeout to be retryable")
|
||||
}
|
||||
if isRetryableSendError(errors.New("permission denied")) {
|
||||
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
359
cmd/wacli/send_ipc.go
Normal 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
|
||||
}
|
||||
59
cmd/wacli/send_ipc_test.go
Normal file
59
cmd/wacli/send_ipc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
160
cmd/wacli/send_react_cmd.go
Normal file
160
cmd/wacli/send_react_cmd.go
Normal file
@ -0,0 +1,160 @@
|
||||
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 newSendReactCmd(flags *rootFlags) *cobra.Command {
|
||||
var to string
|
||||
var msgID string
|
||||
var emoji string
|
||||
var sender string
|
||||
postSendWait := postSendRetryReceiptWait
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "react",
|
||||
Short: "React to a message",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(to) == "" || strings.TrimSpace(msgID) == "" {
|
||||
return fmt.Errorf("--to and --id 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 {
|
||||
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)
|
||||
|
||||
if err := a.EnsureAuthed(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.Connect(ctx, false, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
chat, senderJID, err := reactionTarget(to, sender)
|
||||
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)
|
||||
})
|
||||
if err != nil {
|
||||
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,
|
||||
"to": chat.String(),
|
||||
"id": sentID,
|
||||
"target": msgID,
|
||||
"reaction": emoji,
|
||||
})
|
||||
}
|
||||
if emoji == "" {
|
||||
fmt.Fprintf(os.Stdout, "Removed reaction from %s (id %s)\n", msgID, sentID)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Reacted %s to %s (id %s)\n", emoji, msgID, sentID)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func reactionTarget(to, sender string) (types.JID, types.JID, error) {
|
||||
chat, err := wa.ParseUserOrJID(to)
|
||||
if err != nil {
|
||||
return types.JID{}, types.JID{}, fmt.Errorf("invalid --to: %w", err)
|
||||
}
|
||||
var senderJID types.JID
|
||||
if strings.TrimSpace(sender) != "" {
|
||||
senderJID, err = wa.ParseUserOrJID(sender)
|
||||
if err != nil {
|
||||
return types.JID{}, types.JID{}, fmt.Errorf("invalid --sender: %w", err)
|
||||
}
|
||||
}
|
||||
if chat.Server == types.GroupServer && senderJID.IsEmpty() {
|
||||
return types.JID{}, types.JID{}, fmt.Errorf("--sender is required for group reactions")
|
||||
}
|
||||
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)
|
||||
}
|
||||
41
cmd/wacli/send_react_cmd_test.go
Normal file
41
cmd/wacli/send_react_cmd_test.go
Normal file
@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func TestReactionTargetDirect(t *testing.T) {
|
||||
chat, sender, err := reactionTarget("+15551234567", "")
|
||||
if err != nil {
|
||||
t.Fatalf("reactionTarget: %v", err)
|
||||
}
|
||||
if chat.String() != "15551234567@s.whatsapp.net" {
|
||||
t.Fatalf("chat = %q", chat.String())
|
||||
}
|
||||
if !sender.IsEmpty() {
|
||||
t.Fatalf("sender = %q, want empty", sender.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionTargetGroupRequiresSender(t *testing.T) {
|
||||
_, _, err := reactionTarget("12345@g.us", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--sender is required") {
|
||||
t.Fatalf("expected sender error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReactionTargetGroupSender(t *testing.T) {
|
||||
chat, sender, err := reactionTarget("12345@g.us", "+15551234567")
|
||||
if err != nil {
|
||||
t.Fatalf("reactionTarget: %v", err)
|
||||
}
|
||||
if chat.Server != types.GroupServer {
|
||||
t.Fatalf("chat = %q, want group", chat.String())
|
||||
}
|
||||
if sender.String() != "15551234567@s.whatsapp.net" {
|
||||
t.Fatalf("sender = %q", sender.String())
|
||||
}
|
||||
}
|
||||
217
cmd/wacli/send_sticker.go
Normal file
217
cmd/wacli/send_sticker.go
Normal 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
|
||||
}
|
||||
116
cmd/wacli/send_sticker_cmd.go
Normal file
116
cmd/wacli/send_sticker_cmd.go
Normal 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
|
||||
}
|
||||
164
cmd/wacli/send_sticker_test.go
Normal file
164
cmd/wacli/send_sticker_test.go
Normal 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)
|
||||
}
|
||||
409
cmd/wacli/send_test.go
Normal file
409
cmd/wacli/send_test.go
Normal file
@ -0,0 +1,409 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/linkpreview"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func openSendTestDB(t *testing.T) *store.DB {
|
||||
t.Helper()
|
||||
db, err := store.Open(t.TempDir() + "/wacli.db")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
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}
|
||||
sender := "15551234567@s.whatsapp.net"
|
||||
|
||||
if err := db.UpsertChat(chat.String(), "group", "Group", time.Now()); err != nil {
|
||||
t.Fatalf("UpsertChat: %v", err)
|
||||
}
|
||||
if err := db.UpsertMessage(store.UpsertMessageParams{
|
||||
ChatJID: chat.String(),
|
||||
MsgID: "quoted",
|
||||
SenderJID: sender,
|
||||
Timestamp: time.Now(),
|
||||
Text: "hello",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMessage: %v", err)
|
||||
}
|
||||
|
||||
got, err := resolveReplySender(db, chat, "quoted", "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveReplySender: %v", err)
|
||||
}
|
||||
if got.String() != sender {
|
||||
t.Fatalf("sender = %q, want %q", got.String(), sender)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReplySenderOverride(t *testing.T) {
|
||||
db := openSendTestDB(t)
|
||||
chat := types.JID{User: "12345", Server: types.GroupServer}
|
||||
|
||||
got, err := resolveReplySender(db, chat, "missing", "+15551234567")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveReplySender: %v", err)
|
||||
}
|
||||
if got.String() != "15551234567@s.whatsapp.net" {
|
||||
t.Fatalf("sender = %q", got.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReplySenderRequiresGroupSenderWhenMissing(t *testing.T) {
|
||||
db := openSendTestDB(t)
|
||||
chat := types.JID{User: "12345", Server: types.GroupServer}
|
||||
|
||||
_, err := resolveReplySender(db, chat, "missing", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "--reply-to-sender is required") {
|
||||
t.Fatalf("expected group sender error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReplySenderAllowsDirectMessageWithoutSender(t *testing.T) {
|
||||
db := openSendTestDB(t)
|
||||
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
|
||||
|
||||
got, err := resolveReplySender(db, chat, "missing", "")
|
||||
if err != nil {
|
||||
t.Fatalf("resolveReplySender: %v", err)
|
||||
}
|
||||
if !got.IsEmpty() {
|
||||
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
121
cmd/wacli/send_voice_cmd.go
Normal 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
|
||||
}
|
||||
52
cmd/wacli/signal.go
Normal file
52
cmd/wacli/signal.go
Normal file
@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
)
|
||||
|
||||
// signalContext returns a context that is cancelled on the first SIGINT/SIGTERM.
|
||||
// A second signal force-kills the process so that a stuck cleanup never leaves
|
||||
// the user unable to get their terminal back.
|
||||
func signalContext() (context.Context, context.CancelFunc) {
|
||||
return signalContextWithEvents(nil)
|
||||
}
|
||||
|
||||
func signalContextWithEvents(events *out.EventWriter) (context.Context, context.CancelFunc) {
|
||||
sigCh := make(chan os.Signal, 2)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
return signalContextForChannel(events, sigCh, func() { signal.Stop(sigCh) }, os.Exit)
|
||||
}
|
||||
|
||||
func signalContextForChannel(events *out.EventWriter, sigCh <-chan os.Signal, stopNotify func(), forceExit func(int)) (context.Context, context.CancelFunc) {
|
||||
ctx, ctxCancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
if events.Enabled() {
|
||||
_ = events.Emit("signal", map[string]any{"signal": sig.String(), "action": "shutdown"})
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "\nShutting down (interrupt again to force quit)...")
|
||||
}
|
||||
ctxCancel()
|
||||
|
||||
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() {
|
||||
if stopNotify != nil {
|
||||
stopNotify()
|
||||
}
|
||||
ctxCancel()
|
||||
}
|
||||
}
|
||||
73
cmd/wacli/signal_test.go
Normal file
73
cmd/wacli/signal_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
78
cmd/wacli/storage_limits.go
Normal file
78
cmd/wacli/storage_limits.go
Normal 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
|
||||
}
|
||||
81
cmd/wacli/storage_limits_test.go
Normal file
81
cmd/wacli/storage_limits_test.go
Normal 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
13
cmd/wacli/store.go
Normal 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
126
cmd/wacli/store_cleanup.go
Normal 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
68
cmd/wacli/store_stats.go
Normal 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
|
||||
}
|
||||
@ -4,28 +4,42 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"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 {
|
||||
var once bool
|
||||
var follow bool
|
||||
var idleExit time.Duration
|
||||
var maxReconnect time.Duration
|
||||
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",
|
||||
Short: "Sync messages (requires prior auth; never shows QR)",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
storage.maxMessagesSet = cmd.Flags().Changed("max-messages")
|
||||
maxMessages, maxDBSize, err := resolveSyncStorageLimits(storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if webhookSecret != "" && webhookURL == "" {
|
||||
return fmt.Errorf("--webhook-secret requires --webhook")
|
||||
}
|
||||
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
|
||||
defer stop()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, false)
|
||||
@ -47,13 +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
|
||||
@ -73,8 +113,14 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&once, "once", false, "sync until idle and exit")
|
||||
cmd.Flags().BoolVar(&follow, "follow", true, "keep syncing until Ctrl+C")
|
||||
cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (once mode)")
|
||||
cmd.Flags().DurationVar(&maxReconnect, "max-reconnect", 5*time.Minute, "give up reconnecting after this duration (0 = unlimited)")
|
||||
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
25
cmd/wacli/sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
cmd/wacli/table.go
Normal file
17
cmd/wacli/table.go
Normal file
@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func newTableWriter(dst io.Writer) *tabwriter.Writer {
|
||||
return tabwriter.NewWriter(dst, 2, 4, 2, ' ', 0)
|
||||
}
|
||||
|
||||
func tableCell(s string, max int, full bool) string {
|
||||
if full {
|
||||
return sanitize(s)
|
||||
}
|
||||
return truncate(s, max)
|
||||
}
|
||||
@ -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
20
cmd/wacli/version_test.go
Normal 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
1
docs/CNAME
Normal file
@ -0,0 +1 @@
|
||||
wacli.sh
|
||||
57
docs/accounts.md
Normal file
57
docs/accounts.md
Normal 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
38
docs/auth.md
Normal 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
37
docs/channels.md
Normal 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
47
docs/chats.md
Normal 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
25
docs/completion.md
Normal 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.
|
||||
122
docs/contacts-import-system.md
Normal file
122
docs/contacts-import-system.md
Normal 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
41
docs/contacts.md
Normal 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
26
docs/docs.md
Normal 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
26
docs/doctor.md
Normal 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
51
docs/groups.md
Normal 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
22
docs/help.md
Normal 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
39
docs/history.md
Normal 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
48
docs/index.md
Normal 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
90
docs/install.md
Normal 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
137
docs/integrations.md
Normal 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
26
docs/media.md
Normal 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
68
docs/messages.md
Normal 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
65
docs/overview.md
Normal 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.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user