feat: add history coverage dry-run planning

This commit is contained in:
Peter Steinberger 2026-05-06 00:39:37 +01:00
parent d973482dea
commit a2c78030f6
No known key found for this signature in database
11 changed files with 526 additions and 8 deletions

View File

@ -24,6 +24,7 @@
- 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)

View File

@ -29,7 +29,7 @@ Full docs site: <https://wacli.sh>.
- [Chats](docs/chats.md): `chats list/show`, archive, pin, mute, mark read.
- [Groups](docs/groups.md): group list, refresh, info, rename, leave, participants, invites, join.
- [Channels](docs/channels.md): `channels list/info/join/leave`, plus sending to channel JIDs.
- [History](docs/history.md): `history backfill`.
- [History](docs/history.md): `history coverage`, `history fill --dry-run`, `history backfill`.
- [Presence](docs/presence.md): `presence typing/paused`.
- [Profile](docs/profile.md): `profile set-picture`.
- [Doctor](docs/doctor.md): `doctor [--connect]`.
@ -116,6 +116,8 @@ pnpm wacli messages edit --chat 1234567890@s.whatsapp.net --id <message-id> --me
pnpm wacli messages delete --chat 1234567890@s.whatsapp.net --id <message-id>
# Backfill older messages for a chat (best-effort; requires your primary device online)
pnpm wacli history coverage --include-blocked
pnpm wacli history fill --dry-run --query family
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
# Download media for a message (after syncing)
@ -222,6 +224,8 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
- `wacli channels info --jid CHANNEL_JID`
- `wacli channels join --invite LINK_OR_CODE`
- `wacli channels leave --jid CHANNEL_JID`
- `wacli history coverage [--include-blocked] [--only-actionable]`
- `wacli history fill --dry-run [--query TEXT] [--kind KIND]`
- `wacli history backfill --chat JID [--count 50] [--requests N]`
- `wacli presence typing --to PHONE_OR_JID [--media audio]`
- `wacli presence paused --to PHONE_OR_JID`
@ -266,6 +270,8 @@ 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.
- `history coverage` shows which chats have a local message anchor; blocked chats need `wacli sync` before backfill can request older messages.
- `history fill --dry-run` plans matching chats only. It does not connect to WhatsApp or mutate the store.
- Backfill skips automatic initial history-sync blob downloads and only processes on-demand responses, which keeps memory use bounded on small Linux/ARM devices.
- Recommended `--count` is `50` per request; maximum is `500`.
- Maximum `--requests` per run is `100`.
@ -273,6 +279,8 @@ Important notes:
### Backfill one chat
```bash
pnpm wacli history coverage --include-blocked
pnpm wacli history fill --dry-run --kind group --limit 20
pnpm wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
```

View File

