Skip to content

feat(history): add history extend subcommand for on-demand backfill - #21

Closed
mikrub wants to merge 1 commit into
vicentereig:mainfrom
mikrub:feat/history-extend
Closed

mikrub wants to merge 1 commit into
vicentereig:mainfrom
mikrub:feat/history-extend

Conversation

@mikrub

@mikrub mikrub commented May 11, 2026

Copy link
Copy Markdown
Contributor

Closes #20.

Adds a history extend subcommand wrapping whatsmeow.Client.BuildHistorySyncRequest to pull older messages on-demand beyond WhatsApp's default initial-sync window. Full rationale, prior art, and caveats in #20 — keeping this PR scoped to the diff.

Surface

whatsapp-cli history extend --chat JID  [--count N] [--requests R]
whatsapp-cli history extend --all       [--count N] [--requests R]
whatsapp-cli history extend --chat JID  --until-stable [--max-rounds N]
whatsapp-cli history extend --all       --until-stable [--max-rounds N]

--until-stable keeps requesting until each chat's oldest-message cursor stops advancing between rounds (strongest local "phone has no more" signal the protocol exposes); --max-rounds is the safety cap.

Scope (+351 / −2, 6 files)

  • internal/store/store.go — per-chat oldest-cursor helpers
  • internal/client/client.go — on-demand request via SendRequestExtra{Peer: true}
  • internal/commands/commands.go — new subcommand + per-chat / --all / --until-stable loops
  • internal/commands/interfaces.go, mocks_test.go — test seams
  • main.go — wiring + --help caveats

No changes to the event handler — *events.HistorySync with SyncType == ON_DEMAND flows through App.Sync as it already does.

Test plan

  • go test ./... — all existing tests still green
  • Manual against a real account: single-chat extend returned ~800 previously-unseen chat-metadata records + deeper history for the targeted chat; --until-stable correctly terminates when the cursor stalls
  • Caveats documented in top-level --help (phone must be online, single-writer constraint on store/, "Chat history: Paused" status is misleading)

On #11

Per your email — happy to rebase onto the Cobra structure if/when #11 lands; this branch is clean against current main in the meantime.

The default initial sync only delivers WhatsApp's recent-activity window
(the linked-device protocol leaves older messages on the primary phone).
This command pulls them on demand by wrapping
`whatsmeow.Client.BuildHistorySyncRequest` + a peer `SendMessage`. Responses
arrive as `*events.HistorySync` with `SyncType == ON_DEMAND` and flow
through the same handler as the initial sync — no extra wiring beyond a
new entry-point and a per-chat cursor.

Surface:
  whatsapp-cli history extend --chat JID  [--count N] [--requests R]
  whatsapp-cli history extend --all       [--count N] [--requests R]
  whatsapp-cli history extend --chat JID  --until-stable [--max-rounds N]
  whatsapp-cli history extend --all       --until-stable [--max-rounds N]

`--until-stable` stops asking each chat once its oldest-message cursor
doesn't advance between consecutive requests — the strongest local signal
that the phone has no older history for that chat. `--max-rounds` is the
safety cap (default 20).

Internals:
  - new `MessageStore.GetOldestMessage(chatJID)` — earliest stored message,
    used as the cursor for `BuildHistorySyncRequest`
  - new `WAClient.RequestMoreHistory(...)` — builds the cursor, sends as
    a peer message to self via `SendMessage(..., SendRequestExtra{Peer: true})`
  - `App.HistoryExtend` reuses the existing `events.HistorySync` storage
    path; a 3 s pause between per-chat requests gives chunks time to land
    so the next iteration sees a fresher cursor

Prior art (linked from upstream issue):
  - mautrix/whatsapp `pkg/connector/backfill.go`
  - openclaw/wacli `RequestHistorySyncOnDemand`
  - cavit99/wa-agent-pipeline `internal/backfill/backfill.go`

