diff --git a/README.md b/README.md index 8481e6f..2bcfff9 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,46 @@ 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 + +### `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 2f11f07..b6b2e01 100644 --- a/SPEC.md +++ b/SPEC.md @@ -808,3 +808,54 @@ 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 + +## 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 2ec8263..48e0c4c 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -137,6 +137,10 @@ 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 "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/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..f7443ed 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,23 @@ 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 + 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") } @@ -87,6 +105,8 @@ Commands: wiretap search messages + digest + analytics dms mentions embed @@ -274,6 +294,74 @@ 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 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 { @@ -322,3 +410,42 @@ 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, ", ") +} + +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/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 +} diff --git a/internal/report/quiet.go b/internal/report/quiet.go new file mode 100644 index 0000000..5b472e5 --- /dev/null +++ b/internal/report/quiet.go @@ -0,0 +1,159 @@ +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 + and c.kind in ('text', 'announcement', 'thread_public', 'thread_private', 'thread_announcement') +`) + 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..7d80b24 --- /dev/null +++ b/internal/report/quiet_test.go @@ -0,0 +1,109 @@ +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: "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{ + {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/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) { diff --git a/internal/report/trends.go b/internal/report/trends.go new file mode 100644 index 0000000..d4dd277 --- /dev/null +++ b/internal/report/trends.go @@ -0,0 +1,247 @@ +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 + and c.kind in ('text', 'announcement', 'thread_public', 'thread_private', 'thread_announcement') +`) + 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..337c6b9 --- /dev/null +++ b/internal/report/trends_test.go @@ -0,0 +1,151 @@ +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: "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) { + 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 +}