feat(cli): add fast DM query shortcuts

This commit is contained in:
Peter Steinberger 2026-04-24 17:58:46 +01:00
parent 69f7671ed5
commit 8a9859f50b
No known key found for this signature in database
9 changed files with 323 additions and 5 deletions

View File

@ -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`

View File

@ -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":

View File

@ -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"`},

View File

@ -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) == ""
}

View File

@ -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

View File

@ -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"))
}

View File

@ -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 {

View File

@ -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,

View File

@ -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()
}