feat: add local store cleanup commands

Add local-only cleanup commands for store stats, chat cleanup, group pruning, and age-based store cleanup. Rework group pruning so targets are listed first, dry-run never deletes, confirmation gates every destructive path, and active stale groups require an explicit include flag.

Document the cleanup workflow across README and docs/, including the local-only semantics.

Closes #210.
Co-authored-by: Davy <95214375+thedavidweng@users.noreply.github.com>
This commit is contained in:
Peter Steinberger 2026-05-06 04:50:20 +01:00
parent c912668b21
commit af671e16a9
No known key found for this signature in database
19 changed files with 1360 additions and 3 deletions

View File

@ -36,6 +36,7 @@
- Send: add `send voice` and `send file --ptt` for OGG/Opus WhatsApp voice notes. (#40, #41 — thanks @ricardopolo and @emre6943)
- Send: accept common phone-number formatting in recipient flags while still storing digits-only WhatsApp JIDs. (#130 — thanks @fahmidme and @ImLukeF)
- Send: resolve `send text/file --to` against local contacts, groups, and chats, with `--pick` for non-interactive disambiguation. (#122 — thanks @AndroidPoet)
- Store: add local-only `store stats`, `store cleanup`, `chats cleanup`, and `groups prune` commands with dry-run previews and confirmation gates. (#210, #211 — thanks @thedavidweng)
### Security

View File

@ -28,6 +28,7 @@ Full docs site: <https://wacli.sh>.
- [Contacts](docs/contacts.md): `contacts search/show/refresh`, aliases, tags.
- [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.
- [Store](docs/store.md): local store stats and cleanup commands.
- [Channels](docs/channels.md): `channels list/info/join/leave`, plus sending to channel JIDs.
- [History](docs/history.md): `history coverage`, `history fill --dry-run`, `history backfill`.
- [Presence](docs/presence.md): `presence typing/paused`.
@ -46,7 +47,7 @@ Full docs site: <https://wacli.sh>.
- **Message tools**: list/search/show/context with chat, sender, direction, time, order, and media-type filters.
- **Sending**: send text, mentions, quoted replies, stickers, and image/video/audio/document files with captions, MIME override, and custom display filenames. Sends keep a short retry-receipt grace window, and 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/channels**: search/show contacts, local aliases/tags, list/show/filter chats, archive/pin/mute/mark-read chats, refresh/list/info/rename groups, manage participants, invite links, join, leave, and manage WhatsApp Channels.
- **Contacts/chats/groups/store/channels**: search/show contacts, local aliases/tags, list/show/filter chats, archive/pin/mute/mark-read chats, refresh/list/info/rename/prune groups, inspect/prune the local store, manage participants, invite links, join, leave, and manage WhatsApp Channels.
- **Presence**: send typing/paused indicators.
- **Profile**: set the authenticated account profile picture from JPEG or PNG input.
- **Diagnostics + safety**: `doctor`, read-only mode, store locks with lock-owner reporting, lock waiting, owner-only database permissions, panic recovery, reconnect bounds, and bounded media queue backpressure.
@ -159,6 +160,11 @@ pnpm wacli send react --to 1234567890 --id <message-id>
pnpm wacli groups list
pnpm wacli groups rename --jid 123456789@g.us --name "New name"
# Preview local cleanup before deleting stale local rows
pnpm wacli store stats
pnpm wacli groups prune --dry-run
pnpm wacli store cleanup --days 365 --dry-run
# List/join channels and send to a channel JID
pnpm wacli channels list
pnpm wacli channels join --invite "https://whatsapp.com/channel/AbCdEfGhIjK"
@ -212,6 +218,7 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
- `wacli chats mute --chat CHAT [--duration DURATION] [--pick N]`
- `wacli chats unmute --chat CHAT [--pick N]`
- `wacli chats mark-read|mark-unread --chat CHAT [--pick N]`
- `wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]`
- `wacli groups list [--query TEXT] [--limit N]`
- `wacli groups refresh`
- `wacli groups info --jid GROUP_JID`
@ -220,6 +227,9 @@ Full command docs live under [docs/overview.md](docs/overview.md). Quick referen
- `wacli groups participants add|remove|promote|demote --jid GROUP_JID --user PHONE_OR_JID`
- `wacli groups invite link get|revoke --jid GROUP_JID`
- `wacli groups join --code INVITE_CODE`
- `wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]`
- `wacli store stats`
- `wacli store cleanup [--days N] [--dry-run] [--confirm]`
- `wacli channels list`
- `wacli channels info --jid CHANNEL_JID`
- `wacli channels join --invite LINK_OR_CODE`

View File

@ -33,6 +33,7 @@ func newChatsCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newChatsUnmuteCmd(flags))
cmd.AddCommand(newChatsMarkReadCmd(flags, true))
cmd.AddCommand(newChatsMarkReadCmd(flags, false))
cmd.AddCommand(newChatsCleanupCmd(flags))
return cmd
}

166
cmd/wacli/chats_cleanup.go Normal file
View File

@ -0,0 +1,166 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
)
func newChatsCleanupCmd(flags *rootFlags) *cobra.Command {
var days int
var jid string
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up old chats from local storage",
Long: `Clean up chats that have no recent activity.
By default, removes chats with no messages in the last 365 days.
Use --days to adjust the threshold. Use --dry-run to preview what would be deleted.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
if jid != "" {
return cleanupSingleChat(ctx, a, jid, dryRun, confirm, flags.asJSON)
}
chats, err := a.DB().ListChatsOlderThan(days)
if err != nil {
return err
}
if len(chats) == 0 {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "no chats to clean up"})
}
fmt.Fprintln(os.Stderr, "No chats to clean up.")
return nil
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_delete": len(chats), "chats": chats})
}
fmt.Fprintf(os.Stderr, "Would delete %d chat(s):\n", len(chats))
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, " - %s (%s)\n", name, c.JID)
}
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d chat(s). This cannot be undone.\n", len(chats))
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deleted int
for _, c := range chats {
msgCount, _ := a.DB().CountChatMessages(c.JID)
if err := a.DB().DeleteChat(c.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete chat %s: %v\n", c.JID, err)
continue
}
deleted++
if !flags.asJSON {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s (%d messages)\n", name, msgCount)
}
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": deleted})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d chat(s).\n", deleted)
return nil
},
}
cmd.Flags().IntVar(&days, "days", 365, "delete chats with no messages in the last N days")
cmd.Flags().StringVar(&jid, "jid", "", "delete a specific chat by JID")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}
func cleanupSingleChat(ctx context.Context, a *app.App, jid string, dryRun, confirm, asJSON bool) error {
chat, err := a.DB().GetChat(jid)
if err != nil {
return fmt.Errorf("chat not found: %s", jid)
}
msgCount, _ := a.DB().CountChatMessages(jid)
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"would_delete": 1,
"chat": chat,
"message_count": msgCount,
})
}
name := chat.Name
if name == "" {
name = chat.JID
}
fmt.Fprintf(os.Stderr, "Would delete chat: %s (%s, %d messages)\n", name, chat.JID, msgCount)
return nil
}
if !confirm {
name := chat.Name
if name == "" {
name = chat.JID
}
fmt.Fprintf(os.Stderr, "About to delete chat: %s (%s, %d messages). This cannot be undone.\n", name, chat.JID, msgCount)
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
if err := a.DB().DeleteChat(jid); err != nil {
return err
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 1, "jid": jid, "messages_deleted": msgCount})
}
fmt.Fprintf(os.Stderr, "Deleted chat %s (%d messages)\n", jid, msgCount)
return nil
}

View File

@ -15,5 +15,6 @@ func newGroupsCmd(flags *rootFlags) *cobra.Command {
cmd.AddCommand(newGroupsInviteCmd(flags))
cmd.AddCommand(newGroupsJoinCmd(flags))
cmd.AddCommand(newGroupsLeaveCmd(flags))
cmd.AddCommand(newGroupsPruneCmd(flags))
return cmd
}

135
cmd/wacli/groups_prune.go Normal file
View File

@ -0,0 +1,135 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/app"
"github.com/steipete/wacli/internal/out"
"github.com/steipete/wacli/internal/store"
)
func newGroupsPruneCmd(flags *rootFlags) *cobra.Command {
var days int
var leftOnly bool
var includeActive bool
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "prune",
Short: "Remove old or left groups from local storage",
Long: `Clean up groups that you have left or that have been inactive.
By default, removes groups you have left. Use --days to prune only left
groups older than the threshold. Add --include-active to also prune active
groups whose last local message is older than the threshold.
This only deletes local wacli store rows. It does not leave WhatsApp groups
or delete anything from WhatsApp servers. Use --dry-run to preview targets.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
if days < 0 {
return fmt.Errorf("days must not be negative")
}
if !leftOnly {
includeActive = true
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
return pruneGroups(a, days, includeActive, dryRun, confirm, flags.asJSON)
},
}
cmd.Flags().IntVar(&days, "days", 0, "prune groups older than N days (0 = all left groups)")
cmd.Flags().BoolVar(&leftOnly, "left-only", true, "only remove groups you have left")
cmd.Flags().BoolVar(&includeActive, "include-active", false, "also remove active groups with no messages in the last N days")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}
func pruneGroups(a *app.App, days int, includeActive, dryRun, confirm, asJSON bool) error {
groups, err := a.DB().ListPrunableGroups(days, includeActive)
if err != nil {
return err
}
if len(groups) == 0 {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "no groups to prune"})
}
fmt.Fprintln(os.Stderr, "No groups to prune.")
return nil
}
if dryRun {
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"would_delete": len(groups), "groups": groups})
}
writePruneTargets(os.Stderr, "Would delete", groups)
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d group(s) from the local wacli store. This cannot be undone.\n", len(groups))
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deleted int
for _, g := range groups {
if err := a.DB().DeleteGroupLocalData(g.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete group %s: %v\n", g.JID, err)
continue
}
deleted++
if !asJSON {
name := g.Name
if name == "" {
name = g.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s\n", name)
}
}
if asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": deleted})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d group(s).\n", deleted)
return nil
}
func writePruneTargets(w *os.File, prefix string, groups []store.Group) {
fmt.Fprintf(w, "%s %d group(s):\n", prefix, len(groups))
for _, g := range groups {
name := g.Name
if name == "" {
name = g.JID
}
state := "left"
if g.LeftAt.IsZero() {
state = "inactive"
}
fmt.Fprintf(w, " - %s (%s, %s)\n", name, g.JID, state)
}
}

