Skip to content

Commit fd2ca0f

Browse files
committed
feat: add blocking message sync queries
1 parent d8cdd71 commit fd2ca0f

9 files changed

Lines changed: 376 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ All notable changes to `discrawl` will be documented in this file.
88
- `members search` now matches archived profile fields in addition to names
99
- `members show` now accepts ids or queries and shows recent messages plus message stats when uniquely resolved
1010
- profile extraction surfaces stored fields like `bio`, `website`, `x`, `github`, and other archived URLs when present
11+
- `messages --sync` now blocks on a targeted pre-query refresh for channel/guild scope
12+
- `messages --hours` adds recent-hour slices without manual RFC3339 timestamps
13+
- `messages --last` returns the newest matching rows while preserving oldest-to-newest output order
1114

1215
## 0.1.0 - 2026-03-08
1316

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,17 +181,22 @@ Lists exact message slices by channel, author, and time range.
181181

182182
```bash
183183
bin/discrawl messages --channel maintainers --days 7 --all
184+
bin/discrawl messages --channel maintainers --hours 6 --all
184185
bin/discrawl messages --channel "#maintainers" --since 2026-03-01T00:00:00Z
185186
bin/discrawl messages --channel 1456744319972282449 --author steipete --limit 50
187+
bin/discrawl messages --channel maintainers --last 100 --sync
186188
bin/discrawl messages --channel maintainers --days 7 --all --include-empty
187189
bin/discrawl --json messages --channel maintainers --days 3
188190
```
189191

190192
Notes:
191193

192194
- `--channel` accepts a channel id, exact name, `#name`, or partial name match
195+
- `--hours` is shorthand for "since now minus N hours"
193196
- `--days` is shorthand for "since now minus N days"
197+
- `--last` returns the newest `N` matching messages, then prints them oldest-to-newest
194198
- `--all` removes the safety limit; default is `200`
199+
- `--sync` runs a blocking pre-query sync for the matching channel or guild scope before reading the local DB
195200
- rows with no displayable/searchable content are skipped by default; `--include-empty` opts back in
196201
- at least one filter is required
197202

internal/cli/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func (r *runtime) dispatch(rest []string) error {
125125
case "search":
126126
return r.withServices(false, func() error { return r.runSearch(rest[1:]) })
127127
case "messages":
128-
return r.withServices(false, func() error { return r.runMessages(rest[1:]) })
128+
return r.withServices(hasBoolFlag(rest[1:], "--sync"), func() error { return r.runMessages(rest[1:]) })
129129
case "mentions":
130130
return r.withServices(false, func() error { return r.runMentions(rest[1:]) })
131131
case "sql":

internal/cli/cli_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,7 @@ func TestRuntimeHelpersAndSubcommands(t *testing.T) {
394394
require.NoError(t, rt.runMembers([]string{"search", "pet"}))
395395
require.NoError(t, rt.runMembers([]string{"list"}))
396396
rt.now = func() time.Time { return time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) }
397+
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--hours", "6", "--last", "1"}))
397398
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--days", "7", "--all"}))
398399
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--days", "7", "--all", "--include-empty"}))
399400
require.NoError(t, rt.runMentions([]string{"--channel", "#general", "--target", "u2"}))
@@ -405,12 +406,186 @@ func TestRuntimeHelpersAndSubcommands(t *testing.T) {
405406
}))
406407
}
407408

