fix: warn on rapid send commands

This commit is contained in:
Peter Steinberger 2026-05-04 09:32:56 +01:00
parent 6199cff6cb
commit 2f294e2bb6
No known key found for this signature in database
8 changed files with 101 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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