Compare commits

...

167 Commits
v0.2.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
Peter Steinberger
b1dad5b156
chore: release 0.8.0 2026-05-07 00:08:13 +01:00
Peter Steinberger
55a7955c56
chore: start 0.8.0 development 2026-05-06 23:50:49 +01:00
Peter Steinberger
6f3ba57935
feat: add named accounts 2026-05-06 23:28:01 +01:00
Peter Steinberger
4949423af4
chore: start 0.7.1 development 2026-05-06 06:42:53 +01:00
Peter Steinberger
33aa0ae767
docs: simplify readme 2026-05-06 06:35:28 +01:00
Peter Steinberger
b64cf3c049
chore: finalize 0.7.0 changelog 2026-05-06 06:26:41 +01:00
Peter Steinberger
2433188017
chore: update dependencies 2026-05-06 06:22:12 +01:00
Peter Steinberger
90bb4a3b8c
docs: document system contacts import 2026-05-06 06:16:18 +01:00
Peter Steinberger
403fda0fe7
feat: import system contacts
Co-authored-by: Paul Bohm <29411+enki@users.noreply.github.com>
Co-authored-by: Octavio Froid <froid@bohm.com>
2026-05-06 06:12:59 +01:00
Peter Steinberger
4b84b90a66
docs: clarify raw QR fallback 2026-05-06 05:55:58 +01:00
Peter Steinberger
af671e16a9
feat: add local store cleanup commands
Add local-only cleanup commands for store stats, chat cleanup, group pruning, and age-based store cleanup. Rework group pruning so targets are listed first, dry-run never deletes, confirmation gates every destructive path, and active stale groups require an explicit include flag.

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

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

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

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

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

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

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

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

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

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

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

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

Covers project structure (all internal packages), architectural facts
(two-DB layout, FTS5 trigger-synced table, LOCK file, read-only mode,
send retry, store path precedence), the full build gate including the
CGO_CFLAGS workaround for GCC 15+ and the sqlite_fts5 tag requirement,
coding style, testing guidelines, commit/PR conventions, and agent-
specific notes (--read-only / WACLI_READONLY, --json output).

All claims verified against source.

Co-authored-by: Adi <adhitms@gmail.com>
2026-04-24 21:45:40 -07:00
Peter Steinberger
8a0e13bffc
docs: update 0.7.0 release notes 2026-04-21 06:24:37 +01:00
Peter Steinberger
91d240a658
feat: add resilient message reactions 2026-04-21 06:24:33 +01:00
Peter Steinberger
95f2136887
fix: mark missing groups left on refresh 2026-04-21 06:24:26 +01:00
Peter Steinberger
fa1c800e72
fix: use XDG state dir on Linux 2026-04-21 06:24:22 +01:00
Peter Steinberger
653533928e
docs: update 0.7.0 refactor notes 2026-04-21 06:07:13 +01:00
Peter Steinberger
21063d810a
fix: normalize identities and group left state 2026-04-21 06:07:09 +01:00
Peter Steinberger
f6a94547d6
feat: add command safety controls 2026-04-21 06:06:57 +01:00
Peter Steinberger
86a43def24
refactor: split WhatsApp message parsing 2026-04-21 06:06:47 +01:00
Martín Alcalá Rubí
ec608225f9
feat: enrich auth and doctor status (#149)
Co-authored-by: draix <draix@users.noreply.github.com>
2026-04-21 05:50:11 +01:00
Martín Alcalá Rubí
318421d415
security: validate phone recipients (#144)
Co-authored-by: draix <draix@users.noreply.github.com>
2026-04-21 05:46:01 +01:00
Luke
54d44b34fc
fix: validate message search media filters (#128)
Co-authored-by: ImLukeF <92253590+ImLukeF@users.noreply.github.com>
Co-authored-by: Mansehej Singh <mansehej@gmail.com>
2026-04-21 05:43:41 +01:00
Peter Steinberger
aa00e0a58e
docs: update README feature overview 2026-04-21 05:36:34 +01:00
Peter Steinberger
782c29078c feat: add quoted text replies (#154) (thanks @draix) 2026-04-21 05:25:55 +01:00
Peter Steinberger
120805d9d9 feat: add presence commands (#76) (thanks @redemerco) 2026-04-21 05:23:50 +01:00
Peter Steinberger
a3a620ac73 feat: parse WhatsApp Business message text (#79) (thanks @terry-li-hm) 2026-04-21 05:22:16 +01:00
Peter Steinberger
12e8e1a934 fix: bound media enqueue backpressure (#121) (thanks @jyothepro) 2026-04-21 05:20:44 +01:00
Peter Steinberger
894bc5d1ac fix: keep sync idle timer on message events (#119) (thanks @jyothepro) 2026-04-21 05:18:55 +01:00
Peter Steinberger
5c24523978
ci: update GitHub actions to Node 24 runtime 2026-04-21 05:07:21 +01:00
Peter Steinberger
4517528d57
ci: opt into Node 24 action runtime 2026-04-21 05:04:41 +01:00
Peter Steinberger
f28583d44c
test: split store tests by domain 2026-04-21 05:04:17 +01:00
Peter Steinberger
cd98a9d46d
refactor: centralize table output helpers 2026-04-21 05:03:33 +01:00
Peter Steinberger
6076deba26
fix: enforce private directory permissions 2026-04-21 05:03:04 +01:00
Peter Steinberger
b2935c1eac
fix: make message ordering deterministic 2026-04-21 05:01:54 +01:00
Peter Steinberger
999461ce0e
fix: escape wildcard list queries 2026-04-21 05:01:10 +01:00
Peter Steinberger
716e7a8496
fix: restrict sqlite database file permissions (#147) (thanks @draix) 2026-04-21 04:53:52 +01:00
Peter Steinberger
dffcda4481
feat: add message list filters (#153) (thanks @draix) 2026-04-21 04:53:27 +01:00
Peter Steinberger
372e1fc257
feat: show full message IDs in table output (#13) (thanks @rickhallett) 2026-04-21 04:53:02 +01:00
Peter Steinberger
0ce920839e
fix: surface doctor lock owner state (#105) (thanks @artemgetmann) 2026-04-21 04:52:33 +01:00
Peter Steinberger
e73dba0ecb
fix: attribute LID group history senders (#19) (thanks @entropyy0) 2026-04-21 04:52:10 +01:00
Peter Steinberger
3c8de4d9b1
refactor: split core store schema 2026-04-21 03:54:48 +01:00
Peter Steinberger
bd92cc49a7
refactor: split store chat contact and group methods 2026-04-21 03:54:13 +01:00
Peter Steinberger
fae308a2b9
refactor: split whatsapp jid and session helpers 2026-04-21 03:53:25 +01:00
Peter Steinberger
a03b0e9dda
refactor: split message output formatting 2026-04-21 03:52:45 +01:00
Peter Steinberger
668d7e5762
refactor: split sync event and idle loops 2026-04-21 03:51:54 +01:00
Peter Steinberger
5f897ee277
chore: bump version to 0.7.0 2026-04-21 03:45:13 +01:00
Peter Steinberger
ff6334885f fix: add windows lock implementation (#188) (thanks @dinakars777) 2026-04-21 01:41:34 +01:00
Peter Steinberger
3e17ff0ae2 fix: normalize phone recipients with plus prefix (#74) (thanks @FrederickStempfle) 2026-04-21 01:37:59 +01:00
Peter Steinberger
ee3f28bb12 test: cover sync once idle timing after connect (#171) (thanks @fuleinist) 2026-04-21 01:35:38 +01:00
Peter Steinberger
3dfd4f98a7 fix: detect existing FTS tables after reopen (#185) (thanks @iamhitarth) 2026-04-21 01:32:44 +01:00
Hitarth Sharma
65c9be4e17 fix: detect FTS5 on existing databases after migration already applied
migrateMessagesFTS() sets ftsEnabled=true only when migration 3 first
runs. On subsequent opens, ensureSchema() skips already-applied
migrations, so ftsEnabled stays false — even when the FTS5 table and
triggers are fully functional.

Add a post-migration check in init() that probes the messages_fts
table directly. This correctly sets ftsEnabled regardless of migration
history, fixing doctor reporting FTS5=false and search falling back to
LIKE on existing databases.

Fixes #160 (partially — the build-tag issue is separate, but this
fixes the detection logic for databases that already have FTS5 tables).
2026-04-21 01:32:44 +01:00
Peter Steinberger
a022c49399 fix: show display text in message context (#183) (thanks @fuleinist) 2026-04-21 01:29:44 +01:00
fuleinist
259bcf22a8 messages context: prefer DisplayText over raw Text (issue #173)
Align messages context with messages list display logic:
- Use DisplayText first, fall back to Text
- Show 'Sent <media>' for media-only messages
- Keeps >> marker logic on top of resolved line
2026-04-21 01:29:44 +01:00
Peter Steinberger
a59b960d1c fix: add sync panic telemetry (#181) (thanks @shaun0927) 2026-04-21 01:28:06 +01:00
JunghwanNA
1b529708c7 enhance: log stack trace, event type, and counter when recovering sync panics (#178)
The event-handler recover added in #143 prints only the recover value.
Without a stack trace, event type, or running counter, recoveries are
indistinguishable from a silent swallow: a one-off bad payload looks the
same as a pathological loop, and neither surfaces the source of the
panic to maintainers.

Capture runtime/debug.Stack(), the event type (%T of evt), and a
per-Sync atomic counter in the recovery log so operators can tell how
often the handler is recovering and which event class caused it. No
behavioral change on the happy path.

Closes #178.
2026-04-21 01:28:06 +01:00
Peter Steinberger
42eb6260d7 fix: keep media workers alive after panics (#179) (thanks @shaun0927) 2026-04-21 01:27:03 +01:00
JunghwanNA
f77044b67a bug: recover per media job so a panic no longer kills the worker (#176)
PR #143 added recover() at the top of each media worker goroutine, but it
sits outside the for { select } loop. A panic in downloadMediaJob unwinds
past the loop, fires the recover, and the goroutine returns — permanently
shrinking the pool by one. Four panicking payloads drain a four-worker
pool to zero, after which media downloads stall silently.

Move the recover into an inner function wrapping each downloadMediaJob
call so the worker keeps consuming the queue. Include chatJID, msgID,
and debug.Stack() in the log line so panics stay diagnosable.

Closes #176.
2026-04-21 01:27:03 +01:00
Peter Steinberger
2c09f129c3 security: reject session store URI injection (#180) (thanks @shaun0927) 2026-04-21 01:25:49 +01:00
JunghwanNA
ea4ca18438 security: reject '?' and '#' in StorePath for whatsmeow session store (#177)
PR #141 (closes #59) added a guard against SQLite URI injection for the
message store at internal/store/db.go, but the whatsmeow session store at
internal/wa/client.go:49 uses the same fmt.Sprintf("file:%s?...", path)
pattern without that guard. A --store / WACLI_STORE_DIR value containing
'?' injects arbitrary SQLite URI parameters (mode, cache, _journal_mode,
...) into the session DSN and can silently neutralize foreign keys or
change isolation.

Apply the same ContainsAny(path, "?#") guard in wa.New so both backing
stores reject the injection path identically.

Closes #177.
2026-04-21 01:25:49 +01:00
Peter Steinberger
4eaad5f85f fix: persist retry messages (#186) (thanks @SimDamDev) 2026-04-21 01:24:28 +01:00
SimDamDev
70f2afd470 fix: enable UseRetryMessageStore so retry-receipts are handled
Symptom
-------
Recipients whose Signal session hasn't been freshly bootstrapped — most
commonly the sender's own other linked devices (e.g. WhatsApp Desktop
linked to the same account, or other linked devices of the recipient)
— receive messages sent by wacli but cannot decrypt them, and see
"Waiting for this message" indefinitely in WhatsApp.

Cause
-----
When a recipient device fails to decrypt, it sends a retry-receipt.
whatsmeow's retry handler looks up the original plaintext to re-encrypt
it with fresh session state. The lookup path is:

    in-memory recentMessages cache
      → GetMessageForRetry() callback (unset in wacli)
      → retry_message_store DB table (only if UseRetryMessageStore=true)

The `wacli send …` subcommand sends the message, then exits — the
in-memory cache dies with the process. `wacli sync` is a separate
process that never saw the outgoing message, so its in-memory cache
is empty too. Neither can answer the retry receipt, and whatsmeow
logs:

    Failed to handle retry receipt for <jid>/<id> from <jid>:<n>: couldn't find message <id>

Fix
---
Setting `c.client.UseRetryMessageStore = true` tells whatsmeow to
persist outgoing messages to its `retry_message_store` SQLite table
during Send(). A later wacli process (e.g. `wacli sync` restarting
after a `wacli send …` subcommand) then finds the plaintext via the
shared store, re-encrypts, and the recipient's "Waiting for this
message" resolves to the real content.

Reproduction
------------
1. Authenticate wacli via `wacli auth` (linked device pairing)
2. Send: `wacli send text --to <your own number> --message "hi"` so
   the target account has multiple linked devices
3. Observe: at least one recipient device shows "Waiting for this
   message. Check your phone." with the "retry receipt" log line
   above in the sender's journal.
4. With this patch applied: message is decrypted correctly on every
   recipient device, including ones that weren't online at send time.

Scope
-----
Six-line change. No new dependencies. whatsmeow handles the DB schema
(creates the `retry_message_store` table on first use); no migration
needed on existing installs.
2026-04-21 01:24:28 +01:00
Peter Steinberger
eb562ce875
chore: update dependencies 2026-04-21 01:22:05 +01:00
Dinakar Sarbada
666f77caed
docs: add maintainers section and CODEOWNERS (#163)
Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-04-15 11:28:37 -07:00
Dinakar Sarbada
a684ff03ae
fix: bound reconnect duration to prevent indefinite lock holding (#113)
ReconnectWithBackoff retries forever with exponential backoff (2s–30s).
When sync --follow loses its WhatsApp connection and can't recover, the
process sits retrying indefinitely while holding the store lock, blocking
all other wacli commands.

Add a reconnect() wrapper that applies a deadline to the backoff loop.
New --max-reconnect flag on sync (default 5m) controls this. Set to 0
for the old unlimited behavior.

Fixes #88.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-04-14 14:45:18 -07:00
Dinakar Sarbada
f02ce5d301
fix: force exit on second SIGINT during long-running commands (#112)
If sync/auth/history cleanup hangs after the first Ctrl+C, the process
becomes unkillable because signal.NotifyContext deregisters handlers
after the first delivery.

Extract a signalContext() helper that keeps listening: the first signal
cancels the context for graceful shutdown, the second calls os.Exit(1).

Applies to sync, auth, and history backfill — the three commands that
can run indefinitely.

Fixes #61.

Co-authored-by: Dinakar Sarbada <dinakars777@users.noreply.github.com>
2026-04-14 14:32:35 -07:00
Martín Alcalá Rubí
2fc5abeb11
feat: add WACLI_STORE_DIR env variable to configure store directory (#150)
Adds support for setting the store path via a `WACLI_STORE_DIR` environment variable, which is highly requested for Docker containers, CI pipelines, and multi-tenant deployments. 

The explicit `--store` CLI flag continues to take precedence over the environment variable when provided.

Closes #36
2026-04-14 14:27:10 -07:00
Martín Alcalá Rubí
ffddc91f92
security: add panic recovery to event handler and media workers (#143)
The event handler and media worker goroutines previously lacked panic recovery. If processing panicked from an unexpected message structure, it would crash the entire wacli process and drop the authenticated session.

This adds idiomatic `defer func() { recover() }()` blocks to the handlers. The process now survives individual message panics and logs the incident to stderr safely.

Closes #52
2026-04-14 14:26:40 -07:00
Martín Alcalá Rubí
9ff22a5ecf
bug: sanitize FTS5 user queries to prevent query-syntax injection (#146)
User search queries were passed directly to the FTS5 MATCH clause, allowing syntax errors or operator injection (AND, OR, NOT, NEAR).

This adds `sanitizeFTSQuery` which splits the input on whitespace and individually double-quotes each token. This prevents FTS5 operator injection while preserving intuitive multi-word search (implicit AND).

Closes #57
2026-04-14 14:25:47 -07:00
Martín Alcalá Rubí
ff2d74de9b
bug: escape LIKE wildcard chars in search queries (#145)
User search queries were wrapped in `%...%` LIKE patterns without escaping SQL LIKE wildcards, causing `%` searches to return every message in the database.

This adds an `escapeLIKE` helper that escapes `\`, `%`, and `_` before wrapping them, adding `ESCAPE '\'` clauses to all LIKE predicates to ensure user input is treated as literal text.

Closes #56
2026-04-14 14:25:19 -07:00
Martín Alcalá Rubí
7a2b323098
security: strip null bytes and control chars in path sanitization (#140)
When saving media or handling files, wacli relies on path sanitization. Previously, null bytes (\x00) and certain control characters were not explicitly stripped, which can lead to path traversal vulnerabilities when interfacing with the underlying OS file system.

This safely strips null bytes and control characters inside `SanitizeSegment` and `SanitizeFilename`.

Closes #60
2026-04-14 14:20:25 -07:00
Martín Alcalá Rubí
519cd53eb4
security: guard tableHasColumn against empty table name (#142)
tableHasColumn in migrations.go concatenates the table name directly into a PRAGMA table_info() SQL string. All current call sites pass hardcoded identifiers, but there is no safeguard to prevent accidental misuse with user-controlled input in the future.

This adds an explicit empty-name check that returns an error immediately, making the unsafe pattern visible and preventing invalid SQL from ever reaching SQLite.

Closes #58
2026-04-14 14:10:54 -07:00
Martín Alcalá Rubí
77c38d3a19
security: validate SQLite URI path against '?' and '#' injection (#141)
The --store path is embedded in a SQLite URI via fmt.Sprintf. A path
containing '?' could inject additional connection parameters. Reject
paths with '?' or '#' before constructing the URI.
2026-04-14 14:07:18 -07:00
dependabot[bot]
59a2c6cdc6
chore(deps): bump filippo.io/edwards25519 (#69)
Bumps the go_modules group with 1 update in the / directory: [filippo.io/edwards25519](https://github.com/FiloSottile/edwards25519).


Updates `filippo.io/edwards25519` from 1.1.0 to 1.1.1
- [Commits](https://github.com/FiloSottile/edwards25519/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: filippo.io/edwards25519
  dependency-version: 1.1.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-12 23:12:15 -07:00
alitala2009
f1914c3f5b
fix: update whatsmeow to fix 405 client outdated error (#117)
Bump go.mau.fi/whatsmeow from 20260211 to 20260410 (and go.mau.fi/util
v0.9.5 -> v0.9.6). The older whatsmeow build advertised client version
2.3000.1030403648 which WhatsApp now rejects with a 405 (Client
Outdated) response, preventing connections entirely.

Fixes #106
2026-04-12 01:08:43 -07:00
Peter Steinberger
16947198bc chore: bump version to 0.5.0 and update deps 2026-02-16 06:53:13 +01:00
Peter Steinberger
963f8dee1c docs: sync changelog since v0.2.0 2026-02-16 06:47:45 +01:00
Peter Steinberger
19a9f43350 ci: extract shared workflow setup action 2026-02-16 05:13:58 +01:00
Peter Steinberger
5ca42035de refactor: split store and groups modules 2026-02-16 05:13:53 +01:00
Peter Steinberger
18a10bd64f chore: bump unreleased to 0.3.0 2026-02-15 05:29:37 +01:00
Peter Steinberger
cc6a7a3cd9 docs: bump changelog to 0.2.1 2026-01-23 06:18:35 +00:00
Peter Steinberger
39e986cbf5 ci: install arm64 libc headers for releases 2026-01-23 06:12:43 +00:00
Peter Steinberger
2e69de90ea docs: update readme for 0.2.0 2026-01-23 06:12:29 +00:00
215 changed files with 24593 additions and 2808 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.

54
.github/actions/setup-ci-env/action.yml vendored Normal file
View File

@ -0,0 +1,54 @@
name: Setup CI Environment
description: Shared toolchain/bootstrap for CI and release jobs.
inputs:
go-version-file:
description: Path to go.mod/go.work file.
required: false
default: go.mod
setup-node:
description: Whether to install Node.js.
required: false
default: "false"
node-version:
description: Node.js version to install when setup-node is true.
required: false
default: "24"
setup-pnpm:
description: Whether to enable corepack and activate pnpm.
required: false
default: "false"
apt-packages:
description: Space-separated apt packages to install (ubuntu only).
required: false
default: ""
runs:
using: composite
steps:
- name: Setup Node
if: ${{ inputs.setup-node == 'true' }}
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: ${{ inputs.go-version-file }}
cache: true
- name: Setup pnpm
if: ${{ inputs.setup-pnpm == 'true' }}
shell: bash
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm --version
- name: Install apt packages
if: ${{ inputs.apt-packages != '' }}
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ${{ inputs.apt-packages }}

View File

@ -8,34 +8,23 @@ on:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v4
- name: Setup CI Environment
uses: ./.github/actions/setup-ci-env
with:
node-version: "20"
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Setup pnpm
run: |
corepack enable
corepack prepare pnpm@10.23.0 --activate
pnpm --version
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends build-essential
setup-node: "true"
node-version: "24"
setup-pnpm: "true"
apt-packages: "build-essential"
- name: pnpm format:check
run: pnpm -s format:check
@ -52,3 +41,38 @@ jobs:
env:
CGO_ENABLED: "1"
run: pnpm -s build
linux-release-builds:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup CI Environment
uses: ./.github/actions/setup-ci-env
with:
go-version-file: go.mod
apt-packages: "build-essential gcc-aarch64-linux-gnu libc6-dev-arm64-cross"
- name: GoReleaser check (macOS)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check --config .goreleaser.yaml
- name: GoReleaser check (linux/windows)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: check --config .goreleaser-linux-windows.yaml
- name: GoReleaser build (linux amd64/arm64)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: build --snapshot --clean --config .goreleaser-linux-windows.yaml --id wacli_linux_amd64 --id wacli_linux_arm64

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

@ -0,0 +1,55 @@
name: pages
on:
push:
branches:
- main
paths:
- "docs/**"
- "scripts/build-docs-site.mjs"
- "scripts/docs-site-assets.mjs"
- "scripts/docs-site-render.mjs"
- "package.json"
- ".github/workflows/pages.yml"
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Deploy docs
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
- name: Build docs site
run: node scripts/build-docs-site.mjs
- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: dist/docs-site
- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

View File

@ -14,82 +14,147 @@ on:
permissions:
contents: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
goreleaser-darwin:
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: ./.github/actions/setup-ci-env
with:
go-version-file: go.mod
cache: true
- name: Stash GoReleaser config
run: cp .goreleaser.yaml /tmp/.goreleaser.yaml
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout -- "$RELEASE_TAG"
- name: GoReleaser (macOS universal)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
version: "~> v2"
args: release --clean --config /tmp/.goreleaser.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
update-homebrew-tap:
runs-on: ubuntu-latest
needs: goreleaser-darwin
steps:
- name: Resolve release tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=$INPUT_TAG" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=$REF_NAME" >> "$GITHUB_ENV"
fi
- name: Dispatch tap formula update
env:
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "::warning::Skipping Homebrew tap update because HOMEBREW_TAP_TOKEN is not configured with workflow access to steipete/homebrew-tap"
exit 0
fi
request_id="wacli-${RELEASE_TAG}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
expected_title="Update wacli for ${RELEASE_TAG} (${request_id})"
gh workflow run update-formula.yml \
--repo steipete/homebrew-tap \
--ref main \
-f formula=wacli \
-f tag="$RELEASE_TAG" \
-f repository=openclaw/wacli \
-f macos_artifact=wacli-macos-universal.tar.gz \
-f request_id="$request_id"
run_id=""
for _ in {1..30}; do
run_id=$(gh run list \
--repo steipete/homebrew-tap \
--workflow update-formula.yml \
--branch main \
--event workflow_dispatch \
--limit 20 \
--json databaseId,displayTitle \
--jq ".[] | select(.displayTitle == \"$expected_title\") | .databaseId" | head -n1)
if [ -n "$run_id" ]; then
break
fi
sleep 5
done
if [ -z "$run_id" ]; then
echo "::error::Could not find tap workflow run with title: $expected_title"
exit 1
fi
gh run watch "$run_id" \
--repo steipete/homebrew-tap \
--exit-status \
--interval 10
goreleaser-linux-windows:
runs-on: ubuntu-latest
needs: goreleaser-darwin
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
uses: ./.github/actions/setup-ci-env
with:
go-version-file: go.mod
cache: true
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
build-essential \
gcc-aarch64-linux-gnu \
mingw-w64
apt-packages: "build-essential gcc-aarch64-linux-gnu libc6-dev-arm64-cross mingw-w64"
- name: Stash GoReleaser config
run: cp .goreleaser-linux-windows.yaml /tmp/.goreleaser-linux-windows.yaml
- name: Checkout release tag
if: ${{ github.event_name == 'workflow_dispatch' }}
run: git checkout ${{ inputs.tag }}
env:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout -- "$RELEASE_TAG"
- name: GoReleaser (linux/windows)
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
version: "~> v2"
args: release --clean --skip=publish --config /tmp/.goreleaser-linux-windows.yaml
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve release tag
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=${{ inputs.tag }}" >> $GITHUB_ENV
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "RELEASE_TAG=$INPUT_TAG" >> "$GITHUB_ENV"
else
echo "RELEASE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV
echo "RELEASE_TAG=$REF_NAME" >> "$GITHUB_ENV"
fi
- name: Upload linux/windows artifacts

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:

56
AGENTS.md Normal file
View File

@ -0,0 +1,56 @@
# Repository Guidelines
## Project Structure
- `cmd/wacli/`: CLI command wiring (auth, sync, messages, send, media, contacts, chats, groups, history, presence, doctor).
- `internal/app/`: core app, whatsmeow event handling, backfill, sync idle logic.
- `internal/store/`: SQLite schema, migrations, FTS5 search, chats/contacts/groups/messages/media queries.
- `internal/wa/`: whatsmeow client wrapper, JID resolution, message parsing (text, business, media, context).
- `internal/config/`: store-dir resolution (`WACLI_STORE_DIR` env → XDG state dir on Linux → `~/.wacli`).
- `internal/lock/`: platform-specific LOCK-file locking; acquired before all write commands.
- `internal/out/`: JSON + table output helpers; all human text goes through here.
- `internal/fsutil/`: enforces 0700/0600 owner-only permissions on store files.
- `internal/pathutil/`: sanitises StorePath; rejects `?` and `#` to prevent URI injection.
- `internal/sqliteutil/`: sqlite file helpers.
- Tests sit next to the code they cover (`*_test.go`).
## Key Architectural Facts
- **Two databases**: `session.db` (managed by whatsmeow) + `wacli.db` (app data, FTS5 search).
- **FTS5 table** is a separate trigger-synced table in `wacli.db`; requires `-tags sqlite_fts5` at build time.
- **Store lock**: a `LOCK` file in the store dir is acquired before any write operation; `--lock-wait` controls the wait timeout.
- **Read-only mode**: `--read-only` flag or `WACLI_READONLY=1` env; write commands exit immediately with a clear error.
- **Send retry**: bounded 45 s attempt timeout; retries once after reconnect for stale-session / usync-timeout errors.
- **Store path precedence**: `--store` flag → `WACLI_STORE_DIR` env → XDG `~/.local/state/wacli` on Linux (legacy `~/.wacli` fallback) → `~/.wacli` elsewhere.
## Build, Test, and Development Commands
- Build: `pnpm build` — compiles with `-tags sqlite_fts5` and `CGO_CFLAGS=-Wno-error=missing-braces` (required for GCC 15+).
- Run: `pnpm wacli -- <args>` — rebuilds then runs.
- Test: `pnpm test` — runs `go test ./...` (plain), `go test -tags sqlite_fts5 ./...` (FTS), and a Windows lock cross-compile check.
- Lint: `pnpm lint``go vet ./...`.
- Format fix: `pnpm format``gofmt -w .`.
- Format check: `pnpm format:check` — fails if any file would change.
- **Full gate** (must pass before every PR): `pnpm format:check && pnpm lint && pnpm test && pnpm build && git diff --check`.
## Coding Style
- Standard `gofmt` formatting; run `pnpm format` before committing.
- Output: send structured data to stdout (`--json` / table); send human hints, progress, and errors to stderr via `internal/out`.
- Prefer explicit error returns over panics; write short, early-return functions.
- No build-time CGO beyond sqlite3; keep the dependency tree minimal.
## Testing Guidelines
- Every bug fix should ship with a regression test.
- FTS-sensitive tests must run under `-tags sqlite_fts5`; non-FTS path tests must also pass without the tag.
- Use `fake_wa_test.go` / table-driven tests for whatsmeow interaction; avoid hitting real WhatsApp in unit tests.
- Integration tests that need a live account are opt-in and not part of the standard gate.
## Commit & Pull Request Guidelines
- Follow Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `security:`, `ci:` with an imperative summary.
- Keep commits focused; avoid bundling unrelated changes.
- PRs should state: what changed, why, how it was tested, and any new flags or env vars.
- Run the full gate locally before opening a PR; CI runs the same commands.
- New contributors: add `Co-authored-by:` trailers when building on their work.
## Agent Notes
- This repo uses `AGENTS.md` as its agent-instruction source; `CLAUDE.md` is explicitly ignored.
- For agent-safe execution, pass `--read-only` (or set `WACLI_READONLY=1`) to prevent writes.
- Prefer `--json` output for machine-readable parsing.
- Do not add dependencies or change build tooling without confirming with the maintainer.

View File

@ -1,10 +1,194 @@
# Changelog
## 0.3.0 - Unreleased
## 0.8.1 - 2026-05-08
### Changed
- Module: migrate the canonical Go module/import path to `github.com/openclaw/wacli`. (#217 - thanks @dinakars777)
- Sync: collapse routine interactive TTY progress into a single updating status line while keeping warnings visible as normal stderr lines.
### Chore
- CI: make the Homebrew tap handoff use `openclaw/wacli` and skip gracefully when the tap token is missing. (#216 - thanks @dinakars777)
- Maintainers: remove the stale personal CODEOWNERS rule after the OpenClaw move. (#218 - thanks @dinakars777)
- Release: update GoReleaser archive config to the current v2 schema so release-config checks stay green.
### Fixed
- CLI: truncate table output by rune so emoji and other non-ASCII text stay valid UTF-8. (#222 - thanks @dinakars777)
- History: apply coverage/actionable filters before `LIMIT` so newer blocked chats do not hide ready chats. (#219 - thanks @dinakars777)
- Messages: extract display/search text from shared WhatsApp contact cards, including vCard phone numbers. (#214)
- Send: route whatsmeow diagnostics to stderr and clarify that `sent: true` means WhatsApp accepted the send request. (#215 - thanks @dinakars777)
- Sync: let explicit `--max-messages=0` override `WACLI_SYNC_MAX_MESSAGES`. (#220 - thanks @dinakars777)
## 0.8.0 - 2026-05-07
### Added
- TBD.
- Accounts: add first-class named WhatsApp accounts with isolated stores, `--account NAME`, and `wacli accounts list/add/use/show/remove`.
### Fixed
- Store: fix migration of legacy databases whose `groups` table existed before group hierarchy columns were introduced.
### Docs
- Docs: add a dedicated accounts page covering YAML config, store selection precedence, and multi-account usage.
## 0.7.0 - 2026-05-06
### Added
- CLI: add `--read-only`/`WACLI_READONLY` to reject commands that write WhatsApp or the local store.
- CLI: add `--lock-wait` to wait for transient store locks before failing write commands.
- CLI: add `--events` to emit machine-readable NDJSON lifecycle events for `auth`, `sync`, and `history backfill`. (#204 — thanks @dinakars777 and @0xatrilla)
- CLI: add `wacli docs` and root help text that point to the hosted docs at `https://wacli.sh`.
- CLI: add `--full` to disable table truncation; piped output now keeps full message IDs. (#13 — thanks @rickhallett)
- CLI: add `presence typing` and `presence paused` commands for WhatsApp composing indicators. (#76 — thanks @redemerco)
- Diagnostics: show linked JID and local store counts in `auth status` and `doctor`. (#149 — thanks @draix)
- Messages: add `messages list --sender`, `--from-me`, `--from-them`, and `--asc` filters. (#153 — thanks @draix)
- Messages: track WhatsApp starred state and add `messages starred` plus `--starred` filters for list/search. (#17 — thanks @dan-dr)
- Messages: track WhatsApp delete-for-me app-state events as local tombstones and add `messages delete --for-me`. (#64 — thanks @vlassance)
- Messages: add `messages edit` and `messages delete` for editing or revoking your own sent messages. (#80 — thanks @frapeti)
- Messages: add `messages search --has-media`, `--type text`, case-insensitive media types, and validation for contradictory filters. (#128 — thanks @ImLukeF and @Mansehej)
- Messages: add JSON export with `messages export --after` and `--before` filters.
- Messages: extract searchable/display text from WhatsApp Business templates, buttons, interactive messages, and list replies. (#79 — thanks @terry-li-hm)
- Contacts: add `contacts import-system` to import macOS Contacts display names as local metadata with alias-first precedence. (#33 — thanks @enki and @octaviofroid)
- Auth: add `auth --qr-format text` to print the raw WhatsApp QR payload for external renderers. (#22 — thanks @teren-papercutlabs)
- Auth: add `auth --phone` for WhatsApp's phone-number pairing flow on headless systems. (#148, #184 — thanks @giovanninibarbosa and @KillerSnails)
- Auth: auto-detect a readable linked-device label and default linked-device platform to desktop. (#100 — thanks @pmatheus)
- Chats: add archive/unarchive, pin/unpin, mute/unmute, and mark-read/mark-unread commands, plus list/show state fields. (#46 — thanks @decodiver22)
- Channels: add WhatsApp Channel list/info/join/leave commands, channel chat caching, and text/file sends to `...@newsletter` JIDs. (#72 — thanks @frapeti)
- Groups: persist WhatsApp Community parent/subgroup metadata from group refresh and info. (#207, #39 — thanks @dinakars777 and @TheMazzle)
- History: add `history coverage` and `history fill --dry-run` to inspect local archive anchors before running best-effort backfill. (#111 — thanks @cropsgg)
- Profile: add `profile set-picture` to update the authenticated account profile picture from JPEG or PNG input. (#198 — thanks @gado-ships-it)
- Sync: add signed live-message webhooks with `--webhook` and `--webhook-secret`. (#203 — thanks @dinakars777 and @Melostack)
- Send: add `send react` to add or clear reactions, with group sender validation. (#151 — thanks @draix)
- Send: add opt-in `send text --message-escapes` for `\n`, `\r`, `\t`, `\\`, and `\"` in `--message`. (#206 — thanks @slaveofcode)
- Send: add `send file --reply-to` for quoted media/document replies. (#68 — thanks @vlassance)
- Send: add repeatable `send text --mention` for WhatsApp user mentions in group messages. (#16 — thanks @nicozefrench and @sheepworrier)
- Send: add automatic link previews for text messages with `--no-preview` opt-out. (#94, #95 — thanks @elgatoflaco)
- Send: add `send sticker` for 512x512 WebP stickers, including animated-sticker metadata. (#205, #27 — thanks @dinakars777 and @fm1randa)
- Send: add `send voice` and `send file --ptt` for OGG/Opus WhatsApp voice notes. (#40, #41 — thanks @ricardopolo and @emre6943)
- Send: accept common phone-number formatting in recipient flags while still storing digits-only WhatsApp JIDs. (#130 — thanks @fahmidme and @ImLukeF)
- Send: resolve `send text/file --to` against local contacts, groups, and chats, with `--pick` for non-interactive disambiguation. (#122 — thanks @AndroidPoet)
- Store: add local-only `store stats`, `store cleanup`, `chats cleanup`, and `groups prune` commands with dry-run previews and confirmation gates. (#210, #211 — thanks @thedavidweng)
### Security
- Auth: reject `?` and `#` in whatsmeow session store paths to avoid SQLite URI parameter injection. (#180 — thanks @shaun0927)
- Media: reject send-file uploads and media downloads larger than 100 MiB before reading or writing the payload. (#63 — thanks @alexander-morris)
- Send: warn when send commands are invoked in rapid succession so automation rate-limit/account-risk is visible. (#53 — thanks @alexander-morris)
- Send: validate phone-number recipients before constructing WhatsApp JIDs. (#144 — thanks @draix)
- Sync: add message-count and database-size caps plus uncapped-sync warnings to avoid unbounded local history growth. (#54 — thanks @alexander-morris)
- Store: restrict index and session SQLite database files to owner-only permissions. (#147 — thanks @draix)
### Fixed
- Auth: retry transient websocket drops before QR or phone pairing completes.
- Auth: propagate QR channel setup errors and surface actionable QR pairing failures. (#100 — thanks @pmatheus)
- Build: fail cgo-disabled CLI builds at compile time instead of shipping a go-sqlite3 stub binary. (#194 — thanks @rajgopalv)
- Chats: resolve mapped historical `@lid` chat rows in `chats list/show` output. (#31, #89 — thanks @bhaskoro-muthohar and @alexph-dev)
- Groups: hide groups after `groups leave`, mark missing joined groups as left during refresh, and show them again if a later refresh reports membership. (#125, #129 — thanks @SeifBenayed and @ImLukeF)
- History: cap on-demand backfill at 500 messages per request and 100 requests per run.
- History: skip automatic initial history-sync blob downloads during on-demand backfill to avoid OOM on constrained Linux/ARM devices. (#84 — thanks @jyothepro)
- Messages: normalize device-specific `@s.whatsapp.net` JIDs before storing chats, contacts, and senders.
- Messages: include mapped `@lid` rows when listing, searching, showing, or contextualizing by phone-number chat JID.
- Messages: read stored sender names back from SQLite and resolve blank historical `@lid` senders at display time.
- Store: migrate historical `@lid` chat and message rows to mapped phone-number JIDs during authenticated startup. (#31, #89 — thanks @bhaskoro-muthohar, @alexph-dev, and @dinakars777)
- Messages: make `messages show` prefer stored display text and include stored media/download details.
- Messages: store structured reaction target IDs and emoji in SQLite. (#67 — thanks @vlassance)
- Messages: store forwarded-message metadata and add `--forwarded` filters for list/search. (#24 — thanks @bnvyas)
- Doctor: report lock owner PID and distinguish paired stores locked by another process. (#105 — thanks @artemgetmann)
- Media: recover panics per download job so one bad payload no longer drains the worker pool. (#179 — thanks @shaun0927)
- Media: allow explicit download outputs in shared directories like `/tmp` without trying to chmod the parent directory.
- Messages: attribute history messages from LID-addressed groups to the top-level participant sender. (#19 — thanks @entropyy0)
- Messages: show display text for replies, reactions, and media in `messages context`. (#183 — thanks @fuleinist)
- Send: strip a leading `+` from phone-number recipients before building WhatsApp JIDs. (#74 — thanks @FrederickStempfle)
- Search: keep FTS5 enabled after reopening existing databases with already-applied migrations. (#185 — thanks @iamhitarth)
- Send: delegate send commands through a running `sync --follow` process instead of failing on the store lock. (#6, #48, #92)
- Send: add `send text --reply-to` for quoted replies, with sender inference for synced group messages. (#154 — thanks @draix)
- Send: store outgoing `send react` messages locally so `messages list/show/search` can see the sent reaction immediately.
- Send: validate image uploads and include image dimensions plus a JPEG thumbnail for better client rendering.
- Send: keep the connection alive briefly after successful sends so retry receipts can repair first-send session gaps. (#89 — thanks @alexph-dev)
- Send: bound send attempts and reconnect once for stale-session/time-out failures instead of hanging indefinitely. (#115 — thanks @0xatrilla)
- Send: include the Opus codec parameter when sending OGG audio so WhatsApp delivers it as audio. (#41 — thanks @emre6943)
- Send: persist retry-message plaintext so linked devices can decrypt retried messages. (#186 — thanks @SimDamDev)
- Store: use the XDG state directory on Linux by default, while keeping existing `~/.wacli` stores working. (#172, #164 — thanks @txhno)
- Sync: guard lazy WhatsApp client initialization against concurrent `OpenWA` calls. (#62 — thanks @thakoreh)
- Sync: request a whatsmeow app-state recovery snapshot when LTHash verification fails. (#47 — thanks @elpargo)
- Sync: decrypt encrypted reactions delivered through history sync before storing them. (#192 — thanks @matrixise)
- Sync: resolve live `@lid` chat and sender JIDs to phone-number JIDs before storing messages. (#196 — thanks @mahidconseil)
- Sync: warn when encrypted reaction messages cannot be decrypted instead of dropping the failure silently. (#192 — thanks @matrixise and @dinakars777)
- CLI: emit command errors as NDJSON `error` events when `--events` is enabled.
- Sync: keep `sync --once` idle timing focused on message/history events so connection chatter cannot hang exit. (#119 — thanks @jyothepro)
- Sync: start `sync --once` idle timing after the `Connected` event. (#171 — thanks @fuleinist)
- Sync: include event type, stack trace, and recovery count when logging recovered event-handler panics. (#181 — thanks @shaun0927)
- Sync: apply bounded backpressure to media download enqueueing instead of spawning unbounded overflow goroutines. (#121 — thanks @jyothepro)
- Windows: split store locking by platform so the lock package compiles on Windows. (#188 — thanks @dinakars777)
### Docs
- README: add a documentation index and complete command quick reference.
- Docs: add an overview plus one page for every top-level CLI subcommand.
- Docs: add companion integration guidance for safe read-only SQLite, JSON, events, and webhook usage. (#71 — thanks @jaredtribe)
- Maintainers: add CODEOWNERS and maintainer contact info.
- Agents: add AGENTS.md for AI agent guidance. (#190 — thanks @adhitShet)
### Chore
- CI: compile-test the Windows lock package to catch platform regressions. (#188 — thanks @dinakars777)
- CLI: route `version` output through Cobra's configured output stream for easier command tests. (#78 — thanks @nikolasdehor)
- Dependencies: update Go modules including `whatsmeow`, `go-sqlite3`, `x/*`, and related runtime libs; refresh the pinned pnpm toolchain.
- Refactor: split WhatsApp message parsing into focused text, media, business, and context helpers.
- Refactor: inject clocks in app/store paths for deterministic tests.
- Version: bump CLI version string to `0.7.0`.
## 0.6.0 - 2026-04-14
### Security
- Search: sanitize FTS5 user queries and escape LIKE wildcards to avoid query-syntax injection.
- Store: reject SQLite URI path injection via `?` and `#`, guard empty table names, and strip null/control chars from sanitized paths.
- Sync: recover panics in event handlers and media workers instead of crashing the process.
### Fixed
- Sync: bound reconnect duration so long-running commands do not hold the store lock forever.
- CLI: force exit on a second SIGINT during long-running commands.
### Added
- Store: add `WACLI_STORE_DIR` to configure the default store directory.
### Chore
- Dependencies: bump `filippo.io/edwards25519`.
## 0.5.0 - 2026-04-12
### Fixed
- WhatsApp connectivity: update `whatsmeow` for the current WhatsApp protocol and fix `405 (Client Outdated)` failures.
### Changed
- Internal architecture: split store and groups command logic into focused modules for cleaner maintenance and safer follow-up changes.
- Dependencies: bump core Go modules including `whatsmeow`, `go-sqlite3`, and `x/*` runtime libs.
### Build
- CI: extract a shared setup action and reuse it across CI and release workflows.
- Release: install arm64 libc headers in release workflow to improve ARM build reliability.
### Docs
- README: update usage/docs for the 0.2.0 release baseline.
- Changelog: sync unreleased notes with all commits since `v0.2.0`.
### Chore
- Version: bump CLI version string to `0.5.0`.
## 0.2.0 - 2026-01-23
@ -23,7 +207,18 @@
- Release: multi-OS GoReleaser configs and workflow for macOS, linux, and windows artifacts.
## 0.1.0 - 2026-01-01
### Docs
- Install: clarify Homebrew vs local build paths.
- Changelog: introduce project changelog and prep `0.2.0` release notes.
## 0.1.1 - 2025-12-12
### Fixed
- Release: fix workflow for CGO builds.
## 0.1.0 - 2025-12-12
### Added

View File

@ -1,6 +1,6 @@
MIT License
\g<1>2026 Peter Steinberger
Copyright (c) 2026 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

164
README.md
View File

@ -1,115 +1,139 @@
# 🗃️ wacli — WhatsApp CLI: sync, search, send.
# 🗃️ wacli — WhatsApp CLI: sync, search, send
WhatsApp CLI built on top of `whatsmeow`, focused on:
A scriptable WhatsApp client built on [`whatsmeow`](https://github.com/tulir/whatsmeow). Pairs as a linked WhatsApp Web device, mirrors your messages into a local SQLite store, and gives you offline search, sending, and chat/group/contact management from the command line.
- Best-effort local sync of message history + continuous capture
- Fast offline search
- Sending messages
- Contact + group management
> Third-party tool. Uses the WhatsApp Web protocol via `whatsmeow`. Not affiliated with WhatsApp.
This is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow` and is not affiliated with WhatsApp.
Full documentation: **<https://wacli.sh>**
## Status
## Features
Core implementation is in place. See `docs/spec.md` for the full design notes.
- **Auth + sync** — QR pairing, one-shot or follow-mode sync, optional media downloads, optional signed webhook fan-out.
- **Offline message store** — SQLite with FTS5 search (LIKE fallback), filterable by chat, sender, direction, time, and media type.
- **Sending** — text with mentions/replies/link-previews, files (image/video/audio/document, ≤100 MiB), stickers, voice notes, reactions; rapid-send guardrails and retry-receipt grace.
- **History backfill** — best-effort per-chat requests to your primary device for older messages.
- **Contacts / chats / groups / channels** — search, alias, tag, archive, pin, mute, mark-read, rename, prune, manage participants and invite links, send to channels.
- **Diagnostics + safety**`doctor`, read-only mode, store locks with owner reporting, panic recovery, bounded media queue, owner-only DB perms.
- **Scriptable**`--json` everywhere, `--events` NDJSON lifecycle stream, deterministic exit codes.
## Install / Build
## Install
Choose **one** of the following options.
If you install via Homebrew, you can skip the local build step.
### Homebrew (recommended)
### Option A: Install via Homebrew (tap)
```bash
brew install steipete/tap/wacli
```
- `brew install steipete/tap/wacli`
If a Linux install reports `Binary was compiled with 'CGO_ENABLED=0'`, run `brew update && brew reinstall steipete/tap/wacli`.
### Option B: Build locally
### Build from source
- `go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli`
`wacli` uses `go-sqlite3`, so cgo + a C compiler are required.
Run (local build only):
- macOS: Xcode Command Line Tools.
- Debian/Ubuntu: `sudo apt install build-essential`.
- `./dist/wacli --help`
```bash
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
```
For local development:
```bash
git clone https://github.com/openclaw/wacli.git
cd wacli
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
./dist/wacli --help
```
## Quick start
Default store directory is `~/.wacli` (override with `--store DIR`).
```bash
# 1) Authenticate (shows QR), then bootstrap sync
pnpm wacli auth
# or: ./dist/wacli auth (after pnpm build)
# 1. Pair (shows QR), then bootstrap sync
wacli auth
# 2) Keep syncing (never shows QR; requires prior auth)
pnpm wacli sync --follow
# 2. Keep syncing in the background (no QR; needs prior auth)
wacli sync --follow
# Diagnostics
pnpm wacli doctor
# 3. Search
wacli messages search "meeting"
# Search messages
pnpm wacli messages search "meeting"
# 4. Send
wacli send text --to 1234567890 --message "hello"
wacli send file --to mom --file ./pic.jpg --caption "hi"
# Backfill older messages for a chat (best-effort; requires your primary device online)
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
# Download media for a message (after syncing)
./wacli media download --chat 1234567890@s.whatsapp.net --id <message-id>
# Send a message
pnpm wacli send text --to 1234567890 --message "hello"
# Send a file
./wacli send file --to 1234567890 --file ./pic.jpg --caption "hi"
# List groups and manage participants
pnpm wacli groups list
pnpm wacli groups rename --jid 123456789@g.us --name "New name"
# 5. Diagnostics
wacli doctor
```
## Prior Art / Credit
Recipients accept a JID, phone number (E.164 or formatted), channel JID, or a synced contact/group/chat name. Ambiguous names prompt in a TTY; pass `--pick N` in scripts.
This project is heavily inspired by (and learns from) the excellent `whatsapp-cli` by Vicente Reig:
More recipes — replies, mentions, stickers, voice, reactions, channels, history backfill, chat management — live in the [docs](https://wacli.sh).
- [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli)
## Documentation
## High-level UX
| Area | Pages |
| --- | --- |
| **Setup** | [overview](docs/overview.md) · [auth](docs/auth.md) · [accounts](docs/accounts.md) · [sync](docs/sync.md) · [doctor](docs/doctor.md) |
| **Messaging** | [messages](docs/messages.md) · [send](docs/send.md) · [media](docs/media.md) · [presence](docs/presence.md) |
| **Address book** | [contacts](docs/contacts.md) · [chats](docs/chats.md) · [groups](docs/groups.md) · [channels](docs/channels.md) |
| **History** | [history coverage / fill / backfill](docs/history.md) |
| **Local store** | [store](docs/store.md) · [companion integrations](docs/integrations.md) |
| **Misc** | [profile](docs/profile.md) · [version](docs/version.md) · [completion](docs/completion.md) · [release](docs/release.md) |
- `wacli auth`: interactive login (shows QR code), then immediately performs initial data sync.
- `wacli sync`: non-interactive sync loop (never shows QR; errors if not authenticated).
- Output is human-readable by default; pass `--json` for machine-readable output.
## Configuration
## Storage
Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere. Existing `~/.wacli` directories on Linux keep working. Use `wacli accounts add NAME` and `--account NAME` for first-class multi-account stores.
Defaults to `~/.wacli` (override with `--store DIR`).
**Global flags:** `--store DIR`, `--account NAME`, `--json`, `--events`, `--full`, `--timeout DUR`, `--lock-wait DUR`, `--read-only`.
**Environment overrides:**
| Variable | Effect |
| --- | --- |
| `WACLI_STORE_DIR` | Default store directory. |
| `WACLI_READONLY` | `1`/`true`/`yes`/`on` enables read-only mode. |
| `WACLI_DEVICE_LABEL` | Linked-device label shown in WhatsApp. Defaults to `wacli - <OS> (<host>)`. |
| `WACLI_DEVICE_PLATFORM` | Linked-device platform. Defaults to `DESKTOP`; invalid values fall back to `CHROME`. |
| `WACLI_SYNC_MAX_MESSAGES` | Stop sync once total local messages exceed this count. |
| `WACLI_SYNC_MAX_DB_SIZE` | Stop sync once `wacli.db` + sidecars reach a size like `500MB` or `2GB`. |
## Backfilling older history
`wacli sync` stores whatever WhatsApp Web sends opportunistically. To try to fetch *older* messages, use on-demand history sync requests to your **primary device** (your phone).
`wacli sync` only stores what WhatsApp Web sends opportunistically. To fetch *older* messages, `wacli` issues on-demand history requests to your **primary device** (your phone), which must be online.
Important notes:
- This is **best-effort**: WhatsApp may not return full history.
- Your **primary device must be online**.
- Requests are **per chat** (DM or group). `wacli` uses the *oldest locally stored message* in that chat as the anchor.
- Recommended `--count` is `50` per request.
### Backfill one chat
- Best-effort: WhatsApp may not return full history.
- One request anchors on the **oldest locally stored message** in that chat — run `sync` first.
- Recommended `--count 50` per request (max 500). Max `--requests 100` per run.
- `history coverage` shows which chats are eligible. `history fill --dry-run` plans without connecting.
```bash
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
wacli history coverage --include-blocked
wacli history fill --dry-run --kind group --limit 20
wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
```
### Backfill all chats (script)
This loops through chats already known in your local DB:
Loop over every known chat:
```bash
pnpm -s wacli -- --json chats list --limit 100000 \
| jq -r '.[].JID' \
wacli --json chats list --limit 100000 \
| jq -r '.data[].JID' \
| while read -r jid; do
pnpm -s wacli -- history backfill --chat "$jid" --requests 3 --count 50
wacli history backfill --chat "$jid" --requests 3 --count 50
done
```
## Credits
Heavily inspired by [`whatsapp-cli`](https://github.com/vicentereig/whatsapp-cli) by Vicente Reig.
## Maintainers
- Created by [@steipete](https://github.com/steipete)
- Currently maintained by [@dinakars777](https://github.com/dinakars777)
## License
See `LICENSE`.
See [`LICENSE`](LICENSE).

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

@ -0,0 +1,289 @@
package main
import (
"fmt"
"os"
"sort"
"time"
"github.com/openclaw/wacli/internal/config"
"github.com/openclaw/wacli/internal/fsutil"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
type accountPayload struct {
Name string `json:"name"`
Label string `json:"label,omitempty"`
ConfiguredStore string `json:"configured_store"`
StoreDir string `json:"store_dir"`
Default bool `json:"default"`
}
func newAccountsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "accounts",
Short: "Manage named WhatsApp accounts",
}
cmd.AddCommand(newAccountsListCmd(flags))
cmd.AddCommand(newAccountsAddCmd(flags))
cmd.AddCommand(newAccountsUseCmd(flags))
cmd.AddCommand(newAccountsShowCmd(flags))
cmd.AddCommand(newAccountsRemoveCmd(flags))
return cmd
}
func newAccountsListCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List configured accounts",
RunE: func(cmd *cobra.Command, args []string) error {
path := config.DefaultConfigPath()
cfg, _, err := config.LoadAccountsConfigIfExists(path)
if err != nil {
return err
}
accounts := sortedAccounts(path, cfg)
payloads := accountPayloads(accounts)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"config_path": path,
"default_account": cfg.DefaultAccount,
"accounts": payloads,
})
}
if len(accounts) == 0 {
fmt.Fprintln(os.Stdout, "No accounts configured. Run `wacli accounts add personal`.")
return nil
}
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "DEFAULT\tNAME\tSTORE")
for _, account := range accounts {
mark := ""
if account.Default {
mark = "*"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", mark, account.Name, account.StoreDir)
}
_ = w.Flush()
return nil
},
}
}
func newAccountsAddCmd(flags *rootFlags) *cobra.Command {
opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
var noAuth bool
cmd := &cobra.Command{
Use: "add NAME",
Short: "Add an account and authenticate it",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
name := args[0]
if err := config.ValidateAccountName(name); err != nil {
return err
}
if !noAuth {
if _, err := validateAuthOptions(flags, opts); err != nil {
return err
}
}
path := config.DefaultConfigPath()
cfg, _, err := config.LoadAccountsConfigIfExists(path)
if err != nil {
return err
}
if _, ok := cfg.Accounts[name]; ok {
return fmt.Errorf("account %q already exists", name)
}
cfg.Accounts[name] = config.AccountEntry{Store: config.DefaultAccountStore(name)}
if cfg.DefaultAccount == "" {
cfg.DefaultAccount = name
}
storeDir := config.ListAccounts(path, cfg)
var added config.Account
for _, account := range storeDir {
if account.Name == name {
added = account
break
}
}
if err := fsutil.EnsurePrivateDir(added.StoreDir); err != nil {
return fmt.Errorf("create account store: %w", err)
}
if err := config.SaveAccountsConfig(path, cfg); err != nil {
return err
}
if noAuth {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"config_path": path,
"account": accountPayloadFromAccount(added),
})
}
fmt.Fprintf(os.Stdout, "Account %s added at %s. Run `wacli --account %s auth` to authenticate.\n", name, added.StoreDir, name)
return nil
}
oldAccount := flags.account
oldStore := flags.storeDir
flags.account = name
flags.storeDir = ""
defer func() {
flags.account = oldAccount
flags.storeDir = oldStore
}()
if !flags.asJSON {
fmt.Fprintf(os.Stdout, "Account %s added at %s\n", name, added.StoreDir)
}
res, err := runAuth(flags, opts)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"account": accountPayloadFromAccount(added),
"authenticated": true,
"messages_stored": res.MessagesStored,
})
}
fmt.Fprintf(os.Stdout, "Account %s authenticated. Messages stored: %d\n", name, res.MessagesStored)
return nil
},
}
addAuthFlags(cmd, &opts)
cmd.Flags().BoolVar(&noAuth, "no-auth", false, "create the account without running auth")
return cmd
}
func newAccountsUseCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "use NAME",
Short: "Set the default account",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
name := args[0]
if err := config.ValidateAccountName(name); err != nil {
return err
}
path := config.DefaultConfigPath()
cfg, err := config.LoadAccountsConfig(path)
if err != nil {
return err
}
if _, ok := cfg.Accounts[name]; !ok {
return fmt.Errorf("account %q is not configured", name)
}
cfg.DefaultAccount = name
if err := config.SaveAccountsConfig(path, cfg); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"default_account": name})
}
fmt.Fprintf(os.Stdout, "Default account: %s\n", name)
return nil
},
}
}
func newAccountsShowCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "show NAME",
Short: "Show one configured account",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
_, account, err := config.ResolveAccountStore(config.DefaultConfigPath(), args[0])
if err != nil {
return err
}
payload := accountPayloadFromAccount(account)
if flags.asJSON {
return out.WriteJSON(os.Stdout, payload)
}
fmt.Fprintf(os.Stdout, "Name: %s\nStore: %s\nDefault: %t\n", payload.Name, payload.StoreDir, payload.Default)
if payload.Label != "" {
fmt.Fprintf(os.Stdout, "Label: %s\n", payload.Label)
}
return nil
},
}
}
func newAccountsRemoveCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "remove NAME",
Short: "Remove an account from config without deleting its store",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
name := args[0]
if err := config.ValidateAccountName(name); err != nil {
return err
}
path := config.DefaultConfigPath()
cfg, err := config.LoadAccountsConfig(path)
if err != nil {
return err
}
entry, ok := cfg.Accounts[name]
if !ok {
return fmt.Errorf("account %q is not configured", name)
}
storeDir := config.ListAccounts(path, &config.AccountsConfig{
DefaultAccount: cfg.DefaultAccount,
Accounts: map[string]config.AccountEntry{name: entry},
})[0].StoreDir
delete(cfg.Accounts, name)
if cfg.DefaultAccount == name {
cfg.DefaultAccount = ""
}
if err := config.SaveAccountsConfig(path, cfg); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"removed": name,
"store_dir_kept": storeDir,
})
}
fmt.Fprintf(os.Stdout, "Removed account %s. Store kept at %s\n", name, storeDir)
return nil
},
}
}
func sortedAccounts(path string, cfg *config.AccountsConfig) []config.Account {
accounts := config.ListAccounts(path, cfg)
sort.Slice(accounts, func(i, j int) bool {
return accounts[i].Name < accounts[j].Name
})
return accounts
}
func accountPayloads(accounts []config.Account) []accountPayload {
payloads := make([]accountPayload, 0, len(accounts))
for _, account := range accounts {
payloads = append(payloads, accountPayloadFromAccount(account))
}
return payloads
}
func accountPayloadFromAccount(account config.Account) accountPayload {
return accountPayload{
Name: account.Name,
Label: account.Label,
ConfiguredStore: account.ConfiguredStore,
StoreDir: account.StoreDir,
Default: account.Default,
}
}

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

@ -0,0 +1,117 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/wacli/internal/config"
)
func TestAccountsAddNoAuthCreatesConfig(t *testing.T) {
isolateAccountConfigHome(t)
var stdout string
stderr := captureRootStderr(t, func() {
stdout = captureRootStdout(t, func() {
if err := execute([]string{"accounts", "add", "personal", "--no-auth"}); err != nil {
t.Fatalf("execute accounts add: %v", err)
}
})
})
if stderr != "" {
t.Fatalf("stderr = %q, want empty", stderr)
}
if !strings.Contains(stdout, "Account personal added") {
t.Fatalf("stdout = %q, want account added", stdout)
}
cfg, err := config.LoadAccountsConfig(config.DefaultConfigPath())
if err != nil {
t.Fatalf("LoadAccountsConfig: %v", err)
}
if cfg.DefaultAccount != "personal" {
t.Fatalf("DefaultAccount = %q, want personal", cfg.DefaultAccount)
}
account, ok := cfg.Accounts["personal"]
if !ok {
t.Fatal("personal account missing")
}
if account.Store != "accounts/personal" {
t.Fatalf("Store = %q, want accounts/personal", account.Store)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal")); err != nil {
t.Fatalf("account store not created: %v", err)
}
}
func TestAccountsAddValidatesAuthFlagsBeforeSaving(t *testing.T) {
isolateAccountConfigHome(t)
err := execute([]string{"--json", "accounts", "add", "personal", "--qr-format", "text"})
if err == nil || !strings.Contains(err.Error(), "--qr-format=text cannot be combined with --json") {
t.Fatalf("execute error = %v, want QR/json validation error", err)
}
if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) {
t.Fatalf("config stat error = %v, want not exist", statErr)
}
storeDir := filepath.Join(filepath.Dir(config.DefaultConfigPath()), "accounts", "personal")
if _, statErr := os.Stat(storeDir); !os.IsNotExist(statErr) {
t.Fatalf("store stat error = %v, want not exist", statErr)
}
}
func TestAccountsAddRejectsWhitespaceName(t *testing.T) {
isolateAccountConfigHome(t)
err := execute([]string{"accounts", "add", " work ", "--no-auth"})
if err == nil || !strings.Contains(err.Error(), "whitespace") {
t.Fatalf("execute error = %v, want whitespace validation error", err)
}
if _, statErr := os.Stat(config.DefaultConfigPath()); !os.IsNotExist(statErr) {
t.Fatalf("config stat error = %v, want not exist", statErr)
}
}
func TestAccountsListJSON(t *testing.T) {
isolateAccountConfigHome(t)
cfgPath := config.DefaultConfigPath()
cfg := &config.AccountsConfig{
DefaultAccount: "work",
Accounts: map[string]config.AccountEntry{
"personal": {Store: "accounts/personal"},
"work": {Store: "accounts/work"},
},
}
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
t.Fatal(err)
}
var stdout string
stdout = captureRootStdout(t, func() {
if err := execute([]string{"--json", "accounts", "list"}); err != nil {
t.Fatalf("execute accounts list: %v", err)
}
})
var payload struct {
Data struct {
DefaultAccount string `json:"default_account"`
Accounts []struct {
Name string `json:"name"`
Default bool `json:"default"`
} `json:"accounts"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
t.Fatalf("json.Unmarshal(%q): %v", stdout, err)
}
if payload.Data.DefaultAccount != "work" || len(payload.Data.Accounts) != 2 {
t.Fatalf("payload = %+v, want work and 2 accounts", payload)
}
if payload.Data.Accounts[0].Name != "personal" || payload.Data.Accounts[1].Name != "work" || !payload.Data.Accounts[1].Default {
t.Fatalf("accounts = %+v, want sorted personal/work with work default", payload.Data.Accounts)
}
}

View File

@ -3,54 +3,40 @@ package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"strings"
"time"
"github.com/mdp/qrterminal/v3"
appPkg "github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
appPkg "github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"go.mau.fi/whatsmeow/types"
)
type authOptions struct {
follow bool
idleExit time.Duration
downloadMedia bool
qrFormat string
phone string
}
type validatedAuthOptions struct {
qrFormat string
pairPhone string
}
func newAuthCmd(flags *rootFlags) *cobra.Command {
var follow bool
var idleExit time.Duration
var downloadMedia bool
opts := authOptions{idleExit: 30 * time.Second, qrFormat: "terminal"}
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with WhatsApp (QR) and bootstrap sync",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
a, lk, err := newApp(ctx, flags, true, true)
if err != nil {
return err
}
defer closeApp(a, lk)
mode := appPkg.SyncModeBootstrap
if follow {
mode = appPkg.SyncModeFollow
}
fmt.Fprintln(os.Stderr, "Starting authentication…")
res, err := a.Sync(ctx, appPkg.SyncOptions{
Mode: mode,
AllowQR: true,
DownloadMedia: downloadMedia,
RefreshContacts: true,
RefreshGroups: true,
IdleExit: idleExit,
OnQRCode: func(code string) {
fmt.Fprintln(os.Stderr, "\nScan this QR code with WhatsApp (Linked Devices):")
qrterminal.GenerateHalfBlock(code, qrterminal.M, os.Stderr)
fmt.Fprintln(os.Stderr)
},
})
res, err := runAuth(flags, opts)
if err != nil {
return err
}
@ -67,9 +53,7 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().BoolVar(&follow, "follow", false, "keep syncing after auth")
cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)")
cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
addAuthFlags(cmd, &opts)
cmd.AddCommand(newAuthStatusCmd(flags))
cmd.AddCommand(newAuthLogoutCmd(flags))
@ -77,6 +61,140 @@ func newAuthCmd(flags *rootFlags) *cobra.Command {
return cmd
}
func addAuthFlags(cmd *cobra.Command, opts *authOptions) {
cmd.Flags().BoolVar(&opts.follow, "follow", false, "keep syncing after auth")
cmd.Flags().DurationVar(&opts.idleExit, "idle-exit", 30*time.Second, "exit after being idle (bootstrap/once modes)")
cmd.Flags().BoolVar(&opts.downloadMedia, "download-media", false, "download media in the background during sync")
cmd.Flags().StringVar(&opts.qrFormat, "qr-format", "terminal", "QR output format: terminal or text")
cmd.Flags().StringVar(&opts.phone, "phone", "", "pair by phone number instead of QR code")
}
func runAuth(flags *rootFlags, opts authOptions) (appPkg.SyncResult, error) {
if err := flags.requireWritable(); err != nil {
return appPkg.SyncResult{}, err
}
validated, err := validateAuthOptions(flags, opts)
if err != nil {
return appPkg.SyncResult{}, err
}
maxMessages, maxDBSize, err := resolveSyncStorageLimits(syncStorageLimitFlags{})
if err != nil {
return appPkg.SyncResult{}, err
}
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
defer stop()
a, lk, err := newApp(ctx, flags, true, true)
if err != nil {
return appPkg.SyncResult{}, err
}
defer closeApp(a, lk)
mode := appPkg.SyncModeBootstrap
if opts.follow {
mode = appPkg.SyncModeFollow
}
if a.Events().Enabled() {
_ = a.Events().Emit("auth_starting", nil)
} else {
fmt.Fprintln(os.Stderr, "Starting authentication…")
}
return a.Sync(ctx, appPkg.SyncOptions{
Mode: mode,
AllowQR: true,
DownloadMedia: opts.downloadMedia,
RefreshContacts: true,
RefreshGroups: true,
RefreshChannels: true,
IdleExit: opts.idleExit,
OnQRCode: authQRWriter(validated.qrFormat, os.Stdout, os.Stderr, a.Events()),
PairPhoneNumber: validated.pairPhone,
OnPairCode: authPairCodeWriter(validated.pairPhone, os.Stderr, a.Events()),
MaxMessages: maxMessages,
MaxDBSizeBytes: maxDBSize,
WarnNoLimits: true,
})
}
func validateAuthOptions(flags *rootFlags, opts authOptions) (validatedAuthOptions, error) {
qrFormat, err := normalizeAuthQRFormat(opts.qrFormat)
if err != nil {
return validatedAuthOptions{}, err
}
if flags.asJSON && qrFormat == "text" {
return validatedAuthOptions{}, fmt.Errorf("--qr-format=text cannot be combined with --json because both write to stdout")
}
pairPhone, err := normalizePairPhone(opts.phone)
if err != nil {
return validatedAuthOptions{}, err
}
return validatedAuthOptions{qrFormat: qrFormat, pairPhone: pairPhone}, nil
}
func normalizePairPhone(phone string) (string, error) {
phone = strings.TrimSpace(phone)
if phone == "" {
return "", nil
}
jid, err := wa.ParseUserOrJID(phone)
if err != nil {
return "", fmt.Errorf("invalid --phone: %w", err)
}
if jid.Server != types.DefaultUserServer || jid.Device != 0 {
return "", fmt.Errorf("invalid --phone: must be an international phone number")
}
return jid.User, nil
}
func normalizeAuthQRFormat(format string) (string, error) {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "terminal"
}
switch format {
case "terminal", "text":
return format, nil
default:
return "", fmt.Errorf("unsupported --qr-format %q (want terminal or text)", format)
}
}
func authQRWriter(format string, stdout, stderr io.Writer, events *out.EventWriter) func(string) {
if format == "text" {
return func(code string) {
if events.Enabled() {
_ = events.Emit("qr_code", map[string]any{"code": code})
}
fmt.Fprintln(stdout, code)
}
}
return func(code string) {
if events.Enabled() {
_ = events.Emit("qr_code", map[string]any{"code": code})
return
}
fmt.Fprintln(stderr, "\nScan this QR code with WhatsApp (Linked Devices):")
qrterminal.GenerateHalfBlock(code, qrterminal.M, stderr)
fmt.Fprintln(stderr)
}
}
func authPairCodeWriter(phone string, stderr io.Writer, events *out.EventWriter) func(string) {
if phone == "" {
return nil
}
return func(code string) {
if events.Enabled() {
_ = events.Emit("pair_code", map[string]any{"phone": phone, "code": code})
return
}
fmt.Fprintf(stderr, "\nPairing code for +%s: %s\n", phone, code)
fmt.Fprintln(stderr, "On your phone: WhatsApp > Linked Devices > Link a Device > Link with phone number.")
fmt.Fprintln(stderr, "Enter the code above and keep this command running until authentication completes.")
}
}
func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "status",
@ -95,27 +213,60 @@ func newAuthStatusCmd(flags *rootFlags) *cobra.Command {
return err
}
authed := a.WA().IsAuthed()
var linkedJID string
if authed {
linkedJID = a.WA().LinkedJID()
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"authenticated": authed,
})
}
if authed {
fmt.Fprintln(os.Stdout, "Authenticated.")
} else {
fmt.Fprintln(os.Stdout, "Not authenticated. Run `wacli auth`.")
return out.WriteJSON(os.Stdout, authStatusPayload(authed, linkedJID))
}
writeAuthStatus(os.Stdout, authed, linkedJID)
return nil
},
}
}
func authStatusPayload(authed bool, linkedJID string) map[string]any {
data := map[string]any{"authenticated": authed}
if !authed || linkedJID == "" {
return data
}
data["linked_jid"] = linkedJID
if phone := phoneFromLinkedJID(linkedJID); phone != "" {
data["phone"] = phone
}
return data
}
func writeAuthStatus(w io.Writer, authed bool, linkedJID string) {
if !authed {
fmt.Fprintln(w, "Not authenticated. Run `wacli auth`.")
return
}
if linkedJID != "" {
fmt.Fprintf(w, "Authenticated as %s\n", linkedJID)
return
}
fmt.Fprintln(w, "Authenticated.")
}
func phoneFromLinkedJID(linkedJID string) string {
phone, _, ok := strings.Cut(linkedJID, "@")
if !ok {
return ""
}
return phone
}
func newAuthLogoutCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "Logout (invalidate session)",
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()

159
cmd/wacli/auth_test.go Normal file
View File

@ -0,0 +1,159 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestAuthStatusPayloadIncludesLinkedJID(t *testing.T) {
got := authStatusPayload(true, "1234567890@s.whatsapp.net")
if got["authenticated"] != true {
t.Fatalf("authenticated = %v", got["authenticated"])
}
if got["linked_jid"] != "1234567890@s.whatsapp.net" {
t.Fatalf("linked_jid = %v", got["linked_jid"])
}
if got["phone"] != "1234567890" {
t.Fatalf("phone = %v", got["phone"])
}
}
func TestAuthStatusPayloadOmitsLinkedJIDWhenUnauthed(t *testing.T) {
got := authStatusPayload(false, "1234567890@s.whatsapp.net")
if _, ok := got["linked_jid"]; ok {
t.Fatalf("linked_jid should be omitted: %+v", got)
}
if _, ok := got["phone"]; ok {
t.Fatalf("phone should be omitted: %+v", got)
}
}
func TestWriteAuthStatus(t *testing.T) {
tests := []struct {
name string
authed bool
linkedJID string
want string
}{
{name: "linked", authed: true, linkedJID: "1234567890@s.whatsapp.net", want: "Authenticated as 1234567890@s.whatsapp.net"},
{name: "authed no jid", authed: true, want: "Authenticated."},
{name: "not authed", want: "Not authenticated. Run `wacli auth`."},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var b bytes.Buffer
writeAuthStatus(&b, tc.authed, tc.linkedJID)
if got := strings.TrimSpace(b.String()); got != tc.want {
t.Fatalf("status = %q, want %q", got, tc.want)
}
})
}
}
func TestNormalizeAuthQRFormat(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{input: "", want: "terminal"},
{input: " TERMINAL ", want: "terminal"},
{input: "text", want: "text"},
{input: "png", wantErr: true},
}
for _, tc := range tests {
got, err := normalizeAuthQRFormat(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("normalizeAuthQRFormat(%q) expected error", tc.input)
}
continue
}
if err != nil {
t.Fatalf("normalizeAuthQRFormat(%q): %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("normalizeAuthQRFormat(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestAuthQRWriterText(t *testing.T) {
var stdout, stderr bytes.Buffer
authQRWriter("text", &stdout, &stderr, nil)("2@test-code")
if got := strings.TrimSpace(stdout.String()); got != "2@test-code" {
t.Fatalf("stdout = %q", got)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestNormalizePairPhone(t *testing.T) {
tests := []struct {
input string
want string
wantErr bool
}{
{input: "", want: ""},
{input: "+15551234567", want: "15551234567"},
{input: "15551234567", want: "15551234567"},
{input: "123@g.us", wantErr: true},
{input: "123abc", wantErr: true},
}
for _, tc := range tests {
got, err := normalizePairPhone(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("normalizePairPhone(%q) expected error", tc.input)
}
continue
}
if err != nil {
t.Fatalf("normalizePairPhone(%q): %v", tc.input, err)
}
if got != tc.want {
t.Fatalf("normalizePairPhone(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestAuthPairCodeWriter(t *testing.T) {
var stderr bytes.Buffer
writer := authPairCodeWriter("15551234567", &stderr, nil)
if writer == nil {
t.Fatal("expected writer")
}
writer("ABCD-1234")
got := stderr.String()
if !strings.Contains(got, "Pairing code for +15551234567: ABCD-1234") {
t.Fatalf("stderr = %q", got)
}
if authPairCodeWriter("", &stderr, nil) != nil {
t.Fatal("expected nil writer without phone")
}
}
func TestAuthCommandExposesQRFormat(t *testing.T) {
cmd := newAuthCmd(&rootFlags{})
flag := cmd.Flags().Lookup("qr-format")
if flag == nil {
t.Fatal("expected --qr-format flag")
}
if flag.DefValue != "terminal" {
t.Fatalf("qr-format default = %q", flag.DefValue)
}
if cmd.Flags().Lookup("phone") == nil {
t.Fatal("expected --phone flag")
}
}
func TestPhoneFromLinkedJID(t *testing.T) {
if got := phoneFromLinkedJID("123@s.whatsapp.net"); got != "123" {
t.Fatalf("phoneFromLinkedJID = %q", got)
}
if got := phoneFromLinkedJID("not-a-jid"); got != "" {
t.Fatalf("phoneFromLinkedJID invalid = %q", got)
}
}

View File

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

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

@ -0,0 +1,301 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func newChannelsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "channels",
Short: "Manage WhatsApp channels",
}
cmd.AddCommand(newChannelsListCmd(flags))
cmd.AddCommand(newChannelsInfoCmd(flags))
cmd.AddCommand(newChannelsJoinCmd(flags))
cmd.AddCommand(newChannelsLeaveCmd(flags))
return cmd
}
func newChannelsListCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List subscribed channels (live) and update local chats",
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
list, err := a.WA().GetSubscribedNewsletters(ctx)
if err != nil {
return err
}
rows := channelRecords(list)
persistChannelRecords(a.DB(), rows)
if flags.asJSON {
return out.WriteJSON(os.Stdout, rows)
}
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "NAME\tJID\tROLE\tSTATE\tSUBSCRIBERS\tDESCRIPTION")
fullOutput := fullTableOutput(flags.fullOutput)
for _, row := range rows {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\n",
tableCell(row.Name, 40, fullOutput),
row.JID,
row.Role,
row.State,
row.Subscribers,
tableCell(strings.ReplaceAll(row.Description, "\n", " "), 50, fullOutput),
)
}
_ = w.Flush()
return nil
},
}
return cmd
}
func newChannelsInfoCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "info",
Short: "Fetch channel info (live) and update local chats",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := parseChannelJID(jidStr)
if err != nil {
return err
}
meta, err := a.WA().GetNewsletterInfo(ctx, jid)
if err != nil {
return err
}
if meta == nil {
return fmt.Errorf("channel not found")
}
row := channelRecordFromMeta(meta)
persistChannelRecords(a.DB(), []channelRecord{row})
if flags.asJSON {
return out.WriteJSON(os.Stdout, row)
}
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nDescription: %s\nState: %s\nSubscribers: %d\n",
row.JID,
row.Name,
row.Description,
row.State,
row.Subscribers,
)
if row.Role != "" {
fmt.Fprintf(os.Stdout, "Role: %s\nMute: %s\n", row.Role, row.Mute)
}
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "channel JID (...@newsletter)")
return cmd
}
func newChannelsJoinCmd(flags *rootFlags) *cobra.Command {
var invite string
cmd := &cobra.Command{
Use: "join",
Short: "Join a channel via invite link or code",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(invite) == "" {
return fmt.Errorf("--invite is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
meta, err := a.WA().GetNewsletterInfoWithInvite(ctx, strings.TrimSpace(invite))
if err != nil {
return err
}
if meta == nil {
return fmt.Errorf("could not resolve channel from invite")
}
if err := a.WA().FollowNewsletter(ctx, meta.ID); err != nil {
return err
}
row := channelRecordFromMeta(meta)
persistChannelRecords(a.DB(), []channelRecord{row})
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"joined": true, "channel": row})
}
fmt.Fprintf(os.Stdout, "Joined channel %s (%s).\n", row.Name, row.JID)
return nil
},
}
cmd.Flags().StringVar(&invite, "invite", "", "invite link or code, e.g. https://whatsapp.com/channel/...")
return cmd
}
func newChannelsLeaveCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "leave",
Short: "Leave (unfollow) a channel",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := parseChannelJID(jidStr)
if err != nil {
return err
}
if err := a.WA().UnfollowNewsletter(ctx, jid); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"left": true, "jid": jid.String()})
}
fmt.Fprintf(os.Stdout, "Left channel %s.\n", jid.String())
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "channel JID (...@newsletter)")
return cmd
}
type channelRecord struct {
JID string `json:"jid"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Role string `json:"role,omitempty"`
Mute string `json:"mute,omitempty"`
State string `json:"state,omitempty"`
Subscribers int `json:"subscribers,omitempty"`
}
func channelRecords(list []*types.NewsletterMetadata) []channelRecord {
rows := make([]channelRecord, 0, len(list))
for _, meta := range list {
if meta == nil {
continue
}
rows = append(rows, channelRecordFromMeta(meta))
}
return rows
}
func channelRecordFromMeta(meta *types.NewsletterMetadata) channelRecord {
row := channelRecord{
JID: meta.ID.String(),
Name: wa.NewsletterName(meta),
Description: strings.TrimSpace(meta.ThreadMeta.Description.Text),
State: string(meta.State.Type),
Subscribers: meta.ThreadMeta.SubscriberCount,
}
if row.Name == "" {
row.Name = row.JID
}
if meta.ViewerMeta != nil {
row.Role = string(meta.ViewerMeta.Role)
row.Mute = string(meta.ViewerMeta.Mute)
}
return row
}
func persistChannelRecords(db *store.DB, rows []channelRecord) {
now := time.Now().UTC()
for _, row := range rows {
_ = db.UpsertChat(row.JID, "newsletter", row.Name, now)
}
}
func parseChannelJID(raw string) (types.JID, error) {
jid, err := types.ParseJID(strings.TrimSpace(raw))
if err != nil {
return types.JID{}, err
}
if jid.Server != types.NewsletterServer {
return types.JID{}, fmt.Errorf("JID must be a channel (...@newsletter)")
}
return jid, nil
}

View File

@ -0,0 +1,50 @@
package main
import (
"testing"
"go.mau.fi/whatsmeow/types"
)
func TestChannelRecordFromMeta(t *testing.T) {
jid := types.JID{User: "123", Server: types.NewsletterServer}
row := channelRecordFromMeta(&types.NewsletterMetadata{
ID: jid,
State: types.WrappedNewsletterState{
Type: types.NewsletterStateActive,
},
ThreadMeta: types.NewsletterThreadMetadata{
Name: types.NewsletterText{Text: " News "},
Description: types.NewsletterText{Text: "Updates"},
SubscriberCount: 42,
},
ViewerMeta: &types.NewsletterViewerMetadata{
Role: types.NewsletterRoleAdmin,
Mute: types.NewsletterMuteOff,
},
})
if row.JID != jid.String() || row.Name != "News" || row.Role != "admin" || row.Mute != "off" || row.State != "active" || row.Subscribers != 42 {
t.Fatalf("unexpected row: %+v", row)
}
}
func TestParseChannelJIDRejectsNonChannel(t *testing.T) {
if _, err := parseChannelJID("123@s.whatsapp.net"); err == nil {
t.Fatal("expected non-channel JID to fail")
}
jid, err := parseChannelJID("123@newsletter")
if err != nil {
t.Fatalf("parseChannelJID: %v", err)
}
if jid.Server != types.NewsletterServer {
t.Fatalf("server = %q", jid.Server)
}
}
func TestChatKindFromJIDNewsletter(t *testing.T) {
got := chatKindFromJID(types.JID{User: "123", Server: types.NewsletterServer})
if got != "newsletter" {
t.Fatalf("chatKindFromJID = %q", got)
}
}

View File

@ -2,32 +2,65 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"text/tabwriter"
"path/filepath"
"sort"
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"go.mau.fi/whatsmeow/types"
)
func newChatsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "chats",
Short: "List chats from the local DB",
Short: "List and manage chats",
}
cmd.AddCommand(newChatsListCmd(flags))
cmd.AddCommand(newChatsShowCmd(flags))
cmd.AddCommand(newChatsArchiveCmd(flags, true))
cmd.AddCommand(newChatsArchiveCmd(flags, false))
cmd.AddCommand(newChatsPinCmd(flags, true))
cmd.AddCommand(newChatsPinCmd(flags, false))
cmd.AddCommand(newChatsMuteCmd(flags))
cmd.AddCommand(newChatsUnmuteCmd(flags))
cmd.AddCommand(newChatsMarkReadCmd(flags, true))
cmd.AddCommand(newChatsMarkReadCmd(flags, false))
cmd.AddCommand(newChatsCleanupCmd(flags))
return cmd
}
func newChatsListCmd(flags *rootFlags) *cobra.Command {
var query string
var limit int
var archived, noArchived bool
var pinned, noPinned bool
var muted, noMuted bool
var unread, noUnread bool
cmd := &cobra.Command{
Use: "list",
Short: "List chats",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateBoolFilter("archived", archived, noArchived); err != nil {
return err
}
if err := validateBoolFilter("pinned", pinned, noPinned); err != nil {
return err
}
if err := validateBoolFilter("muted", muted, noMuted); err != nil {
return err
}
if err := validateBoolFilter("unread", unread, noUnread); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
@ -37,22 +70,32 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
chats, err := a.DB().ListChats(query, limit)
filter := store.ChatListFilter{
Query: query,
Limit: limit,
Archived: boolFilter(archived, noArchived),
Pinned: boolFilter(pinned, noPinned),
Muted: boolFilter(muted, noMuted),
Unread: boolFilter(unread, noUnread),
}
chats, err := a.DB().ListChatsFiltered(filter)
if err != nil {
return err
}
chats = resolveStoredChats(ctx, a, chats)
if flags.asJSON {
return out.WriteJSON(os.Stdout, chats)
}
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST")
fullOutput := fullTableOutput(flags.fullOutput)
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "KIND\tNAME\tJID\tLAST\tFLAGS")
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.Kind, truncate(name, 28), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"))
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", c.Kind, tableCell(name, 28, fullOutput), c.JID, c.LastMessageTS.Local().Format("2006-01-02 15:04:05"), chatFlagsString(c))
}
_ = w.Flush()
return nil
@ -60,6 +103,14 @@ func newChatsListCmd(flags *rootFlags) *cobra.Command {
}
cmd.Flags().StringVar(&query, "query", "", "search query")
cmd.Flags().IntVar(&limit, "limit", 50, "limit")
cmd.Flags().BoolVar(&archived, "archived", false, "show only archived chats")
cmd.Flags().BoolVar(&noArchived, "no-archived", false, "exclude archived chats")
cmd.Flags().BoolVar(&pinned, "pinned", false, "show only pinned chats")
cmd.Flags().BoolVar(&noPinned, "no-pinned", false, "exclude pinned chats")
cmd.Flags().BoolVar(&muted, "muted", false, "show only muted chats")
cmd.Flags().BoolVar(&noMuted, "no-muted", false, "exclude muted chats")
cmd.Flags().BoolVar(&unread, "unread", false, "show only unread chats")
cmd.Flags().BoolVar(&noUnread, "no-unread", false, "exclude unread chats")
return cmd
}
@ -81,17 +132,170 @@ func newChatsShowCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
c, err := a.DB().GetChat(jid)
c, err := getChatForDisplay(ctx, a, jid)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, c)
}
fmt.Fprintf(os.Stdout, "JID: %s\nKind: %s\nName: %s\nLast: %s\n", c.JID, c.Kind, c.Name, c.LastMessageTS.Local().Format(time.RFC3339))
fmt.Fprintf(os.Stdout, "JID: %s\nKind: %s\nName: %s\nLast: %s\nArchived: %t\nPinned: %t\nMuted: %t\nMuted until: %s\nUnread: %t\n",
c.JID, c.Kind, c.Name, c.LastMessageTS.Local().Format(time.RFC3339), c.Archived, c.Pinned, c.Muted(), formatMutedUntil(c.MutedUntil), c.Unread)
return nil
},
}
cmd.Flags().StringVar(&jid, "jid", "", "chat JID")
return cmd
}
type chatDisplayResolver interface {
ResolveChatName(context.Context, types.JID, string) string
ResolveLIDToPN(context.Context, types.JID) types.JID
ResolvePNToLID(context.Context, types.JID) types.JID
}
func resolveStoredChats(ctx context.Context, a *app.App, chats []store.Chat) []store.Chat {
if len(chats) == 0 || !chatsNeedLIDResolution(chats) {
return chats
}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return chats
}
if err := a.OpenWA(); err != nil {
return chats
}
return resolveStoredChatsWith(ctx, a.WA(), chats)
}
func chatsNeedLIDResolution(chats []store.Chat) bool {
for _, chat := range chats {
if strings.HasSuffix(strings.TrimSpace(chat.JID), "@"+types.HiddenUserServer) {
return true
}
}
return false
}
func resolveStoredChatsWith(ctx context.Context, resolver chatDisplayResolver, chats []store.Chat) []store.Chat {
out := make([]store.Chat, 0, len(chats))
seen := make(map[string]int, len(chats))
for _, chat := range chats {
chat = resolveStoredChatWith(ctx, resolver, chat)
if idx, ok := seen[chat.JID]; ok {
out[idx] = mergeDisplayChats(out[idx], chat)
continue
}
seen[chat.JID] = len(out)
out = append(out, chat)
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].LastMessageTS.After(out[j].LastMessageTS)
})
return out
}
func resolveStoredChatWith(ctx context.Context, resolver chatDisplayResolver, chat store.Chat) store.Chat {
jid, err := types.ParseJID(strings.TrimSpace(chat.JID))
if err != nil || jid.Server != types.HiddenUserServer {
return chat
}
pn := resolver.ResolveLIDToPN(ctx, jid)
if pn.IsEmpty() || pn.Server != types.DefaultUserServer {
return chat
}
out := chat
out.JID = pn.ToNonAD().String()
if out.Kind == "" || out.Kind == "unknown" {
out.Kind = "dm"
}
if chatNameRank(out.Name, chat.JID) < 2 {
if name := strings.TrimSpace(resolver.ResolveChatName(ctx, pn, "")); name != "" {
out.Name = name
}
}
if strings.TrimSpace(out.Name) == "" || strings.TrimSpace(out.Name) == strings.TrimSpace(chat.JID) {
out.Name = out.JID
}
return out
}
func mergeDisplayChats(a, b store.Chat) store.Chat {
out := a
if b.LastMessageTS.After(out.LastMessageTS) {
out.LastMessageTS = b.LastMessageTS
}
if out.Kind == "" || out.Kind == "unknown" || b.Kind == "dm" {
out.Kind = b.Kind
}
if chatNameRank(b.Name, b.JID) > chatNameRank(out.Name, out.JID) {
out.Name = b.Name
}
return out
}
func chatNameRank(name, jid string) int {
name = strings.TrimSpace(name)
switch {
case name == "":
return 0
case name == strings.TrimSpace(jid), strings.Contains(name, "@"):
return 1
default:
return 2
}
}
func getChatForDisplay(ctx context.Context, a *app.App, rawJID string) (store.Chat, error) {
chat, err := a.DB().GetChat(rawJID)
if err == nil {
return resolveStoredChatForDisplay(ctx, a, chat), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return store.Chat{}, err
}
chatJIDs := mappedChatJIDs(ctx, a, rawJID)
for _, chatJID := range chatJIDs {
if chatJID == rawJID {
continue
}
chat, err = a.DB().GetChat(chatJID)
if err == nil {
return resolveStoredChatForDisplay(ctx, a, chat), nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return store.Chat{}, err
}
}
return store.Chat{}, sql.ErrNoRows
}
func resolveStoredChatForDisplay(ctx context.Context, a *app.App, chat store.Chat) store.Chat {
return resolveStoredChats(ctx, a, []store.Chat{chat})[0]
}
func mappedChatJIDs(ctx context.Context, a *app.App, rawJID string) []string {
jid, err := types.ParseJID(strings.TrimSpace(rawJID))
if err != nil {
return []string{rawJID}
}
jids := []types.JID{jid}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return jidStrings(jids)
}
if err := a.OpenWA(); err != nil {
return jidStrings(jids)
}
client := a.WA()
if client == nil {
return jidStrings(jids)
}
switch jid.Server {
case types.DefaultUserServer:
jids = append(jids, client.ResolvePNToLID(ctx, jid))
case types.HiddenUserServer:
jids = append(jids, client.ResolveLIDToPN(ctx, jid))
}
return jidStrings(jids)
}

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

@ -0,0 +1,166 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newChatsCleanupCmd(flags *rootFlags) *cobra.Command {
var days int
var jid string
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up old chats from local storage",
Long: `Clean up chats that have no recent activity.
By default, removes chats with no messages in the last 365 days.
Use --days to adjust the threshold. Use --dry-run to preview what would be deleted.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if jid != "" {
return cleanupSingleChat(ctx, a, jid, dryRun, confirm, flags.asJSON)
}
chats, err := a.DB().ListChatsOlderThan(days)
if err != nil {
return err
}
if len(chats) == 0 {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "no chats to clean up"})
}
fmt.Fprintln(os.Stderr, "No chats to clean up.")
return nil
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_delete": len(chats), "chats": chats})
}
fmt.Fprintf(os.Stderr, "Would delete %d chat(s):\n", len(chats))
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, " - %s (%s)\n", name, c.JID)
}
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d chat(s). This cannot be undone.\n", len(chats))
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deleted int
for _, c := range chats {
msgCount, _ := a.DB().CountChatMessages(c.JID)
if err := a.DB().DeleteChat(c.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete chat %s: %v\n", c.JID, err)
continue
}
deleted++
if !flags.asJSON {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s (%d messages)\n", name, msgCount)
}
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": deleted})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d chat(s).\n", deleted)
return nil
},
}
cmd.Flags().IntVar(&days, "days", 365, "delete chats with no messages in the last N days")
cmd.Flags().StringVar(&jid, "jid", "", "delete a specific chat by JID")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}
func cleanupSingleChat(ctx context.Context, a *app.App, jid string, dryRun, confirm, asJSON bool) error {
chat, err := a.DB().GetChat(jid)
if err != nil {
return fmt.Errorf("chat not found: %s", jid)
}
msgCount, _ := a.DB().CountChatMessages(jid)
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"would_delete": 1,
"chat": chat,
"message_count": msgCount,
})
}
name := chat.Name
if name == "" {
name = chat.JID
}
fmt.Fprintf(os.Stderr, "Would delete chat: %s (%s, %d messages)\n", name, chat.JID, msgCount)
return nil
}
if !confirm {
name := chat.Name
if name == "" {
name = chat.JID
}
fmt.Fprintf(os.Stderr, "About to delete chat: %s (%s, %d messages). This cannot be undone.\n", name, chat.JID, msgCount)
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
if err := a.DB().DeleteChat(jid); err != nil {
return err
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 1, "jid": jid, "messages_deleted": msgCount})
}
fmt.Fprintf(os.Stderr, "Deleted chat %s (%d messages)\n", jid, msgCount)
return nil
}

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

@ -0,0 +1,210 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
type chatStateOptions struct {
chat string
pick int
}
func newChatsArchiveCmd(flags *rootFlags, archive bool) *cobra.Command {
use, short := "archive", "Archive a chat"
if !archive {
use, short = "unarchive", "Unarchive a chat"
}
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: use,
Short: short,
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, use, func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.ArchiveChat(ctx, jid, archive)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
func newChatsPinCmd(flags *rootFlags, pin bool) *cobra.Command {
use, short := "pin", "Pin a chat"
if !pin {
use, short = "unpin", "Unpin a chat"
}
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: use,
Short: short,
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, use, func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.PinChat(ctx, jid, pin)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
func newChatsMuteCmd(flags *rootFlags) *cobra.Command {
opts := chatStateOptions{}
var duration time.Duration
cmd := &cobra.Command{
Use: "mute",
Short: "Mute a chat",
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, "mute", func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.MuteChat(ctx, jid, true, duration)
})
},
}
addChatStateFlags(cmd, &opts)
cmd.Flags().DurationVar(&duration, "duration", 0, "mute duration (for example 8h, 24h, 168h); 0 means forever")
return cmd
}
func newChatsUnmuteCmd(flags *rootFlags) *cobra.Command {
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: "unmute",
Short: "Unmute a chat",
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, "unmute", func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.MuteChat(ctx, jid, false, 0)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
func newChatsMarkReadCmd(flags *rootFlags, read bool) *cobra.Command {
use, short := "mark-read", "Mark a chat as read"
if !read {
use, short = "mark-unread", "Mark a chat as unread"
}
opts := chatStateOptions{}
cmd := &cobra.Command{
Use: use,
Short: short,
RunE: func(cmd *cobra.Command, args []string) error {
return runChatState(flags, opts, use, func(ctx context.Context, a chatStateApp, jid types.JID) error {
return a.MarkChatRead(ctx, jid, read)
})
},
}
addChatStateFlags(cmd, &opts)
return cmd
}
type chatStateApp interface {
ArchiveChat(context.Context, types.JID, bool) error
PinChat(context.Context, types.JID, bool) error
MuteChat(context.Context, types.JID, bool, time.Duration) error
MarkChatRead(context.Context, types.JID, bool) error
}
func runChatState(flags *rootFlags, opts chatStateOptions, action string, run func(context.Context, chatStateApp, types.JID) error) error {
if strings.TrimSpace(opts.chat) == "" {
return fmt.Errorf("--chat is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := resolveRecipient(a, opts.chat, recipientOptions{pick: opts.pick, asJSON: flags.asJSON})
if err != nil {
return err
}
if err := run(ctx, a, jid); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"ok": true,
"action": action,
"chat": jid.String(),
})
}
fmt.Fprintf(os.Stdout, "%s: %s\n", action, jid.String())
return nil
}
func addChatStateFlags(cmd *cobra.Command, opts *chatStateOptions) {
cmd.Flags().StringVar(&opts.chat, "chat", "", "chat name, phone number, or JID")
cmd.Flags().IntVar(&opts.pick, "pick", 0, "choose match N when --chat is ambiguous")
}
func validateBoolFilter(name string, pos, neg bool) error {
if pos && neg {
return fmt.Errorf("--%s and --no-%s are mutually exclusive", name, name)
}
return nil
}
func boolFilter(pos, neg bool) *bool {
if pos {
v := true
return &v
}
if neg {
v := false
return &v
}
return nil
}
func chatFlagsString(c store.Chat) string {
var flags []string
if c.Pinned {
flags = append(flags, "pinned")
}
if c.Archived {
flags = append(flags, "archived")
}
if c.Muted() {
flags = append(flags, "muted")
}
if c.Unread {
flags = append(flags, "unread")
}
return strings.Join(flags, ",")
}
func formatMutedUntil(until int64) string {
switch {
case until == -1:
return "forever"
case until > 0:
return time.Unix(until, 0).Local().Format(time.RFC3339)
default:
return ""
}
}

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

@ -0,0 +1,97 @@
package main
import (
"context"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
type fakeChatResolver struct {
lidToPN map[types.JID]types.JID
names map[types.JID]string
}
func (f fakeChatResolver) ResolveChatName(ctx context.Context, chat types.JID, pushName string) string {
if name, ok := f.names[chat.ToNonAD()]; ok {
return name
}
return chat.String()
}
func (f fakeChatResolver) ResolveLIDToPN(ctx context.Context, jid types.JID) types.JID {
if pn, ok := f.lidToPN[jid.ToNonAD()]; ok {
pn.Device = jid.Device
return pn
}
return jid
}
func (f fakeChatResolver) ResolvePNToLID(ctx context.Context, jid types.JID) types.JID {
for lid, pn := range f.lidToPN {
if pn == jid.ToNonAD() {
lid.Device = jid.Device
return lid
}
}
return jid
}
func TestResolveStoredChatsMapsLIDRows(t *testing.T) {
lid := mustParseJID(t, "999123456789@lid")
pn := mustParseJID(t, "15551234567@s.whatsapp.net")
resolver := fakeChatResolver{
lidToPN: map[types.JID]types.JID{lid: pn},
names: map[types.JID]string{pn: "Alice"},
}
got := resolveStoredChatsWith(context.Background(), resolver, []store.Chat{{
JID: lid.String(),
Kind: "unknown",
Name: lid.String(),
LastMessageTS: time.Unix(10, 0),
}})
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %+v", len(got), got)
}
if got[0].JID != pn.String() || got[0].Kind != "dm" || got[0].Name != "Alice" {
t.Fatalf("resolved chat = %+v", got[0])
}
}
func TestResolveStoredChatsMergesMappedDuplicates(t *testing.T) {
lid := mustParseJID(t, "999123456789@lid")
pn := mustParseJID(t, "15551234567@s.whatsapp.net")
resolver := fakeChatResolver{
lidToPN: map[types.JID]types.JID{lid: pn},
names: map[types.JID]string{pn: "Alice"},
}
old := time.Unix(10, 0)
newer := time.Unix(20, 0)
got := resolveStoredChatsWith(context.Background(), resolver, []store.Chat{
{JID: lid.String(), Kind: "unknown", Name: lid.String(), LastMessageTS: newer},
{JID: pn.String(), Kind: "dm", Name: "", LastMessageTS: old},
})
if len(got) != 1 {
t.Fatalf("len = %d, want 1: %+v", len(got), got)
}
if got[0].JID != pn.String() || got[0].Name != "Alice" || !got[0].LastMessageTS.Equal(newer) {
t.Fatalf("merged chat = %+v", got[0])
}
}
func TestChatFlagsString(t *testing.T) {
got := chatFlagsString(store.Chat{Pinned: true, Archived: true, MutedUntil: -1, Unread: true})
if got != "pinned,archived,muted,unread" {
t.Fatalf("flags = %q", got)
}
if err := validateBoolFilter("archived", true, true); err == nil {
t.Fatal("expected mutually exclusive filter error")
}
if err := validateBoolFilter("archived", true, false); err != nil {
t.Fatalf("unexpected filter error: %v", err)
}
}

View File

@ -5,10 +5,9 @@ import (
"fmt"
"os"
"strings"
"text/tabwriter"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newContactsCmd(flags *rootFlags) *cobra.Command {
@ -19,6 +18,7 @@ func newContactsCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newContactsSearchCmd(flags))
cmd.AddCommand(newContactsShowCmd(flags))
cmd.AddCommand(newContactsRefreshCmd(flags))
cmd.AddCommand(newContactsImportSystemCmd(flags))
cmd.AddCommand(newContactsAliasCmd(flags))
cmd.AddCommand(newContactsTagsCmd(flags))
return cmd
@ -49,13 +49,14 @@ func newContactsSearchCmd(flags *rootFlags) *cobra.Command {
return out.WriteJSON(os.Stdout, cs)
}
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fullOutput := fullTableOutput(flags.fullOutput)
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "ALIAS\tNAME\tPHONE\tJID")
for _, c := range cs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
truncate(c.Alias, 18),
truncate(c.Name, 24),
truncate(c.Phone, 14),
tableCell(c.Alias, 18, fullOutput),
tableCell(c.Name, 24, fullOutput),
tableCell(c.Phone, 14, fullOutput),
c.JID,
)
}
@ -104,6 +105,9 @@ func newContactsShowCmd(flags *rootFlags) *cobra.Command {
if c.Alias != "" {
fmt.Fprintf(os.Stdout, "Alias: %s\n", c.Alias)
}
if c.SystemName != "" {
fmt.Fprintf(os.Stdout, "System Name: %s\n", c.SystemName)
}
if len(c.Tags) > 0 {
fmt.Fprintf(os.Stdout, "Tags: %s\n", strings.Join(c.Tags, ", "))
}
@ -119,6 +123,9 @@ func newContactsRefreshCmd(flags *rootFlags) *cobra.Command {
Use: "refresh",
Short: "Import contacts from whatsmeow store into local DB",
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
@ -138,6 +145,7 @@ func newContactsRefreshCmd(flags *rootFlags) *cobra.Command {
var count int
for jid, info := range cs {
jid = canonicalCLIJID(jid)
_ = a.DB().UpsertContact(
jid.String(),
jid.User,
@ -173,6 +181,9 @@ func newContactsAliasCmd(flags *rootFlags) *cobra.Command {
if strings.TrimSpace(jid) == "" || strings.TrimSpace(alias) == "" {
return fmt.Errorf("--jid and --alias are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
@ -198,6 +209,9 @@ func newContactsAliasCmd(flags *rootFlags) *cobra.Command {
if strings.TrimSpace(jid) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
@ -235,6 +249,9 @@ func newContactsTagsCmd(flags *rootFlags) *cobra.Command {
if strings.TrimSpace(jid) == "" || strings.TrimSpace(tag) == "" {
return fmt.Errorf("--jid and --tag are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
@ -261,6 +278,9 @@ func newContactsTagsCmd(flags *rootFlags) *cobra.Command {
if strings.TrimSpace(jid) == "" || strings.TrimSpace(tag) == "" {
return fmt.Errorf("--jid and --tag are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)

View File

@ -0,0 +1,182 @@
package main
import (
"context"
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/syscontacts"
"github.com/spf13/cobra"
)
type systemContactMatch struct {
JID string `json:"jid"`
Phone string `json:"phone"`
CurrentName string `json:"current_name"`
SystemName string `json:"system_name"`
ExistingValue string `json:"existing_system_name,omitempty"`
}
func newContactsImportSystemCmd(flags *rootFlags) *cobra.Command {
var dryRun bool
var clear bool
var input string
cmd := &cobra.Command{
Use: "import-system",
Short: "Import display names from macOS Contacts",
Long: `Import display names from macOS Contacts and store them as local system names.
System names are local wacli metadata. They do not edit WhatsApp contacts or
macOS Contacts. Display precedence is: alias, system name, WhatsApp names.
On macOS, the default source is the Contacts framework. Use --input to import
from a JSON array or NDJSON file with fields first_name, last_name, full_name,
and phones.`,
RunE: func(cmd *cobra.Command, args []string) error {
if !dryRun {
if err := flags.requireWritable(); err != nil {
return err
}
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, !dryRun, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if clear {
return runContactsSystemClear(a.DB(), dryRun, flags.asJSON)
}
systemContacts, err := readSystemContacts(ctx, input)
if err != nil {
return err
}
phoneToName := syscontacts.PhoneToName(systemContacts)
localContacts, err := a.DB().ListContacts(0)
if err != nil {
return err
}
matches, skippedNoPhone, skippedNoMatch, skippedSame := matchSystemContacts(localContacts, phoneToName)
result := map[string]any{
"matched": len(matches),
"matches": matches,
"skipped_no_phone": skippedNoPhone,
"skipped_no_match": skippedNoMatch,
"skipped_same": skippedSame,
"dry_run": dryRun,
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, result)
}
writeSystemImportPreview(matches, skippedNoPhone, skippedNoMatch, skippedSame)
return nil
}
applied := 0
for _, m := range matches {
if err := a.DB().SetSystemName(m.JID, m.SystemName); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to set system name for %s: %v\n", m.JID, err)
continue
}
applied++
}
result["applied"] = applied
if flags.asJSON {
return out.WriteJSON(os.Stdout, result)
}
fmt.Fprintf(os.Stdout, "Applied %d system contact name(s).\n", applied)
return nil
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be imported without writing")
cmd.Flags().BoolVar(&clear, "clear", false, "clear all imported system names")
cmd.Flags().StringVar(&input, "input", "", "read system contacts from JSON/NDJSON instead of macOS Contacts")
return cmd
}
func readSystemContacts(ctx context.Context, input string) ([]syscontacts.Contact, error) {
if input != "" {
return syscontacts.ReadFile(input)
}
return syscontacts.ReadSystem(ctx)
}
func runContactsSystemClear(db *store.DB, dryRun, asJSON bool) error {
count, err := db.CountSystemNames()
if err != nil {
return err
}
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_clear": count, "dry_run": true})
}
fmt.Fprintf(os.Stdout, "Would clear %d system contact name(s).\n", count)
return nil
}
cleared, err := db.ClearAllSystemNames()
if err != nil {
return err
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"cleared": cleared})
}
fmt.Fprintf(os.Stdout, "Cleared %d system contact name(s).\n", cleared)
return nil
}
func matchSystemContacts(local []store.Contact, phoneToName map[string]string) ([]systemContactMatch, int, int, int) {
var matches []systemContactMatch
var skippedNoPhone, skippedNoMatch, skippedSame int
for _, c := range local {
phone := syscontacts.NormalizePhone(c.Phone)
if phone == "" {
skippedNoPhone++
continue
}
systemName, ok := phoneToName[phone]
if !ok {
skippedNoMatch++
continue
}
if c.SystemName == systemName {
skippedSame++
continue
}
matches = append(matches, systemContactMatch{
JID: c.JID,
Phone: c.Phone,
CurrentName: c.Name,
SystemName: systemName,
ExistingValue: c.SystemName,
})
}
return matches, skippedNoPhone, skippedNoMatch, skippedSame
}
func writeSystemImportPreview(matches []systemContactMatch, skippedNoPhone, skippedNoMatch, skippedSame int) {
fmt.Fprintf(os.Stdout, "Would import %d system contact name(s).\n", len(matches))
fmt.Fprintf(os.Stdout, "Skipped: %d no phone, %d no match, %d already current.\n", skippedNoPhone, skippedNoMatch, skippedSame)
if len(matches) == 0 {
return
}
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "PHONE\tCURRENT\tSYSTEM")
for _, m := range matches {
fmt.Fprintf(w, "%s\t%s\t%s\n",
tableCell(m.Phone, 16, false),
tableCell(m.CurrentName, 24, false),
tableCell(m.SystemName, 24, false),
)
}
_ = w.Flush()
}

View File

@ -0,0 +1,99 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
)
func TestContactsImportSystemFromInputDryRunDoesNotWrite(t *testing.T) {
storeDir, input := seedSystemImportStore(t)
cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--input", input, "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("contacts import-system dry-run: %v", err)
}
db := openSystemImportStore(t, storeDir)
defer db.Close()
c, err := db.GetContact("14157347847@s.whatsapp.net")
if err != nil {
t.Fatalf("GetContact: %v", err)
}
if c.SystemName != "" {
t.Fatalf("dry-run wrote system name: %#v", c)
}
}
func TestContactsImportSystemFromInputWritesAndClears(t *testing.T) {
storeDir, input := seedSystemImportStore(t)
cmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--input", input})
if err := cmd.Execute(); err != nil {
t.Fatalf("contacts import-system: %v", err)
}
db := openSystemImportStore(t, storeDir)
c, err := db.GetContact("14157347847@s.whatsapp.net")
if err != nil {
t.Fatalf("GetContact: %v", err)
}
if c.SystemName != "Alice Appleseed" || c.Name != "Alice Appleseed" {
t.Fatalf("contact = %#v", c)
}
_ = db.Close()
clearCmd := newContactsImportSystemCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
clearCmd.SetArgs([]string{"--clear"})
if err := clearCmd.Execute(); err != nil {
t.Fatalf("contacts import-system --clear: %v", err)
}
db = openSystemImportStore(t, storeDir)
defer db.Close()
c, err = db.GetContact("14157347847@s.whatsapp.net")
if err != nil {
t.Fatalf("GetContact after clear: %v", err)
}
if c.SystemName != "" {
t.Fatalf("clear left system name: %#v", c)
}
}
func seedSystemImportStore(t *testing.T) (string, string) {
t.Helper()
storeDir := t.TempDir()
db := openSystemImportStore(t, storeDir)
if err := db.UpsertContact("14157347847@s.whatsapp.net", "14157347847", "WhatsApp Alice", "", "", ""); err != nil {
t.Fatalf("UpsertContact: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
input := filepath.Join(storeDir, "contacts.json")
raw, err := json.Marshal([]map[string]any{
{"full_name": "Alice Appleseed", "phones": []string{"+1 (415) 734-7847"}},
})
if err != nil {
t.Fatalf("Marshal: %v", err)
}
if err := os.WriteFile(input, raw, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return storeDir, input
}
func openSystemImportStore(t *testing.T, storeDir string) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
return db
}

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

@ -0,0 +1,23 @@
package main
import (
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newDocsCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
Use: "docs",
Short: "Print documentation URL",
RunE: func(cmd *cobra.Command, args []string) error {
if flags != nil && flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]string{"url": docsURL})
}
_, err := fmt.Fprintln(os.Stdout, docsURL)
return err
},
}
}

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

@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestDocsCommandPrintsDocsURL(t *testing.T) {
out := captureRootStdout(t, func() {
if err := execute([]string{"docs"}); err != nil {
t.Fatalf("execute docs: %v", err)
}
})
if strings.TrimSpace(out) != docsURL {
t.Fatalf("docs output = %q, want %q", out, docsURL)
}
}
func TestDocsCommandJSON(t *testing.T) {
out := captureRootStdout(t, func() {
if err := execute([]string{"--json", "docs"}); err != nil {
t.Fatalf("execute docs --json: %v", err)
}
})
var got struct {
Success bool `json:"success"`
Data struct {
URL string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("docs JSON = %q: %v", out, err)
}
if !got.Success || got.Data.URL != docsURL {
t.Fatalf("docs JSON = %+v, want url %q", got, docsURL)
}
}
func TestRootHelpShowsDocsURL(t *testing.T) {
out := captureRootStdout(t, func() {
if err := execute([]string{"--help"}); err != nil {
t.Fatalf("execute --help: %v", err)
}
})
if !strings.Contains(out, docsURL) {
t.Fatalf("root help did not include docs URL: %q", out)
}
if !strings.Contains(out, "docs") {
t.Fatalf("root help did not include docs command: %q", out)
}
}

View File

@ -3,17 +3,108 @@ package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"text/tabwriter"
"time"
"github.com/openclaw/wacli/internal/lock"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/config"
"github.com/steipete/wacli/internal/lock"
"github.com/steipete/wacli/internal/out"
)
func parseLockOwnerPID(lockInfo string) int {
for _, line := range strings.Split(lockInfo, "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "pid=") {
continue
}
pid, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "pid=")))
if err == nil && pid > 0 {
return pid
}
}
return 0
}
func doctorConnectionState(authed, connected, lockHeld, connect bool) string {
switch {
case connected:
return "connected"
case authed && lockHeld && !connect:
return "locked_by_other_process"
default:
return "disconnected"
}
}
type doctorStoreStats struct {
Messages int64 `json:"messages"`
Chats int64 `json:"chats"`
Contacts int64 `json:"contacts"`
Groups int64 `json:"groups"`
LastSyncAt string `json:"last_sync_at,omitempty"`
}
type doctorReport struct {
StoreDir string `json:"store_dir"`
LockHeld bool `json:"lock_held"`
LockInfo string `json:"lock_info,omitempty"`
LockOwnerPID int `json:"lock_owner_pid,omitempty"`
Authed bool `json:"authenticated"`
LinkedJID string `json:"linked_jid,omitempty"`
Connected bool `json:"connected"`
ConnectionState string `json:"connection_state"`
FTSEnabled bool `json:"fts_enabled"`
Store *doctorStoreStats `json:"store,omitempty"`
StoreError string `json:"store_error,omitempty"`
}
func doctorStoreStatsFromStoreStats(stats store.StoreStats) doctorStoreStats {
out := doctorStoreStats{
Messages: stats.Messages,
Chats: stats.Chats,
Contacts: stats.Contacts,
Groups: stats.Groups,
}
if stats.LastMessageTS > 0 {
out.LastSyncAt = time.Unix(stats.LastMessageTS, 0).UTC().Format(time.RFC3339)
}
return out
}
func writeDoctorReport(w io.Writer, rep doctorReport) {
tw := newTableWriter(w)
fmt.Fprintf(tw, "STORE\t%s\n", rep.StoreDir)
fmt.Fprintf(tw, "LOCKED\t%v\n", rep.LockHeld)
if rep.LockHeld && rep.LockInfo != "" {
fmt.Fprintf(tw, "LOCK_INFO\t%s\n", rep.LockInfo)
}
if rep.LockOwnerPID > 0 {
fmt.Fprintf(tw, "LOCK_OWNER_PID\t%d\n", rep.LockOwnerPID)
}
fmt.Fprintf(tw, "AUTHENTICATED\t%v\n", rep.Authed)
if rep.LinkedJID != "" {
fmt.Fprintf(tw, "LINKED_JID\t%s\n", rep.LinkedJID)
}
fmt.Fprintf(tw, "CONNECTED\t%v\n", rep.Connected)
fmt.Fprintf(tw, "CONNECTION_STATE\t%s\n", rep.ConnectionState)
fmt.Fprintf(tw, "FTS5\t%v\n", rep.FTSEnabled)
if rep.Store != nil {
fmt.Fprintf(tw, "MESSAGES\t%d\n", rep.Store.Messages)
fmt.Fprintf(tw, "CHATS\t%d\n", rep.Store.Chats)
fmt.Fprintf(tw, "CONTACTS\t%d\n", rep.Store.Contacts)
fmt.Fprintf(tw, "GROUPS\t%d\n", rep.Store.Groups)
if rep.Store.LastSyncAt != "" {
fmt.Fprintf(tw, "LAST_SYNC\t%s\n", rep.Store.LastSyncAt)
}
}
_ = tw.Flush()
}
func newDoctorCmd(flags *rootFlags) *cobra.Command {
var connect bool
@ -24,11 +115,10 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
storeDir := flags.storeDir
if storeDir == "" {
storeDir = config.DefaultStoreDir()
storeDir, err := resolveStoreDir(flags)
if err != nil {
return err
}
storeDir, _ = filepath.Abs(storeDir)
var lockHeld bool
var lockInfo string
@ -41,56 +131,64 @@ func newDoctorCmd(flags *rootFlags) *cobra.Command {
lockHeld = true
}
var storeErr string
a, lk, err := newApp(ctx, flags, connect, true)
if err != nil {
return err
storeErr = err.Error()
} else {
defer closeApp(a, lk)
}
defer closeApp(a, lk)
var authed bool
var connected bool
if err := a.OpenWA(); err == nil {
authed = a.WA().IsAuthed()
var linkedJID string
if a != nil {
if err := a.OpenWA(); err == nil {
authed = a.WA().IsAuthed()
if authed {
linkedJID = a.WA().LinkedJID()
}
}
if connect && authed {
if err := a.Connect(ctx, false, nil); err == nil {
connected = true
}
}
}
if connect && authed {
if err := a.Connect(ctx, false, nil); err == nil {
connected = true
lockOwnerPID := parseLockOwnerPID(lockInfo)
var stats *doctorStoreStats
if a != nil {
if raw, err := a.DB().Stats(); err == nil {
converted := doctorStoreStatsFromStoreStats(raw)
stats = &converted
}
}
type report struct {
StoreDir string `json:"store_dir"`
LockHeld bool `json:"lock_held"`
LockInfo string `json:"lock_info,omitempty"`
Authed bool `json:"authenticated"`
Connected bool `json:"connected"`
FTSEnabled bool `json:"fts_enabled"`
}
rep := report{
StoreDir: storeDir,
LockHeld: lockHeld,
LockInfo: lockInfo,
Authed: authed,
Connected: connected,
FTSEnabled: a.DB().HasFTS(),
rep := doctorReport{
StoreDir: storeDir,
LockHeld: lockHeld,
LockInfo: lockInfo,
LockOwnerPID: lockOwnerPID,
Authed: authed,
LinkedJID: linkedJID,
Connected: connected,
ConnectionState: doctorConnectionState(authed, connected, lockHeld, connect),
FTSEnabled: a != nil && a.DB().HasFTS(),
Store: stats,
StoreError: storeErr,
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, rep)
}
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fmt.Fprintf(w, "STORE\t%s\n", rep.StoreDir)
fmt.Fprintf(w, "LOCKED\t%v\n", rep.LockHeld)
if rep.LockHeld && rep.LockInfo != "" {
fmt.Fprintf(w, "LOCK_INFO\t%s\n", rep.LockInfo)
}
fmt.Fprintf(w, "AUTHENTICATED\t%v\n", rep.Authed)
fmt.Fprintf(w, "CONNECTED\t%v\n", rep.Connected)
fmt.Fprintf(w, "FTS5\t%v\n", rep.FTSEnabled)
_ = w.Flush()
writeDoctorReport(os.Stdout, rep)
if rep.StoreError != "" {
fmt.Fprintf(os.Stdout, "\nERROR: store could not be opened: %s\n", rep.StoreError)
fmt.Fprintln(os.Stdout, "Tip: check that the store directory exists and is not corrupted.")
}
if rep.LockHeld {
fmt.Fprintln(os.Stdout, "\nTip: stop the running `wacli sync` before running write operations.")
}

110
cmd/wacli/doctor_test.go Normal file
View File

@ -0,0 +1,110 @@
package main
import (
"bytes"
"strings"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
)
func TestParseLockOwnerPID(t *testing.T) {
tests := []struct {
name string
info string
want int
}{
{name: "pid line", info: "pid=50394\nacquired_at=2026-04-05T12:30:11Z", want: 50394},
{name: "trimmed pid", info: " pid= 42 ", want: 42},
{name: "missing pid", info: "acquired_at=2026-04-05T12:30:11Z"},
{name: "invalid pid", info: "pid=abc"},
{name: "zero pid", info: "pid=0"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := parseLockOwnerPID(tc.info); got != tc.want {
t.Fatalf("parseLockOwnerPID() = %d, want %d", got, tc.want)
}
})
}
}
func TestDoctorConnectionState(t *testing.T) {
tests := []struct {
name string
authed bool
connected bool
lockHeld bool
connect bool
want string
}{
{name: "connected wins", authed: true, connected: true, lockHeld: true, want: "connected"},
{name: "locked paired session", authed: true, lockHeld: true, want: "locked_by_other_process"},
{name: "connect requested stays disconnected", authed: true, lockHeld: true, connect: true, want: "disconnected"},
{name: "plain disconnected", authed: true, want: "disconnected"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := doctorConnectionState(tc.authed, tc.connected, tc.lockHeld, tc.connect)
if got != tc.want {
t.Fatalf("doctorConnectionState() = %q, want %q", got, tc.want)
}
})
}
}
func TestDoctorStoreStatsFromStoreStats(t *testing.T) {
when := time.Date(2024, 4, 1, 12, 30, 0, 0, time.FixedZone("offset", 2*60*60))
got := doctorStoreStatsFromStoreStats(store.StoreStats{
Messages: 4,
Chats: 3,
Contacts: 2,
Groups: 1,
LastMessageTS: when.Unix(),
})
if got.Messages != 4 || got.Chats != 3 || got.Contacts != 2 || got.Groups != 1 {
t.Fatalf("unexpected counts: %+v", got)
}
if got.LastSyncAt != "2024-04-01T10:30:00Z" {
t.Fatalf("LastSyncAt = %q", got.LastSyncAt)
}
}
func TestWriteDoctorReportIncludesLinkedJIDAndStats(t *testing.T) {
var b bytes.Buffer
writeDoctorReport(&b, doctorReport{
StoreDir: "/tmp/wacli",
Authed: true,
LinkedJID: "1234567890@s.whatsapp.net",
ConnectionState: "disconnected",
FTSEnabled: true,
Store: &doctorStoreStats{
Messages: 9,
Chats: 8,
Contacts: 7,
Groups: 6,
LastSyncAt: "2024-04-01T10:30:00Z",
},
})
out := b.String()
for _, want := range []string{
"LINKED_JID",
"1234567890@s.whatsapp.net",
"MESSAGES",
"9",
"CHATS",
"8",
"CONTACTS",
"7",
"GROUPS",
"6",
"LAST_SYNC",
"2024-04-01T10:30:00Z",
} {
if !strings.Contains(out, want) {
t.Fatalf("doctor output missing %q:\n%s", want, out)
}
}
}

View File

@ -1,19 +1,6 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)
import "github.com/spf13/cobra"
func newGroupsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
@ -28,477 +15,6 @@ func newGroupsCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newGroupsInviteCmd(flags))
cmd.AddCommand(newGroupsJoinCmd(flags))
cmd.AddCommand(newGroupsLeaveCmd(flags))
cmd.AddCommand(newGroupsPruneCmd(flags))
return cmd
}
func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "refresh",
Short: "Fetch joined groups (live) and update local DB",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gs, err := a.WA().GetJoinedGroups(ctx)
if err != nil {
return err
}
for _, g := range gs {
if g == nil {
continue
}
_ = persistGroupInfo(a.DB(), g)
_ = a.DB().UpsertChat(g.JID.String(), "group", g.GroupName.Name, time.Now())
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"groups": len(gs)})
}
fmt.Fprintf(os.Stdout, "Imported %d groups.\n", len(gs))
return nil
},
}
return cmd
}
func newGroupsListCmd(flags *rootFlags) *cobra.Command {
var query string
var limit int
cmd := &cobra.Command{
Use: "list",
Short: "List known groups (from local DB; run sync to populate)",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
gs, err := a.DB().ListGroups(query, limit)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, gs)
}
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tJID\tCREATED")
for _, g := range gs {
name := g.Name
if name == "" {
name = g.JID
}
fmt.Fprintf(w, "%s\t%s\t%s\n", truncate(name, 40), g.JID, g.CreatedAt.Local().Format("2006-01-02"))
}
_ = w.Flush()
return nil
},
}
cmd.Flags().StringVar(&query, "query", "", "search query")
cmd.Flags().IntVar(&limit, "limit", 50, "limit")
return cmd
}
func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "info",
Short: "Fetch group info (live) and update local DB",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
info, err := a.WA().GetGroupInfo(ctx, gjid)
if err != nil {
return err
}
if info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, info)
}
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nCreated: %s\nParticipants: %d\n",
info.JID.String(),
info.GroupName.Name,
info.OwnerJID.String(),
info.GroupCreated.Local().Format(time.RFC3339),
len(info.Participants),
)
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}
func newGroupsRenameCmd(flags *rootFlags) *cobra.Command {
var jidStr string
var name string
cmd := &cobra.Command{
Use: "rename",
Short: "Rename group",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" || strings.TrimSpace(name) == "" {
return fmt.Errorf("--jid and --name are required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
if err := a.WA().SetGroupName(ctx, gjid, name); err != nil {
return err
}
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "name": name})
}
fmt.Fprintln(os.Stdout, "OK")
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
cmd.Flags().StringVar(&name, "name", "", "new name")
return cmd
}
func newGroupsParticipantsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "participants",
Short: "Manage group participants",
}
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "add"))
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "remove"))
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "promote"))
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "demote"))
return cmd
}
func newGroupsParticipantsActionCmd(flags *rootFlags, action string) *cobra.Command {
var group string
var users []string
cmd := &cobra.Command{
Use: action,
Short: action + " participants",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(group) == "" || len(users) == 0 {
return fmt.Errorf("--jid and at least one --user are required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(group)
if err != nil {
return err
}
var jids []types.JID
for _, u := range users {
j, err := wa.ParseUserOrJID(u)
if err != nil {
return err
}
jids = append(jids, j)
}
updated, err := a.WA().UpdateGroupParticipants(ctx, gjid, jids, wa.GroupParticipantAction(action))
if err != nil {
return err
}
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, updated)
}
fmt.Fprintln(os.Stdout, "OK")
return nil
},
}
cmd.Flags().StringVar(&group, "jid", "", "group JID (…@g.us)")
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number or JID (repeatable)")
return cmd
}
func newGroupsInviteCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "invite",
Short: "Manage group invite links",
}
cmd.AddCommand(newGroupsInviteLinkCmd(flags))
return cmd
}
func newGroupsInviteLinkCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "link",
Short: "Get or revoke invite links",
}
cmd.AddCommand(newGroupsInviteLinkGetCmd(flags))
cmd.AddCommand(newGroupsInviteLinkRevokeCmd(flags))
return cmd
}
func newGroupsInviteLinkGetCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "get",
Short: "Get invite link",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
link, err := a.WA().GetGroupInviteLink(ctx, gjid, false)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link})
}
fmt.Fprintln(os.Stdout, link)
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}
func newGroupsInviteLinkRevokeCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "revoke",
Short: "Revoke/reset invite link",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
link, err := a.WA().GetGroupInviteLink(ctx, gjid, true)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link, "revoked": true})
}
fmt.Fprintln(os.Stdout, link)
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}
func newGroupsJoinCmd(flags *rootFlags) *cobra.Command {
var code string
cmd := &cobra.Command{
Use: "join",
Short: "Join group by invite code",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(code) == "" {
return fmt.Errorf("--code is required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := a.WA().JoinGroupWithLink(ctx, code)
if err != nil {
return err
}
if info, err := a.WA().GetGroupInfo(ctx, jid); err == nil && info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": jid.String(), "joined": true})
}
fmt.Fprintf(os.Stdout, "Joined: %s\n", jid.String())
return nil
},
}
cmd.Flags().StringVar(&code, "code", "", "invite code (from link)")
return cmd
}
func newGroupsLeaveCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "leave",
Short: "Leave a group",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
if err := a.WA().LeaveGroup(ctx, gjid); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "left": true})
}
fmt.Fprintln(os.Stdout, "OK")
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}
func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
if info == nil {
return nil
}
if err := db.UpsertGroup(info.JID.String(), info.GroupName.Name, info.OwnerJID.String(), info.GroupCreated); err != nil {
return err
}
var ps []store.GroupParticipant
for _, p := range info.Participants {
role := "member"
if p.IsSuperAdmin {
role = "superadmin"
} else if p.IsAdmin {
role = "admin"
}
ps = append(ps, store.GroupParticipant{
GroupJID: info.JID.String(),
UserJID: p.JID.String(),
Role: role,
})
}
return db.ReplaceGroupParticipants(info.JID.String(), ps)
}

View File

@ -0,0 +1,174 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func newGroupsInfoCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "info",
Short: "Fetch group info (live) and update local DB",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
info, err := a.WA().GetGroupInfo(ctx, gjid)
if err != nil {
return err
}
if info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, info)
}
fmt.Fprintf(os.Stdout, "JID: %s\nName: %s\nOwner: %s\nType: %s\n",
info.JID.String(),
info.GroupName.Name,
info.OwnerJID.String(),
groupKindLabel(info.IsParent, info.LinkedParentJID.String()),
)
if !info.LinkedParentJID.IsEmpty() {
fmt.Fprintf(os.Stdout, "Parent: %s\n", info.LinkedParentJID.String())
}
fmt.Fprintf(os.Stdout, "Created: %s\nParticipants: %d\n",
info.GroupCreated.Local().Format(time.RFC3339),
len(info.Participants),
)
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}
func newGroupsRenameCmd(flags *rootFlags) *cobra.Command {
var jidStr string
var name string
cmd := &cobra.Command{
Use: "rename",
Short: "Rename group",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" || strings.TrimSpace(name) == "" {
return fmt.Errorf("--jid and --name are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
if err := a.WA().SetGroupName(ctx, gjid, name); err != nil {
return err
}
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "name": name})
}
fmt.Fprintln(os.Stdout, "OK")
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
cmd.Flags().StringVar(&name, "name", "", "new name")
return cmd
}
func newGroupsLeaveCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "leave",
Short: "Leave a group",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
if err := a.WA().LeaveGroup(ctx, gjid); err != nil {
return err
}
_ = a.DB().MarkGroupLeft(gjid.String(), time.Now().UTC())
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "left": true})
}
fmt.Fprintln(os.Stdout, "OK")
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}

View File

@ -0,0 +1,165 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func newGroupsInviteCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "invite",
Short: "Manage group invite links",
}
cmd.AddCommand(newGroupsInviteLinkCmd(flags))
return cmd
}
func newGroupsInviteLinkCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "link",
Short: "Get or revoke invite links",
}
cmd.AddCommand(newGroupsInviteLinkGetCmd(flags))
cmd.AddCommand(newGroupsInviteLinkRevokeCmd(flags))
return cmd
}
func newGroupsInviteLinkGetCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "get",
Short: "Get invite link",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
link, err := a.WA().GetGroupInviteLink(ctx, gjid, false)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link})
}
fmt.Fprintln(os.Stdout, link)
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}
func newGroupsInviteLinkRevokeCmd(flags *rootFlags) *cobra.Command {
var jidStr string
cmd := &cobra.Command{
Use: "revoke",
Short: "Revoke/reset invite link",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(jidStr) == "" {
return fmt.Errorf("--jid is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(jidStr)
if err != nil {
return err
}
link, err := a.WA().GetGroupInviteLink(ctx, gjid, true)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": gjid.String(), "link": link, "revoked": true})
}
fmt.Fprintln(os.Stdout, link)
return nil
},
}
cmd.Flags().StringVar(&jidStr, "jid", "", "group JID (…@g.us)")
return cmd
}
func newGroupsJoinCmd(flags *rootFlags) *cobra.Command {
var code string
cmd := &cobra.Command{
Use: "join",
Short: "Join group by invite code",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(code) == "" {
return fmt.Errorf("--code is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
jid, err := a.WA().JoinGroupWithLink(ctx, code)
if err != nil {
return err
}
if info, err := a.WA().GetGroupInfo(ctx, jid); err == nil && info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"jid": jid.String(), "joined": true})
}
fmt.Fprintf(os.Stdout, "Joined: %s\n", jid.String())
return nil
},
}
cmd.Flags().StringVar(&code, "code", "", "invite code (from link)")
return cmd
}

View File

@ -0,0 +1,87 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func newGroupsParticipantsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "participants",
Short: "Manage group participants",
}
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "add"))
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "remove"))
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "promote"))
cmd.AddCommand(newGroupsParticipantsActionCmd(flags, "demote"))
return cmd
}
func newGroupsParticipantsActionCmd(flags *rootFlags, action string) *cobra.Command {
var group string
var users []string
cmd := &cobra.Command{
Use: action,
Short: action + " participants",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(group) == "" || len(users) == 0 {
return fmt.Errorf("--jid and at least one --user are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gjid, err := types.ParseJID(group)
if err != nil {
return err
}
var jids []types.JID
for _, u := range users {
j, err := wa.ParseUserOrJID(u)
if err != nil {
return err
}
jids = append(jids, j)
}
updated, err := a.WA().UpdateGroupParticipants(ctx, gjid, jids, wa.GroupParticipantAction(action))
if err != nil {
return err
}
if info, err := a.WA().GetGroupInfo(ctx, gjid); err == nil && info != nil {
_ = persistGroupInfo(a.DB(), info)
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, updated)
}
fmt.Fprintln(os.Stdout, "OK")
return nil
},
}
cmd.Flags().StringVar(&group, "jid", "", "group JID (…@g.us)")
cmd.Flags().StringSliceVar(&users, "user", nil, "user phone number (+E164 and formatting ok) or JID (repeatable)")
return cmd
}

View File

@ -0,0 +1,54 @@
package main
import (
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
func canonicalCLIJID(jid types.JID) types.JID {
if jid.Server == types.DefaultUserServer {
return jid.ToNonAD()
}
return jid
}
func persistGroupInfo(db *store.DB, info *types.GroupInfo) error {
if info == nil {
return nil
}
if err := db.UpsertGroupWithHierarchy(
info.JID.String(),
info.GroupName.Name,
info.OwnerJID.String(),
info.GroupCreated,
info.IsParent,
info.LinkedParentJID.String(),
); err != nil {
return err
}
var ps []store.GroupParticipant
for _, p := range info.Participants {
role := "member"
if p.IsSuperAdmin {
role = "superadmin"
} else if p.IsAdmin {
role = "admin"
}
ps = append(ps, store.GroupParticipant{
GroupJID: info.JID.String(),
UserJID: canonicalCLIJID(p.JID).String(),
Role: role,
})
}
return db.ReplaceGroupParticipants(info.JID.String(), ps)
}
func groupKindLabel(isParent bool, linkedParentJID string) string {
if isParent {
return "community"
}
if linkedParentJID != "" {
return "subgroup"
}
return "group"
}

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

@ -0,0 +1,135 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
)
func newGroupsPruneCmd(flags *rootFlags) *cobra.Command {
var days int
var leftOnly bool
var includeActive bool
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "prune",
Short: "Remove old or left groups from local storage",
Long: `Clean up groups that you have left or that have been inactive.
By default, removes groups you have left. Use --days to prune only left
groups older than the threshold. Add --include-active to also prune active
groups whose last local message is older than the threshold.
This only deletes local wacli store rows. It does not leave WhatsApp groups
or delete anything from WhatsApp servers. Use --dry-run to preview targets.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
if days < 0 {
return fmt.Errorf("days must not be negative")
}
if !leftOnly {
includeActive = true
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
return pruneGroups(a, days, includeActive, dryRun, confirm, flags.asJSON)
},
}
cmd.Flags().IntVar(&days, "days", 0, "prune groups older than N days (0 = all left groups)")
cmd.Flags().BoolVar(&leftOnly, "left-only", true, "only remove groups you have left")
cmd.Flags().BoolVar(&includeActive, "include-active", false, "also remove active groups with no messages in the last N days")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}
func pruneGroups(a *app.App, days int, includeActive, dryRun, confirm, asJSON bool) error {
groups, err := a.DB().ListPrunableGroups(days, includeActive)
if err != nil {
return err
}
if len(groups) == 0 {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "no groups to prune"})
}
fmt.Fprintln(os.Stderr, "No groups to prune.")
return nil
}
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_delete": len(groups), "groups": groups})
}
writePruneTargets(os.Stderr, "Would delete", groups)
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d group(s) from the local wacli store. This cannot be undone.\n", len(groups))
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deleted int
for _, g := range groups {
if err := a.DB().DeleteGroupLocalData(g.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete group %s: %v\n", g.JID, err)
continue
}
deleted++
if !asJSON {
name := g.Name
if name == "" {
name = g.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s\n", name)
}
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": deleted})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d group(s).\n", deleted)
return nil
}
func writePruneTargets(w *os.File, prefix string, groups []store.Group) {
fmt.Fprintf(w, "%s %d group(s):\n", prefix, len(groups))
for _, g := range groups {
name := g.Name
if name == "" {
name = g.JID
}
state := "left"
if g.LeftAt.IsZero() {
state = "inactive"
}
fmt.Fprintf(w, " - %s (%s, %s)\n", name, g.JID, state)
}
}

View File

@ -0,0 +1,117 @@
package main
import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
)
func TestGroupsPruneExposesSafetyFlags(t *testing.T) {
cmd := newGroupsPruneCmd(&rootFlags{})
for _, name := range []string{"days", "left-only", "include-active", "dry-run", "confirm"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestGroupsPruneRejectsReadOnlyBeforeOpeningStore(t *testing.T) {
cmd := newGroupsPruneCmd(&rootFlags{readOnly: true})
cmd.SetArgs([]string{"--dry-run"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "read-only mode") {
t.Fatalf("error = %v, want read-only", err)
}
}
func TestGroupsPruneDryRunDoesNotDeleteOlderLeftGroups(t *testing.T) {
storeDir := seedPruneStore(t)
cmd := newGroupsPruneCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--days", "180", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("groups prune dry-run: %v", err)
}
db := openPruneStore(t, storeDir)
defer db.Close()
if _, err := db.GetChat("old-left@g.us"); err != nil {
t.Fatalf("old-left chat should survive dry-run: %v", err)
}
left, err := db.ListPrunableGroups(180, false)
if err != nil {
t.Fatalf("ListPrunableGroups: %v", err)
}
if got := len(left); got != 1 {
t.Fatalf("dry-run deleted targets: got %d left, want 1", got)
}
}
func TestGroupsPruneConfirmDeletesOnlyMatchingGroups(t *testing.T) {
storeDir := seedPruneStore(t)
cmd := newGroupsPruneCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--days", "180", "--confirm"})
if err := cmd.Execute(); err != nil {
t.Fatalf("groups prune confirm: %v", err)
}
db := openPruneStore(t, storeDir)
defer db.Close()
if _, err := db.GetChat("old-left@g.us"); err == nil {
t.Fatalf("old-left chat should be deleted")
}
for _, jid := range []string{"recent-left@g.us", "old-active@g.us"} {
if _, err := db.GetChat(jid); err != nil {
t.Fatalf("%s chat should survive: %v", jid, err)
}
}
}
func seedPruneStore(t *testing.T) string {
t.Helper()
storeDir := t.TempDir()
db := openPruneStore(t, storeDir)
defer db.Close()
now := time.Now().UTC()
created := now.AddDate(0, 0, -400)
oldLeft := now.AddDate(0, 0, -200)
recentLeft := now.AddDate(0, 0, -30)
oldActive := now.AddDate(0, 0, -220)
for _, tc := range []struct {
jid string
name string
lastTS time.Time
leftAt time.Time
}{
{"old-left@g.us", "Old Left", oldLeft, oldLeft},
{"recent-left@g.us", "Recent Left", recentLeft, recentLeft},
{"old-active@g.us", "Old Active", oldActive, time.Time{}},
} {
if err := db.UpsertGroup(tc.jid, tc.name, "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup %s: %v", tc.jid, err)
}
if err := db.UpsertChat(tc.jid, "group", tc.name, tc.lastTS); err != nil {
t.Fatalf("UpsertChat %s: %v", tc.jid, err)
}
if !tc.leftAt.IsZero() {
if err := db.MarkGroupLeft(tc.jid, tc.leftAt); err != nil {
t.Fatalf("MarkGroupLeft %s: %v", tc.jid, err)
}
}
}
return storeDir
}
func openPruneStore(t *testing.T, storeDir string) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
return db
}

View File

@ -0,0 +1,116 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newGroupsRefreshCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "refresh",
Short: "Fetch joined groups (live) and update local DB",
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
gs, err := a.WA().GetJoinedGroups(ctx)
if err != nil {
return err
}
joined := map[string]bool{}
now := time.Now().UTC()
for _, g := range gs {
if g == nil {
continue
}
joined[g.JID.String()] = true
_ = persistGroupInfo(a.DB(), g)
_ = a.DB().UpsertChat(g.JID.String(), "group", g.GroupName.Name, now)
}
if err := a.DB().MarkGroupsMissingFrom(joined, now); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"groups": len(gs)})
}
fmt.Fprintf(os.Stdout, "Imported %d groups.\n", len(gs))
return nil
},
}
return cmd
}
func newGroupsListCmd(flags *rootFlags) *cobra.Command {
var query string
var limit int
cmd := &cobra.Command{
Use: "list",
Short: "List known groups (from local DB; run sync to populate)",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
gs, err := a.DB().ListGroups(query, limit)
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, gs)
}
fullOutput := fullTableOutput(flags.fullOutput)
w := newTableWriter(os.Stdout)
fmt.Fprintln(w, "NAME\tJID\tTYPE\tPARENT\tCREATED")
for _, g := range gs {
name := g.Name
if name == "" {
name = g.JID
}
parent := g.LinkedParentJID
if parent == "" {
parent = "-"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
tableCell(name, 40, fullOutput),
g.JID,
groupKindLabel(g.IsParent, g.LinkedParentJID),
parent,
g.CreatedAt.Local().Format("2006-01-02"),
)
}
_ = w.Flush()
return nil
},
}
cmd.Flags().StringVar(&query, "query", "", "search query")
cmd.Flags().IntVar(&limit, "limit", 50, "limit")
return cmd
}

View File

@ -13,6 +13,10 @@ func isTTY() bool {
return term.IsTerminal(int(os.Stdout.Fd()))
}
func isInteractive() bool {
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stderr.Fd()))
}
func parseTime(s string) (time.Time, error) {
s = strings.TrimSpace(s)
if s == "" {
@ -27,14 +31,30 @@ func parseTime(s string) (time.Time, error) {
return time.Time{}, fmt.Errorf("unsupported time format %q (use RFC3339 or YYYY-MM-DD)", s)
}
func truncate(s string, max int) string {
func sanitize(s string) string {
s = strings.ReplaceAll(s, "\n", " ")
s = strings.TrimSpace(s)
if max <= 0 || len(s) <= max {
return strings.TrimSpace(s)
}
func truncate(s string, max int) string {
s = sanitize(s)
if max <= 0 {
return s
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max <= 1 {
return s[:max]
return string(runes[:max])
}
return s[:max-1] + "…"
return string(runes[:max-1]) + "…"
}
func fullTableOutput(forceFull bool) bool {
return fullTableOutputWithTTY(forceFull, isTTY())
}
func fullTableOutputWithTTY(forceFull, tty bool) bool {
return forceFull || !tty
}

View File

@ -1,27 +1,130 @@
package main
import (
"context"
"fmt"
"io"
"os"
"os/signal"
"syscall"
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
)
func newHistoryCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "history",
Short: "History backfill (best-effort; requires prior auth)",
Short: "History coverage and backfill",
}
cmd.AddCommand(newHistoryCoverageCmd(flags))
cmd.AddCommand(newHistoryFillCmd(flags))
cmd.AddCommand(newHistoryBackfillCmd(flags))
return cmd
}
func newHistoryCoverageCmd(flags *rootFlags) *cobra.Command {
var chats []string
var query string
var kind string
var limit int
var includeBlocked bool
var onlyActionable bool
cmd := &cobra.Command{
Use: "coverage",
Short: "Show local archive coverage by chat",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(cmd.Context(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, true)
if err != nil {
return err
}
defer closeApp(a, lk)
coverage, err := a.DB().ListHistoryCoverage(store.ListHistoryCoverageParams{
ChatJIDs: chats,
Query: query,
Kind: kind,
Limit: limit,
IncludeBlocked: includeBlocked,
OnlyActionable: onlyActionable,
})
if err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"coverage": coverage})
}
return writeHistoryCoverageTable(os.Stdout, coverage, fullTableOutput(flags.fullOutput), false)
},
}
cmd.Flags().StringSliceVar(&chats, "chat", nil, "chat JID to inspect (repeatable)")
cmd.Flags().StringVar(&query, "query", "", "filter chats by local name or JID")
cmd.Flags().StringVar(&kind, "kind", "", "chat kind filter (dm|group|broadcast|newsletter|unknown)")
cmd.Flags().IntVar(&limit, "limit", 100, "limit rows")
cmd.Flags().BoolVar(&includeBlocked, "include-blocked", false, "include chats without a local message anchor")
cmd.Flags().BoolVar(&onlyActionable, "only-actionable", false, "show only chats with a local message anchor")
return cmd
}
func newHistoryFillCmd(flags *rootFlags) *cobra.Command {
var chats []string
var query string
var kind string
var limit int
var dryRun bool
cmd := &cobra.Command{
Use: "fill",
Short: "Plan multi-chat history backfill",
RunE: func(cmd *cobra.Command, args []string) error {
if !dryRun {
return fmt.Errorf("history fill currently supports --dry-run only; use history backfill --chat JID to request history")
}
ctx, cancel := withTimeout(cmd.Context(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, true)
if err != nil {
return err
}
defer closeApp(a, lk)
coverage, err := a.DB().ListHistoryCoverage(store.ListHistoryCoverageParams{
ChatJIDs: chats,
Query: query,
Kind: kind,
Limit: limit,
IncludeBlocked: true,
})
if err != nil {
return err
}
selected := historyFillCandidates(coverage)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"selected": selected,
"coverage": coverage,
})
}
fmt.Fprintf(os.Stdout, "Selected %d chats for fill dry run.\n", len(selected))
return writeHistoryCoverageTable(os.Stdout, coverage, fullTableOutput(flags.fullOutput), true)
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show which chats would be selected without connecting")
cmd.Flags().StringSliceVar(&chats, "chat", nil, "chat JID to consider (repeatable)")
cmd.Flags().StringVar(&query, "query", "", "filter chats by local name or JID")
cmd.Flags().StringVar(&kind, "kind", "", "chat kind filter (dm|group|broadcast|newsletter|unknown)")
cmd.Flags().IntVar(&limit, "limit", 100, "limit rows")
return cmd
}
func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
var chat string
var count int
@ -36,8 +139,11 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
if chat == "" {
return fmt.Errorf("--chat is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
defer stop()
a, lk, err := newApp(ctx, flags, true, false)
@ -73,9 +179,79 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
}
cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
cmd.Flags().IntVar(&count, "count", 50, "number of messages to request per on-demand sync (recommended: 50)")
cmd.Flags().IntVar(&requests, "requests", 1, "number of on-demand requests to attempt")
cmd.Flags().IntVar(&count, "count", app.DefaultBackfillCount, "number of messages to request per on-demand sync")
cmd.Flags().IntVar(&requests, "requests", app.DefaultBackfillRequests, "number of on-demand requests to attempt")
cmd.Flags().DurationVar(&wait, "wait", 60*time.Second, "time to wait for an on-demand response per request")
cmd.Flags().DurationVar(&idleExit, "idle-exit", 5*time.Second, "exit after being idle (after backfill requests)")
return cmd
}
func historyFillCandidates(coverage []store.HistoryCoverage) []store.HistoryCoverage {
out := make([]store.HistoryCoverage, 0, len(coverage))
for _, c := range coverage {
if c.Status == store.HistoryCoverageStatusReady {
out = append(out, c)
}
}
return out
}
func writeHistoryCoverageTable(dst io.Writer, coverage []store.HistoryCoverage, fullOutput, includeSelected bool) error {
w := newTableWriter(dst)
if includeSelected {
fmt.Fprintln(w, "SELECTED\tCHAT\tKIND\tMESSAGES\tOLDEST\tNEWEST\tSTATUS\tDETAIL")
} else {
fmt.Fprintln(w, "CHAT\tKIND\tMESSAGES\tOLDEST\tNEWEST\tSTATUS\tDETAIL")
}
for _, c := range coverage {
name := c.Name
if strings.TrimSpace(name) == "" {
name = c.ChatJID
}
detail := historyCoverageDetail(c)
selected := ""
if includeSelected {
if c.Status == store.HistoryCoverageStatusReady {
selected = "yes"
} else {
selected = "no"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
selected,
tableCell(name, 32, fullOutput),
c.Kind,
c.MessageCount,
formatHistoryDate(c.OldestTS),
formatHistoryDate(c.NewestTS),
c.Status,
tableCell(detail, 36, fullOutput),
)
continue
}
fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\t%s\n",
tableCell(name, 32, fullOutput),
c.Kind,
c.MessageCount,
formatHistoryDate(c.OldestTS),
formatHistoryDate(c.NewestTS),
c.Status,
tableCell(detail, 36, fullOutput),
)
}
_ = w.Flush()
return nil
}
func historyCoverageDetail(c store.HistoryCoverage) string {
if c.BlockedReason != "" {
return c.BlockedReason
}
return c.ChatJID
}
func formatHistoryDate(t time.Time) string {
if t.IsZero() {
return "-"
}
return t.Local().Format("2006-01-02")
}

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

@ -0,0 +1,87 @@
package main
import (
"strings"
"testing"
"time"
"github.com/openclaw/wacli/internal/store"
)
func TestHistoryCoverageCommandListsReadyAndBlockedChats(t *testing.T) {
storeDir := t.TempDir()
db, err := store.Open(storeDir + "/wacli.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
base := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat("ready@s.whatsapp.net", "dm", "Ready", base); err != nil {
t.Fatalf("UpsertChat ready: %v", err)
}
if err := db.UpsertChat("blocked@s.whatsapp.net", "dm", "Blocked", base); err != nil {
t.Fatalf("UpsertChat blocked: %v", err)
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: "ready@s.whatsapp.net",
MsgID: "m1",
Timestamp: base,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
_ = db.Close()
cmd := newHistoryCoverageCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--include-blocked"})
raw := captureRootStdout(t, func() {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(raw, "Ready") || !strings.Contains(raw, "Blocked") || !strings.Contains(raw, "no_local_anchor") {
t.Fatalf("coverage output missing expected rows: %q", raw)
}
}
func TestHistoryFillRequiresDryRun(t *testing.T) {
cmd := newHistoryFillCmd(&rootFlags{})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--dry-run") {
t.Fatalf("expected --dry-run error, got %v", err)
}
}
func TestHistoryFillDryRunSelectsReadyChats(t *testing.T) {
storeDir := t.TempDir()
db, err := store.Open(storeDir + "/wacli.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
base := time.Date(2024, 6, 2, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat("ready@s.whatsapp.net", "dm", "Ready", base); err != nil {
t.Fatalf("UpsertChat ready: %v", err)
}
if err := db.UpsertChat("blocked@s.whatsapp.net", "dm", "Blocked", base); err != nil {
t.Fatalf("UpsertChat blocked: %v", err)
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: "ready@s.whatsapp.net",
MsgID: "m1",
Timestamp: base,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
_ = db.Close()
cmd := newHistoryFillCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--dry-run"})
raw := captureRootStdout(t, func() {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
})
if !strings.Contains(raw, "Selected 1 chats") || !strings.Contains(raw, "yes") || !strings.Contains(raw, "no") {
t.Fatalf("dry-run output missing selection markers: %q", raw)
}
}

View File

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

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

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

View File

@ -6,8 +6,8 @@ import (
"os"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newMediaCmd(flags *rootFlags) *cobra.Command {
@ -31,6 +31,9 @@ func newMediaDownloadCmd(flags *rootFlags) *cobra.Command {
if chat == "" || id == "" {
return fmt.Errorf("--chat and --id are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()

View File

@ -5,12 +5,15 @@ import (
"fmt"
"os"
"strings"
"text/tabwriter"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
)
func newMessagesCmd(flags *rootFlags) *cobra.Command {
@ -20,16 +23,26 @@ func newMessagesCmd(flags *rootFlags) *cobra.Command {
}
cmd.AddCommand(newMessagesListCmd(flags))
cmd.AddCommand(newMessagesSearchCmd(flags))
cmd.AddCommand(newMessagesStarredCmd(flags))
cmd.AddCommand(newMessagesShowCmd(flags))
cmd.AddCommand(newMessagesContextCmd(flags))
cmd.AddCommand(newMessagesExportCmd(flags))
cmd.AddCommand(newMessagesDeleteCmd(flags))
cmd.AddCommand(newMessagesEditCmd(flags))
return cmd
}
func newMessagesListCmd(flags *rootFlags) *cobra.Command {
var chat string
var sender string
var limit int
var afterStr string
var beforeStr string
var fromMe bool
var fromThem bool
var asc bool
var forwarded bool
var starred bool
cmd := &cobra.Command{
Use: "list",
@ -38,6 +51,10 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
if fromMe && fromThem {
return fmt.Errorf("--from-me and --from-them are mutually exclusive")
}
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
@ -61,15 +78,36 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
before = &t
}
var fromMeFilter *bool
switch {
case fromMe:
v := true
fromMeFilter = &v
case fromThem:
v := false
fromMeFilter = &v
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().ListMessages(store.ListMessagesParams{
ChatJID: chat,
Limit: limit,
After: after,
Before: before,
ChatJIDs: chatJIDs,
SenderJID: sender,
Limit: limit,
After: after,
Before: before,
FromMe: fromMeFilter,
Asc: asc,
Forwarded: forwarded,
Starred: starred,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
@ -78,41 +116,20 @@ func newMessagesListCmd(flags *rootFlags) *cobra.Command {
})
}
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fmt.Fprintln(w, "TIME\tCHAT\tFROM\tID\tTEXT")
for _, m := range msgs {
from := m.SenderJID
if m.FromMe {
from = "me"
}
chatLabel := m.ChatName
if chatLabel == "" {
chatLabel = m.ChatJID
}
text := strings.TrimSpace(m.DisplayText)
if text == "" {
text = strings.TrimSpace(m.Text)
}
if m.MediaType != "" && text == "" {
text = "Sent " + m.MediaType
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
truncate(chatLabel, 24),
truncate(from, 18),
truncate(m.MsgID, 14),
truncate(text, 80),
)
}
_ = w.Flush()
return nil
return writeMessagesList(os.Stdout, msgs, fullTableOutput(flags.fullOutput))
},
}
cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
cmd.Flags().StringVar(&chat, "chat", "", "filter by chat JID")
cmd.Flags().StringVar(&sender, "sender", "", "filter by sender JID")
cmd.Flags().IntVar(&limit, "limit", 50, "max number of messages to return")
cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().BoolVar(&fromMe, "from-me", false, "only messages sent by me")
cmd.Flags().BoolVar(&fromThem, "from-them", false, "only messages received (not sent by me)")
cmd.Flags().BoolVar(&asc, "asc", false, "show oldest messages first (default: newest first)")
cmd.Flags().BoolVar(&forwarded, "forwarded", false, "only forwarded messages")
cmd.Flags().BoolVar(&starred, "starred", false, "only starred messages")
return cmd
}
@ -122,7 +139,10 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
var limit int
var afterStr string
var beforeStr string
var hasMedia bool
var msgType string
var forwarded bool
var starred bool
cmd := &cobra.Command{
Use: "search <query>",
@ -155,18 +175,27 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
before = &t
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().SearchMessages(store.SearchMessagesParams{
Query: args[0],
ChatJID: chat,
From: from,
Limit: limit,
After: after,
Before: before,
Type: msgType,
Query: args[0],
ChatJIDs: chatJIDs,
From: from,
Limit: limit,
After: after,
Before: before,
HasMedia: hasMedia,
Type: msgType,
Forwarded: forwarded,
Starred: starred,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
@ -175,33 +204,9 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
})
}
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fmt.Fprintf(w, "TIME\tCHAT\tFROM\tID\tMATCH\n")
for _, m := range msgs {
fromLabel := m.SenderJID
if m.FromMe {
fromLabel = "me"
}
chatLabel := m.ChatName
if chatLabel == "" {
chatLabel = m.ChatJID
}
match := m.Snippet
if match == "" {
match = strings.TrimSpace(m.DisplayText)
}
if match == "" {
match = m.Text
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
truncate(chatLabel, 24),
truncate(fromLabel, 18),
truncate(m.MsgID, 14),
truncate(match, 90),
)
if err := writeMessagesSearch(os.Stdout, msgs, fullTableOutput(flags.fullOutput)); err != nil {
return err
}
_ = w.Flush()
if !a.DB().HasFTS() {
fmt.Fprintln(os.Stderr, "Note: FTS5 not enabled; search is using LIKE (slow).")
}
@ -214,7 +219,80 @@ func newMessagesSearchCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().IntVar(&limit, "limit", 50, "limit results")
cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&msgType, "type", "", "media type filter (image|video|audio|document)")
cmd.Flags().BoolVar(&hasMedia, "has-media", false, "only messages with media")
cmd.Flags().StringVar(&msgType, "type", "", "message type filter (text|image|video|audio|document)")
cmd.Flags().BoolVar(&forwarded, "forwarded", false, "only forwarded messages")
cmd.Flags().BoolVar(&starred, "starred", false, "only starred messages")
return cmd
}
func newMessagesStarredCmd(flags *rootFlags) *cobra.Command {
var chat string
var limit int
var afterStr string
var beforeStr string
var asc bool
cmd := &cobra.Command{
Use: "starred",
Short: "List starred messages",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
var after *time.Time
var before *time.Time
if afterStr != "" {
t, err := parseTime(afterStr)
if err != nil {
return err
}
after = &t
}
if beforeStr != "" {
t, err := parseTime(beforeStr)
if err != nil {
return err
}
before = &t
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().ListStarredMessages(store.ListStarredMessagesParams{
ChatJIDs: chatJIDs,
Limit: limit,
After: after,
Before: before,
Asc: asc,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"messages": msgs,
"fts": a.DB().HasFTS(),
})
}
return writeMessagesStarred(os.Stdout, msgs, fullTableOutput(flags.fullOutput))
},
}
cmd.Flags().StringVar(&chat, "chat", "", "filter by chat JID")
cmd.Flags().IntVar(&limit, "limit", 50, "max number of messages to return")
cmd.Flags().StringVar(&afterStr, "after", "", "only messages with stored star time after time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages with stored star time before time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().BoolVar(&asc, "asc", false, "show oldest starred messages first (default: newest starred first)")
return cmd
}
@ -239,31 +317,21 @@ func newMessagesShowCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
m, err := a.DB().GetMessage(chat, id)
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
m, err := getMessageByChatFilter(a.DB(), chatJIDs, id)
if err != nil {
return err
}
m = resolveMessageSenderNames(ctx, a, []store.Message{m})[0]
if flags.asJSON {
return out.WriteJSON(os.Stdout, m)
}
fmt.Fprintf(os.Stdout, "Chat: %s\n", m.ChatJID)
if m.ChatName != "" {
fmt.Fprintf(os.Stdout, "Chat name: %s\n", m.ChatName)
}
fmt.Fprintf(os.Stdout, "ID: %s\n", m.MsgID)
fmt.Fprintf(os.Stdout, "Time: %s\n", m.Timestamp.Local().Format(time.RFC3339))
if m.FromMe {
fmt.Fprintf(os.Stdout, "From: me\n")
} else {
fmt.Fprintf(os.Stdout, "From: %s\n", m.SenderJID)
}
if m.MediaType != "" {
fmt.Fprintf(os.Stdout, "Media: %s\n", m.MediaType)
}
fmt.Fprintf(os.Stdout, "\n%s\n", m.Text)
return nil
return writeMessageShow(os.Stdout, m)
},
}
@ -295,35 +363,21 @@ func newMessagesContextCmd(flags *rootFlags) *cobra.Command {
}
defer closeApp(a, lk)
msgs, err := a.DB().MessageContext(chat, id, before, after)
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := getMessageContextByChatFilter(a.DB(), chatJIDs, id, before, after)
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
if flags.asJSON {
return out.WriteJSON(os.Stdout, msgs)
}
w := tabwriter.NewWriter(os.Stdout, 2, 4, 2, ' ', 0)
fmt.Fprintln(w, "TIME\tFROM\tID\tTEXT")
for _, m := range msgs {
from := m.SenderJID
if m.FromMe {
from = "me"
}
line := m.Text
if m.MsgID == id {
line = ">> " + line
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
truncate(from, 18),
truncate(m.MsgID, 14),
truncate(line, 100),
)
}
_ = w.Flush()
return nil
return writeMessageContext(os.Stdout, msgs, id, fullTableOutput(flags.fullOutput))
},
}
cmd.Flags().StringVar(&chat, "chat", "", "chat JID")
@ -332,3 +386,345 @@ func newMessagesContextCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().IntVar(&after, "after", 5, "messages after")
return cmd
}
func newMessagesExportCmd(flags *rootFlags) *cobra.Command {
var chat string
var limit int
var afterStr string
var beforeStr string
var output string
cmd := &cobra.Command{
Use: "export",
Short: "Export messages as JSON",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
var after *time.Time
var before *time.Time
if afterStr != "" {
t, err := parseTime(afterStr)
if err != nil {
return err
}
after = &t
}
if beforeStr != "" {
t, err := parseTime(beforeStr)
if err != nil {
return err
}
before = &t
}
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return err
}
msgs, err := a.DB().ListMessages(store.ListMessagesParams{
ChatJIDs: chatJIDs,
Limit: limit,
After: after,
Before: before,
Asc: true,
})
if err != nil {
return err
}
msgs = resolveMessageSenderNames(ctx, a, msgs)
dst := os.Stdout
if output != "" {
f, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer f.Close()
dst = f
}
return out.WriteJSON(dst, map[string]any{
"messages": msgs,
"fts": a.DB().HasFTS(),
})
},
}
cmd.Flags().StringVar(&chat, "chat", "", "filter by chat JID")
cmd.Flags().IntVar(&limit, "limit", 1000, "max number of messages to export")
cmd.Flags().StringVar(&afterStr, "after", "", "only messages after time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&beforeStr, "before", "", "only messages before time (RFC3339 or YYYY-MM-DD)")
cmd.Flags().StringVar(&output, "output", "", "write JSON export to file instead of stdout")
return cmd
}
func newMessagesDeleteCmd(flags *rootFlags) *cobra.Command {
var chat string
var id string
var forMe bool
var deleteMedia bool
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "delete",
Short: "Delete a message for everyone or for you",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(chat) == "" || strings.TrimSpace(id) == "" {
return fmt.Errorf("--chat and --id are required")
}
if deleteMedia && !forMe {
return fmt.Errorf("--delete-media requires --for-me")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
msg, chatJID, err := loadMessageMutationTarget(ctx, a, chat, id)
if err != nil {
return err
}
if !forMe {
if err := validateMessageCanRevoke(msg); err != nil {
return err
}
} else if err := validateMessageCanDeleteForMe(msg); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
if forMe {
info, err := messageInfoForDeleteForMe(msg, chatJID)
if err != nil {
return err
}
if _, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (struct{}, error) {
return struct{}{}, a.WA().DeleteMessageForMe(ctx, info, deleteMedia)
}); err != nil {
return err
}
if err := a.DB().MarkMessageDeletedForMe(msg.ChatJID, msg.MsgID, msg.SenderJID, msg.FromMe, time.Now().UTC()); err != nil {
return fmt.Errorf("store deleted-for-me message state: %w", err)
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"deleted_for_me": true,
"to": chatJID.String(),
"target": msg.MsgID,
})
}
fmt.Fprintf(os.Stdout, "Deleted message %s for me in %s\n", msg.MsgID, chatJID.String())
return nil
}
sentID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return a.WA().RevokeMessage(ctx, chatJID, types.MessageID(msg.MsgID))
})
if err != nil {
return err
}
if err := a.DB().MarkMessageRevoked(msg.ChatJID, msg.MsgID); err != nil {
return fmt.Errorf("store deleted message state: %w", err)
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"revoked": true,
"to": chatJID.String(),
"id": sentID,
"target": msg.MsgID,
})
}
fmt.Fprintf(os.Stdout, "Deleted message %s in %s (id %s)\n", msg.MsgID, chatJID.String(), sentID)
return nil
},
}
cmd.Flags().StringVar(&chat, "chat", "", "chat JID, phone number, or contact/group/chat name")
cmd.Flags().StringVar(&id, "id", "", "message ID to delete")
cmd.Flags().BoolVar(&forMe, "for-me", false, "delete the message only for this WhatsApp account")
cmd.Flags().BoolVar(&deleteMedia, "delete-media", false, "also remove local media when used with --for-me")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after delete so retry receipts can be handled (0 disables)")
return cmd
}
func newMessagesEditCmd(flags *rootFlags) *cobra.Command {
var chat string
var id string
var message string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "edit",
Short: "Edit one of your recent sent text messages",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(chat) == "" || strings.TrimSpace(id) == "" || strings.TrimSpace(message) == "" {
return fmt.Errorf("--chat, --id, and --message are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
msg, chatJID, err := loadMessageMutationTarget(ctx, a, chat, id)
if err != nil {
return err
}
if err := validateMessageCanEdit(msg, time.Now().UTC()); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
sentID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return a.WA().EditMessage(ctx, chatJID, types.MessageID(msg.MsgID), message)
})
if err != nil {
return err
}
if err := a.DB().UpdateMessageText(msg.ChatJID, msg.MsgID, message); err != nil {
return fmt.Errorf("store edited message text: %w", err)
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"edited": true,
"to": chatJID.String(),
"id": sentID,
"target": msg.MsgID,
"message": message,
})
}
fmt.Fprintf(os.Stdout, "Edited message %s in %s (id %s)\n", msg.MsgID, chatJID.String(), sentID)
return nil
},
}
cmd.Flags().StringVar(&chat, "chat", "", "chat JID, phone number, or contact/group/chat name")
cmd.Flags().StringVar(&id, "id", "", "message ID to edit")
cmd.Flags().StringVar(&message, "message", "", "new message text")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after edit so retry receipts can be handled (0 disables)")
return cmd
}
func loadMessageMutationTarget(ctx context.Context, a *app.App, chat, id string) (store.Message, types.JID, error) {
chatJIDs, err := messageChatJIDFilter(ctx, a, chat)
if err != nil {
return store.Message{}, types.JID{}, err
}
msg, err := getMessageByChatFilter(a.DB(), chatJIDs, id)
if err != nil {
return store.Message{}, types.JID{}, err
}
chatJID, err := wa.ParseUserOrJID(msg.ChatJID)
if err != nil {
return store.Message{}, types.JID{}, fmt.Errorf("stored chat JID is invalid: %w", err)
}
return msg, chatJID, nil
}
func validateMessageCanRevoke(msg store.Message) error {
if msg.Revoked {
return fmt.Errorf("message %s is already deleted", msg.MsgID)
}
if msg.DeletedForMe {
return fmt.Errorf("message %s was deleted for me", msg.MsgID)
}
if !msg.FromMe {
return fmt.Errorf("message %s was not sent by me", msg.MsgID)
}
return nil
}
func validateMessageCanDeleteForMe(msg store.Message) error {
if msg.Revoked {
return fmt.Errorf("message %s is already deleted", msg.MsgID)
}
if msg.DeletedForMe {
return fmt.Errorf("message %s was deleted for me", msg.MsgID)
}
return nil
}
func messageInfoForDeleteForMe(msg store.Message, chat types.JID) (types.MessageInfo, error) {
sender := types.EmptyJID
if strings.TrimSpace(msg.SenderJID) != "" {
parsed, err := types.ParseJID(msg.SenderJID)
if err != nil {
return types.MessageInfo{}, fmt.Errorf("stored sender JID is invalid: %w", err)
}
sender = parsed
} else if !msg.FromMe && chat.Server == types.DefaultUserServer {
sender = chat
}
if !msg.FromMe && chat.Server == types.GroupServer && sender.IsEmpty() {
return types.MessageInfo{}, fmt.Errorf("stored sender JID is required to delete a group message for me")
}
return types.MessageInfo{
MessageSource: types.MessageSource{
Chat: chat,
Sender: sender,
IsFromMe: msg.FromMe,
IsGroup: chat.Server == types.GroupServer,
},
ID: types.MessageID(msg.MsgID),
Timestamp: msg.Timestamp,
}, nil
}
func validateMessageCanEdit(msg store.Message, now time.Time) error {
if err := validateMessageCanRevoke(msg); err != nil {
return err
}
if strings.TrimSpace(msg.MediaType) != "" {
return fmt.Errorf("only text messages can be edited")
}
if strings.TrimSpace(msg.Text) == "" && strings.TrimSpace(msg.DisplayText) == "" {
return fmt.Errorf("only text messages can be edited")
}
if !msg.Timestamp.IsZero() && now.Sub(msg.Timestamp) > whatsmeow.EditWindow {
return fmt.Errorf("message %s is older than WhatsApp's %s edit window", msg.MsgID, whatsmeow.EditWindow)
}
return nil
}

View File

@ -0,0 +1,208 @@
package main
import (
"fmt"
"io"
"strings"
"time"
"github.com/openclaw/wacli/internal/store"
)
func writeMessagesList(dst io.Writer, msgs []store.Message, fullOutput bool) error {
w := newTableWriter(dst)
fmt.Fprintln(w, "TIME\tCHAT\tFROM\tID\tTEXT")
for _, m := range msgs {
chatLabel := m.ChatName
if chatLabel == "" {
chatLabel = m.ChatJID
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
tableCell(chatLabel, 24, fullOutput),
tableCell(messageFrom(m), 18, fullOutput),
tableCell(m.MsgID, 14, fullOutput),
tableCell(messageText(m), 80, fullOutput),
)
}
return w.Flush()
}
func writeMessagesSearch(dst io.Writer, msgs []store.Message, fullOutput bool) error {
w := newTableWriter(dst)
fmt.Fprintf(w, "TIME\tCHAT\tFROM\tID\tMATCH\n")
for _, m := range msgs {
chatLabel := m.ChatName
if chatLabel == "" {
chatLabel = m.ChatJID
}
match := m.Snippet
if match == "" {
match = messageText(m)
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
tableCell(chatLabel, 24, fullOutput),
tableCell(messageFrom(m), 18, fullOutput),
tableCell(m.MsgID, 14, fullOutput),
tableCell(match, 90, fullOutput),
)
}
return w.Flush()
}
func writeMessagesStarred(dst io.Writer, msgs []store.Message, fullOutput bool) error {
w := newTableWriter(dst)
fmt.Fprintln(w, "STARRED\tTIME\tCHAT\tFROM\tID\tTEXT")
for _, m := range msgs {
chatLabel := m.ChatName
if chatLabel == "" {
chatLabel = m.ChatJID
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n",
m.StarredAt.Local().Format("2006-01-02 15:04:05"),
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
tableCell(chatLabel, 24, fullOutput),
tableCell(messageFrom(m), 18, fullOutput),
tableCell(m.MsgID, 14, fullOutput),
tableCell(messageText(m), 80, fullOutput),
)
}
return w.Flush()
}
func writeMessageShow(dst io.Writer, m store.Message) error {
fmt.Fprintf(dst, "Chat: %s\n", m.ChatJID)
if m.ChatName != "" {
fmt.Fprintf(dst, "Chat name: %s\n", m.ChatName)
}
fmt.Fprintf(dst, "ID: %s\n", m.MsgID)
fmt.Fprintf(dst, "Time: %s\n", m.Timestamp.Local().Format(time.RFC3339))
fmt.Fprintf(dst, "From: %s\n", messageFromDetail(m))
if m.MediaType != "" {
fmt.Fprintf(dst, "Media: %s\n", m.MediaType)
}
if m.MediaCaption != "" {
fmt.Fprintf(dst, "Caption: %s\n", m.MediaCaption)
}
if m.Filename != "" {
fmt.Fprintf(dst, "Filename: %s\n", m.Filename)
}
if m.MimeType != "" {
fmt.Fprintf(dst, "MIME type: %s\n", m.MimeType)
}
if m.LocalPath != "" {
fmt.Fprintf(dst, "Downloaded: %s\n", m.LocalPath)
if !m.DownloadedAt.IsZero() {
fmt.Fprintf(dst, "Downloaded at: %s\n", m.DownloadedAt.Local().Format(time.RFC3339))
}
}
if m.IsForwarded {
fmt.Fprintln(dst, "Forwarded: yes")
if m.ForwardingScore > 0 {
fmt.Fprintf(dst, "Forwarding score: %d\n", m.ForwardingScore)
}
}
if m.Starred {
fmt.Fprintln(dst, "Starred: yes")
if !m.StarredAt.IsZero() {
fmt.Fprintf(dst, "Starred at: %s\n", m.StarredAt.Local().Format(time.RFC3339))
}
}
if m.Revoked {
fmt.Fprintln(dst, "Deleted: yes")
}
if m.DeletedForMe {
fmt.Fprintln(dst, "Deleted for me: yes")
}
fmt.Fprintf(dst, "\n%s\n", messageText(m))
if raw := messageRawText(m); raw != "" {
fmt.Fprintf(dst, "\nRaw text:\n%s\n", raw)
}
return nil
}
func writeMessageContext(dst io.Writer, msgs []store.Message, selectedID string, fullOutput bool) error {
w := newTableWriter(dst)
fmt.Fprintln(w, "TIME\tFROM\tID\tTEXT")
for _, m := range msgs {
line := messageContextLine(m)
if m.MsgID == selectedID {
line = ">> " + line
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n",
m.Timestamp.Local().Format("2006-01-02 15:04:05"),
tableCell(messageFrom(m), 18, fullOutput),
tableCell(m.MsgID, 14, fullOutput),
tableCell(line, 100, fullOutput),
)
}
return w.Flush()
}
func messageFrom(m store.Message) string {
if m.FromMe {
return "me"
}
if name := strings.TrimSpace(m.SenderName); name != "" {
return name
}
return m.SenderJID
}
func messageFromDetail(m store.Message) string {
if m.FromMe {
return "me"
}
name := strings.TrimSpace(m.SenderName)
jid := strings.TrimSpace(m.SenderJID)
switch {
case name != "" && jid != "" && name != jid:
return fmt.Sprintf("%s (%s)", name, jid)
case name != "":
return name
case jid != "":
return jid
default:
return "(unknown)"
}
}
func messageText(m store.Message) string {
if m.DeletedForMe {
return store.DeletedForMeMessageDisplayText
}
if m.Revoked {
return store.DeletedMessageDisplayText
}
if text := strings.TrimSpace(m.DisplayText); text != "" {
return text
}
if text := strings.TrimSpace(m.Text); text != "" {
return text
}
if strings.TrimSpace(m.MediaType) != "" {
return "Sent " + messageMediaLabel(m.MediaType)
}
return ""
}
func messageRawText(m store.Message) string {
raw := strings.TrimSpace(m.Text)
if raw == "" || raw == messageText(m) {
return ""
}
return raw
}
func messageContextLine(m store.Message) string {
return messageText(m)
}
func messageMediaLabel(mediaType string) string {
mt := strings.ToLower(strings.TrimSpace(mediaType))
if mt == "" {
return "message"
}
return mt
}

View File

@ -0,0 +1,182 @@
package main
import (
"context"
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"go.mau.fi/whatsmeow/types"
)
func messageChatJIDFilter(ctx context.Context, a *app.App, chat string) ([]string, error) {
chat = strings.TrimSpace(chat)
if chat == "" {
return nil, nil
}
jid, err := wa.ParseUserOrJID(chat)
if err != nil {
return nil, err
}
jids := []types.JID{canonicalMessageFilterJID(jid)}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return jidStrings(jids), nil
}
if err := a.OpenWA(); err != nil {
return jidStrings(jids), nil
}
client := a.WA()
if client == nil {
return jidStrings(jids), nil
}
switch jid.Server {
case types.DefaultUserServer:
jids = append(jids, canonicalMessageFilterJID(client.ResolvePNToLID(ctx, jid)))
case types.HiddenUserServer:
jids = append(jids, canonicalMessageFilterJID(client.ResolveLIDToPN(ctx, jid)))
}
return jidStrings(jids), nil
}
func canonicalMessageFilterJID(jid types.JID) types.JID {
if jid.Server == types.DefaultUserServer {
return jid.ToNonAD()
}
return jid
}
func jidStrings(jids []types.JID) []string {
out := make([]string, 0, len(jids))
seen := make(map[string]struct{}, len(jids))
for _, jid := range jids {
if jid.IsEmpty() {
continue
}
s := jid.String()
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
type lidSenderResolver interface {
ResolveLIDToPN(context.Context, types.JID) types.JID
}
func resolveMessageSenderNames(ctx context.Context, a *app.App, msgs []store.Message) []store.Message {
if len(msgs) == 0 || !messagesNeedSenderResolution(msgs) {
return msgs
}
if _, err := os.Stat(filepath.Join(a.StoreDir(), "session.db")); err != nil {
return msgs
}
if err := a.OpenWA(); err != nil {
return msgs
}
return resolveMessageSenderNamesWith(ctx, a.DB(), a.WA(), msgs)
}
func messagesNeedSenderResolution(msgs []store.Message) bool {
for _, msg := range msgs {
if !msg.FromMe && strings.TrimSpace(msg.SenderName) == "" && strings.HasSuffix(strings.TrimSpace(msg.SenderJID), "@"+types.HiddenUserServer) {
return true
}
}
return false
}
func resolveMessageSenderNamesWith(ctx context.Context, db *store.DB, resolver lidSenderResolver, msgs []store.Message) []store.Message {
if resolver == nil {
return msgs
}
cache := map[string]string{}
for i := range msgs {
if msgs[i].FromMe || strings.TrimSpace(msgs[i].SenderName) != "" {
continue
}
sender := strings.TrimSpace(msgs[i].SenderJID)
if sender == "" {
continue
}
if name, ok := cache[sender]; ok {
msgs[i].SenderName = name
continue
}
name := resolvedSenderName(ctx, db, resolver, sender)
cache[sender] = name
msgs[i].SenderName = name
}
return msgs
}
func resolvedSenderName(ctx context.Context, db *store.DB, resolver lidSenderResolver, sender string) string {
jid, err := types.ParseJID(sender)
if err != nil || jid.Server != types.HiddenUserServer {
return ""
}
pn := resolver.ResolveLIDToPN(ctx, jid)
if pn.IsEmpty() || pn == jid {
return ""
}
contact, err := db.GetContact(pn.String())
if err == nil {
if contact.Alias != "" {
return contact.Alias
}
if contact.Name != "" {
return contact.Name
}
if contact.Phone != "" {
return contact.Phone
}
}
return pn.String()
}
func getMessageByChatFilter(db *store.DB, chatJIDs []string, id string) (store.Message, error) {
var notFound error
for _, chatJID := range chatJIDs {
m, err := db.GetMessage(chatJID, id)
if err == nil {
return m, nil
}
if !isNoRows(err) {
return store.Message{}, err
}
notFound = err
}
if notFound != nil {
return store.Message{}, notFound
}
return store.Message{}, sql.ErrNoRows
}
func getMessageContextByChatFilter(db *store.DB, chatJIDs []string, id string, before, after int) ([]store.Message, error) {
var notFound error
for _, chatJID := range chatJIDs {
msgs, err := db.MessageContext(chatJID, id, before, after)
if err == nil {
return msgs, nil
}
if !isNoRows(err) {
return nil, err
}
notFound = err
}
if notFound != nil {
return nil, notFound
}
return nil, sql.ErrNoRows
}
func isNoRows(err error) bool {
return errors.Is(err, sql.ErrNoRows)
}

471
cmd/wacli/messages_test.go Normal file
View File

@ -0,0 +1,471 @@
package main
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/openclaw/wacli/internal/store"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func TestTruncate(t *testing.T) {
tests := []struct {
input string
max int
want string
}{
{input: "hello", max: 10, want: "hello"},
{input: "hello world", max: 5, want: "hell…"},
{input: "hello", max: 0, want: "hello"},
{input: "ab", max: 1, want: "a"},
{input: "hello\nworld", max: 20, want: "hello world"},
{input: " hello ", max: 20, want: "hello"},
}
for _, tc := range tests {
if got := truncate(tc.input, tc.max); got != tc.want {
t.Fatalf("truncate(%q, %d) = %q, want %q", tc.input, tc.max, got, tc.want)
}
}
}
func TestTruncatePreservesUTF8(t *testing.T) {
got := truncate("🙂🙂🙂", 2)
if got != "🙂…" {
t.Fatalf("truncate emoji = %q, want first rune plus ellipsis", got)
}
if !utf8.ValidString(got) {
t.Fatalf("truncate produced invalid UTF-8: %q", got)
}
}
func TestTruncateForDisplay(t *testing.T) {
const longID = "3EB0B0E8A1B2C3D4E5F6A7B8C9D0"
if got := tableCell(longID, 14, true); got != longID {
t.Fatalf("force full = %q, want %q", got, longID)
}
if got := fullTableOutputWithTTY(false, false); !got {
t.Fatalf("non-TTY should request full output")
}
if got := tableCell(longID, 14, false); got != "3EB0B0E8A1B2C…" {
t.Fatalf("tty truncation = %q", got)
}
}
func TestMessageContextLinePrefersDisplayText(t *testing.T) {
got := messageContextLine(store.Message{
Text: "raw reaction payload",
DisplayText: "Reacted 👍 to hello",
})
if got != "Reacted 👍 to hello" {
t.Fatalf("messageContextLine() = %q", got)
}
}
func TestMessageContextLineFallsBackToText(t *testing.T) {
got := messageContextLine(store.Message{Text: "hello"})
if got != "hello" {
t.Fatalf("messageContextLine() = %q", got)
}
}
func TestMessageContextLineFallsBackToMedia(t *testing.T) {
got := messageContextLine(store.Message{MediaType: "IMAGE"})
if got != "Sent image" {
t.Fatalf("messageContextLine() = %q", got)
}
}
func TestMessageFromPrefersSenderName(t *testing.T) {
got := messageFrom(store.Message{
SenderJID: "123456789@lid",
SenderName: "Alice",
})
if got != "Alice" {
t.Fatalf("messageFrom() = %q, want Alice", got)
}
}
func TestMessageFromDetailIncludesJID(t *testing.T) {
got := messageFromDetail(store.Message{
SenderJID: "123@s.whatsapp.net",
SenderName: "Alice",
})
if got != "Alice (123@s.whatsapp.net)" {
t.Fatalf("messageFromDetail() = %q", got)
}
}
func TestWriteMessagesListFullOutput(t *testing.T) {
msg := store.Message{
ChatJID: "chat@s.whatsapp.net",
SenderJID: "sender@s.whatsapp.net",
MsgID: "3EB0B0E8A1B2C3D4E5F6A7B8C9D0",
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
DisplayText: "Reacted 👍 to hello",
Text: "raw",
}
var truncated bytes.Buffer
if err := writeMessagesList(&truncated, []store.Message{msg}, false); err != nil {
t.Fatalf("writeMessagesList truncated: %v", err)
}
if strings.Contains(truncated.String(), msg.MsgID) {
t.Fatalf("expected truncated ID, got output:\n%s", truncated.String())
}
var full bytes.Buffer
if err := writeMessagesList(&full, []store.Message{msg}, true); err != nil {
t.Fatalf("writeMessagesList full: %v", err)
}
if !strings.Contains(full.String(), msg.MsgID) {
t.Fatalf("expected full ID, got output:\n%s", full.String())
}
if !strings.Contains(full.String(), "Reacted 👍 to hello") {
t.Fatalf("expected display text, got output:\n%s", full.String())
}
}
func TestWriteMessageShowPrefersDisplayTextAndMediaDetails(t *testing.T) {
msg := store.Message{
ChatJID: "chat@s.whatsapp.net",
SenderJID: "sender@s.whatsapp.net",
SenderName: "Alice",
MsgID: "mid",
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Text: "raw payload",
DisplayText: "Reacted 👍 to hello",
MediaType: "image",
MediaCaption: "caption",
Filename: "pic.jpg",
MimeType: "image/jpeg",
LocalPath: "/tmp/pic.jpg",
DownloadedAt: time.Date(2024, 1, 1, 12, 1, 0, 0, time.UTC),
}
var out bytes.Buffer
if err := writeMessageShow(&out, msg); err != nil {
t.Fatalf("writeMessageShow: %v", err)
}
got := out.String()
for _, want := range []string{
"From: Alice (sender@s.whatsapp.net)",
"Caption: caption",
"Filename: pic.jpg",
"MIME type: image/jpeg",
"Downloaded: /tmp/pic.jpg",
"Reacted 👍 to hello",
"Raw text:\nraw payload",
} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestMessagesSearchCommandExposesMediaFilters(t *testing.T) {
cmd := newMessagesSearchCmd(&rootFlags{})
for _, name := range []string{"has-media", "type", "forwarded", "starred"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
if got := cmd.Flags().Lookup("type").Usage; !strings.Contains(got, "text|image|video|audio|document") {
t.Fatalf("type usage = %q", got)
}
}
func TestMessagesListCommandExposesMessageFilters(t *testing.T) {
cmd := newMessagesListCmd(&rootFlags{})
for _, name := range []string{"forwarded", "starred"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestMessagesStarredCommandExposesFilters(t *testing.T) {
cmd := newMessagesStarredCmd(&rootFlags{})
for _, name := range []string{"chat", "limit", "after", "before", "asc"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestMessagesExportCommandExposesDateFilters(t *testing.T) {
cmd := newMessagesExportCmd(&rootFlags{})
for _, name := range []string{"after", "before", "output"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestMessagesMutationCommandsExposeSafetyFlags(t *testing.T) {
for _, cmd := range []*cobra.Command{
newMessagesDeleteCmd(&rootFlags{}),
newMessagesEditCmd(&rootFlags{}),
} {
for _, name := range []string{"chat", "id", "post-send-wait"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("%s missing --%s", cmd.Name(), name)
}
}
}
if newMessagesEditCmd(&rootFlags{}).Flags().Lookup("message") == nil {
t.Fatalf("edit missing --message")
}
}
func TestMessagesDeleteRejectsReadOnlyBeforeOpeningStore(t *testing.T) {
cmd := newMessagesDeleteCmd(&rootFlags{readOnly: true})
cmd.SetArgs([]string{"--chat", "+15551234567", "--id", "mid"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "read-only mode") {
t.Fatalf("error = %v, want read-only", err)
}
}
func TestMessagesEditValidation(t *testing.T) {
now := time.Date(2024, 1, 1, 12, 30, 0, 0, time.UTC)
msg := store.Message{
MsgID: "mid",
Timestamp: now.Add(-time.Minute),
FromMe: true,
Text: "old",
}
if err := validateMessageCanEdit(msg, now); err != nil {
t.Fatalf("validateMessageCanEdit: %v", err)
}
msg.FromMe = false
if err := validateMessageCanEdit(msg, now); err == nil || !strings.Contains(err.Error(), "not sent by me") {
t.Fatalf("from-them error = %v", err)
}
msg.FromMe = true
msg.DeletedForMe = true
msg.Timestamp = now.Add(-time.Minute)
if err := validateMessageCanEdit(msg, now); err == nil || !strings.Contains(err.Error(), "deleted for me") {
t.Fatalf("deleted-for-me error = %v", err)
}
msg.DeletedForMe = false
msg.Timestamp = now.Add(-21 * time.Minute)
if err := validateMessageCanEdit(msg, now); err == nil || !strings.Contains(err.Error(), "edit window") {
t.Fatalf("old message error = %v", err)
}
}
func TestMessagesDeleteForMeValidation(t *testing.T) {
msg := store.Message{MsgID: "mid", FromMe: false}
if err := validateMessageCanDeleteForMe(msg); err != nil {
t.Fatalf("validateMessageCanDeleteForMe: %v", err)
}
if err := validateMessageCanRevoke(msg); err == nil || !strings.Contains(err.Error(), "not sent by me") {
t.Fatalf("revoke from-them error = %v", err)
}
msg.DeletedForMe = true
if err := validateMessageCanDeleteForMe(msg); err == nil || !strings.Contains(err.Error(), "deleted for me") {
t.Fatalf("deleted-for-me error = %v", err)
}
}
func TestMessagesExportCommandAppliesDateFilters(t *testing.T) {
storeDir := t.TempDir()
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
chat := "chat@s.whatsapp.net"
base := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat(chat, "dm", "Alice", base); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
for _, row := range []store.UpsertMessageParams{
{ChatJID: chat, MsgID: "before", SenderJID: chat, Timestamp: base, Text: "before"},
{ChatJID: chat, MsgID: "inside-1", SenderJID: chat, Timestamp: base.Add(time.Second), Text: "inside 1"},
{ChatJID: chat, MsgID: "inside-2", SenderJID: chat, Timestamp: base.Add(2 * time.Second), Text: "inside 2"},
{ChatJID: chat, MsgID: "after", SenderJID: chat, Timestamp: base.Add(3 * time.Second), Text: "after"},
} {
if err := db.UpsertMessage(row); err != nil {
t.Fatalf("UpsertMessage %s: %v", row.MsgID, err)
}
}
if err := db.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
output := filepath.Join(storeDir, "export.json")
cmd := newMessagesExportCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{
"--chat", chat,
"--after", base.Format(time.RFC3339),
"--before", base.Add(3 * time.Second).Format(time.RFC3339),
"--output", output,
"--limit", "10",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("messages export: %v", err)
}
raw, err := os.ReadFile(output)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
info, err := os.Stat(output)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("output mode = %04o, want 0600", got)
}
var got struct {
Success bool `json:"success"`
Data struct {
Messages []store.Message `json:"messages"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("Unmarshal export: %v\n%s", err, string(raw))
}
if !got.Success {
t.Fatalf("success = false")
}
if gotIDs := messageIDs(got.Data.Messages); gotIDs != "inside-1,inside-2" {
t.Fatalf("exported ids = %s", gotIDs)
}
}
func TestWriteMessageShowIncludesForwardedMetadata(t *testing.T) {
msg := store.Message{
ChatJID: "chat@s.whatsapp.net",
SenderJID: "sender@s.whatsapp.net",
MsgID: "mid",
Timestamp: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC),
Text: "hello",
IsForwarded: true,
ForwardingScore: 3,
}
var out bytes.Buffer
if err := writeMessageShow(&out, msg); err != nil {
t.Fatalf("writeMessageShow: %v", err)
}
if !strings.Contains(out.String(), "Forwarded: yes") {
t.Fatalf("expected forwarded marker, got:\n%s", out.String())
}
if !strings.Contains(out.String(), "Forwarding score: 3") {
t.Fatalf("expected forwarding score, got:\n%s", out.String())
}
}
func TestGetMessageByChatFilterTriesMappedChatJIDs(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
pn := "15551234567@s.whatsapp.net"
lid := "123456789@lid"
now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
for _, jid := range []string{pn, lid} {
if err := db.UpsertChat(jid, "dm", jid, now); err != nil {
t.Fatalf("UpsertChat %s: %v", jid, err)
}
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: lid,
MsgID: "mid",
SenderJID: lid,
Timestamp: now,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
msg, err := getMessageByChatFilter(db, []string{pn, lid}, "mid")
if err != nil {
t.Fatalf("getMessageByChatFilter: %v", err)
}
if msg.ChatJID != lid {
t.Fatalf("ChatJID = %q, want %q", msg.ChatJID, lid)
}
msgs, err := getMessageContextByChatFilter(db, []string{pn, lid}, "mid", 1, 1)
if err != nil {
t.Fatalf("getMessageContextByChatFilter: %v", err)
}
if len(msgs) != 1 || msgs[0].ChatJID != lid {
t.Fatalf("context = %+v", msgs)
}
}
func TestResolveMessageSenderNamesUsesLIDMappingAndContacts(t *testing.T) {
db, err := store.Open(filepath.Join(t.TempDir(), "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
defer db.Close()
pn := "15551234567@s.whatsapp.net"
lid := "123456789@lid"
if err := db.UpsertContact(pn, "+15551234567", "", "Alice", "", ""); err != nil {
t.Fatalf("UpsertContact: %v", err)
}
resolver := fakeLIDResolver{lid: mustParseJID(t, lid), pn: mustParseJID(t, pn)}
msgs := resolveMessageSenderNamesWith(context.Background(), db, resolver, []store.Message{
{SenderJID: lid, Text: "hello"},
{SenderJID: "someone@s.whatsapp.net", Text: "plain"},
{SenderJID: lid, SenderName: "Existing", Text: "kept"},
})
if msgs[0].SenderName != "Alice" {
t.Fatalf("resolved SenderName = %q, want Alice", msgs[0].SenderName)
}
if msgs[1].SenderName != "" {
t.Fatalf("non-LID SenderName = %q, want empty", msgs[1].SenderName)
}
if msgs[2].SenderName != "Existing" {
t.Fatalf("existing SenderName = %q", msgs[2].SenderName)
}
}
type fakeLIDResolver struct {
lid types.JID
pn types.JID
}
func (f fakeLIDResolver) ResolveLIDToPN(ctx context.Context, jid types.JID) types.JID {
if jid == f.lid {
return f.pn
}
return jid
}
func mustParseJID(t *testing.T, s string) types.JID {
t.Helper()
jid, err := types.ParseJID(s)
if err != nil {
t.Fatalf("ParseJID(%q): %v", s, err)
}
return jid
}
func messageIDs(msgs []store.Message) string {
ids := make([]string, 0, len(msgs))
for _, msg := range msgs {
ids = append(ids, msg.MsgID)
}
return strings.Join(ids, ",")
}

115
cmd/wacli/presence.go Normal file
View File

@ -0,0 +1,115 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func newPresenceCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "presence",
Short: "Send presence indicators (typing, paused)",
}
cmd.AddCommand(newPresenceTypingCmd(flags))
cmd.AddCommand(newPresencePausedCmd(flags))
return cmd
}
func newPresenceTypingCmd(flags *rootFlags) *cobra.Command {
var to string
var media string
cmd := &cobra.Command{
Use: "typing",
Short: "Send a 'composing' (typing) indicator to a chat",
RunE: func(cmd *cobra.Command, args []string) error {
return runPresence(flags, to, types.ChatPresenceComposing, media)
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
cmd.Flags().StringVar(&media, "media", "", "media type: 'audio' for recording indicator (default: typing text)")
return cmd
}
func newPresencePausedCmd(flags *rootFlags) *cobra.Command {
var to string
cmd := &cobra.Command{
Use: "paused",
Short: "Send a 'paused' indicator (stop typing) to a chat",
RunE: func(cmd *cobra.Command, args []string) error {
return runPresence(flags, to, types.ChatPresencePaused, "")
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
return cmd
}
func runPresence(flags *rootFlags, to string, state types.ChatPresence, media string) error {
if strings.TrimSpace(to) == "" {
return fmt.Errorf("--to is required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
toJID, err := wa.ParseUserOrJID(to)
if err != nil {
return err
}
chatMedia, err := presenceMediaFromString(media)
if err != nil {
return err
}
if err := a.WA().SendChatPresence(ctx, toJID, state, chatMedia); err != nil {
return err
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
"to": toJID.String(),
"state": string(state),
})
}
fmt.Fprintf(os.Stdout, "Presence '%s' sent to %s\n", state, toJID.String())
return nil
}
func presenceMediaFromString(media string) (types.ChatPresenceMedia, error) {
switch strings.ToLower(strings.TrimSpace(media)) {
case "":
return "", nil
case "audio":
return types.ChatPresenceMediaAudio, nil
default:
return "", fmt.Errorf("unsupported --media %q (supported: audio)", media)
}
}

View File

@ -0,0 +1,39 @@
package main
import (
"testing"
"go.mau.fi/whatsmeow/types"
)
func TestPresenceMediaFromString(t *testing.T) {
tests := []struct {
name string
input string
want types.ChatPresenceMedia
wantErr bool
}{
{name: "empty", input: "", want: ""},
{name: "audio", input: "audio", want: types.ChatPresenceMediaAudio},
{name: "trimmed case", input: " Audio ", want: types.ChatPresenceMediaAudio},
{name: "unknown", input: "video", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := presenceMediaFromString(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("presenceMediaFromString(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

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

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

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

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

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

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

View File

@ -6,21 +6,29 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/config"
"github.com/openclaw/wacli/internal/lock"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/config"
"github.com/steipete/wacli/internal/lock"
"github.com/steipete/wacli/internal/out"
)
var version = "dev"
var version = "0.8.1"
const docsURL = "https://wacli.sh"
type rootFlags struct {
storeDir string
asJSON bool
timeout time.Duration
storeDir string
account string
asJSON bool
fullOutput bool
events bool
timeout time.Duration
readOnly bool
lockWait time.Duration
}
func execute(args []string) error {
@ -28,17 +36,25 @@ func execute(args []string) error {
rootCmd := &cobra.Command{
Use: "wacli",
Short: "WhatsApp CLI: sync, search, send",
Long: "wacli is a WhatsApp CLI for syncing, searching, and sending from local scripts.\n\nDocs: " + docsURL,
SilenceUsage: true,
SilenceErrors: true,
Version: version,
}
rootCmd.SetVersionTemplate("wacli {{.Version}}\n")
rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: ~/.wacli)")
rootCmd.PersistentFlags().StringVar(&flags.storeDir, "store", "", "store directory (default: $WACLI_STORE_DIR, XDG state dir on Linux, or ~/.wacli)")
rootCmd.PersistentFlags().StringVar(&flags.account, "account", "", "named account from config.yaml")
rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "output JSON instead of human-readable text")
rootCmd.PersistentFlags().BoolVar(&flags.fullOutput, "full", false, "disable truncation in table output")
rootCmd.PersistentFlags().BoolVar(&flags.events, "events", false, "emit machine-readable NDJSON lifecycle events on stderr")
rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 5*time.Minute, "command timeout (non-sync commands)")
rootCmd.PersistentFlags().DurationVar(&flags.lockWait, "lock-wait", 0, "wait for the store lock before failing (write commands)")
rootCmd.PersistentFlags().BoolVar(&flags.readOnly, "read-only", false, "reject commands that intentionally write WhatsApp or the local store (or set WACLI_READONLY=1)")
rootCmd.AddCommand(newVersionCmd())
rootCmd.AddCommand(newAccountsCmd(&flags))
rootCmd.AddCommand(newDoctorCmd(&flags))
rootCmd.AddCommand(newAuthCmd(&flags))
rootCmd.AddCommand(newSyncCmd(&flags))
@ -48,27 +64,41 @@ func execute(args []string) error {
rootCmd.AddCommand(newContactsCmd(&flags))
rootCmd.AddCommand(newChatsCmd(&flags))
rootCmd.AddCommand(newGroupsCmd(&flags))
rootCmd.AddCommand(newChannelsCmd(&flags))
rootCmd.AddCommand(newHistoryCmd(&flags))
rootCmd.AddCommand(newPresenceCmd(&flags))
rootCmd.AddCommand(newProfileCmd(&flags))
rootCmd.AddCommand(newDocsCmd(&flags))
rootCmd.AddCommand(newStoreCmd(&flags))
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {
_ = out.WriteError(os.Stderr, flags.asJSON, err)
writeRootError(flags, err)
return err
}
return nil
}
func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) {
storeDir := flags.storeDir
if storeDir == "" {
storeDir = config.DefaultStoreDir()
func writeRootError(flags rootFlags, err error) {
if err == nil {
return
}
if flags.events {
_ = out.NewEventWriter(os.Stderr, true).Emit("error", map[string]any{"message": err.Error()})
return
}
_ = out.WriteError(os.Stderr, flags.asJSON, err)
}
func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed bool) (*app.App, *lock.Lock, error) {
storeDir, err := resolveStoreDir(flags)
if err != nil {
return nil, nil, err
}
storeDir, _ = filepath.Abs(storeDir)
var lk *lock.Lock
if needLock {
var err error
lk, err = lock.Acquire(storeDir)
lk, err = lock.AcquireWithTimeout(ctx, storeDir, flags.lockWait)
if err != nil {
return nil, nil, err
}
@ -78,6 +108,7 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed
StoreDir: storeDir,
Version: version,
JSON: flags.asJSON,
Events: out.NewEventWriter(os.Stderr, flags.events),
AllowUnauthed: allowUnauthed,
})
if err != nil {
@ -90,6 +121,67 @@ func newApp(ctx context.Context, flags *rootFlags, needLock bool, allowUnauthed
return a, lk, nil
}
func resolveStoreDir(flags *rootFlags) (string, error) {
storeDir := ""
account := ""
if flags != nil {
storeDir = flags.storeDir
account = strings.TrimSpace(flags.account)
}
if storeDir != "" && account != "" {
return "", fmt.Errorf("--store and --account cannot be combined")
}
switch {
case storeDir != "":
case account != "":
resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), account)
if err != nil {
return "", err
}
storeDir = resolved
case os.Getenv(config.EnvStoreDir) != "":
storeDir = config.DefaultStoreDir()
default:
cfg, found, err := config.LoadAccountsConfigIfExists(config.DefaultConfigPath())
if err != nil {
return "", err
}
if found && strings.TrimSpace(cfg.DefaultAccount) != "" {
resolved, _, err := config.ResolveAccountStore(config.DefaultConfigPath(), cfg.DefaultAccount)
if err != nil {
return "", err
}
storeDir = resolved
} else {
storeDir = config.DefaultStoreDir()
}
}
storeDir, _ = filepath.Abs(storeDir)
return storeDir, nil
}
func (f *rootFlags) isReadOnly() bool {
if f == nil {
return false
}
if f.readOnly {
return true
}
switch strings.ToLower(strings.TrimSpace(os.Getenv("WACLI_READONLY"))) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func (f *rootFlags) requireWritable() error {
if f.isReadOnly() {
return fmt.Errorf("read-only mode: command would intentionally modify WhatsApp or the local store")
}
return nil
}
func withTimeout(ctx context.Context, flags *rootFlags) (context.Context, context.CancelFunc) {
if flags.timeout <= 0 {
return context.WithCancel(ctx)

159
cmd/wacli/root_test.go Normal file
View File

@ -0,0 +1,159 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/wacli/internal/config"
)
func captureRootStderr(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stderr = w
defer func() { os.Stderr = orig }()
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()
fn()
_ = w.Close()
return <-done
}
func captureRootStdout(t *testing.T, fn func()) string {
t.Helper()
orig := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
os.Stdout = w
defer func() { os.Stdout = orig }()
done := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
done <- buf.String()
}()
fn()
_ = w.Close()
return <-done
}
func TestWriteRootErrorEventsUsesNDJSON(t *testing.T) {
raw := captureRootStderr(t, func() {
writeRootError(rootFlags{events: true}, errors.New("boom"))
})
var evt struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
t.Fatalf("root error was not NDJSON: %q: %v", raw, err)
}
if evt.Event != "error" {
t.Fatalf("event = %q, want error", evt.Event)
}
if evt.Data["message"] != "boom" {
t.Fatalf("message = %#v, want boom", evt.Data["message"])
}
}
func TestRootFlagsReadOnlyFlag(t *testing.T) {
flags := &rootFlags{readOnly: true}
if !flags.isReadOnly() {
t.Fatal("isReadOnly = false, want true")
}
err := flags.requireWritable()
if err == nil || !strings.Contains(err.Error(), "read-only mode") {
t.Fatalf("requireWritable error = %v", err)
}
}
func TestRootFlagsReadOnlyEnv(t *testing.T) {
t.Setenv("WACLI_READONLY", "yes")
if !(&rootFlags{}).isReadOnly() {
t.Fatal("isReadOnly = false, want true")
}
}
func TestResolveStoreDirAccount(t *testing.T) {
isolateAccountConfigHome(t)
cfgPath := config.DefaultConfigPath()
cfg := &config.AccountsConfig{
Accounts: map[string]config.AccountEntry{
"work": {Store: "accounts/work"},
},
}
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
t.Fatal(err)
}
got, err := resolveStoreDir(&rootFlags{account: "work"})
if err != nil {
t.Fatalf("resolveStoreDir: %v", err)
}
want := filepath.Join(filepath.Dir(cfgPath), "accounts", "work")
if got != want {
t.Fatalf("storeDir = %q, want %q", got, want)
}
}
func TestResolveStoreDirStoreAndAccountConflict(t *testing.T) {
_, err := resolveStoreDir(&rootFlags{storeDir: "/tmp/wacli", account: "work"})
if err == nil || !strings.Contains(err.Error(), "cannot be combined") {
t.Fatalf("resolveStoreDir error = %v, want conflict", err)
}
}
func TestResolveStoreDirEnvBeatsDefaultAccount(t *testing.T) {
isolateAccountConfigHome(t)
cfgPath := config.DefaultConfigPath()
cfg := &config.AccountsConfig{
DefaultAccount: "work",
Accounts: map[string]config.AccountEntry{
"work": {Store: "accounts/work"},
},
}
if err := config.SaveAccountsConfig(cfgPath, cfg); err != nil {
t.Fatal(err)
}
envStore := filepath.Join(t.TempDir(), "env-store")
t.Setenv(config.EnvStoreDir, envStore)
got, err := resolveStoreDir(&rootFlags{})
if err != nil {
t.Fatalf("resolveStoreDir: %v", err)
}
if got != envStore {
t.Fatalf("storeDir = %q, want %q", got, envStore)
}
}
func isolateAccountConfigHome(t *testing.T) {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
t.Setenv(config.EnvStoreDir, "")
}

View File

@ -2,14 +2,22 @@ package main
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/linkpreview"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
)
func newSendCmd(flags *rootFlags) *cobra.Command {
@ -19,12 +27,22 @@ func newSendCmd(flags *rootFlags) *cobra.Command {
}
cmd.AddCommand(newSendTextCmd(flags))
cmd.AddCommand(newSendFileCmd(flags))
cmd.AddCommand(newSendStickerCmd(flags))
cmd.AddCommand(newSendVoiceCmd(flags))
cmd.AddCommand(newSendReactCmd(flags))
return cmd
}
func newSendTextCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var message string
var mentions []string
var replyTo string
var replyToSender string
var noPreview bool
var messageEscapes bool
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "text",
@ -33,12 +51,39 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
if to == "" || message == "" {
return fmt.Errorf("--to and --message are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
if messageEscapes {
decoded, err := decodeMessageEscapes(message)
if err != nil {
return err
}
message = decoded
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "text",
To: to,
Pick: pick,
Message: message,
Mentions: mentions,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
NoPreview: noPreview,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "text", resp)
}
return err
}
defer closeApp(a, lk)
@ -46,16 +91,26 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
toJID, err := wa.ParseUserOrJID(to)
toJID, err := resolveRecipient(a, to, recipientOptions{pick: pick, asJSON: flags.asJSON})
if err != nil {
return err
}
mentionedJIDs, err := parseMentionedJIDs(mentions)
if err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
msgID, err := a.WA().SendText(ctx, toJID, message)
preview := fetchLinkPreview(ctx, message, noPreview)
msgID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender, preview, mentionedJIDs)
})
if err != nil {
return err
}
@ -76,6 +131,8 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
Text: message,
})
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
@ -88,7 +145,202 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient JID, phone number, or contact/group/chat name")
cmd.Flags().IntVar(&pick, "pick", 0, "when --to is ambiguous, pick the Nth match (1-indexed)")
cmd.Flags().StringVar(&message, "message", "", "message text")
cmd.Flags().StringArrayVar(&mentions, "mention", nil, "phone number or user JID to mention (repeatable)")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "message ID to quote/reply to")
cmd.Flags().StringVar(&replyToSender, "reply-to-sender", "", "sender JID of the quoted message (required for unsynced group replies)")
cmd.Flags().BoolVar(&noPreview, "no-preview", false, "disable automatic link previews for the first URL in text")
cmd.Flags().BoolVar(&messageEscapes, "message-escapes", false, `interpret backslash escapes in --message (\n, \r, \t, \\, \")`)
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}
type sendTextApp interface {
WA() app.WAClient
DB() *store.DB
}
func sendTextMessage(ctx context.Context, a sendTextApp, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview, mentionedJIDs []string) (types.MessageID, error) {
msg, plainText, err := buildTextMessage(a.DB(), to, text, replyTo, replyToSender, preview, mentionedJIDs)
if err != nil {
return "", err
}
if plainText {
return a.WA().SendText(ctx, to, text)
}
return a.WA().SendProtoMessage(ctx, to, msg)
}
func fetchLinkPreview(ctx context.Context, text string, disabled bool) *linkpreview.Preview {
if disabled {
return nil
}
rawURL := linkpreview.FindFirstHTTPURL(text)
if rawURL == "" {
return nil
}
previewCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
preview, err := linkpreview.Fetch(previewCtx, nil, rawURL)
if err != nil {
return nil
}
return preview
}
func decodeMessageEscapes(s string) (string, error) {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] != '\\' {
b.WriteByte(s[i])
continue
}
i++
if i >= len(s) {
return "", fmt.Errorf(`unfinished escape sequence in --message; supported escapes: \n, \r, \t, \\, \"`)
}
switch s[i] {
case 'n':
b.WriteByte('\n')
case 'r':
b.WriteByte('\r')
case 't':
b.WriteByte('\t')
case '\\':
b.WriteByte('\\')
case '"':
b.WriteByte('"')
default:
return "", fmt.Errorf(`unsupported escape sequence \%c in --message; supported escapes: \n, \r, \t, \\, \"`, s[i])
}
}
return b.String(), nil
}
func buildTextMessage(db *store.DB, to types.JID, text, replyTo, replyToSender string, preview *linkpreview.Preview, mentionedJIDs []string) (*waProto.Message, bool, error) {
info, err := buildTextContextInfo(db, to, replyTo, replyToSender, mentionedJIDs)
if err != nil {
return nil, false, err
}
if info == nil && preview == nil {
return nil, true, nil
}
ext := &waProto.ExtendedTextMessage{
Text: proto.String(text),
ContextInfo: info,
}
attachLinkPreview(ext, preview)
return &waProto.Message{ExtendedTextMessage: ext}, false, nil
}
func attachLinkPreview(msg *waProto.ExtendedTextMessage, preview *linkpreview.Preview) {
if preview == nil {
return
}
if preview.URL != "" {
msg.MatchedText = proto.String(preview.URL)
}
if preview.Title != "" {
msg.Title = proto.String(preview.Title)
}
if preview.Description != "" {
msg.Description = proto.String(preview.Description)
}
if len(preview.Thumbnail) > 0 {
msg.PreviewType = waProto.ExtendedTextMessage_IMAGE.Enum()
msg.JPEGThumbnail = preview.Thumbnail
return
}
msg.PreviewType = waProto.ExtendedTextMessage_NONE.Enum()
}
func buildTextContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string, mentionedJIDs []string) (*waProto.ContextInfo, error) {
info, err := buildReplyContextInfo(db, chat, replyTo, replyToSender)
if err != nil {
return nil, err
}
if len(mentionedJIDs) == 0 {
return info, nil
}
if info == nil {
info = &waProto.ContextInfo{}
}
info.MentionedJID = append([]string(nil), mentionedJIDs...)
return info, nil
}
func buildReplyContextInfo(db *store.DB, chat types.JID, replyTo, replyToSender string) (*waProto.ContextInfo, error) {
replyTo = strings.TrimSpace(replyTo)
if replyTo == "" {
return nil, nil
}
sender, err := resolveReplySender(db, chat, replyTo, replyToSender)
if err != nil {
return nil, err
}
stanzaID := replyTo
info := &waProto.ContextInfo{StanzaID: proto.String(stanzaID)}
if !sender.IsEmpty() {
participant := sender.String()
info.Participant = proto.String(participant)
}
return info, nil
}
func resolveReplySender(db *store.DB, chat types.JID, replyTo, override string) (types.JID, error) {
if strings.TrimSpace(override) != "" {
jid, err := wa.ParseUserOrJID(override)
if err != nil {
return types.JID{}, fmt.Errorf("invalid --reply-to-sender: %w", err)
}
return jid, nil
}
msg, err := db.GetMessage(chat.String(), replyTo)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return types.JID{}, fmt.Errorf("lookup quoted message: %w", err)
}
if err == nil && strings.TrimSpace(msg.SenderJID) != "" {
jid, err := types.ParseJID(msg.SenderJID)
if err != nil {
return types.JID{}, fmt.Errorf("stored quoted sender is invalid: %w", err)
}
return jid, nil
}
if chat.Server == types.GroupServer {
return types.JID{}, fmt.Errorf("--reply-to-sender is required for unsynced group replies")
}
return types.JID{}, nil
}
func parseMentionedJIDs(values []string) ([]string, error) {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
jid, err := wa.ParseUserOrJID(value)
if err != nil {
return nil, fmt.Errorf("invalid --mention: %w", err)
}
if jid.Server == types.GroupServer {
return nil, fmt.Errorf("invalid --mention %q: mentions must target a user phone number or user JID", value)
}
normalized := jid.String()
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
return out, nil
}

View File

@ -1,46 +1,70 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"image"
"image/color"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"math"
"mime"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/store"
"github.com/steipete/wacli/internal/wa"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
)
const maxSendFileSize = 100 * 1024 * 1024
const imageThumbnailMaxDimension = 96
const voiceWaveformSamples = 64
const voiceWaveformMax = 100
type sendFileOptions struct {
filename string
caption string
mimeOverride string
replyTo string
replyToSender string
ptt bool
}
type voiceNoteMetadata struct {
seconds uint32
waveform []byte
}
func sendFile(ctx context.Context, a interface {
WA() app.WAClient
DB() *store.DB
}, to types.JID, filePath, filename, caption, mimeOverride string) (string, map[string]string, error) {
data, err := os.ReadFile(filePath)
}, to types.JID, filePath string, opts sendFileOptions) (string, map[string]string, error) {
data, err := readSendFileData(filePath)
if err != nil {
return "", nil, err
}
name := strings.TrimSpace(filename)
name := strings.TrimSpace(opts.filename)
if name == "" {
name = filepath.Base(filePath)
}
mimeType := strings.TrimSpace(mimeOverride)
if mimeType == "" {
// Use filePath for MIME detection, not the display name override
mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))
}
if mimeType == "" {
sniff := data
if len(sniff) > 512 {
sniff = sniff[:512]
}
mimeType = http.DetectContentType(sniff)
mimeType := detectSendFileMIME(filePath, opts.mimeOverride, data)
if opts.ptt && !isOggOpusMIME(mimeType) {
return "", nil, fmt.Errorf("voice notes require OGG Opus audio; got %s", mimeType)
}
mediaType := "document"
@ -57,26 +81,45 @@ func sendFile(ctx context.Context, a interface {
uploadType, _ = wa.MediaTypeFromString("audio")
}
up, err := a.WA().Upload(ctx, data, uploadType)
isNewsletter := to.Server == types.NewsletterServer
if isNewsletter && opts.ptt {
return "", nil, fmt.Errorf("voice-note mode is not supported for channels; omit --ptt to send audio")
}
if isNewsletter && (strings.TrimSpace(opts.replyTo) != "" || strings.TrimSpace(opts.replyToSender) != "") {
return "", nil, fmt.Errorf("quoted file replies are not supported for channels")
}
var up whatsmeow.UploadResponse
if isNewsletter {
up, err = a.WA().UploadNewsletter(ctx, data, uploadType)
} else {
up, err = a.WA().Upload(ctx, data, uploadType)
}
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
msg := &waProto.Message{}
var replyContext *waProto.ContextInfo
if !isNewsletter {
replyContext, err = buildReplyContextInfo(a.DB(), to, opts.replyTo, opts.replyToSender)
if err != nil {
return "", nil, err
}
}
voiceMeta := voiceNoteMetadata{}
if opts.ptt {
voiceMeta = loadVoiceNoteMetadata(ctx, filePath)
}
switch mediaType {
case "image":
msg.ImageMessage = &waProto.ImageMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(caption),
imageMsg, err := newImageMessage(up, mimeType, opts.caption, data)
if err != nil {
return "", nil, err
}
msg.ImageMessage = imageMsg
case "video":
msg.VideoMessage = &waProto.VideoMessage{
URL: proto.String(up.URL),
@ -86,19 +129,10 @@ func sendFile(ctx context.Context, a interface {
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(caption),
Caption: proto.String(opts.caption),
}
case "audio":
msg.AudioMessage = &waProto.AudioMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
PTT: proto.Bool(false),
}
msg.AudioMessage = newAudioMessage(up, mimeType, opts.ptt, voiceMeta)
default:
msg.DocumentMessage = &waProto.DocumentMessage{
URL: proto.String(up.URL),
@ -109,12 +143,18 @@ func sendFile(ctx context.Context, a interface {
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
FileName: proto.String(name),
Caption: proto.String(caption),
Caption: proto.String(opts.caption),
Title: proto.String(name),
}
}
attachSendFileReplyContext(msg, replyContext)
id, err := a.WA().SendProtoMessage(ctx, to, msg)
var id types.MessageID
if isNewsletter {
id, err = a.WA().SendProtoMessageWithExtra(ctx, to, msg, up.Handle)
} else {
id, err = a.WA().SendProtoMessage(ctx, to, msg)
}
if err != nil {
return "", nil, err
}
@ -130,9 +170,9 @@ func sendFile(ctx context.Context, a interface {
SenderName: "me",
Timestamp: now,
FromMe: true,
Text: caption,
Text: opts.caption,
MediaType: mediaType,
MediaCaption: caption,
MediaCaption: opts.caption,
Filename: name,
MimeType: mimeType,
DirectPath: up.DirectPath,
@ -146,10 +186,140 @@ func sendFile(ctx context.Context, a interface {
"name": name,
"mime_type": mimeType,
"media": mediaType,
"ptt": strconv.FormatBool(opts.ptt),
}, nil
}
func newImageMessage(up whatsmeow.UploadResponse, mimeType, caption string, data []byte) (*waProto.ImageMessage, error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("invalid image data: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, fmt.Errorf("invalid image dimensions: %dx%d", cfg.Width, cfg.Height)
}
msg := &waProto.ImageMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
Caption: proto.String(caption),
Height: proto.Uint32(uint32(cfg.Height)),
Width: proto.Uint32(uint32(cfg.Width)),
}
if thumbnail, err := imageJPEGThumbnail(data); err == nil && len(thumbnail) > 0 {
msg.JPEGThumbnail = thumbnail
}
return msg, nil
}
func imageJPEGThumbnail(data []byte) ([]byte, error) {
src, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
bounds := src.Bounds()
srcW, srcH := bounds.Dx(), bounds.Dy()
if srcW <= 0 || srcH <= 0 {
return nil, fmt.Errorf("invalid image dimensions: %dx%d", srcW, srcH)
}
dstW, dstH := scaledDimensions(srcW, srcH, imageThumbnailMaxDimension)
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.Draw(dst, dst.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
for y := 0; y < dstH; y++ {
for x := 0; x < dstW; x++ {
srcX := bounds.Min.X + x*srcW/dstW
srcY := bounds.Min.Y + y*srcH/dstH
dst.Set(x, y, src.At(srcX, srcY))
}
}
var out bytes.Buffer
if err := jpeg.Encode(&out, dst, &jpeg.Options{Quality: 75}); err != nil {
return nil, err
}
return out.Bytes(), nil
}
func scaledDimensions(width, height, maxDimension int) (int, int) {
if width <= 0 || height <= 0 {
return 0, 0
}
if maxDimension <= 0 || (width <= maxDimension && height <= maxDimension) {
return width, height
}
if width >= height {
scaledHeight := height * maxDimension / width
if scaledHeight < 1 {
scaledHeight = 1
}
return maxDimension, scaledHeight
}
scaledWidth := width * maxDimension / height
if scaledWidth < 1 {
scaledWidth = 1
}
return scaledWidth, maxDimension
}
func newAudioMessage(up whatsmeow.UploadResponse, mimeType string, ptt bool, meta voiceNoteMetadata) *waProto.AudioMessage {
msg := &waProto.AudioMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(mimeType),
PTT: proto.Bool(ptt),
}
if ptt {
if meta.seconds > 0 {
msg.Seconds = proto.Uint32(meta.seconds)
}
if len(meta.waveform) == voiceWaveformSamples {
msg.Waveform = meta.waveform
}
}
return msg
}
func readSendFileData(filePath string) ([]byte, error) {
info, err := os.Stat(filePath)
if err != nil {
return nil, err
}
if info.Size() > maxSendFileSize {
return nil, fmt.Errorf("file too large (%d bytes); maximum send file size is %d bytes", info.Size(), maxSendFileSize)
}
return os.ReadFile(filePath)
}
func attachSendFileReplyContext(msg *waProto.Message, info *waProto.ContextInfo) {
if info == nil {
return
}
switch {
case msg.GetImageMessage() != nil:
msg.ImageMessage.ContextInfo = info
case msg.GetVideoMessage() != nil:
msg.VideoMessage.ContextInfo = info
case msg.GetAudioMessage() != nil:
msg.AudioMessage.ContextInfo = info
case msg.GetDocumentMessage() != nil:
msg.DocumentMessage.ContextInfo = info
}
}
func chatKindFromJID(j types.JID) string {
if j.Server == types.NewsletterServer {
return "newsletter"
}
if j.Server == types.GroupServer {
return "group"
}
@ -161,3 +331,122 @@ func chatKindFromJID(j types.JID) string {
}
return "unknown"
}
func detectSendFileMIME(filePath, mimeOverride string, data []byte) string {
mimeType := strings.TrimSpace(mimeOverride)
if mimeType == "" {
// Use filePath for MIME detection, not the display name override.
mimeType = mime.TypeByExtension(strings.ToLower(filepath.Ext(filePath)))
}
if mimeType == "" {
sniff := data
if len(sniff) > 512 {
sniff = sniff[:512]
}
mimeType = http.DetectContentType(sniff)
}
if mimeType == "audio/ogg" || mimeType == "application/ogg" {
return "audio/ogg; codecs=opus"
}
return mimeType
}
func isOggOpusMIME(mimeType string) bool {
mediaType, params, err := mime.ParseMediaType(mimeType)
if err != nil {
return false
}
codecs := strings.ToLower(params["codecs"])
return mediaType == "audio/ogg" && strings.Contains(codecs, "opus")
}
func loadVoiceNoteMetadata(ctx context.Context, filePath string) voiceNoteMetadata {
return voiceNoteMetadata{
seconds: probeAudioSeconds(ctx, filePath),
waveform: probeAudioWaveform(ctx, filePath),
}
}
func probeAudioSeconds(ctx context.Context, filePath string) uint32 {
probeCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
filePath,
).Output()
if err != nil {
return 0
}
seconds, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || seconds <= 0 {
return 0
}
return uint32(math.Ceil(seconds))
}
func probeAudioWaveform(ctx context.Context, filePath string) []byte {
probeCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
out, err := exec.CommandContext(probeCtx, "ffmpeg",
"-v", "error",
"-i", filePath,
"-ac", "1",
"-ar", "8000",
"-f", "s16le",
"-acodec", "pcm_s16le",
"-",
).Output()
if err != nil {
return nil
}
return waveformFromPCM16LE(out)
}
func waveformFromPCM16LE(data []byte) []byte {
waveform := make([]byte, voiceWaveformSamples)
sampleCount := len(data) / 2
if sampleCount == 0 {
return waveform
}
bucketSize := int(math.Ceil(float64(sampleCount) / voiceWaveformSamples))
levels := make([]float64, voiceWaveformSamples)
var maxLevel float64
for i := 0; i < voiceWaveformSamples; i++ {
start := i * bucketSize
if start >= sampleCount {
break
}
end := start + bucketSize
if end > sampleCount {
end = sampleCount
}
var sum float64
for sampleIndex := start; sampleIndex < end; sampleIndex++ {
offset := sampleIndex * 2
sample := int16(binary.LittleEndian.Uint16(data[offset : offset+2]))
sum += math.Abs(float64(sample))
}
levels[i] = sum / float64(end-start)
if levels[i] > maxLevel {
maxLevel = levels[i]
}
}
if maxLevel == 0 {
return waveform
}
for i, level := range levels {
normalized := math.Round((level / maxLevel) * voiceWaveformMax)
if normalized > voiceWaveformMax {
normalized = voiceWaveformMax
}
waveform[i] = byte(normalized)
}
return waveform
}

View File

@ -4,18 +4,24 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/wa"
)
func newSendFileCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var filePath string
var filename string
var caption string
var mimeOverride string
var replyTo string
var replyToSender string
var ptt bool
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "file",
@ -24,12 +30,38 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
if to == "" || filePath == "" {
return fmt.Errorf("--to and --file are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
delegateFile := filePath
if abs, absErr := filepath.Abs(filePath); absErr == nil {
delegateFile = abs
}
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "file",
To: to,
Pick: pick,
File: delegateFile,
Filename: filename,
Caption: caption,
MIME: mimeOverride,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
PTT: ptt,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "file", resp)
}
return err
}
defer closeApp(a, lk)
@ -37,19 +69,42 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
if err := a.EnsureAuthed(); err != nil {
return err
}
toJID, err := resolveRecipient(a, to, recipientOptions{pick: pick, asJSON: flags.asJSON})
if err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
toJID, err := wa.ParseUserOrJID(to)
if err != nil {
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
msgID, meta, err := sendFile(ctx, a, toJID, filePath, filename, caption, mimeOverride)
type sendFileResult struct {
id string
meta map[string]string
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendFileResult, error) {
msgID, meta, err := sendFile(ctx, a, toJID, filePath, sendFileOptions{
filename: filename,
caption: caption,
mimeOverride: mimeOverride,
replyTo: replyTo,
replyToSender: replyToSender,
ptt: ptt,
})
if err != nil {
return sendFileResult{}, err
}
return sendFileResult{id: msgID, meta: meta}, nil
})
if err != nil {
return err
}
msgID, meta := res.id, res.meta
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
@ -64,10 +119,15 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command {
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number or JID")
cmd.Flags().StringVar(&to, "to", "", "recipient JID, phone number, or contact/group/chat name")
cmd.Flags().IntVar(&pick, "pick", 0, "when --to is ambiguous, pick the Nth match (1-indexed)")
cmd.Flags().StringVar(&filePath, "file", "", "path to file")
cmd.Flags().StringVar(&filename, "filename", "", "display name for the file (defaults to basename of --file)")
cmd.Flags().StringVar(&caption, "caption", "", "caption (images/videos/documents)")
cmd.Flags().StringVar(&mimeOverride, "mime", "", "override detected mime type")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "message ID to quote/reply to")
cmd.Flags().StringVar(&replyToSender, "reply-to-sender", "", "sender JID of the quoted message (required for unsynced group replies)")
cmd.Flags().BoolVar(&ptt, "ptt", false, "send OGG/Opus audio as a WhatsApp voice note")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}

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

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

131
cmd/wacli/send_helpers.go Normal file
View File

@ -0,0 +1,131 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/openclaw/wacli/internal/app"
"go.mau.fi/whatsmeow"
)
const sendAttemptTimeout = 45 * time.Second
const postSendRetryReceiptWait = 2 * time.Second
const rapidSendWarningThreshold = 5 * time.Second
const lastSendAttemptFile = ".last-send-at"
func runSendOperation[T any](
ctx context.Context,
reconnect func(context.Context) error,
op func(context.Context) (T, error),
) (T, error) {
result, err := runSendAttempt(ctx, sendAttemptTimeout, op)
if err == nil {
return result, nil
}
var zero T
if !isRetryableSendError(err) || ctx.Err() != nil {
return zero, err
}
if reconnectErr := reconnect(ctx); reconnectErr != nil {
return zero, fmt.Errorf("%w; reconnect failed: %v", err, reconnectErr)
}
return runSendAttempt(ctx, sendAttemptTimeout, op)
}
func runSendAttempt[T any](ctx context.Context, timeout time.Duration, op func(context.Context) (T, error)) (T, error) {
attemptCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
type result struct {
value T
err error
}
ch := make(chan result, 1)
go func() {
value, err := op(attemptCtx)
ch <- result{value: value, err: err}
}()
select {
case res := <-ch:
if errors.Is(res.err, context.DeadlineExceeded) && errors.Is(attemptCtx.Err(), context.DeadlineExceeded) {
var zero T
return zero, fmt.Errorf("send timed out after %s", timeout)
}
return res.value, res.err
case <-attemptCtx.Done():
var zero T
if errors.Is(attemptCtx.Err(), context.DeadlineExceeded) {
return zero, fmt.Errorf("send timed out after %s", timeout)
}
return zero, attemptCtx.Err()
}
}
func isRetryableSendError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, whatsmeow.ErrIQTimedOut) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "failed to send usync query") ||
strings.Contains(msg, "failed to get user info") ||
strings.Contains(msg, "failed to get device list") ||
strings.Contains(msg, "info query timed out") ||
strings.Contains(msg, "not connected")
}
func reconnectForSend(a interface {
WA() app.WAClient
Connect(context.Context, bool, func(string)) error
}) func(context.Context) error {
return func(ctx context.Context) error {
a.WA().Close()
return a.Connect(ctx, false, nil)
}
}
func waitForPostSendRetryReceipts(ctx context.Context, d time.Duration) {
if d <= 0 {
return
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
}
}
func warnRapidSendIfNeeded(storeDir string, now time.Time, stderr io.Writer) error {
path := filepath.Join(storeDir, lastSendAttemptFile)
data, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("read last send marker: %w", err)
}
if err == nil {
last, parseErr := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(data)))
if parseErr == nil {
if elapsed := now.Sub(last); elapsed >= 0 && elapsed < rapidSendWarningThreshold {
fmt.Fprintf(stderr, "warning: send command was invoked %s after the previous send; rapid automated sends may trigger WhatsApp rate limits or account restrictions\n", elapsed.Round(time.Second))
}
}
}
if err := os.WriteFile(path, []byte(now.Format(time.RFC3339Nano)+"\n"), 0o600); err != nil {
return fmt.Errorf("write last send marker: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod last send marker: %w", err)
}
return nil
}

View File

@ -0,0 +1,159 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.mau.fi/whatsmeow"
)
func TestRunSendOperationRetriesRetryableError(t *testing.T) {
var reconnects int
attempts := 0
got, err := runSendOperation(context.Background(), func(ctx context.Context) error {
reconnects++
return nil
}, func(ctx context.Context) (string, error) {
attempts++
if attempts == 1 {
return "", fmt.Errorf("failed to get device list: failed to send usync query: %w", whatsmeow.ErrIQTimedOut)
}
return "ok", nil
})
if err != nil {
t.Fatalf("runSendOperation: %v", err)
}
if got != "ok" {
t.Fatalf("expected ok, got %q", got)
}
if reconnects != 1 {
t.Fatalf("expected 1 reconnect, got %d", reconnects)
}
}
func TestRunSendOperationDoesNotRetryValidationError(t *testing.T) {
var reconnects int
_, err := runSendOperation(context.Background(), func(ctx context.Context) error {
reconnects++
return nil
}, func(ctx context.Context) (string, error) {
return "", errors.New("permission denied")
})
if err == nil {
t.Fatalf("expected error")
}
if reconnects != 0 {
t.Fatalf("expected no reconnect, got %d", reconnects)
}
}
func TestRunSendAttemptTimesOut(t *testing.T) {
_, err := runSendAttempt(context.Background(), 20*time.Millisecond, func(ctx context.Context) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
if err == nil {
t.Fatalf("expected timeout error")
}
if err.Error() != "send timed out after 20ms" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWaitForPostSendRetryReceipts(t *testing.T) {
start := time.Now()
waitForPostSendRetryReceipts(context.Background(), 10*time.Millisecond)
if elapsed := time.Since(start); elapsed < 10*time.Millisecond {
t.Fatalf("wait elapsed %s, want at least 10ms", elapsed)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
start = time.Now()
waitForPostSendRetryReceipts(ctx, time.Minute)
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("canceled wait elapsed %s, want quick return", elapsed)
}
start = time.Now()
waitForPostSendRetryReceipts(context.Background(), 0)
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("disabled wait elapsed %s, want quick return", elapsed)
}
}
func TestIsRetryableSendError(t *testing.T) {
if !isRetryableSendError(fmt.Errorf("wrapped: %w", whatsmeow.ErrIQTimedOut)) {
t.Fatalf("expected ErrIQTimedOut to be retryable")
}
if !isRetryableSendError(errors.New("failed to get user info for 123@s.whatsapp.net to fill LID cache: failed to send usync query: info query timed out")) {
t.Fatalf("expected wrapped usync timeout to be retryable")
}
if isRetryableSendError(errors.New("permission denied")) {
t.Fatalf("did not expect arbitrary error to be retryable")
}
}
func TestWarnRapidSendIfNeededWarnsAndUpdatesMarker(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
var stderr bytes.Buffer
if err := warnRapidSendIfNeeded(dir, now, &stderr); err != nil {
t.Fatalf("first warning check: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("first send warned: %q", stderr.String())
}
if err := warnRapidSendIfNeeded(dir, now.Add(time.Second), &stderr); err != nil {
t.Fatalf("second warning check: %v", err)
}
if got := stderr.String(); !strings.Contains(got, "warning: send command was invoked 1s after the previous send") {
t.Fatalf("expected rapid-send warning, got %q", got)
}
info, err := os.Stat(filepath.Join(dir, lastSendAttemptFile))
if err != nil {
t.Fatalf("stat marker: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("marker mode = %04o, want 0600", got)
}
}
func TestWarnRapidSendIfNeededSkipsOldOrInvalidMarker(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
path := filepath.Join(dir, lastSendAttemptFile)
if err := os.WriteFile(path, []byte(now.Add(-rapidSendWarningThreshold).Format(time.RFC3339Nano)), 0o600); err != nil {
t.Fatalf("write old marker: %v", err)
}
var stderr bytes.Buffer
if err := warnRapidSendIfNeeded(dir, now, &stderr); err != nil {
t.Fatalf("old marker warning check: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("old marker warned: %q", stderr.String())
}
if err := os.WriteFile(path, []byte("not a timestamp"), 0o600); err != nil {
t.Fatalf("write invalid marker: %v", err)
}
if err := warnRapidSendIfNeeded(dir, now.Add(time.Second), &stderr); err != nil {
t.Fatalf("invalid marker warning check: %v", err)
}
if stderr.Len() != 0 {
t.Fatalf("invalid marker warned: %q", stderr.String())
}
}

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

@ -0,0 +1,359 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/lock"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"go.mau.fi/whatsmeow/types"
)
const (
sendDelegateVersion = 1
sendDelegateSocketName = ".send.sock"
)
var errSendDelegateUnavailable = errors.New("send delegate unavailable")
type sendDelegateRequest struct {
Version int `json:"version"`
Kind string `json:"kind"`
To string `json:"to,omitempty"`
Pick int `json:"pick,omitempty"`
Message string `json:"message,omitempty"`
Mentions []string `json:"mentions,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
ReplyToSender string `json:"reply_to_sender,omitempty"`
NoPreview bool `json:"no_preview,omitempty"`
File string `json:"file,omitempty"`
Filename string `json:"filename,omitempty"`
Caption string `json:"caption,omitempty"`
MIME string `json:"mime,omitempty"`
PTT bool `json:"ptt,omitempty"`
ID string `json:"id,omitempty"`
Reaction string `json:"reaction,omitempty"`
Sender string `json:"sender,omitempty"`
PostSendWaitMS int64 `json:"post_send_wait_ms,omitempty"`
TimeoutMS int64 `json:"timeout_ms,omitempty"`
}
type sendDelegateResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Sent bool `json:"sent,omitempty"`
To string `json:"to,omitempty"`
ID string `json:"id,omitempty"`
Target string `json:"target,omitempty"`
Reaction string `json:"reaction,omitempty"`
File map[string]string `json:"file,omitempty"`
}
func sendDelegateSocketPath(storeDir string) string {
return filepath.Join(storeDir, sendDelegateSocketName)
}
func delegateSend(ctx context.Context, flags *rootFlags, req sendDelegateRequest) (sendDelegateResponse, error) {
req.Version = sendDelegateVersion
req.TimeoutMS = durationMillis(flags.timeout)
storeDir, err := resolveStoreDir(flags)
if err != nil {
return sendDelegateResponse{}, err
}
path := sendDelegateSocketPath(storeDir)
var d net.Dialer
conn, err := d.DialContext(ctx, "unix", path)
if err != nil {
return sendDelegateResponse{}, fmt.Errorf("%w: %v", errSendDelegateUnavailable, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(commandTimeout(flags)))
if err := json.NewEncoder(conn).Encode(req); err != nil {
return sendDelegateResponse{}, err
}
var resp sendDelegateResponse
if err := json.NewDecoder(conn).Decode(&resp); err != nil {
return sendDelegateResponse{}, err
}
if !resp.OK {
return sendDelegateResponse{}, errors.New(resp.Error)
}
return resp, nil
}
func tryDelegateSend(ctx context.Context, flags *rootFlags, lockErr error, req sendDelegateRequest) (sendDelegateResponse, bool, error) {
if !lock.IsLocked(lockErr) {
return sendDelegateResponse{}, false, lockErr
}
resp, err := delegateSend(ctx, flags, req)
if err != nil {
if errors.Is(err, errSendDelegateUnavailable) {
return sendDelegateResponse{}, false, lockErr
}
return sendDelegateResponse{}, true, err
}
return resp, true, nil
}
func startSendDelegateServer(ctx context.Context, a *app.App) (func(), error) {
path := sendDelegateSocketPath(a.StoreDir())
if err := removeStaleSendDelegateSocket(path); err != nil {
return nil, err
}
ln, err := net.Listen("unix", path)
if err != nil {
return nil, err
}
if err := os.Chmod(path, 0o600); err != nil {
_ = ln.Close()
_ = os.Remove(path)
return nil, err
}
done := make(chan struct{})
var sendMu sync.Mutex
go func() {
defer close(done)
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleSendDelegateConn(ctx, conn, a, &sendMu)
}
}()
stop := func() {
_ = ln.Close()
<-done
_ = os.Remove(path)
}
return stop, nil
}
func removeStaleSendDelegateSocket(path string) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if info.Mode()&os.ModeSocket == 0 {
return fmt.Errorf("%s exists and is not a socket", path)
}
return os.Remove(path)
}
func handleSendDelegateConn(ctx context.Context, conn net.Conn, a *app.App, sendMu *sync.Mutex) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Minute))
var req sendDelegateRequest
if err := json.NewDecoder(conn).Decode(&req); err != nil {
_ = json.NewEncoder(conn).Encode(sendDelegateResponse{OK: false, Error: err.Error()})
return
}
sendMu.Lock()
defer sendMu.Unlock()
resp, err := executeDelegatedSend(ctx, a, req)
if err != nil {
resp = sendDelegateResponse{OK: false, Error: err.Error()}
}
_ = json.NewEncoder(conn).Encode(resp)
}
func executeDelegatedSend(parent context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
if req.Version != sendDelegateVersion {
return sendDelegateResponse{}, fmt.Errorf("unsupported send delegate version %d", req.Version)
}
ctx, cancel := context.WithTimeout(parent, millisDuration(req.TimeoutMS, 5*time.Minute))
defer cancel()
switch req.Kind {
case "text":
return executeDelegatedText(ctx, a, req)
case "file", "voice":
return executeDelegatedFile(ctx, a, req)
case "sticker":
return executeDelegatedSticker(ctx, a, req)
case "react":
return executeDelegatedReact(ctx, a, req)
default:
return sendDelegateResponse{}, fmt.Errorf("unsupported send kind %q", req.Kind)
}
}
func executeDelegatedText(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
toJID, err := resolveRecipient(a, req.To, recipientOptions{pick: req.Pick, asJSON: true})
if err != nil {
return sendDelegateResponse{}, err
}
mentionedJIDs, err := parseMentionedJIDs(req.Mentions)
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
preview := fetchLinkPreview(ctx, req.Message, req.NoPreview)
msgID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return sendTextMessage(ctx, a, toJID, req.Message, req.ReplyTo, req.ReplyToSender, preview, mentionedJIDs)
})
if err != nil {
return sendDelegateResponse{}, err
}
now := time.Now().UTC()
chatName := a.WA().ResolveChatName(ctx, toJID, "")
_ = a.DB().UpsertChat(toJID.String(), chatKindFromJID(toJID), chatName, now)
_ = a.DB().UpsertMessage(store.UpsertMessageParams{
ChatJID: toJID.String(),
ChatName: chatName,
MsgID: string(msgID),
SenderName: "me",
Timestamp: now,
FromMe: true,
Text: req.Message,
})
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return sendDelegateResponse{OK: true, Sent: true, To: toJID.String(), ID: string(msgID)}, nil
}
func executeDelegatedFile(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
toJID, err := resolveRecipient(a, req.To, recipientOptions{pick: req.Pick, asJSON: true})
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendDelegateResponse, error) {
msgID, meta, err := sendFile(ctx, a, toJID, req.File, sendFileOptions{
filename: req.Filename,
caption: req.Caption,
mimeOverride: req.MIME,
replyTo: req.ReplyTo,
replyToSender: req.ReplyToSender,
ptt: req.PTT || req.Kind == "voice",
})
if err != nil {
return sendDelegateResponse{}, err
}
return sendDelegateResponse{OK: true, Sent: true, To: toJID.String(), ID: msgID, File: meta}, nil
})
if err != nil {
return sendDelegateResponse{}, err
}
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return res, nil
}
func executeDelegatedSticker(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
toJID, err := resolveRecipient(a, req.To, recipientOptions{pick: req.Pick, asJSON: true})
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendDelegateResponse, error) {
msgID, meta, err := sendSticker(ctx, a, toJID, req.File, sendStickerOptions{
replyTo: req.ReplyTo,
replyToSender: req.ReplyToSender,
})
if err != nil {
return sendDelegateResponse{}, err
}
return sendDelegateResponse{OK: true, Sent: true, To: toJID.String(), ID: msgID, File: meta}, nil
})
if err != nil {
return sendDelegateResponse{}, err
}
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return res, nil
}
func executeDelegatedReact(ctx context.Context, a *app.App, req sendDelegateRequest) (sendDelegateResponse, error) {
chat, senderJID, err := reactionTarget(req.To, req.Sender)
if err != nil {
return sendDelegateResponse{}, err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return sendDelegateResponse{}, err
}
sentID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return a.WA().SendReaction(ctx, chat, senderJID, types.MessageID(req.ID), req.Reaction)
})
if err != nil {
return sendDelegateResponse{}, err
}
now := time.Now().UTC()
chatName := a.WA().ResolveChatName(ctx, chat, "")
upsertSentReaction(a.DB(), chat, chatName, sentID, req.ID, req.Reaction, now)
waitForPostSendRetryReceipts(ctx, millisDuration(req.PostSendWaitMS, 0))
return sendDelegateResponse{OK: true, Sent: true, To: chat.String(), ID: string(sentID), Target: req.ID, Reaction: req.Reaction}, nil
}
func writeDelegatedSendOutput(flags *rootFlags, kind string, resp sendDelegateResponse) error {
if flags.asJSON {
body := map[string]any{"sent": resp.Sent, "to": resp.To, "id": resp.ID}
if resp.File != nil {
body["file"] = resp.File
}
if kind == "react" {
body["target"] = resp.Target
body["reaction"] = resp.Reaction
}
return out.WriteJSON(os.Stdout, body)
}
switch kind {
case "file":
fmt.Fprintf(os.Stdout, "Sent %s to %s (id %s)\n", resp.File["name"], resp.To, resp.ID)
case "sticker":
fmt.Fprintf(os.Stdout, "Sent sticker to %s (id %s)\n", resp.To, resp.ID)
case "voice":
fmt.Fprintf(os.Stdout, "Sent voice note to %s (id %s)\n", resp.To, resp.ID)
case "react":
if resp.Reaction == "" {
fmt.Fprintf(os.Stdout, "Removed reaction from %s (id %s)\n", resp.Target, resp.ID)
} else {
fmt.Fprintf(os.Stdout, "Reacted %s to %s (id %s)\n", resp.Reaction, resp.Target, resp.ID)
}
default:
fmt.Fprintf(os.Stdout, "Sent to %s (id %s)\n", resp.To, resp.ID)
}
return nil
}
func durationMillis(d time.Duration) int64 {
if d <= 0 {
return 0
}
return int64(d / time.Millisecond)
}
func millisDuration(ms int64, fallback time.Duration) time.Duration {
if ms <= 0 {
return fallback
}
return time.Duration(ms) * time.Millisecond
}
func commandTimeout(flags *rootFlags) time.Duration {
if flags == nil || flags.timeout <= 0 {
return 5 * time.Minute
}
return flags.timeout
}

View File

@ -0,0 +1,59 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/wacli/internal/lock"
)
func TestTryDelegateSendFallsBackWhenSocketUnavailable(t *testing.T) {
dir := t.TempDir()
flags := &rootFlags{storeDir: dir}
lockErr := fmt.Errorf("held: %w", lock.ErrLocked)
_, delegated, err := tryDelegateSend(context.Background(), flags, lockErr, sendDelegateRequest{Kind: "text"})
if delegated {
t.Fatalf("delegated = true, want false for missing socket")
}
if !errors.Is(err, lock.ErrLocked) {
t.Fatalf("error = %v, want original lock error", err)
}
}
func TestTryDelegateSendDoesNotDelegateNonLockErrors(t *testing.T) {
orig := errors.New("open store")
_, delegated, err := tryDelegateSend(context.Background(), &rootFlags{}, orig, sendDelegateRequest{Kind: "text"})
if delegated {
t.Fatalf("delegated = true, want false")
}
if !errors.Is(err, orig) {
t.Fatalf("error = %v, want original", err)
}
}
func TestExecuteDelegatedSendRejectsBadVersionBeforeAppUse(t *testing.T) {
_, err := executeDelegatedSend(context.Background(), nil, sendDelegateRequest{
Version: sendDelegateVersion + 1,
Kind: "text",
})
if err == nil || !strings.Contains(err.Error(), "unsupported send delegate version") {
t.Fatalf("error = %v", err)
}
}
func TestRemoveStaleSendDelegateSocketRefusesRegularFile(t *testing.T) {
path := filepath.Join(t.TempDir(), sendDelegateSocketName)
if err := os.WriteFile(path, []byte("not a socket"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := removeStaleSendDelegateSocket(path); err == nil || !strings.Contains(err.Error(), "not a socket") {
t.Fatalf("error = %v, want not a socket", err)
}
}

160
cmd/wacli/send_react_cmd.go Normal file
View File

@ -0,0 +1,160 @@
package main
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"github.com/spf13/cobra"
"go.mau.fi/whatsmeow/types"
)
func newSendReactCmd(flags *rootFlags) *cobra.Command {
var to string
var msgID string
var emoji string
var sender string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "react",
Short: "React to a message",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(to) == "" || strings.TrimSpace(msgID) == "" {
return fmt.Errorf("--to and --id are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "react",
To: to,
ID: msgID,
Reaction: emoji,
Sender: sender,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "react", resp)
}
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
chat, senderJID, err := reactionTarget(to, sender)
if err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
sentID, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) {
return a.WA().SendReaction(ctx, chat, senderJID, types.MessageID(msgID), emoji)
})
if err != nil {
return err
}
now := time.Now().UTC()
chatName := a.WA().ResolveChatName(ctx, chat, "")
upsertSentReaction(a.DB(), chat, chatName, sentID, msgID, emoji, now)
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
"to": chat.String(),
"id": sentID,
"target": msgID,
"reaction": emoji,
})
}
if emoji == "" {
fmt.Fprintf(os.Stdout, "Removed reaction from %s (id %s)\n", msgID, sentID)
return nil
}
fmt.Fprintf(os.Stdout, "Reacted %s to %s (id %s)\n", emoji, msgID, sentID)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient phone number (+E164 and formatting ok) or JID")
cmd.Flags().StringVar(&msgID, "id", "", "target message ID")
cmd.Flags().StringVar(&emoji, "reaction", "\U0001f44d", "reaction emoji (pass an empty string to remove)")
cmd.Flags().StringVar(&sender, "sender", "", "message sender JID (required for group messages)")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}
func reactionTarget(to, sender string) (types.JID, types.JID, error) {
chat, err := wa.ParseUserOrJID(to)
if err != nil {
return types.JID{}, types.JID{}, fmt.Errorf("invalid --to: %w", err)
}
var senderJID types.JID
if strings.TrimSpace(sender) != "" {
senderJID, err = wa.ParseUserOrJID(sender)
if err != nil {
return types.JID{}, types.JID{}, fmt.Errorf("invalid --sender: %w", err)
}
}
if chat.Server == types.GroupServer && senderJID.IsEmpty() {
return types.JID{}, types.JID{}, fmt.Errorf("--sender is required for group reactions")
}
return chat, senderJID, nil
}
func upsertSentReaction(db *store.DB, chat types.JID, chatName string, sentID types.MessageID, targetID, emoji string, now time.Time) {
if db == nil || chat.IsEmpty() || sentID == "" {
return
}
_ = db.UpsertChat(chat.String(), chatKindFromJID(chat), chatName, now)
_ = db.UpsertMessage(store.UpsertMessageParams{
ChatJID: chat.String(),
ChatName: chatName,
MsgID: string(sentID),
SenderName: "me",
Timestamp: now,
FromMe: true,
DisplayText: sentReactionDisplayText(db, chat.String(), targetID, emoji),
ReactionToID: targetID,
ReactionEmoji: emoji,
})
}
func sentReactionDisplayText(db *store.DB, chatJID, targetID, emoji string) string {
display := "message"
if db != nil && strings.TrimSpace(chatJID) != "" && strings.TrimSpace(targetID) != "" {
if msg, err := db.GetMessage(chatJID, targetID); err == nil {
if text := strings.TrimSpace(messageText(msg)); text != "" {
display = text
}
}
}
if strings.TrimSpace(emoji) == "" {
return fmt.Sprintf("Reacted to %s", display)
}
return fmt.Sprintf("Reacted %s to %s", emoji, display)
}

View File

@ -0,0 +1,41 @@
package main
import (
"strings"
"testing"
"go.mau.fi/whatsmeow/types"
)
func TestReactionTargetDirect(t *testing.T) {
chat, sender, err := reactionTarget("+15551234567", "")
if err != nil {
t.Fatalf("reactionTarget: %v", err)
}
if chat.String() != "15551234567@s.whatsapp.net" {
t.Fatalf("chat = %q", chat.String())
}
if !sender.IsEmpty() {
t.Fatalf("sender = %q, want empty", sender.String())
}
}
func TestReactionTargetGroupRequiresSender(t *testing.T) {
_, _, err := reactionTarget("12345@g.us", "")
if err == nil || !strings.Contains(err.Error(), "--sender is required") {
t.Fatalf("expected sender error, got %v", err)
}
}
func TestReactionTargetGroupSender(t *testing.T) {
chat, sender, err := reactionTarget("12345@g.us", "+15551234567")
if err != nil {
t.Fatalf("reactionTarget: %v", err)
}
if chat.Server != types.GroupServer {
t.Fatalf("chat = %q, want group", chat.String())
}
if sender.String() != "15551234567@s.whatsapp.net" {
t.Fatalf("sender = %q", sender.String())
}
}

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

@ -0,0 +1,217 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/store"
"github.com/openclaw/wacli/internal/wa"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
)
const sendStickerMIME = "image/webp"
const (
stickerDimension = 512
maxStaticStickerBytes = 100 * 1024
maxAnimatedStickerByte = 500 * 1024
)
type sendStickerOptions struct {
replyTo string
replyToSender string
}
type webPStickerMetadata struct {
width uint32
height uint32
animated bool
}
func sendSticker(ctx context.Context, a interface {
WA() app.WAClient
DB() *store.DB
}, to types.JID, filePath string, opts sendStickerOptions) (string, map[string]string, error) {
data, err := readSendFileData(filePath)
if err != nil {
return "", nil, err
}
meta, err := validateWebPSticker(data)
if err != nil {
return "", nil, err
}
uploadType, err := wa.MediaTypeFromString("sticker")
if err != nil {
return "", nil, err
}
up, err := a.WA().Upload(ctx, data, uploadType)
if err != nil {
return "", nil, err
}
replyContext, err := buildReplyContextInfo(a.DB(), to, opts.replyTo, opts.replyToSender)
if err != nil {
return "", nil, err
}
msg := newStickerMessage(up, replyContext, meta)
id, err := a.WA().SendProtoMessage(ctx, to, msg)
if err != nil {
return "", nil, err
}
now := time.Now().UTC()
name := filepath.Base(filePath)
chatName := a.WA().ResolveChatName(ctx, to, "")
_ = a.DB().UpsertChat(to.String(), chatKindFromJID(to), chatName, now)
_ = a.DB().UpsertMessage(store.UpsertMessageParams{
ChatJID: to.String(),
ChatName: chatName,
MsgID: id,
SenderJID: "",
SenderName: "me",
Timestamp: now,
FromMe: true,
MediaType: "sticker",
Filename: name,
MimeType: sendStickerMIME,
DirectPath: up.DirectPath,
MediaKey: up.MediaKey,
FileSHA256: up.FileSHA256,
FileEncSHA256: up.FileEncSHA256,
FileLength: up.FileLength,
})
return id, map[string]string{
"name": name,
"mime_type": sendStickerMIME,
"media": "sticker",
}, nil
}
func newStickerMessage(up whatsmeow.UploadResponse, info *waProto.ContextInfo, meta webPStickerMetadata) *waProto.Message {
return &waProto.Message{
StickerMessage: &waProto.StickerMessage{
URL: proto.String(up.URL),
DirectPath: proto.String(up.DirectPath),
MediaKey: up.MediaKey,
FileEncSHA256: up.FileEncSHA256,
FileSHA256: up.FileSHA256,
FileLength: proto.Uint64(up.FileLength),
Mimetype: proto.String(sendStickerMIME),
Height: proto.Uint32(meta.height),
Width: proto.Uint32(meta.width),
IsAnimated: proto.Bool(meta.animated),
ContextInfo: info,
},
}
}
func isWebPStickerData(data []byte) bool {
_, err := parseWebPStickerMetadata(data)
return err == nil
}
func validateWebPSticker(data []byte) (webPStickerMetadata, error) {
meta, err := parseWebPStickerMetadata(data)
if err != nil {
return webPStickerMetadata{}, fmt.Errorf("stickers must be valid WebP files")
}
if meta.width != stickerDimension || meta.height != stickerDimension {
return webPStickerMetadata{}, fmt.Errorf("stickers must be %dx%d WebP files (got %dx%d)", stickerDimension, stickerDimension, meta.width, meta.height)
}
maxBytes := maxStaticStickerBytes
kind := "static"
if meta.animated {
maxBytes = maxAnimatedStickerByte
kind = "animated"
}
if len(data) > maxBytes {
return webPStickerMetadata{}, fmt.Errorf("%s stickers must be at most %d KiB (got %d KiB)", kind, maxBytes/1024, (len(data)+1023)/1024)
}
return meta, nil
}
func parseWebPStickerMetadata(data []byte) (webPStickerMetadata, error) {
if len(data) < 12 || !bytes.Equal(data[0:4], []byte("RIFF")) || !bytes.Equal(data[8:12], []byte("WEBP")) {
return webPStickerMetadata{}, fmt.Errorf("missing WebP header")
}
for off := 12; off+8 <= len(data); {
chunkType := string(data[off : off+4])
chunkSize := int(binary.LittleEndian.Uint32(data[off+4 : off+8]))
chunkStart := off + 8
chunkEnd := chunkStart + chunkSize
if chunkSize < 0 || chunkEnd > len(data) {
return webPStickerMetadata{}, fmt.Errorf("invalid WebP chunk size")
}
chunk := data[chunkStart:chunkEnd]
switch chunkType {
case "VP8X":
meta, err := parseWebPVP8X(chunk)
if err != nil {
return webPStickerMetadata{}, err
}
return meta, nil
case "VP8L":
meta, err := parseWebPVP8L(chunk)
if err != nil {
return webPStickerMetadata{}, err
}
return meta, nil
case "VP8 ":
meta, err := parseWebPVP8(chunk)
if err != nil {
return webPStickerMetadata{}, err
}
return meta, nil
}
off = chunkEnd
if chunkSize%2 == 1 {
off++
}
}
return webPStickerMetadata{}, fmt.Errorf("missing WebP image chunk")
}
func parseWebPVP8X(chunk []byte) (webPStickerMetadata, error) {
if len(chunk) < 10 {
return webPStickerMetadata{}, fmt.Errorf("short VP8X chunk")
}
width := uint32(chunk[4]) | uint32(chunk[5])<<8 | uint32(chunk[6])<<16
height := uint32(chunk[7]) | uint32(chunk[8])<<8 | uint32(chunk[9])<<16
return webPStickerMetadata{
width: width + 1,
height: height + 1,
animated: chunk[0]&0x02 != 0,
}, nil
}
func parseWebPVP8L(chunk []byte) (webPStickerMetadata, error) {
if len(chunk) < 5 || chunk[0] != 0x2f {
return webPStickerMetadata{}, fmt.Errorf("invalid VP8L chunk")
}
bits := binary.LittleEndian.Uint32(chunk[1:5])
return webPStickerMetadata{
width: (bits & 0x3fff) + 1,
height: ((bits >> 14) & 0x3fff) + 1,
}, nil
}
func parseWebPVP8(chunk []byte) (webPStickerMetadata, error) {
if len(chunk) < 10 || !bytes.Equal(chunk[3:6], []byte{0x9d, 0x01, 0x2a}) {
return webPStickerMetadata{}, fmt.Errorf("invalid VP8 chunk")
}
return webPStickerMetadata{
width: uint32(binary.LittleEndian.Uint16(chunk[6:8]) & 0x3fff),
height: uint32(binary.LittleEndian.Uint16(chunk[8:10]) & 0x3fff),
}, nil
}

View File

@ -0,0 +1,116 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newSendStickerCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var filePath string
var replyTo string
var replyToSender string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "sticker",
Short: "Send a sticker (WebP image)",
RunE: func(cmd *cobra.Command, args []string) error {
if to == "" || filePath == "" {
return fmt.Errorf("--to and --file are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
delegateFile := filePath
if abs, absErr := filepath.Abs(filePath); absErr == nil {
delegateFile = abs
}
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "sticker",
To: to,
Pick: pick,
File: delegateFile,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "sticker", resp)
}
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
toJID, err := resolveRecipient(a, to, recipientOptions{pick: pick, asJSON: flags.asJSON})
if err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
type sendStickerResult struct {
id string
meta map[string]string
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendStickerResult, error) {
msgID, meta, err := sendSticker(ctx, a, toJID, filePath, sendStickerOptions{
replyTo: replyTo,
replyToSender: replyToSender,
})
if err != nil {
return sendStickerResult{}, err
}
return sendStickerResult{id: msgID, meta: meta}, nil
})
if err != nil {
return err
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
"to": toJID.String(),
"id": res.id,
"file": res.meta,
})
}
fmt.Fprintf(os.Stdout, "Sent sticker to %s (id %s)\n", toJID.String(), res.id)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient JID, phone number, or contact/group/chat name")
cmd.Flags().IntVar(&pick, "pick", 0, "when --to is ambiguous, pick the Nth match (1-indexed)")
cmd.Flags().StringVar(&filePath, "file", "", "path to WebP sticker file")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "message ID to quote/reply to")
cmd.Flags().StringVar(&replyToSender, "reply-to-sender", "", "sender JID of the quoted message (required for unsynced group replies)")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}

View File

@ -0,0 +1,164 @@
package main
import (
"context"
"encoding/binary"
"os"
"path/filepath"
"strings"
"testing"
"go.mau.fi/whatsmeow"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
"google.golang.org/protobuf/proto"
)
func TestSendCommandIncludesStickerSubcommand(t *testing.T) {
cmd := newSendCmd(&rootFlags{})
for _, sub := range cmd.Commands() {
if sub.Name() == "sticker" {
return
}
}
t.Fatalf("missing send sticker subcommand")
}
func TestSendStickerCommandExposesSharedSendFlags(t *testing.T) {
cmd := newSendStickerCmd(&rootFlags{})
for _, name := range []string{"to", "pick", "file", "reply-to", "reply-to-sender", "post-send-wait"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
func TestIsWebPStickerData(t *testing.T) {
valid := testWebPVP8X(512, 512, false, nil)
if !isWebPStickerData(valid) {
t.Fatalf("valid WebP header was rejected")
}
for _, data := range [][]byte{
nil,
[]byte("RIFF\x10\x00\x00\x00PNG "),
[]byte("not webp"),
} {
if isWebPStickerData(data) {
t.Fatalf("invalid WebP header was accepted: %q", string(data))
}
}
}
func TestValidateWebPSticker(t *testing.T) {
static := testWebPVP8X(512, 512, false, nil)
meta, err := validateWebPSticker(static)
if err != nil {
t.Fatalf("validateWebPSticker: %v", err)
}
if meta.width != 512 || meta.height != 512 || meta.animated {
t.Fatalf("metadata = %+v, want static 512x512", meta)
}
animated := testWebPVP8X(512, 512, true, bytesOfSize(101*1024))
meta, err = validateWebPSticker(animated)
if err != nil {
t.Fatalf("animated sticker should allow >100 KiB: %v", err)
}
if !meta.animated {
t.Fatalf("animated WebP was not detected")
}
for name, tc := range map[string]struct {
data []byte
want string
}{
"wrong dimensions": {testWebPVP8X(256, 512, false, nil), "512x512"},
"static too large": {testWebPVP8X(512, 512, false, bytesOfSize(101*1024)), "static stickers"},
"animated too large": {testWebPVP8X(512, 512, true, bytesOfSize(501*1024)), "animated stickers"},
} {
if _, err := validateWebPSticker(tc.data); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: expected %q error, got %v", name, tc.want, err)
}
}
}
func TestSendStickerRejectsNonWebPBeforeUpload(t *testing.T) {
path := filepath.Join(t.TempDir(), "sticker.png")
if err := os.WriteFile(path, []byte("not-webp"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, _, err := sendSticker(context.Background(), nil, types.JID{}, path, sendStickerOptions{})
if err == nil || !strings.Contains(err.Error(), "stickers must be valid WebP") {
t.Fatalf("expected WebP validation error, got %v", err)
}
}
func TestNewStickerMessageAttachesUploadFieldsAndReply(t *testing.T) {
up := whatsmeow.UploadResponse{
URL: "https://upload",
DirectPath: "/direct",
MediaKey: []byte("key"),
FileEncSHA256: []byte("enc"),
FileSHA256: []byte("plain"),
FileLength: 123,
}
meta := webPStickerMetadata{width: 512, height: 512, animated: true}
info := &waProto.ContextInfo{
StanzaID: proto.String("quoted"),
Participant: proto.String("15551234567@s.whatsapp.net"),
}
msg := newStickerMessage(up, info, meta)
sticker := msg.GetStickerMessage()
if sticker == nil {
t.Fatalf("missing sticker message")
}
if sticker.GetMimetype() != sendStickerMIME {
t.Fatalf("mime = %q, want %q", sticker.GetMimetype(), sendStickerMIME)
}
if sticker.GetURL() != up.URL || sticker.GetDirectPath() != up.DirectPath || sticker.GetFileLength() != up.FileLength {
t.Fatalf("upload fields were not attached")
}
if string(sticker.GetMediaKey()) != string(up.MediaKey) ||
string(sticker.GetFileSHA256()) != string(up.FileSHA256) ||
string(sticker.GetFileEncSHA256()) != string(up.FileEncSHA256) {
t.Fatalf("upload hashes were not attached")
}
if sticker.GetWidth() != meta.width || sticker.GetHeight() != meta.height || !sticker.GetIsAnimated() {
t.Fatalf("sticker metadata was not attached")
}
if sticker.GetContextInfo() != info {
t.Fatalf("reply context was not attached")
}
}
func testWebPVP8X(width, height uint32, animated bool, extra []byte) []byte {
chunk := make([]byte, 10)
if animated {
chunk[0] = 0x02
}
putUint24(chunk[4:7], width-1)
putUint24(chunk[7:10], height-1)
data := make([]byte, 0, 12+8+len(chunk)+len(extra))
data = append(data, []byte("RIFF")...)
data = binary.LittleEndian.AppendUint32(data, uint32(4+8+len(chunk)+len(extra)))
data = append(data, []byte("WEBPVP8X")...)
data = binary.LittleEndian.AppendUint32(data, uint32(len(chunk)))
data = append(data, chunk...)
data = append(data, extra...)
return data
}
func putUint24(dst []byte, v uint32) {
dst[0] = byte(v)
dst[1] = byte(v >> 8)
dst[2] = byte(v >> 16)
}
func bytesOfSize(n int) []byte {
if n <= 0 {
return nil
}
return make([]byte, n)
}

409
cmd/wacli/send_test.go Normal file
View File

@ -0,0 +1,409 @@
package main
import (
"strings"
"testing"
"time"
"github.com/openclaw/wacli/internal/linkpreview"
"github.com/openclaw/wacli/internal/store"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/types"
)
func openSendTestDB(t *testing.T) *store.DB {
t.Helper()
db, err := store.Open(t.TempDir() + "/wacli.db")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
type recipientTestApp struct {
db *store.DB
}
func (a recipientTestApp) DB() *store.DB {
return a.db
}
func TestResolveRecipientFallsBackToFormattedPhone(t *testing.T) {
db := openSendTestDB(t)
got, err := resolveRecipient(recipientTestApp{db: db}, "+1 (555) 123-4567", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "15551234567@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientUsesContactAlias(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertContact("15551234567@s.whatsapp.net", "15551234567", "Alice", "", "", ""); err != nil {
t.Fatalf("UpsertContact: %v", err)
}
if err := db.SetAlias("15551234567@s.whatsapp.net", "mom"); err != nil {
t.Fatalf("SetAlias: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "mom", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "15551234567@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientNumericGroupNameBeatsPhoneFallback(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertGroup("12345@g.us", "12345", "", time.Now()); err != nil {
t.Fatalf("UpsertGroup: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "12345", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "12345@g.us" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientNumericDirectChatDoesNotHijackPhone(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertChat("999@s.whatsapp.net", "dm", "1234567", time.Now()); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "1234567", recipientOptions{})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "1234567@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveRecipientAmbiguousRequiresPickWhenNonInteractive(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertContact("1@s.whatsapp.net", "1", "", "John", "", ""); err != nil {
t.Fatalf("UpsertContact 1: %v", err)
}
if err := db.UpsertContact("2@s.whatsapp.net", "2", "", "Johnny", "", ""); err != nil {
t.Fatalf("UpsertContact 2: %v", err)
}
_, err := resolveRecipient(recipientTestApp{db: db}, "John", recipientOptions{})
if err == nil || !strings.Contains(err.Error(), "use --pick N") {
t.Fatalf("expected --pick ambiguity, got %v", err)
}
if !strings.Contains(err.Error(), "1)") || !strings.Contains(err.Error(), "2)") {
t.Fatalf("expected numbered candidates, got %v", err)
}
}
func TestResolveRecipientPickSelectsCandidate(t *testing.T) {
db := openSendTestDB(t)
if err := db.UpsertContact("1@s.whatsapp.net", "1", "", "John", "", ""); err != nil {
t.Fatalf("UpsertContact 1: %v", err)
}
if err := db.UpsertContact("2@s.whatsapp.net", "2", "", "Johnny", "", ""); err != nil {
t.Fatalf("UpsertContact 2: %v", err)
}
got, err := resolveRecipient(recipientTestApp{db: db}, "John", recipientOptions{pick: 2})
if err != nil {
t.Fatalf("resolveRecipient: %v", err)
}
if got.String() != "2@s.whatsapp.net" {
t.Fatalf("recipient = %q", got.String())
}
}
func TestResolveReplySenderFromStore(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
sender := "15551234567@s.whatsapp.net"
if err := db.UpsertChat(chat.String(), "group", "Group", time.Now()); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: chat.String(),
MsgID: "quoted",
SenderJID: sender,
Timestamp: time.Now(),
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
got, err := resolveReplySender(db, chat, "quoted", "")
if err != nil {
t.Fatalf("resolveReplySender: %v", err)
}
if got.String() != sender {
t.Fatalf("sender = %q, want %q", got.String(), sender)
}
}
func TestResolveReplySenderOverride(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
got, err := resolveReplySender(db, chat, "missing", "+15551234567")
if err != nil {
t.Fatalf("resolveReplySender: %v", err)
}
if got.String() != "15551234567@s.whatsapp.net" {
t.Fatalf("sender = %q", got.String())
}
}
func TestResolveReplySenderRequiresGroupSenderWhenMissing(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
_, err := resolveReplySender(db, chat, "missing", "")
if err == nil || !strings.Contains(err.Error(), "--reply-to-sender is required") {
t.Fatalf("expected group sender error, got %v", err)
}
}
func TestResolveReplySenderAllowsDirectMessageWithoutSender(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
got, err := resolveReplySender(db, chat, "missing", "")
if err != nil {
t.Fatalf("resolveReplySender: %v", err)
}
if !got.IsEmpty() {
t.Fatalf("expected empty sender for direct reply, got %q", got.String())
}
}
func TestUpsertSentReactionStoresDisplayText(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
now := time.Date(2026, 5, 5, 6, 30, 0, 0, time.UTC)
if err := db.UpsertChat(chat.String(), "dm", "Alice", now); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
if err := db.UpsertMessage(store.UpsertMessageParams{
ChatJID: chat.String(),
MsgID: "target",
Timestamp: now.Add(-time.Second),
FromMe: true,
Text: "hello reaction target",
}); err != nil {
t.Fatalf("UpsertMessage target: %v", err)
}
upsertSentReaction(db, chat, "Alice", "react1", "target", "👍", now)
msg, err := db.GetMessage(chat.String(), "react1")
if err != nil {
t.Fatalf("GetMessage reaction: %v", err)
}
if !msg.FromMe || msg.SenderName != "me" {
t.Fatalf("unexpected sender fields: from_me=%v sender=%q", msg.FromMe, msg.SenderName)
}
if msg.ReactionToID != "target" || msg.ReactionEmoji != "👍" {
t.Fatalf("unexpected reaction fields: to=%q emoji=%q", msg.ReactionToID, msg.ReactionEmoji)
}
if msg.DisplayText != "Reacted 👍 to hello reaction target" {
t.Fatalf("display text = %q", msg.DisplayText)
}
}
func TestBuildReplyContextInfo(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
got, err := buildReplyContextInfo(db, chat, "quoted", "+15551234567")
if err != nil {
t.Fatalf("buildReplyContextInfo: %v", err)
}
if got.GetStanzaID() != "quoted" {
t.Fatalf("stanza ID = %q, want quoted", got.GetStanzaID())
}
if got.GetParticipant() != "15551234567@s.whatsapp.net" {
t.Fatalf("participant = %q", got.GetParticipant())
}
got, err = buildReplyContextInfo(db, chat, "", "+15551234567")
if err != nil {
t.Fatalf("empty buildReplyContextInfo: %v", err)
}
if got != nil {
t.Fatalf("empty reply context = %v, want nil", got)
}
}
func TestParseMentionedJIDs(t *testing.T) {
got, err := parseMentionedJIDs([]string{
" +1 (555) 123-4567 ",
"15551234567@s.whatsapp.net",
"15557654321@s.whatsapp.net",
"",
})
if err != nil {
t.Fatalf("parseMentionedJIDs: %v", err)
}
want := []string{"15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("mentions = %v, want %v", got, want)
}
}
func TestParseMentionedJIDsRejectsGroupJID(t *testing.T) {
_, err := parseMentionedJIDs([]string{"12345@g.us"})
if err == nil || !strings.Contains(err.Error(), "mentions must target a user") {
t.Fatalf("expected group mention rejection, got %v", err)
}
}
func TestSendTextCommandExposesNoPreviewFlag(t *testing.T) {
cmd := newSendTextCmd(&rootFlags{})
if cmd.Flags().Lookup("no-preview") == nil {
t.Fatalf("missing --no-preview flag")
}
}
func TestSendTextCommandExposesMessageEscapesFlag(t *testing.T) {
cmd := newSendTextCmd(&rootFlags{})
if cmd.Flags().Lookup("message-escapes") == nil {
t.Fatalf("missing --message-escapes flag")
}
}
func TestSendTextCommandExposesMentionFlag(t *testing.T) {
cmd := newSendTextCmd(&rootFlags{})
if cmd.Flags().Lookup("mention") == nil {
t.Fatalf("missing --mention flag")
}
}
func TestDecodeMessageEscapes(t *testing.T) {
got, err := decodeMessageEscapes(`line1\nline2\ttab\rcr\\slash\"quote`)
if err != nil {
t.Fatalf("decodeMessageEscapes: %v", err)
}
want := "line1\nline2\ttab\rcr\\slash\"quote"
if got != want {
t.Fatalf("decoded = %q, want %q", got, want)
}
}
func TestDecodeMessageEscapesRejectsUnknownEscape(t *testing.T) {
_, err := decodeMessageEscapes(`hello\q`)
if err == nil || !strings.Contains(err.Error(), `unsupported escape sequence \q`) {
t.Fatalf("error = %v", err)
}
}
func TestBuildTextMessageUsesPlainConversationWithoutReplyOrPreview(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
msg, plain, err := buildTextMessage(db, chat, "hello", "", "", nil, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if !plain {
t.Fatalf("plain = false, want true")
}
if msg != nil {
t.Fatalf("msg = %v, want nil", msg)
}
}
func TestBuildTextMessageAttachesMentions(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
mentions := []string{"15551234567@s.whatsapp.net", "15557654321@s.whatsapp.net"}
msg, plain, err := buildTextMessage(db, chat, "hey @15551234567", "", "", nil, mentions)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
ext := msg.GetExtendedTextMessage()
if ext.GetText() != "hey @15551234567" {
t.Fatalf("text = %q", ext.GetText())
}
got := ext.GetContextInfo().GetMentionedJID()
if strings.Join(got, ",") != strings.Join(mentions, ",") {
t.Fatalf("mentioned JIDs = %v, want %v", got, mentions)
}
}
func TestBuildTextMessageCombinesReplyAndMentions(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "12345", Server: types.GroupServer}
msg, plain, err := buildTextMessage(db, chat, "replying @15551234567", "quoted", "+15557654321", nil, []string{"15551234567@s.whatsapp.net"})
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
info := msg.GetExtendedTextMessage().GetContextInfo()
if info.GetStanzaID() != "quoted" {
t.Fatalf("stanza ID = %q, want quoted", info.GetStanzaID())
}
if info.GetParticipant() != "15557654321@s.whatsapp.net" {
t.Fatalf("participant = %q", info.GetParticipant())
}
if got := info.GetMentionedJID(); strings.Join(got, ",") != "15551234567@s.whatsapp.net" {
t.Fatalf("mentioned JIDs = %v", got)
}
}
func TestBuildTextMessageAttachesLinkPreview(t *testing.T) {
db := openSendTestDB(t)
chat := types.JID{User: "15551234567", Server: types.DefaultUserServer}
preview := &linkpreview.Preview{
URL: "https://example.com/post",
Title: "Example",
Description: "Description",
Thumbnail: []byte("jpeg"),
}
msg, plain, err := buildTextMessage(db, chat, "see https://example.com/post", "", "", preview, nil)
if err != nil {
t.Fatalf("buildTextMessage: %v", err)
}
if plain {
t.Fatalf("plain = true, want false")
}
ext := msg.GetExtendedTextMessage()
if ext.GetText() != "see https://example.com/post" {
t.Fatalf("text = %q", ext.GetText())
}
if ext.GetMatchedText() != preview.URL {
t.Fatalf("matched text = %q", ext.GetMatchedText())
}
if ext.GetTitle() != preview.Title {
t.Fatalf("title = %q", ext.GetTitle())
}
if ext.GetDescription() != preview.Description {
t.Fatalf("description = %q", ext.GetDescription())
}
if ext.GetPreviewType() != waProto.ExtendedTextMessage_IMAGE {
t.Fatalf("preview type = %v", ext.GetPreviewType())
}
if string(ext.GetJPEGThumbnail()) != "jpeg" {
t.Fatalf("thumbnail = %q", string(ext.GetJPEGThumbnail()))
}
}

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

@ -0,0 +1,121 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newSendVoiceCmd(flags *rootFlags) *cobra.Command {
var to string
var pick int
var filePath string
var mimeOverride string
var replyTo string
var replyToSender string
postSendWait := postSendRetryReceiptWait
cmd := &cobra.Command{
Use: "voice",
Short: "Send a voice note",
RunE: func(cmd *cobra.Command, args []string) error {
if to == "" || filePath == "" {
return fmt.Errorf("--to and --file are required")
}
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
delegateFile := filePath
if abs, absErr := filepath.Abs(filePath); absErr == nil {
delegateFile = abs
}
resp, delegated, delegateErr := tryDelegateSend(ctx, flags, err, sendDelegateRequest{
Kind: "voice",
To: to,
Pick: pick,
File: delegateFile,
MIME: mimeOverride,
ReplyTo: replyTo,
ReplyToSender: replyToSender,
PostSendWaitMS: durationMillis(postSendWait),
})
if delegated {
if delegateErr != nil {
return delegateErr
}
return writeDelegatedSendOutput(flags, "voice", resp)
}
return err
}
defer closeApp(a, lk)
if err := a.EnsureAuthed(); err != nil {
return err
}
toJID, err := resolveRecipient(a, to, recipientOptions{pick: pick, asJSON: flags.asJSON})
if err != nil {
return err
}
if err := a.Connect(ctx, false, nil); err != nil {
return err
}
if err := warnRapidSendIfNeeded(a.StoreDir(), time.Now().UTC(), os.Stderr); err != nil {
return err
}
type sendVoiceResult struct {
id string
meta map[string]string
}
res, err := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (sendVoiceResult, error) {
msgID, meta, err := sendFile(ctx, a, toJID, filePath, sendFileOptions{
mimeOverride: mimeOverride,
replyTo: replyTo,
replyToSender: replyToSender,
ptt: true,
})
if err != nil {
return sendVoiceResult{}, err
}
return sendVoiceResult{id: msgID, meta: meta}, nil
})
if err != nil {
return err
}
waitForPostSendRetryReceipts(ctx, postSendWait)
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"sent": true,
"to": toJID.String(),
"id": res.id,
"file": res.meta,
})
}
fmt.Fprintf(os.Stdout, "Sent voice note to %s (id %s)\n", toJID.String(), res.id)
return nil
},
}
cmd.Flags().StringVar(&to, "to", "", "recipient JID, phone number, or contact/group/chat name")
cmd.Flags().IntVar(&pick, "pick", 0, "when --to is ambiguous, pick the Nth match (1-indexed)")
cmd.Flags().StringVar(&filePath, "file", "", "path to OGG/Opus audio file")
cmd.Flags().StringVar(&mimeOverride, "mime", "", "override detected mime type")
cmd.Flags().StringVar(&replyTo, "reply-to", "", "message ID to quote/reply to")
cmd.Flags().StringVar(&replyToSender, "reply-to-sender", "", "sender JID of the quoted message (required for unsynced group replies)")
cmd.Flags().DurationVar(&postSendWait, "post-send-wait", postSendRetryReceiptWait, "keep the connection alive after send so retry receipts can be handled (0 disables)")
return cmd
}

52
cmd/wacli/signal.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/openclaw/wacli/internal/out"
)
// signalContext returns a context that is cancelled on the first SIGINT/SIGTERM.
// A second signal force-kills the process so that a stuck cleanup never leaves
// the user unable to get their terminal back.
func signalContext() (context.Context, context.CancelFunc) {
return signalContextWithEvents(nil)
}
func signalContextWithEvents(events *out.EventWriter) (context.Context, context.CancelFunc) {
sigCh := make(chan os.Signal, 2)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
return signalContextForChannel(events, sigCh, func() { signal.Stop(sigCh) }, os.Exit)
}
func signalContextForChannel(events *out.EventWriter, sigCh <-chan os.Signal, stopNotify func(), forceExit func(int)) (context.Context, context.CancelFunc) {
ctx, ctxCancel := context.WithCancel(context.Background())
go func() {
sig := <-sigCh
if events.Enabled() {
_ = events.Emit("signal", map[string]any{"signal": sig.String(), "action": "shutdown"})
} else {
fmt.Fprintln(os.Stderr, "\nShutting down (interrupt again to force quit)...")
}
ctxCancel()
sig = <-sigCh
if events.Enabled() {
_ = events.Emit("signal", map[string]any{"signal": sig.String(), "action": "force_quit"})
} else {
fmt.Fprintln(os.Stderr, "Force quit.")
}
forceExit(1)
}()
return ctx, func() {
if stopNotify != nil {
stopNotify()
}
ctxCancel()
}
}

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

@ -0,0 +1,73 @@
package main
import (
"bytes"
"context"
"encoding/json"
"os"
"strings"
"syscall"
"testing"
"time"
"github.com/openclaw/wacli/internal/out"
)
func TestSignalContextWithEventsKeepsStderrNDJSON(t *testing.T) {
var stderr bytes.Buffer
exits := make(chan int, 1)
sigCh := make(chan os.Signal, 2)
ctx, stop := signalContextForChannel(out.NewEventWriter(&stderr, true), sigCh, nil, func(code int) {
exits <- code
})
defer stop()
sigCh <- os.Interrupt
select {
case <-ctx.Done():
case <-time.After(time.Second):
t.Fatal("context was not canceled after first signal")
}
sigCh <- syscall.SIGTERM
select {
case code := <-exits:
if code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
case <-time.After(time.Second):
t.Fatal("force-exit callback was not called after second signal")
}
raw := stderr.String()
if strings.Contains(raw, "Shutting down") || strings.Contains(raw, "Force quit") {
t.Fatalf("human signal text leaked into --events stderr:\n%s", raw)
}
var sawShutdown, sawForceQuit bool
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
var evt struct {
Event string `json:"event"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal([]byte(line), &evt); err != nil {
t.Fatalf("signal line is not JSON %q: %v", line, err)
}
if evt.Event != "signal" {
t.Fatalf("event = %q, want signal", evt.Event)
}
switch evt.Data["action"] {
case "shutdown":
sawShutdown = true
case "force_quit":
sawForceQuit = true
}
}
if !sawShutdown || !sawForceQuit {
t.Fatalf("missing signal events shutdown=%v force_quit=%v in:\n%s", sawShutdown, sawForceQuit, raw)
}
if err := ctx.Err(); err != context.Canceled {
t.Fatalf("ctx.Err() = %v, want context.Canceled", err)
}
}

View File

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

View File

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

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

@ -0,0 +1,13 @@
package main
import "github.com/spf13/cobra"
func newStoreCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "store",
Short: "Manage local data store",
}
cmd.AddCommand(newStoreCleanupCmd(flags))
cmd.AddCommand(newStoreStatsCmd(flags))
return cmd
}

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

@ -0,0 +1,126 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newStoreCleanupCmd(flags *rootFlags) *cobra.Command {
var days int
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up old data from local store",
Long: `Clean up old messages and chats from local storage.
Removes chats with no recent activity and their associated messages.
Use --days to set the threshold (default: 365 days).
Use --dry-run to preview what would be deleted.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
_ = ctx
chats, err := a.DB().ListChatsOlderThan(days)
if err != nil {
return err
}
if len(chats) == 0 {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "nothing to clean up"})
}
fmt.Fprintln(os.Stderr, "Nothing to clean up.")
return nil
}
var totalMessages int64
for _, c := range chats {
count, _ := a.DB().CountChatMessages(c.JID)
totalMessages += count
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"would_delete_chats": len(chats),
"would_delete_messages": totalMessages,
"days": days,
})
}
fmt.Fprintf(os.Stderr, "Would delete %d chat(s) with %d total message(s) (older than %d days):\n", len(chats), totalMessages, days)
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
count, _ := a.DB().CountChatMessages(c.JID)
fmt.Fprintf(os.Stderr, " - %s (%s, %d messages)\n", name, c.JID, count)
}
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d chat(s) with %d total message(s). This cannot be undone.\n", len(chats), totalMessages)
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deletedChats, deletedMessages int64
for _, c := range chats {
count, _ := a.DB().CountChatMessages(c.JID)
if err := a.DB().DeleteChat(c.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete chat %s: %v\n", c.JID, err)
continue
}
deletedChats++
deletedMessages += count
if !flags.asJSON {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s (%d messages)\n", name, count)
}
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"deleted_chats": deletedChats,
"deleted_messages": deletedMessages,
})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d chat(s) with %d message(s).\n", deletedChats, deletedMessages)
return nil
},
}
cmd.Flags().IntVar(&days, "days", 365, "delete data older than N days")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}

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

@ -0,0 +1,68 @@
package main
import (
"context"
"fmt"
"os"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
)
func newStoreStatsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "stats",
Short: "Show store statistics",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
_ = ctx
chats, err := a.DB().ListChats("", 0)
if err != nil {
return err
}
groups, err := a.DB().ListGroups("", 0)
if err != nil {
return err
}
leftGroups, err := a.DB().ListLeftGroups()
if err != nil {
return err
}
totalMessages, err := a.DB().CountMessages()
if err != nil {
return err
}
stats := map[string]any{
"chats": len(chats),
"groups": len(groups),
"left_groups": len(leftGroups),
"messages": totalMessages,
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, stats)
}
fmt.Fprintf(os.Stdout, "Store Statistics:\n")
fmt.Fprintf(os.Stdout, " Chats: %d\n", len(chats))
fmt.Fprintf(os.Stdout, " Groups: %d\n", len(groups))
fmt.Fprintf(os.Stdout, " Left Groups: %d\n", len(leftGroups))
fmt.Fprintf(os.Stdout, " Messages: %d\n", totalMessages)
return nil
},
}
return cmd
}

View File

@ -4,28 +4,42 @@ import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
appPkg "github.com/openclaw/wacli/internal/app"
"github.com/openclaw/wacli/internal/out"
"github.com/spf13/cobra"
appPkg "github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
)
func newSyncCmd(flags *rootFlags) *cobra.Command {
var once bool
var follow bool
var idleExit time.Duration
var maxReconnect time.Duration
var downloadMedia bool
var refreshContacts bool
var refreshGroups bool
var refreshChannels bool
var webhookURL string
var webhookSecret string
var storage syncStorageLimitFlags
cmd := &cobra.Command{
Use: "sync",
Short: "Sync messages (requires prior auth; never shows QR)",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
if err := flags.requireWritable(); err != nil {
return err
}
storage.maxMessagesSet = cmd.Flags().Changed("max-messages")
maxMessages, maxDBSize, err := resolveSyncStorageLimits(storage)
if err != nil {
return err
}
if webhookSecret != "" && webhookURL == "" {
return fmt.Errorf("--webhook-secret requires --webhook")
}
ctx, stop := signalContextWithEvents(out.NewEventWriter(os.Stderr, flags.events))
defer stop()
a, lk, err := newApp(ctx, flags, true, false)
@ -47,13 +61,39 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
mode = appPkg.SyncModeOnce
}
var stopSendDelegate func()
defer func() {
if stopSendDelegate != nil {
stopSendDelegate()
}
}()
var afterConnect func(context.Context) error
if mode == appPkg.SyncModeFollow {
afterConnect = func(ctx context.Context) error {
stop, err := startSendDelegateServer(ctx, a)
if err != nil {
return err
}
stopSendDelegate = stop
return nil
}
}
res, err := a.Sync(ctx, appPkg.SyncOptions{
Mode: mode,
AllowQR: false,
AfterConnect: afterConnect,
DownloadMedia: downloadMedia,
RefreshContacts: refreshContacts,
RefreshGroups: refreshGroups,
RefreshChannels: refreshChannels,
IdleExit: idleExit,
MaxReconnect: maxReconnect,
MaxMessages: maxMessages,
MaxDBSizeBytes: maxDBSize,
WarnNoLimits: true,
WebhookURL: webhookURL,
WebhookSecret: webhookSecret,
})
if err != nil {
return err
@ -73,8 +113,14 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().BoolVar(&once, "once", false, "sync until idle and exit")
cmd.Flags().BoolVar(&follow, "follow", true, "keep syncing until Ctrl+C")
cmd.Flags().DurationVar(&idleExit, "idle-exit", 30*time.Second, "exit after being idle (once mode)")
cmd.Flags().DurationVar(&maxReconnect, "max-reconnect", 5*time.Minute, "give up reconnecting after this duration (0 = unlimited)")
cmd.Flags().BoolVar(&downloadMedia, "download-media", false, "download media in the background during sync")
cmd.Flags().BoolVar(&refreshContacts, "refresh-contacts", false, "refresh contacts from session store into local DB")
cmd.Flags().BoolVar(&refreshGroups, "refresh-groups", false, "refresh joined groups (live) into local DB")
cmd.Flags().BoolVar(&refreshChannels, "refresh-channels", false, "refresh subscribed channels (live) into local DB")
cmd.Flags().StringVar(&webhookURL, "webhook", "", "URL to POST live message JSON")
cmd.Flags().StringVar(&webhookSecret, "webhook-secret", "", "HMAC-SHA256 secret for X-Wacli-Signature header")
cmd.Flags().Int64Var(&storage.maxMessages, "max-messages", 0, "maximum total messages to keep in the local DB before sync stops (0 = unlimited, or WACLI_SYNC_MAX_MESSAGES)")
cmd.Flags().StringVar(&storage.maxDBSize, "max-db-size", "", "maximum wacli.db disk usage before sync stops, e.g. 500MB or 2GB (default: WACLI_SYNC_MAX_DB_SIZE or unlimited)")
return cmd
}

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

@ -0,0 +1,25 @@
package main
import (
"strings"
"testing"
)
func TestSyncCommandExposesWebhookFlags(t *testing.T) {
cmd := newSyncCmd(&rootFlags{})
for _, name := range []string{"webhook", "webhook-secret"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("missing --%s flag", name)
}
}
}
func TestSyncCommandRequiresWebhookForSecret(t *testing.T) {
cmd := newSyncCmd(&rootFlags{})
cmd.SetArgs([]string{"--webhook-secret", "secret"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--webhook-secret requires --webhook") {
t.Fatalf("expected webhook-secret validation error, got %v", err)
}
}

17
cmd/wacli/table.go Normal file
View File

@ -0,0 +1,17 @@
package main
import (
"io"
"text/tabwriter"
)
func newTableWriter(dst io.Writer) *tabwriter.Writer {
return tabwriter.NewWriter(dst, 2, 4, 2, ' ', 0)
}
func tableCell(s string, max int, full bool) string {
if full {
return sanitize(s)
}
return truncate(s, max)
}

View File

@ -11,7 +11,7 @@ func newVersionCmd() *cobra.Command {
Use: "version",
Short: "Print version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println(version)
fmt.Fprintln(cmd.OutOrStdout(), version)
},
}
}

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

@ -0,0 +1,20 @@
package main
import (
"bytes"
"testing"
)
func TestVersionCommandUsesConfiguredOutput(t *testing.T) {
var out bytes.Buffer
cmd := newVersionCmd()
cmd.SetOut(&out)
cmd.SetArgs(nil)
if err := cmd.Execute(); err != nil {
t.Fatalf("version command: %v", err)
}
if got, want := out.String(), version+"\n"; got != want {
t.Fatalf("version output = %q, want %q", got, want)
}
}

1
docs/CNAME Normal file
View File

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

57
docs/accounts.md Normal file
View File

@ -0,0 +1,57 @@
# accounts
Read when: using more than one WhatsApp account, choosing the active account, or migrating from manual `--store` directories.
`wacli accounts` manages named accounts. Each account is an isolated store directory with its own WhatsApp linked-device session, local mirror database, media files, and lock.
## Commands
```bash
wacli accounts list
wacli accounts add NAME [--no-auth]
wacli accounts use NAME
wacli accounts show NAME
wacli accounts remove NAME
```
Use a named account with any command:
```bash
wacli --account work chats list
wacli --account personal send text --to 1234567890 --message "hi"
```
## Config
The default config path is `<base>/config.yaml`, where `<base>` is the default store root (`~/.wacli` on macOS and existing legacy Linux installs, otherwise `~/.local/state/wacli` on Linux).
```yaml
default_account: personal
accounts:
personal:
store: accounts/personal
work:
store: accounts/work
```
Relative `store` paths resolve from the config directory. Absolute paths are allowed for custom layouts.
## Selection Rules
Store selection is intentionally explicit:
1. `--store DIR` uses that exact store and cannot be combined with `--account`.
2. `--account NAME` resolves `NAME` from `config.yaml`.
3. `WACLI_STORE_DIR` keeps its existing override behavior for scripts and one-off stores.
4. If `default_account` is set, commands use that account.
5. Otherwise existing single-store behavior remains: XDG state dir on Linux, or `~/.wacli` elsewhere.
Account names may contain letters, digits, `.`, `_`, and `-`, and must start with a letter or digit.
## Notes
- `accounts add NAME` creates the isolated store and then runs the normal auth/bootstrap flow for that account. Use `--no-auth` to only write config and create the store.
- Locks are per account store, so `wacli --account personal sync --follow` and `wacli --account work chats list` do not block each other unless they share the same store path.
- Cross-account search or status should be explicit aggregate commands, not accidental shared database queries.
- Use `--store DIR` for one-off migration/debugging against an old manual store.

38
docs/auth.md Normal file
View File

@ -0,0 +1,38 @@
# auth
Read when: pairing a store, checking auth state, logging out, or choosing QR vs phone pairing.
`wacli auth` connects interactively and bootstraps sync after successful pairing. `wacli sync` never shows a QR code, so use `auth` first for a new store or named account.
## Commands
```bash
wacli auth [--follow] [--idle-exit 30s] [--download-media] [--qr-format terminal|text] [--phone PHONE] [--events]
wacli auth status
wacli auth logout
wacli --account work auth status
```
## Notes
- Default pairing prints a terminal QR code.
- `--qr-format text` prints the raw QR payload for external renderers.
- `--phone PHONE` uses WhatsApp phone-number pairing instead of QR pairing.
- Transient websocket drops before pairing completes are retried with a fresh QR/code.
- After pairing, auth runs bootstrap sync until idle unless `--follow` is set.
- Bootstrap sync honors `WACLI_SYNC_MAX_MESSAGES` and `WACLI_SYNC_MAX_DB_SIZE` to cap local history growth.
- `--events` emits NDJSON lifecycle events on stderr, including raw QR and phone-pairing codes for external renderers.
- `auth status` reports whether the local store is authenticated.
- `auth logout` invalidates the linked-device session and requires writable mode.
- For multiple accounts, prefer `wacli accounts add NAME`; it creates an isolated account store and runs the same auth/bootstrap flow.
## Examples
```bash
wacli auth
wacli auth --qr-format text
wacli auth --phone "+1 (234) 567-8900"
wacli auth --download-media
wacli auth status --json
wacli auth logout
```

37
docs/channels.md Normal file
View File

@ -0,0 +1,37 @@
# channels
Read when: listing, joining, leaving, inspecting, or sending to WhatsApp Channels.
`wacli channels` manages WhatsApp Channels, which `whatsmeow` calls newsletters. Commands use live WhatsApp APIs and require authentication. Commands that update WhatsApp or the local chat cache require writable mode.
## Commands
```bash
wacli channels list
wacli channels info --jid CHANNEL_JID
wacli channels join --invite LINK_OR_CODE
wacli channels leave --jid CHANNEL_JID
```
## Notes
- Channel JIDs use the `...@newsletter` server.
- `channels list` fetches subscribed channels live and updates local chat rows with kind `newsletter`.
- `channels info` fetches one joined channel live and updates the local chat row.
- `channels join` accepts a full `https://whatsapp.com/channel/...` link or just the invite code.
- `channels leave` unfollows the channel on WhatsApp.
- `sync --refresh-channels` refreshes subscribed channel names into the local chat cache.
- `send text --to ...@newsletter` can send to channels when the authenticated account has permission.
- `send file --to ...@newsletter` uses WhatsApp's unencrypted newsletter media upload path and requires channel posting permission.
- Quoted file replies and `--ptt` voice-note mode are not supported for channels.
## Examples
```bash
wacli channels list
wacli channels info --jid 123456789012345@newsletter
wacli channels join --invite https://whatsapp.com/channel/AbCdEfGhIjK
wacli channels leave --jid 123456789012345@newsletter
wacli send text --to 123456789012345@newsletter --message "Hello channel"
wacli send file --to 123456789012345@newsletter --file ./image.png --caption "Update"
```

47
docs/chats.md Normal file
View File

@ -0,0 +1,47 @@
# chats
Read when: listing known chats, filtering chat state, archiving/pinning/muting/marking chats, or pruning stale local chat rows.
`wacli chats` reads chat rows from `wacli.db`. It can use session-backed PN/LID mappings to make historical `@lid` chat rows display as phone-number chats when possible. State commands send WhatsApp app-state patches through the authenticated session and update the local index after WhatsApp accepts the change.
## Commands
```bash
wacli chats list [--query TEXT] [--limit N] [--archived|--no-archived] [--pinned|--no-pinned] [--muted|--no-muted] [--unread|--no-unread]
wacli chats show --jid JID
wacli chats archive --chat CHAT [--pick N]
wacli chats unarchive --chat CHAT [--pick N]
wacli chats pin --chat CHAT [--pick N]
wacli chats unpin --chat CHAT [--pick N]
wacli chats mute --chat CHAT [--duration DURATION] [--pick N]
wacli chats unmute --chat CHAT [--pick N]
wacli chats mark-read --chat CHAT [--pick N]
wacli chats mark-unread --chat CHAT [--pick N]
wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]
```
## Notes
- `list` is local and sorted by pinned chats first, then newest known message timestamp.
- `--query` filters by chat name or JID.
- `list --json` and `show --json` include `archived`, `pinned`, `muted_until`, and `unread`.
- `show` accepts the stored JID. If a phone JID maps to a historical `@lid` row, it can show that row too.
- State commands use `--chat` and resolve names, phone numbers, groups, and JIDs like send commands. Use `--pick N` for ambiguous matches.
- State commands print a compact success line by default and a stable JSON object with `--json`.
- `mute --duration 0` or omitting `--duration` mutes forever. Use `unmute` to clear it.
- Run `wacli sync` to catch up chat-state changes made on other devices; run `wacli contacts refresh` to improve chat names.
- `cleanup` only deletes local `wacli.db` rows. It does not delete chats or messages from WhatsApp.
- `cleanup --days N` skips chats with no known local activity timestamp; use `--jid` for an explicit local row.
- Use `cleanup --dry-run` before deleting and `--confirm` only for scripts that already reviewed the target list.
## Examples
```bash
wacli chats list
wacli chats list --query family --limit 20
wacli chats list --pinned
wacli chats show --jid 1234567890@s.whatsapp.net
wacli chats mute --chat "+1 555 123 4567" --duration 8h
wacli chats mark-read --chat family --pick 1
wacli chats cleanup --days 365 --dry-run
```

25
docs/completion.md Normal file
View File

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

View File

@ -0,0 +1,122 @@
# contacts import-system
Read when: importing macOS Contacts names into wacli, previewing matched phone numbers, clearing imported names, or feeding contacts from JSON/NDJSON.
`wacli contacts import-system` matches phone numbers from your system contacts against contacts already stored in `wacli.db`, then stores the system display name as local wacli metadata.
It does not modify WhatsApp, your phone contacts, or macOS Contacts.
## Before Importing
Run a contact refresh first so wacli has the latest WhatsApp-side contact rows:
```bash
wacli contacts refresh
```
Then preview the import:
```bash
wacli contacts import-system --dry-run
```
The dry run prints how many local contacts would receive a system name, plus skipped counts for contacts with no phone number, no system match, or an already-current system name.
## Apply
```bash
wacli contacts import-system
```
On macOS, this reads Contacts.app through the Contacts framework. macOS may prompt for Contacts permission the first time. If access is denied, grant Contacts access in System Settings and run the command again.
The command stores names in `contacts.system_name`. Display and search precedence is:
```text
alias > system_name > WhatsApp full/push/business/first name
```
Manual aliases still win. Use aliases for intentional local nicknames; use system names to mirror your address book display names.
## JSON
Use global `--json` for machine-readable output:
```bash
wacli --json contacts import-system --dry-run
```
The JSON response is wrapped in the standard envelope. Import details live under `.data`:
```json
{
"success": true,
"data": {
"matched": 42,
"matches": [
{
"jid": "1234567890@s.whatsapp.net",
"phone": "1234567890",
"current_name": "WhatsApp Name",
"system_name": "Address Book Name"
}
],
"skipped_no_phone": 0,
"skipped_no_match": 10,
"skipped_same": 5,
"dry_run": true
},
"error": null
}
```
## Import From A File
Use `--input FILE` to import from a JSON array or newline-delimited JSON instead of opening macOS Contacts:
```bash
wacli contacts import-system --input contacts.json --dry-run
wacli contacts import-system --input contacts.ndjson
```
Each contact object can contain `full_name`, `first_name`, `last_name`, and `phones`:
```json
[
{
"full_name": "Alice Appleseed",
"phones": ["+1 (415) 734-7847"]
}
]
```
NDJSON works too:
```json
{"full_name":"Alice Appleseed","phones":["+1 (415) 734-7847"]}
{"first_name":"Bob","last_name":"Builder","phones":["0043 664 104 2436"]}
```
Phone matching strips non-digits. Numbers with a leading international `00` prefix are normalized to the same digits as `+`.
## Clear Imported Names
Preview and clear imported system names:
```bash
wacli contacts import-system --clear --dry-run
wacli contacts import-system --clear
```
Clearing removes only `system_name` values. It does not remove contacts, aliases, tags, messages, WhatsApp data, or macOS Contacts entries.
## Verify
Show a contact and search by its imported system name:
```bash
wacli contacts show --jid 1234567890@s.whatsapp.net
wacli contacts search "Alice Appleseed"
```
`contacts show` includes `System Name:` when one is present. Search matches imported system names in addition to aliases, WhatsApp names, phone numbers, and JIDs.

41
docs/contacts.md Normal file
View File

@ -0,0 +1,41 @@
# contacts
Read when: finding synced contacts, importing macOS Contacts names, or managing local contact metadata.
`wacli contacts` works with contact metadata stored locally. Aliases and tags are local to `wacli`; they do not edit WhatsApp contacts on the phone.
## Commands
```bash
wacli contacts search <query> [--limit N]
wacli contacts show --jid JID
wacli contacts refresh
wacli contacts import-system [--input FILE] [--dry-run] [--clear]
wacli contacts alias set --jid JID --alias NAME
wacli contacts alias rm --jid JID
wacli contacts tags add --jid JID --tag TAG
wacli contacts tags rm --jid JID --tag TAG
```
## Notes
- `search` matches alias, full name, push name, first name, business name, phone, and JID.
- `refresh` imports contacts from the whatsmeow session store into `wacli.db`.
- `import-system` imports display names from macOS Contacts by matching phone numbers against already-synced wacli contacts. Run `contacts refresh` first.
- `import-system --input FILE` reads a JSON array or newline-delimited JSON contacts file with `full_name` and `phones` fields instead of opening macOS Contacts.
- Imported system names are local wacli metadata. They do not edit WhatsApp contacts or macOS Contacts.
- Display precedence is local alias, imported system name, then WhatsApp names.
- Use `import-system --dry-run` before writing. Use `import-system --clear` to remove imported system names.
- See [contacts import-system](contacts-import-system.md) for the full import workflow, JSON shape, file format, and verification steps.
- Tags are local grouping metadata for scripts and future workflows.
## Examples
```bash
wacli contacts search Alice
wacli contacts show --jid 1234567890@s.whatsapp.net
wacli contacts refresh
wacli contacts import-system --dry-run
wacli contacts alias set --jid 1234567890@s.whatsapp.net --alias mom
wacli contacts tags add --jid 1234567890@s.whatsapp.net --tag family
```

26
docs/docs.md Normal file
View File

@ -0,0 +1,26 @@
# docs
Read when: opening the hosted documentation site from the CLI.
`wacli docs` prints the canonical hosted documentation URL: <https://wacli.sh>.
Use it from scripts or terminal sessions when you need a stable pointer to the
GitHub Pages documentation.
## Command
```bash
wacli docs
```
## JSON
```bash
wacli --json docs
```
## Examples
```bash
wacli docs
open "$(wacli docs)"
```

26
docs/doctor.md Normal file
View File

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

51
docs/groups.md Normal file
View File

@ -0,0 +1,51 @@
# groups
Read when: listing, refreshing, inspecting, renaming, joining, leaving, inviting, pruning stale local group rows, or managing group participants.
`wacli groups` combines local group rows with live WhatsApp operations. Commands that mutate WhatsApp require writable mode.
## Commands
```bash
wacli groups list [--query TEXT] [--limit N]
wacli groups refresh
wacli groups info --jid GROUP_JID
wacli groups rename --jid GROUP_JID --name NAME
wacli groups leave --jid GROUP_JID
wacli groups participants add --jid GROUP_JID --user PHONE_OR_JID [--user ...]
wacli groups participants remove --jid GROUP_JID --user PHONE_OR_JID [--user ...]
wacli groups participants promote --jid GROUP_JID --user PHONE_OR_JID [--user ...]
wacli groups participants demote --jid GROUP_JID --user PHONE_OR_JID [--user ...]
wacli groups invite link get --jid GROUP_JID
wacli groups invite link revoke --jid GROUP_JID
wacli groups join --code INVITE_CODE
wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]
```
## Notes
- Group JIDs use the `...@g.us` server.
- `list` reads local rows and hides groups marked left. Human output includes the group type (`group`, `community`, or `subgroup`) and parent community JID when known.
- `list --json` includes `IsParent` for communities and `LinkedParentJID` for subgroups.
- `refresh` fetches joined groups live and updates local rows, including WhatsApp Community hierarchy metadata exposed by whatsmeow.
- `info` fetches one group live and persists it, including whether the chat is a Community parent or linked subgroup.
- `leave` marks the group left locally after WhatsApp confirms.
- `prune` only deletes local group/chat/message rows from `wacli.db`. It does not leave WhatsApp groups or delete anything from WhatsApp servers.
- `prune` defaults to groups marked left locally. `--days N` limits left-group pruning to groups left more than `N` days ago.
- `prune --include-active --days N` also targets active groups whose last known local message is older than `N` days. Groups with no known local activity timestamp are skipped.
- Use `prune --dry-run` before deleting and `--confirm` only after reviewing the target list.
- Participant users accept phone numbers with common formatting or JIDs.
- Invite `revoke` resets the invite link.
## Examples
```bash
wacli groups list --query family
wacli groups refresh
wacli groups info --jid 123456789@g.us
wacli groups rename --jid 123456789@g.us --name "New name"
wacli groups participants add --jid 123456789@g.us --user "+1 (234) 567-8900"
wacli groups invite link get --jid 123456789@g.us
wacli groups join --code AbCdEfGhIjK
wacli groups prune --dry-run
```

22
docs/help.md Normal file
View File

@ -0,0 +1,22 @@
# help
Read when: discovering command usage from the CLI itself.
`wacli help` is the Cobra-provided help command. Every command also accepts `--help`.
Root help prints the hosted documentation URL, and `wacli docs` prints it directly.
## Commands
```bash
wacli help [command]
wacli [command] --help
```
## Examples
```bash
wacli help send
wacli send text --help
wacli docs
wacli groups participants add --help
```

39
docs/history.md Normal file
View File

@ -0,0 +1,39 @@
# history
Read when: trying to fetch older messages for a known chat.
`wacli history` inspects local archive coverage and can send on-demand history sync requests to the primary device. Backfill is best-effort and depends on the phone being online and WhatsApp returning older messages.
## Commands
```bash
wacli history coverage [--query TEXT] [--kind KIND] [--include-blocked] [--only-actionable]
wacli history fill --dry-run [--query TEXT] [--kind KIND] [--limit 100]
wacli history backfill --chat JID [--count 50] [--requests N] [--wait 1m] [--idle-exit 5s] [--events]
```
## Coverage and planning
- `history coverage` reads only the local `wacli.db` store.
- `ready` chats have at least one local message, so `history backfill` has an anchor.
- `blocked` / `no_local_anchor` chats have no local message yet; run `wacli sync` first.
- `history fill --dry-run` lists matching ready chats that would be selected for a future multi-chat fill workflow. It does not connect to WhatsApp or write state.
## Limits
- `--count` defaults to 50 and must be at most 500.
- `--requests` defaults to 1 and must be at most 100.
- Requests are per chat.
- The anchor is the oldest locally stored message in that chat.
- Automatic initial history-sync blob downloads are disabled during backfill; only on-demand responses are processed.
- `--events` emits NDJSON request/response/stop lifecycle events on stderr.
## Examples
```bash
wacli history coverage --include-blocked
wacli history coverage --query family --only-actionable
wacli history fill --dry-run --kind group --limit 20
wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
wacli history backfill --chat 123456789@g.us --requests 3 --wait 90s
```

48
docs/index.md Normal file
View File

@ -0,0 +1,48 @@
---
title: Overview
permalink: /
description: "wacli is a single Go CLI that pairs as a linked WhatsApp Web device, mirrors message history into local SQLite with FTS5 search, and exposes send, media, contact, and group workflows for terminals, scripts, and coding agents."
---
# wacli
A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/whatsmeow). One binary pairs as a linked WhatsApp Web device, syncs messages into a local SQLite store, and exposes search, send, media, contact, and group commands with predictable output for terminals, shell pipelines, and coding agents.
## Why wacli
- **Local mirror, fast search.** All synced messages land in a SQLite store with an FTS5 index; offline `messages search` returns hits in milliseconds.
- **Chat state controls.** Archive, pin, mute, and mark chats read/unread from the CLI, then filter `chats list` by those states.
- **Stable output.** Human-readable tables by default, `--json` to stdout for scripts, NDJSON `--events` for long-running commands. Human progress, prompts, and errors stay on stderr so pipes stay clean.
- **Single binary.** No daemon, no plugin host. Run `wacli auth`, then `wacli sync --follow` to keep the store warm.
- **Built for agents.** `--read-only` (or `WACLI_READONLY=1`) blocks every command that mutates WhatsApp or local state. Store locks prevent two instances from racing on the same device identity.
- **Boundable storage.** `sync` warns when storage is uncapped; `--max-messages` / `--max-db-size` cap local growth. Send retries are bounded; media uploads/downloads cap at 100 MiB.
- **Best-effort history.** `history coverage` shows local anchors, `history fill --dry-run` plans candidate chats, and `history backfill` requests older messages per chat from your primary device.
## Pick your path
- **Trying it.** Read [Install](install.md), then [Quickstart](quickstart.md). Pair, sync, and send your first message in under five minutes.
- **Using multiple WhatsApp accounts.** Read [Accounts](accounts.md) for named account stores and `--account`.
- **Searching old chats.** Read [Sync](sync.md) for the sync model and [History](history.md) for coverage planning and on-demand backfill.
- **Managing chat state.** Read [Chats](chats.md) for archive, pin, mute, and read/unread commands.
- **Managing local storage.** Read [Store](store.md) for stats, dry-run cleanup, and local-only pruning.
- **Sending from scripts.** Read [Send](send.md) for recipient resolution, channels, replies, mentions, files, and reactions.
- **Mirroring address-book names.** Read [Contacts import-system](contacts-import-system.md) to import macOS Contacts display names into local wacli metadata.
- **Wiring up an agent.** Pair `--read-only`, `--json`, and `--events` from [Overview](overview.md); read [Doctor](doctor.md) for self-checks.
- **Building companion tools.** Read [Companion integrations](integrations.md) for safe read-only SQLite and JSON integration patterns.
- **Looking up a flag.** Open the per-command pages from [Overview](overview.md).
## Status
Core implementation is in place. The [CHANGELOG](https://github.com/openclaw/wacli/blob/main/CHANGELOG.md) tracks shipped behavior. WhatsApp Web is not a published API; expect occasional breakage from upstream protocol changes — `wacli` follows `whatsmeow` upstream.
## Out of scope
- Guaranteed full-history export (WhatsApp Web history is best-effort).
- A daemon, MCP server, web UI, or GUI.
- End-to-end "contact creation" inside WhatsApp; local aliases and tags only.
## Disclaimer
`wacli` is a third-party tool that uses the WhatsApp Web protocol via `whatsmeow`. It is **not affiliated with WhatsApp or Meta**. Use at your own risk; pairing as a linked device is subject to WhatsApp's terms.
Released under the [MIT license](https://github.com/openclaw/wacli/blob/main/LICENSE).

90
docs/install.md Normal file
View File

@ -0,0 +1,90 @@
---
title: Install
description: "Install wacli via Homebrew tap, prebuilt release archives, or a local build with cgo."
---
# Install
`wacli` ships as a single binary. Local builds need cgo (because of `go-sqlite3` with FTS5); release artifacts and the Homebrew tap take care of that for you.
## Homebrew (macOS, Linux)
```bash
brew install steipete/tap/wacli
wacli --version
```
If a Linux install from the tap reports `Binary was compiled with 'CGO_ENABLED=0'`, update the tap and reinstall the formula:
```bash
brew update
brew reinstall steipete/tap/wacli
```
## GitHub releases (raw binaries)
Download the matching archive from the [latest release](https://github.com/openclaw/wacli/releases) and put `wacli` (or `wacli.exe` on Windows) on your `PATH`.
## Build from source
`wacli` uses `go-sqlite3`, so source builds require cgo and a C toolchain:
- macOS: Xcode Command Line Tools.
- Debian / Ubuntu: `sudo apt install build-essential`.
- Fedora / RHEL: `sudo dnf groupinstall "Development Tools"`.
Then:
```bash
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go install -tags sqlite_fts5 github.com/openclaw/wacli/cmd/wacli@latest
```
For local development:
```bash
git clone https://github.com/openclaw/wacli.git
cd wacli
CGO_ENABLED=1 CGO_CFLAGS="-Wno-error=missing-braces" \
go build -tags sqlite_fts5 -o ./dist/wacli ./cmd/wacli
./dist/wacli --version
```
The `sqlite_fts5` build tag is required for `messages search` to use the FTS5 index. Without it, search falls back to `LIKE`.
GCC 15 has stricter brace-init warnings; the `-Wno-error=missing-braces` flag keeps the `go-sqlite3` build green there. macOS / clang and older GCC do not need it.
If you have `pnpm` installed, `pnpm build` runs the same command and writes `./dist/wacli`.
## Verify the install
```bash
wacli --version
wacli doctor
wacli --help
```
`wacli doctor` checks the store directory, database integrity, FTS5 availability, and (with `--connect`) live connectivity to WhatsApp. See [Doctor](doctor.md).
## Updating
- **Homebrew tap**: `brew upgrade wacli` (or `brew reinstall steipete/tap/wacli`).
- **GitHub release archives**: download the new tarball / ZIP and replace the binary.
- **Source builds**: `git pull && pnpm build` (or the manual `go build` above). Local builds use the version compiled into the source tree; release artifacts inject the tag during GoReleaser builds.
The local store format is forward-compatible across point releases; routine upgrades do not require re-pairing.
## Storage
- Default store directory: `~/.local/state/wacli` on Linux (XDG state dir), `~/.wacli` on macOS / Windows. Existing Linux `~/.wacli` directories keep working.
- Override with `--store DIR` or `WACLI_STORE_DIR`.
- The store contains `session.db` (whatsmeow keys), `wacli.db` (messages + FTS), `media/`, and a `LOCK` file. See [Spec](spec.md#storage-layout) for the layout.
- Permissions are owner-only (`0700` on the directory, `0600` on files). Do not relax these — they protect your WhatsApp session keys.
## Related pages
- [Quickstart](quickstart.md) — pair, sync, and send your first message.
- [Auth](auth.md) — `wacli auth`, `auth status`, `auth logout`.
- [Sync](sync.md) — bootstrap and follow-mode sync, refresh flags.
- [Doctor](doctor.md) — self-checks and connectivity probe.
- [Release](release.md) — release workflow and artifact expectations.

137
docs/integrations.md Normal file
View File

@ -0,0 +1,137 @@
# companion integrations
Read when: building a local analytics, search, CRM, or agent-side companion tool on top of synced `wacli` data.
`wacli` is intentionally useful from scripts without becoming a plugin host. Companion tools should prefer stable CLI output first, then use read-only SQLite access when they need low-latency local queries or their own derived database.
## Integration surfaces
- Use `--json` for one-shot command output from `chats`, `contacts`, `groups`, `messages`, and `doctor`.
- Use `--events` for line-delimited lifecycle events from long-running `auth`, `sync`, and `history backfill` commands.
- Use `sync --webhook` for live-message delivery to another process or service.
- Use a read-only SQLite connection to `<store>/wacli.db` for local analytics that need joins, cursors, or incremental scans.
Prefer the CLI or webhook when possible. Direct SQLite reads are powerful, but the schema can evolve between releases.
## Store paths
The default store is:
- Linux: `~/.local/state/wacli`, with legacy `~/.wacli` reused when present.
- macOS and other platforms: `~/.wacli`.
Override with `--store DIR` or `WACLI_STORE_DIR`. Named accounts live in `config.yaml` and resolve with `--account NAME`; each account points at a normal isolated store directory.
The store contains two SQLite databases:
- `session.db`: owned by `whatsmeow`; contains linked-device identity and keys.
- `wacli.db`: owned by `wacli`; contains chats, contacts, groups, messages, media metadata, and local state.
Companion tools should not read or write `session.db` unless they are explicitly working on WhatsApp session internals. Never write to `wacli.db` from a companion tool.
For multi-account tools, iterate configured accounts explicitly and annotate derived rows with the account name in the companion tool's own database. Do not merge account data into `wacli.db`.
## Read-only SQLite
Open the database in SQLite read-only mode:
```bash
sqlite3 "file:$HOME/.wacli/wacli.db?mode=ro" \
"SELECT chat_jid, msg_id, datetime(ts, 'unixepoch') AS at, display_text
FROM messages
WHERE revoked = 0 AND deleted_for_me = 0
ORDER BY ts DESC
LIMIT 20"
```
In Python:
```python
from pathlib import Path
import sqlite3
db = Path.home() / ".wacli" / "wacli.db"
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
conn.row_factory = sqlite3.Row
rows = conn.execute("""
SELECT chat_jid, msg_id, sender_jid, sender_name, ts, display_text
FROM messages
WHERE revoked = 0 AND deleted_for_me = 0
ORDER BY ts DESC
LIMIT ?
""", (50,)).fetchall()
```
Avoid `immutable=1` when `wacli sync --follow` may be writing concurrently; a normal read-only SQLite connection can see WAL updates safely.
## Common queries
Recent human-visible messages:
```sql
SELECT
m.chat_jid,
COALESCE(m.chat_name, c.name, '') AS chat_name,
m.msg_id,
m.sender_jid,
COALESCE(m.sender_name, '') AS sender_name,
m.ts,
COALESCE(m.display_text, m.text, '') AS text
FROM messages m
LEFT JOIN chats c ON c.jid = m.chat_jid
WHERE m.revoked = 0
AND m.deleted_for_me = 0
ORDER BY m.ts DESC
LIMIT 100;
```
Incremental scan cursor:
```sql
SELECT rowid, chat_jid, msg_id, sender_jid, ts, display_text
FROM messages
WHERE rowid > ?
ORDER BY rowid ASC
LIMIT 1000;
```
Known chats by newest activity:
```sql
SELECT jid, kind, name, last_message_ts, archived, pinned, muted_until, unread
FROM chats
ORDER BY COALESCE(last_message_ts, 0) DESC
LIMIT 100;
```
Community subgroups:
```sql
SELECT jid, name, linked_parent_jid
FROM groups
WHERE linked_parent_jid IS NOT NULL
ORDER BY name;
```
## Privacy and safety
- Store derived data in your own database, not in `wacli.db`.
- Treat JIDs, display names, message text, media filenames, and local media paths as sensitive.
- Hash JIDs with a tool-local salt if you only need stable identity buckets.
- Provide a delete or opt-out path if the companion tool tracks people.
- Do not copy `session.db`, media keys, or WhatsApp device keys into unrelated systems.
- Use `WACLI_READONLY=1` when shelling out to `wacli` from a tool that should never mutate WhatsApp or the local store.
## Speaker-tracking pattern
A speaker tracker can stay small and non-invasive:
1. Run `wacli sync --follow` separately to keep the store warm.
2. Keep a cursor using the largest processed `messages.rowid`.
3. Read only new rows from `messages` in read-only mode.
4. Skip `from_me` rows if you only want contacts.
5. Hash `sender_jid` before writing to the tool database.
6. Store counts, first/last seen timestamps, and opt-out state in the tool database.
This pattern keeps `wacli` responsible for WhatsApp sync and keeps the companion tool responsible only for its derived local state.

26
docs/media.md Normal file
View File

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

68
docs/messages.md Normal file
View File

@ -0,0 +1,68 @@
# messages
Read when: listing, searching, exporting, showing, or inspecting local message context.
Most `wacli messages` commands read from the local store. `messages edit` and `messages delete` are remote WhatsApp mutations and require an authenticated, writable store.
## Commands
```bash
wacli messages list [--chat JID] [--sender JID] [--from-me|--from-them] [--asc] [--limit N] [--after DATE] [--before DATE] [--forwarded] [--starred]
wacli messages search <query> [--chat JID] [--from JID] [--has-media] [--type text|image|video|audio|document] [--forwarded] [--starred] [--limit N] [--after DATE] [--before DATE]
wacli messages starred [--chat JID] [--limit N] [--after DATE] [--before DATE] [--asc]
wacli messages export [--chat JID] [--limit N] [--after DATE] [--before DATE] [--output PATH]
wacli messages show --chat JID --id MSG_ID
wacli messages context --chat JID --id MSG_ID [--before N] [--after N]
wacli messages edit --chat JID --id MSG_ID --message TEXT [--post-send-wait 2s]
wacli messages delete --chat JID --id MSG_ID [--for-me] [--delete-media] [--post-send-wait 2s]
```
## Search
- Uses SQLite FTS5 when the binary was built with `-tags sqlite_fts5`.
- Falls back to `LIKE` if FTS5 is not available.
- `--type` accepts `text`, `image`, `video`, `audio`, or `document`.
- Shared WhatsApp contact cards are stored as searchable text with contact names and phone numbers when WhatsApp includes a vCard payload.
- `--starred` restricts list/search results to messages marked as starred by WhatsApp.
- Time filters accept RFC3339 or `YYYY-MM-DD`.
## Starred
- `messages starred` lists starred messages ordered by star time when app-state events provide it; history-imported rows fall back to message time.
- `--after` and `--before` on `messages starred` filter by that stored star time.
- Starred state is imported from history sync and app-state star/unstar events.
## Export
- `messages export` writes a JSON export envelope with messages ordered oldest first.
- Use `--chat` to export one chat, or omit it to export recent messages across chats.
- Use `--after` and `--before` to bound the exported time window.
- Use `--output` to write the JSON export to a file.
## Edit and Delete
- `messages edit` updates one of your own recent sent text messages. WhatsApp only accepts edits inside its current edit window.
- `messages delete` revokes one of your own sent messages for everyone.
- `messages delete --for-me` removes a stored message only for your WhatsApp account using WhatsApp's `deleteMessageForMe` app-state patch; it can target messages sent by you or by others. `--delete-media` is only valid with `--for-me`.
- Both commands look up the target in the local store first and honor `--read-only`/`WACLI_READONLY`. Delete-for-everyone and edit require a message sent by you.
- Deleted messages and WhatsApp delete-for-me events are kept as local tombstones for direct `messages show`, but are hidden from normal list/search/starred/export results.
## LID mapping
When a phone-number chat JID maps to a stored `@lid` row, list/search/show/context include the mapped rows so historical LID splits do not hide messages.
## Examples
```bash
wacli messages list --chat 1234567890@s.whatsapp.net --asc
wacli messages list --from-me --limit 20
wacli messages starred --limit 20
wacli messages search "invoice" --has-media --type document
wacli messages search "invoice" --starred
wacli messages export --chat 1234567890@s.whatsapp.net --after 2024-01-01 --before 2024-02-01 --output messages.json
wacli messages show --chat 1234567890@s.whatsapp.net --id ABC123
wacli messages context --chat 1234567890@s.whatsapp.net --id ABC123 --before 3 --after 3
wacli messages edit --chat 1234567890@s.whatsapp.net --id ABC123 --message "updated text"
wacli messages delete --chat 1234567890@s.whatsapp.net --id ABC123
wacli messages delete --chat 1234567890@s.whatsapp.net --id ABC123 --for-me
```

65
docs/overview.md Normal file
View File

@ -0,0 +1,65 @@
# wacli overview
Read when: you need the user-facing command map, global flags, store model, or links to command-specific docs.
`wacli` is a WhatsApp CLI built on `whatsmeow`. It pairs as a linked WhatsApp Web device, stores message metadata locally, supports offline search, and exposes send/media/group/contact workflows for scripts and humans. Named accounts let multiple WhatsApp identities use isolated stores via `--account`.
## Store and output
- Default store: `~/.local/state/wacli` on Linux, `~/.wacli` elsewhere.
- Existing Linux `~/.wacli` stores are reused when no XDG store exists.
- Override the store with `--store DIR` or `WACLI_STORE_DIR`.
- Human-readable tables are the default.
- Use `--json` for scriptable output.
- Use `--full` to avoid table truncation.
- Write commands acquire the store lock; use `--lock-wait DURATION` to wait.
- Use `--read-only` or `WACLI_READONLY=1` to reject commands that write WhatsApp or local state.
- Use `sync --max-messages`, `sync --max-db-size`, `WACLI_SYNC_MAX_MESSAGES`, or `WACLI_SYNC_MAX_DB_SIZE` to bound local history growth.
- Use `store cleanup`, `chats cleanup`, and `groups prune` to preview and remove stale local rows after sync has already stored them.
- Authenticated startup resolves historical `@lid` chat/message rows to phone-number JIDs when the WhatsApp session store has the mapping.
- Companion tools should prefer `--json`, `--events`, webhooks, or read-only access to `wacli.db`; see [companion integrations](integrations.md).
## Command pages
- [auth](auth.md) - pair, inspect auth status, logout.
- [accounts](accounts.md) - create and select named account stores.
- [sync](sync.md) - sync messages, contacts, groups, channels, and optional media.
- [messages](messages.md) - list, search, show, and contextualize stored messages.
- [send](send.md) - send text, files, stickers, replies, and reactions.
- [media](media.md) - download media attached to stored messages.
- [contacts](contacts.md) - search contacts and manage local aliases/tags.
- [contacts import-system](contacts-import-system.md) - import macOS Contacts names into local contact metadata.
- [chats](chats.md) - list, show, filter, and manage known chat state.
- [groups](groups.md) - refresh, inspect, rename, leave, join, invite, and manage participants.
- [store](store.md) - inspect local store stats and prune stale local rows.
- [channels](channels.md) - list, inspect, join, leave, and send to WhatsApp Channels.
- [history](history.md) - inspect archive coverage and request older per-chat history from the primary device.
- [presence](presence.md) - send typing/paused indicators.
- [profile](profile.md) - set the authenticated account profile picture.
- [doctor](doctor.md) - diagnose store, auth, search, and optional live connectivity.
- [docs](docs.md) - print the hosted documentation URL.
- [version](version.md) - print the CLI version.
- [completion](completion.md) - generate shell completion scripts.
- [help](help.md) - inspect command help from the CLI.
- [companion integrations](integrations.md) - build read-only local tools on top of synced data.
## Common flow
```bash
wacli auth
wacli sync --follow
wacli messages search "meeting"
wacli send text --to mom --message "hello"
```
## Recipient formats
Commands that accept `PHONE_OR_JID` accept a WhatsApp JID like `1234567890@s.whatsapp.net`, a group JID like `123456789@g.us`, a channel JID like `123456789012345@newsletter`, or a phone number with common formatting such as `+1 (234) 567-8900`.
`send text`, `send file`, `send sticker`, and `send voice` also accept synced contact, group, or chat names through `RECIPIENT`. If a name is ambiguous, interactive terminals prompt; scripts can use `--pick N`.
`chats archive`, `chats pin`, `chats mute`, and `chats mark-read` use the same synced contact/group/chat resolver through `--chat`. Pass a raw JID when you need an exact target.
## History limits
WhatsApp Web history is best-effort. `wacli sync` stores events WhatsApp provides, and `wacli history backfill` can ask the primary phone for older messages per chat. It cannot guarantee a full account export.

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