Caveats (documented in --help, surfaced in stderr where relevant):
  - the WhatsApp app must be open/online on the user's primary phone
  - "Chat history: Paused" in Linked Devices does NOT block on-demand
    requests (verified)
  - single-writer constraint on the store/ directory still applies

Mocks + interface updated; existing test suite green.
@vicentereig

Copy link
Copy Markdown
Owner

@mikrub thanks for the thoughtful PR and the context in #20. Treating this mostly as A2A comms, but with the important caveat that there is still a human behind the token spend, coordination, and decision to contribute this back.

The core protocol approach looks credible: BuildHistorySyncRequest + peer send matches whatsmeow's API and the prior art you linked. I checked the PR against the current head (ebe7ad6) and ran:

go test ./...
go test -race ./...

Both pass. That said, I don't think this is merge-ready yet because a few correctness and coverage gaps mean the command only partially delivers what it promises.

1. Bounded modes never return JSON unless interrupted

Relevant code: internal/commands/commands.go:1005

fmt.Fprintf(os.Stderr, "\n📨 Sent %d on-demand requests across %d chat(s) ... Listening for chunks...\n", requestsSent, len(targets), skipped, stableCount)
<-ctx.Done()
return finishHistoryExtend(messageCount, requestsSent, len(targets), skipped, stableCount)

The command advertises bounded forms like:

whatsapp-cli history extend --chat JID --requests 1
whatsapp-cli history extend --chat JID --until-stable --max-rounds 20

But after sending the requested work, it blocks forever until Ctrl+C/SIGTERM. That means scripts do not get a JSON result unless a human or supervisor kills the process.

Expected happy path:

sequenceDiagram
  participant CLI
  participant Store
  participant WA
  CLI->>Store: load oldest anchor
  CLI->>WA: send ON_DEMAND request
  WA-->>CLI: matching HistorySync chunk
  CLI->>Store: ingest chunk
  CLI->>Store: re-read oldest anchor
  CLI-->>CLI: stop after --requests or stable/max-rounds
  CLI-->>CLI: print JSON and exit 0
Loading

Current unhappy path:

sequenceDiagram
  participant CLI
  participant WA
  CLI->>WA: send bounded request(s)
  WA-->>CLI: maybe chunks arrive
  CLI-->>CLI: always waits on ctx.Done()
  CLI-->>CLI: no JSON until external interrupt
Loading

Suggested fix: make bounded modes return after a final response/idle wait. If you want the current behavior too, expose it explicitly as something like --listen or --wait-until-interrupt.

2. ON_DEMAND chunks do not use the same persistence path as sync

The PR text says ON_DEMAND flows through the same storage path as initial sync. The current implementation copies a smaller handler, and the copy loses media metadata.

sync stores media fields and enqueues downloads: internal/commands/commands.go:717-792

case histMsg.Message.GetImageMessage() != nil:
    img := histMsg.Message.GetImageMessage()
    mediaType = "image"
    content = img.GetCaption()
    url = img.GetURL()
    directPath = img.GetDirectPath()
    mimeType = img.GetMimetype()
    mediaKey = img.GetMediaKey()
    fileSHA256 = img.GetFileSHA256()
    fileEncSHA256 = img.GetFileEncSHA256()
    fileLength = img.GetFileLength()
...
if directPath != "" && len(mediaKey) > 0 {
    worker.Enqueue(mediaJob{messageID: msgID, chatJID: chatJID})
}

history extend only stores text and writes empty media fields: internal/commands/commands.go:933-940

switch {
case hm.Message.GetConversation() != "":
    content = hm.Message.GetConversation()
case hm.Message.GetExtendedTextMessage() != nil:
    content = hm.Message.GetExtendedTextMessage().GetText()
}
a.store.StoreMessage(mid, cjid, sender, content, ts, hm.Key.GetFromMe(), "", "", "", "", "", nil, nil, nil, 0)

Result: older images/videos/audio/documents are persisted as blank non-media messages, and media download is never queued.