409+
func TestRunMembersShowUsesDefaultGuildForAmbiguousQuery(t *testing.T) {
410+
t.Parallel()
411+
412+
ctx := context.Background()
413+
dir := t.TempDir()
414+
cfgPath := filepath.Join(dir, "config.toml")
415+
dbPath := filepath.Join(dir, "discrawl.db")
416+
417+
cfg := config.Default()
418+
cfg.DBPath = dbPath
419+
cfg.DefaultGuildID = "g1"
420+
require.NoError(t, config.Write(cfgPath, cfg))
421+
422+
s, err := store.Open(ctx, dbPath)
423+
require.NoError(t, err)
424+
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
425+
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{
426+
GuildID: "g1",
427+
UserID: "u1",
428+
Username: "same",
429+
DisplayName: "Same",
430+
RoleIDsJSON: `[]`,
431+
RawJSON: `{"github":"steipete"}`,
432+
}))
433+
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{
434+
GuildID: "g2",
435+
UserID: "u2",
436+
Username: "same",
437+
DisplayName: "Same",
438+
RoleIDsJSON: `[]`,
439+
RawJSON: `{"github":"other"}`,
440+
}))
441+
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
442+
ID: "m1",
443+
GuildID: "g1",
444+
ChannelID: "c1",
445+
ChannelName: "general",
446+
AuthorID: "u1",
447+
AuthorName: "Same",
448+
MessageType: 0,
449+
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
450+
Content: "hello",
451+
NormalizedContent: "hello",
452+
RawJSON: `{}`,
453+
}))
454+
require.NoError(t, s.Close())
455+
456+
var out bytes.Buffer
457+
rt := &runtime{
458+
ctx: ctx,
459+
configPath: cfgPath,
460+
stdout: &out,
461+
stderr: &bytes.Buffer{},
462+
logger: discardLogger(),
463+
}
464+
require.NoError(t, rt.withServices(false, func() error {
465+
return rt.runMembers([]string{"show", "same"})
466+
}))
467+
require.Contains(t, out.String(), "guild=g1")
468+
require.Contains(t, out.String(), "github=steipete")
469+
}
470+
471+
func TestRunMembersShowReturnsListWhenStillAmbiguous(t *testing.T) {
472+
t.Parallel()
473+
474+
ctx := context.Background()
475+
dir := t.TempDir()
476+
cfgPath := filepath.Join(dir, "config.toml")
477+
dbPath := filepath.Join(dir, "discrawl.db")
478+
479+
cfg := config.Default()
480+
cfg.DBPath = dbPath
481+
require.NoError(t, config.Write(cfgPath, cfg))
482+
483+
s, err := store.Open(ctx, dbPath)
484+
require.NoError(t, err)
485+
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u1", Username: "same", DisplayName: "Same", RoleIDsJSON: `[]`, RawJSON: `{}`}))
486+
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g2", UserID: "u2", Username: "same", DisplayName: "Same", RoleIDsJSON: `[]`, RawJSON: `{}`}))
487+
require.NoError(t, s.Close())
488+
489+
var out bytes.Buffer
490+
rt := &runtime{
491+
ctx: ctx,
492+
configPath: cfgPath,
493+
stdout: &out,
494+
stderr: &bytes.Buffer{},
495+
logger: discardLogger(),
496+
}
497+
require.NoError(t, rt.withServices(false, func() error {
498+
return rt.runMembers([]string{"show", "same"})
499+
}))
500+
require.Contains(t, out.String(), "GUILD")
501+
require.Contains(t, out.String(), "u1")
502+
require.Contains(t, out.String(), "u2")
503+
}
504+
505+
func TestRunMessagesSyncTargetsResolvedChannel(t *testing.T) {
506+
ctx := context.Background()
507+
dir := t.TempDir()
508+
cfgPath := filepath.Join(dir, "config.toml")
509+
dbPath := filepath.Join(dir, "discrawl.db")
510+
t.Setenv(config.DefaultTokenEnv, "env-token")
511+
512+
cfg := config.Default()
513+
cfg.DBPath = dbPath
514+
cfg.DefaultGuildID = "g1"
515+
require.NoError(t, config.Write(cfgPath, cfg))
516+
517+
s, err := store.Open(ctx, dbPath)
518+
require.NoError(t, err)
519+
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
520+
require.NoError(t, s.Close())
521+
522+
fakeSync := &fakeSyncService{}
523+
rt := &runtime{
524+
ctx: ctx,
525+
configPath: cfgPath,
526+
stdout: &bytes.Buffer{},
527+
stderr: &bytes.Buffer{},
528+
logger: discardLogger(),
529+
openStore: store.Open,
530+
newDiscord: func(config.Config) (discordClient, error) { return &fakeDiscordClient{}, nil },
531+
newSyncer: func(syncer.Client, *store.Store, *slog.Logger) syncService {
532+
return fakeSync
533+
},
534+
}
535+
rt.now = func() time.Time { return time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) }
536+
537+
require.NoError(t, rt.withServices(true, func() error {
538+
return rt.runMessages([]string{"--channel", "#general", "--hours", "6", "--last", "1", "--sync"})
539+
}))
540+
require.Equal(t, []string{"g1"}, fakeSync.lastSync.GuildIDs)
541+
require.Equal(t, []string{"c1"}, fakeSync.lastSync.ChannelIDs)
542+
}
543+
544+
func TestRunMessagesSyncFallsBackToGuildSyncForUnknownChannel(t *testing.T) {
545+
ctx := context.Background()
546+
dir := t.TempDir()
547+
cfgPath := filepath.Join(dir, "config.toml")
548+
dbPath := filepath.Join(dir, "discrawl.db")
549+
t.Setenv(config.DefaultTokenEnv, "env-token")
550+
551+
cfg := config.Default()
552+
cfg.DBPath = dbPath
553+
cfg.DefaultGuildID = "g1"
554+
require.NoError(t, config.Write(cfgPath, cfg))
555+
556+
fakeSync := &fakeSyncService{}
557+
rt := &runtime{
558+
ctx: ctx,
559+
configPath: cfgPath,
560+
stdout: &bytes.Buffer{},
561+
stderr: &bytes.Buffer{},
562+
logger: discardLogger(),
563+
openStore: store.Open,
564+
newDiscord: func(config.Config) (discordClient, error) { return &fakeDiscordClient{}, nil },
565+
newSyncer: func(syncer.Client, *store.Store, *slog.Logger) syncService {
566+
return fakeSync
567+
},
568+
}
569+
570+
require.NoError(t, rt.withServices(true, func() error {
571+
return rt.runMessages([]string{"--channel", "new-channel", "--days", "1", "--sync"})
572+
}))
573+
require.Equal(t, []string{"g1"}, fakeSync.lastSync.GuildIDs)
574+
require.Empty(t, fakeSync.lastSync.ChannelIDs)
575+
}
576+
408577
func TestRunMentionsValidation(t *testing.T) {
409578
t.Parallel()
410579

411580
rt := &runtime{stderr: &bytes.Buffer{}}
412581
rt.now = func() time.Time { return time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) }
413582

