Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,25 @@ Configure `OPENAI_API_KEY` in the discrawl repo secrets to enable agent-written

The backup workflows restore and save `.discrawl-ci/discrawl.db` with `actions/cache`. On a warm runner cache, `discrawl update` compares the cached DB's last imported snapshot timestamp with `manifest.json` and skips the full sharded import when they match. Cache misses and newer backup manifests still take the normal pull/import path.

### `digest`

Summarizes per-channel activity for a lookback window.

```bash
discrawl digest
discrawl digest --since 30d
discrawl digest --guild 123456789012345678
discrawl digest --channel general
discrawl --json digest --since 7d --top-n 5
```

Notes:

- `--since` accepts Go durations (`72h`, `30m`) and `Nd` shorthand (`7d`, `30d`)
- `--guild` scopes to one guild; when omitted, `default_guild_id` is used if configured
- `--channel` accepts a channel id or exact channel name
- `--top-n` controls how many top posters and mention targets are shown per channel

### `doctor`

Checks config, auth, DB, and FTS wiring.
Expand Down
26 changes: 26 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -808,3 +808,29 @@ For an AI agent to finish the product without external memory, this repo should
- milestone order

This file is the authoritative engineering spec for now.

## Digest

`discrawl digest` provides a per-channel activity summary over a lookback window.

Example usage:

```bash
discrawl digest
discrawl digest --since 7d
discrawl digest --since 30d --guild 123456789012345678
discrawl digest --channel general --top-n 5
discrawl --json digest --since 72h
```

Behavior:

- window defaults to `7d` when `--since` is omitted
- `--since` accepts Go durations (`72h`, `30m`) and `Nd` shorthand (`7d`, `30d`)
- `--guild` filters by `guild_id`; empty means no guild filter
- `--channel` accepts channel id or exact channel name
- per-channel metrics include `messages`, `threads` (distinct `reply_to_message_id`), and `active_authors`
- top posters are ranked by message count using member display fallback order: `display_name -> nick -> global_name -> username -> author_id -> unknown`
- top mentions are ranked from `mention_events` and include all target types (`user` and `role`)
- channels are sorted by message count descending, then channel name ascending
- JSON output returns a `Digest` object with channel rows and totals; plain output emits one tab-separated row per channel
2 changes: 2 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ func (r *runtime) dispatch(rest []string) error {
}
autoShareUpdate := !hasBoolFlag(rest[1:], "--dm")
return r.withLocalStoreDefault(autoShareUpdate, func() error { return r.runMessages(rest[1:]) })
case "digest":
return r.withLocalStoreDefault(true, func() error { return r.runDigest(rest[1:]) })
case "dms":
return r.withLocalStoreDefault(false, func() error { return r.runDirectMessages(rest[1:]) })
case "mentions":
Expand Down
73 changes: 73 additions & 0 deletions internal/cli/digest.go
Original file line number Diff line number Diff line change
@@ -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 changes: 166 additions & 0 deletions internal/cli/digest_test.go
Original file line number Diff line number Diff line change
@@ -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: `{}`,
},
},
})
}
38 changes: 38 additions & 0 deletions internal/cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
}
Expand All @@ -87,6 +93,7 @@ Commands:
wiretap
search
messages
digest
dms
mentions
embed
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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, ", ")
}
Loading