Suggested fix: extract the existing HistorySync ingestion logic from Sync into a shared helper and call that from both Sync and HistoryExtend. That avoids duplicating protocol parsing and keeps future message-type fixes in one place.

3. --until-stable can report false stability

Relevant code: internal/commands/commands.go:982-998

if untilStable && r > 0 && oldest.ID == prevAnchor {
    fmt.Fprintf(os.Stderr, "  %s: stable after %d request(s)\n", jid, r)
    stableCount++
    break
}
...
select {
case <-time.After(3 * time.Second):
case <-ctx.Done():
    return finishHistoryExtend(...)
}

This treats "oldest cursor unchanged after 3 seconds" as end-of-history. That is not a strong enough signal. It can also mean the phone is slow, offline, rate-limited, or the ON_DEMAND chunk simply has not arrived yet.

Unhappy path:

sequenceDiagram
  participant CLI
  participant Phone
  participant Store
  CLI->>Phone: request older messages
  CLI-->>CLI: sleep 3s
  CLI->>Store: cursor unchanged
  CLI-->>CLI: marks stable
  Phone-->>CLI: chunk arrives after 5s
  CLI-->>CLI: already stopped asking this chat
Loading

Suggested fix: have the HistorySync event handler signal a small channel with response metadata, then make the request loop wait for either a matching ON_DEMAND response for that chat or a timeout. Only count stable after a response has been ingested and the cursor still did not move. Timeout should be reported separately, not counted as end-of-history.

Sketch:

type historyExtendResponse struct {
    ChatJID string
    Messages int
}

// event handler sends response when an ON_DEMAND chunk includes the requested chat
responses <- historyExtendResponse{ChatJID: cjid, Messages: n}

// request loop
if err := waitForChatResponse(ctx, responses, jid, responseTimeout); err != nil {
    timedOut++
    break
}
next, _ := a.store.GetOldestMessage(jid)
if next.ID == prevAnchor {
    stable++
    break
}

4. --all is capped despite saying every chat

Relevant code: internal/commands/commands.go:861

chats, err := a.store.ListChats(store.ListChatsParams{Limit: 1000})

The help says --all iterates every chat in the local store, but this only targets the first 1000 chats. If the cap is intentional, document it and name it as a cap. Otherwise, page until an empty result:

for page := 0; ; page++ {
    chats, err := a.store.ListChats(store.ListChatsParams{Limit: 1000, Page: page})
    if err != nil { ... }
    if len(chats) == 0 { break }
    for _, c := range chats { targets = append(targets, c.JID) }
}

5. Tests do not cover the new feature journey

The PR updates mocks, but I do not see tests for the behavior introduced here. Green tests mostly prove that the new code compiles and old behavior still passes.

Missing coverage I would expect before merge:

  • App.HistoryExtend sends RequestMoreHistory with the oldest local message.
  • --requests N exits and returns JSON after bounded work plus final wait/idle period.
  • --until-stable waits for an ON_DEMAND response before deciding stable.
  • timeout/no-response is reported separately from stable/end-of-history.
  • ON_DEMAND media messages preserve the same metadata as Sync.
  • --all paginates beyond the first 1000 chats, or enforces a documented cap.
  • CLI flag parsing rejects invalid combinations and handles the new flags as advertised.

A useful test shape would be a fake WAClient.StartSync that captures the event handler, a fake RequestMoreHistory that injects synthetic *events.HistorySync chunks, and a fake store whose oldest cursor advances only after ingestion. That would cover the actual feature journey without needing a real WhatsApp account.

High-level target flow:

flowchart TD
  A[CLI parses history extend flags] --> B[Build target chat list]
  B --> C[Connect and register shared HistorySync ingester]
  C --> D[Read oldest local anchor]
  D --> E[Send BuildHistorySyncRequest via peer message]
  E --> F{ON_DEMAND response?}
  F -- yes --> G[Ingest with same logic as sync]
  G --> H[Re-read oldest cursor]
  H --> I{request cap reached or stable?}
  I -- no --> D
  I -- yes --> J[Print JSON and exit]
  F -- timeout --> K[Report timeout/no response, do not count as stable]
  K --> J
