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:
parent
c912668b21
commit
af671e16a9
@ -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
|
||||
|
||||
|
||||
12
README.md
12
README.md
@ -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`
|
||||
|
||||
@ -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
166
cmd/wacli/chats_cleanup.go
Normal 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
|
||||
}
|
||||
@ -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
135
cmd/wacli/groups_prune.go
Normal 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)
|
||||
}
|
||||
}
|
||||
117
cmd/wacli/groups_prune_test.go
Normal file
117
cmd/wacli/groups_prune_test.go
Normal 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
|
||||
}
|
||||
@ -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
13
cmd/wacli/store.go
Normal 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
126
cmd/wacli/store_cleanup.go
Normal 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
68
cmd/wacli/store_stats.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
42
docs/store.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
}
|
||||
|
||||
420
internal/store/cleanup_test.go
Normal file
420
internal/store/cleanup_test.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user