Skip to content

Commit d5df845

Browse files
committed
feat: add message history query command
1 parent eed5c30 commit d5df845

6 files changed

Lines changed: 381 additions & 0 deletions

File tree

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,24 @@ bin/discrawl search --channel billing --author steipete --limit 50 "invoice"
113113
bin/discrawl --json search "websocket closed"
114114
```
115115

116+
### `messages`
117+
118+
Lists exact message slices by channel, author, and time range.
119+
120+
```bash
121+
bin/discrawl messages --channel maintainers --days 7 --all
122+
bin/discrawl messages --channel "#maintainers" --since 2026-03-01T00:00:00Z
123+
bin/discrawl messages --channel 1456744319972282449 --author steipete --limit 50
124+
bin/discrawl --json messages --channel maintainers --days 3
125+
```
126+
127+
Notes:
128+
129+
- `--channel` accepts a channel id, exact name, `#name`, or partial name match
130+
- `--days` is shorthand for "since now minus N days"
131+
- `--all` removes the safety limit; default is `200`
132+
- at least one filter is required
133+
116134
### `sql`
117135

118136
Runs read-only SQL against the local database.

internal/cli/cli.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
9797
return runtime.withServices(true, func() error { return runtime.runTail(rest[1:]) })
9898
case "search":
9999
return runtime.withServices(false, func() error { return runtime.runSearch(rest[1:]) })
100+
case "messages":
101+
return runtime.withServices(false, func() error { return runtime.runMessages(rest[1:]) })
100102
case "sql":
101103
return runtime.withServices(false, func() error { return runtime.runSQL(rest[1:]) })
102104
case "members":
@@ -127,6 +129,7 @@ type runtime struct {
127129
openStore func(context.Context, string) (*store.Store, error)
128130
newDiscord func(config.Config) (discordClient, error)
129131
newSyncer func(syncer.Client, *store.Store, *slog.Logger) syncService
132+
now func() time.Time
130133
}
131134