View File

@ -0,0 +1,117 @@
package main
import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/steipete/wacli/internal/store"
)
func TestGroupsPruneExposesSafetyFlags(t *testing.T) {
cmd := newGroupsPruneCmd(&rootFlags{})
for _, name := range []string{"days", "left-only", "include-active", "dry-run", "confirm"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("expected --%s flag", name)
}
}
}
func TestGroupsPruneRejectsReadOnlyBeforeOpeningStore(t *testing.T) {
cmd := newGroupsPruneCmd(&rootFlags{readOnly: true})
cmd.SetArgs([]string{"--dry-run"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "read-only mode") {
t.Fatalf("error = %v, want read-only", err)
}
}
func TestGroupsPruneDryRunDoesNotDeleteOlderLeftGroups(t *testing.T) {
storeDir := seedPruneStore(t)
cmd := newGroupsPruneCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--days", "180", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("groups prune dry-run: %v", err)
}
db := openPruneStore(t, storeDir)
defer db.Close()
if _, err := db.GetChat("old-left@g.us"); err != nil {
t.Fatalf("old-left chat should survive dry-run: %v", err)
}
left, err := db.ListPrunableGroups(180, false)
if err != nil {
t.Fatalf("ListPrunableGroups: %v", err)
}
if got := len(left); got != 1 {
t.Fatalf("dry-run deleted targets: got %d left, want 1", got)
}
}
func TestGroupsPruneConfirmDeletesOnlyMatchingGroups(t *testing.T) {
storeDir := seedPruneStore(t)
cmd := newGroupsPruneCmd(&rootFlags{storeDir: storeDir, timeout: time.Minute})
cmd.SetArgs([]string{"--days", "180", "--confirm"})
if err := cmd.Execute(); err != nil {
t.Fatalf("groups prune confirm: %v", err)
}
db := openPruneStore(t, storeDir)
defer db.Close()
if _, err := db.GetChat("old-left@g.us"); err == nil {
t.Fatalf("old-left chat should be deleted")
}
for _, jid := range []string{"recent-left@g.us", "old-active@g.us"} {
if _, err := db.GetChat(jid); err != nil {
t.Fatalf("%s chat should survive: %v", jid, err)
}
}
}
func seedPruneStore(t *testing.T) string {
t.Helper()
storeDir := t.TempDir()
db := openPruneStore(t, storeDir)
defer db.Close()
now := time.Now().UTC()
created := now.AddDate(0, 0, -400)
oldLeft := now.AddDate(0, 0, -200)
recentLeft := now.AddDate(0, 0, -30)
oldActive := now.AddDate(0, 0, -220)
for _, tc := range []struct {
jid string
name string
lastTS time.Time
leftAt time.Time
}{
{"old-left@g.us", "Old Left", oldLeft, oldLeft},
{"recent-left@g.us", "Recent Left", recentLeft, recentLeft},
{"old-active@g.us", "Old Active", oldActive, time.Time{}},
} {
if err := db.UpsertGroup(tc.jid, tc.name, "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup %s: %v", tc.jid, err)
}
if err := db.UpsertChat(tc.jid, "group", tc.name, tc.lastTS); err != nil {
t.Fatalf("UpsertChat %s: %v", tc.jid, err)
}
if !tc.leftAt.IsZero() {
if err := db.MarkGroupLeft(tc.jid, tc.leftAt); err != nil {
t.Fatalf("MarkGroupLeft %s: %v", tc.jid, err)
}
}
}
return storeDir
}
func openPruneStore(t *testing.T, storeDir string) *store.DB {
t.Helper()
db, err := store.Open(filepath.Join(storeDir, "wacli.db"))
if err != nil {
t.Fatalf("Open: %v", err)
}
return db
}

