From 8a9859f50b4f4e199a9eab9ded04b295b916e3bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Apr 2026 17:58:46 +0100 Subject: [PATCH] feat(cli): add fast DM query shortcuts --- README.md | 17 ++++ internal/cli/cli.go | 10 ++- internal/cli/cli_test.go | 22 +++++ internal/cli/direct_messages.go | 144 ++++++++++++++++++++++++++++++ internal/cli/helpers.go | 13 +++ internal/cli/messages.go | 9 +- internal/cli/output.go | 20 +++++ internal/cli/query_commands.go | 7 +- internal/store/direct_messages.go | 86 ++++++++++++++++++ 9 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 internal/cli/direct_messages.go create mode 100644 internal/store/direct_messages.go diff --git a/README.md b/README.md index a9b7f1d..6b6f426 100644 --- a/README.md +++ b/README.md @@ -278,6 +278,7 @@ discrawl search --mode fts "panic: nil pointer" discrawl search --mode semantic "missing launch checklist" discrawl search --mode hybrid "database timeout" discrawl search --guild 123456789012345678 "payment failed" +discrawl search --dm "launch checklist" discrawl search --channel billing --author steipete --limit 50 "invoice" discrawl search --include-empty "GitHub" discrawl --json search "websocket closed" @@ -303,6 +304,7 @@ discrawl messages --channel maintainers --hours 6 --all discrawl messages --channel "#maintainers" --since 2026-03-01T00:00:00Z discrawl messages --channel 1456744319972282449 --author steipete --limit 50 discrawl messages --channel maintainers --last 100 --sync +discrawl messages --dm --channel Molty --last 20 discrawl messages --channel maintainers --days 7 --all --include-empty discrawl --json messages --channel maintainers --days 3 ``` @@ -317,6 +319,21 @@ Notes: - `--sync` runs a blocking pre-query sync for the matching channel or guild scope before reading the local DB - rows with no displayable/searchable content are skipped by default; `--include-empty` opts back in - at least one filter is required +- `--dm` is shorthand for `--guild @me`, so DM searches and message slices do not need raw SQL + +### `dms` + +Lists local wiretap DM conversations or reads one DM thread. + +```bash +discrawl dms +discrawl dms --with Molty --last 20 +discrawl dms --with 1456464433768300635 --all +discrawl dms --search "launch checklist" +discrawl dms --with Molty --search "invoice" +``` + +`discrawl dms` shows one row per local DM channel with message count, author count, and first/last cached message times. Passing `--with` switches to message output for that DM conversation unless `--list` is also set. `--search` searches only local DM messages. This is a convenience layer over the local-only synthetic guild id `@me`; it skips Git snapshot auto-update because DMs are never imported from the shared mirror, and it still only sees Discord Desktop cache data imported by `wiretap`. ### `mentions` diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3b74712..2ec8263 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -129,12 +129,16 @@ func (r *runtime) dispatch(rest []string) error { case "wiretap": return r.withLocalStoreDefault(false, func() error { return r.runWiretap(rest[1:]) }) case "search": - return r.withLocalStoreDefault(true, func() error { return r.runSearch(rest[1:]) }) + autoShareUpdate := !hasBoolFlag(rest[1:], "--dm") + return r.withLocalStoreDefault(autoShareUpdate, func() error { return r.runSearch(rest[1:]) }) case "messages": - if hasBoolFlag(rest[1:], "--sync") { + if hasBoolFlag(rest[1:], "--sync") && !hasBoolFlag(rest[1:], "--dm") { return r.withServicesAuto(true, true, func() error { return r.runMessages(rest[1:]) }) } - return r.withLocalStoreDefault(true, func() error { return r.runMessages(rest[1:]) }) + autoShareUpdate := !hasBoolFlag(rest[1:], "--dm") + return r.withLocalStoreDefault(autoShareUpdate, func() error { return r.runMessages(rest[1:]) }) + case "dms": + return r.withLocalStoreDefault(false, func() error { return r.runDirectMessages(rest[1:]) }) case "mentions": return r.withLocalStoreDefault(true, func() error { return r.runMentions(rest[1:]) }) case "embed": diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index cdd410d..5292978 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -162,6 +162,24 @@ func TestWiretapImportsDesktopDirectMessages(t *testing.T) { require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "launch"}, &out, &bytes.Buffer{})) require.Contains(t, out.String(), "secret DM launch plan") require.Contains(t, out.String(), "@me") + + out.Reset() + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms"}, &out, &bytes.Buffer{})) + require.Contains(t, out.String(), "Alice") + require.Contains(t, out.String(), "111111111111111111") + + out.Reset() + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms", "--with", "Alice", "--last", "1"}, &out, &bytes.Buffer{})) + require.Contains(t, out.String(), "secret DM launch plan") + require.Contains(t, out.String(), "@me") + + out.Reset() + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--dm", "launch"}, &out, &bytes.Buffer{})) + require.Contains(t, out.String(), "secret DM launch plan") + + out.Reset() + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "messages", "--dm", "--channel", "Alice", "--last", "1"}, &out, &bytes.Buffer{})) + require.Contains(t, out.String(), "secret DM launch plan") } func TestWiretapAndSearchWorkWithoutConfig(t *testing.T) { @@ -1391,6 +1409,10 @@ func TestCommandUsageBranches(t *testing.T) { {[]string{"--config", cfgPath, "sql", "--confirm", "select 1"}, "--confirm requires --unsafe"}, {[]string{"--config", cfgPath, "sql", "--unsafe", "delete from messages"}, "--unsafe requires --confirm"}, {[]string{"--config", cfgPath, "search"}, "search requires a query"}, + {[]string{"--config", cfgPath, "search", "--dm", "--guild", "g1", "panic"}, "use either --dm or --guild/--guilds"}, + {[]string{"--config", cfgPath, "messages", "--dm", "--guild", "g1"}, "use either --dm or --guild/--guilds"}, + {[]string{"--config", cfgPath, "messages", "--dm", "--sync"}, "messages --sync is not supported with --dm"}, + {[]string{"--config", cfgPath, "dms", "extra"}, "dms takes flags only"}, {[]string{"--config", cfgPath, "members"}, "members requires a subcommand"}, {[]string{"--config", cfgPath, "members", "search"}, "members search requires a query"}, {[]string{"--config", cfgPath, "members", "bogus"}, `unknown members subcommand "bogus"`}, diff --git a/internal/cli/direct_messages.go b/internal/cli/direct_messages.go new file mode 100644 index 0000000..570cd2f --- /dev/null +++ b/internal/cli/direct_messages.go @@ -0,0 +1,144 @@ +package cli + +import ( + "flag" + "fmt" + "io" + "strings" + "time" + + "github.com/steipete/discrawl/internal/store" +) + +const defaultDMLast = 50 + +func (r *runtime) runDirectMessages(args []string) error { + fs := flag.NewFlagSet("dms", flag.ContinueOnError) + fs.SetOutput(io.Discard) + with := fs.String("with", "", "") + search := fs.String("search", "", "") + hours := fs.Int("hours", 0, "") + days := fs.Int("days", 0, "") + since := fs.String("since", "", "") + before := fs.String("before", "", "") + limit := fs.Int("limit", defaultDMLast, "") + last := fs.Int("last", defaultDMLast, "") + all := fs.Bool("all", false, "") + list := fs.Bool("list", false, "") + includeEmpty := fs.Bool("include-empty", false, "") + if err := fs.Parse(args); err != nil { + return usageErr(err) + } + if fs.NArg() != 0 { + return usageErr(fmt.Errorf("dms takes flags only")) + } + if *hours < 0 { + return usageErr(fmt.Errorf("--hours must be >= 0")) + } + if *days < 0 { + return usageErr(fmt.Errorf("--days must be >= 0")) + } + if countNonZero(*hours > 0, *days > 0, strings.TrimSpace(*since) != "") > 1 { + return usageErr(fmt.Errorf("use only one of --hours, --days, or --since")) + } + if *limit < 0 { + return usageErr(fmt.Errorf("--limit must be >= 0")) + } + if *last < 0 { + return usageErr(fmt.Errorf("--last must be >= 0")) + } + if *all && *last > 0 && flagPassed(fs, "last") { + return usageErr(fmt.Errorf("use either --all or --last")) + } + if flagPassed(fs, "limit") && flagPassed(fs, "last") { + return usageErr(fmt.Errorf("use either --limit or --last")) + } + + if *list || (strings.TrimSpace(*with) == "" && strings.TrimSpace(*search) == "" && noDMMessageTimeFilter(*hours, *days, *since, *before)) { + rows, err := r.store.DirectMessageConversations(r.ctx, store.DirectMessageConversationOptions{With: *with}) + if err != nil { + return err + } + return r.print(rows) + } + + sinceTime, beforeTime, err := r.parseMessageWindow(*hours, *days, *since, *before) + if err != nil { + return err + } + if query := strings.TrimSpace(*search); query != "" { + opts := store.SearchOptions{ + Query: query, + GuildIDs: []string{store.DirectMessageGuildID}, + Channel: *with, + Limit: *limit, + IncludeEmpty: *includeEmpty, + } + results, err := r.store.SearchMessages(r.ctx, opts) + if err != nil { + return err + } + return r.print(results) + } + + messageLimit := *limit + messageLast := *last + if *all { + messageLimit = 0 + messageLast = 0 + } else if flagPassed(fs, "limit") { + messageLast = 0 + } else { + messageLimit = 0 + } + rows, err := r.store.ListMessages(r.ctx, store.MessageListOptions{ + GuildIDs: []string{store.DirectMessageGuildID}, + Channel: *with, + Since: sinceTime, + Before: beforeTime, + Limit: messageLimit, + Last: messageLast, + IncludeEmpty: *includeEmpty, + }) + if err != nil { + return err + } + return r.print(rows) +} + +func (r *runtime) parseMessageWindow(hours, days int, since, before string) (time.Time, time.Time, error) { + var sinceTime time.Time + var beforeTime time.Time + var err error + if hours > 0 { + now := time.Now().UTC() + if r.now != nil { + now = r.now().UTC() + } + sinceTime = now.Add(-time.Duration(hours) * time.Hour) + } + if days > 0 { + now := time.Now().UTC() + if r.now != nil { + now = r.now().UTC() + } + sinceTime = now.Add(-time.Duration(days) * 24 * time.Hour) + } + if strings.TrimSpace(since) != "" { + sinceTime, err = time.Parse(time.RFC3339, since) + if err != nil { + return time.Time{}, time.Time{}, usageErr(fmt.Errorf("invalid --since: %w", err)) + } + } + if strings.TrimSpace(before) != "" { + beforeTime, err = time.Parse(time.RFC3339, before) + if err != nil { + return time.Time{}, time.Time{}, usageErr(fmt.Errorf("invalid --before: %w", err)) + } + } + return sinceTime, beforeTime, nil +} + +func noDMMessageTimeFilter(hours, days int, since, before string) bool { + return hours == 0 && days == 0 && strings.TrimSpace(since) == "" && strings.TrimSpace(before) == "" +} diff --git a/internal/cli/helpers.go b/internal/cli/helpers.go index 1e2ed62..bab5c6f 100644 --- a/internal/cli/helpers.go +++ b/internal/cli/helpers.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" "time" + + "github.com/steipete/discrawl/internal/store" ) func (r *runtime) resolveSyncGuilds(guild, guilds string) []string { @@ -34,6 +36,17 @@ func (r *runtime) resolveSearchGuilds(guild, guilds string) []string { return csvList(strings.Join(requested, ",")) } +func directMessageGuildScope(dm bool, guild, guilds string) ([]string, error) { + if !dm { + requested := append(csvList(guilds), strings.TrimSpace(guild)) + return csvList(strings.Join(requested, ",")), nil + } + if len(csvList(guilds)) > 0 || strings.TrimSpace(guild) != "" { + return nil, fmt.Errorf("use either --dm or --guild/--guilds") + } + return []string{store.DirectMessageGuildID}, nil +} + func csvList(raw string) []string { if raw == "" { return nil diff --git a/internal/cli/messages.go b/internal/cli/messages.go index fddbded..8bfb655 100644 --- a/internal/cli/messages.go +++ b/internal/cli/messages.go @@ -26,6 +26,7 @@ func (r *runtime) runMessages(args []string) error { all := fs.Bool("all", false, "") syncNow := fs.Bool("sync", false, "") includeEmpty := fs.Bool("include-empty", false, "") + dm := fs.Bool("dm", false, "") guildsFlag := fs.String("guilds", "", "") guildFlag := fs.String("guild", "", "") if err := fs.Parse(args); err != nil { @@ -90,7 +91,13 @@ func (r *runtime) runMessages(args []string) error { } } - guildIDs := r.resolveSearchGuilds(*guildFlag, *guildsFlag) + guildIDs, err := directMessageGuildScope(*dm, *guildFlag, *guildsFlag) + if err != nil { + return usageErr(err) + } + if *dm && *syncNow { + return usageErr(fmt.Errorf("messages --sync is not supported with --dm; run wiretap or sync --source wiretap first")) + } if strings.TrimSpace(*channel) == "" && strings.TrimSpace(*author) == "" && sinceTime.IsZero() && beforeTime.IsZero() && len(guildIDs) == 0 { return usageErr(fmt.Errorf("messages needs at least one filter")) } diff --git a/internal/cli/output.go b/internal/cli/output.go index fcd679a..3971efe 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -58,6 +58,11 @@ func printPlain(w io.Writer, value any) error { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", formatTime(row.CreatedAt), row.GuildID, row.ChannelID, row.AuthorID, row.MessageID, row.Content) } return nil + case []store.DirectMessageConversationRow: + for _, row := range v { + _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\t%s\n", row.ChannelID, row.Name, row.MessageCount, row.AuthorCount, formatTime(row.FirstMessageAt), formatTime(row.LastMessageAt)) + } + return nil case []store.MentionRow: for _, row := range v { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", formatTime(row.CreatedAt), row.GuildID, row.ChannelID, row.AuthorID, row.TargetType, row.TargetID, row.Content) @@ -81,6 +86,7 @@ Commands: wiretap search messages + dms mentions embed sql @@ -161,6 +167,20 @@ func printHuman(w io.Writer, value any) error { } } return nil + case []store.DirectMessageConversationRow: + tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "CHANNEL\tNAME\tMESSAGES\tAUTHORS\tFIRST\tLAST") + for _, row := range v { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%s\t%s\n", + row.ChannelID, + row.Name, + row.MessageCount, + row.AuthorCount, + formatTime(row.FirstMessageAt), + formatTime(row.LastMessageAt), + ) + } + return tw.Flush() case []store.MentionRow: for _, row := range v { if _, err := fmt.Fprintf(w, "[%s/%s] %s -> %s:%s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, row.TargetType, firstNonEmpty(row.TargetName, row.TargetID), formatTime(row.CreatedAt), row.Content); err != nil { diff --git a/internal/cli/query_commands.go b/internal/cli/query_commands.go index a53f8f9..26a12ee 100644 --- a/internal/cli/query_commands.go +++ b/internal/cli/query_commands.go @@ -22,6 +22,7 @@ func (r *runtime) runSearch(args []string) error { author := fs.String("author", "", "") limit := fs.Int("limit", 20, "") includeEmpty := fs.Bool("include-empty", false, "") + dm := fs.Bool("dm", false, "") guildsFlag := fs.String("guilds", "", "") guildFlag := fs.String("guild", "", "") if err := fs.Parse(args); err != nil { @@ -30,9 +31,13 @@ func (r *runtime) runSearch(args []string) error { if fs.NArg() != 1 { return usageErr(fmt.Errorf("search requires a query")) } + guildIDs, err := directMessageGuildScope(*dm, *guildFlag, *guildsFlag) + if err != nil { + return usageErr(err) + } opts := store.SearchOptions{ Query: fs.Arg(0), - GuildIDs: r.resolveSearchGuilds(*guildFlag, *guildsFlag), + GuildIDs: guildIDs, Channel: *channel, Author: *author, Limit: *limit, diff --git a/internal/store/direct_messages.go b/internal/store/direct_messages.go new file mode 100644 index 0000000..98d2f67 --- /dev/null +++ b/internal/store/direct_messages.go @@ -0,0 +1,86 @@ +package store + +import ( + "context" + "strings" + "time" +) + +const DirectMessageGuildID = "@me" + +type DirectMessageConversationOptions struct { + With string + Limit int +} + +type DirectMessageConversationRow struct { + ChannelID string `json:"channel_id"` + Name string `json:"name"` + MessageCount int `json:"message_count"` + AuthorCount int `json:"author_count"` + FirstMessageAt time.Time `json:"first_message_at,omitempty"` + LastMessageAt time.Time `json:"last_message_at,omitempty"` +} + +func (s *Store) DirectMessageConversations(ctx context.Context, opts DirectMessageConversationOptions) ([]DirectMessageConversationRow, error) { + args := []any{DirectMessageGuildID} + clauses := []string{"c.guild_id = ?"} + if with := strings.TrimSpace(opts.With); with != "" { + clauses = append(clauses, `( + c.id = ? or c.name = ? or c.name like ? or exists ( + select 1 + from messages mx + where mx.guild_id = ? + and mx.channel_id = c.id + and ( + mx.author_id = ? + or coalesce(json_extract(mx.raw_json, '$.author.username'), '') = ? + or coalesce(json_extract(mx.raw_json, '$.author.global_name'), '') = ? + or coalesce(json_extract(mx.raw_json, '$.author.username'), '') like ? + or coalesce(json_extract(mx.raw_json, '$.author.global_name'), '') like ? + ) + ) + )`) + like := "%" + with + "%" + args = append(args, with, with, like, DirectMessageGuildID, with, with, with, like, like) + } + + query := ` + select + c.id, + c.name, + count(m.id), + count(distinct m.author_id), + coalesce(min(m.created_at), ''), + coalesce(max(m.created_at), '') + from channels c + left join messages m on m.guild_id = c.guild_id and m.channel_id = c.id + where ` + strings.Join(clauses, " and ") + ` + group by c.id, c.name + order by coalesce(max(m.created_at), '') desc, c.name + ` + if opts.Limit > 0 { + query += ` limit ?` + args = append(args, opts.Limit) + } + + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + out := []DirectMessageConversationRow{} + for rows.Next() { + var row DirectMessageConversationRow + var first string + var last string + if err := rows.Scan(&row.ChannelID, &row.Name, &row.MessageCount, &row.AuthorCount, &first, &last); err != nil { + return nil, err + } + row.FirstMessageAt = parseTime(first) + row.LastMessageAt = parseTime(last) + out = append(out, row) + } + return out, rows.Err() +}