583+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--hours", "-1", "--channel", "general"})))
584+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--hours", "1", "--days", "1", "--channel", "general"})))
585+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--hours", "1", "--since", "2026-03-01T00:00:00Z", "--channel", "general"})))
586+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--last", "-1", "--channel", "general"})))
587+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--last", "1", "--limit", "20", "--channel", "general"})))
588+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--last", "1", "--all", "--channel", "general"})))
414589
require.Equal(t, 2, ExitCode(rt.runMentions([]string{"--days", "-1", "--target", "u1"})))
415590
require.Equal(t, 2, ExitCode(rt.runMentions([]string{"--days", "1", "--since", "2026-03-01T00:00:00Z", "--target", "u1"})))
416591
require.Equal(t, 2, ExitCode(rt.runMentions([]string{"--since", "bad", "--target", "u1"})))

internal/cli/helpers.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"flag"
45
"strings"
56
"time"
67
)
@@ -43,6 +44,16 @@ func csvList(raw string) []string {
4344
return out
4445
}
4546

47+
func flagPassed(fs *flag.FlagSet, name string) bool {
48+
found := false
49+
fs.Visit(func(f *flag.Flag) {
50+
if f.Name == name {
51+
found = true
52+
}
53+
})
54+
return found
55+
}
56+
4657
func mustDuration(raw string) time.Duration {
4758
d, err := time.ParseDuration(raw)
4859
if err != nil {

internal/cli/messages.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@ func (r *runtime) runMessages(args []string) error {
1717
fs.SetOutput(io.Discard)
1818
channel := fs.String("channel", "", "")
1919
author := fs.String("author", "", "")
20+
hours := fs.Int("hours", 0, "")
2021
days := fs.Int("days", 0, "")
2122
since := fs.String("since", "", "")
2223
before := fs.String("before", "", "")
2324
limit := fs.Int("limit", defaultMessageLimit, "")
25+
last := fs.Int("last", 0, "")
2426
all := fs.Bool("all", false, "")
27+
syncNow := fs.Bool("sync", false, "")
2528
includeEmpty := fs.Bool("include-empty", false, "")
2629
guildsFlag := fs.String("guilds", "", "")
2730
guildFlag := fs.String("guild", "", "")
@@ -31,19 +34,42 @@ func (r *runtime) runMessages(args []string) error {
3134
if fs.NArg() != 0 {
3235
return usageErr(fmt.Errorf("messages takes flags only"))
3336
}
37+
if *hours < 0 {
38+
return usageErr(fmt.Errorf("--hours must be >= 0"))
39+
}
3440
if *days < 0 {
3541
return usageErr(fmt.Errorf("--days must be >= 0"))
3642
}
37-
if *days > 0 && strings.TrimSpace(*since) != "" {
38-
return usageErr(fmt.Errorf("use either --days or --since"))
43+
if countNonZero(*hours > 0, *days > 0, strings.TrimSpace(*since) != "") > 1 {
44+
return usageErr(fmt.Errorf("use only one of --hours, --days, or --since"))
3945
}
4046
if *limit < 0 {
4147
return usageErr(fmt.Errorf("--limit must be >= 0"))
4248
}
49+
if *last < 0 {
50+
return usageErr(fmt.Errorf("--last must be >= 0"))
51+
}
52+
limitSet := flagPassed(fs, "limit")
53+
if *all && *last > 0 {
54+
return usageErr(fmt.Errorf("use either --all or --last"))
55+
}
56+
if limitSet && *last > 0 {
57+
return usageErr(fmt.Errorf("use either --limit or --last"))
58+
}
59+
if *last > 0 {
60+
*limit = 0
61+
}
4362

4463
var sinceTime time.Time
4564
var beforeTime time.Time
4665
var err error
66+
if *hours > 0 {
67+
now := time.Now().UTC()
68+
if r.now != nil {
69+
now = r.now().UTC()
70+
}
71+
sinceTime = now.Add(-time.Duration(*hours) * time.Hour)
72+
}
4773
if *days > 0 {
4874
now := time.Now().UTC()
4975
if r.now != nil {
@@ -71,6 +97,11 @@ func (r *runtime) runMessages(args []string) error {
7197
if *all {
7298
*limit = 0
7399
}
100+
if *syncNow {
101+
if err := r.syncMessagesQuery(*channel, *guildFlag, *guildsFlag); err != nil {
102+
return err
103+
}
104+
}
74105

75106
rows, err := r.store.ListMessages(r.ctx, store.MessageListOptions{
76107
GuildIDs: guildIDs,
@@ -79,10 +110,21 @@ func (r *runtime) runMessages(args []string) error {
79110
Since: sinceTime,
80111
Before: beforeTime,
81112
Limit: *limit,
113+
Last: *last,
82114
IncludeEmpty: *includeEmpty,
83115
})
84116
if err != nil {
85117
return err
86118
}
87119
return r.print(rows)
88120
}
121+
122+
func countNonZero(values ...bool) int {
123+
count := 0
124+
for _, value := range values {
125+
if value {
126+
count++
127+
}
128+
}
129+
return count
130+
}

0 commit comments

Comments
 (0)