View File

@ -66,6 +66,7 @@ func execute(args []string) error {
rootCmd.AddCommand(newPresenceCmd(&flags))
rootCmd.AddCommand(newProfileCmd(&flags))
rootCmd.AddCommand(newDocsCmd(&flags))
rootCmd.AddCommand(newStoreCmd(&flags))
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {

13
cmd/wacli/store.go Normal file
View File

@ -0,0 +1,13 @@
package main
import "github.com/spf13/cobra"
func newStoreCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "store",
Short: "Manage local data store",
}
cmd.AddCommand(newStoreCleanupCmd(flags))
cmd.AddCommand(newStoreStatsCmd(flags))
return cmd
}

126
cmd/wacli/store_cleanup.go Normal file
View File

@ -0,0 +1,126 @@
package main
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newStoreCleanupCmd(flags *rootFlags) *cobra.Command {
var days int
var dryRun bool
var confirm bool
cmd := &cobra.Command{
Use: "cleanup",
Short: "Clean up old data from local store",
Long: `Clean up old messages and chats from local storage.
Removes chats with no recent activity and their associated messages.
Use --days to set the threshold (default: 365 days).
Use --dry-run to preview what would be deleted.`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := flags.requireWritable(); err != nil {
return err
}
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, true, false)
if err != nil {
return err
}
defer closeApp(a, lk)
_ = ctx
chats, err := a.DB().ListChatsOlderThan(days)
if err != nil {
return err
}
if len(chats) == 0 {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{"deleted": 0, "message": "nothing to clean up"})
}
fmt.Fprintln(os.Stderr, "Nothing to clean up.")
return nil
}
var totalMessages int64
for _, c := range chats {
count, _ := a.DB().CountChatMessages(c.JID)
totalMessages += count
}
if dryRun {
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"would_delete_chats": len(chats),
"would_delete_messages": totalMessages,
"days": days,
})
}
fmt.Fprintf(os.Stderr, "Would delete %d chat(s) with %d total message(s) (older than %d days):\n", len(chats), totalMessages, days)
for _, c := range chats {
name := c.Name
if name == "" {
name = c.JID
}
count, _ := a.DB().CountChatMessages(c.JID)
fmt.Fprintf(os.Stderr, " - %s (%s, %d messages)\n", name, c.JID, count)
}
fmt.Fprintln(os.Stderr, "\nRun without --dry-run to actually delete.")
return nil
}
if !confirm {
fmt.Fprintf(os.Stderr, "About to delete %d chat(s) with %d total message(s). This cannot be undone.\n", len(chats), totalMessages)
fmt.Fprint(os.Stderr, "Continue? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Fprintln(os.Stderr, "Aborted.")
return nil
}
}
var deletedChats, deletedMessages int64
for _, c := range chats {
count, _ := a.DB().CountChatMessages(c.JID)
if err := a.DB().DeleteChat(c.JID); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete chat %s: %v\n", c.JID, err)
continue
}
deletedChats++
deletedMessages += count
if !flags.asJSON {
name := c.Name
if name == "" {
name = c.JID
}
fmt.Fprintf(os.Stderr, "Deleted %s (%d messages)\n", name, count)
}
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, map[string]any{
"deleted_chats": deletedChats,
"deleted_messages": deletedMessages,
})
}
fmt.Fprintf(os.Stderr, "\nDone. Deleted %d chat(s) with %d message(s).\n", deletedChats, deletedMessages)
return nil
},
}
cmd.Flags().IntVar(&days, "days", 365, "delete data older than N days")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deleted without deleting")
cmd.Flags().BoolVar(&confirm, "confirm", false, "skip confirmation prompt")
return cmd
}

68
cmd/wacli/store_stats.go Normal file
View File