@ -2,23 +2,129 @@ package main
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
)
func newHistoryCmd(flags *rootFlags) *cobra.Command {
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
@ -79,3 +185,73 @@ func newHistoryBackfillCmd(flags *rootFlags) *cobra.Command {
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/steipete/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,14 +2,23 @@
Read when: trying to fetch older messages for a known chat.
`wacli history backfill` sends on-demand history sync requests to the primary device. This is best-effort and depends on the phone being online and WhatsApp returning older messages.
`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.
## Command
## 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.
@ -22,6 +31,9 @@ wacli history backfill --chat JID [--count 50] [--requests N] [--wait 1m] [--idl
## 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
```

View File

@ -16,12 +16,12 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w
- **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 backfill` requests older messages per chat from your primary device; documented as best-effort because WhatsApp Web is.
- **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.
- **Searching old chats.** Read [Sync](sync.md) for the sync model and [History](history.md) for on-demand backfill.
- **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.
- **Sending from scripts.** Read [Send](send.md) for recipient resolution, channels, replies, mentions, files, and reactions.
- **Wiring up an agent.** Pair `--read-only`, `--json`, and `--events` from [Overview](overview.md); read [Doctor](doctor.md) for self-checks.

View File

@ -29,7 +29,7 @@ Read when: you need the user-facing command map, global flags, store model, or l
- [chats](chats.md) - list, show, filter, and manage known chat state.
- [groups](groups.md) - refresh, inspect, rename, leave, join, invite, and manage participants.
- [channels](channels.md) - list, inspect, join, leave, and send to WhatsApp Channels.
- [history](history.md) - request older per-chat history from the primary device.
- [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.

View File

@ -94,10 +94,12 @@ Recipient resolution and disambiguation (`--pick N`, ambiguous-name prompts), li
`sync` only stores what WhatsApp Web pushes. To request older messages for a specific chat from your **primary device** (your phone), use:
```bash
wacli history coverage --include-blocked
wacli history fill --dry-run --limit 20
wacli history backfill --chat 1234567890@s.whatsapp.net --requests 10 --count 50
```
The phone must be online. WhatsApp may not return full history. See [History](history.md) for limits and patterns (loop over chats with `jq`, recommended `--count`/`--requests` ceilings).
The phone must be online for `backfill`. WhatsApp may not return full history. See [History](history.md) for coverage planning, limits, and patterns.
## 7. Diagnostics and safety

View File

@ -171,6 +171,8 @@ Notes:
WhatsApp Web history is best-effort. If you want to try fetching *older* messages for a specific chat, `wacli` can send an on-demand history request to your primary device:
- `wacli history backfill --chat JID [--count 50] [--requests N]`
- `wacli history coverage` inspects local chat/message coverage without connecting.
- `wacli history fill --dry-run` plans matching chats with local anchors; it does not write or connect.
- Backfill caps: `--count <= 500`, `--requests <= 100`.
- During backfill, automatic initial history-sync blob downloads are disabled; only on-demand history-sync notifications are downloaded and stored.

115
internal/store/history.go Normal file
View File

@ -0,0 +1,115 @@
package store
import (
"strings"
"time"
)
const (
HistoryCoverageStatusReady = "ready"
HistoryCoverageStatusBlocked = "blocked"
HistoryCoverageBlockedNoAnchor = "no_local_anchor"
)
type HistoryCoverage struct {
ChatJID string `json:"chat_jid"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
LastMessageTS time.Time `json:"last_message_ts,omitempty"`
MessageCount int64 `json:"message_count"`
OldestTS time.Time `json:"oldest_ts,omitempty"`
NewestTS time.Time `json:"newest_ts,omitempty"`
Status string `json:"status"`
BlockedReason string `json:"blocked_reason,omitempty"`
}
type ListHistoryCoverageParams struct {
Query string
Kind string
ChatJIDs []string
Limit int
IncludeBlocked bool
OnlyActionable bool
}
func (d *DB) ListHistoryCoverage(p ListHistoryCoverageParams) ([]HistoryCoverage, error) {
if p.Limit <= 0 {
p.Limit = 100
}
query := `
SELECT c.jid,
c.kind,
COALESCE(c.name,''),
COALESCE(c.last_message_ts, 0),
COALESCE(ms.message_count, 0),
COALESCE(ms.oldest_ts, 0),
COALESCE(ms.newest_ts, 0)
FROM chats c
LEFT JOIN (
SELECT chat_jid,
COUNT(1) AS message_count,
MIN(ts) AS oldest_ts,
MAX(ts) AS newest_ts
FROM messages
GROUP BY chat_jid
) ms ON ms.chat_jid = c.jid
WHERE 1=1`
args := make([]interface{}, 0, 8)
if q := strings.TrimSpace(p.Query); q != "" {
needle := likeContains(q)
query += ` AND (LOWER(COALESCE(c.name,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(c.jid) LIKE LOWER(?) ESCAPE '\')`
args = append(args, needle, needle)
}
if kind := strings.TrimSpace(p.Kind); kind != "" {
query += ` AND c.kind = ?`
args = append(args, kind)
}
if len(p.ChatJIDs) > 0 {
query, args = appendStringFilter(query, args, "c.jid", "", p.ChatJIDs)
}
query += ` ORDER BY COALESCE(c.last_message_ts, 0) DESC, c.jid LIMIT ?`
args = append(args, p.Limit)
rows, err := d.sql.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]HistoryCoverage, 0, p.Limit)
for rows.Next() {
var c HistoryCoverage
var lastTS, oldestTS, newestTS int64
if err := rows.Scan(&c.ChatJID, &c.Kind, &c.Name, &lastTS, &c.MessageCount, &oldestTS, &newestTS); err != nil {
return nil, err
}
c.LastMessageTS = fromUnix(lastTS)
c.OldestTS = fromUnix(oldestTS)
c.NewestTS = fromUnix(newestTS)
c = normalizeHistoryCoverage(c)
if p.OnlyActionable && c.Status != HistoryCoverageStatusReady {
continue
}
if !p.IncludeBlocked && c.Status == HistoryCoverageStatusBlocked {
continue
}
out = append(out, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func normalizeHistoryCoverage(c HistoryCoverage) HistoryCoverage {
if c.MessageCount <= 0 {
c.Status = HistoryCoverageStatusBlocked
c.BlockedReason = HistoryCoverageBlockedNoAnchor
return c
}
c.Status = HistoryCoverageStatusReady
c.BlockedReason = ""
return c
}

View File

@ -0,0 +1,115 @@
package store
import (
"testing"
"time"
)
func TestListHistoryCoverage(t *testing.T) {
db := openTestDB(t)
base := time.Date(2024, 5, 1, 0, 0, 0, 0, time.UTC)
ready := "ready@s.whatsapp.net"
blocked := "blocked@s.whatsapp.net"
group := "group@g.us"
if err := db.UpsertChat(ready, "dm", "Ready", base.Add(3*time.Minute)); err != nil {
t.Fatalf("UpsertChat ready: %v", err)
}
if err := db.UpsertChat(blocked, "dm", "Blocked", base.Add(2*time.Minute)); err != nil {
t.Fatalf("UpsertChat blocked: %v", err)
}
if err := db.UpsertChat(group, "group", "Group", base.Add(time.Minute)); err != nil {
t.Fatalf("UpsertChat group: %v", err)
}
for _, msg := range []struct {
chat string
id string
ts time.Time
}{
{ready, "m2", base.Add(2 * time.Second)},
{ready, "m1", base.Add(time.Second)},
{group, "g1", base.Add(3 * time.Second)},
} {
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: msg.chat,
MsgID: msg.id,
Timestamp: msg.ts,
FromMe: false,
Text: msg.id,
}); err != nil {
t.Fatalf("UpsertMessage %s: %v", msg.id, err)
}
}
coverage, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Limit: 10})
if err != nil {
t.Fatalf("ListHistoryCoverage: %v", err)
}
if len(coverage) != 2 {
t.Fatalf("expected blocked chat hidden by default, got %+v", coverage)
}
byChat := map[string]HistoryCoverage{}
for _, c := range coverage {
byChat[c.ChatJID] = c
}
if byChat[ready].Status != HistoryCoverageStatusReady || byChat[ready].MessageCount != 2 {
t.Fatalf("ready coverage = %+v", byChat[ready])
}
if !byChat[ready].OldestTS.Equal(base.Add(time.Second)) || !byChat[ready].NewestTS.Equal(base.Add(2*time.Second)) {
t.Fatalf("ready time range = %+v", byChat[ready])
}
withBlocked, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Limit: 10, IncludeBlocked: true})
if err != nil {
t.Fatalf("ListHistoryCoverage blocked: %v", err)
}
byChat = map[string]HistoryCoverage{}
for _, c := range withBlocked {
byChat[c.ChatJID] = c
}
if byChat[blocked].Status != HistoryCoverageStatusBlocked || byChat[blocked].BlockedReason != HistoryCoverageBlockedNoAnchor {
t.Fatalf("blocked coverage = %+v", byChat[blocked])
}
groups, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Kind: "group", Limit: 10, IncludeBlocked: true})
if err != nil {
t.Fatalf("ListHistoryCoverage group: %v", err)
}
if len(groups) != 1 || groups[0].ChatJID != group {
t.Fatalf("group coverage = %+v", groups)
}
selected, err := db.ListHistoryCoverage(ListHistoryCoverageParams{ChatJIDs: []string{blocked, ready}, OnlyActionable: true, IncludeBlocked: true})
if err != nil {
t.Fatalf("ListHistoryCoverage actionable: %v", err)
}
if len(selected) != 1 || selected[0].ChatJID != ready {
t.Fatalf("actionable coverage = %+v", selected)
}
}
func TestListHistoryCoverageEscapesQueryWildcards(t *testing.T) {
db := openTestDB(t)
when := time.Date(2024, 5, 2, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat("literal@s.whatsapp.net", "dm", "100% literal", when); err != nil {
t.Fatalf("UpsertChat literal: %v", err)
}
if err := db.UpsertChat("wildcard@s.whatsapp.net", "dm", "100X wildcard", when); err != nil {
t.Fatalf("UpsertChat wildcard: %v", err)
}
for _, chat := range []string{"literal@s.whatsapp.net", "wildcard@s.whatsapp.net"} {
if err := db.UpsertMessage(UpsertMessageParams{ChatJID: chat, MsgID: chat, Timestamp: when, Text: "x"}); err != nil {
t.Fatalf("UpsertMessage %s: %v", chat, err)
}
}
coverage, err := db.ListHistoryCoverage(ListHistoryCoverageParams{Query: "100%", Limit: 10})
if err != nil {
t.Fatalf("ListHistoryCoverage: %v", err)
}
if len(coverage) != 1 || coverage[0].ChatJID != "literal@s.whatsapp.net" {
t.Fatalf("wildcard leak: %+v", coverage)
}
}