From 00c57990a6175ee3adb511652ea28b1d4c4502e3 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:40:09 -0700 Subject: [PATCH 1/4] feat(cli): digest command for windowed per-channel summary Adds discrawl digest, a per-channel activity summary over a time window. discrawl already has report for repo-wide README dumps and messages / search for retrieval; digest answers what happened in this guild over the last N days, per channel. Ports vincentkoc/slacrawl#9 (merged 2026-04-22). Same SQL recipe, adapted to discrawl's Discord schema (guild_id, members, mention_events) and the existing stdlib-flag CLI dispatch. This contribution was developed with AI assistance. --- README.md | 19 +++ SPEC.md | 26 ++++ internal/cli/cli.go | 2 + internal/cli/digest.go | 73 ++++++++++ internal/cli/digest_test.go | 166 ++++++++++++++++++++++ internal/cli/output.go | 38 +++++ internal/report/digest.go | 248 +++++++++++++++++++++++++++++++++ internal/report/digest_test.go | 136 ++++++++++++++++++ 8 files changed, 708 insertions(+) create mode 100644 internal/cli/digest.go create mode 100644 internal/cli/digest_test.go create mode 100644 internal/report/digest.go create mode 100644 internal/report/digest_test.go diff --git a/README.md b/README.md index 8481e6f..798af67 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,25 @@ Configure `OPENAI_API_KEY` in the discrawl repo secrets to enable agent-written The backup workflows restore and save `.discrawl-ci/discrawl.db` with `actions/cache`. On a warm runner cache, `discrawl update` compares the cached DB's last imported snapshot timestamp with `manifest.json` and skips the full sharded import when they match. Cache misses and newer backup manifests still take the normal pull/import path. +### `digest` + +Summarizes per-channel activity for a lookback window. + +```bash +discrawl digest +discrawl digest --since 30d +discrawl digest --guild 123456789012345678 +discrawl digest --channel general +discrawl --json digest --since 7d --top-n 5 +``` + +Notes: + +- `--since` accepts Go durations (`72h`, `30m`) and `Nd` shorthand (`7d`, `30d`) +- `--guild` scopes to one guild; when omitted, `default_guild_id` is used if configured +- `--channel` accepts a channel id or exact channel name +- `--top-n` controls how many top posters and mention targets are shown per channel + ### `doctor` Checks config, auth, DB, and FTS wiring. diff --git a/SPEC.md b/SPEC.md index 2f11f07..878eeee 100644 --- a/SPEC.md +++ b/SPEC.md @@ -808,3 +808,29 @@ For an AI agent to finish the product without external memory, this repo should - milestone order This file is the authoritative engineering spec for now. + +## Digest + +`discrawl digest` provides a per-channel activity summary over a lookback window. + +Example usage: + +```bash +discrawl digest +discrawl digest --since 7d +discrawl digest --since 30d --guild 123456789012345678 +discrawl digest --channel general --top-n 5 +discrawl --json digest --since 72h +``` + +Behavior: + +- window defaults to `7d` when `--since` is omitted +- `--since` accepts Go durations (`72h`, `30m`) and `Nd` shorthand (`7d`, `30d`) +- `--guild` filters by `guild_id`; empty means no guild filter +- `--channel` accepts channel id or exact channel name +- per-channel metrics include `messages`, `threads` (distinct `reply_to_message_id`), and `active_authors` +- top posters are ranked by message count using member display fallback order: `display_name -> nick -> global_name -> username -> author_id -> unknown` +- top mentions are ranked from `mention_events` and include all target types (`user` and `role`) +- channels are sorted by message count descending, then channel name ascending +- JSON output returns a `Digest` object with channel rows and totals; plain output emits one tab-separated row per channel diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 2ec8263..c6349a3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -137,6 +137,8 @@ func (r *runtime) dispatch(rest []string) error { } autoShareUpdate := !hasBoolFlag(rest[1:], "--dm") return r.withLocalStoreDefault(autoShareUpdate, func() error { return r.runMessages(rest[1:]) }) + case "digest": + return r.withLocalStoreDefault(true, func() error { return r.runDigest(rest[1:]) }) case "dms": return r.withLocalStoreDefault(false, func() error { return r.runDirectMessages(rest[1:]) }) case "mentions": diff --git a/internal/cli/digest.go b/internal/cli/digest.go new file mode 100644 index 0000000..3acd3d6 --- /dev/null +++ b/internal/cli/digest.go @@ -0,0 +1,73 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/steipete/discrawl/internal/report" +) + +func (r *runtime) runDigest(args []string) error { + fs := flag.NewFlagSet("digest", flag.ContinueOnError) + fs.SetOutput(io.Discard) + since := fs.String("since", "7d", "") + guild := fs.String("guild", "", "") + channel := fs.String("channel", "", "") + topN := fs.Int("top-n", 3, "") + if err := fs.Parse(args); err != nil { + return usageErr(err) + } + if fs.NArg() != 0 { + return usageErr(errors.New("digest takes no positional arguments")) + } + + lookback, err := parseLookback(*since) + if err != nil { + return usageErr(fmt.Errorf("parse --since: %w", err)) + } + guildID := strings.TrimSpace(*guild) + if guildID == "" { + guildID = r.cfg.EffectiveDefaultGuildID() + } + + digest, err := report.BuildDigest(r.ctx, r.store, report.DigestOptions{ + Since: lookback, + GuildID: guildID, + Channel: *channel, + TopN: *topN, + }) + if err != nil { + return err + } + return r.print(digest) +} + +func parseLookback(value string) (time.Duration, error) { + value = strings.TrimSpace(value) + if value == "" { + return 0, errors.New("empty duration") + } + if strings.HasSuffix(value, "d") { + days, err := strconv.Atoi(strings.TrimSuffix(value, "d")) + if err != nil { + return 0, fmt.Errorf("invalid day count: %w", err) + } + if days < 0 { + return 0, errors.New("negative duration") + } + return time.Duration(days) * 24 * time.Hour, nil + } + d, err := time.ParseDuration(value) + if err != nil { + return 0, err + } + if d < 0 { + return 0, errors.New("negative duration") + } + return d, nil +} diff --git a/internal/cli/digest_test.go b/internal/cli/digest_test.go new file mode 100644 index 0000000..c81b242 --- /dev/null +++ b/internal/cli/digest_test.go @@ -0,0 +1,166 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/steipete/discrawl/internal/config" + "github.com/steipete/discrawl/internal/store" +) + +func TestParseLookback(t *testing.T) { + cases := []struct { + in string + want time.Duration + err bool + }{ + {"7d", 7 * 24 * time.Hour, false}, + {"30d", 30 * 24 * time.Hour, false}, + {"72h", 72 * time.Hour, false}, + {"30m", 30 * time.Minute, false}, + {"", 0, true}, + {"abc", 0, true}, + {"-2d", 0, true}, + {"-1h", 0, true}, + } + for _, tc := range cases { + d, err := parseLookback(tc.in) + if tc.err { + require.Error(t, err, tc.in) + continue + } + require.NoError(t, err, tc.in) + require.Equal(t, tc.want, d, tc.in) + } +} + +func TestDigestCommand(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + dbPath := filepath.Join(dir, "discrawl.db") + + require.NoError(t, seedDigestCLIStore(ctx, dbPath)) + + cfg := config.Default() + cfg.DBPath = dbPath + cfg.DefaultGuildID = "g1" + require.NoError(t, config.Write(cfgPath, cfg)) + + t.Run("since 7d happy path", func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "digest", "--since", "7d"}, &out, &bytes.Buffer{})) + require.Contains(t, out.String(), "general (text)") + require.Contains(t, out.String(), "Window:") + require.Contains(t, out.String(), "Totals: messages=") + }) + + t.Run("json output", func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "digest", "--since", "7d"}, &out, &bytes.Buffer{})) + var payload map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &payload)) + require.Equal(t, "7d", payload["window_label"]) + require.Equal(t, float64(3), payload["top_n"]) + totals, ok := payload["totals"].(map[string]any) + require.True(t, ok) + require.Equal(t, float64(2), totals["messages"]) + }) + + t.Run("channel name filter", func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "digest", "--channel", "incidents", "--since", "7d"}, &out, &bytes.Buffer{})) + var payload map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &payload)) + channels, ok := payload["channels"].([]any) + require.True(t, ok) + require.Len(t, channels, 1) + channel := channels[0].(map[string]any) + require.Equal(t, "incidents", channel["channel_name"]) + }) + + t.Run("unknown flag fails", func(t *testing.T) { + err := Run(ctx, []string{"--config", cfgPath, "digest", "--bogus"}, &bytes.Buffer{}, &bytes.Buffer{}) + require.Error(t, err) + require.Equal(t, 2, ExitCode(err)) + }) + + t.Run("no positional args allowed", func(t *testing.T) { + err := Run(ctx, []string{"--config", cfgPath, "digest", "extra"}, &bytes.Buffer{}, &bytes.Buffer{}) + require.Error(t, err) + require.Equal(t, 2, ExitCode(err)) + }) +} + +func seedDigestCLIStore(ctx context.Context, path string) error { + s, err := store.Open(ctx, path) + if err != nil { + return err + } + defer func() { _ = s.Close() }() + + now := time.Now().UTC() + if err := s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "incidents", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u1", Username: "alice", DisplayName: "Alice", RoleIDsJSON: `[]`, RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u2", Username: "bob", DisplayName: "Bob", RoleIDsJSON: `[]`, RawJSON: `{}`}); err != nil { + return err + } + return s.UpsertMessages(ctx, []store.MessageMutation{ + { + Record: store.MessageRecord{ + ID: "m1", + GuildID: "g1", + ChannelID: "c1", + ChannelName: "general", + AuthorID: "u1", + AuthorName: "Alice", + MessageType: 0, + CreatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), + Content: "hello", + NormalizedContent: "hello", + RawJSON: `{}`, + }, + Mentions: []store.MentionEventRecord{{ + MessageID: "m1", + GuildID: "g1", + ChannelID: "c1", + AuthorID: "u1", + TargetType: "user", + TargetID: "u2", + TargetName: "Bob", + EventAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), + }}, + }, + { + Record: store.MessageRecord{ + ID: "m2", + GuildID: "g1", + ChannelID: "c2", + ChannelName: "incidents", + AuthorID: "u2", + AuthorName: "Bob", + MessageType: 0, + CreatedAt: now.Add(-90 * time.Minute).Format(time.RFC3339Nano), + Content: "incident", + NormalizedContent: "incident", + RawJSON: `{}`, + }, + }, + }) +} diff --git a/internal/cli/output.go b/internal/cli/output.go index 92d6e0c..2abf0d5 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -11,6 +11,7 @@ import ( "time" "github.com/steipete/discrawl/internal/discorddesktop" + "github.com/steipete/discrawl/internal/report" "github.com/steipete/discrawl/internal/store" "github.com/steipete/discrawl/internal/syncer" ) @@ -69,6 +70,11 @@ func printPlain(w io.Writer, value any) error { _, _ = 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) } return nil + case report.Digest: + for _, row := range v.Channels { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%d\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.Messages, row.Threads, row.ActiveAuthors) + } + return nil default: return errors.New("no plain printer") } @@ -87,6 +93,7 @@ Commands: wiretap search messages + digest dms mentions embed @@ -274,6 +281,26 @@ func printHuman(w io.Writer, value any) error { _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.GuildID, row.ID, row.Kind, row.Name) } return tw.Flush() + case report.Digest: + for _, channel := range v.Channels { + if _, err := fmt.Fprintf(w, "%s (%s)\n", channel.ChannelName, firstNonEmpty(channel.Kind, "unknown")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " messages=%d threads=%d authors=%d\n", channel.Messages, channel.Threads, channel.ActiveAuthors); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " top posters %s\n", formatRankedCounts(channel.TopPosters)); err != nil { + return err + } + if _, err := fmt.Fprintf(w, " top mentions %s\n\n", formatRankedCounts(channel.TopMentions)); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "Window: %s to %s (%s)\n", formatTime(v.Since), formatTime(v.Until), v.WindowLabel); err != nil { + return err + } + _, err := fmt.Fprintf(w, "Totals: messages=%d threads=%d channels=%d authors=%d\n", v.Totals.Messages, v.Totals.Threads, v.Totals.Channels, v.Totals.ActiveAuthors) + return err case map[string]any: keys := make([]string, 0, len(v)) for key := range v { @@ -322,3 +349,14 @@ func trimForTable(value string) string { } return value[:37] + "..." } + +func formatRankedCounts(rows []report.RankedCount) string { + if len(rows) == 0 { + return "-" + } + parts := make([]string, 0, len(rows)) + for _, row := range rows { + parts = append(parts, fmt.Sprintf("%s (%d)", firstNonEmpty(row.Name, "unknown"), row.Count)) + } + return strings.Join(parts, ", ") +} diff --git a/internal/report/digest.go b/internal/report/digest.go new file mode 100644 index 0000000..b5ee676 --- /dev/null +++ b/internal/report/digest.go @@ -0,0 +1,248 @@ +package report + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" + + "github.com/steipete/discrawl/internal/store" +) + +// DigestOptions controls how a Digest is built. +type DigestOptions struct { + Now time.Time + Since time.Duration + GuildID string + Channel string + TopN int +} + +// Digest summarizes recent activity for each channel inside a window. +type Digest struct { + GeneratedAt time.Time `json:"generated_at"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + WindowLabel string `json:"window_label"` + Guild string `json:"guild,omitempty"` + Channel string `json:"channel,omitempty"` + TopN int `json:"top_n"` + Channels []ChannelDigest `json:"channels"` + Totals DigestTotals `json:"totals"` +} + +// ChannelDigest is the per-channel roll-up inside a Digest. +type ChannelDigest struct { + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + Kind string `json:"kind,omitempty"` + GuildID string `json:"guild_id"` + Messages int `json:"messages"` + Threads int `json:"threads"` + ActiveAuthors int `json:"active_authors"` + TopPosters []RankedCount `json:"top_posters"` + TopMentions []RankedCount `json:"top_mentions"` +} + +// DigestTotals sums message and channel counts across the digest window. +type DigestTotals struct { + Messages int `json:"messages"` + Threads int `json:"threads"` + Channels int `json:"channels"` + ActiveAuthors int `json:"active_authors"` +} + +// BuildDigest computes a per-channel activity digest from the local store. +func BuildDigest(ctx context.Context, s *store.Store, opts DigestOptions) (Digest, error) { + now := opts.Now + if now.IsZero() { + now = time.Now().UTC() + } + now = now.UTC() + + sinceDuration := opts.Since + if sinceDuration < 0 { + sinceDuration = -sinceDuration + } + if sinceDuration == 0 { + sinceDuration = 7 * 24 * time.Hour + } + + topN := opts.TopN + if topN <= 0 { + topN = 3 + } + + digest := Digest{ + GeneratedAt: now, + Since: now.Add(-sinceDuration), + Until: now, + WindowLabel: humanDuration(sinceDuration), + Guild: strings.TrimSpace(opts.GuildID), + Channel: strings.TrimSpace(opts.Channel), + TopN: topN, + } + + channels, err := perChannelDigest(ctx, s.DB(), digest.Since, digest.Until, digest.Guild, digest.Channel) + if err != nil { + return Digest{}, err + } + for i := range channels { + channels[i].TopPosters, err = topPostersForDigestChannel(ctx, s.DB(), digest.Since, digest.Until, channels[i].GuildID, channels[i].ChannelID, topN) + if err != nil { + return Digest{}, err + } + channels[i].TopMentions, err = topMentionsForDigestChannel(ctx, s.DB(), digest.Since, digest.Until, channels[i].GuildID, channels[i].ChannelID, topN) + if err != nil { + return Digest{}, err + } + } + digest.Channels = channels + + totals, err := digestTotals(ctx, s.DB(), digest.Since, digest.Until, digest.Guild, digest.Channel) + if err != nil { + return Digest{}, err + } + digest.Totals = totals + + return digest, nil +} + +func perChannelDigest(ctx context.Context, db *sql.DB, since, until time.Time, guildID, channel string) ([]ChannelDigest, error) { + query := &strings.Builder{} + query.WriteString(` +select + c.guild_id, + c.id, + coalesce(nullif(c.name, ''), c.id) as channel_name, + coalesce(c.kind, '') as kind, + count(m.id) as messages, + count(distinct case when nullif(m.reply_to_message_id, '') is not null then m.reply_to_message_id else null end) as threads, + count(distinct nullif(m.author_id, '')) as active_authors +from channels c +left join messages m on m.guild_id = c.guild_id + and m.channel_id = c.id + and m.created_at >= ? + and m.created_at < ? +where 1=1 +`) + args := []any{since.UTC().Format(time.RFC3339Nano), until.UTC().Format(time.RFC3339Nano)} + if guildID != "" { + query.WriteString(" and c.guild_id = ?\n") + args = append(args, guildID) + } + if channel != "" { + query.WriteString(" and (c.id = ? or c.name = ?)\n") + args = append(args, channel, channel) + } + query.WriteString(` +group by c.guild_id, c.id, c.name, c.kind +having count(m.id) > 0 +order by messages desc, channel_name asc +`) + + rows, err := db.QueryContext(ctx, query.String(), args...) + if err != nil { + return nil, fmt.Errorf("digest per-channel query: %w", err) + } + defer func() { _ = rows.Close() }() + + var out []ChannelDigest + for rows.Next() { + var row ChannelDigest + if err := rows.Scan(&row.GuildID, &row.ChannelID, &row.ChannelName, &row.Kind, &row.Messages, &row.Threads, &row.ActiveAuthors); err != nil { + return nil, fmt.Errorf("digest per-channel scan: %w", err) + } + out = append(out, row) + } + return out, rows.Err() +} + +func topPostersForDigestChannel(ctx context.Context, db *sql.DB, since, until time.Time, guildID, channelID string, limit int) ([]RankedCount, error) { + return ranked(ctx, db, ` +select + coalesce( + nullif(mem.display_name, ''), + nullif(mem.nick, ''), + nullif(mem.global_name, ''), + nullif(mem.username, ''), + nullif(m.author_id, ''), + 'unknown' + ) as name, + count(*) as total +from messages m +left join members mem on mem.guild_id = m.guild_id and mem.user_id = m.author_id +where m.created_at >= ? + and m.created_at < ? + and m.guild_id = ? + and m.channel_id = ? +group by m.author_id, name +order by total desc, name asc +limit ? +`, since.UTC().Format(time.RFC3339Nano), until.UTC().Format(time.RFC3339Nano), guildID, channelID, limit) +} + +func topMentionsForDigestChannel(ctx context.Context, db *sql.DB, since, until time.Time, guildID, channelID string, limit int) ([]RankedCount, error) { + return ranked(ctx, db, ` +select + coalesce( + nullif(me.target_name, ''), + nullif(me.target_id, ''), + 'unknown' + ) as name, + count(*) as total +from mention_events me +where me.event_at >= ? + and me.event_at < ? + and me.guild_id = ? + and me.channel_id = ? +group by me.target_type, me.target_id, name +order by total desc, name asc +limit ? +`, since.UTC().Format(time.RFC3339Nano), until.UTC().Format(time.RFC3339Nano), guildID, channelID, limit) +} + +func digestTotals(ctx context.Context, db *sql.DB, since, until time.Time, guildID, channel string) (DigestTotals, error) { + query := &strings.Builder{} + query.WriteString(` +select + count(*) as messages, + count(distinct case when nullif(m.reply_to_message_id, '') is not null then m.reply_to_message_id else null end) as threads, + count(distinct m.guild_id || '|' || m.channel_id) as channels, + count(distinct nullif(m.author_id, '')) as active_authors +from messages m +left join channels c on c.id = m.channel_id and c.guild_id = m.guild_id +where m.created_at >= ? + and m.created_at < ? +`) + args := []any{since.UTC().Format(time.RFC3339Nano), until.UTC().Format(time.RFC3339Nano)} + if guildID != "" { + query.WriteString(" and m.guild_id = ?\n") + args = append(args, guildID) + } + if channel != "" { + query.WriteString(" and (m.channel_id = ? or c.name = ?)\n") + args = append(args, channel, channel) + } + + var totals DigestTotals + if err := db.QueryRowContext(ctx, query.String(), args...).Scan(&totals.Messages, &totals.Threads, &totals.Channels, &totals.ActiveAuthors); err != nil { + return DigestTotals{}, fmt.Errorf("digest totals: %w", err) + } + return totals, nil +} + +func humanDuration(d time.Duration) string { + if d <= 0 { + return "0" + } + if d%(24*time.Hour) == 0 { + days := int(d / (24 * time.Hour)) + return fmt.Sprintf("%dd", days) + } + if d%time.Hour == 0 { + return fmt.Sprintf("%dh", int(d/time.Hour)) + } + return d.String() +} diff --git a/internal/report/digest_test.go b/internal/report/digest_test.go new file mode 100644 index 0000000..7e0fb00 --- /dev/null +++ b/internal/report/digest_test.go @@ -0,0 +1,136 @@ +package report + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/steipete/discrawl/internal/store" +) + +func TestBuildDigest(t *testing.T) { + ctx := context.Background() + s, now := seedDigestStore(t, ctx) + defer func() { _ = s.Close() }() + + t.Run("happy path with defaults", func(t *testing.T) { + digest, err := BuildDigest(ctx, s, DigestOptions{Now: now}) + require.NoError(t, err) + + require.Equal(t, now, digest.GeneratedAt) + require.Equal(t, now.Add(-7*24*time.Hour), digest.Since) + require.Equal(t, now, digest.Until) + require.Equal(t, "7d", digest.WindowLabel) + require.Equal(t, 3, digest.TopN) + require.Len(t, digest.Channels, 3) + + require.Equal(t, "c1", digest.Channels[0].ChannelID) + require.Equal(t, "general", digest.Channels[0].ChannelName) + require.Equal(t, 4, digest.Channels[0].Messages) + require.Equal(t, 1, digest.Channels[0].Threads) + require.Equal(t, 3, digest.Channels[0].ActiveAuthors) + + require.Equal(t, "Alice", digest.Channels[0].TopPosters[0].Name) + require.Equal(t, 2, digest.Channels[0].TopPosters[0].Count) + require.Equal(t, "Bob", digest.Channels[0].TopMentions[0].Name) + require.Equal(t, 2, digest.Channels[0].TopMentions[0].Count) + require.Equal(t, "Oncall", digest.Channels[0].TopMentions[1].Name) + + require.Equal(t, 6, digest.Totals.Messages) + require.Equal(t, 1, digest.Totals.Threads) + require.Equal(t, 3, digest.Totals.Channels) + require.Equal(t, 4, digest.Totals.ActiveAuthors) + }) + + t.Run("window filter", func(t *testing.T) { + digest, err := BuildDigest(ctx, s, DigestOptions{Now: now, Since: 24 * time.Hour}) + require.NoError(t, err) + require.Equal(t, "1d", digest.WindowLabel) + require.Equal(t, 5, digest.Totals.Messages) + require.Equal(t, 3, digest.Channels[0].Messages) + }) + + t.Run("channel filter by id", func(t *testing.T) { + digest, err := BuildDigest(ctx, s, DigestOptions{Now: now, Channel: "c2", TopN: 5}) + require.NoError(t, err) + require.Len(t, digest.Channels, 1) + require.Equal(t, "incidents", digest.Channels[0].ChannelName) + require.Equal(t, 1, digest.Totals.Messages) + require.Equal(t, 5, digest.TopN) + }) + + t.Run("channel filter by name", func(t *testing.T) { + digest, err := BuildDigest(ctx, s, DigestOptions{Now: now, Channel: "general"}) + require.NoError(t, err) + require.Len(t, digest.Channels, 1) + require.Equal(t, "c1", digest.Channels[0].ChannelID) + require.Equal(t, 4, digest.Totals.Messages) + }) + + t.Run("guild filter", func(t *testing.T) { + digest, err := BuildDigest(ctx, s, DigestOptions{Now: now, GuildID: "g1"}) + require.NoError(t, err) + require.Len(t, digest.Channels, 2) + require.Equal(t, 5, digest.Totals.Messages) + require.Equal(t, 3, digest.Totals.ActiveAuthors) + }) + + t.Run("negative since is normalized", func(t *testing.T) { + digest, err := BuildDigest(ctx, s, DigestOptions{Now: now, Since: -24 * time.Hour}) + require.NoError(t, err) + require.Equal(t, "1d", digest.WindowLabel) + require.Equal(t, now.Add(-24*time.Hour), digest.Since) + }) +} + +func seedDigestStore(t *testing.T, ctx context.Context) (*store.Store, time.Time) { + t.Helper() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db")) + require.NoError(t, err) + + now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild 1", RawJSON: `{}`})) + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g2", Name: "Guild 2", RawJSON: `{}`})) + + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "incidents", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "forum", Name: "unused", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c4", GuildID: "g2", Kind: "text", Name: "alpha", RawJSON: `{}`})) + + require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u1", Username: "alice", DisplayName: "Alice", RoleIDsJSON: `[]`, RawJSON: `{}`})) + require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u2", Username: "bob", DisplayName: "Bob", RoleIDsJSON: `[]`, RawJSON: `{}`})) + require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u3", Username: "carol", RoleIDsJSON: `[]`, RawJSON: `{}`})) + require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g2", UserID: "u9", Username: "dana", DisplayName: "Dana", RoleIDsJSON: `[]`, RawJSON: `{}`})) + + require.NoError(t, s.UpsertMessages(ctx, []store.MessageMutation{ + { + Record: store.MessageRecord{ID: "m1", GuildID: "g1", ChannelID: "c1", ChannelName: "general", AuthorID: "u1", AuthorName: "Alice", CreatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), Content: "hello", NormalizedContent: "hello", RawJSON: `{}`}, + Mentions: []store.MentionEventRecord{{MessageID: "m1", GuildID: "g1", ChannelID: "c1", AuthorID: "u1", TargetType: "user", TargetID: "u2", TargetName: "Bob", EventAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano)}}, + }, + { + Record: store.MessageRecord{ID: "m2", GuildID: "g1", ChannelID: "c1", ChannelName: "general", AuthorID: "u2", AuthorName: "Bob", CreatedAt: now.Add(-90 * time.Minute).Format(time.RFC3339Nano), ReplyToMessageID: "m1", Content: "reply", NormalizedContent: "reply", RawJSON: `{}`}, + Mentions: []store.MentionEventRecord{{MessageID: "m2", GuildID: "g1", ChannelID: "c1", AuthorID: "u2", TargetType: "role", TargetID: "r1", TargetName: "Oncall", EventAt: now.Add(-90 * time.Minute).Format(time.RFC3339Nano)}}, + }, + { + Record: store.MessageRecord{ID: "m3", GuildID: "g1", ChannelID: "c1", ChannelName: "general", AuthorID: "u1", AuthorName: "Alice", CreatedAt: now.Add(-80 * time.Minute).Format(time.RFC3339Nano), ReplyToMessageID: "m1", Content: "another reply", NormalizedContent: "another reply", RawJSON: `{}`}, + Mentions: []store.MentionEventRecord{{MessageID: "m3", GuildID: "g1", ChannelID: "c1", AuthorID: "u1", TargetType: "user", TargetID: "u2", TargetName: "Bob", EventAt: now.Add(-80 * time.Minute).Format(time.RFC3339Nano)}}, + }, + { + Record: store.MessageRecord{ID: "m4", GuildID: "g1", ChannelID: "c1", ChannelName: "general", AuthorID: "u3", AuthorName: "carol", CreatedAt: now.Add(-26 * time.Hour).Format(time.RFC3339Nano), Content: "older", NormalizedContent: "older", RawJSON: `{}`}, + }, + { + Record: store.MessageRecord{ID: "m5", GuildID: "g1", ChannelID: "c2", ChannelName: "incidents", AuthorID: "u2", AuthorName: "Bob", CreatedAt: now.Add(-3 * time.Hour).Format(time.RFC3339Nano), Content: "incident", NormalizedContent: "incident", RawJSON: `{}`}, + }, + { + Record: store.MessageRecord{ID: "m6", GuildID: "g1", ChannelID: "c2", ChannelName: "incidents", AuthorID: "u2", AuthorName: "Bob", CreatedAt: now.Add(-10 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "stale", NormalizedContent: "stale", RawJSON: `{}`}, + }, + { + Record: store.MessageRecord{ID: "m7", GuildID: "g2", ChannelID: "c4", ChannelName: "alpha", AuthorID: "u9", AuthorName: "Dana", CreatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), Content: "other guild", NormalizedContent: "other guild", RawJSON: `{}`}, + }, + })) + + return s, now +} From 123da36e04f726eb4ba93dcdf9557111c9a4733a Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 25 Apr 2026 08:44:51 -0700 Subject: [PATCH 2/4] fix(digest): add snake_case JSON tags to RankedCount RankedCount is reused by digest's top_posters/top_mentions slices. Without JSON tags, those nested entries serialized as {Name, Count} while the rest of the digest schema uses snake_case ({channel_id, messages, ...}). Tag the fields so --json output is consistent. Surfaced by codex review on the previous commit. --- internal/report/report.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/report/report.go b/internal/report/report.go index c112fd6..94c7104 100644 --- a/internal/report/report.go +++ b/internal/report/report.go @@ -48,8 +48,8 @@ type WindowStats struct { } type RankedCount struct { - Name string - Count int + Name string `json:"name"` + Count int `json:"count"` } func Build(ctx context.Context, s *store.Store, opts Options) (ActivityReport, error) { From 7cafe36c5b48a012699ea991a81ef1c2ee0f39bb Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:40:55 -0700 Subject: [PATCH 3/4] feat(analytics): analytics subcommand group (digest shim, quiet, trends) Introduces discrawl analytics as a namespace for activity-style queries. Three subcommands ship: - analytics digest: delegates to the existing digest implementation, so discrawl digest is unchanged - analytics quiet: channels with no activity in the lookback window (archive candidates), default --since 30d - analytics trends: week-bucketed message counts per channel, zero-filled across the window, default --weeks 8 Ports vincentkoc/slacrawl#13 (merged 2026-04-23). Same SQL recipes adapted to discrawl's Discord schema. Stacked on top of the digest PR so analytics digest can shim to runDigest and share the implementation. This contribution was developed with AI assistance. --- README.md | 21 +++ SPEC.md | 25 ++++ internal/cli/analytics.go | 103 ++++++++++++++ internal/cli/analytics_test.go | 215 ++++++++++++++++++++++++++++ internal/cli/cli.go | 2 + internal/cli/output.go | 89 ++++++++++++ internal/report/quiet.go | 158 +++++++++++++++++++++ internal/report/quiet_test.go | 107 ++++++++++++++ internal/report/trends.go | 246 +++++++++++++++++++++++++++++++++ internal/report/trends_test.go | 149 ++++++++++++++++++++ 10 files changed, 1115 insertions(+) create mode 100644 internal/cli/analytics.go create mode 100644 internal/cli/analytics_test.go create mode 100644 internal/report/quiet.go create mode 100644 internal/report/quiet_test.go create mode 100644 internal/report/trends.go create mode 100644 internal/report/trends_test.go diff --git a/README.md b/README.md index 798af67..2bcfff9 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,27 @@ Notes: - `--channel` accepts a channel id or exact channel name - `--top-n` controls how many top posters and mention targets are shown per channel +### `analytics` + +Groups activity-style queries under one namespace. + +```bash +discrawl analytics +discrawl analytics digest --since 7d +discrawl analytics quiet --since 30d +discrawl analytics quiet --guild 123456789012345678 +discrawl analytics trends --weeks 8 +discrawl analytics trends --weeks 12 --channel general +discrawl --json analytics trends --weeks 4 +``` + +Notes: + +- `analytics digest` is a shim that delegates to `discrawl digest` +- `analytics quiet` shows channels with no messages in the lookback window, including never-active channels +- `analytics trends` shows week-over-week message counts with zero-filled weekly buckets +- `analytics trends --channel` accepts a channel id or exact channel name + ### `doctor` Checks config, auth, DB, and FTS wiring. diff --git a/SPEC.md b/SPEC.md index 878eeee..b6b2e01 100644 --- a/SPEC.md +++ b/SPEC.md @@ -834,3 +834,28 @@ Behavior: - top mentions are ranked from `mention_events` and include all target types (`user` and `role`) - channels are sorted by message count descending, then channel name ascending - JSON output returns a `Digest` object with channel rows and totals; plain output emits one tab-separated row per channel + +## Analytics + +`discrawl analytics` is a subcommand group for activity-style queries. + +Example usage: + +```bash +discrawl analytics +discrawl analytics digest --since 7d +discrawl analytics quiet --since 30d +discrawl analytics quiet --guild 123456789012345678 +discrawl analytics trends --weeks 8 +discrawl analytics trends --weeks 12 --channel general +discrawl --json analytics trends --weeks 4 +``` + +Behavior: + +- `analytics digest` delegates to `discrawl digest` so both paths share one implementation +- `analytics quiet` defaults to `30d` lookback and supports `--guild` +- `analytics quiet` includes channels with no messages at all +- `analytics trends` defaults to `8` weeks and supports `--guild` plus `--channel` (id or exact name) +- `analytics trends` buckets messages into 7-day windows aligned by Unix week boundary and zero-fills missing weeks for every returned channel +- trends rows are sorted by total messages descending, then channel name ascending diff --git a/internal/cli/analytics.go b/internal/cli/analytics.go new file mode 100644 index 0000000..a3849a7 --- /dev/null +++ b/internal/cli/analytics.go @@ -0,0 +1,103 @@ +package cli + +import ( + "errors" + "flag" + "fmt" + "io" + "strings" + + "github.com/steipete/discrawl/internal/report" +) + +func (r *runtime) runAnalytics(args []string) error { + if len(args) == 0 { + printAnalyticsUsage(r.stdout) + return nil + } + + subcommand := strings.TrimSpace(args[0]) + subArgs := args[1:] + switch subcommand { + case "digest": + return r.runDigest(subArgs) + case "quiet": + return r.runAnalyticsQuiet(subArgs) + case "trends": + return r.runAnalyticsTrends(subArgs) + default: + return usageErr(fmt.Errorf("unknown analytics subcommand %q", subcommand)) + } +} + +func printAnalyticsUsage(w io.Writer) { + _, _ = fmt.Fprintln(w, "Usage: discrawl analytics [flags]") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Subcommands:") + _, _ = fmt.Fprintln(w, " digest Per-channel activity summary for a window.") + _, _ = fmt.Fprintln(w, " quiet Channels with no activity in the lookback window.") + _, _ = fmt.Fprintln(w, " trends Week-over-week message counts per channel.") +} + +func (r *runtime) runAnalyticsQuiet(args []string) error { + fs := flag.NewFlagSet("analytics quiet", flag.ContinueOnError) + fs.SetOutput(io.Discard) + since := fs.String("since", "30d", "") + guild := fs.String("guild", "", "") + if err := fs.Parse(args); err != nil { + return usageErr(err) + } + if fs.NArg() != 0 { + return usageErr(errors.New("analytics quiet takes no positional arguments")) + } + + lookback, err := parseLookback(*since) + if err != nil { + return usageErr(fmt.Errorf("parse --since: %w", err)) + } + guildID := strings.TrimSpace(*guild) + if guildID == "" { + guildID = r.cfg.EffectiveDefaultGuildID() + } + + quiet, err := report.BuildQuiet(r.ctx, r.store, report.QuietOptions{ + Since: lookback, + GuildID: guildID, + }) + if err != nil { + return err + } + return r.print(quiet) +} + +func (r *runtime) runAnalyticsTrends(args []string) error { + fs := flag.NewFlagSet("analytics trends", flag.ContinueOnError) + fs.SetOutput(io.Discard) + weeks := fs.Int("weeks", 8, "") + guild := fs.String("guild", "", "") + channel := fs.String("channel", "", "") + if err := fs.Parse(args); err != nil { + return usageErr(err) + } + if fs.NArg() != 0 { + return usageErr(errors.New("analytics trends takes no positional arguments")) + } + if *weeks < 0 { + return usageErr(errors.New("--weeks must be zero or greater")) + } + + guildID := strings.TrimSpace(*guild) + if guildID == "" { + guildID = r.cfg.EffectiveDefaultGuildID() + } + + trends, err := report.BuildTrends(r.ctx, r.store, report.TrendsOptions{ + Weeks: *weeks, + GuildID: guildID, + Channel: *channel, + }) + if err != nil { + return err + } + return r.print(trends) +} diff --git a/internal/cli/analytics_test.go b/internal/cli/analytics_test.go new file mode 100644 index 0000000..92eb302 --- /dev/null +++ b/internal/cli/analytics_test.go @@ -0,0 +1,215 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/steipete/discrawl/internal/config" + "github.com/steipete/discrawl/internal/store" +) + +func TestAnalyticsCommand(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.toml") + dbPath := filepath.Join(dir, "discrawl.db") + + require.NoError(t, seedAnalyticsCLIStore(ctx, dbPath)) + + cfg := config.Default() + cfg.DBPath = dbPath + cfg.DefaultGuildID = "g1" + require.NoError(t, config.Write(cfgPath, cfg)) + + t.Run("analytics with no subcommand prints usage", func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "analytics"}, &out, &bytes.Buffer{})) + require.Contains(t, out.String(), "Usage: discrawl analytics [flags]") + require.Contains(t, out.String(), "digest") + require.Contains(t, out.String(), "quiet") + require.Contains(t, out.String(), "trends") + }) + + t.Run("analytics digest delegates to digest", func(t *testing.T) { + var outAnalytics bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "analytics", "digest", "--since", "7d"}, &outAnalytics, &bytes.Buffer{})) + var analyticsDigest map[string]any + require.NoError(t, json.Unmarshal(outAnalytics.Bytes(), &analyticsDigest)) + + var outDigest bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "digest", "--since", "7d"}, &outDigest, &bytes.Buffer{})) + var digest map[string]any + require.NoError(t, json.Unmarshal(outDigest.Bytes(), &digest)) + + require.Equal(t, digest["window_label"], analyticsDigest["window_label"]) + require.Equal(t, digest["top_n"], analyticsDigest["top_n"]) + require.Equal(t, digest["totals"], analyticsDigest["totals"]) + require.Equal(t, digest["channels"], analyticsDigest["channels"]) + }) + + t.Run("analytics quiet json schema", func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "analytics", "quiet", "--since", "30d"}, &out, &bytes.Buffer{})) + + var payload map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &payload)) + require.Contains(t, payload, "generated_at") + require.Contains(t, payload, "since") + require.Contains(t, payload, "until") + require.Contains(t, payload, "channels") + + channels, ok := payload["channels"].([]any) + require.True(t, ok) + require.NotEmpty(t, channels) + + first, ok := channels[0].(map[string]any) + require.True(t, ok) + require.Contains(t, first, "channel_id") + require.Contains(t, first, "channel_name") + require.Contains(t, first, "guild_id") + require.Contains(t, first, "days_silent") + + totals, ok := payload["totals"].(map[string]any) + require.True(t, ok) + require.Contains(t, totals, "channels") + }) + + t.Run("analytics trends json schema", func(t *testing.T) { + var out bytes.Buffer + require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "analytics", "trends", "--weeks", "4"}, &out, &bytes.Buffer{})) + + var payload map[string]any + require.NoError(t, json.Unmarshal(out.Bytes(), &payload)) + require.Equal(t, float64(4), payload["weeks"]) + require.Contains(t, payload, "rows") + + rows, ok := payload["rows"].([]any) + require.True(t, ok) + require.NotEmpty(t, rows) + + first, ok := rows[0].(map[string]any) + require.True(t, ok) + require.Contains(t, first, "channel_id") + require.Contains(t, first, "channel_name") + require.Contains(t, first, "weekly") + + weekly := first["weekly"].([]any) + require.Len(t, weekly, 4) + weekRow := weekly[0].(map[string]any) + require.Contains(t, weekRow, "week_start") + require.Contains(t, weekRow, "messages") + }) + + t.Run("unknown analytics subcommand returns usage error", func(t *testing.T) { + err := Run(ctx, []string{"--config", cfgPath, "analytics", "unknown-sub"}, &bytes.Buffer{}, &bytes.Buffer{}) + require.Error(t, err) + require.Equal(t, 2, ExitCode(err)) + }) + + t.Run("subcommands validate their own flags", func(t *testing.T) { + cases := [][]string{ + {"--config", cfgPath, "analytics", "digest", "--bogus"}, + {"--config", cfgPath, "analytics", "quiet", "--bogus"}, + {"--config", cfgPath, "analytics", "quiet", "extra"}, + {"--config", cfgPath, "analytics", "trends", "--bogus"}, + {"--config", cfgPath, "analytics", "trends", "--weeks", "-1"}, + {"--config", cfgPath, "analytics", "trends", "extra"}, + } + for _, args := range cases { + err := Run(ctx, args, &bytes.Buffer{}, &bytes.Buffer{}) + require.Error(t, err) + require.Equal(t, 2, ExitCode(err)) + } + }) +} + +func seedAnalyticsCLIStore(ctx context.Context, path string) error { + s, err := store.Open(ctx, path) + if err != nil { + return err + } + defer func() { _ = s.Close() }() + + now := time.Now().UTC() + if err := s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "incidents", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "text", Name: "stale", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertChannel(ctx, store.ChannelRecord{ID: "c4", GuildID: "g1", Kind: "forum", Name: "never", RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u1", Username: "alice", DisplayName: "Alice", RoleIDsJSON: `[]`, RawJSON: `{}`}); err != nil { + return err + } + if err := s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u2", Username: "bob", DisplayName: "Bob", RoleIDsJSON: `[]`, RawJSON: `{}`}); err != nil { + return err + } + return s.UpsertMessages(ctx, []store.MessageMutation{ + { + Record: store.MessageRecord{ + ID: "m1", + GuildID: "g1", + ChannelID: "c1", + ChannelName: "general", + AuthorID: "u1", + AuthorName: "Alice", + CreatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), + Content: "hello", + NormalizedContent: "hello", + RawJSON: `{}`, + }, + Mentions: []store.MentionEventRecord{{ + MessageID: "m1", + GuildID: "g1", + ChannelID: "c1", + AuthorID: "u1", + TargetType: "user", + TargetID: "u2", + TargetName: "Bob", + EventAt: now.Add(-2 * time.Hour).Format(time.RFC3339Nano), + }}, + }, + { + Record: store.MessageRecord{ + ID: "m2", + GuildID: "g1", + ChannelID: "c2", + ChannelName: "incidents", + AuthorID: "u2", + AuthorName: "Bob", + CreatedAt: now.Add(-9 * 24 * time.Hour).Format(time.RFC3339Nano), + Content: "incident", + NormalizedContent: "incident", + RawJSON: `{}`, + }, + }, + { + Record: store.MessageRecord{ + ID: "m3", + GuildID: "g1", + ChannelID: "c3", + ChannelName: "stale", + AuthorID: "u1", + AuthorName: "Alice", + CreatedAt: now.Add(-45 * 24 * time.Hour).Format(time.RFC3339Nano), + Content: "old", + NormalizedContent: "old", + RawJSON: `{}`, + }, + }, + }) +} diff --git a/internal/cli/cli.go b/internal/cli/cli.go index c6349a3..48e0c4c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -139,6 +139,8 @@ func (r *runtime) dispatch(rest []string) error { return r.withLocalStoreDefault(autoShareUpdate, func() error { return r.runMessages(rest[1:]) }) case "digest": return r.withLocalStoreDefault(true, func() error { return r.runDigest(rest[1:]) }) + case "analytics": + return r.withLocalStoreDefault(true, func() error { return r.runAnalytics(rest[1:]) }) case "dms": return r.withLocalStoreDefault(false, func() error { return r.runDirectMessages(rest[1:]) }) case "mentions": diff --git a/internal/cli/output.go b/internal/cli/output.go index 2abf0d5..f7443ed 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -75,6 +75,18 @@ func printPlain(w io.Writer, value any) error { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%d\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.Messages, row.Threads, row.ActiveAuthors) } return nil + case report.Quiet: + for _, row := range v.Channels { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.LastMessage, row.DaysSilent) + } + return nil + case report.Trends: + for _, row := range v.Rows { + for _, week := range row.Weekly { + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", row.GuildID, row.ChannelID, row.ChannelName, row.Kind, formatTime(week.WeekStart), week.Messages) + } + } + return nil default: return errors.New("no plain printer") } @@ -94,6 +106,7 @@ Commands: search messages digest + analytics dms mentions embed @@ -301,6 +314,54 @@ func printHuman(w io.Writer, value any) error { } _, err := fmt.Fprintf(w, "Totals: messages=%d threads=%d channels=%d authors=%d\n", v.Totals.Messages, v.Totals.Threads, v.Totals.Channels, v.Totals.ActiveAuthors) return err + case report.Quiet: + tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) + _, _ = fmt.Fprintln(tw, "CHANNEL\tKIND\tLAST MESSAGE\tDAYS SILENT") + for _, row := range v.Channels { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + row.ChannelName, + firstNonEmpty(row.Kind, "unknown"), + firstNonEmpty(row.LastMessage, "never"), + formatDaysSilent(row.DaysSilent), + ) + } + if err := tw.Flush(); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "\nWindow: %s to %s (%s)\n", formatTime(v.Since), formatTime(v.Until), formatWindowDuration(v.Until.Sub(v.Since))); err != nil { + return err + } + _, err := fmt.Fprintf(w, "Totals: channels=%d\n", v.Totals.Channels) + return err + case report.Trends: + tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) + header := []string{"CHANNEL", "KIND", "TOTAL"} + weekStarts := make([]time.Time, 0, v.Weeks) + if len(v.Rows) > 0 { + for _, week := range v.Rows[0].Weekly { + weekStarts = append(weekStarts, week.WeekStart) + } + } else { + for i := 0; i < v.Weeks; i++ { + weekStarts = append(weekStarts, v.Since.Add(time.Duration(i)*7*24*time.Hour)) + } + } + for _, start := range weekStarts { + header = append(header, start.Format("2006-01-02")) + } + _, _ = fmt.Fprintln(tw, strings.Join(header, "\t")) + for _, row := range v.Rows { + cols := []string{row.ChannelName, firstNonEmpty(row.Kind, "unknown"), fmt.Sprintf("%d", trendsRowTotal(row.Weekly))} + for _, week := range row.Weekly { + cols = append(cols, fmt.Sprintf("%d", week.Messages)) + } + _, _ = fmt.Fprintln(tw, strings.Join(cols, "\t")) + } + if err := tw.Flush(); err != nil { + return err + } + _, err := fmt.Fprintf(w, "\nWindow: %s to %s (%d weeks)\n", formatTime(v.Since), formatTime(v.Until), v.Weeks) + return err case map[string]any: keys := make([]string, 0, len(v)) for key := range v { @@ -360,3 +421,31 @@ func formatRankedCounts(rows []report.RankedCount) string { } return strings.Join(parts, ", ") } + +func formatDaysSilent(days int) string { + if days < 0 { + return "-" + } + return fmt.Sprintf("%d", days) +} + +func formatWindowDuration(d time.Duration) string { + if d <= 0 { + return "0" + } + if d%(24*time.Hour) == 0 { + return fmt.Sprintf("%dd", int(d/(24*time.Hour))) + } + if d%time.Hour == 0 { + return fmt.Sprintf("%dh", int(d/time.Hour)) + } + return d.String() +} + +func trendsRowTotal(weekly []report.WeeklyCount) int { + total := 0 + for _, row := range weekly { + total += row.Messages + } + return total +} diff --git a/internal/report/quiet.go b/internal/report/quiet.go new file mode 100644 index 0000000..8cae815 --- /dev/null +++ b/internal/report/quiet.go @@ -0,0 +1,158 @@ +package report + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "time" + + "github.com/steipete/discrawl/internal/store" +) + +// QuietOptions controls how a Quiet report is built. +type QuietOptions struct { + Now time.Time + Since time.Duration + GuildID string +} + +// Quiet summarizes channels with no activity in a window. +type Quiet struct { + GeneratedAt time.Time `json:"generated_at"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + Guild string `json:"guild,omitempty"` + Channels []QuietChannel `json:"channels"` + Totals QuietTotals `json:"totals"` +} + +// QuietChannel is one channel with no recent activity. +type QuietChannel struct { + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + Kind string `json:"kind,omitempty"` + GuildID string `json:"guild_id"` + LastMessage string `json:"last_message,omitempty"` + DaysSilent int `json:"days_silent"` +} + +// QuietTotals summarizes quiet-channel counts. +type QuietTotals struct { + Channels int `json:"channels"` +} + +// BuildQuiet computes channels with no messages newer than the lookback window. +func BuildQuiet(ctx context.Context, s *store.Store, opts QuietOptions) (Quiet, error) { + now := opts.Now + if now.IsZero() { + now = time.Now().UTC() + } + now = now.UTC() + + sinceWindow := opts.Since + if sinceWindow < 0 { + sinceWindow = -sinceWindow + } + if sinceWindow == 0 { + sinceWindow = 30 * 24 * time.Hour + } + since := now.Add(-sinceWindow) + + out := Quiet{ + GeneratedAt: now, + Since: since, + Until: now, + Guild: strings.TrimSpace(opts.GuildID), + } + + channels, err := quietChannels(ctx, s.DB(), since, now, out.Guild) + if err != nil { + return Quiet{}, err + } + out.Channels = channels + out.Totals = QuietTotals{Channels: len(channels)} + return out, nil +} + +func quietChannels(ctx context.Context, db *sql.DB, since, now time.Time, guildID string) ([]QuietChannel, error) { + query := &strings.Builder{} + query.WriteString(` +with latest_messages as ( + select + m.guild_id, + m.channel_id, + max(m.created_at) as last_message + from messages m + group by m.guild_id, m.channel_id +) +select + c.guild_id, + c.id as channel_id, + coalesce(nullif(c.name, ''), c.id) as channel_name, + coalesce(c.kind, '') as kind, + coalesce(lm.last_message, '') as last_message +from channels c +left join latest_messages lm on lm.guild_id = c.guild_id and lm.channel_id = c.id +where 1 = 1 +`) + args := make([]any, 0, 2) + if guildID != "" { + query.WriteString(" and c.guild_id = ?\n") + args = append(args, guildID) + } + query.WriteString(" and (lm.last_message is null or lm.last_message < ?)\n") + args = append(args, since.UTC().Format(time.RFC3339Nano)) + query.WriteString("order by channel_name asc\n") + + rows, err := db.QueryContext(ctx, query.String(), args...) + if err != nil { + return nil, fmt.Errorf("quiet channels query: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]QuietChannel, 0) + for rows.Next() { + var row QuietChannel + if err := rows.Scan(&row.GuildID, &row.ChannelID, &row.ChannelName, &row.Kind, &row.LastMessage); err != nil { + return nil, fmt.Errorf("quiet channels scan: %w", err) + } + if strings.TrimSpace(row.LastMessage) == "" { + row.LastMessage = "" + row.DaysSilent = -1 + } else { + last, err := time.Parse(time.RFC3339Nano, row.LastMessage) + if err != nil { + return nil, fmt.Errorf("quiet last message parse: %w", err) + } + last = last.UTC() + row.LastMessage = last.Format(time.RFC3339) + row.DaysSilent = int(now.Sub(last).Hours() / 24) + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, err + } + + sort.SliceStable(out, func(i, j int) bool { + iNever := out[i].DaysSilent < 0 + jNever := out[j].DaysSilent < 0 + if iNever != jNever { + return iNever + } + if out[i].DaysSilent != out[j].DaysSilent { + return out[i].DaysSilent > out[j].DaysSilent + } + if out[i].ChannelName != out[j].ChannelName { + return out[i].ChannelName < out[j].ChannelName + } + if out[i].GuildID != out[j].GuildID { + return out[i].GuildID < out[j].GuildID + } + return out[i].ChannelID < out[j].ChannelID + }) + + return out, nil +} diff --git a/internal/report/quiet_test.go b/internal/report/quiet_test.go new file mode 100644 index 0000000..053b5ec --- /dev/null +++ b/internal/report/quiet_test.go @@ -0,0 +1,107 @@ +package report + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/steipete/discrawl/internal/store" +) + +func TestBuildQuiet(t *testing.T) { + ctx := context.Background() + s, now := seedQuietStore(t, ctx) + defer func() { _ = s.Close() }() + + t.Run("happy path mixed active and silent channels", func(t *testing.T) { + quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour}) + require.NoError(t, err) + + require.Equal(t, now, quiet.GeneratedAt) + require.Equal(t, now.Add(-30*24*time.Hour), quiet.Since) + require.Equal(t, now, quiet.Until) + require.Equal(t, 4, quiet.Totals.Channels) + + ids := []string{quiet.Channels[0].ChannelID, quiet.Channels[1].ChannelID, quiet.Channels[2].ChannelID, quiet.Channels[3].ChannelID} + require.Equal(t, []string{"c0", "c2", "c9", "c3"}, ids) + require.Equal(t, -1, quiet.Channels[0].DaysSilent) + require.Equal(t, 45, quiet.Channels[1].DaysSilent) + require.Equal(t, 40, quiet.Channels[2].DaysSilent) + require.Equal(t, 35, quiet.Channels[3].DaysSilent) + }) + + t.Run("zero activity channel inclusion", func(t *testing.T) { + quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour}) + require.NoError(t, err) + + var never *QuietChannel + for i := range quiet.Channels { + if quiet.Channels[i].ChannelID == "c0" { + never = &quiet.Channels[i] + break + } + } + require.NotNil(t, never) + require.Empty(t, never.LastMessage) + require.Equal(t, -1, never.DaysSilent) + }) + + t.Run("guild filter", func(t *testing.T) { + quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour, GuildID: "g1"}) + require.NoError(t, err) + require.Len(t, quiet.Channels, 3) + for _, row := range quiet.Channels { + require.Equal(t, "g1", row.GuildID) + } + }) + + t.Run("negative since is normalized", func(t *testing.T) { + quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: -30 * 24 * time.Hour}) + require.NoError(t, err) + require.Equal(t, now.Add(-30*24*time.Hour), quiet.Since) + }) + + t.Run("default now and since defaults", func(t *testing.T) { + quiet, err := BuildQuiet(ctx, s, QuietOptions{}) + require.NoError(t, err) + require.WithinDuration(t, quiet.GeneratedAt.Add(-30*24*time.Hour), quiet.Since, 2*time.Second) + require.WithinDuration(t, quiet.GeneratedAt, quiet.Until, 2*time.Second) + }) + + t.Run("sort order most silent first with never-active first", func(t *testing.T) { + quiet, err := BuildQuiet(ctx, s, QuietOptions{Now: now, Since: 30 * 24 * time.Hour}) + require.NoError(t, err) + require.Len(t, quiet.Channels, 4) + require.Equal(t, "c0", quiet.Channels[0].ChannelID) + require.Greater(t, quiet.Channels[1].DaysSilent, quiet.Channels[2].DaysSilent) + require.Greater(t, quiet.Channels[2].DaysSilent, quiet.Channels[3].DaysSilent) + }) +} + +func seedQuietStore(t *testing.T, ctx context.Context) (*store.Store, time.Time) { + t.Helper() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db")) + require.NoError(t, err) + + now := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild 1", RawJSON: `{}`})) + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g2", Name: "Guild 2", RawJSON: `{}`})) + + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c0", GuildID: "g1", Kind: "text", Name: "never", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "recent", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "stale-45", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "forum", Name: "stale-35", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c9", GuildID: "g2", Kind: "text", Name: "other-guild-stale", RawJSON: `{}`})) + + require.NoError(t, s.UpsertMessages(ctx, []store.MessageMutation{ + {Record: store.MessageRecord{ID: "m1", GuildID: "g1", ChannelID: "c1", ChannelName: "recent", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-2 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "recent", NormalizedContent: "recent", RawJSON: `{}`}}, + {Record: store.MessageRecord{ID: "m2", GuildID: "g1", ChannelID: "c2", ChannelName: "stale-45", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-45 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "stale", NormalizedContent: "stale", RawJSON: `{}`}}, + {Record: store.MessageRecord{ID: "m3", GuildID: "g1", ChannelID: "c3", ChannelName: "stale-35", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-35 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "stale", NormalizedContent: "stale", RawJSON: `{}`}}, + {Record: store.MessageRecord{ID: "m4", GuildID: "g2", ChannelID: "c9", ChannelName: "other-guild-stale", AuthorID: "u1", AuthorName: "u1", CreatedAt: now.Add(-40 * 24 * time.Hour).Format(time.RFC3339Nano), Content: "stale", NormalizedContent: "stale", RawJSON: `{}`}}, + })) + + return s, now +} diff --git a/internal/report/trends.go b/internal/report/trends.go new file mode 100644 index 0000000..e183259 --- /dev/null +++ b/internal/report/trends.go @@ -0,0 +1,246 @@ +package report + +import ( + "context" + "database/sql" + "fmt" + "sort" + "strings" + "time" + + "github.com/steipete/discrawl/internal/store" +) + +const secondsPerWeek = int64(7 * 24 * 60 * 60) + +// TrendsOptions controls how a Trends report is built. +type TrendsOptions struct { + Now time.Time + Weeks int + GuildID string + Channel string +} + +// Trends summarizes week-over-week message volume per channel. +type Trends struct { + GeneratedAt time.Time `json:"generated_at"` + Since time.Time `json:"since"` + Until time.Time `json:"until"` + Weeks int `json:"weeks"` + Guild string `json:"guild,omitempty"` + Channel string `json:"channel,omitempty"` + Rows []TrendsRow `json:"rows"` +} + +// TrendsRow is one channel's weekly message trend. +type TrendsRow struct { + GuildID string `json:"guild_id"` + ChannelID string `json:"channel_id"` + ChannelName string `json:"channel_name"` + Kind string `json:"kind,omitempty"` + Weekly []WeeklyCount `json:"weekly"` +} + +// WeeklyCount is the message count for one week bucket. +type WeeklyCount struct { + WeekStart time.Time `json:"week_start"` + Messages int `json:"messages"` +} + +// BuildTrends computes weekly message counts per channel. +func BuildTrends(ctx context.Context, s *store.Store, opts TrendsOptions) (Trends, error) { + now := opts.Now + if now.IsZero() { + now = time.Now().UTC() + } + now = now.UTC() + + weeks := opts.Weeks + if weeks <= 0 { + weeks = 8 + } + + guildID := strings.TrimSpace(opts.GuildID) + channel := strings.TrimSpace(opts.Channel) + + untilBucket := now.Unix() / secondsPerWeek + sinceBucket := untilBucket - int64(weeks) + 1 + since := time.Unix(sinceBucket*secondsPerWeek, 0).UTC() + + rows, err := trendsRows(ctx, s.DB(), since, now, sinceBucket, untilBucket, weeks, guildID, channel) + if err != nil { + return Trends{}, err + } + + return Trends{ + GeneratedAt: now, + Since: since, + Until: now, + Weeks: weeks, + Guild: guildID, + Channel: channel, + Rows: rows, + }, nil +} + +type trendChannel struct { + guildID string + channelID string + channelName string + kind string +} + +func trendsRows(ctx context.Context, db *sql.DB, since, until time.Time, sinceBucket, untilBucket int64, weeks int, guildID, channel string) ([]TrendsRow, error) { + channels, err := listTrendChannels(ctx, db, guildID, channel) + if err != nil { + return nil, err + } + counts, err := listTrendCounts(ctx, db, since, until, sinceBucket, untilBucket, guildID, channel) + if err != nil { + return nil, err + } + + out := make([]TrendsRow, 0, len(channels)) + for _, ch := range channels { + weekly := make([]WeeklyCount, 0, weeks) + for i := 0; i < weeks; i++ { + bucket := sinceBucket + int64(i) + count := counts[ch.channelID][bucket] + weekly = append(weekly, WeeklyCount{ + WeekStart: time.Unix(bucket*secondsPerWeek, 0).UTC(), + Messages: count, + }) + } + out = append(out, TrendsRow{ + GuildID: ch.guildID, + ChannelID: ch.channelID, + ChannelName: ch.channelName, + Kind: ch.kind, + Weekly: weekly, + }) + } + + sort.SliceStable(out, func(i, j int) bool { + it := weeklyTotal(out[i].Weekly) + jt := weeklyTotal(out[j].Weekly) + if it != jt { + return it > jt + } + if out[i].ChannelName != out[j].ChannelName { + return out[i].ChannelName < out[j].ChannelName + } + if out[i].GuildID != out[j].GuildID { + return out[i].GuildID < out[j].GuildID + } + return out[i].ChannelID < out[j].ChannelID + }) + + return out, nil +} + +func listTrendChannels(ctx context.Context, db *sql.DB, guildID, channel string) ([]trendChannel, error) { + query := &strings.Builder{} + query.WriteString(` +select + c.guild_id, + c.id, + coalesce(nullif(c.name, ''), c.id) as channel_name, + coalesce(c.kind, '') as kind +from channels c +where 1 = 1 +`) + args := make([]any, 0, 3) + if guildID != "" { + query.WriteString(" and c.guild_id = ?\n") + args = append(args, guildID) + } + if channel != "" { + query.WriteString(" and (c.id = ? or c.name = ?)\n") + args = append(args, channel, channel) + } + query.WriteString("order by channel_name asc, c.guild_id asc, c.id asc\n") + + rows, err := db.QueryContext(ctx, query.String(), args...) + if err != nil { + return nil, fmt.Errorf("trends channels query: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make([]trendChannel, 0) + for rows.Next() { + var row trendChannel + if err := rows.Scan(&row.guildID, &row.channelID, &row.channelName, &row.kind); err != nil { + return nil, fmt.Errorf("trends channels scan: %w", err) + } + out = append(out, row) + } + return out, rows.Err() +} + +func listTrendCounts(ctx context.Context, db *sql.DB, since, until time.Time, sinceBucket, untilBucket int64, guildID, channel string) (map[string]map[int64]int, error) { + query := &strings.Builder{} + query.WriteString(` +select + m.channel_id, + cast(strftime('%s', m.created_at) / ? as integer) as week_bucket, + count(*) as messages +from messages m +left join channels c on c.id = m.channel_id and c.guild_id = m.guild_id +where m.created_at >= ? + and m.created_at < ? + and cast(strftime('%s', m.created_at) / ? as integer) >= ? + and cast(strftime('%s', m.created_at) / ? as integer) <= ? +`) + args := []any{ + secondsPerWeek, + since.UTC().Format(time.RFC3339Nano), + until.UTC().Format(time.RFC3339Nano), + secondsPerWeek, + sinceBucket, + secondsPerWeek, + untilBucket, + } + if guildID != "" { + query.WriteString(" and m.guild_id = ?\n") + args = append(args, guildID) + } + if channel != "" { + query.WriteString(" and (m.channel_id = ? or c.name = ?)\n") + args = append(args, channel, channel) + } + query.WriteString(`group by m.channel_id, week_bucket +order by m.channel_id asc, week_bucket asc +`) + + rows, err := db.QueryContext(ctx, query.String(), args...) + if err != nil { + return nil, fmt.Errorf("trends counts query: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make(map[string]map[int64]int) + for rows.Next() { + var channelID string + var weekBucket int64 + var messages int + if err := rows.Scan(&channelID, &weekBucket, &messages); err != nil { + return nil, fmt.Errorf("trends counts scan: %w", err) + } + if out[channelID] == nil { + out[channelID] = make(map[int64]int) + } + out[channelID][weekBucket] = messages + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + +func weeklyTotal(weekly []WeeklyCount) int { + total := 0 + for _, w := range weekly { + total += w.Messages + } + return total +} diff --git a/internal/report/trends_test.go b/internal/report/trends_test.go new file mode 100644 index 0000000..1a9c3b5 --- /dev/null +++ b/internal/report/trends_test.go @@ -0,0 +1,149 @@ +package report + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/steipete/discrawl/internal/store" +) + +func TestBuildTrends(t *testing.T) { + ctx := context.Background() + s, now := seedTrendsStore(t, ctx) + defer func() { _ = s.Close() }() + + nowBucket := now.Unix() / secondsPerWeek + + t.Run("zero fill returns exact weeks for every channel", func(t *testing.T) { + trends, err := BuildTrends(ctx, s, TrendsOptions{Now: now, Weeks: 3, GuildID: "g1"}) + require.NoError(t, err) + require.Equal(t, 3, trends.Weeks) + require.Len(t, trends.Rows, 4) + + for _, row := range trends.Rows { + require.Len(t, row.Weekly, 3) + } + + alpha := trends.Rows[0] + require.Equal(t, "alpha", alpha.ChannelName) + require.Equal(t, 2, alpha.Weekly[0].Messages) + require.Equal(t, 3, alpha.Weekly[1].Messages) + require.Equal(t, 1, alpha.Weekly[2].Messages) + + beta := trends.Rows[1] + require.Equal(t, "beta", beta.ChannelName) + require.Equal(t, 0, beta.Weekly[0].Messages) + require.Equal(t, 2, beta.Weekly[1].Messages) + require.Equal(t, 2, beta.Weekly[2].Messages) + + gamma := trends.Rows[2] + require.Equal(t, "gamma", gamma.ChannelName) + require.Equal(t, 0, gamma.Weekly[0].Messages) + require.Equal(t, 0, gamma.Weekly[1].Messages) + require.Equal(t, 1, gamma.Weekly[2].Messages) + + zeta := trends.Rows[3] + require.Equal(t, "zeta", zeta.ChannelName) + require.Equal(t, 0, zeta.Weekly[0].Messages) + require.Equal(t, 0, zeta.Weekly[1].Messages) + require.Equal(t, 0, zeta.Weekly[2].Messages) + }) + + t.Run("guild filter", func(t *testing.T) { + trends, err := BuildTrends(ctx, s, TrendsOptions{Now: now, Weeks: 3, GuildID: "g2"}) + require.NoError(t, err) + require.Len(t, trends.Rows, 1) + require.Equal(t, "omega", trends.Rows[0].ChannelName) + }) + + t.Run("channel filter by id", func(t *testing.T) { + trends, err := BuildTrends(ctx, s, TrendsOptions{Now: now, Weeks: 3, GuildID: "g1", Channel: "c2"}) + require.NoError(t, err) + require.Len(t, trends.Rows, 1) + require.Equal(t, "beta", trends.Rows[0].ChannelName) + require.Len(t, trends.Rows[0].Weekly, 3) + }) + + t.Run("channel filter by name", func(t *testing.T) { + trends, err := BuildTrends(ctx, s, TrendsOptions{Now: now, Weeks: 3, GuildID: "g1", Channel: "gamma"}) + require.NoError(t, err) + require.Len(t, trends.Rows, 1) + require.Equal(t, "c3", trends.Rows[0].ChannelID) + require.Equal(t, 1, trends.Rows[0].Weekly[2].Messages) + }) + + t.Run("week start values are aligned", func(t *testing.T) { + trends, err := BuildTrends(ctx, s, TrendsOptions{Now: now, Weeks: 3, GuildID: "g1"}) + require.NoError(t, err) + require.Len(t, trends.Rows, 4) + + expectedBuckets := []int64{nowBucket - 2, nowBucket - 1, nowBucket} + for _, row := range trends.Rows { + for i, week := range row.Weekly { + require.Equal(t, time.Unix(expectedBuckets[i]*secondsPerWeek, 0).UTC(), week.WeekStart) + require.Equal(t, int64(0), week.WeekStart.Unix()%secondsPerWeek) + } + } + }) + + t.Run("weeks default", func(t *testing.T) { + trends, err := BuildTrends(ctx, s, TrendsOptions{Now: now, Weeks: 0, GuildID: "g1", Channel: "c1"}) + require.NoError(t, err) + require.Equal(t, 8, trends.Weeks) + require.Len(t, trends.Rows, 1) + require.Len(t, trends.Rows[0].Weekly, 8) + }) +} + +func seedTrendsStore(t *testing.T, ctx context.Context) (*store.Store, time.Time) { + t.Helper() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db")) + require.NoError(t, err) + + now := time.Unix(1776852000, 0).UTC() // 2026-04-22T12:00:00Z + nowBucket := now.Unix() / secondsPerWeek + + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild 1", RawJSON: `{}`})) + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g2", Name: "Guild 2", RawJSON: `{}`})) + + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "alpha", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "beta", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "forum", Name: "gamma", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c4", GuildID: "g1", Kind: "text", Name: "zeta", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c9", GuildID: "g2", Kind: "text", Name: "omega", RawJSON: `{}`})) + + seed := func(guildID, channelID, channelName string, bucket int64, count int) { + mutations := make([]store.MessageMutation, 0, count) + for i := 0; i < count; i++ { + mutations = append(mutations, store.MessageMutation{Record: store.MessageRecord{ + ID: channelID + "-" + time.Unix(bucket*secondsPerWeek+int64(120+i), 0).UTC().Format("20060102150405") + "-" + time.Unix(int64(i), 0).UTC().Format("05"), + GuildID: guildID, + ChannelID: channelID, + ChannelName: channelName, + AuthorID: "u1", + AuthorName: "u1", + CreatedAt: time.Unix(bucket*secondsPerWeek+int64(120+i), 0).UTC().Format(time.RFC3339Nano), + Content: "message", + NormalizedContent: "message", + RawJSON: `{}`, + }}) + } + require.NoError(t, s.UpsertMessages(ctx, mutations)) + } + + seed("g1", "c1", "alpha", nowBucket-2, 2) + seed("g1", "c1", "alpha", nowBucket-1, 3) + seed("g1", "c1", "alpha", nowBucket, 1) + + seed("g1", "c2", "beta", nowBucket-1, 2) + seed("g1", "c2", "beta", nowBucket, 2) + + seed("g1", "c3", "gamma", nowBucket, 1) + seed("g2", "c9", "omega", nowBucket-1, 2) + + return s, now +} From 710aa54f59be916267dbd5b0a0ee2120fa144c53 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sat, 25 Apr 2026 09:53:36 -0700 Subject: [PATCH 4/4] fix(analytics): exclude non-message channels from quiet and trends Both quiet and trends queries left-joined the full channels table, which includes category and voice channels. Those rows can never have synced messages, so quiet surfaced them as never-active archive candidates and trends emitted all-zero rows for them. Filter to the message-bearing kinds the syncer ingests: - text - announcement - thread_public - thread_private - thread_announcement Forum parents are excluded since the syncer's messageChannelKinds() also excludes them. Forum threads (kind='thread_public') are still included. Surfaced by codex review on the previous commit. --- internal/report/quiet.go | 1 + internal/report/quiet_test.go | 4 +++- internal/report/trends.go | 1 + internal/report/trends_test.go | 4 +++- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/report/quiet.go b/internal/report/quiet.go index 8cae815..5b472e5 100644 --- a/internal/report/quiet.go +++ b/internal/report/quiet.go @@ -96,6 +96,7 @@ select from channels c left join latest_messages lm on lm.guild_id = c.guild_id and lm.channel_id = c.id where 1 = 1 + and c.kind in ('text', 'announcement', 'thread_public', 'thread_private', 'thread_announcement') `) args := make([]any, 0, 2) if guildID != "" { diff --git a/internal/report/quiet_test.go b/internal/report/quiet_test.go index 053b5ec..7d80b24 100644 --- a/internal/report/quiet_test.go +++ b/internal/report/quiet_test.go @@ -93,7 +93,9 @@ func seedQuietStore(t *testing.T, ctx context.Context) (*store.Store, time.Time) require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c0", GuildID: "g1", Kind: "text", Name: "never", RawJSON: `{}`})) require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "recent", RawJSON: `{}`})) require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "stale-45", RawJSON: `{}`})) - require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "forum", Name: "stale-35", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "thread_public", Name: "stale-35", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c5", GuildID: "g1", Kind: "category", Name: "structural", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c6", GuildID: "g1", Kind: "voice", Name: "lobby", RawJSON: `{}`})) require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c9", GuildID: "g2", Kind: "text", Name: "other-guild-stale", RawJSON: `{}`})) require.NoError(t, s.UpsertMessages(ctx, []store.MessageMutation{ diff --git a/internal/report/trends.go b/internal/report/trends.go index e183259..d4dd277 100644 --- a/internal/report/trends.go +++ b/internal/report/trends.go @@ -148,6 +148,7 @@ select coalesce(c.kind, '') as kind from channels c where 1 = 1 + and c.kind in ('text', 'announcement', 'thread_public', 'thread_private', 'thread_announcement') `) args := make([]any, 0, 3) if guildID != "" { diff --git a/internal/report/trends_test.go b/internal/report/trends_test.go index 1a9c3b5..337c6b9 100644 --- a/internal/report/trends_test.go +++ b/internal/report/trends_test.go @@ -112,8 +112,10 @@ func seedTrendsStore(t *testing.T, ctx context.Context) (*store.Store, time.Time require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "alpha", RawJSON: `{}`})) require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g1", Kind: "text", Name: "beta", RawJSON: `{}`})) - require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "forum", Name: "gamma", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c3", GuildID: "g1", Kind: "thread_public", Name: "gamma", RawJSON: `{}`})) require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c4", GuildID: "g1", Kind: "text", Name: "zeta", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c5", GuildID: "g1", Kind: "category", Name: "structural", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c6", GuildID: "g1", Kind: "voice", Name: "lobby", RawJSON: `{}`})) require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c9", GuildID: "g2", Kind: "text", Name: "omega", RawJSON: `{}`})) seed := func(guildID, channelID, channelName string, bucket int64, count int) {