@ -0,0 +1,68 @@
package main
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/steipete/wacli/internal/out"
)
func newStoreStatsCmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
Use: "stats",
Short: "Show store statistics",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := withTimeout(context.Background(), flags)
defer cancel()
a, lk, err := newApp(ctx, flags, false, false)
if err != nil {
return err
}
defer closeApp(a, lk)
_ = ctx
chats, err := a.DB().ListChats("", 0)
if err != nil {
return err
}
groups, err := a.DB().ListGroups("", 0)
if err != nil {
return err
}
leftGroups, err := a.DB().ListLeftGroups()
if err != nil {
return err
}
totalMessages, err := a.DB().CountMessages()
if err != nil {
return err
}
stats := map[string]any{
"chats": len(chats),
"groups": len(groups),
"left_groups": len(leftGroups),
"messages": totalMessages,
}
if flags.asJSON {
return out.WriteJSON(os.Stdout, stats)
}
fmt.Fprintf(os.Stdout, "Store Statistics:\n")
fmt.Fprintf(os.Stdout, " Chats: %d\n", len(chats))
fmt.Fprintf(os.Stdout, " Groups: %d\n", len(groups))
fmt.Fprintf(os.Stdout, " Left Groups: %d\n", len(leftGroups))
fmt.Fprintf(os.Stdout, " Messages: %d\n", totalMessages)
return nil
},
}
return cmd
}

View File

@ -1,6 +1,6 @@
# chats
Read when: listing known chats, filtering chat state, or archiving/pinning/muting/marking chats.
Read when: listing known chats, filtering chat state, archiving/pinning/muting/marking chats, or pruning stale local chat rows.
`wacli chats` reads chat rows from `wacli.db`. It can use session-backed PN/LID mappings to make historical `@lid` chat rows display as phone-number chats when possible. State commands send WhatsApp app-state patches through the authenticated session and update the local index after WhatsApp accepts the change.
@ -17,6 +17,7 @@ wacli chats mute --chat CHAT [--duration DURATION] [--pick N]
wacli chats unmute --chat CHAT [--pick N]
wacli chats mark-read --chat CHAT [--pick N]
wacli chats mark-unread --chat CHAT [--pick N]
wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]
```
## Notes
@ -29,6 +30,9 @@ wacli chats mark-unread --chat CHAT [--pick N]
- State commands print a compact success line by default and a stable JSON object with `--json`.
- `mute --duration 0` or omitting `--duration` mutes forever. Use `unmute` to clear it.
- Run `wacli sync` to catch up chat-state changes made on other devices; run `wacli contacts refresh` to improve chat names.
- `cleanup` only deletes local `wacli.db` rows. It does not delete chats or messages from WhatsApp.
- `cleanup --days N` skips chats with no known local activity timestamp; use `--jid` for an explicit local row.
- Use `cleanup --dry-run` before deleting and `--confirm` only for scripts that already reviewed the target list.
## Examples
@ -39,4 +43,5 @@ wacli chats list --pinned
wacli chats show --jid 1234567890@s.whatsapp.net
wacli chats mute --chat "+1 555 123 4567" --duration 8h
wacli chats mark-read --chat family --pick 1
wacli chats cleanup --days 365 --dry-run
```

View File

@ -1,6 +1,6 @@
# groups
Read when: listing, refreshing, inspecting, renaming, joining, leaving, inviting, or managing group participants.
Read when: listing, refreshing, inspecting, renaming, joining, leaving, inviting, pruning stale local group rows, or managing group participants.
`wacli groups` combines local group rows with live WhatsApp operations. Commands that mutate WhatsApp require writable mode.
@ -19,6 +19,7 @@ wacli groups participants demote --jid GROUP_JID --user PHONE_OR_JID [--user ...
wacli groups invite link get --jid GROUP_JID
wacli groups invite link revoke --jid GROUP_JID
wacli groups join --code INVITE_CODE
wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]
```
## Notes
@ -29,6 +30,10 @@ wacli groups join --code INVITE_CODE
- `refresh` fetches joined groups live and updates local rows, including WhatsApp Community hierarchy metadata exposed by whatsmeow.
- `info` fetches one group live and persists it, including whether the chat is a Community parent or linked subgroup.
- `leave` marks the group left locally after WhatsApp confirms.
- `prune` only deletes local group/chat/message rows from `wacli.db`. It does not leave WhatsApp groups or delete anything from WhatsApp servers.
- `prune` defaults to groups marked left locally. `--days N` limits left-group pruning to groups left more than `N` days ago.
- `prune --include-active --days N` also targets active groups whose last known local message is older than `N` days. Groups with no known local activity timestamp are skipped.
- Use `prune --dry-run` before deleting and `--confirm` only after reviewing the target list.
- Participant users accept phone numbers with common formatting or JIDs.
- Invite `revoke` resets the invite link.
@ -42,4 +47,5 @@ wacli groups rename --jid 123456789@g.us --name "New name"
wacli groups participants add --jid 123456789@g.us --user "+1 (234) 567-8900"
wacli groups invite link get --jid 123456789@g.us
wacli groups join --code AbCdEfGhIjK
wacli groups prune --dry-run
```

View File

