Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f1861547 | ||
|
|
42ce6831bf | ||
|
|
2a9193e91c | ||
|
|
a0166a88ea | ||
|
|
3677b5b3cd | ||
|
|
cdddf110ef | ||
|
|
f7cbace0e3 | ||
|
|
1f7c6fa19a | ||
|
|
0e1a4d08f8 | ||
|
|
3909781d7a | ||
|
|
30150518f2 | ||
|
|
5a6fce1e41 | ||
|
|
4102a04e38 | ||
|
|
76d2414433 | ||
|
|
a5ed16b922 | ||
|
|
b9ba3b371d | ||
|
|
e3c4ea61e6 | ||
|
|
c68d285400 | ||
|
|
0796db5ff9 |
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
|
||||
|
||||
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:
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## 0.8.1 - 2026-05-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Module: migrate the canonical Go module/import path to `github.com/openclaw/wacli`. (#217 - thanks @dinakars777)
|
||||
- Sync: collapse routine interactive TTY progress into a single updating status line while keeping warnings visible as normal stderr lines.
|
||||
|
||||
### Chore
|
||||
|
||||
- CI: make the Homebrew tap handoff use `openclaw/wacli` and skip gracefully when the tap token is missing. (#216 - thanks @dinakars777)
|
||||
- Maintainers: remove the stale personal CODEOWNERS rule after the OpenClaw move. (#218 - thanks @dinakars777)
|
||||
- Release: update GoReleaser archive config to the current v2 schema so release-config checks stay green.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI: truncate table output by rune so emoji and other non-ASCII text stay valid UTF-8. (#222 - thanks @dinakars777)
|
||||
- History: apply coverage/actionable filters before `LIMIT` so newer blocked chats do not hide ready chats. (#219 - thanks @dinakars777)
|
||||
- Messages: extract display/search text from shared WhatsApp contact cards, including vCard phone numbers. (#214)
|
||||
- Send: route whatsmeow diagnostics to stderr and clarify that `sent: true` means WhatsApp accepted the send request. (#215 - thanks @dinakars777)
|
||||
- Sync: let explicit `--max-messages=0` override `WACLI_SYNC_MAX_MESSAGES`. (#220 - thanks @dinakars777)
|
||||
|
||||
## 0.8.0 - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
12
README.md
12
README.md
@ -34,7 +34,17 @@ If a Linux install reports `Binary was compiled with 'CGO_ENABLED=0'`, run `brew
|
||||
- Debian/Ubuntu: `sudo apt install build-essential`.
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=1 go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
|
||||
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
|
||||
```
|
||||
|
||||
For local development:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/openclaw/wacli.git
|
||||
cd wacli
|
||||
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
|
||||
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
|
||||
./dist/wacli --help
|
||||
```
|
||||
|
||||
|
||||
@ -6,10 +6,10 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/config"
|
||||
"github.com/openclaw/wacli/internal/fsutil"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/config"
|
||||
"github.com/steipete/wacli/internal/fsutil"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
type accountPayload struct {
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/wacli/internal/config"
|
||||
"github.com/openclaw/wacli/internal/config"
|
||||
)
|
||||
|
||||
func TestAccountsAddNoAuthCreatesConfig(t *testing.T) {
|
||||
|
||||
@ -9,10 +9,10 @@ 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"
|
||||
)
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -11,10 +11,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -7,9 +7,9 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newChatsCleanupCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -7,9 +7,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -5,10 +5,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/syscontacts"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/syscontacts"
|
||||
)
|
||||
|
||||
type systemContactMatch struct {
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestContactsImportSystemFromInputDryRunDoesNotWrite(t *testing.T) {
|
||||
|
||||
@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newDocsCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -10,10 +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/lock"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
)
|
||||
|
||||
func parseLockOwnerPID(lockInfo string) int {
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestParseLockOwnerPID(t *testing.T) {
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -7,10 +7,10 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
)
|
||||
|
||||
func newGroupsPruneCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestGroupsPruneExposesSafetyFlags(t *testing.T) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -7,10 +7,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
)
|
||||
|
||||
func newHistoryCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestHistoryCoverageCommandListsReadyAndBlockedChats(t *testing.T) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -7,11 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,9 +9,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
@ -35,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 {
|
||||
|
||||
@ -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,14 +9,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/config"
|
||||
"github.com/openclaw/wacli/internal/lock"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/config"
|
||||
"github.com/steipete/wacli/internal/lock"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
var version = "0.8.0"
|
||||
var version = "0.8.1"
|
||||
|
||||
const docsURL = "https://wacli.sh"
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/wacli/internal/config"
|
||||
"github.com/openclaw/wacli/internal/config"
|
||||
)
|
||||
|
||||
func captureRootStderr(t *testing.T, fn func()) string {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newStoreCleanupCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -5,8 +5,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newStoreStatsCmd(flags *rootFlags) *cobra.Command {
|
||||
|
||||
@ -6,9 +6,9 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
appPkg "github.com/openclaw/wacli/internal/app"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"github.com/spf13/cobra"
|
||||
appPkg "github.com/steipete/wacli/internal/app"
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
)
|
||||
|
||||
func newSyncCmd(flags *rootFlags) *cobra.Command {
|
||||
@ -31,6 +31,7 @@ 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
|
||||
|
||||
@ -33,7 +33,7 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w
|
||||
|
||||
## Status
|
||||
|
||||
Core implementation is in place. The [CHANGELOG](https://github.com/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
|
||||
|
||||
@ -45,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
|
||||
|
||||
@ -22,6 +22,7 @@ wacli messages delete --chat JID --id MSG_ID [--for-me] [--delete-media] [--post
|
||||
- Uses SQLite FTS5 when the binary was built with `-tags sqlite_fts5`.
|
||||
- Falls back to `LIKE` if FTS5 is not available.
|
||||
- `--type` accepts `text`, `image`, `video`, `audio`, or `document`.
|
||||
- Shared WhatsApp contact cards are stored as searchable text with contact names and phone numbers when WhatsApp includes a vCard payload.
|
||||
- `--starred` restricts list/search results to messages marked as starred by WhatsApp.
|
||||
- Time filters accept RFC3339 or `YYYY-MM-DD`.
|
||||
|
||||
|
||||
@ -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,7 +2,7 @@
|
||||
|
||||
Read when: sending text, files, stickers, quoted replies, or reactions.
|
||||
|
||||
`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. 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.
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ wacli sync [--once] [--follow] [--idle-exit 30s] [--max-reconnect 5m] [--max-mes
|
||||
- While `sync --follow` is running, `send text`, `send file`, `send sticker`, `send voice`, and `send react` commands for the same store are delegated to the running sync process so they do not fail on the store lock.
|
||||
- After connecting, sync fetches WhatsApp chat app-state deltas (`regular_high` and `regular_low`) so starred, delete-for-me, mute, archive, pin, and mark-read changes made while `wacli` was offline are caught up instead of relying only on live push notifications.
|
||||
- If whatsmeow reports an app-state LTHash mismatch, sync asks the primary device for the official recovery snapshot once for that app-state collection. If recovery also fails, the warning is printed and sync keeps handling normal message/history events.
|
||||
- In an interactive terminal, routine connected/history/progress updates share one updating stderr status line. Warnings and errors still print as separate lines so they remain visible.
|
||||
- `--events` emits one NDJSON lifecycle event per stderr line for machine consumers. Routine human progress/status lines, interrupt prompts, and command errors are emitted as events while events are enabled.
|
||||
|
||||
## Examples
|
||||
|
||||
2
go.mod
2
go.mod
@ -1,4 +1,4 @@
|
||||
module github.com/steipete/wacli
|
||||
module github.com/openclaw/wacli
|
||||
|
||||
go 1.25.0
|
||||
|
||||
|
||||
@ -7,10 +7,10 @@ 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"
|
||||
@ -89,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"
|
||||
|
||||
@ -3,7 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
)
|
||||
|
||||
func (a *App) refreshContacts(ctx context.Context) error {
|
||||
|
||||
@ -8,7 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"go.mau.fi/whatsmeow/proto/waCommon"
|
||||
"go.mau.fi/whatsmeow/proto/waSyncAction"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"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"
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
// panicFirstWA wraps a fakeWA but panics on the first DownloadMediaToFile
|
||||
|
||||
@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
func TestDownloadMediaJobMarksDownloaded(t *testing.T) {
|
||||
|
||||
@ -10,8 +10,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/appstate"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
@ -52,6 +52,9 @@ type SyncResult struct {
|
||||
}
|
||||
|
||||
func (a *App) Sync(ctx context.Context, opts SyncOptions) (SyncResult, error) {
|
||||
status := a.beginSyncStatus()
|
||||
defer a.endSyncStatus(status)
|
||||
|
||||
if opts.Mode == "" {
|
||||
opts.Mode = SyncModeFollow
|
||||
}
|
||||
|
||||
@ -11,8 +11,8 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/appstate"
|
||||
"go.mau.fi/whatsmeow/proto/waE2E"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
@ -246,7 +246,7 @@ func (a *App) handleHistorySync(ctx context.Context, opts SyncOptions, v *events
|
||||
}
|
||||
}
|
||||
if !a.eventsEnabled() {
|
||||
fmt.Fprintf(os.Stderr, "\rSynced %d messages...", messagesStored.Load())
|
||||
a.emitOrPrint("progress", map[string]any{"messages_synced": messagesStored.Load()}, "\rSynced %d messages...", messagesStored.Load())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,12 +3,13 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/out"
|
||||
"github.com/openclaw/wacli/internal/out"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
)
|
||||
|
||||
@ -65,3 +66,83 @@ func TestSyncEventsOutputStaysNDJSONDuringProgress(t *testing.T) {
|
||||
t.Fatalf("expected progress event in:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTTYProgressUsesSingleStatusLine(t *testing.T) {
|
||||
oldTerminal := syncStatusTerminal
|
||||
syncStatusTerminal = func() bool { return true }
|
||||
t.Cleanup(func() { syncStatusTerminal = oldTerminal })
|
||||
|
||||
a := newTestApp(t)
|
||||
f := newFakeWA()
|
||||
a.wa = f
|
||||
|
||||
chat := types.JID{User: "123", Server: types.DefaultUserServer}
|
||||
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
ids := make([]string, 30)
|
||||
for i := range ids {
|
||||
ids[i] = "m" + string(rune('a'+i))
|
||||
}
|
||||
f.connectEvents = []interface{}{historySyncWithTextMessages(chat, base, ids...)}
|
||||
|
||||
raw := captureStderr(t, func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, err := a.Sync(ctx, SyncOptions{
|
||||
Mode: SyncModeOnce,
|
||||
AllowQR: false,
|
||||
IdleExit: time.Millisecond,
|
||||
WarnNoLimits: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if strings.Contains(raw, "\nProcessing history sync") || strings.Contains(raw, "\nSynced 25 messages") {
|
||||
t.Fatalf("TTY progress should update one status line, got:\n%q", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "\rConnected. Waiting for history sync...") {
|
||||
t.Fatalf("missing connected status in:\n%q", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "\rSyncing history: 1 conversations, 25 messages stored") {
|
||||
t.Fatalf("missing history progress status in:\n%q", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "\rSyncing history: 1 conversations, 30 messages stored") {
|
||||
t.Fatalf("missing final history status in:\n%q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncTTYWarningBreaksThroughStatusLine(t *testing.T) {
|
||||
oldTerminal := syncStatusTerminal
|
||||
syncStatusTerminal = func() bool { return true }
|
||||
t.Cleanup(func() { syncStatusTerminal = oldTerminal })
|
||||
|
||||
a := newTestApp(t)
|
||||
f := newFakeWA()
|
||||
f.appStateFetchErr = errors.New("not connected")
|
||||
a.wa = f
|
||||
|
||||
raw := captureStderr(t, func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_, err := a.Sync(ctx, SyncOptions{
|
||||
Mode: SyncModeOnce,
|
||||
AllowQR: false,
|
||||
IdleExit: time.Millisecond,
|
||||
WarnNoLimits: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Sync: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if !strings.Contains(raw, "warning: failed to sync WhatsApp app state regular_high: not connected\n") {
|
||||
t.Fatalf("missing regular_high warning in:\n%q", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "warning: failed to sync WhatsApp app state regular_low: not connected\n") {
|
||||
t.Fatalf("missing regular_low warning in:\n%q", raw)
|
||||
}
|
||||
if strings.Contains(raw, "\nConnected.\n") {
|
||||
t.Fatalf("connected status should not become a separate noisy line:\n%q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
)
|
||||
|
||||
type syncStorageLimits struct {
|
||||
|
||||
159
internal/app/sync_status.go
Normal file
159
internal/app/sync_status.go
Normal file
@ -0,0 +1,159 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var syncStatusTerminal = func() bool {
|
||||
return term.IsTerminal(int(os.Stderr.Fd()))
|
||||
}
|
||||
|
||||
type syncStatus struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
last string
|
||||
lastLen int
|
||||
conversations int
|
||||
messages int64
|
||||
}
|
||||
|
||||
func newSyncStatus(w io.Writer) *syncStatus {
|
||||
return &syncStatus{w: w}
|
||||
}
|
||||
|
||||
func (a *App) beginSyncStatus() *syncStatus {
|
||||
if a == nil || a.eventsEnabled() || !syncStatusTerminal() {
|
||||
return nil
|
||||
}
|
||||
st := newSyncStatus(os.Stderr)
|
||||
a.statusMu.Lock()
|
||||
a.status = st
|
||||
a.statusMu.Unlock()
|
||||
return st
|
||||
}
|
||||
|
||||
func (a *App) endSyncStatus(st *syncStatus) {
|
||||
if st == nil {
|
||||
return
|
||||
}
|
||||
st.Clear()
|
||||
a.statusMu.Lock()
|
||||
if a.status == st {
|
||||
a.status = nil
|
||||
}
|
||||
a.statusMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) currentSyncStatus() *syncStatus {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
a.statusMu.Lock()
|
||||
defer a.statusMu.Unlock()
|
||||
return a.status
|
||||
}
|
||||
|
||||
func (s *syncStatus) Connected() {
|
||||
s.Set("Connected. Waiting for history sync...")
|
||||
}
|
||||
|
||||
func (s *syncStatus) HistorySync(conversations int) {
|
||||
s.mu.Lock()
|
||||
s.conversations = conversations
|
||||
msg := s.historyMessageLocked()
|
||||
s.mu.Unlock()
|
||||
s.Set(msg)
|
||||
}
|
||||
|
||||
func (s *syncStatus) Progress(messages int64) {
|
||||
s.mu.Lock()
|
||||
s.messages = messages
|
||||
msg := s.historyMessageLocked()
|
||||
s.mu.Unlock()
|
||||
s.Set(msg)
|
||||
}
|
||||
|
||||
func (s *syncStatus) historyMessageLocked() string {
|
||||
if s.conversations > 0 {
|
||||
return fmt.Sprintf("Syncing history: %d conversations, %d messages stored", s.conversations, s.messages)
|
||||
}
|
||||
return fmt.Sprintf("Synced %d messages", s.messages)
|
||||
}
|
||||
|
||||
func (s *syncStatus) Set(message string) {
|
||||
if s == nil || s.w == nil {
|
||||
return
|
||||
}
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.renderLocked(message)
|
||||
}
|
||||
|
||||
func (s *syncStatus) PrintLine(message string) {
|
||||
if s == nil || s.w == nil {
|
||||
return
|
||||
}
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.clearLocked()
|
||||
fmt.Fprintln(s.w, message)
|
||||
}
|
||||
|
||||
func (s *syncStatus) WarnLine(message string) {
|
||||
if s == nil || s.w == nil {
|
||||
return
|
||||
}
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.clearLocked()
|
||||
fmt.Fprintln(s.w, message)
|
||||
if s.last != "" {
|
||||
s.renderLocked(s.last)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *syncStatus) Clear() {
|
||||
if s == nil || s.w == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.clearLocked()
|
||||
s.last = ""
|
||||
}
|
||||
|
||||
func (s *syncStatus) renderLocked(message string) {
|
||||
padding := ""
|
||||
if s.lastLen > len(message) {
|
||||
padding = strings.Repeat(" ", s.lastLen-len(message))
|
||||
}
|
||||
fmt.Fprintf(s.w, "\r%s%s", message, padding)
|
||||
s.last = message
|
||||
s.lastLen = len(message)
|
||||
}
|
||||
|
||||
func (s *syncStatus) clearLocked() {
|
||||
if s.lastLen <= 0 {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(s.w, "\r%s\r", strings.Repeat(" ", s.lastLen))
|
||||
s.lastLen = 0
|
||||
}
|
||||
@ -11,8 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
"go.mau.fi/whatsmeow/appstate"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/proto/waCommon"
|
||||
|
||||
@ -15,7 +15,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
)
|
||||
|
||||
var syncWebhookHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/wa"
|
||||
"github.com/openclaw/wacli/internal/wa"
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
"go.mau.fi/whatsmeow/types"
|
||||
"go.mau.fi/whatsmeow/types/events"
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/steipete/wacli/internal/fsutil"
|
||||
"github.com/openclaw/wacli/internal/fsutil"
|
||||
)
|
||||
|
||||
type Lock struct {
|
||||
|
||||
@ -5,7 +5,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
@ -4,7 +4,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/wacli/internal/store"
|
||||
"github.com/openclaw/wacli/internal/store"
|
||||
)
|
||||
|
||||
type fakeSource struct {
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/steipete/wacli/internal/fsutil"
|
||||
"github.com/steipete/wacli/internal/sqliteutil"
|
||||
"github.com/openclaw/wacli/internal/fsutil"
|
||||
"github.com/openclaw/wacli/internal/sqliteutil"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
|
||||
@ -68,6 +68,9 @@ func (d *DB) ListHistoryCoverage(p ListHistoryCoverageParams) ([]HistoryCoverage
|
||||
if len(p.ChatJIDs) > 0 {
|
||||
query, args = appendStringFilter(query, args, "c.jid", "", p.ChatJIDs)
|
||||
}
|
||||
if !p.IncludeBlocked || p.OnlyActionable {
|
||||
query += ` AND COALESCE(ms.message_count, 0) > 0`
|
||||
}
|
||||
|
||||
query += ` ORDER BY COALESCE(c.last_message_ts, 0) DESC, c.jid LIMIT ?`
|
||||
args = append(args, p.Limit)
|
||||
|
||||
@ -89,6 +89,44 @@ func TestListHistoryCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHistoryCoverageAppliesBlockedFilterBeforeLimit(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
base := time.Date(2024, 5, 3, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
blocked := "blocked@s.whatsapp.net"
|
||||
ready := "ready@s.whatsapp.net"
|
||||
if err := db.UpsertChat(blocked, "dm", "Blocked", base.Add(2*time.Minute)); err != nil {
|
||||
t.Fatalf("UpsertChat blocked: %v", err)
|
||||
}
|
||||
if err := db.UpsertChat(ready, "dm", "Ready", base.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("UpsertChat ready: %v", err)
|
||||
}
|
||||
if err := db.UpsertMessage(UpsertMessageParams{
|
||||
ChatJID: ready,
|
||||
MsgID: "m1",
|
||||
Timestamp: base.Add(time.Second),
|
||||
Text: "ready",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMessage ready: %v", err)
|
||||
}
|
||||
|
||||
coverage, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Limit: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("ListHistoryCoverage: %v", err)
|
||||
}
|
||||
if len(coverage) != 1 || coverage[0].ChatJID != ready {
|
||||
t.Fatalf("coverage = %+v, want ready chat despite newer blocked row", coverage)
|
||||
}
|
||||
|
||||
withBlocked, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Limit: 1, IncludeBlocked: true})
|
||||
if err != nil {
|
||||
t.Fatalf("ListHistoryCoverage IncludeBlocked: %v", err)
|
||||
}
|
||||
if len(withBlocked) != 1 || withBlocked[0].ChatJID != blocked {
|
||||
t.Fatalf("withBlocked = %+v, want newer blocked chat when requested", withBlocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHistoryCoverageEscapesQueryWildcards(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
when := time.Date(2024, 5, 2, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
@ -25,6 +25,9 @@ func TestNewEnablesRetryMessageStore(t *testing.T) {
|
||||
if !c.client.UseRetryMessageStore {
|
||||
t.Fatal("expected retry message store to be enabled")
|
||||
}
|
||||
if _, ok := c.client.Log.(*whatsmeowLogger); !ok {
|
||||
t.Fatalf("client logger = %T, want *whatsmeowLogger", c.client.Log)
|
||||
}
|
||||
if got := c.LinkedJID(); got != "" {
|
||||
t.Fatalf("LinkedJID before auth = %q", got)
|
||||
}
|
||||
|
||||
68
internal/wa/logger.go
Normal file
68
internal/wa/logger.go
Normal file
@ -0,0 +1,68 @@
|
||||
package wa
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
waLog "go.mau.fi/whatsmeow/util/log"
|
||||
)
|
||||
|
||||
type whatsmeowLogger struct {
|
||||
module string
|
||||
min int
|
||||
w io.Writer
|
||||
mu *sync.Mutex
|
||||
}
|
||||
|
||||
var _ waLog.Logger = (*whatsmeowLogger)(nil)
|
||||
|
||||
var whatsmeowLogLevels = map[string]int{
|
||||
"": -1,
|
||||
"DEBUG": 0,
|
||||
"INFO": 1,
|
||||
"WARN": 2,
|
||||
"ERROR": 3,
|
||||
}
|
||||
|
||||
func newWhatsmeowLogger(module, minLevel string, w io.Writer) *whatsmeowLogger {
|
||||
if w == nil {
|
||||
w = io.Discard
|
||||
}
|
||||
min, ok := whatsmeowLogLevels[strings.ToUpper(minLevel)]
|
||||
if !ok {
|
||||
min = whatsmeowLogLevels["ERROR"]
|
||||
}
|
||||
return &whatsmeowLogger{
|
||||
module: module,
|
||||
min: min,
|
||||
w: w,
|
||||
mu: &sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *whatsmeowLogger) Errorf(msg string, args ...interface{}) { l.outputf("ERROR", msg, args...) }
|
||||
func (l *whatsmeowLogger) Warnf(msg string, args ...interface{}) { l.outputf("WARN", msg, args...) }
|
||||
func (l *whatsmeowLogger) Infof(msg string, args ...interface{}) { l.outputf("INFO", msg, args...) }
|
||||
func (l *whatsmeowLogger) Debugf(msg string, args ...interface{}) { l.outputf("DEBUG", msg, args...) }
|
||||
|
||||
func (l *whatsmeowLogger) Sub(module string) waLog.Logger {
|
||||
return &whatsmeowLogger{
|
||||
module: fmt.Sprintf("%s/%s", l.module, module),
|
||||
min: l.min,
|
||||
w: l.w,
|
||||
mu: l.mu,
|
||||
}
|
||||
}
|
||||
|
||||
func (l *whatsmeowLogger) outputf(level, msg string, args ...interface{}) {
|
||||
levelValue, ok := whatsmeowLogLevels[level]
|
||||
if !ok || levelValue < l.min {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
fmt.Fprintf(l.w, "%s [%s %s] %s\n", time.Now().Format("15:04:05.000"), l.module, level, fmt.Sprintf(msg, args...))
|
||||
}
|
||||
34
internal/wa/logger_test.go
Normal file
34
internal/wa/logger_test.go
Normal file
@ -0,0 +1,34 @@
|
||||
package wa
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWhatsmeowLoggerWritesToConfiguredWriter(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
logger := newWhatsmeowLogger("Client", "ERROR", &stderr)
|
||||
|
||||
logger.Warnf("hidden warning")
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("WARN was written for ERROR logger: %q", stderr.String())
|
||||
}
|
||||
|
||||
logger.Errorf("Failed to issue privacy token for %s: %v", "123@s.whatsapp.net", "bad-request")
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "[Client ERROR] Failed to issue privacy token for 123@s.whatsapp.net: bad-request") {
|
||||
t.Fatalf("unexpected log line: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatsmeowLoggerSubmoduleSharesWriter(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
logger := newWhatsmeowLogger("Client", "ERROR", &stderr)
|
||||
|
||||
logger.Sub("Socket").Errorf("boom")
|
||||
got := stderr.String()
|
||||
if !strings.Contains(got, "[Client/Socket ERROR] boom") {
|
||||
t.Fatalf("unexpected submodule log line: %q", got)
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steipete/wacli/internal/fsutil"
|
||||
"github.com/openclaw/wacli/internal/fsutil"
|
||||
"go.mau.fi/whatsmeow"
|
||||
)
|
||||
|
||||
|
||||
@ -100,6 +100,7 @@ func extractWAProto(m *waProto.Message, pm *ParsedMessage) {
|
||||
extractReaction(m, pm)
|
||||
extractPlainText(m, pm)
|
||||
extractMedia(m, pm)
|
||||
extractContactText(m, pm)
|
||||
extractBusinessText(m, pm)
|
||||
|
||||
if ctx := contextInfoForMessage(m); ctx != nil {
|
||||
|
||||
117
internal/wa/messages_contacts.go
Normal file
117
internal/wa/messages_contacts.go
Normal file
@ -0,0 +1,117 @@
|
||||
package wa
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
waProto "go.mau.fi/whatsmeow/binary/proto"
|
||||
)
|
||||
|
||||
func extractContactText(m *waProto.Message, pm *ParsedMessage) {
|
||||
if contact := m.GetContactMessage(); contact != nil {
|
||||
pm.Text = contactDisplayText(contact)
|
||||
return
|
||||
}
|
||||
if contacts := m.GetContactsArrayMessage(); contacts != nil {
|
||||
pm.Text = contactsArrayDisplayText(contacts)
|
||||
}
|
||||
}
|
||||
|
||||
func contactDisplayText(contact *waProto.ContactMessage) string {
|
||||
if contact == nil {
|
||||
return ""
|
||||
}
|
||||
name, phones := contactDetails(contact)
|
||||
if name == "" && len(phones) == 0 {
|
||||
return ""
|
||||
}
|
||||
return formatContactLine(name, phones)
|
||||
}
|
||||
|
||||
func contactsArrayDisplayText(contacts *waProto.ContactsArrayMessage) string {
|
||||
if contacts == nil {
|
||||
return ""
|
||||
}
|
||||
var lines []string
|
||||
for _, contact := range contacts.GetContacts() {
|
||||
if line := contactDisplayText(contact); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
if name := strings.TrimSpace(contacts.GetDisplayName()); name != "" {
|
||||
return "Contacts: " + name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if len(lines) == 1 {
|
||||
return lines[0]
|
||||
}
|
||||
return "Contacts:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func contactDetails(contact *waProto.ContactMessage) (string, []string) {
|
||||
name := strings.TrimSpace(contact.GetDisplayName())
|
||||
var phones []string
|
||||
for _, line := range unfoldedVCardLines(contact.GetVcard()) {
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
field := strings.ToUpper(strings.TrimSpace(strings.Split(key, ";")[0]))
|
||||
value = unescapeVCardValue(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
switch field {
|
||||
case "FN":
|
||||
if name == "" {
|
||||
name = value
|
||||
}
|
||||
case "TEL":
|
||||
phones = appendUnique(phones, value)
|
||||
}
|
||||
}
|
||||
return name, phones
|
||||
}
|
||||
|
||||
func unfoldedVCardLines(vcard string) []string {
|
||||
raw := strings.Split(strings.ReplaceAll(vcard, "\r\n", "\n"), "\n")
|
||||
lines := make([]string, 0, len(raw))
|
||||
for _, line := range raw {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && len(lines) > 0 {
|
||||
lines[len(lines)-1] += strings.TrimLeft(line, " \t")
|
||||
continue
|
||||
}
|
||||
lines = append(lines, strings.TrimSpace(line))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func unescapeVCardValue(value string) string {
|
||||
replacer := strings.NewReplacer(`\\`, `\`, `\,`, `,`, `\;`, `;`, `\n`, "\n", `\N`, "\n")
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func formatContactLine(name string, phones []string) string {
|
||||
switch {
|
||||
case name != "" && len(phones) > 0:
|
||||
return fmt.Sprintf("Contact: %s (%s)", name, strings.Join(phones, ", "))
|
||||
case name != "":
|
||||
return "Contact: " + name
|
||||
default:
|
||||
return "Contact: " + strings.Join(phones, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
func appendUnique(values []string, value string) []string {
|
||||
for _, existing := range values {
|
||||
if existing == value {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
@ -91,10 +91,10 @@ func displayTextForProto(m *waProto.Message) string {
|
||||
return "Sent location"
|
||||
}
|
||||
if contact := m.GetContactMessage(); contact != nil {
|
||||
return "Sent contact"
|
||||
return contactDisplayText(contact)
|
||||
}
|
||||
if contacts := m.GetContactsArrayMessage(); contacts != nil {
|
||||
return "Sent contacts"
|
||||
return contactsArrayDisplayText(contacts)
|
||||
}
|
||||
|
||||
if text := strings.TrimSpace(m.GetConversation()); text != "" {
|
||||
|
||||
@ -233,6 +233,73 @@ func TestParseLiveMessageForwarded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContactMessageText(t *testing.T) {
|
||||
chat, _ := types.ParseJID("123@s.whatsapp.net")
|
||||
sender, _ := types.ParseJID("sender@s.whatsapp.net")
|
||||
|
||||
ev := &events.Message{
|
||||
Info: types.MessageInfo{
|
||||
MessageSource: types.MessageSource{
|
||||
Chat: chat,
|
||||
Sender: sender,
|
||||
IsFromMe: false,
|
||||
},
|
||||
ID: "contact1",
|
||||
Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Message: &waProto.Message{
|
||||
ContactMessage: &waProto.ContactMessage{
|
||||
DisplayName: proto.String("Ada Lovelace"),
|
||||
Vcard: proto.String("BEGIN:VCARD\nVERSION:3.0\nFN:Ada Lovelace\n" +
|
||||
"TEL;type=CELL;waid=441234567890:+44 1234 567890\nEND:VCARD"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pm := ParseLiveMessage(ev)
|
||||
if pm.Text != "Contact: Ada Lovelace (+44 1234 567890)" {
|
||||
t.Fatalf("unexpected contact text: %q", pm.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseContactsArrayMessageText(t *testing.T) {
|
||||
chat, _ := types.ParseJID("123@s.whatsapp.net")
|
||||
sender, _ := types.ParseJID("sender@s.whatsapp.net")
|
||||
|
||||
ev := &events.Message{
|
||||
Info: types.MessageInfo{
|
||||
MessageSource: types.MessageSource{
|
||||
Chat: chat,
|
||||
Sender: sender,
|
||||
IsFromMe: false,
|
||||
},
|
||||
ID: "contacts1",
|
||||
Timestamp: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Message: &waProto.Message{
|
||||
ContactsArrayMessage: &waProto.ContactsArrayMessage{
|
||||
DisplayName: proto.String("2 contacts"),
|
||||
Contacts: []*waProto.ContactMessage{
|
||||
{
|
||||
DisplayName: proto.String("Ada Lovelace"),
|
||||
Vcard: proto.String("BEGIN:VCARD\nFN:Ada Lovelace\nTEL:+44 1234\nEND:VCARD"),
|
||||
},
|
||||
{
|
||||
DisplayName: proto.String("Grace Hopper"),
|
||||
Vcard: proto.String("BEGIN:VCARD\nFN:Grace Hopper\nTEL:+1 555\nEND:VCARD"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pm := ParseLiveMessage(ev)
|
||||
want := "Contacts:\nContact: Ada Lovelace (+44 1234)\nContact: Grace Hopper (+1 555)"
|
||||
if pm.Text != want {
|
||||
t.Fatalf("unexpected contacts text: %q", pm.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTemplateMessage(t *testing.T) {
|
||||
chat, _ := types.ParseJID("123@s.whatsapp.net")
|
||||
sender, _ := types.ParseJID("biz@s.whatsapp.net")
|
||||
@ -460,6 +527,16 @@ func TestDisplayTextForProtoBusinessTypes(t *testing.T) {
|
||||
msg *waProto.Message
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "contact",
|
||||
msg: &waProto.Message{
|
||||
ContactMessage: &waProto.ContactMessage{
|
||||
DisplayName: proto.String("Ada Lovelace"),
|
||||
Vcard: proto.String("BEGIN:VCARD\nFN:Ada Lovelace\nTEL:+44 1234\nEND:VCARD"),
|
||||
},
|
||||
},
|
||||
want: "Contact: Ada Lovelace (+44 1234)",
|
||||
},
|
||||
{
|
||||
name: "template",
|
||||
msg: &waProto.Message{
|
||||
|
||||
@ -4,12 +4,12 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/steipete/wacli/internal/sqliteutil"
|
||||
"github.com/openclaw/wacli/internal/sqliteutil"
|
||||
"go.mau.fi/whatsmeow"
|
||||
"go.mau.fi/whatsmeow/store/sqlstore"
|
||||
waLog "go.mau.fi/whatsmeow/util/log"
|
||||
)
|
||||
|
||||
func (c *Client) init() error {
|
||||
@ -17,7 +17,7 @@ func (c *Client) init() error {
|
||||
defer c.mu.Unlock()
|
||||
|
||||
ctx := context.Background()
|
||||
dbLog := waLog.Stdout("Database", "ERROR", true)
|
||||
dbLog := newWhatsmeowLogger("Database", "ERROR", os.Stderr)
|
||||
if err := sqliteutil.ChmodFiles(c.opts.StorePath, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -38,7 +38,7 @@ func (c *Client) init() error {
|
||||
}
|
||||
}
|
||||
|
||||
logger := waLog.Stdout("Client", "ERROR", true)
|
||||
logger := newWhatsmeowLogger("Client", "ERROR", os.Stderr)
|
||||
c.client = whatsmeow.NewClient(deviceStore, logger)
|
||||
c.client.EmitAppStateEventsOnFullSync = true
|
||||
// Persist recently-sent messages so whatsmeow can answer retry-receipts
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user