From 2f294e2bb6fa28048587baae999e920c8836a676 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:32:56 +0100 Subject: [PATCH] fix: warn on rapid send commands --- CHANGELOG.md | 1 + README.md | 2 +- cmd/wacli/send.go | 3 ++ cmd/wacli/send_file_cmd.go | 4 +++ cmd/wacli/send_helpers.go | 28 ++++++++++++++++ cmd/wacli/send_helpers_test.go | 59 ++++++++++++++++++++++++++++++++++ cmd/wacli/send_react_cmd.go | 4 +++ docs/send.md | 2 +- 8 files changed, 101 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06d86bd..deff6c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - 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) - Store: restrict index and session SQLite database files to owner-only permissions. (#147 — thanks @draix) diff --git a/README.md b/README.md index 633fea8..e185bc0 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Core implementation is in place. Start with [docs/overview.md](docs/overview.md) - **Auth + sync**: `auth` shows QR login and bootstraps sync; `sync` is non-interactive, can run once or follow continuously, and can refresh contacts/groups. - **Offline message store**: local SQLite store with FTS5 search when available and LIKE fallback. - **Message tools**: list/search/show/context with chat, sender, direction, time, order, and media-type filters. -- **Sending**: send text, quoted replies, and image/video/audio/document files with captions, MIME override, and custom display filenames. +- **Sending**: send text, quoted replies, and image/video/audio/document files with captions, MIME override, and custom display filenames. Rapid repeated sends warn on stderr. - **Media**: download synced message media on demand, or download in the background during auth/sync; send-file uploads and downloads are capped at 100 MiB. - **Contacts/chats/groups**: search/show contacts, local aliases/tags, list/show chats, refresh/list/info/rename groups, manage participants, invite links, join, and leave; left groups are hidden after leave. - **Presence**: send typing/paused indicators. diff --git a/cmd/wacli/send.go b/cmd/wacli/send.go index fb23836..543c24a 100644 --- a/cmd/wacli/send.go +++ b/cmd/wacli/send.go @@ -68,6 +68,9 @@ func newSendTextCmd(flags *rootFlags) *cobra.Command { 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 := runSendOperation(ctx, reconnectForSend(a), func(ctx context.Context) (types.MessageID, error) { return sendTextMessage(ctx, a, toJID, message, replyTo, replyToSender) diff --git a/cmd/wacli/send_file_cmd.go b/cmd/wacli/send_file_cmd.go index 892f36a..2806cf3 100644 --- a/cmd/wacli/send_file_cmd.go +++ b/cmd/wacli/send_file_cmd.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "time" "github.com/spf13/cobra" "github.com/steipete/wacli/internal/out" @@ -50,6 +51,9 @@ func newSendFileCmd(flags *rootFlags) *cobra.Command { 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 sendFileResult struct { id string diff --git a/cmd/wacli/send_helpers.go b/cmd/wacli/send_helpers.go index 149fc21..d357ce0 100644 --- a/cmd/wacli/send_helpers.go +++ b/cmd/wacli/send_helpers.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "io" + "os" + "path/filepath" "strings" "time" @@ -12,6 +15,8 @@ import ( ) const sendAttemptTimeout = 45 * time.Second +const rapidSendWarningThreshold = 5 * time.Second +const lastSendAttemptFile = ".last-send-at" func runSendOperation[T any]( ctx context.Context, @@ -88,3 +93,26 @@ func reconnectForSend(a interface { return a.Connect(ctx, false, nil) } } + +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 +} diff --git a/cmd/wacli/send_helpers_test.go b/cmd/wacli/send_helpers_test.go index 67a8a18..e0d0b15 100644 --- a/cmd/wacli/send_helpers_test.go +++ b/cmd/wacli/send_helpers_test.go @@ -1,9 +1,13 @@ package main import ( + "bytes" "context" "errors" "fmt" + "os" + "path/filepath" + "strings" "testing" "time" @@ -76,3 +80,58 @@ func TestIsRetryableSendError(t *testing.T) { 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()) + } +} diff --git a/cmd/wacli/send_react_cmd.go b/cmd/wacli/send_react_cmd.go index 91e9f64..12dbfc4 100644 --- a/cmd/wacli/send_react_cmd.go +++ b/cmd/wacli/send_react_cmd.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strings" + "time" "github.com/spf13/cobra" "github.com/steipete/wacli/internal/out" @@ -49,6 +50,9 @@ func newSendReactCmd(flags *rootFlags) *cobra.Command { 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) }) diff --git a/docs/send.md b/docs/send.md index c3d5f1c..dbb8c33 100644 --- a/docs/send.md +++ b/docs/send.md @@ -2,7 +2,7 @@ Read when: sending text, files, quoted replies, or reactions. -`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. +`wacli send` requires authentication, a live connection, and writable mode. Send attempts are bounded and retry once after reconnect for known stale-session/usync timeout failures. Repeated send commands within 5 seconds print a stderr warning so tight loops make WhatsApp rate-limit/account-risk visible. ## Commands