feat: add history coverage dry-run planning
This commit is contained in:
parent
d973482dea
commit
a2c78030f6
@ -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)
|
||||
|
||||
10
README.md
10
README.md
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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
87
cmd/wacli/history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
115
internal/store/history.go
Normal 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
|
||||
}
|
||||
115
internal/store/history_test.go
Normal file
115
internal/store/history_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user