Compare commits
43 Commits
codex/send
...
main
| 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 |
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.
|
||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -1 +0,0 @@
|
||||
* @dinakars777
|
||||
35
.github/workflows/ci.yml
vendored
35
.github/workflows/ci.yml
vendored
@ -41,3 +41,38 @@ jobs:
|
||||
env:
|
||||
CGO_ENABLED: "1"
|
||||
run: pnpm -s build
|
||||
|
||||
linux-release-builds:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup CI Environment
|
||||
uses: ./.github/actions/setup-ci-env
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
apt-packages: "build-essential gcc-aarch64-linux-gnu libc6-dev-arm64-cross"
|
||||
|
||||
- name: GoReleaser check (macOS)
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: check --config .goreleaser.yaml
|
||||
|
||||
- name: GoReleaser check (linux/windows)
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: check --config .goreleaser-linux-windows.yaml
|
||||
|
||||
- name: GoReleaser build (linux amd64/arm64)
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: build --snapshot --clean --config .goreleaser-linux-windows.yaml --id wacli_linux_amd64 --id wacli_linux_arm64
|
||||
|
||||
1
.github/workflows/pages.yml
vendored
1
.github/workflows/pages.yml
vendored
@ -8,6 +8,7 @@ on:
|
||||
- "docs/**"
|
||||
- "scripts/build-docs-site.mjs"
|
||||
- "scripts/docs-site-assets.mjs"
|
||||
- "scripts/docs-site-render.mjs"
|
||||
- "package.json"
|
||||
- ".github/workflows/pages.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
38
.github/workflows/release.yml
vendored
38
.github/workflows/release.yml
vendored
@ -36,13 +36,15 @@ jobs:
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: git checkout -- "$RELEASE_TAG"
|
||||
|
||||
- name: GoReleaser (macOS universal)
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
version: "~> v2"
|
||||
args: release --clean --config /tmp/.goreleaser.yaml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -52,11 +54,15 @@ jobs:
|
||||
needs: goreleaser-darwin
|
||||
steps:
|
||||
- name: Resolve release tag
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ "${{ 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: Dispatch tap formula update
|
||||
@ -64,8 +70,8 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::error::Set HOMEBREW_TAP_TOKEN with workflow access to steipete/homebrew-tap"
|
||||
exit 1
|
||||
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}"
|
||||
@ -76,7 +82,7 @@ jobs:
|
||||
--ref main \
|
||||
-f formula=wacli \
|
||||
-f tag="$RELEASE_TAG" \
|
||||
-f repository=steipete/wacli \
|
||||
-f repository=openclaw/wacli \
|
||||
-f macos_artifact=wacli-macos-universal.tar.gz \
|
||||
-f request_id="$request_id"
|
||||
|
||||
@ -126,23 +132,29 @@ jobs:
|
||||
|
||||
- name: Checkout release tag
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
run: git checkout ${{ inputs.tag }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: git checkout -- "$RELEASE_TAG"
|
||||
|
||||
- name: GoReleaser (linux/windows)
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
version: "~> v2"
|
||||
args: release --clean --skip=publish --config /tmp/.goreleaser-linux-windows.yaml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Resolve release tag
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "RELEASE_TAG=${{ inputs.tag }}" >> $GITHUB_ENV
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
echo "RELEASE_TAG=$INPUT_TAG" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "RELEASE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "RELEASE_TAG=$REF_NAME" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Upload linux/windows artifacts
|
||||
|
||||
@ -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:
|
||||
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@ -1,25 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0 - Unreleased
|
||||
## 0.8.1 - 2026-05-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Module: migrate the canonical Go module/import path to `github.com/openclaw/wacli`. (#217 - thanks @dinakars777)
|
||||
- Sync: collapse routine interactive TTY progress into a single updating status line while keeping warnings visible as normal stderr lines.
|
||||
|
||||
### Chore
|
||||
|
||||
- CI: make the Homebrew tap handoff use `openclaw/wacli` and skip gracefully when the tap token is missing. (#216 - thanks @dinakars777)
|
||||
- Maintainers: remove the stale personal CODEOWNERS rule after the OpenClaw move. (#218 - thanks @dinakars777)
|
||||
- Release: update GoReleaser archive config to the current v2 schema so release-config checks stay green.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI: truncate table output by rune so emoji and other non-ASCII text stay valid UTF-8. (#222 - thanks @dinakars777)
|
||||
- History: apply coverage/actionable filters before `LIMIT` so newer blocked chats do not hide ready chats. (#219 - thanks @dinakars777)
|
||||
- Messages: extract display/search text from shared WhatsApp contact cards, including vCard phone numbers. (#214)
|
||||
- Send: route whatsmeow diagnostics to stderr and clarify that `sent: true` means WhatsApp accepted the send request. (#215 - thanks @dinakars777)
|
||||
- Sync: let explicit `--max-messages=0` override `WACLI_SYNC_MAX_MESSAGES`. (#220 - thanks @dinakars777)
|
||||
|
||||
## 0.8.0 - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- Accounts: add first-class named WhatsApp accounts with isolated stores, `--account NAME`, and `wacli accounts list/add/use/show/remove`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Store: fix migration of legacy databases whose `groups` table existed before group hierarchy columns were introduced.
|
||||
|
||||
### Docs
|
||||
|
||||
- Docs: add a dedicated accounts page covering YAML config, store selection precedence, and multi-account usage.
|
||||
|
||||
## 0.7.0 - 2026-05-06
|
||||
|
||||
### Added
|
||||
|
||||
- CLI: add `--read-only`/`WACLI_READONLY` to reject commands that write WhatsApp or the local store.
|
||||
- CLI: add `--lock-wait` to wait for transient store locks before failing write commands.
|
||||
- CLI: add `--events` to emit machine-readable NDJSON lifecycle events for `auth`, `sync`, and `history backfill`. (#204 — thanks @dinakars777 and @0xatrilla)
|
||||
- CLI: add `wacli docs` and root help text that point to the hosted docs at `https://wacli.sh`.
|
||||
- CLI: add `--full` to disable table truncation; piped output now keeps full message IDs. (#13 — thanks @rickhallett)
|
||||
- CLI: add `presence typing` and `presence paused` commands for WhatsApp composing indicators. (#76 — thanks @redemerco)
|
||||
- Diagnostics: show linked JID and local store counts in `auth status` and `doctor`. (#149 — thanks @draix)
|
||||
- Messages: add `messages list --sender`, `--from-me`, `--from-them`, and `--asc` filters. (#153 — thanks @draix)
|
||||
- Messages: track WhatsApp starred state and add `messages starred` plus `--starred` filters for list/search. (#17 — thanks @dan-dr)
|
||||
- Messages: track WhatsApp delete-for-me app-state events as local tombstones and add `messages delete --for-me`. (#64 — thanks @vlassance)
|
||||
- Messages: add `messages edit` and `messages delete` for editing or revoking your own sent messages. (#80 — thanks @frapeti)
|
||||
- Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej)
|
||||
- Messages: add JSON export with `messages export --after` and `--before` filters.
|
||||
- Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm)
|
||||
- Contacts: add `contacts import-system` to import macOS Contacts display names as local metadata with alias-first precedence. (#33 — thanks @enki and @octaviofroid)
|
||||
- Auth: add `auth --qr-format text` to print the raw WhatsApp QR payload for external renderers. (#22 — thanks @teren-papercutlabs)
|
||||
- Auth: add `auth --phone` for WhatsApp's phone-number pairing flow on headless systems. (#148, #184 — thanks @giovanninibarbosa and @KillerSnails)
|
||||
- Auth: auto-detect a readable linked-device label and default linked-device platform to desktop. (#100 — thanks @pmatheus)
|
||||
- Chats: add archive/unarchive, pin/unpin, mute/unmute, and mark-read/mark-unread commands, plus list/show state fields. (#46 — thanks @decodiver22)
|
||||
- Channels: add WhatsApp Channel list/info/join/leave commands, channel chat caching, and text/file sends to `...@newsletter` JIDs. (#72 — thanks @frapeti)
|
||||
- Groups: persist WhatsApp Community parent/subgroup metadata from group refresh and info. (#207, #39 — thanks @dinakars777 and @TheMazzle)
|
||||
- History: add `history coverage` and `history fill --dry-run` to inspect local archive anchors before running best-effort backfill. (#111 — thanks @cropsgg)
|
||||
- Profile: add `profile set-picture` to update the authenticated account profile picture from JPEG or PNG input. (#198 — thanks @gado-ships-it)
|
||||
- Sync: add signed live-message webhooks with `--webhook` and `--webhook-secret`. (#203 — thanks @dinakars777 and @Melostack)
|
||||
- Send: add `send react` to add or clear reactions, with group sender validation. (#151 — thanks @draix)
|
||||
- Send: add opt-in `send text --message-escapes` for `\n`, `\r`, `\t`, `\\`, and `\"` in `--message`. (#206 — thanks @slaveofcode)
|
||||
- Send: add `send file --reply-to` for quoted media/document replies. (#68 — thanks @vlassance)
|
||||
- Send: add repeatable `send text --mention` for WhatsApp user mentions in group messages. (#16 — thanks @nicozefrench and @sheepworrier)
|
||||
- Send: add automatic link previews for text messages with `--no-preview` opt-out. (#94, #95 — thanks @elgatoflaco)
|
||||
@ -27,6 +72,7 @@
|
||||
- Send: add `send voice` and `send file --ptt` for OGG/Opus WhatsApp voice notes. (#40, #41 — thanks @ricardopolo and @emre6943)
|
||||
- Send: accept common phone-number formatting in recipient flags while still storing digits-only WhatsApp JIDs. (#130 — thanks @fahmidme and @ImLukeF)
|
||||
- Send: resolve `send text/file --to` against local contacts, groups, and chats, with `--pick` for non-interactive disambiguation. (#122 — thanks @AndroidPoet)
|
||||
- Store: add local-only `store stats`, `store cleanup`, `chats cleanup`, and `groups prune` commands with dry-run previews and confirmation gates. (#210, #211 — thanks @thedavidweng)
|
||||
|
||||
### Security
|
||||
|
||||
@ -85,13 +131,15 @@
|
||||
|
||||
- README: add a documentation index and complete command quick reference.
|
||||
- Docs: add an overview plus one page for every top-level CLI subcommand.
|
||||
- Docs: add companion integration guidance for safe read-only SQLite, JSON, events, and webhook usage. (#71 — thanks @jaredtribe)
|
||||
- Maintainers: add CODEOWNERS and maintainer contact info.
|
||||
- Agents: add AGENTS.md for AI agent guidance. (#190 — thanks @adhitShet)
|
||||
|
||||
### Chore
|
||||
|
||||
- CI: compile-test the Windows lock package to catch platform regressions. (#188 — thanks @dinakars777)
|
||||
- Dependencies: update Go modules including `whatsmeow`, `go-sqlite3`, `x/*`, and related runtime libs.
|
||||
- CLI: route `version` output through Cobra's configured output stream for easier command tests. (#78 — thanks @nikolasdehor)
|
||||
- Dependencies: update Go modules including `whatsmeow`, `go-sqlite3`, `x/*`, and related runtime libs; refresh the pinned pnpm toolchain.
|
||||
- Refactor: split WhatsApp message parsing into focused text, media, business, and context helpers.
|
||||
- Refactor: inject clocks in app/store paths for deterministic tests.
|
||||
- Version: bump CLI version string to `0.7.0`.
|
||||
|
||||
303
README.md
303
README.md
@ -1,272 +1,139 @@
|
||||
# 🗃️ wacli — WhatsApp CLI: sync, search, send
|
||||
|
||||
WhatsApp CLI built on top of `whatsmeow`, focused on:
|
||||
A scriptable WhatsApp client built on [`whatsmeow`](https://github.com/tulir/whatsmeow). Pairs as a linked WhatsApp Web device, mirrors your messages into a local SQLite store, and gives you offline search, sending, and chat/group/contact management from the command line.
|
||||
|
||||
- Best-effort local sync of message history + continuous capture
|
||||
- Fast offline search
|
||||
- Sending text, mentions, quoted replies, and files
|
||||
- Contact + group management
|
||||
- Scriptable JSON output
|
||||
> Third-party tool. Uses the WhatsApp Web protocol via `whatsmeow`. Not affiliated with WhatsApp.
|
||||
|
||||
This is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow` and is not affiliated with WhatsApp.
|
||||
Full documentation: **<https://wacli.sh>**
|
||||
|
||||
## Status
|
||||
## Features
|
||||
|
||||
Core implementation is in place. The full documentation site lives at [wacli.sh](https://wacli.sh). Start with [docs/overview.md](docs/overview.md) for the command map and [docs/spec.md](docs/spec.md) for design notes.
|
||||
- **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.
|
||||
|
||||
## Documentation
|
||||
## Install
|
||||
|
||||
Full docs site: <https://wacli.sh>.
|
||||
### Homebrew (recommended)
|
||||
|
||||
- [Overview](docs/overview.md): store model, global flags, common flow, command index.
|
||||
- [Auth](docs/auth.md): `auth`, `auth status`, `auth logout`.
|
||||
- [Sync](docs/sync.md): `sync --once`, `sync --follow`, refresh, media download.
|
||||
- [Messages](docs/messages.md): `messages list/search/starred/show/context`.
|
||||
- [Send](docs/send.md): `send text/file/sticker/voice/react`, recipient resolution, replies.
|
||||
- [Media](docs/media.md): `media download`.
|
||||
- [Contacts](docs/contacts.md): `contacts search/show/refresh`, aliases, tags.
|
||||
- [Chats](docs/chats.md): `chats list/show`.
|
||||
- [Groups](docs/groups.md): group list, refresh, info, rename, leave, participants, invites, join.
|
||||
- [History](docs/history.md): `history backfill`.
|
||||
- [Presence](docs/presence.md): `presence typing/paused`.
|
||||
- [Profile](docs/profile.md): `profile set-picture`.
|
||||
- [Doctor](docs/doctor.md): `doctor [--connect]`.
|
||||
- [Version](docs/version.md): `version`, `--version`.
|
||||
- [Completion](docs/completion.md): generated shell completions.
|
||||
- [Help](docs/help.md): `help`, per-command `--help`.
|
||||
- [Release](docs/release.md): release workflow and artifact expectations.
|
||||
```bash
|
||||
brew install steipete/tap/wacli
|
||||
```
|
||||
|
||||
## Major features
|
||||
If a Linux install reports `Binary was compiled with 'CGO_ENABLED=0'`, run `brew update && brew reinstall steipete/tap/wacli`.
|
||||
|
||||
- **Auth + sync**: `auth` shows QR login and bootstraps sync; `sync` is non-interactive, can run once or follow continuously, and can refresh contacts/groups.
|
||||
- **Offline message store**: local SQLite store with FTS5 search when available and LIKE fallback.
|
||||
- **Message tools**: list/search/show/context with chat, sender, direction, time, order, and media-type filters.
|
||||
- **Sending**: send text, mentions, quoted replies, stickers, and image/video/audio/document files with captions, MIME override, and custom display filenames. Sends keep a short retry-receipt grace window, and rapid repeated sends warn on stderr.
|
||||
- **Media**: download synced message media on demand, or download in the background during auth/sync; send-file uploads and downloads are capped at 100 MiB.
|
||||
- **Contacts/chats/groups**: search/show contacts, local aliases/tags, list/show chats, refresh/list/info/rename groups, manage participants, invite links, join, and leave; left groups are hidden after leave.
|
||||
- **Presence**: send typing/paused indicators.
|
||||
- **Profile**: set the authenticated account profile picture from JPEG or PNG input.
|
||||
- **Diagnostics + safety**: `doctor`, read-only mode, store locks with lock-owner reporting, lock waiting, owner-only database permissions, panic recovery, reconnect bounds, and bounded media queue backpressure.
|
||||
- **CLI UX**: human-readable tables by default; `--json` for scripts; `--full` to avoid truncation.
|
||||
### Build from source
|
||||
|
||||
## Install / Build
|
||||
`wacli` uses `go-sqlite3`, so cgo + a C compiler are required.
|
||||
|
||||
Choose **one** of the following options.
|
||||
If you install via Homebrew, you can skip the local build step.
|
||||
- macOS: Xcode Command Line Tools.
|
||||
- Debian/Ubuntu: `sudo apt install build-essential`.
|
||||
|
||||
### Option A: Install via Homebrew (tap)
|
||||
```bash
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
|
||||
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
|
||||
```
|
||||
|
||||
- `brew install steipete/tap/wacli`
|
||||
For local development:
|
||||
|
||||
If a Linux install from the tap reports `Binary was compiled with 'CGO_ENABLED=0'`,
|
||||
update the tap and rebuild the formula:
|
||||
|
||||
- `brew update`
|
||||
- `brew reinstall steipete/tap/wacli`
|
||||
|
||||
### Option B: Build locally
|
||||
|
||||
`wacli` uses `go-sqlite3`, so local builds require cgo and a C compiler:
|
||||
|
||||
- macOS: Xcode Command Line Tools are enough.
|
||||
- Debian/Ubuntu: `sudo apt install build-essential`
|
||||
|
||||
Build:
|
||||
|
||||
- `CGO_ENABLED=1 go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli`
|
||||
|
||||
Run (local build only):
|
||||
|
||||
- `./dist/wacli --help`
|
||||
```bash
|
||||
git clone https://github.com/openclaw/wacli.git
|
||||
cd wacli
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
|
||||
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
|
||||
./dist/wacli --help
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
Default store directory is the XDG state directory on Linux (`~/.local/state/wacli`) and `~/.wacli` elsewhere. Existing Linux `~/.wacli` stores keep working; override with `--store DIR` or `WACLI_STORE_DIR`.
|
||||
|
||||
```bash
|
||||
# 1) Authenticate (shows QR), then bootstrap sync
|
||||
pnpm wacli auth
|
||||
# or, after building locally: ./dist/wacli auth
|
||||
# 1. Pair (shows QR), then bootstrap sync
|
||||
wacli auth
|
||||
|
||||
# 2) Keep syncing (never shows QR; requires prior auth)
|
||||
pnpm wacli sync --follow
|
||||
# 2. Keep syncing in the background (no QR; needs prior auth)
|
||||
wacli sync --follow
|
||||
|
||||
# Diagnostics
|
||||
pnpm wacli doctor
|
||||
# 3. Search
|
||||
wacli messages search "meeting"
|
||||
|
||||
# Search messages
|
||||
pnpm wacli messages search "meeting"
|
||||
# 4. Send
|
||||
wacli send text --to 1234567890 --message "hello"
|
||||
wacli send file --to mom --file ./pic.jpg --caption "hi"
|
||||
|
||||
# List recent messages from a chat, oldest first
|
||||
pnpm wacli messages list --chat 1234567890@s.whatsapp.net --asc
|
||||
|
||||
# Show context around a message
|
||||
pnpm wacli messages context --chat 1234567890@s.whatsapp.net --id <message-id>
|
||||
|
||||
# Export messages to JSON with a time window
|
||||
pnpm wacli messages export --chat 1234567890@s.whatsapp.net --after 2024-01-01 --before 2024-02-01 --output messages.json
|
||||
|
||||
# Backfill older messages for a chat (best-effort; requires your primary device online)
|
||||
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
|
||||
|
||||
# Download media for a message (after syncing)
|
||||
pnpm wacli media download --chat 1234567890@s.whatsapp.net --id <message-id>
|
||||
|
||||
# Send a message by phone/JID, or by a synced contact/group/chat name
|
||||
pnpm wacli send text --to 1234567890 --message "hello"
|
||||
# Link previews are added automatically for the first http(s) URL; use --no-preview to skip.
|
||||
pnpm wacli send text --to 1234567890 --message "https://example.com" --no-preview
|
||||
# Mention one or more users in a group text.
|
||||
pnpm wacli send text --to "Family" --message "hey @15551234567" --mention +15551234567
|
||||
# Phone numbers can also be passed as +E164 or formatted input like "+1 (234) 567-8900"
|
||||
pnpm wacli send text --to mom --message "hello"
|
||||
pnpm wacli send text --to "Family" --pick 2 --message "hello"
|
||||
|
||||
# Send a quoted reply
|
||||
pnpm wacli send text --to 1234567890 --message "replying" --reply-to <message-id>
|
||||
|
||||
# Send a file
|
||||
pnpm wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
|
||||
# Send a quoted reply with a file
|
||||
pnpm wacli send file --to 1234567890 --file ./pic.jpg --caption "replying" --reply-to <message-id>
|
||||
# Or override display name
|
||||
pnpm wacli send file --to 1234567890 --file /tmp/abc123 --filename report.pdf
|
||||
# Send a 512x512 WebP sticker
|
||||
pnpm wacli send sticker --to 1234567890 --file ./sticker-512.webp
|
||||
# Send an OGG/Opus audio file as a native WhatsApp voice note
|
||||
pnpm wacli send voice --to 1234567890 --file ./voice.ogg
|
||||
|
||||
# React to a message (omit --reaction for the default; use --reaction "" to clear)
|
||||
pnpm wacli send react --to 1234567890 --id <message-id>
|
||||
|
||||
# List groups and manage them
|
||||
pnpm wacli groups list
|
||||
pnpm wacli groups rename --jid 123456789@g.us --name "New name"
|
||||
|
||||
# Send presence indicators
|
||||
pnpm wacli presence typing --to 1234567890
|
||||
pnpm wacli presence paused --to 1234567890
|
||||
# 5. Diagnostics
|
||||
wacli doctor
|
||||
```
|
||||
|
||||
## High-level UX
|
||||
Recipients accept a JID, phone number (E.164 or formatted), channel JID, or a synced contact/group/chat name. Ambiguous names prompt in a TTY; pass `--pick N` in scripts.
|
||||
|
||||
- `wacli auth`: interactive login (shows QR code), then immediately performs initial data sync.
|
||||
- `wacli sync`: non-interactive sync loop (never shows QR; errors if not authenticated).
|
||||
- `wacli sync` warns when local storage is uncapped; use `--max-messages` or `--max-db-size` to bound history growth.
|
||||
- Output is human-readable by default; pass `--json` for machine-readable output.
|
||||
- Pass `--full` to keep full IDs in table output; non-TTY output keeps full IDs automatically.
|
||||
- Pass `--read-only` or set `WACLI_READONLY=1` to block commands that intentionally mutate WhatsApp or the local store.
|
||||
More recipes — replies, mentions, stickers, voice, reactions, channels, history backfill, chat management — live in the [docs](https://wacli.sh).
|
||||
|
||||
## Command surface
|
||||
## Documentation
|
||||
|
||||
Full command docs live under [docs/overview.md](docs/overview.md). Quick reference:
|
||||
| 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 [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE]`
|
||||
- `wacli auth status`
|
||||
- `wacli auth logout`
|
||||
- `wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-messages N] [--max-db-size SIZE] [--download-media] [--refresh-contacts] [--refresh-groups]`
|
||||
- `wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded] [--starred]`
|
||||
- `wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded] [--starred]`
|
||||
- `wacli messages starred [--chat JID] [--limit N] [--after DATE] [--before DATE] [--asc]`
|
||||
- `wacli messages export [--chat JID] [--limit N] [--after DATE] [--before DATE] [--output PATH]`
|
||||
- `wacli messages show --chat JID --id MSG_ID`
|
||||
- `wacli messages context --chat JID --id MSG_ID [--before N] [--after N]`
|
||||
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send file --to RECIPIENT --file PATH [--pick N] [--caption TEXT] [--filename NAME] [--mime TYPE] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send voice --to RECIPIENT --file PATH [--pick N] [--mime TYPE] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]`
|
||||
- `wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID] [--post-send-wait 2s]`
|
||||
- `wacli media download --chat JID --id MSG_ID [--output PATH]`
|
||||
- `wacli contacts search <query>`
|
||||
- `wacli contacts show --jid JID`
|
||||
- `wacli contacts refresh`
|
||||
- `wacli contacts alias set|rm --jid JID [--alias NAME]`
|
||||
- `wacli contacts tags add|rm --jid JID --tag TAG`
|
||||
- `wacli chats list [--query TEXT] [--limit N]`
|
||||
- `wacli chats show --jid JID`
|
||||
- `wacli groups list [--query TEXT] [--limit N]`
|
||||
- `wacli groups refresh`
|
||||
- `wacli groups info --jid GROUP_JID`
|
||||
- `wacli groups rename --jid GROUP_JID --name NAME`
|
||||
- `wacli groups leave --jid GROUP_JID`
|
||||
- `wacli groups participants add|remove|promote|demote --jid GROUP_JID --user PHONE_OR_JID`
|
||||
- `wacli groups invite link get|revoke --jid GROUP_JID`
|
||||
- `wacli groups join --code INVITE_CODE`
|
||||
- `wacli history backfill --chat JID [--count 50] [--requests N]`
|
||||
- `wacli presence typing --to PHONE_OR_JID [--media audio]`
|
||||
- `wacli presence paused --to PHONE_OR_JID`
|
||||
- `wacli profile set-picture IMAGE`
|
||||
- `wacli doctor [--connect]`
|
||||
- `wacli version`
|
||||
- `wacli completion bash|zsh|fish|powershell [--no-descriptions]`
|
||||
- `wacli help [command]`
|
||||
## Configuration
|
||||
|
||||
`RECIPIENT` for `send text/file/sticker/voice` accepts a JID, phone number, or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
|
||||
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.
|
||||
|
||||
## Storage
|
||||
**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
|
||||
|
||||
Defaults to `~/.local/state/wacli` on Linux and `~/.wacli` elsewhere. Existing Linux `~/.wacli` stores are reused when the XDG state store does not exist. Override with `--store DIR`.
|
||||
**Environment overrides:**
|
||||
|
||||
Global flags:
|
||||
|
||||
- `--store DIR`: store directory.
|
||||
- `--json`: JSON output.
|
||||
- `--events`: emit machine-readable NDJSON lifecycle events on stderr for long-running commands, including interrupt signals and command errors.
|
||||
- `--full`: disable table truncation.
|
||||
- `--timeout DURATION`: timeout for non-sync commands.
|
||||
- `--lock-wait DURATION`: wait for the store lock before failing write commands.
|
||||
- `--read-only`: reject commands that intentionally write WhatsApp or the local store.
|
||||
|
||||
## Environment overrides
|
||||
|
||||
- `WACLI_DEVICE_LABEL`: override the linked device label shown in WhatsApp (defaults to `wacli - <OS> (<hostname>)` when detectable).
|
||||
- `WACLI_DEVICE_PLATFORM`: override the linked device platform (defaults to `DESKTOP`; invalid values fall back to `CHROME`).
|
||||
- `WACLI_READONLY`: set to `1`, `true`, `yes`, or `on` to enable read-only mode.
|
||||
- `WACLI_SYNC_MAX_MESSAGES`: stop `auth` bootstrap sync or `sync` before storing more than this many total local messages.
|
||||
- `WACLI_SYNC_MAX_DB_SIZE`: stop `auth` bootstrap sync or `sync` when `wacli.db` plus SQLite sidecars reaches a size such as `500MB` or `2GB`.
|
||||
- `WACLI_STORE_DIR`: override the default store directory.
|
||||
| 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.
|
||||
- Backfill skips automatic initial history-sync blob downloads and only processes on-demand responses, which keeps memory use bounded on small Linux/ARM devices.
|
||||
- Recommended `--count` is `50` per request; maximum is `500`.
|
||||
- Maximum `--requests` per run is `100`.
|
||||
|
||||
### Backfill one chat
|
||||
- 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 \
|
||||
wacli --json chats list --limit 100000 \
|
||||
| jq -r '.data[].JID' \
|
||||
| while read -r jid; do
|
||||
pnpm -s wacli -- history backfill --chat "$jid" --requests 3 --count 50
|
||||
wacli history backfill --chat "$jid" --requests 3 --count 50
|
||||
done
|
||||
```
|
||||
|
||||
## Prior art / credit
|
||||
## Credits
|
||||
|
||||
This project is heavily inspired by (and learns from) the excellent `whatsapp-cli` by Vicente Reig:
|
||||
Heavily inspired by [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli) by Vicente Reig.
|
||||
|
||||
- [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli)
|
||||
## Maintainers
|
||||
|
||||
- Created by [@steipete](https://github.com/steipete)
|
||||
- Currently maintained by [@dinakars777](https://github.com/dinakars777)
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE`.
|
||||
|
||||
## Maintainers
|
||||
- Created by [@steipete](https://github.com/steipete)
|
||||
- Currently maintained by [@dinakars777](https://github.com/dinakars777)
|
||||
See [`LICENSE`](LICENSE).
|
||||
|
||||
289
cmd/wacli/accounts.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
@ -9,75 +9,34 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
appPkg "github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
appPkg "github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
type authOptions struct {
|
||||
follow bool
|
||||
idleExit time.Duration
|
||||
downloadMedia bool
|
||||
qrFormat string
|
||||
phone string
|
||||
}
|
||||
|
||||
type validatedAuthOptions struct {
|
||||
qrFormat string
|
||||
pairPhone string
|
||||
}
|
||||
|
||||
func newAuthCmd(flags *rootFlags) *cobra.Command {
|
||||
var follow bool
|
||||
var idleExit time.Duration
|
||||
var downloadMedia bool
|
||||
var qrFormat string
|
||||
var phone string
|
||||
opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "auth",
|
||||
Short: "Authenticate with WhatsApp (QR) and bootstrap sync",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
qrFormat, err := normalizeAuthQRFormat(qrFormat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON && qrFormat == "text" {
|
||||
return fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout")
|
||||
}
|
||||
pairPhone, err := normalizePairPhone(phone)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
|
||||
defer stop()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, true, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
mode := appPkg.SyncModeBootstrap
|
||||
if follow {
|
||||
mode = appPkg.SyncModeFollow
|
||||
}
|
||||
|
||||
if a.Events().Enabled() {
|
||||
_ = a.Events().Emit("auth_starting", nil)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, "Starting authentication…")
|
||||
}
|
||||
res, err := a.Sync(ctx, appPkg.SyncOptions{
|
||||
Mode: mode,
|
||||
AllowQR: true,
|
||||
DownloadMedia: downloadMedia,
|
||||
RefreshContacts: true,
|
||||
RefreshGroups: true,
|
||||
IdleExit: idleExit,
|
||||
OnQRCode: authQRWriter(qrFormat, os.Stdout, os.Stderr, a.Events()),
|
||||
PairPhoneNumber: pairPhone,
|
||||
OnPairCode: authPairCodeWriter(pairPhone, os.Stderr, a.Events()),
|
||||
MaxMessages: maxMessages,
|
||||
MaxDBSizeBytes: maxDBSize,
|
||||
WarnNoLimits: true,
|
||||
})
|
||||
res, err := runAuth(flags, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -94,11 +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")
|
||||
cmd.Flags().StringVar(&qrFormat, "qr-format", "terminal", "QR output format: terminal or text")
|
||||
cmd.Flags().StringVar(&phone, "phone", "", "pair by phone number instead of QR code")
|
||||
addAuthFlags(cmd, &opts)
|
||||
|
||||
cmd.AddCommand(newAuthStatusCmd(flags))
|
||||
cmd.AddCommand(newAuthLogoutCmd(flags))
|
||||
@ -106,6 +61,77 @@ 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 == "" {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -11,30 +11,56 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func newChatsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "chats",
|
||||
Short: "List 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()
|
||||
|
||||
@ -44,7 +70,15 @@ 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
|
||||
}
|
||||
@ -55,13 +89,13 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
fullOutput := fullTableOutput(flags.fullOutput)
|
||||
w := newTableWriter(os.Stdout)
|
||||
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST")
|
||||
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST\tFLAGS")
|
||||
for _, c := range chats {
|
||||
name := c.Name
|
||||
if name == "" {
|
||||
name = c.JID
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Kind, tableCell(name, 28, fullOutput), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", c.Kind, tableCell(name, 28, fullOutput), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"), chatFlagsString(c))
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
@ -69,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
|
||||
}
|
||||
|
||||
@ -97,7 +139,8 @@ func newChatsShowCmd(flags *rootFlags) *cobra.Command {
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, c)
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "JID: %s\nKind: %s\nName: %s\nLast: %s\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
|
||||
},
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
@ -82,3 +82,16 @@ func TestResolveStoredChatsMergesMappedDuplicates(t *testing.T) {
|
||||
t.Fatalf("merged chat = %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFlagsString(t *testing.T) {
|
||||
got := chatFlagsString(store.Chat{Pinned: true, Archived: true, MutedUntil: -1, Unread: true})
|
||||
if got != "pinned,archived,muted,unread" {
|
||||
t.Fatalf("flags = %q", got)
|
||||
}
|
||||
if err := validateBoolFilter("archived", true, true); err == nil {
|
||||
t.Fatal("expected mutually exclusive filter error")
|
||||
}
|
||||
if err := validateBoolFilter("archived", true, false); err != nil {
|
||||
t.Fatalf("unexpected filter error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newContactsCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -18,6 +18,7 @@ func newContactsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.AddCommand(newContactsSearchCmd(flags))
|
||||
cmd.AddCommand(newContactsShowCmd(flags))
|
||||
cmd.AddCommand(newContactsRefreshCmd(flags))
|
||||
cmd.AddCommand(newContactsImportSystemCmd(flags))
|
||||
cmd.AddCommand(newContactsAliasCmd(flags))
|
||||
cmd.AddCommand(newContactsTagsCmd(flags))
|
||||
return cmd
|
||||
@ -104,6 +105,9 @@ func newContactsShowCmd(flags *rootFlags) *cobra.Command {
|
||||
if c.Alias != "" {
|
||||
fmt.Fprintf(os.Stdout, "Alias: %s\n", c.Alias)
|
||||
}
|
||||
if c.SystemName != "" {
|
||||
fmt.Fprintf(os.Stdout, "System Name: %s\n", c.SystemName)
|
||||
}
|
||||
if len(c.Tags) > 0 {
|
||||
fmt.Fprintf(os.Stdout, "Tags: %s\n", strings.Join(c.Tags, ", "))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -10,11 +10,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/lock"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/config"
|
||||
"github.com/steipete/wacli/internal/lock"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
)
|
||||
|
||||
func parseLockOwnerPID(lockInfo string) int {
|
||||
@ -116,11 +115,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
|
||||
storeDir := flags.storeDir
|
||||
if storeDir == "" {
|
||||
storeDir = config.DefaultStoreDir()
|
||||
storeDir, err := resolveStoreDir(flags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
storeDir, _ = filepath.Abs(storeDir)
|
||||
|
||||
var lockHeld bool
|
||||
var lockInfo string
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestParseLockOwnerPID(t *testing.T) {
|
||||
|
||||
@ -15,5 +15,6 @@ func newGroupsCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.AddCommand(newGroupsInviteCmd(flags))
|
||||
cmd.AddCommand(newGroupsJoinCmd(flags))
|
||||
cmd.AddCommand(newGroupsLeaveCmd(flags))
|
||||
cmd.AddCommand(newGroupsPruneCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
@ -56,10 +56,16 @@ func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
|
||||
return out.WriteJSON(os.Stdout, info)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nCreated: %s\nParticipants: %d\n",
|
||||
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nType: %s\n",
|
||||
info.JID.String(),
|
||||
info.GroupName.Name,
|
||||
info.OwnerJID.String(),
|
||||
groupKindLabel(info.IsParent, info.LinkedParentJID.String()),
|
||||
)
|
||||
if !info.LinkedParentJID.IsEmpty() {
|
||||
fmt.Fprintf(os.Stdout, "Parent: %s\n", info.LinkedParentJID.String())
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "Created: %s\nParticipants: %d\n",
|
||||
info.GroupCreated.Local().Format(time.RFC3339),
|
||||
len(info.Participants),
|
||||
)
|
||||
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -6,9 +6,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
@ -16,7 +16,14 @@ func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
if err := db.UpsertGroup(info.JID.String(), info.GroupName.Name, info.OwnerJID.String(), info.GroupCreated); err != nil {
|
||||
if err := db.UpsertGroupWithHierarchy(
|
||||
info.JID.String(),
|
||||
info.GroupName.Name,
|
||||
info.OwnerJID.String(),
|
||||
info.GroupCreated,
|
||||
info.IsParent,
|
||||
info.LinkedParentJID.String(),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
var ps []store.GroupParticipant
|
||||
@ -35,3 +42,13 @@ func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
|
||||
}
|
||||
return db.ReplaceGroupParticipants(info.JID.String(), ps)
|
||||
}
|
||||
|
||||
func groupKindLabel(isParent bool, linkedParentJID string) string {
|
||||
if isParent {
|
||||
return "community"
|
||||
}
|
||||
if linkedParentJID != "" {
|
||||
return "subgroup"
|
||||
}
|
||||
return "group"
|
||||
}
|
||||
|
||||
135
cmd/wacli/groups_prune.go
Normal file
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
|
||||
}
|
||||
@ -6,8 +6,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -88,13 +88,23 @@ func newGroupsListCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
fullOutput := fullTableOutput(flags.fullOutput)
|
||||
w := newTableWriter(os.Stdout)
|
||||
fmt.Fprintln(w, "NAME\tJID\tCREATED")
|
||||
fmt.Fprintln(w, "NAME\tJID\tTYPE\tPARENT\tCREATED")
|
||||
for _, g := range gs {
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.JID
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\n", tableCell(name, 40, fullOutput), g.JID, g.CreatedAt.Local().Format("2006-01-02"))
|
||||
parent := g.LinkedParentJID
|
||||
if parent == "" {
|
||||
parent = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||
tableCell(name, 40, fullOutput),
|
||||
g.JID,
|
||||
groupKindLabel(g.IsParent, g.LinkedParentJID),
|
||||
parent,
|
||||
g.CreatedAt.Local().Format("2006-01-02"),
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
|
||||
@ -38,13 +38,17 @@ func sanitize(s string) string {
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
s = sanitize(s)
|
||||
if max <= 0 || len(s) <= max {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
if len(runes) <= max {
|
||||
return s
|
||||
}
|
||||
if max <= 1 {
|
||||
return s[:max]
|
||||
return string(runes[:max])
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
|
||||
func fullTableOutput(forceFull bool) bool {
|
||||
|
||||
@ -2,23 +2,129 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newHistoryCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "history",
|
||||
Short: "History backfill (best-effort; requires prior auth)",
|
||||
Short: "History coverage and backfill",
|
||||
}
|
||||
cmd.AddCommand(newHistoryCoverageCmd(flags))
|
||||
cmd.AddCommand(newHistoryFillCmd(flags))
|
||||
cmd.AddCommand(newHistoryBackfillCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHistoryCoverageCmd(flags *rootFlags) *cobra.Command {
|
||||
var chats []string
|
||||
var query string
|
||||
var kind string
|
||||
var limit int
|
||||
var includeBlocked bool
|
||||
var onlyActionable bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "coverage",
|
||||
Short: "Show local archive coverage by chat",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := withTimeout(cmd.Context(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, false, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
coverage, err := a.DB().ListHistoryCoverage(store.ListHistoryCoverageParams{
|
||||
ChatJIDs: chats,
|
||||
Query: query,
|
||||
Kind: kind,
|
||||
Limit: limit,
|
||||
IncludeBlocked: includeBlocked,
|
||||
OnlyActionable: onlyActionable,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{"coverage": coverage})
|
||||
}
|
||||
return writeHistoryCoverageTable(os.Stdout, coverage, fullTableOutput(flags.fullOutput), false)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringSliceVar(&chats, "chat", nil, "chat JID to inspect (repeatable)")
|
||||
cmd.Flags().StringVar(&query, "query", "", "filter chats by local name or JID")
|
||||
cmd.Flags().StringVar(&kind, "kind", "", "chat kind filter (dm|group|broadcast|newsletter|unknown)")
|
||||
cmd.Flags().IntVar(&limit, "limit", 100, "limit rows")
|
||||
cmd.Flags().BoolVar(&includeBlocked, "include-blocked", false, "include chats without a local message anchor")
|
||||
cmd.Flags().BoolVar(&onlyActionable, "only-actionable", false, "show only chats with a local message anchor")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHistoryFillCmd(flags *rootFlags) *cobra.Command {
|
||||
var chats []string
|
||||
var query string
|
||||
var kind string
|
||||
var limit int
|
||||
var dryRun bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "fill",
|
||||
Short: "Plan multi-chat history backfill",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !dryRun {
|
||||
return fmt.Errorf("history fill currently supports --dry-run only; use history backfill --chat JID to request history")
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout(cmd.Context(), flags)
|
||||
defer cancel()
|
||||
|
||||
a, lk, err := newApp(ctx, flags, false, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeApp(a, lk)
|
||||
|
||||
coverage, err := a.DB().ListHistoryCoverage(store.ListHistoryCoverageParams{
|
||||
ChatJIDs: chats,
|
||||
Query: query,
|
||||
Kind: kind,
|
||||
Limit: limit,
|
||||
IncludeBlocked: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
selected := historyFillCandidates(coverage)
|
||||
if flags.asJSON {
|
||||
return out.WriteJSON(os.Stdout, map[string]any{
|
||||
"selected": selected,
|
||||
"coverage": coverage,
|
||||
})
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stdout, "Selected %d chats for fill dry run.\n", len(selected))
|
||||
return writeHistoryCoverageTable(os.Stdout, coverage, fullTableOutput(flags.fullOutput), true)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show which chats would be selected without connecting")
|
||||
cmd.Flags().StringSliceVar(&chats, "chat", nil, "chat JID to consider (repeatable)")
|
||||
cmd.Flags().StringVar(&query, "query", "", "filter chats by local name or JID")
|
||||
cmd.Flags().StringVar(&kind, "kind", "", "chat kind filter (dm|group|broadcast|newsletter|unknown)")
|
||||
cmd.Flags().IntVar(&limit, "limit", 100, "limit rows")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
|
||||
var chat string
|
||||
var count int
|
||||
@ -79,3 +185,73 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.Flags().DurationVar(&idleExit, "idle-exit", 5*time.Second, "exit after being idle (after backfill requests)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func historyFillCandidates(coverage []store.HistoryCoverage) []store.HistoryCoverage {
|
||||
out := make([]store.HistoryCoverage, 0, len(coverage))
|
||||
for _, c := range coverage {
|
||||
if c.Status == store.HistoryCoverageStatusReady {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func writeHistoryCoverageTable(dst io.Writer, coverage []store.HistoryCoverage, fullOutput, includeSelected bool) error {
|
||||
w := newTableWriter(dst)
|
||||
if includeSelected {
|
||||
fmt.Fprintln(w, "SELECTED\tCHAT\tKIND\tMESSAGES\tOLDEST\tNEWEST\tSTATUS\tDETAIL")
|
||||
} else {
|
||||
fmt.Fprintln(w, "CHAT\tKIND\tMESSAGES\tOLDEST\tNEWEST\tSTATUS\tDETAIL")
|
||||
}
|
||||
for _, c := range coverage {
|
||||
name := c.Name
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = c.ChatJID
|
||||
}
|
||||
detail := historyCoverageDetail(c)
|
||||
selected := ""
|
||||
if includeSelected {
|
||||
if c.Status == store.HistoryCoverageStatusReady {
|
||||
selected = "yes"
|
||||
} else {
|
||||
selected = "no"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
|
||||
selected,
|
||||
tableCell(name, 32, fullOutput),
|
||||
c.Kind,
|
||||
c.MessageCount,
|
||||
formatHistoryDate(c.OldestTS),
|
||||
formatHistoryDate(c.NewestTS),
|
||||
c.Status,
|
||||
tableCell(detail, 36, fullOutput),
|
||||
)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
|
||||
tableCell(name, 32, fullOutput),
|
||||
c.Kind,
|
||||
c.MessageCount,
|
||||
formatHistoryDate(c.OldestTS),
|
||||
formatHistoryDate(c.NewestTS),
|
||||
c.Status,
|
||||
tableCell(detail, 36, fullOutput),
|
||||
)
|
||||
}
|
||||
_ = w.Flush()
|
||||
return nil
|
||||
}
|
||||
|
||||
func historyCoverageDetail(c store.HistoryCoverage) string {
|
||||
if c.BlockedReason != "" {
|
||||
return c.BlockedReason
|
||||
}
|
||||
return c.ChatJID
|
||||
}
|
||||
|
||||
func formatHistoryDate(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
return t.Local().Format("2006-01-02")
|
||||
}
|
||||
|
||||
87
cmd/wacli/history_test.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -4,11 +4,16 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
func newMessagesCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -22,6 +27,8 @@ func newMessagesCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.AddCommand(newMessagesShowCmd(flags))
|
||||
cmd.AddCommand(newMessagesContextCmd(flags))
|
||||
cmd.AddCommand(newMessagesExportCmd(flags))
|
||||
cmd.AddCommand(newMessagesDeleteCmd(flags))
|
||||
cmd.AddCommand(newMessagesEditCmd(flags))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@ -458,3 +465,266 @@ func newMessagesExportCmd(flags *rootFlags) *cobra.Command {
|
||||
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
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func writeMessagesList(dst io.Writer, msgs []store.Message, fullOutput bool) error {
|
||||
@ -109,6 +109,12 @@ func writeMessageShow(dst io.Writer, m store.Message) error {
|
||||
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)
|
||||
@ -163,6 +169,12 @@ func messageFromDetail(m store.Message) string {
|
||||
}
|
||||
|
||||
func messageText(m store.Message) string {
|
||||
if m.DeletedForMe {
|
||||
return store.DeletedForMeMessageDisplayText
|
||||
}
|
||||
if m.Revoked {
|
||||
return store.DeletedMessageDisplayText
|
||||
}
|
||||
if text := strings.TrimSpace(m.DisplayText); text != "" {
|
||||
return text
|
||||
}
|
||||
|
||||
@ -8,9 +8,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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/types"
|
||||
)
|
||||
|
||||
|
||||
@ -9,8 +9,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
@ -34,6 +36,16 @@ func TestTruncate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatePreservesUTF8(t *testing.T) {
|
||||
got := truncate("🙂🙂🙂", 2)
|
||||
if got != "🙂…" {
|
||||
t.Fatalf("truncate emoji = %q, want first rune plus ellipsis", got)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("truncate produced invalid UTF-8: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateForDisplay(t *testing.T) {
|
||||
const longID = "3EB0B0E8A1B2C3D4E5F6A7B8C9D0"
|
||||
if got := tableCell(longID, 14, true); got != longID {
|
||||
@ -197,6 +209,77 @@ func TestMessagesExportCommandExposesDateFilters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"))
|
||||
|
||||
@ -6,9 +6,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -11,8 +11,8 @@ import (
|
||||
_ "image/png"
|
||||
"os"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
// profileMaxPx is the max dimension WhatsApp accepts for profile pictures.
|
||||
|
||||
@ -8,9 +8,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/wacli/internal/resolve"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/resolve"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -9,17 +9,20 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/config"
|
||||
"github.com/openclaw/wacli/internal/lock"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/config"
|
||||
"github.com/steipete/wacli/internal/lock"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
var version = "0.7.0"
|
||||
var version = "0.8.1"
|
||||
|
||||
const docsURL = "https://wacli.sh"
|
||||
|
||||
type rootFlags struct {
|
||||
storeDir string
|
||||
account string
|
||||
asJSON bool
|
||||
fullOutput bool
|
||||
events bool
|
||||
@ -33,6 +36,8 @@ func execute(args []string) error {
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "wacli",
|
||||
Short: "WhatsApp CLI: sync, search, send",
|
||||
Long: "wacli is a WhatsApp CLI for syncing, searching, and sending from local scripts.\n\nDocs: " + docsURL,
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
Version: version,
|
||||
@ -40,6 +45,7 @@ func execute(args []string) error {
|
||||
rootCmd.SetVersionTemplate("wacli {{.Version}}\n")
|
||||
|
||||
rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: $WACLI_STORE_DIR, XDG state dir on Linux, or ~/.wacli)")
|
||||
rootCmd.PersistentFlags().StringVar(&flags.account, "account", "", "named account from config.yaml")
|
||||
rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "output JSON instead of human-readable text")
|
||||
rootCmd.PersistentFlags().BoolVar(&flags.fullOutput, "full", false, "disable truncation in table output")
|
||||
rootCmd.PersistentFlags().BoolVar(&flags.events, "events", false, "emit machine-readable NDJSON lifecycle events on stderr")
|
||||
@ -48,6 +54,7 @@ func execute(args []string) error {
|
||||
rootCmd.PersistentFlags().BoolVar(&flags.readOnly, "read-only", false, "reject commands that intentionally write WhatsApp or the local store (or set WACLI_READONLY=1)")
|
||||
|
||||
rootCmd.AddCommand(newVersionCmd())
|
||||
rootCmd.AddCommand(newAccountsCmd(&flags))
|
||||
rootCmd.AddCommand(newDoctorCmd(&flags))
|
||||
rootCmd.AddCommand(newAuthCmd(&flags))
|
||||
rootCmd.AddCommand(newSyncCmd(&flags))
|
||||
@ -57,9 +64,12 @@ 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 {
|
||||
@ -81,11 +91,13 @@ func writeRootError(flags rootFlags, err error) {
|
||||
}
|
||||
|
||||
func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) {
|
||||
storeDir := resolveStoreDir(flags)
|
||||
storeDir, err := resolveStoreDir(flags)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var lk *lock.Lock
|
||||
if needLock {
|
||||
var err error
|
||||
lk, err = lock.AcquireWithTimeout(ctx, storeDir, flags.lockWait)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@ -109,16 +121,43 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed
|
||||
return a, lk, nil
|
||||
}
|
||||
|
||||
func resolveStoreDir(flags *rootFlags) string {
|
||||
func resolveStoreDir(flags *rootFlags) (string, error) {
|
||||
storeDir := ""
|
||||
account := ""
|
||||
if flags != nil {
|
||||
storeDir = flags.storeDir
|
||||
account = strings.TrimSpace(flags.account)
|
||||
}
|
||||
if storeDir == "" {
|
||||
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
|
||||
return storeDir, nil
|
||||
}
|
||||
|
||||
func (f *rootFlags) isReadOnly() bool {
|
||||
|
||||
@ -6,8 +6,11 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/openclaw/wacli/internal/config"
|
||||
)
|
||||
|
||||
func captureRootStderr(t *testing.T, fn func()) string {
|
||||
@ -32,6 +35,28 @@ func captureRootStderr(t *testing.T, fn func()) string {
|
||||
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"))
|
||||
@ -71,3 +96,64 @@ func TestRootFlagsReadOnlyEnv(t *testing.T) {
|
||||
t.Fatal("isReadOnly = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStoreDirAccount(t *testing.T) {
|
||||
isolateAccountConfigHome(t)
|
||||
cfgPath := config.DefaultConfigPath()
|
||||
cfg := &config.AccountsConfig{
|
||||
Accounts: map[string]config.AccountEntry{
|
||||
"work": {Store: "accounts/work"},
|
||||
},
|
||||
}
|
||||
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := resolveStoreDir(&rootFlags{account: "work"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveStoreDir: %v", err)
|
||||
}
|
||||
want := filepath.Join(filepath.Dir(cfgPath), "accounts", "work")
|
||||
if got != want {
|
||||
t.Fatalf("storeDir = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStoreDirStoreAndAccountConflict(t *testing.T) {
|
||||
_, err := resolveStoreDir(&rootFlags{storeDir: "/tmp/wacli", account: "work"})
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
|
||||
t.Fatalf("resolveStoreDir error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveStoreDirEnvBeatsDefaultAccount(t *testing.T) {
|
||||
isolateAccountConfigHome(t)
|
||||
cfgPath := config.DefaultConfigPath()
|
||||
cfg := &config.AccountsConfig{
|
||||
DefaultAccount: "work",
|
||||
Accounts: map[string]config.AccountEntry{
|
||||
"work": {Store: "accounts/work"},
|
||||
},
|
||||
}
|
||||
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envStore := filepath.Join(t.TempDir(), "env-store")
|
||||
t.Setenv(config.EnvStoreDir, envStore)
|
||||
|
||||
got, err := resolveStoreDir(&rootFlags{})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveStoreDir: %v", err)
|
||||
}
|
||||
if got != envStore {
|
||||
t.Fatalf("storeDir = %q, want %q", got, envStore)
|
||||
}
|
||||
}
|
||||
|
||||
func isolateAccountConfigHome(t *testing.T) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
|
||||
t.Setenv(config.EnvStoreDir, "")
|
||||
}
|
||||
|
||||
@ -9,12 +9,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/linkpreview"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/linkpreview"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
"google.golang.org/protobuf/proto"
|
||||
@ -41,6 +41,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
|
||||
var replyTo string
|
||||
var replyToSender string
|
||||
var noPreview bool
|
||||
var messageEscapes bool
|
||||
postSendWait := postSendRetryReceiptWait
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@ -53,6 +54,13 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
if messageEscapes {
|
||||
decoded, err := decodeMessageEscapes(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message = decoded
|
||||
}
|
||||
|
||||
ctx, cancel := withTimeout(context.Background(), flags)
|
||||
defer cancel()
|
||||
@ -144,6 +152,7 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
|
||||
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
|
||||
}
|
||||
@ -181,6 +190,36 @@ func fetchLinkPreview(ctx context.Context, text string, disabled bool) *linkprev
|
||||
return preview
|
||||
}
|
||||
|
||||
func decodeMessageEscapes(s string) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '\\' {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(s) {
|
||||
return "", fmt.Errorf(`unfinished escape sequence in --message; supported escapes: \n, \r, \t, \\, \"`)
|
||||
}
|
||||
switch s[i] {
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
case '\\':
|
||||
b.WriteByte('\\')
|
||||
case '"':
|
||||
b.WriteByte('"')
|
||||
default:
|
||||
return "", fmt.Errorf(`unsupported escape sequence \%c in --message; supported escapes: \n, \r, \t, \\, \"`, s[i])
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func buildTextMessage(db *store.DB, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview, mentionedJIDs []string) (*waProto.Message, bool, error) {
|
||||
info, err := buildTextContextInfo(db, to, replyTo, replyToSender, mentionedJIDs)
|
||||
if err != nil {
|
||||
|
||||
@ -21,9 +21,9 @@ import (
|
||||
"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"
|
||||
@ -81,16 +81,32 @@ 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{}
|
||||
replyContext, err := buildReplyContextInfo(a.DB(), to, opts.replyTo, opts.replyToSender)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
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 {
|
||||
@ -133,7 +149,12 @@ func sendFile(ctx context.Context, a interface {
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -296,6 +317,9 @@ func attachSendFileReplyContext(msg *waProto.Message, info *waProto.ContextInfo)
|
||||
}
|
||||
|
||||
func chatKindFromJID(j types.JID) string {
|
||||
if j.Server == types.NewsletterServer {
|
||||
return "newsletter"
|
||||
}
|
||||
if j.Server == types.GroupServer {
|
||||
return "group"
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newSendFileCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"go.mau.fi/whatsmeow"
|
||||
)
|
||||
|
||||
|
||||
@ -11,10 +11,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/lock"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"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"
|
||||
)
|
||||
|
||||
@ -65,7 +65,11 @@ func sendDelegateSocketPath(storeDir string) string {
|
||||
func delegateSend(ctx context.Context, flags *rootFlags, req sendDelegateRequest) (sendDelegateResponse, error) {
|
||||
req.Version = sendDelegateVersion
|
||||
req.TimeoutMS = durationMillis(flags.timeout)
|
||||
path := sendDelegateSocketPath(resolveStoreDir(flags))
|
||||
storeDir, err := resolveStoreDir(flags)
|
||||
if err != nil {
|
||||
return sendDelegateResponse{}, err
|
||||
}
|
||||
path := sendDelegateSocketPath(storeDir)
|
||||
|
||||
var d net.Dialer
|
||||
conn, err := d.DialContext(ctx, "unix", path)
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/wacli/internal/lock"
|
||||
"github.com/openclaw/wacli/internal/lock"
|
||||
)
|
||||
|
||||
func TestTryDelegateSendFallsBackWhenSocketUnavailable(t *testing.T) {
|
||||
|
||||
@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -8,9 +8,9 @@ import (
|
||||
"path/filepath"
|
||||
"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"
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newSendStickerCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -5,8 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/linkpreview"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/linkpreview"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
@ -277,6 +277,13 @@ func TestSendTextCommandExposesNoPreviewFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -284,6 +291,24 @@ func TestSendTextCommandExposesMentionFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newSendVoiceCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
)
|
||||
|
||||
// signalContext returns a context that is cancelled on the first SIGINT/SIGTERM.
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
)
|
||||
|
||||
func TestSignalContextWithEventsKeepsStderrNDJSON(t *testing.T) {
|
||||
|
||||
@ -13,13 +13,14 @@ const (
|
||||
)
|
||||
|
||||
type syncStorageLimitFlags struct {
|
||||
maxMessages int64
|
||||
maxDBSize string
|
||||
maxMessages int64
|
||||
maxMessagesSet bool
|
||||
maxDBSize string
|
||||
}
|
||||
|
||||
func resolveSyncStorageLimits(flags syncStorageLimitFlags) (int64, int64, error) {
|
||||
maxMessages := flags.maxMessages
|
||||
if maxMessages <= 0 {
|
||||
if !flags.maxMessagesSet && maxMessages <= 0 {
|
||||
raw := strings.TrimSpace(os.Getenv(envSyncMaxMessages))
|
||||
if raw != "" {
|
||||
n, err := strconv.ParseInt(raw, 10, 64)
|
||||
|
||||
@ -64,3 +64,18 @@ func TestResolveSyncStorageLimitsFlagsOverrideEnv(t *testing.T) {
|
||||
t.Fatalf("maxDBSize = %d, want 4MiB", maxDBSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSyncStorageLimitsExplicitZeroMaxMessagesOverridesEnv(t *testing.T) {
|
||||
t.Setenv(envSyncMaxMessages, "123")
|
||||
|
||||
maxMessages, _, err := resolveSyncStorageLimits(syncStorageLimitFlags{
|
||||
maxMessages: 0,
|
||||
maxMessagesSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSyncStorageLimits: %v", err)
|
||||
}
|
||||
if maxMessages != 0 {
|
||||
t.Fatalf("maxMessages = %d, want explicit unlimited", maxMessages)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ -6,9 +6,9 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
appPkg "github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
appPkg "github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newSyncCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -19,6 +19,9 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
|
||||
var downloadMedia bool
|
||||
var refreshContacts bool
|
||||
var refreshGroups bool
|
||||
var refreshChannels bool
|
||||
var webhookURL string
|
||||
var webhookSecret string
|
||||
var storage syncStorageLimitFlags
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@ -28,10 +31,14 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
|
||||
if err := flags.requireWritable(); err != nil {
|
||||
return err
|
||||
}
|
||||
storage.maxMessagesSet = cmd.Flags().Changed("max-messages")
|
||||
maxMessages, maxDBSize, err := resolveSyncStorageLimits(storage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if webhookSecret != "" && webhookURL == "" {
|
||||
return fmt.Errorf("--webhook-secret requires --webhook")
|
||||
}
|
||||
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
|
||||
defer stop()
|
||||
|
||||
@ -79,11 +86,14 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
|
||||
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
|
||||
@ -107,6 +117,9 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
|
||||
cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
|
||||
cmd.Flags().BoolVar(&refreshContacts, "refresh-contacts", false, "refresh contacts from session store into local DB")
|
||||
cmd.Flags().BoolVar(&refreshGroups, "refresh-groups", false, "refresh joined groups (live) into local DB")
|
||||
cmd.Flags().BoolVar(&refreshChannels, "refresh-channels", false, "refresh subscribed channels (live) into local DB")
|
||||
cmd.Flags().StringVar(&webhookURL, "webhook", "", "URL to POST live message JSON")
|
||||
cmd.Flags().StringVar(&webhookSecret, "webhook-secret", "", "HMAC-SHA256 secret for X-Wacli-Signature header")
|
||||
cmd.Flags().Int64Var(&storage.maxMessages, "max-messages", 0, "maximum total messages to keep in the local DB before sync stops (0 = unlimited, or WACLI_SYNC_MAX_MESSAGES)")
|
||||
cmd.Flags().StringVar(&storage.maxDBSize, "max-db-size", "", "maximum wacli.db disk usage before sync stops, e.g. 500MB or 2GB (default: WACLI_SYNC_MAX_DB_SIZE or unlimited)")
|
||||
return cmd
|
||||
|
||||
25
cmd/wacli/sync_test.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
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.
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Read when: pairing a store, checking auth state, logging out, or choosing QR vs phone pairing.
|
||||
|
||||
`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store.
|
||||
`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
|
||||
|
||||
@ -10,6 +10,7 @@ Read when: pairing a store, checking auth state, logging out, or choosing QR vs
|
||||
wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE] [--events]
|
||||
wacli auth status
|
||||
wacli auth logout
|
||||
wacli --account work auth status
|
||||
```
|
||||
|
||||
## Notes
|
||||
@ -23,6 +24,7 @@ wacli auth logout
|
||||
- `--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
|
||||
|
||||
|
||||
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"
|
||||
```
|
||||
@ -1,27 +1,47 @@
|
||||
# chats
|
||||
|
||||
Read when: listing known chats or resolving one chat from the local store.
|
||||
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.
|
||||
`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]
|
||||
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 newest known message timestamp.
|
||||
- `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.
|
||||
- Run `wacli sync` or `wacli contacts refresh` to improve chat names.
|
||||
- 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
|
||||
```
|
||||
|
||||
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.
|
||||
@ -1,6 +1,6 @@
|
||||
# contacts
|
||||
|
||||
Read when: finding synced contacts or managing local contact metadata.
|
||||
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.
|
||||
|
||||
@ -10,6 +10,7 @@ Read when: finding synced contacts or managing local contact metadata.
|
||||
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
|
||||
@ -20,7 +21,12 @@ wacli contacts tags rm --jid JID --tag TAG
|
||||
|
||||
- `search` matches alias, full name, push name, first name, business name, phone, and JID.
|
||||
- `refresh` imports contacts from the whatsmeow session store into `wacli.db`.
|
||||
- Local aliases are preferred in contact search and display.
|
||||
- `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
|
||||
@ -29,6 +35,7 @@ wacli contacts tags rm --jid JID --tag TAG
|
||||
wacli contacts search Alice
|
||||
wacli contacts show --jid 1234567890@s.whatsapp.net
|
||||
wacli contacts refresh
|
||||
wacli contacts import-system --dry-run
|
||||
wacli contacts alias set --jid 1234567890@s.whatsapp.net --alias mom
|
||||
wacli contacts tags add --jid 1234567890@s.whatsapp.net --tag family
|
||||
```
|
||||
|
||||
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)"
|
||||
```
|
||||
@ -1,6 +1,6 @@
|
||||
# groups
|
||||
|
||||
Read when: listing, refreshing, inspecting, renaming, joining, leaving, inviting, or managing group participants.
|
||||
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.
|
||||
|
||||
@ -19,15 +19,21 @@ 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.
|
||||
- `refresh` fetches joined groups live and updates local rows.
|
||||
- `info` fetches one group live and persists it.
|
||||
- `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.
|
||||
|
||||
@ -41,4 +47,5 @@ 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
|
||||
```
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
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
|
||||
|
||||
@ -16,5 +17,6 @@ wacli [command] --help
|
||||
```bash
|
||||
wacli help send
|
||||
wacli send text --help
|
||||
wacli docs
|
||||
wacli groups participants add --help
|
||||
```
|
||||
|
||||
@ -2,14 +2,23 @@
|
||||
|
||||
Read when: trying to fetch older messages for a known chat.
|
||||
|
||||
`wacli history backfill` sends on-demand history sync requests to the primary device. This is best-effort and depends on the phone being online and WhatsApp returning older messages.
|
||||
`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.
|
||||
|
||||
## Command
|
||||
## 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.
|
||||
@ -22,6 +31,9 @@ wacli history backfill --chat JID [--count 50] [--requests N] [--wait 1m] [--idl
|
||||
## 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
|
||||
```
|
||||
|
||||
@ -11,23 +11,29 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w
|
||||
## 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 backfill` requests older messages per chat from your primary device; documented as best-effort because WhatsApp Web is.
|
||||
- **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.
|
||||
- **Searching old chats.** Read [Sync](sync.md) for the sync model and [History](history.md) for on-demand backfill.
|
||||
- **Sending from scripts.** Read [Send](send.md) for recipient resolution, replies, mentions, files, and reactions.
|
||||
- **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/steipete/wacli/blob/main/CHANGELOG.md) tracks shipped behavior. WhatsApp Web is not a published API; expect occasional breakage from upstream protocol changes — `wacli` follows `whatsmeow` upstream.
|
||||
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
|
||||
|
||||
@ -39,4 +45,4 @@ Core implementation is in place. The [CHANGELOG](https://github.com/steipete/wac
|
||||
|
||||
`wacli` is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow`. It is **not affiliated with WhatsApp or Meta**. Use at your own risk; pairing as a linked device is subject to WhatsApp's terms.
|
||||
|
||||
Released under the [MIT license](https://github.com/steipete/wacli/blob/main/LICENSE).
|
||||
Released under the [MIT license](https://github.com/openclaw/wacli/blob/main/LICENSE).
|
||||
|
||||
@ -23,7 +23,7 @@ brew reinstall steipete/tap/wacli
|
||||
|
||||
## GitHub releases (raw binaries)
|
||||
|
||||
Download the matching archive from the [latest release](https://github.com/steipete/wacli/releases) and put `wacli` (or `wacli.exe` on Windows) on your `PATH`.
|
||||
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
|
||||
|
||||
@ -36,7 +36,14 @@ Download the matching archive from the [latest release](https://github.com/steip
|
||||
Then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/steipete/wacli.git
|
||||
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
|
||||
|
||||
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.
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Read when: listing, searching, exporting, showing, or inspecting local message context.
|
||||
|
||||
`wacli messages` reads from the local store. It does not connect to WhatsApp unless a display path needs session-backed LID mapping.
|
||||
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
|
||||
|
||||
@ -13,6 +13,8 @@ wacli messages starred [--chat JID] [--limit N] [--after DATE] [--before DATE] [
|
||||
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
|
||||
@ -20,6 +22,7 @@ wacli messages context --chat JID --id MSG_ID [--before N] [--after N]
|
||||
- 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`.
|
||||
|
||||
@ -36,6 +39,14 @@ wacli messages context --chat JID --id MSG_ID [--before N] [--after N]
|
||||
- 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.
|
||||
@ -51,4 +62,7 @@ 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
|
||||
```
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Read when: you need the user-facing command map, global flags, store model, or links to command-specific docs.
|
||||
|
||||
`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans.
|
||||
`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
|
||||
|
||||
@ -15,25 +15,33 @@ Read when: you need the user-facing command map, global flags, store model, or l
|
||||
- Write commands acquire the store lock; use `--lock-wait DURATION` to wait.
|
||||
- Use `--read-only` or `WACLI_READONLY=1` to reject commands that write WhatsApp or local state.
|
||||
- Use `sync --max-messages`, `sync --max-db-size`, `WACLI_SYNC_MAX_MESSAGES`, or `WACLI_SYNC_MAX_DB_SIZE` to bound local history growth.
|
||||
- Use `store cleanup`, `chats cleanup`, and `groups prune` to preview and remove stale local rows after sync has already stored them.
|
||||
- Authenticated startup resolves historical `@lid` chat/message rows to phone-number JIDs when the WhatsApp session store has the mapping.
|
||||
- Companion tools should prefer `--json`, `--events`, webhooks, or read-only access to `wacli.db`; see [companion integrations](integrations.md).
|
||||
|
||||
## Command pages
|
||||
|
||||
- [auth](auth.md) - pair, inspect auth status, logout.
|
||||
- [sync](sync.md) - sync messages, contacts, groups, and optional media.
|
||||
- [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.
|
||||
- [chats](chats.md) - list and show known chats.
|
||||
- [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.
|
||||
- [history](history.md) - request older per-chat history from the primary device.
|
||||
- [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
|
||||
|
||||
@ -46,10 +54,12 @@ 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`, or a phone number with common formatting such as `+1 (234) 567-8900`.
|
||||
Commands that accept `PHONE_OR_JID` accept a WhatsApp JID like `1234567890@s.whatsapp.net`, a group JID like `123456789@g.us`, 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.
|
||||
|
||||
@ -24,7 +24,7 @@ wacli auth
|
||||
|
||||
`auth` prints a QR code in your terminal. On your phone, open WhatsApp → **Linked devices** → **Link a device**, scan the QR, and approve. As soon as pairing succeeds, `auth` immediately starts the initial sync — keep it running until it idles out or press `Ctrl+C` once it has caught up.
|
||||
|
||||
If the QR does not scan, try `--qr-format text` (numeric fallback) or pair via phone-number code with `--phone +15551234567`.
|
||||
If the terminal QR does not scan, try `--qr-format text` and render that raw QR payload in another app, or pair via phone-number code with `--phone +15551234567`.
|
||||
|
||||
> Refresh tokens last as long as the linked device stays linked on your phone. Unlinking from the phone (or `wacli auth logout`) ends the session and requires a fresh QR.
|
||||
|
||||
@ -94,10 +94,12 @@ Recipient resolution and disambiguation (`--pick N`, ambiguous-name prompts), li
|
||||
`sync` only stores what WhatsApp Web pushes. To request older messages for a specific chat from your **primary device** (your phone), use:
|
||||
|
||||
```bash
|
||||
wacli history coverage --include-blocked
|
||||
wacli history fill --dry-run --limit 20
|
||||
wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
|
||||
```
|
||||
|
||||
The phone must be online. WhatsApp may not return full history. See [History](history.md) for limits and patterns (loop over chats with `jq`, recommended `--count`/`--requests` ceilings).
|
||||
The phone must be online for `backfill`. WhatsApp may not return full history. See [History](history.md) for coverage planning, limits, and patterns.
|
||||
|
||||
## 7. Diagnostics and safety
|
||||
|
||||
|
||||
@ -30,10 +30,12 @@ early if someone tries to compile it with `CGO_ENABLED=0`.
|
||||
|
||||
## Homebrew Tap
|
||||
|
||||
The release workflow dispatches the `Update Formula` workflow in `steipete/homebrew-tap` after the macOS artifact is published. The tap workflow owns the formula-editing logic and updates both the macOS artifact SHA256 and the Linux source archive SHA256 in `Formula/wacli.rb`.
|
||||
The release workflow dispatches the `Update Formula` workflow in `steipete/homebrew-tap` after the macOS artifact is published when the tap token is configured. The tap workflow owns the formula-editing logic and updates both the macOS artifact SHA256 and the Linux source archive SHA256 in `Formula/wacli.rb`.
|
||||
|
||||
Required repository secret:
|
||||
Optional repository secret:
|
||||
|
||||
- `HOMEBREW_TAP_TOKEN`: token with permission to run workflows in `steipete/homebrew-tap`
|
||||
|
||||
If `HOMEBREW_TAP_TOKEN` is missing, release artifacts are still published and the tap update is skipped with a workflow warning.
|
||||
|
||||
To backfill an existing release, rerun the `release` workflow manually with `tag: vX.Y.Z`.
|
||||
|
||||
@ -2,14 +2,14 @@
|
||||
|
||||
Read when: sending text, files, stickers, quoted replies, or reactions.
|
||||
|
||||
`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. After a successful send, wacli keeps the connection alive briefly so whatsmeow can handle retry receipts from devices that could not decrypt the first copy. Repeated send commands within 5 seconds print a stderr warning so tight loops make WhatsApp rate-limit/account-risk visible.
|
||||
`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. `Sent to ...` and JSON `sent: true` mean WhatsApp accepted the send request and returned a message ID; they do not confirm recipient delivery. After a successful send, wacli keeps the connection alive briefly so whatsmeow can handle retry receipts from devices that could not decrypt the first copy. Repeated send commands within 5 seconds print a stderr warning so tight loops make WhatsApp rate-limit/account-risk visible.
|
||||
|
||||
When `sync --follow` is already running for the same store, send commands delegate the send to that running process instead of opening a second WhatsApp session. This keeps scripts usable while continuous sync owns the store lock.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
wacli send text --to RECIPIENT --message TEXT [--pick N] [--mention USER] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send text --to RECIPIENT --message TEXT [--message-escapes] [--pick N] [--mention USER] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send file --to RECIPIENT --file PATH [--pick N] [--caption TEXT] [--filename NAME] [--mime TYPE] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
wacli send voice --to RECIPIENT --file PATH [--pick N] [--mime TYPE] [--reply-to MSG_ID] [--reply-to-sender JID] [--post-send-wait 2s]
|
||||
@ -19,6 +19,7 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
|
||||
## Recipients
|
||||
|
||||
- `send text`, `send file`, `send sticker`, and `send voice` accept a JID, phone number, or synced contact/group/chat name.
|
||||
- Channel JIDs use `...@newsletter`; `send text` and `send file` can target channels when the authenticated account has posting permission.
|
||||
- If a name matches multiple recipients, interactive terminals prompt.
|
||||
- In scripts, use `--pick N` to choose a displayed match.
|
||||
- Phone numbers may use common formatting such as `+1 (234) 567-8900`.
|
||||
@ -28,6 +29,7 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
|
||||
- `send text` fetches Open Graph metadata for the first `http://` or `https://` URL and sends it as a WhatsApp link preview.
|
||||
- Preview metadata fetches time out after 10 seconds and fall back to plain text.
|
||||
- Pass `--no-preview` to disable link-preview fetching.
|
||||
- `--message` is literal by default. Pass `--message-escapes` to interpret `\n`, `\r`, `\t`, `\\`, and `\"` before sending.
|
||||
- Use repeatable `--mention USER` with a phone number or user JID to add WhatsApp mentions to `send text`.
|
||||
- `--reply-to` quotes a stored message ID.
|
||||
- For unsynced group replies, pass `--reply-to-sender`.
|
||||
@ -43,6 +45,8 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
|
||||
- MIME type is detected automatically unless `--mime` is set.
|
||||
- `--filename` changes the displayed document name.
|
||||
- Captions apply to images, videos, and documents.
|
||||
- Files sent to channels use WhatsApp's unencrypted newsletter media upload path and include the upstream media handle required by `whatsmeow`.
|
||||
- Quoted file replies and `--ptt` voice-note mode are not supported for channel sends.
|
||||
- `send sticker` requires 512x512 WebP input. Static stickers are capped at 100 KiB; animated stickers are capped at 500 KiB and are sent with animation metadata.
|
||||
- `send voice` is a shortcut for `send file --ptt`.
|
||||
- Voice notes require OGG/Opus audio (`audio/ogg; codecs=opus`).
|
||||
@ -52,6 +56,7 @@ wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]
|
||||
|
||||
```bash
|
||||
wacli send text --to mom --message "landed"
|
||||
wacli send text --to mom --message "line1\nline2" --message-escapes
|
||||
wacli send text --to "Family" --pick 2 --message "on my way"
|
||||
wacli send text --to "Family" --message "hey @15551234567" --mention +15551234567
|
||||
wacli send text --to 1234567890 --message "replying" --reply-to ABC123
|
||||
|
||||
52
docs/spec.md
52
docs/spec.md
@ -22,7 +22,7 @@ This document defines the v1 plan for `wacli`: a WhatsApp CLI that syncs message
|
||||
|
||||
## Terminology
|
||||
|
||||
- **JID**: WhatsApp Jabber ID, e.g. `1234567890@s.whatsapp.net` (user) or `123456789@g.us` (group).
|
||||
- **JID**: WhatsApp Jabber ID, e.g. `1234567890@s.whatsapp.net` (user), `123456789@g.us` (group), or `123456789012345@newsletter` (channel).
|
||||
- **Store directory**: directory containing all local state, default `~/.local/state/wacli` on Linux and `~/.wacli` elsewhere.
|
||||
|
||||
## Storage layout
|
||||
@ -96,13 +96,14 @@ Immediately after QR pairing success, `wacli auth` runs a bootstrap sync:
|
||||
### Tables (proposed)
|
||||
|
||||
- `chats`
|
||||
- `jid` (PK), `name`, `kind` (`dm|group|broadcast`), `last_message_ts`, …
|
||||
- `jid` (PK), `name`, `kind` (`dm|group|broadcast|newsletter|unknown`), `last_message_ts`, `archived`, `pinned`, `muted_until`, `unread`, …
|
||||
- `contacts`
|
||||
- `jid` (PK), `push_name`, `full_name`, `business_name`, `phone`, …
|
||||
- `groups`
|
||||
- `jid` (PK), `name`, `owner_jid`, `created_ts`, …
|
||||
- `jid` (PK), `name`, `owner_jid`, `created_ts`, `is_parent`, `linked_parent_jid`, …
|
||||
- `is_parent` marks WhatsApp Communities; `linked_parent_jid` points from a subgroup to its parent Community when WhatsApp exposes that metadata.
|
||||
- `messages`
|
||||
- `rowid` (PK), `chat_jid`, `msg_id`, `sender_jid`, `ts`, `from_me`, `text`, `media_type`, `media_caption`, `filename`, `mime_type`, `direct_path`, hashes/keys, …
|
||||
- `rowid` (PK), `chat_jid`, `msg_id`, `sender_jid`, `ts`, `from_me`, `text`, `display_text`, `revoked`, `deleted_for_me`, `media_type`, `media_caption`, `filename`, `mime_type`, `direct_path`, hashes/keys, …
|
||||
- unique constraint: (`chat_jid`, `msg_id`)
|
||||
- `contact_aliases` (local management)
|
||||
- `jid` (PK/FK), `alias`, `notes`, `tags` (or join table)
|
||||
@ -119,6 +120,7 @@ Approach:
|
||||
- media caption
|
||||
- document filename
|
||||
- (optionally) denormalized sender/chat names for convenience
|
||||
- Revoked and delete-for-me tombstones are excluded from list/search/starred/export results and FTS rows, but remain addressable by direct `messages show`.
|
||||
|
||||
Query behavior:
|
||||
|
||||
@ -135,6 +137,7 @@ Fallback:
|
||||
Global flags:
|
||||
|
||||
- `--store DIR` (default: XDG state dir on Linux, `~/.wacli` elsewhere)
|
||||
- `--account NAME` (named account from `config.yaml`; mutually exclusive with `--store`)
|
||||
- `--json` (default: human text)
|
||||
- `--full` (disable table truncation; non-TTY output keeps full IDs)
|
||||
- `--timeout DURATION` (non-sync commands; e.g. `5m`)
|
||||
@ -152,20 +155,38 @@ Global flags:
|
||||
- `wacli auth status`
|
||||
- `wacli auth logout`
|
||||
|
||||
### Accounts
|
||||
|
||||
- `wacli accounts list`
|
||||
- `wacli accounts add NAME [--no-auth]`
|
||||
- `wacli accounts use NAME`
|
||||
- `wacli accounts show NAME`
|
||||
- `wacli accounts remove NAME`
|
||||
|
||||
Named accounts resolve to isolated store directories. Account config lives in
|
||||
`<base>/config.yaml`; relative account store paths resolve from that config
|
||||
directory. `--store` remains the direct manual-store escape hatch and cannot be
|
||||
combined with `--account`.
|
||||
|
||||
### Sync
|
||||
|
||||
- `wacli sync [--once] [--follow] [--download-media]`
|
||||
- `wacli sync [--once] [--follow] [--download-media] [--webhook URL] [--webhook-secret SECRET]`
|
||||
|
||||
Notes:
|
||||
|
||||
- `sync` errors if not authenticated (never prints QR).
|
||||
- `--download-media` runs a bounded/concurrent media downloader for messages that contain downloadable media metadata.
|
||||
- `--webhook` posts live message JSON after successful local storage on a bounded background worker.
|
||||
- `--webhook-secret` adds an HMAC-SHA256 `X-Wacli-Signature` header and requires `--webhook`.
|
||||
- Webhook failures and full-queue drops emit warnings but do not fail sync.
|
||||
|
||||
### History backfill (best-effort)
|
||||
|
||||
WhatsApp Web history is best-effort. If you want to try fetching *older* messages for a specific chat, `wacli` can send an on-demand history request to your primary device:
|
||||
|
||||
- `wacli history backfill --chat JID [--count 50] [--requests N]`
|
||||
- `wacli history coverage` inspects local chat/message coverage without connecting.
|
||||
- `wacli history fill --dry-run` plans matching chats with local anchors; it does not write or connect.
|
||||
- Backfill caps: `--count <= 500`, `--requests <= 100`.
|
||||
- During backfill, automatic initial history-sync blob downloads are disabled; only on-demand history-sync notifications are downloaded and stored.
|
||||
|
||||
@ -177,16 +198,19 @@ WhatsApp Web history is best-effort. If you want to try fetching *older* message
|
||||
- `wacli messages export [--chat JID] [--limit N] [--before TS] [--after TS] [--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]`
|
||||
|
||||
### Send
|
||||
|
||||
- `wacli send text --to RECIPIENT --message TEXT [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send text --to RECIPIENT --message TEXT [--message-escapes] [--pick N] [--no-preview] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send file --to RECIPIENT --file PATH [--caption TEXT] [--mime TYPE] [--pick N] [--ptt] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send sticker --to RECIPIENT --file PATH [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send voice --to RECIPIENT --file PATH [--mime TYPE] [--pick N] [--reply-to MSG_ID] [--reply-to-sender JID]`
|
||||
- `wacli send react --to PHONE_OR_JID --id MSG_ID [--reaction TEXT] [--sender JID]`
|
||||
|
||||
`RECIPIENT` accepts a JID, phone number, or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
|
||||
`RECIPIENT` accepts a JID, phone number, channel JID (`...@newsletter`), or synced contact/group/chat name. If a name is ambiguous, interactive terminals prompt; scripts can pass `--pick N`.
|
||||
Sending to channels requires channel posting permission. File sends to channels use WhatsApp's unencrypted newsletter media upload and pass the returned media handle through `whatsmeow.SendRequestExtra`.
|
||||
Text sends automatically include a link preview for the first `http://` or `https://` URL unless `--no-preview` is passed.
|
||||
Voice notes require OGG/Opus audio and use optional `ffprobe`/`ffmpeg` metadata when available.
|
||||
Stickers require 512x512 WebP input and are stored locally as `sticker` media after sending. Static stickers are capped at 100 KiB; animated stickers are capped at 500 KiB and carry animation metadata in the outgoing proto.
|
||||
@ -205,8 +229,13 @@ or writing unexpectedly large payloads in one command.
|
||||
|
||||
### Chats
|
||||
|
||||
- `wacli chats list [--query TEXT]`
|
||||
- `wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]`
|
||||
- `wacli chats show --jid JID`
|
||||
- `wacli chats archive|unarchive --chat CHAT [--pick N]`
|
||||
- `wacli chats pin|unpin --chat CHAT [--pick N]`
|
||||
- `wacli chats mute --chat CHAT [--duration DURATION] [--pick N]`
|
||||
- `wacli chats unmute --chat CHAT [--pick N]`
|
||||
- `wacli chats mark-read|mark-unread --chat CHAT [--pick N]`
|
||||
|
||||
### Groups
|
||||
|
||||
@ -220,6 +249,13 @@ or writing unexpectedly large payloads in one command.
|
||||
- `wacli groups join --code INVITE_CODE`
|
||||
- `wacli groups leave --jid GROUP_JID`
|
||||
|
||||
### Channels
|
||||
|
||||
- `wacli channels list`
|
||||
- `wacli channels info --jid CHANNEL_JID`
|
||||
- `wacli channels join --invite LINK_OR_CODE`
|
||||
- `wacli channels leave --jid CHANNEL_JID`
|
||||
|
||||
## Output formats
|
||||
|
||||
Default: human-readable text (tables / aligned columns; TTY-aware wrapping).
|
||||
|
||||
43
docs/store.md
Normal file
43
docs/store.md
Normal file
@ -0,0 +1,43 @@
|
||||
# store
|
||||
|
||||
Read when: inspecting local SQLite size/counts or pruning old local chat/group rows.
|
||||
|
||||
`wacli store` manages the selected account's local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
wacli store stats
|
||||
wacli store cleanup [--days N] [--dry-run] [--confirm]
|
||||
```
|
||||
|
||||
Related cleanup commands:
|
||||
|
||||
```bash
|
||||
wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]
|
||||
wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `store stats` reads local counts for chats, groups, left groups, and messages.
|
||||
- `store cleanup` removes chats whose known local activity is older than `--days` and deletes their messages through the SQLite chat/message cascade.
|
||||
- `chats cleanup --jid JID` removes one local chat row and its local messages.
|
||||
- `groups prune` removes local group metadata plus the matching local chat/messages for pruned group JIDs.
|
||||
- `groups prune` defaults to groups you have left. `--days N` limits that to groups left more than `N` days ago.
|
||||
- `groups prune --include-active --days N` also prunes active groups whose last known local message is older than `N` days. Groups with no known local activity timestamp are skipped.
|
||||
- Destructive cleanup commands require confirmation unless `--confirm` is passed.
|
||||
- Use `--dry-run` first; it lists what would be deleted without changing the local store.
|
||||
- Use `--read-only` or `WACLI_READONLY=1` to make cleanup commands fail before opening the store for writes.
|
||||
- Use `--account NAME` to target a named account store. Use `--store DIR` for manual stores or migration debugging; it cannot be combined with `--account`.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
wacli store stats
|
||||
wacli store cleanup --days 365 --dry-run
|
||||
wacli chats cleanup --jid 1234567890@s.whatsapp.net --dry-run
|
||||
wacli groups prune --dry-run
|
||||
wacli groups prune --days 180 --dry-run
|
||||
wacli groups prune --include-active --days 365 --dry-run
|
||||
```
|
||||
11
docs/sync.md
11
docs/sync.md
@ -7,7 +7,7 @@ Read when: running continuous capture, one-shot sync, contact/group refresh, or
|
||||
## Command
|
||||
|
||||
```bash
|
||||
wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-messages N] [--max-db-size SIZE] [--download-media] [--refresh-contacts] [--refresh-groups] [--events]
|
||||
wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-messages N] [--max-db-size SIZE] [--download-media] [--refresh-contacts] [--refresh-groups] [--refresh-channels] [--events] [--webhook URL] [--webhook-secret SECRET]
|
||||
```
|
||||
|
||||
## Modes
|
||||
@ -21,10 +21,16 @@ wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-mes
|
||||
- `--download-media` runs a bounded media downloader for sync events.
|
||||
- `--refresh-contacts` imports contacts from the session store.
|
||||
- `--refresh-groups` fetches joined groups live and updates the local DB.
|
||||
- `--refresh-channels` fetches subscribed WhatsApp Channels live and updates local chat rows.
|
||||
- `--webhook URL` posts successfully stored live message events as JSON on a bounded background worker.
|
||||
- `--webhook-secret SECRET` signs webhook payloads with `X-Wacli-Signature: sha256=<hmac>`.
|
||||
- Webhook delivery is best-effort: failures and full-queue drops are logged as warnings and do not stop sync. Retries/backoff are intentionally out of scope for this flag.
|
||||
- If neither storage cap is configured, sync prints one warning because WhatsApp history can grow the local database substantially.
|
||||
- `WACLI_SYNC_MAX_MESSAGES` and `WACLI_SYNC_MAX_DB_SIZE` apply the same caps to `auth` bootstrap sync and `sync`.
|
||||
- While `sync --follow` is running, `send text`, `send file`, `send sticker`, `send voice`, and `send react` commands for the same store are delegated to the running sync process so they do not fail on the store lock.
|
||||
- After connecting, sync fetches WhatsApp chat app-state deltas (`regular_high` and `regular_low`) so starred, delete-for-me, mute, archive, pin, and mark-read changes made while `wacli` was offline are caught up instead of relying only on live push notifications.
|
||||
- If whatsmeow reports an app-state LTHash mismatch, sync asks the primary device for the official recovery snapshot once for that app-state collection. If recovery also fails, the warning is printed and sync keeps handling normal message/history events.
|
||||
- In an interactive terminal, routine connected/history/progress updates share one updating stderr status line. Warnings and errors still print as separate lines so they remain visible.
|
||||
- `--events` emits one NDJSON lifecycle event per stderr line for machine consumers. Routine human progress/status lines, interrupt prompts, and command errors are emitted as events while events are enabled.
|
||||
|
||||
## Examples
|
||||
@ -33,7 +39,8 @@ wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-mes
|
||||
wacli sync --once
|
||||
wacli sync --follow --max-reconnect 10m
|
||||
wacli sync --follow --max-messages 250000 --max-db-size 2GB
|
||||
wacli sync --once --refresh-contacts --refresh-groups
|
||||
wacli sync --once --refresh-contacts --refresh-groups --refresh-channels
|
||||
wacli sync --follow --download-media
|
||||
wacli sync --once --events 2>events.ndjson
|
||||
wacli sync --follow --webhook https://example.com/wacli --webhook-secret "$WACLI_WEBHOOK_SECRET"
|
||||
```
|
||||
|
||||
7
go.mod
7
go.mod
@ -1,4 +1,4 @@
|
||||
module github.com/steipete/wacli
|
||||
module github.com/openclaw/wacli
|
||||
|
||||
go 1.25.0
|
||||
|
||||
@ -6,11 +6,12 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/mdp/qrterminal/v3 v3.2.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7
|
||||
go.mau.fi/whatsmeow v0.0.0-20260505142014-6dd3d24c1ca6
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sys v0.43.0
|
||||
golang.org/x/term v0.42.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@ -20,6 +21,7 @@ require (
|
||||
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
@ -33,5 +35,6 @@ require (
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
|
||||
7
go.sum
7
go.sum
@ -22,8 +22,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@ -57,8 +59,8 @@ go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||
go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8=
|
||||
go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7 h1:jEOI4I7kU+MYUNI1L94rhYXhUg8N9+YUNHVY525aYTc=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260427122815-7514259253a7/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260505142014-6dd3d24c1ca6 h1:Wn0o3TEJygn++2n4AdEU0MbEQyeYIbvgrZGhTsXXjc4=
|
||||
go.mau.fi/whatsmeow v0.0.0-20260505142014-6dd3d24c1ca6/go.mod h1:ijfkzOXauA/Vz/htXEMfOAJSUgglribW5oQeYC9tSSg=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
@ -77,6 +79,7 @@ golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
@ -7,12 +7,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/fsutil"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/fsutil"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"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/proto/waCommon"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
"go.mau.fi/whatsmeow/proto/waHistorySync"
|
||||
"go.mau.fi/whatsmeow/proto/waWeb"
|
||||
@ -44,10 +45,25 @@ type WAClient interface {
|
||||
JoinGroupWithLink(ctx context.Context, code string) (types.JID, error)
|
||||
LeaveGroup(ctx context.Context, group types.JID) error
|
||||
|
||||
GetNewsletterInfoWithInvite(ctx context.Context, key string) (*types.NewsletterMetadata, error)
|
||||
FollowNewsletter(ctx context.Context, jid types.JID) error
|
||||
UnfollowNewsletter(ctx context.Context, jid types.JID) error
|
||||
GetSubscribedNewsletters(ctx context.Context) ([]*types.NewsletterMetadata, error)
|
||||
GetNewsletterInfo(ctx context.Context, jid types.JID) (*types.NewsletterMetadata, error)
|
||||
|
||||
SendText(ctx context.Context, to types.JID, text string) (types.MessageID, error)
|
||||
SendProtoMessage(ctx context.Context, to types.JID, msg *waProto.Message) (types.MessageID, error)
|
||||
SendProtoMessageWithExtra(ctx context.Context, to types.JID, msg *waProto.Message, mediaHandle string) (types.MessageID, error)
|
||||
SendReaction(ctx context.Context, chat, sender types.JID, targetID types.MessageID, reaction string) (types.MessageID, error)
|
||||
RevokeMessage(ctx context.Context, chat types.JID, targetID types.MessageID) (types.MessageID, error)
|
||||
DeleteMessageForMe(ctx context.Context, info types.MessageInfo, deleteMedia bool) error
|
||||
EditMessage(ctx context.Context, chat types.JID, targetID types.MessageID, text string) (types.MessageID, error)
|
||||
ArchiveChat(ctx context.Context, target types.JID, archive bool, lastMsgTS time.Time, lastMsgKey *waCommon.MessageKey) error
|
||||
PinChat(ctx context.Context, target types.JID, pin bool) error
|
||||
MuteChat(ctx context.Context, target types.JID, mute bool, duration time.Duration) error
|
||||
MarkChatAsRead(ctx context.Context, target types.JID, read bool, lastMsgTS time.Time, lastMsgKey *waCommon.MessageKey) error
|
||||
Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
|
||||
UploadNewsletter(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error)
|
||||
DownloadMediaToFile(ctx context.Context, directPath string, encFileHash, fileHash, mediaKey []byte, fileLength uint64, mediaType, mmsType string, targetPath string) (int64, error)
|
||||
|
||||
SendChatPresence(ctx context.Context, jid types.JID, state types.ChatPresence, media types.ChatPresenceMedia) error
|
||||
@ -56,6 +72,7 @@ type WAClient interface {
|
||||
SetManualHistorySyncDownload(enabled bool)
|
||||
DownloadHistorySync(ctx context.Context, notif *waE2E.HistorySyncNotification) (*waHistorySync.HistorySync, error)
|
||||
RequestHistorySyncOnDemand(ctx context.Context, lastKnown types.MessageInfo, count int) (types.MessageID, error)
|
||||
FetchAppState(ctx context.Context, name string, fullSync, onlyIfNotSynced bool) error
|
||||
RequestAppStateRecovery(ctx context.Context, name string) (types.MessageID, error)
|
||||
Logout(ctx context.Context) error
|
||||
LinkedJID() string
|
||||
@ -72,10 +89,12 @@ type Options struct {
|
||||
}
|
||||
|
||||
type App struct {
|
||||
opts Options
|
||||
waMu sync.Mutex
|
||||
wa WAClient
|
||||
db *store.DB
|
||||
opts Options
|
||||
waMu sync.Mutex
|
||||
wa WAClient
|
||||
db *store.DB
|
||||
statusMu sync.Mutex
|
||||
status *syncStatus
|
||||
}
|
||||
|
||||
func New(opts Options) (*App, error) {
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/proto/waCommon"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package app
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
)
|
||||
|
||||
func (a *App) refreshContacts(ctx context.Context) error {
|
||||
if err := a.OpenWA(); err != nil {
|
||||
@ -39,8 +43,30 @@ func (a *App) refreshGroups(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
joined[g.JID.String()] = true
|
||||
_ = a.db.UpsertGroup(g.JID.String(), g.GroupName.Name, g.OwnerJID.String(), g.GroupCreated)
|
||||
_ = a.db.UpsertGroupWithHierarchy(g.JID.String(), g.GroupName.Name, g.OwnerJID.String(), g.GroupCreated, g.IsParent, g.LinkedParentJID.String())
|
||||
_ = a.db.UpsertChat(g.JID.String(), "group", g.GroupName.Name, now)
|
||||
}
|
||||
return a.db.MarkGroupsMissingFrom(joined, now)
|
||||
}
|
||||
|
||||
func (a *App) refreshNewsletters(ctx context.Context) error {
|
||||
if err := a.OpenWA(); err != nil {
|
||||
return err
|
||||
}
|
||||
list, err := a.wa.GetSubscribedNewsletters(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := nowUTC()
|
||||
for _, meta := range list {
|
||||
if meta == nil {
|
||||
continue
|
||||
}
|
||||
name := wa.NewsletterName(meta)
|
||||
if name == "" {
|
||||
name = meta.ID.String()
|
||||
}
|
||||
_ = a.db.UpsertChat(meta.ID.String(), "newsletter", name, now)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -41,10 +41,11 @@ func TestRefreshGroupsStoresGroupsAndChats(t *testing.T) {
|
||||
gid := types.JID{User: "12345", Server: types.GroupServer}
|
||||
created := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
f.groups[gid] = &types.GroupInfo{
|
||||
JID: gid,
|
||||
OwnerJID: types.JID{User: "999", Server: types.DefaultUserServer},
|
||||
GroupName: types.GroupName{Name: "MyGroup"},
|
||||
GroupCreated: created,
|
||||
JID: gid,
|
||||
OwnerJID: types.JID{User: "999", Server: types.DefaultUserServer},
|
||||
GroupName: types.GroupName{Name: "MyGroup"},
|
||||
GroupCreated: created,
|
||||
GroupLinkedParent: types.GroupLinkedParent{LinkedParentJID: types.JID{User: "parent", Server: types.GroupServer}},
|
||||
}
|
||||
|
||||
if err := a.refreshGroups(context.Background()); err != nil {
|
||||
@ -57,6 +58,9 @@ func TestRefreshGroupsStoresGroupsAndChats(t *testing.T) {
|
||||
if len(gs) != 1 || gs[0].JID != gid.String() {
|
||||
t.Fatalf("expected group to be stored, got %+v", gs)
|
||||
}
|
||||
if gs[0].LinkedParentJID != "parent@g.us" {
|
||||
t.Fatalf("expected linked parent to be stored, got %+v", gs[0])
|
||||
}
|
||||
c, err := a.db.GetChat(gid.String())
|
||||
if err != nil {
|
||||
t.Fatalf("GetChat: %v", err)
|
||||
@ -66,6 +70,31 @@ func TestRefreshGroupsStoresGroupsAndChats(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshNewslettersStoresChats(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
f := newFakeWA()
|
||||
a.wa = f
|
||||
|
||||
jid := types.JID{User: "12345", Server: types.NewsletterServer}
|
||||
f.news[jid] = &types.NewsletterMetadata{
|
||||
ID: jid,
|
||||
ThreadMeta: types.NewsletterThreadMetadata{
|
||||
Name: types.NewsletterText{Text: "Launch Notes"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := a.refreshNewsletters(context.Background()); err != nil {
|
||||
t.Fatalf("refreshNewsletters: %v", err)
|
||||
}
|
||||
c, err := a.db.GetChat(jid.String())
|
||||
if err != nil {
|
||||
t.Fatalf("GetChat: %v", err)
|
||||
}
|
||||
if c.Kind != "newsletter" || c.Name != "Launch Notes" {
|
||||
t.Fatalf("expected newsletter chat, got %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshGroupsMarksMissingGroupsLeft(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
f := newFakeWA()
|
||||
|
||||
150
internal/app/chat_state.go
Normal file
150
internal/app/chat_state.go
Normal file
@ -0,0 +1,150 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/proto/waCommon"
|
||||
"go.mau.fi/whatsmeow/proto/waSyncAction"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
"go.mau.fi/whatsmeow/types/events"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func (a *App) ArchiveChat(ctx context.Context, jid types.JID, archive bool) error {
|
||||
chatJID := canonicalJIDString(a.canonicalStoreJID(ctx, jid))
|
||||
lastTS, lastKey := a.latestMessageRange(chatJID)
|
||||
if err := a.wa.ArchiveChat(ctx, jid, archive, lastTS, lastKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.db.SetChatArchived(chatJID, archive)
|
||||
}
|
||||
|
||||
func (a *App) PinChat(ctx context.Context, jid types.JID, pin bool) error {
|
||||
chatJID := canonicalJIDString(a.canonicalStoreJID(ctx, jid))
|
||||
if err := a.wa.PinChat(ctx, jid, pin); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.db.SetChatPinned(chatJID, pin)
|
||||
}
|
||||
|
||||
func (a *App) MuteChat(ctx context.Context, jid types.JID, mute bool, duration time.Duration) error {
|
||||
chatJID := canonicalJIDString(a.canonicalStoreJID(ctx, jid))
|
||||
if err := a.wa.MuteChat(ctx, jid, mute, duration); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.db.SetChatMutedUntil(chatJID, mutedUntilUnix(mute, duration, nowUTC()))
|
||||
}
|
||||
|
||||
func (a *App) MarkChatRead(ctx context.Context, jid types.JID, read bool) error {
|
||||
chatJID := canonicalJIDString(a.canonicalStoreJID(ctx, jid))
|
||||
lastTS, lastKey := a.latestMessageRange(chatJID)
|
||||
if err := a.wa.MarkChatAsRead(ctx, jid, read, lastTS, lastKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.db.SetChatUnread(chatJID, !read)
|
||||
}
|
||||
|
||||
func (a *App) latestMessageRange(chatJID string) (time.Time, *waCommon.MessageKey) {
|
||||
info, err := a.db.GetLatestMessageInfo(chatJID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
a.emitWarning(
|
||||
"chat_state_latest_message_failed",
|
||||
fmt.Sprintf("warning: failed to load latest message for chat state patch: %v", err),
|
||||
map[string]any{"chat_jid": chatJID, "error": err.Error()},
|
||||
)
|
||||
}
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return info.Timestamp, messageKeyFromStore(info)
|
||||
}
|
||||
|
||||
func messageKeyFromStore(info store.MessageInfo) *waCommon.MessageKey {
|
||||
if strings.TrimSpace(info.ChatJID) == "" || strings.TrimSpace(info.MsgID) == "" {
|
||||
return nil
|
||||
}
|
||||
key := &waCommon.MessageKey{
|
||||
RemoteJID: proto.String(info.ChatJID),
|
||||
FromMe: proto.Bool(info.FromMe),
|
||||
ID: proto.String(info.MsgID),
|
||||
}
|
||||
if sender := strings.TrimSpace(info.SenderJID); sender != "" && sender != info.ChatJID {
|
||||
key.Participant = proto.String(sender)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func (a *App) handleChatStateEvent(ctx context.Context, evt interface{}) {
|
||||
switch v := evt.(type) {
|
||||
case *events.Archive:
|
||||
if v == nil || v.JID.IsEmpty() || v.Action == nil {
|
||||
return
|
||||
}
|
||||
chat := a.canonicalStoreJID(ctx, v.JID)
|
||||
if err := a.db.SetChatArchived(canonicalJIDString(chat), v.Action.GetArchived()); err != nil {
|
||||
a.emitChatStateWarning("archive", v.JID, err)
|
||||
}
|
||||
case *events.Pin:
|
||||
if v == nil || v.JID.IsEmpty() || v.Action == nil {
|
||||
return
|
||||
}
|
||||
chat := a.canonicalStoreJID(ctx, v.JID)
|
||||
if err := a.db.SetChatPinned(canonicalJIDString(chat), v.Action.GetPinned()); err != nil {
|
||||
a.emitChatStateWarning("pin", v.JID, err)
|
||||
}
|
||||
case *events.Mute:
|
||||
if v == nil || v.JID.IsEmpty() || v.Action == nil {
|
||||
return
|
||||
}
|
||||
chat := a.canonicalStoreJID(ctx, v.JID)
|
||||
if err := a.db.SetChatMutedUntil(canonicalJIDString(chat), mutedUntilFromAction(v.Action)); err != nil {
|
||||
a.emitChatStateWarning("mute", v.JID, err)
|
||||
}
|
||||
case *events.MarkChatAsRead:
|
||||
if v == nil || v.JID.IsEmpty() || v.Action == nil {
|
||||
return
|
||||
}
|
||||
chat := a.canonicalStoreJID(ctx, v.JID)
|
||||
if err := a.db.SetChatUnread(canonicalJIDString(chat), !v.Action.GetRead()); err != nil {
|
||||
a.emitChatStateWarning("mark_read", v.JID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mutedUntilFromAction(action *waSyncAction.MuteAction) int64 {
|
||||
if action == nil || !action.GetMuted() {
|
||||
return 0
|
||||
}
|
||||
ms := action.GetMuteEndTimestamp()
|
||||
if ms < 0 {
|
||||
return -1
|
||||
}
|
||||
if ms > 0 {
|
||||
return time.UnixMilli(ms).Unix()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func mutedUntilUnix(mute bool, duration time.Duration, base time.Time) int64 {
|
||||
if !mute {
|
||||
return 0
|
||||
}
|
||||
if duration <= 0 {
|
||||
return -1
|
||||
}
|
||||
return base.Add(duration).Unix()
|
||||
}
|
||||
|
||||
func (a *App) emitChatStateWarning(kind string, jid types.JID, err error) {
|
||||
a.emitWarning(
|
||||
"chat_state_store_failed",
|
||||
fmt.Sprintf("warning: failed to store %s chat state for %s: %v", kind, jid, err),
|
||||
map[string]any{"kind": kind, "jid": jid.String(), "error": err.Error()},
|
||||
)
|
||||
}
|
||||
@ -21,6 +21,21 @@ func (a *App) emitOrPrint(event string, data map[string]any, format string, args
|
||||
a.emitEvent(event, data)
|
||||
return
|
||||
}
|
||||
if st := a.currentSyncStatus(); st != nil {
|
||||
switch event {
|
||||
case "connected":
|
||||
st.Connected()
|
||||
case "history_sync":
|
||||
conversations, _ := data["conversations"].(int)
|
||||
st.HistorySync(conversations)
|
||||
case "progress":
|
||||
messages, _ := data["messages_synced"].(int64)
|
||||
st.Progress(messages)
|
||||
default:
|
||||
st.PrintLine(fmt.Sprintf(format, args...))
|
||||
}
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, format, args...)
|
||||
}
|
||||
|
||||
@ -34,5 +49,9 @@ func (a *App) emitWarning(code, message string, data map[string]any) {
|
||||
a.emitEvent("warning", data)
|
||||
return
|
||||
}
|
||||
if st := a.currentSyncStatus(); st != nil {
|
||||
st.WarnLine(message)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, message)
|
||||
}
|
||||
|
||||
@ -5,12 +5,14 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/proto/waCommon"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
"go.mau.fi/whatsmeow/proto/waHistorySync"
|
||||
"go.mau.fi/whatsmeow/proto/waWeb"
|
||||
@ -35,6 +37,7 @@ type fakeWA struct {
|
||||
|
||||
contacts map[types.JID]types.ContactInfo
|
||||
groups map[types.JID]*types.GroupInfo
|
||||
news map[types.JID]*types.NewsletterMetadata
|
||||
lids map[types.JID]types.JID
|
||||
|
||||
decryptedReaction *waProto.ReactionMessage
|
||||
@ -43,9 +46,47 @@ type fakeWA struct {
|
||||
onDemandEvent func(lastKnown types.MessageInfo, count int) interface{}
|
||||
downloadHistory func(notif *waE2E.HistorySyncNotification) (*waHistorySync.HistorySync, error)
|
||||
appStateRecoveryErr error
|
||||
appStateFetchErr error
|
||||
appStateFetchEvent func(name string, fullSync, onlyIfNotSynced bool) interface{}
|
||||
archiveCalls []fakeArchiveCall
|
||||
pinCalls []fakePinCall
|
||||
muteCalls []fakeMuteCall
|
||||
markReadCalls []fakeMarkReadCall
|
||||
|
||||
manualHistorySyncCalls []bool
|
||||
appStateRecoveries []string
|
||||
appStateFetches []fakeAppStateFetch
|
||||
}
|
||||
|
||||
type fakeArchiveCall struct {
|
||||
target types.JID
|
||||
archive bool
|
||||
lastMsgTS time.Time
|
||||
lastMsgKey *waCommon.MessageKey
|
||||
}
|
||||
|
||||
type fakePinCall struct {
|
||||
target types.JID
|
||||
pin bool
|
||||
}
|
||||
|
||||
type fakeMuteCall struct {
|
||||
target types.JID
|
||||
mute bool
|
||||
duration time.Duration
|
||||
}
|
||||
|
||||
type fakeMarkReadCall struct {
|
||||
target types.JID
|
||||
read bool
|
||||
lastMsgTS time.Time
|
||||
lastMsgKey *waCommon.MessageKey
|
||||
}
|
||||
|
||||
type fakeAppStateFetch struct {
|
||||
name string
|
||||
fullSync bool
|
||||
onlyIfNotSynced bool
|
||||
}
|
||||
|
||||
func newFakeWA() *fakeWA {
|
||||
@ -54,6 +95,7 @@ func newFakeWA() *fakeWA {
|
||||
handlers: map[uint32]func(interface{}){},
|
||||
contacts: map[types.JID]types.ContactInfo{},
|
||||
groups: map[types.JID]*types.GroupInfo{},
|
||||
news: map[types.JID]*types.NewsletterMetadata{},
|
||||
lids: map[types.JID]types.JID{},
|
||||
nextHandlerID: 1,
|
||||
}
|
||||
@ -147,6 +189,13 @@ func (f *fakeWA) ResolveChatName(ctx context.Context, chat types.JID, pushName s
|
||||
return gi.GroupName.Name
|
||||
}
|
||||
}
|
||||
if chat.Server == types.NewsletterServer {
|
||||
if meta, _ := f.GetNewsletterInfo(ctx, chat); meta != nil {
|
||||
if name := wa.NewsletterName(meta); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
if info, _ := f.GetContact(ctx, chat.ToNonAD()); info.Found {
|
||||
if name := wa.BestContactName(info); name != "" {
|
||||
return name
|
||||
@ -265,11 +314,46 @@ func (f *fakeWA) JoinGroupWithLink(ctx context.Context, code string) (types.JID,
|
||||
|
||||
func (f *fakeWA) LeaveGroup(ctx context.Context, group types.JID) error { return nil }
|
||||
|
||||
func (f *fakeWA) GetNewsletterInfoWithInvite(ctx context.Context, key string) (*types.NewsletterMetadata, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for _, meta := range f.news {
|
||||
if meta.ThreadMeta.InviteCode == key || strings.HasSuffix(key, meta.ThreadMeta.InviteCode) {
|
||||
return meta, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) FollowNewsletter(ctx context.Context, jid types.JID) error { return nil }
|
||||
|
||||
func (f *fakeWA) UnfollowNewsletter(ctx context.Context, jid types.JID) error { return nil }
|
||||
|
||||
func (f *fakeWA) GetSubscribedNewsletters(ctx context.Context) ([]*types.NewsletterMetadata, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]*types.NewsletterMetadata, 0, len(f.news))
|
||||
for _, meta := range f.news {
|
||||
out = append(out, meta)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) GetNewsletterInfo(ctx context.Context, jid types.JID) (*types.NewsletterMetadata, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.news[jid], nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) SendText(ctx context.Context, to types.JID, text string) (types.MessageID, error) {
|
||||
return types.MessageID("msgid"), nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) SendProtoMessage(ctx context.Context, to types.JID, msg *waProto.Message) (types.MessageID, error) {
|
||||
return f.SendProtoMessageWithExtra(ctx, to, msg, "")
|
||||
}
|
||||
|
||||
func (f *fakeWA) SendProtoMessageWithExtra(ctx context.Context, to types.JID, msg *waProto.Message, mediaHandle string) (types.MessageID, error) {
|
||||
return types.MessageID("msgid"), nil
|
||||
}
|
||||
|
||||
@ -277,10 +361,22 @@ func (f *fakeWA) SendReaction(ctx context.Context, chat, sender types.JID, targe
|
||||
return types.MessageID("reactionid"), nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) RevokeMessage(ctx context.Context, chat types.JID, targetID types.MessageID) (types.MessageID, error) {
|
||||
return types.MessageID("revokeid"), nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) EditMessage(ctx context.Context, chat types.JID, targetID types.MessageID, text string) (types.MessageID, error) {
|
||||
return types.MessageID("editid"), nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) Upload(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error) {
|
||||
return whatsmeow.UploadResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) UploadNewsletter(ctx context.Context, data []byte, mediaType whatsmeow.MediaType) (whatsmeow.UploadResponse, error) {
|
||||
return whatsmeow.UploadResponse{Handle: "newsletter-media-handle"}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) SendChatPresence(ctx context.Context, jid types.JID, state types.ChatPresence, media types.ChatPresenceMedia) error {
|
||||
return nil
|
||||
}
|
||||
@ -386,6 +482,59 @@ func (f *fakeWA) RequestAppStateRecovery(ctx context.Context, name string) (type
|
||||
return types.MessageID("recovery-req"), nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) DeleteMessageForMe(ctx context.Context, info types.MessageInfo, deleteMedia bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) ArchiveChat(ctx context.Context, target types.JID, archive bool, lastMsgTS time.Time, lastMsgKey *waCommon.MessageKey) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.archiveCalls = append(f.archiveCalls, fakeArchiveCall{target: target, archive: archive, lastMsgTS: lastMsgTS, lastMsgKey: lastMsgKey})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) PinChat(ctx context.Context, target types.JID, pin bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.pinCalls = append(f.pinCalls, fakePinCall{target: target, pin: pin})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) MuteChat(ctx context.Context, target types.JID, mute bool, duration time.Duration) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.muteCalls = append(f.muteCalls, fakeMuteCall{target: target, mute: mute, duration: duration})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) MarkChatAsRead(ctx context.Context, target types.JID, read bool, lastMsgTS time.Time, lastMsgKey *waCommon.MessageKey) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.markReadCalls = append(f.markReadCalls, fakeMarkReadCall{target: target, read: read, lastMsgTS: lastMsgTS, lastMsgKey: lastMsgKey})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) FetchAppState(ctx context.Context, name string, fullSync, onlyIfNotSynced bool) error {
|
||||
f.mu.Lock()
|
||||
f.appStateFetches = append(f.appStateFetches, fakeAppStateFetch{
|
||||
name: name,
|
||||
fullSync: fullSync,
|
||||
onlyIfNotSynced: onlyIfNotSynced,
|
||||
})
|
||||
err := f.appStateFetchErr
|
||||
eventCB := f.appStateFetchEvent
|
||||
f.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if eventCB != nil {
|
||||
if evt := eventCB(name, fullSync, onlyIfNotSynced); evt != nil {
|
||||
f.emit(evt)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeWA) Logout(ctx context.Context) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -11,9 +11,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/steipete/wacli/internal/fsutil"
|
||||
"github.com/steipete/wacli/internal/pathutil"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/fsutil"
|
||||
"github.com/openclaw/wacli/internal/pathutil"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
type mediaJob struct {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user