feat(cli): digest command for windowed per-channel summary
Adds a windowed per-channel digest command over existing archive tables.
This commit is contained in:
parent
41b675d5f4
commit
468ad1680c
19
README.md
19
README.md
@ -471,6 +471,25 @@ Every scheduled snapshot publish updates deterministic README stats: latest upda
|
||||
|
||||
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.
|
||||
|
||||
26
SPEC.md
26
SPEC.md
@ -786,3 +786,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
|
||||
|
||||
@ -138,6 +138,8 @@ func (r *runtime) dispatch(rest []string) error {
|
||||
}
|
||||
autoShareUpdate := !hasBoolFlag(rest[1:], "--dm")
|
||||
return r.withLocalStoreDefaultLocked(autoShareUpdate, autoShareUpdate, func() error { return r.runMessages(rest[1:]) })
|
||||
case "digest":
|
||||
return r.withLocalStoreDefaultLocked(true, true, func() error { return r.runDigest(rest[1:]) })
|
||||
case "dms":
|
||||
return r.withLocalStoreDefault(false, func() error { return r.runDirectMessages(rest[1:]) })
|
||||
case "mentions":
|
||||
|
||||
73
internal/cli/digest.go
Normal file
73
internal/cli/digest.go
Normal file
@ -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
|
||||
}
|
||||
166
internal/cli/digest_test.go
Normal file
166
internal/cli/digest_test.go
Normal file
@ -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: `{}`,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -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, ", ")
|
||||
}
|
||||
|
||||
248
internal/report/digest.go
Normal file
248
internal/report/digest.go
Normal file
@ -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()
|
||||
}
|
||||
136
internal/report/digest_test.go
Normal file
136
internal/report/digest_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user