fix: warn on rapid send commands
This commit is contained in:
parent
6199cff6cb
commit
2f294e2bb6
@ -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)
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user