132135
type discordClient interface {
@@ -548,6 +551,11 @@ func (r *runtime) print(value any) error {
548551
_, _ = fmt.Fprintf(r.stdout, "%s\t%s\t%s\t%s\n", row.GuildID, row.ID, row.Kind, row.Name)
549552
}
550553
return nil
554+
case []store.MessageRow:
555+
for _, row := range v {
556+
_, _ = fmt.Fprintf(r.stdout, "%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)
557+
}
558+
return nil
551559
}
552560
}
553561
if err := printHuman(r.stdout, value); err == nil {
@@ -569,6 +577,7 @@ Commands:
569577
sync
570578
tail
571579
search
580+
messages
572581
sql
573582
members
574583
channels
@@ -653,6 +662,13 @@ func printHuman(w io.Writer, value any) error {
653662
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.GuildID, row.ID, row.Kind, row.Name)
654663
}
655664
return tw.Flush()
665+
case []store.MessageRow:
666+
for _, row := range v {
667+
if _, err := fmt.Fprintf(w, "[%s/%s] %s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, formatTime(row.CreatedAt), row.Content); err != nil {
668+
return err
669+
}
670+
}
671+
return nil
656672
case map[string]any:
657673
keys := make([]string, 0, len(v))
658674
for key := range v {

internal/cli/cli_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ func TestStatusSearchSQLAndListings(t *testing.T) {
6767
tests := [][]string{
6868
{"--config", cfgPath, "status"},
6969
{"--config", cfgPath, "search", "panic"},
70+
{"--config", cfgPath, "messages", "--channel", "general", "--days", "7", "--all"},
7071
{"--config", cfgPath, "sql", "select count(*) as total from messages"},
7172
{"--config", cfgPath, "members", "list"},
7273
{"--config", cfgPath, "channels", "list"},
@@ -254,6 +255,8 @@ func TestRuntimeHelpersAndSubcommands(t *testing.T) {
254255
require.NoError(t, rt.runMembers([]string{"show", "u1"}))
255256
require.NoError(t, rt.runMembers([]string{"search", "pet"}))
256257
require.NoError(t, rt.runMembers([]string{"list"}))
258+
rt.now = func() time.Time { return time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) }
259+
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--days", "7", "--all"}))
257260
require.NoError(t, rt.runChannels([]string{"show", "c1"}))
258261
require.NoError(t, rt.runChannels([]string{"list"}))
259262
require.NoError(t, rt.runStatus(nil))
@@ -284,6 +287,10 @@ func TestPrintJSONAndPlain(t *testing.T) {
284287
require.NoError(t, rt.print([]store.SearchResult{{GuildID: "g1", ChannelID: "c1", AuthorID: "u1", Content: "hello"}}))
285288
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "hello")
286289

290+
rt = &runtime{stdout: &bytes.Buffer{}, plain: true}
291+
require.NoError(t, rt.print([]store.MessageRow{{GuildID: "g1", ChannelID: "c1", AuthorID: "u1", MessageID: "m1", Content: "hello", CreatedAt: time.Unix(1, 0).UTC()}}))
292+
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "m1")
293+
287294
rt = &runtime{stdout: &bytes.Buffer{}}
288295
require.NoError(t, rt.print(struct{ OK bool }{OK: true}))
289296
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "\"OK\": true")
@@ -330,6 +337,9 @@ func TestCommandUsageErrors(t *testing.T) {
330337
rt := &runtime{}
331338
require.Equal(t, 2, ExitCode(rt.runMembers(nil)))
332339
require.Equal(t, 2, ExitCode(rt.runMembers([]string{"nope"})))
340+
require.Equal(t, 2, ExitCode(rt.runMessages(nil)))
341+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--days", "-1"})))
342+
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--days", "1", "--since", "2026-03-01T00:00:00Z"})))
333343
require.Equal(t, 2, ExitCode(rt.runChannels(nil)))
334344
require.Equal(t, 2, ExitCode(rt.runStatus([]string{"extra"})))
335345
require.NoError(t, (&runtime{stdout: &bytes.Buffer{}}).runDoctor(nil))

internal/cli/messages.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package cli
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"io"
7+
"strings"
8+
"time"
9+
10+
"github.com/steipete/discrawl/internal/store"
11+
)
12+
13+
const defaultMessageLimit = 200
14+
15+
func (r *runtime) runMessages(args []string) error {
16+
fs := flag.NewFlagSet("messages", flag.ContinueOnError)
17+
fs.SetOutput(io.Discard)
18+
channel := fs.String("channel", "", "")
19+
author := fs.String("author", "", "")
20+
days := fs.Int("days", 0, "")
21+
since := fs.String("since", "", "")
22+
before := fs.String("before", "", "")
23+
limit := fs.Int("limit", defaultMessageLimit, "")
24+
all := fs.Bool("all", false, "")
25+
guildsFlag := fs.String("guilds", "", "")
26+
guildFlag := fs.String("guild", "", "")
27+
if err := fs.Parse(args); err != nil {
28+
return usageErr(err)
29+
}
30+
if fs.NArg() != 0 {
31+
return usageErr(fmt.Errorf("messages takes flags only"))
32+
}
33+
if *days < 0 {
34+
return usageErr(fmt.Errorf("--days must be >= 0"))
35+
}
36+
if *days > 0 && strings.TrimSpace(*since) != "" {
37+
return usageErr(fmt.Errorf("use either --days or --since"))
38+
}
39+
if *limit < 0 {
40+
return usageErr(fmt.Errorf("--limit must be >= 0"))
41+
}
42+
43+
var sinceTime time.Time
44+
var beforeTime time.Time
45+
var err error
46+
if *days > 0 {
47+
now := time.Now().UTC()
48+
if r.now != nil {
49+
now = r.now().UTC()
50+
}
51+
sinceTime = now.Add(-time.Duration(*days) * 24 * time.Hour)
52+
}
53+
if strings.TrimSpace(*since) != "" {
54+
sinceTime, err = time.Parse(time.RFC3339, *since)
55+
if err != nil {
56+
return usageErr(fmt.Errorf("invalid --since: %w", err))
57+
}
58+
}
59+
if strings.TrimSpace(*before) != "" {
60+
beforeTime, err = time.Parse(time.RFC3339, *before)
61+
if err != nil {
62+
return usageErr(fmt.Errorf("invalid --before: %w", err))
63+
}
64+
}
65+
66+
guildIDs := r.resolveSearchGuilds(*guildFlag, *guildsFlag)
67+
if strings.TrimSpace(*channel) == "" && strings.TrimSpace(*author) == "" && sinceTime.IsZero() && beforeTime.IsZero() && len(guildIDs) == 0 {
68+
return usageErr(fmt.Errorf("messages needs at least one filter"))
69+
}
70+
if *all {
71+
*limit = 0
72+
}
73+
74+
rows, err := r.store.ListMessages(r.ctx, store.MessageListOptions{
75+
GuildIDs: guildIDs,
76+
Channel: *channel,
77+
Author: *author,
78+
Since: sinceTime,
79+
Before: beforeTime,
80+
Limit: *limit,
81+
})
82+
if err != nil {
83+
return err
84+
}
85+
return r.print(rows)
86+
}

internal/store/messages.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package store
2+
3+
import (
4+
"context"
5+
"strings"
6+
"time"
7+
)
8+
9+
type MessageListOptions struct {
10+
GuildIDs []string
11+
Channel string
12+
Author string
13+
Since time.Time
14+
Before time.Time
15+
Limit int
16+
}
17+
18+
type MessageRow struct {
19+
MessageID string `json:"message_id"`
20+
GuildID string `json:"guild_id"`
21+
ChannelID string `json:"channel_id"`
22+
ChannelName string `json:"channel_name"`
23+
AuthorID string `json:"author_id"`
24+
AuthorName string `json:"author_name"`
25+
Content string `json:"content"`
26+
CreatedAt time.Time `json:"created_at"`
27+
ReplyToMessage string `json:"reply_to_message_id,omitempty"`
28+
HasAttachments bool `json:"has_attachments"`
29+
Pinned bool `json:"pinned"`
30+
}
31+
32+
func (s *Store) ListMessages(ctx context.Context, opts MessageListOptions) ([]MessageRow, error) {
33+
args := []any{}
34+
clauses := []string{"1=1"}
35+
if len(opts.GuildIDs) > 0 {
36+
clauses = append(clauses, "m.guild_id in ("+placeholders(len(opts.GuildIDs))+")")
37+
for _, guildID := range opts.GuildIDs {
38+
args = append(args, guildID)
39+
}
40+
}
41+
if channel := normalizeChannelFilter(opts.Channel); channel != "" {
42+
clauses = append(clauses, "(m.channel_id = ? or c.name = ? or c.name like ?)")
43+
args = append(args, channel, channel, "%"+channel+"%")
44+
}
45+
if author := strings.TrimSpace(opts.Author); author != "" {
46+
clauses = append(clauses, `(m.author_id = ? or coalesce(mem.username, '') = ? or coalesce(mem.display_name, '') = ? or coalesce(mem.username, '') like ? or coalesce(mem.display_name, '') like ? or json_extract(m.raw_json, '$.author.username') = ?)`)
47+
args = append(args, author, author, author, "%"+author+"%", "%"+author+"%", author)
48+
}
49+
if !opts.Since.IsZero() {
50+
clauses = append(clauses, "m.created_at >= ?")
51+
args = append(args, opts.Since.UTC().Format(timeLayout))
52+
}
53+
if !opts.Before.IsZero() {
54+
clauses = append(clauses, "m.created_at < ?")
55+
args = append(args, opts.Before.UTC().Format(timeLayout))
56+
}
57+
58+
query := `
59+
select
60+
m.id,
61+
m.guild_id,
62+
m.channel_id,
63+
coalesce(c.name, ''),
64+
coalesce(m.author_id, ''),
65+
coalesce(
66+
nullif(mem.display_name, ''),
67+
nullif(mem.nick, ''),
68+
nullif(mem.global_name, ''),
69+
nullif(mem.username, ''),
70+
nullif(json_extract(m.raw_json, '$.author.global_name'), ''),
71+
nullif(json_extract(m.raw_json, '$.author.username'), ''),
72+
''
73+
),
74+
m.content,
75+
m.created_at,
76+
coalesce(m.reply_to_message_id, ''),
77+
m.has_attachments,
78+
m.pinned
79+
from messages m
80+
left join channels c on c.id = m.channel_id
81+
left join members mem on mem.guild_id = m.guild_id and mem.user_id = m.author_id
82+
where ` + strings.Join(clauses, " and ") + `
83+
order by m.created_at asc, m.id asc
84+
`
85+
if opts.Limit > 0 {
86+
query += ` limit ?`
87+
args = append(args, opts.Limit)
88+
}
89+
90+
rows, err := s.db.QueryContext(ctx, query, args...)
91+
if err != nil {
92+
return nil, err
93+
}
94+
defer func() { _ = rows.Close() }()
95+
96+
var out []MessageRow
97+
for rows.Next() {
98+
var row MessageRow
99+
var created string
100+
var hasAttachments int
101+
var pinned int
102+
if err := rows.Scan(
103+
&row.MessageID,
104+
&row.GuildID,
105+
&row.ChannelID,
106+
&row.ChannelName,
107+
&row.AuthorID,
108+
&row.AuthorName,
109+
&row.Content,
110+
&created,
111+
&row.ReplyToMessage,
112+
&hasAttachments,
113+
&pinned,
114+
); err != nil {
115+
return nil, err
116+
}
117+
row.CreatedAt = parseTime(created)
118+
row.HasAttachments = hasAttachments == 1
119+
row.Pinned = pinned == 1
120+
out = append(out, row)
121+
}
122+
return out, rows.Err()
123+
}
124+
125+
func normalizeChannelFilter(raw string) string {
126+
return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(raw), "#"))
127+
}

0 commit comments

Comments
 (0)