feat(cli): add fast DM query shortcuts
This commit is contained in:
parent
69f7671ed5
commit
8a9859f50b
17
README.md
17
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`
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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"`},
|
||||
|
||||
144
internal/cli/direct_messages.go
Normal file
144
internal/cli/direct_messages.go
Normal 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) == ""
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
86
internal/store/direct_messages.go
Normal file
86
internal/store/direct_messages.go
Normal 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()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user