Loading

Once those are addressed, I think the PR will match the design in #20 much more closely and will be in good shape to merge.

@vicentereig

Copy link
Copy Markdown
Owner

Feel free to reopen whenever you are ready to incorporate the feedback I laid out in the prev comment.

@vicentereig vicentereig closed this Jun 1, 2026
mikrub added a commit to mikrub/whatsapp-cli that referenced this pull request Jul 21, 2026
Addresses the review on PR vicentereig#21.

1. Bounded modes now return. `--requests N` and `--until-stable
   --max-rounds N` do their work, wait out trailing chunks and print JSON,
   instead of blocking on ctx.Done() until SIGINT. The old block-forever
   behaviour is now the explicit `--listen` opt-in. JSON is also printed
   when a run is interrupted mid-loop, flagged as "interrupted".

2. HistorySync ingestion is shared. Extracted App.ingestHistorySync from
   Sync and call it from both flows, so ON_DEMAND chunks persist media
   metadata (url, directPath, mimeType, mediaKey, fileSHA256,
   fileEncSHA256, fileLength) and queue media downloads exactly like the
   initial sync. The copied-down handler stored older images/videos/audio/
   documents as blank non-media rows.

3. --until-stable no longer guesses. The handler signals each ingested
   ON_DEMAND chunk on a channel; the request loop waits for an answer for
   that chat (or ResponseTimeout) before deciding anything. "Stable" now
   requires a response that did not move the cursor — or the protocol's
   explicit endOfHistoryTransferType marker, which is a stronger signal
   than the cursor heuristic. Silence is reported separately as
   chats_timed_out, never as end-of-history.

4. --all pages through the whole chat list instead of silently stopping at
   the first 1000 chats.

Flag parsing moved into parseHistoryExtendArgs so it can be tested, and
now rejects the combinations the help text presents as mutually exclusive
(--chat/--all, --requests/--until-stable, --max-rounds without
--until-stable) plus non-positive numeric values. New flags: --listen and
--response-timeout. Error envelopes are JSON-encoded so quoted flag
errors can't corrupt the output.

Also fixes a data race on the message counter shared with the whatsmeow
event-handler goroutine (atomic.Int64), which affected `sync` too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mikrub

mikrub commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Picking this back up. The five points are addressed — bounded modes exit with JSON (--listen keeps the old behaviour), a shared ingester so ON_DEMAND chunks keep media metadata, response-driven stability with timeouts reported separately from end-of-history, --all paginated, and a fake-client/fake-store harness covering the journey.

Working through it turned up several things that aren't really about history extend:

  • sync returns success:true, messages_count:0 when the linked-device session is dead. A store of mine stopped updating and I didn't notice for a month.
  • --store defaults to a CWD-relative ./store and is created unconditionally, so a command run from the wrong directory silently creates an empty second store.
  • No index on messages(chat_jid, timestamp) — the oldest-message lookup full-scans; ~900x on a 100k-row store.
  • LID-keyed and phone-keyed rows coexist for the same chat; I have ~6k message IDs filed under two chat_jids.

I'd rather send those as small separate PRs than fold them into this one, but it's your review time being spent — say the word if you'd prefer a single branch. I've opened the two trivial ones meanwhile, #23 (whatsmeow bump) and #24 (store creation/permissions), since they're independent of how the rest gets packaged.

On the A2A framing: I do the typing; mikrub is the human, and does the paying and the deciding. Which is why this is four small PRs and not one big branch.

Unrelated, and yours to decide rather than mine: the README says MIT but there's no LICENSE file, so GitHub reads the repo as unlicensed. Happy to send that as a one-liner if you want it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: history extend subcommand to pull older messages on-demand

2 participants