@ -23,6 +23,7 @@ A script-friendly WhatsApp CLI built on [`whatsmeow`](https://github.com/tulir/w
- **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 coverage planning and on-demand backfill.
- **Managing chat state.** Read [Chats](chats.md) for archive, pin, mute, and read/unread commands.
- **Managing local storage.** Read [Store](store.md) for stats, dry-run cleanup, and local-only pruning.
- **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.
- **Building companion tools.** Read [Companion integrations](integrations.md) for safe read-only SQLite and JSON integration patterns.

View File

@ -15,6 +15,7 @@ Read when: you need the user-facing command map, global flags, store model, or l
- Write commands acquire the store lock; use `--lock-wait DURATION` to wait.
- Use `--read-only` or `WACLI_READONLY=1` to reject commands that write WhatsApp or local state.
- Use `sync --max-messages`, `sync --max-db-size`, `WACLI_SYNC_MAX_MESSAGES`, or `WACLI_SYNC_MAX_DB_SIZE` to bound local history growth.
- Use `store cleanup`, `chats cleanup`, and `groups prune` to preview and remove stale local rows after sync has already stored them.
- Authenticated startup resolves historical `@lid` chat/message rows to phone-number JIDs when the WhatsApp session store has the mapping.
- Companion tools should prefer `--json`, `--events`, webhooks, or read-only access to `wacli.db`; see [companion integrations](integrations.md).
@ -28,6 +29,7 @@ Read when: you need the user-facing command map, global flags, store model, or l
- [contacts](contacts.md) - search contacts and manage local aliases/tags.
- [chats](chats.md) - list, show, filter, and manage known chat state.
- [groups](groups.md) - refresh, inspect, rename, leave, join, invite, and manage participants.
- [store](store.md) - inspect local store stats and prune stale local rows.
- [channels](channels.md) - list, inspect, join, leave, and send to WhatsApp Channels.
- [history](history.md) - inspect archive coverage and request older per-chat history from the primary device.
- [presence](presence.md) - send typing/paused indicators.

42
docs/store.md Normal file
View File

@ -0,0 +1,42 @@
# store
Read when: inspecting local SQLite size/counts or pruning old local chat/group rows.
`wacli store` manages the local `wacli.db` mirror. Cleanup commands only delete local wacli cache/history rows; they do not delete WhatsApp chats, leave groups, or remove messages from WhatsApp servers.
## Commands
```bash
wacli store stats
wacli store cleanup [--days N] [--dry-run] [--confirm]
```
Related cleanup commands:
```bash
wacli chats cleanup [--days N] [--jid JID] [--dry-run] [--confirm]
wacli groups prune [--days N] [--left-only=false|--include-active] [--dry-run] [--confirm]
```
## Notes
- `store stats` reads local counts for chats, groups, left groups, and messages.
- `store cleanup` removes chats whose known local activity is older than `--days` and deletes their messages through the SQLite chat/message cascade.
- `chats cleanup --jid JID` removes one local chat row and its local messages.
- `groups prune` removes local group metadata plus the matching local chat/messages for pruned group JIDs.
- `groups prune` defaults to groups you have left. `--days N` limits that to groups left more than `N` days ago.
- `groups prune --include-active --days N` also prunes active groups whose last known local message is older than `N` days. Groups with no known local activity timestamp are skipped.
- Destructive cleanup commands require confirmation unless `--confirm` is passed.
- Use `--dry-run` first; it lists what would be deleted without changing the local store.
- Use `--read-only` or `WACLI_READONLY=1` to make cleanup commands fail before opening the store for writes.
## Examples
```bash
wacli store stats
wacli store cleanup --days 365 --dry-run
wacli chats cleanup --jid 1234567890@s.whatsapp.net --dry-run
wacli groups prune --dry-run
wacli groups prune --days 180 --dry-run
wacli groups prune --include-active --days 365 --dry-run
```

View File

@ -1,6 +1,7 @@
package store
import (
"fmt"
"strings"
"time"
)
@ -142,3 +143,103 @@ func (d *DB) SetChatUnread(jid string, unread bool) error {
`, jid, boolToInt(unread))
return err
}
func (d *DB) DeleteChat(jid string) error {
jid = strings.TrimSpace(jid)
if jid == "" {
return fmt.Errorf("chat JID is required")
}
_, err := d.sql.Exec(`DELETE FROM chats WHERE jid = ?`, jid)
return err
}
func (d *DB) DeleteChatsOlderThan(days int) (int64, error) {
if days <= 0 {
return 0, fmt.Errorf("days must be positive")
}
cutoff := nowUTC().AddDate(0, 0, -days)
res, err := d.sql.Exec(`
DELETE FROM chats
WHERE jid IN (
SELECT jid FROM (
SELECT
c.jid,
CASE
WHEN COALESCE(MAX(m.ts), 0) > COALESCE(c.last_message_ts, 0) THEN COALESCE(MAX(m.ts), 0)
ELSE COALESCE(c.last_message_ts, 0)
END AS activity_ts
FROM chats c
LEFT JOIN messages m ON m.chat_jid = c.jid
GROUP BY c.jid
)
WHERE activity_ts > 0 AND activity_ts < ?
)
`, unix(cutoff))
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (d *DB) ListChatsOlderThan(days int) ([]Chat, error) {
if days <= 0 {
return nil, fmt.Errorf("days must be positive")
}
cutoff := nowUTC().AddDate(0, 0, -days)
rows, err := d.sql.Query(`
SELECT jid, kind, name, last_message_ts, archived, pinned, muted_until, unread
FROM (
SELECT
c.jid,
c.kind,
COALESCE(c.name,'') AS name,
COALESCE(c.last_message_ts,0) AS last_message_ts,
COALESCE(c.archived,0) AS archived,
COALESCE(c.pinned,0) AS pinned,
COALESCE(c.muted_until,0) AS muted_until,
COALESCE(c.unread,0) AS unread,
CASE
WHEN COALESCE(MAX(m.ts), 0) > COALESCE(c.last_message_ts, 0) THEN COALESCE(MAX(m.ts), 0)
ELSE COALESCE(c.last_message_ts, 0)
END AS activity_ts
FROM chats c
LEFT JOIN messages m ON m.chat_jid = c.jid
GROUP BY c.jid
)
WHERE activity_ts > 0 AND activity_ts < ?
ORDER BY activity_ts ASC
`, unix(cutoff))
if err != nil {
return nil, err
}
defer rows.Close()
var out []Chat
for rows.Next() {
var c Chat
var ts int64
var archived, pinned, unread int
if err := rows.Scan(&c.JID, &c.Kind, &c.Name, &ts, &archived, &pinned, &c.MutedUntil, &unread); err != nil {
return nil, err
}
c.LastMessageTS = fromUnix(ts)
c.Archived = archived != 0
c.Pinned = pinned != 0
c.Unread = unread != 0
out = append(out, c)
}
return out, rows.Err()
}
func (d *DB) CountChatMessages(jid string) (int64, error) {
jid = strings.TrimSpace(jid)
if jid == "" {
return 0, fmt.Errorf("chat JID is required")
}
row := d.sql.QueryRow(`SELECT COUNT(1) FROM messages WHERE chat_jid = ?`, jid)
var n int64
if err := row.Scan(&n); err != nil {
return 0, err
}
return n, nil
}

View File

@ -0,0 +1,420 @@
package store
import (
"testing"
"time"
)
func TestDeleteChat(t *testing.T) {
db := openTestDB(t)
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertChat("123@s.whatsapp.net", "dm", "Alice", now); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: "123@s.whatsapp.net",
MsgID: "msg1",
Timestamp: now,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
msgCount, err := db.CountChatMessages("123@s.whatsapp.net")
if err != nil {
t.Fatalf("CountChatMessages: %v", err)
}
if msgCount != 1 {
t.Fatalf("expected 1 message, got %d", msgCount)
}
if err := db.DeleteChat("123@s.whatsapp.net"); err != nil {
t.Fatalf("DeleteChat: %v", err)
}
_, err = db.GetChat("123@s.whatsapp.net")
if err == nil {
t.Fatal("expected error after delete, got nil")
}
msgCount, err = db.CountChatMessages("123@s.whatsapp.net")
if err != nil {
t.Fatalf("CountChatMessages after delete: %v", err)
}
if msgCount != 0 {
t.Fatalf("expected 0 messages after delete, got %d", msgCount)
}
}
func TestDeleteChatsOlderThan(t *testing.T) {
db := openTestDB(t)
now := time.Now().UTC()
old := now.AddDate(0, 0, -200)
recent := now.AddDate(0, 0, -30)
if err := db.UpsertChat("old@s.whatsapp.net", "dm", "Old", old); err != nil {
t.Fatalf("UpsertChat old: %v", err)
}
if err := db.UpsertChat("recent@s.whatsapp.net", "dm", "Recent", recent); err != nil {
t.Fatalf("UpsertChat recent: %v", err)
}
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: "old@s.whatsapp.net",
MsgID: "msg1",
Timestamp: old,
Text: "old message",
}); err != nil {
t.Fatalf("UpsertMessage old: %v", err)
}
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: "recent@s.whatsapp.net",
MsgID: "msg2",
Timestamp: recent,
Text: "recent message",
}); err != nil {
t.Fatalf("UpsertMessage recent: %v", err)
}
deleted, err := db.DeleteChatsOlderThan(180)
if err != nil {
t.Fatalf("DeleteChatsOlderThan: %v", err)
}
if deleted != 1 {
t.Fatalf("expected 1 deleted, got %d", deleted)
}
_, err = db.GetChat("old@s.whatsapp.net")
if err == nil {
t.Fatal("expected old chat to be deleted")
}
c, err := db.GetChat("recent@s.whatsapp.net")
if err != nil {
t.Fatalf("GetChat recent: %v", err)
}
if c.JID != "recent@s.whatsapp.net" {
t.Fatalf("expected recent chat to survive, got %s", c.JID)
}
}
func TestListChatsOlderThan(t *testing.T) {
db := openTestDB(t)
now := time.Now().UTC()
old := now.AddDate(0, 0, -200)
recent := now.AddDate(0, 0, -30)
if err := db.UpsertChat("old@s.whatsapp.net", "dm", "Old", old); err != nil {
t.Fatalf("UpsertChat old: %v", err)
}
if err := db.UpsertChat("recent@s.whatsapp.net", "dm", "Recent", recent); err != nil {
t.Fatalf("UpsertChat recent: %v", err)
}
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: "old@s.whatsapp.net",
MsgID: "msg1",
Timestamp: old,
Text: "old message",
}); err != nil {
t.Fatalf("UpsertMessage old: %v", err)
}
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: "recent@s.whatsapp.net",
MsgID: "msg2",
Timestamp: recent,
Text: "recent message",
}); err != nil {
t.Fatalf("UpsertMessage recent: %v", err)
}
chats, err := db.ListChatsOlderThan(180)
if err != nil {
t.Fatalf("ListChatsOlderThan: %v", err)
}
if len(chats) != 1 {
t.Fatalf("expected 1 chat, got %d", len(chats))
}
if chats[0].JID != "old@s.whatsapp.net" {
t.Fatalf("expected old chat, got %s", chats[0].JID)
}
}
func TestListChatsOlderThanSkipsUnknownActivity(t *testing.T) {
db := openTestDB(t)
if err := db.UpsertChat("unknown@s.whatsapp.net", "dm", "Unknown", time.Time{}); err != nil {
t.Fatalf("UpsertChat unknown: %v", err)
}
chats, err := db.ListChatsOlderThan(1)
if err != nil {
t.Fatalf("ListChatsOlderThan: %v", err)
}
if len(chats) != 0 {
t.Fatalf("expected unknown-activity chat to be skipped, got %#v", chats)
}
deleted, err := db.DeleteChatsOlderThan(1)
if err != nil {
t.Fatalf("DeleteChatsOlderThan: %v", err)
}
if deleted != 0 {
t.Fatalf("expected 0 deleted, got %d", deleted)
}
}
func TestDeleteGroup(t *testing.T) {
db := openTestDB(t)
created := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertGroup("12345@g.us", "Test Group", "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup: %v", err)
}
if err := db.DeleteGroup("12345@g.us"); err != nil {
t.Fatalf("DeleteGroup: %v", err)
}
groups, err := db.ListGroups("", 10)
if err != nil {
t.Fatalf("ListGroups: %v", err)
}
if len(groups) != 0 {
t.Fatalf("expected 0 groups after delete, got %d", len(groups))
}
}
func TestDeleteGroupLocalDataDeletesGroupChatAndMessages(t *testing.T) {
db := openTestDB(t)
now := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertGroup("12345@g.us", "Test Group", "owner@s.whatsapp.net", now); err != nil {
t.Fatalf("UpsertGroup: %v", err)
}
if err := db.UpsertChat("12345@g.us", "group", "Test Group", now); err != nil {
t.Fatalf("UpsertChat: %v", err)
}
if err := db.UpsertMessage(UpsertMessageParams{
ChatJID: "12345@g.us",
MsgID: "msg1",
Timestamp: now,
Text: "hello",
}); err != nil {
t.Fatalf("UpsertMessage: %v", err)
}
if err := db.DeleteGroupLocalData("12345@g.us"); err != nil {
t.Fatalf("DeleteGroupLocalData: %v", err)
}
if groups, err := db.ListGroups("", 10); err != nil {
t.Fatalf("ListGroups: %v", err)
} else if len(groups) != 0 {
t.Fatalf("expected group deleted, got %#v", groups)
}
if _, err := db.GetChat("12345@g.us"); err == nil {
t.Fatalf("expected chat to be deleted")
}
if got := countRows(t, db.sql, `SELECT COUNT(1) FROM messages WHERE chat_jid = ?`, "12345@g.us"); got != 0 {
t.Fatalf("expected messages deleted, got %d", got)
}
}
func TestListLeftGroups(t *testing.T) {
db := openTestDB(t)
created := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
leftAt := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertGroup("active@g.us", "Active Group", "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup active: %v", err)
}
if err := db.UpsertGroup("left@g.us", "Left Group", "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup left: %v", err)
}
if err := db.MarkGroupLeft("left@g.us", leftAt); err != nil {
t.Fatalf("MarkGroupLeft: %v", err)
}
leftGroups, err := db.ListLeftGroups()
if err != nil {
t.Fatalf("ListLeftGroups: %v", err)
}
if len(leftGroups) != 1 {
t.Fatalf("expected 1 left group, got %d", len(leftGroups))
}
if leftGroups[0].JID != "left@g.us" {
t.Fatalf("expected left@g.us, got %s", leftGroups[0].JID)
}
}
func TestDeleteLeftGroups(t *testing.T) {
db := openTestDB(t)
created := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
leftAt := time.Date(2024, 6, 1, 0, 0, 0, 0, time.UTC)
if err := db.UpsertGroup("active@g.us", "Active Group", "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup active: %v", err)
}
if err := db.UpsertGroup("left@g.us", "Left Group", "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup left: %v", err)
}
if err := db.MarkGroupLeft("left@g.us", leftAt); err != nil {
t.Fatalf("MarkGroupLeft: %v", err)
}
deleted, err := db.DeleteLeftGroups()
if err != nil {
t.Fatalf("DeleteLeftGroups: %v", err)
}
if deleted != 1 {
t.Fatalf("expected 1 deleted, got %d", deleted)
}
groups, err := db.ListGroups("", 10)
if err != nil {
t.Fatalf("ListGroups: %v", err)
}
if len(groups) != 1 {
t.Fatalf("expected 1 active group remaining, got %d", len(groups))
}
if groups[0].JID != "active@g.us" {
t.Fatalf("expected active@g.us, got %s", groups[0].JID)
}
}
func TestDeleteLeftGroupsOlderThan(t *testing.T) {
db := openTestDB(t)
now := time.Now().UTC()
created := now.AddDate(0, 0, -365)
oldLeft := now.AddDate(0, 0, -200)
recentLeft := now.AddDate(0, 0, -30)
if err := db.UpsertGroup("old-left@g.us", "Old Left", "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup old-left: %v", err)
}
if err := db.UpsertGroup("recent-left@g.us", "Recent Left", "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup recent-left: %v", err)
}
if err := db.MarkGroupLeft("old-left@g.us", oldLeft); err != nil {
t.Fatalf("MarkGroupLeft old: %v", err)
}
if err := db.MarkGroupLeft("recent-left@g.us", recentLeft); err != nil {
t.Fatalf("MarkGroupLeft recent: %v", err)
}
deleted, err := db.DeleteLeftGroupsOlderThan(180)
if err != nil {
t.Fatalf("DeleteLeftGroupsOlderThan: %v", err)
}
if deleted != 1 {
t.Fatalf("expected 1 deleted, got %d", deleted)
}
leftGroups, err := db.ListLeftGroups()
if err != nil {
t.Fatalf("ListLeftGroups: %v", err)
}
if len(leftGroups) != 1 {
t.Fatalf("expected 1 left group remaining, got %d", len(leftGroups))
}
if leftGroups[0].JID != "recent-left@g.us" {
t.Fatalf("expected recent-left@g.us, got %s", leftGroups[0].JID)
}
}
func TestListPrunableGroupsHonorsAgeAndIncludeActive(t *testing.T) {
db := openTestDB(t)
now := time.Now().UTC()
created := now.AddDate(0, 0, -400)
oldLeft := now.AddDate(0, 0, -200)
recentLeft := now.AddDate(0, 0, -30)
oldActive := now.AddDate(0, 0, -220)
recentActive := now.AddDate(0, 0, -10)
for _, tc := range []struct {
jid string
name string
}{
{"old-left@g.us", "Old Left"},
{"recent-left@g.us", "Recent Left"},
{"old-active@g.us", "Old Active"},
{"recent-active@g.us", "Recent Active"},
{"unknown-active@g.us", "Unknown Active"},
} {
if err := db.UpsertGroup(tc.jid, tc.name, "owner@s.whatsapp.net", created); err != nil {
t.Fatalf("UpsertGroup %s: %v", tc.jid, err)
}
}
if err := db.MarkGroupLeft("old-left@g.us", oldLeft); err != nil {
t.Fatalf("MarkGroupLeft old: %v", err)
}
if err := db.MarkGroupLeft("recent-left@g.us", recentLeft); err != nil {
t.Fatalf("MarkGroupLeft recent: %v", err)
}
if err := db.UpsertChat("old-active@g.us", "group", "Old Active", oldActive); err != nil {
t.Fatalf("UpsertChat old active: %v", err)
}
if err := db.UpsertChat("recent-active@g.us", "group", "Recent Active", recentActive); err != nil {
t.Fatalf("UpsertChat recent active: %v", err)
}
leftOnly, err := db.ListPrunableGroups(180, false)
if err != nil {
t.Fatalf("ListPrunableGroups left only: %v", err)
}
if got, want := groupJIDs(leftOnly), []string{"old-left@g.us"}; !sameStrings(got, want) {
t.Fatalf("left-only targets = %#v, want %#v", got, want)
}
withActive, err := db.ListPrunableGroups(180, true)
if err != nil {
t.Fatalf("ListPrunableGroups include active: %v", err)
}
if got, want := groupJIDs(withActive), []string{"old-left@g.us", "old-active@g.us"}; !sameStrings(got, want) {
t.Fatalf("include-active targets = %#v, want %#v", got, want)
}
allLeft, err := db.ListPrunableGroups(0, false)
if err != nil {
t.Fatalf("ListPrunableGroups all left: %v", err)
}
if got, want := groupJIDs(allLeft), []string{"old-left@g.us", "recent-left@g.us"}; !sameStrings(got, want) {
t.Fatalf("all-left targets = %#v, want %#v", got, want)
}
}
func groupJIDs(groups []Group) []string {
out := make([]string, 0, len(groups))
for _, g := range groups {
out = append(out, g.JID)
}
return out
}
func sameStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
seen := make(map[string]int, len(a))
for _, s := range a {
seen[s]++
}
for _, s := range b {
seen[s]--
if seen[s] < 0 {
return false
}
}
return true
}

View File

@ -1,6 +1,7 @@
package store
import (
"fmt"
"strings"
"time"
)
@ -154,3 +155,143 @@ func (d *DB) ListGroups(query string, limit int) ([]Group, error) {
}
return out, rows.Err()
}
func (d *DB) DeleteGroup(jid string) error {
jid = strings.TrimSpace(jid)
if jid == "" {
return fmt.Errorf("group JID is required")
}
_, err := d.sql.Exec(`DELETE FROM groups WHERE jid = ?`, jid)
return err
}
func (d *DB) DeleteGroupLocalData(jid string) (err error) {
jid = strings.TrimSpace(jid)
if jid == "" {
return fmt.Errorf("group JID is required")
}
tx, err := d.sql.Begin()
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.Exec(`DELETE FROM groups WHERE jid = ?`, jid); err != nil {
return err
}
if _, err = tx.Exec(`DELETE FROM chats WHERE jid = ?`, jid); err != nil {
return err
}
return tx.Commit()
}
func (d *DB) ListLeftGroups() ([]Group, error) {
rows, err := d.sql.Query(`
SELECT jid, COALESCE(name,''), COALESCE(owner_jid,''), is_parent, COALESCE(linked_parent_jid,''), COALESCE(created_ts,0), COALESCE(left_at,0), updated_at
FROM groups
WHERE left_at IS NOT NULL
ORDER BY left_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Group
for rows.Next() {
var g Group
var isParent int
var created, left, updated int64
if err := rows.Scan(&g.JID, &g.Name, &g.OwnerJID, &isParent, &g.LinkedParentJID, &created, &left, &updated); err != nil {
return nil, err
}
g.IsParent = isParent != 0
g.CreatedAt = fromUnix(created)
g.LeftAt = fromUnix(left)
g.UpdatedAt = fromUnix(updated)
out = append(out, g)
}
return out, rows.Err()
}
func (d *DB) ListPrunableGroups(days int, includeActive bool) ([]Group, error) {
if days < 0 {
return nil, fmt.Errorf("days must not be negative")
}
if includeActive && days <= 0 {
return nil, fmt.Errorf("days must be positive when pruning active groups")
}
cutoff := int64(0)
if days > 0 {
cutoff = unix(nowUTC().AddDate(0, 0, -days))
}
rows, err := d.sql.Query(`
SELECT jid, name, owner_jid, is_parent, linked_parent_jid, created_ts, left_at, updated_at
FROM (
SELECT
g.jid,
COALESCE(g.name,'') AS name,
COALESCE(g.owner_jid,'') AS owner_jid,
g.is_parent,
COALESCE(g.linked_parent_jid,'') AS linked_parent_jid,
COALESCE(g.created_ts,0) AS created_ts,
COALESCE(g.left_at,0) AS left_at,
g.updated_at,
CASE
WHEN COALESCE(MAX(m.ts), 0) > COALESCE(c.last_message_ts, 0) THEN COALESCE(MAX(m.ts), 0)
ELSE COALESCE(c.last_message_ts, 0)
END AS activity_ts
FROM groups g
LEFT JOIN chats c ON c.jid = g.jid
LEFT JOIN messages m ON m.chat_jid = g.jid
GROUP BY g.jid
)
WHERE
(left_at > 0 AND (? = 0 OR left_at < ?))
OR (? = 1 AND left_at = 0 AND activity_ts > 0 AND activity_ts < ?)
ORDER BY CASE WHEN left_at > 0 THEN left_at ELSE activity_ts END ASC
`, cutoff, cutoff, boolToInt(includeActive), cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Group
for rows.Next() {
var g Group
var isParent int
var created, left, updated int64
if err := rows.Scan(&g.JID, &g.Name, &g.OwnerJID, &isParent, &g.LinkedParentJID, &created, &left, &updated); err != nil {
return nil, err
}
g.IsParent = isParent != 0
g.CreatedAt = fromUnix(created)
g.LeftAt = fromUnix(left)
g.UpdatedAt = fromUnix(updated)
out = append(out, g)
}
return out, rows.Err()
}
func (d *DB) DeleteLeftGroups() (int64, error) {
res, err := d.sql.Exec(`DELETE FROM groups WHERE left_at IS NOT NULL`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
func (d *DB) DeleteLeftGroupsOlderThan(days int) (int64, error) {
if days <= 0 {
return 0, fmt.Errorf("days must be positive")
}
cutoff := nowUTC().AddDate(0, 0, -days)
res, err := d.sql.Exec(`DELETE FROM groups WHERE left_at IS NOT NULL AND left_at < ?`, unix(cutoff))
if err != nil {
return 0, err
}
return res.RowsAffected()
}