Compare commits

..

19 Commits
v0.8.0 ... main

Author SHA1 Message Date
Peter Steinberger
a5f1861547
chore: release 0.8.1
Some checks failed
CI / test (push) Has been cancelled
CI / linux-release-builds (push) Has been cancelled
pages / Deploy docs (push) Has been cancelled
2026-05-08 02:44:14 +01:00
Peter Steinberger
42ce6831bf
ci: pin goreleaser action version 2026-05-08 02:37:05 +01:00
Peter Steinberger
2a9193e91c
ci: add linux release build checks 2026-05-08 02:34:48 +01:00
Peter Steinberger
a0166a88ea
ci: update goreleaser archive schema 2026-05-08 02:29:56 +01:00
Peter Steinberger
3677b5b3cd
docs: update unreleased changelog 2026-05-08 02:13:20 +01:00
Dinakar Sarbada
cdddf110ef
chore: migrate module path to openclaw 2026-05-08 02:12:21 +01:00
Dinakar Sarbada
f7cbace0e3
chore: remove stale codeowners entry 2026-05-08 02:12:11 +01:00
Dinakar Sarbada
1f7c6fa19a
ci: harden release tap handoff after move 2026-05-08 02:12:07 +01:00
Dinakar Sarbada
0e1a4d08f8
fix: route whatsmeow diagnostics to stderr
Refs #212
2026-05-08 02:12:04 +01:00
Dinakar Sarbada
3909781d7a
fix: apply history coverage filters before limit 2026-05-08 02:12:00 +01:00
Dinakar Sarbada
30150518f2
fix: let max-messages zero override env 2026-05-08 02:11:56 +01:00
Dinakar Sarbada
5a6fce1e41
fix: truncate table output by rune 2026-05-08 02:11:39 +01:00
Peter Steinberger
4102a04e38
docs: quote wacli skill description 2026-05-08 02:07:12 +01:00
Peter Steinberger
76d2414433
docs: point wacli site at openclaw repo
Some checks are pending
CI / test (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-07 14:26:00 +01:00
Peter Steinberger
a5ed16b922
docs: clarify wacli skill trigger 2026-05-07 12:49:08 +01:00
Peter Steinberger
b9ba3b371d
fix: extract shared contact card text 2026-05-07 12:15:36 +01:00
Peter Steinberger
e3c4ea61e6
docs: highlight docs site code blocks 2026-05-07 04:20:45 +01:00
Peter Steinberger
c68d285400
docs: add wacli agent skill 2026-05-07 01:22:13 +01:00
Peter Steinberger
0796db5ff9
feat: improve interactive sync status
Some checks are pending
CI / test (push) Waiting to run
pages / Deploy docs (push) Waiting to run
2026-05-07 00:18:13 +01:00
104 changed files with 1087 additions and 170 deletions

View File

@ -0,0 +1,120 @@
---
name: wacli
description: "Use when explicitly working with wacli: linked-device WhatsApp accounts, local stores, sync/auth/send behavior, and wacli repo/release work."
---
# Wacli
Use this for `wacli` repo work and local WhatsApp linked-device stores. Prefer read-only commands for inspection unless the user explicitly asks to auth, sync, send, mutate chats/groups, or release.
## Sources
- Repo: `~/Projects/wacli`
- CLI in repo: `./dist/wacli` after `pnpm build`
- Installed CLI: `wacli`
- Default config: `~/.wacli/config.yaml`
- Default macOS store: `~/.wacli`
- Named account stores: `~/.wacli/accounts/<name>`
- App DB: `<store>/wacli.db`
- WhatsApp session DB: `<store>/session.db`
## Safety
- Use `--read-only` or `WACLI_READONLY=1` for inspection.
- Use `--json` for parsing.
- Do not send messages unless explicitly asked.
- Do not write `session.db` directly.
- Do not merge account data into one `wacli.db`; named accounts are isolated stores.
- Watch dirty worktrees; leave unrelated files alone.
## Account Workflow
List accounts and store paths:
```bash
wacli accounts list --json
```
Inspect one account without connecting:
```bash
wacli --account me doctor --read-only --json
wacli --account me auth status --read-only --json
```
Use `--account NAME` for normal multi-account work. Use `--store DIR` only for one-off legacy/manual store debugging.
## Message/Store Checks
Prefer CLI first:
```bash
wacli --account me messages list --read-only --json --limit 20
wacli --account me messages search --read-only --json "query"
wacli --account me chats list --read-only --json
```
For DB health or aggregate checks, use SQLite read-only where possible:
```bash
sqlite3 "$HOME/.wacli/accounts/me/wacli.db" "pragma integrity_check;"
sqlite3 "$HOME/.wacli/accounts/me/wacli.db" \
"select count(*) from messages;
select count(*) from messages_fts;"
```
Useful consistency checks:
```sql
select count(*) from (
select chat_jid, msg_id, count(*) c
from messages
group by chat_jid, msg_id
having c > 1
);
select count(*)
from messages m
left join chats c on c.jid = m.chat_jid
where c.jid is null;
select count(*) from messages where revoked = 0 and deleted_for_me = 0;
select count(*) from messages_fts;
```
## Sync/Auth UX
`auth` pairs and then bootstraps sync. `sync` never shows QR and requires an authenticated store.
Common commands:
```bash
wacli --account me auth
wacli --account me sync --once
wacli --account me sync --follow
wacli --account me sync --once --events 2>events.ndjson
```
Interactive TTY sync progress should be concise; warnings must remain visible. `--events` must keep stderr as NDJSON.
## Repo Workflow
Read docs before coding when behavior changes:
```bash
pnpm -s docs:list || bin/docs-list || true
```
Focused tests first, then full gate:
```bash
go test ./internal/app
go test ./internal/store
pnpm docs:site && pnpm format:check && pnpm lint && pnpm test && pnpm build && git diff --check
```
User-facing changes need docs and `CHANGELOG.md`. Use `committer` with explicit file paths.
## Release
Read `docs/release.md` before release work. Release is tag-driven; verify workflow state with `gh run list/view`. If a release workflow is cancelled or partially failed, state exactly which jobs completed and which did not.

1
.github/CODEOWNERS vendored
View File

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

View File

@ -41,3 +41,38 @@ jobs:
env:
CGO_ENABLED: "1"
run: pnpm -s build
linux-release-builds:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup CI Environment
uses: ./.github/actions/setup-ci-env
with:
go-version-file: go.mod
apt-packages: "build-essential gcc-aarch64-linux-gnu libc6-dev-arm64-cross"
- name: GoReleaser check (macOS)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check --config .goreleaser.yaml
- name: GoReleaser check (linux/windows)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check --config .goreleaser-linux-windows.yaml
- name: GoReleaser build (linux amd64/arm64)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: build --snapshot --clean --config .goreleaser-linux-windows.yaml --id wacli_linux_amd64 --id wacli_linux_arm64

View File

@ -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

View File

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

View File

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

View File

@ -1,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

View File

@ -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
```

View File

@ -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 {

View File

@ -7,7 +7,7 @@ import (
"strings"
"testing"
"github.com/steipete/wacli/internal/config"
"github.com/openclaw/wacli/internal/config"
)
func TestAccountsAddNoAuthCreatesConfig(t *testing.T) {

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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 {

View File

@ -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"
)

View File

@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -6,8 +6,8 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newContactsCmd(flags *rootFlags) *cobra.Command {

View File

@ -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 {

View File

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

View File

@ -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 {

View File

@ -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 {

View File

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

View File

@ -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"
)

View File

@ -6,8 +6,8 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"go.mau.fi/whatsmeow/types"
)

View File

@ -6,9 +6,9 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -1,7 +1,7 @@
package main
import (
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -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 {

View File

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

View File

@ -6,8 +6,8 @@ import (
"os"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {

View File

@ -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 {

View File

@ -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 {

View File

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

View File

@ -6,8 +6,8 @@ import (
"os"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newMediaCmd(flags *rootFlags) *cobra.Command {

View File

@ -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"
)

View File

@ -6,7 +6,7 @@ import (
"strings"
"time"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
)
func writeMessagesList(dst io.Writer, msgs []store.Message, fullOutput bool) error {

View File

@ -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"
)

View File

@ -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 {

View File

@ -6,9 +6,9 @@ import (
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)

View File

@ -11,8 +11,8 @@ import (
_ "image/png"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
// profileMaxPx is the max dimension WhatsApp accepts for profile pictures.

View File

@ -8,9 +8,9 @@ import (
"strconv"
"strings"
"github.com/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"
)

View File

@ -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"

View File

@ -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 {

View File

@ -9,12 +9,12 @@ import (
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/linkpreview"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/linkpreview"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"

View File

@ -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"

View File

@ -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 {

View File

@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/steipete/wacli/internal/app"
"github.com/openclaw/wacli/internal/app"
"go.mau.fi/whatsmeow"
)

View File

@ -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"
)

View File

@ -9,7 +9,7 @@ import (
"strings"
"testing"
"github.com/steipete/wacli/internal/lock"
"github.com/openclaw/wacli/internal/lock"
)
func TestTryDelegateSendFallsBackWhenSocketUnavailable(t *testing.T) {

View File

@ -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"
)

View File

@ -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"

View File

@ -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 {

View File

@ -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"
)

View File

@ -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 {

View File

@ -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.

View File

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

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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

View File

@ -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).

View File

@ -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

View File

@ -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`.

View File

@ -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`.

View File

@ -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.

View File

@ -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
View File

@ -1,4 +1,4 @@
module github.com/steipete/wacli
module github.com/openclaw/wacli
go 1.25.0

View File

@ -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) {

View File

@ -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"

View File

@ -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 {

View File

@ -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"

View File

@ -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)
}

View File

@ -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"

View File

@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)

View File

@ -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 {

View File

@ -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

View File

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

View File

@ -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
}

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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
View 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
}

View File

@ -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"

View File

@ -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}

View File

@ -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"

View File

@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/steipete/wacli/internal/fsutil"
"github.com/openclaw/wacli/internal/fsutil"
)
type Lock struct {

View File

@ -5,7 +5,7 @@ import (
"sort"
"strings"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
)
type Kind string

View File

@ -4,7 +4,7 @@ import (
"strings"
"testing"
"github.com/steipete/wacli/internal/store"
"github.com/openclaw/wacli/internal/store"
)
type fakeSource struct {

View File

@ -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 {

View File

@ -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)

View File

@ -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)

View File

@ -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
View 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...))
}

View 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)
}
}

View File

@ -7,7 +7,7 @@ import (
"path/filepath"
"strings"
"github.com/steipete/wacli/internal/fsutil"
"github.com/openclaw/wacli/internal/fsutil"
"go.mau.fi/whatsmeow"
)

View File

@ -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 {

View 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)
}

View File

@ -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 != "" {

View File

@ -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{

View File

@ -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