diff --git a/CHANGELOG.md b/CHANGELOG.md index d7e8639..4c925ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 2ad2ad1..67e6d55 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Full docs site: . - [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: . - **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 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` diff --git a/cmd/wacli/chats.go b/cmd/wacli/chats.go index 10f27f4..22acddf 100644 --- a/cmd/wacli/chats.go +++ b/cmd/wacli/chats.go @@ -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 } diff --git a/cmd/wacli/chats_cleanup.go b/cmd/wacli/chats_cleanup.go new file mode 100644 index 0000000..71c9626 --- /dev/null +++ b/cmd/wacli/chats_cleanup.go @@ -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 +} diff --git a/cmd/wacli/groups.go b/cmd/wacli/groups.go index 30c333a..a56f264 100644 --- a/cmd/wacli/groups.go +++ b/cmd/wacli/groups.go @@ -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 } diff --git a/cmd/wacli/groups_prune.go b/cmd/wacli/groups_prune.go new file mode 100644 index 0000000..55f7ee9 --- /dev/null +++ b/cmd/wacli/groups_prune.go @@ -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) + } +} diff --git a/cmd/wacli/groups_prune_test.go b/cmd/wacli/groups_prune_test.go new file mode 100644 index 0000000..76cee8f --- /dev/null +++ b/cmd/wacli/groups_prune_test.go @@ -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 +} diff --git a/cmd/wacli/root.go b/cmd/wacli/root.go index 6db184f..87c9225 100644 --- a/cmd/wacli/root.go +++ b/cmd/wacli/root.go @@ -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 { diff --git a/cmd/wacli/store.go b/cmd/wacli/store.go new file mode 100644 index 0000000..0dd7e72 --- /dev/null +++ b/cmd/wacli/store.go @@ -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 +} diff --git a/cmd/wacli/store_cleanup.go b/cmd/wacli/store_cleanup.go new file mode 100644 index 0000000..dc8cdee --- /dev/null +++ b/cmd/wacli/store_cleanup.go @@ -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 +} diff --git a/cmd/wacli/store_stats.go b/cmd/wacli/store_stats.go new file mode 100644 index 0000000..bac911c --- /dev/null +++ b/cmd/wacli/store_stats.go @@ -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 +} diff --git a/docs/chats.md b/docs/chats.md index b484928..76ba859 100644 --- a/docs/chats.md +++ b/docs/chats.md @@ -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 ``` diff --git a/docs/groups.md b/docs/groups.md index 3a7774f..b828248 100644 --- a/docs/groups.md +++ b/docs/groups.md @@ -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 ``` diff --git a/docs/index.md b/docs/index.md index 6771df5..a066e8a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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. diff --git a/docs/overview.md b/docs/overview.md index eb5fe08..aa663b6 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -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. diff --git a/docs/store.md b/docs/store.md new file mode 100644 index 0000000..de5ec4c --- /dev/null +++ b/docs/store.md @@ -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 +``` diff --git a/internal/store/chats.go b/internal/store/chats.go index 3361dc4..035665d 100644 --- a/internal/store/chats.go +++ b/internal/store/chats.go @@ -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 +} diff --git a/internal/store/cleanup_test.go b/internal/store/cleanup_test.go new file mode 100644 index 0000000..e2a0715 --- /dev/null +++ b/internal/store/cleanup_test.go @@ -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 +} diff --git a/internal/store/groups.go b/internal/store/groups.go index 71f2ada..6ea5148 100644 --- a/internal/store/groups.go +++ b/internal/store/groups.go @@ -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() +}