From 7825ca0edc4ec7aabe2d85f0c37a0897c122b59e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 05:38:38 +0100 Subject: [PATCH] feat: publish discord activity report --- .github/workflows/discord-backup-report.yml | 57 +++ .github/workflows/publish-discord-backup.yml | 6 +- README.md | 13 + internal/cli/cli.go | 2 + internal/cli/cli_test.go | 3 + internal/cli/output.go | 1 + internal/cli/report_commands.go | 53 +++ internal/cli/share_commands.go | 16 + internal/report/ai.go | 133 +++++++ internal/report/report.go | 393 +++++++++++++++++++ internal/report/report_test.go | 145 +++++++ 11 files changed, 819 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/discord-backup-report.yml create mode 100644 internal/cli/report_commands.go create mode 100644 internal/report/ai.go create mode 100644 internal/report/report.go create mode 100644 internal/report/report_test.go diff --git a/.github/workflows/discord-backup-report.yml b/.github/workflows/discord-backup-report.yml new file mode 100644 index 0000000..a6436c2 --- /dev/null +++ b/.github/workflows/discord-backup-report.yml @@ -0,0 +1,57 @@ +name: discord-backup-report + +on: + schedule: + - cron: "17 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: discord-backup-report + cancel-in-progress: false + +jobs: + report: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout discrawl + uses: actions/checkout@v6.0.2 + + - name: Setup Go + uses: actions/setup-go@v6.3.0 + with: + go-version-file: go.mod + cache: true + + - name: Configure Git identity + run: | + git config --global user.name "discrawl reporter" + git config --global user.email "discrawl-reporter@users.noreply.github.com" + + - name: Generate daily Discord report + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + BACKUP_REMOTE: https://x-access-token:${{ secrets.DISCORD_BACKUP_TOKEN }}@github.com/openclaw/discord-backup.git + CONFIG: ${{ runner.temp }}/discrawl/config.toml + BACKUP_REPO: ${{ runner.temp }}/discord-backup + run: | + if [ -z "${OPENAI_API_KEY:-}" ]; then + echo "OPENAI_API_KEY not configured; skipping AI report" + exit 0 + fi + mkdir -p "$(dirname "$CONFIG")" + git clone "$BACKUP_REMOTE" "$BACKUP_REPO" + go run ./cmd/discrawl --config "$CONFIG" subscribe --repo "$BACKUP_REPO" "$BACKUP_REMOTE" + go run ./cmd/discrawl --config "$CONFIG" report \ + --readme "$BACKUP_REPO/README.md" \ + --ai + if git -C "$BACKUP_REPO" diff --quiet README.md; then + echo "README already up to date" + exit 0 + fi + git -C "$BACKUP_REPO" add README.md + git -C "$BACKUP_REPO" commit -m "docs: update discord activity report" + git -C "$BACKUP_REPO" push diff --git a/.github/workflows/publish-discord-backup.yml b/.github/workflows/publish-discord-backup.yml index 5f65bbc..c1c469e 100644 --- a/.github/workflows/publish-discord-backup.yml +++ b/.github/workflows/publish-discord-backup.yml @@ -41,14 +41,14 @@ jobs: run: | mkdir -p "$(dirname "$CONFIG")" git clone "$BACKUP_REMOTE" "$BACKUP_REPO" + go run ./cmd/discrawl --config "$CONFIG" init --db "$DB" if [ -f "$BACKUP_REPO/manifest.json" ]; then - go run ./cmd/discrawl --config "$CONFIG" subscribe --repo "$BACKUP_REPO" "$BACKUP_REMOTE" - else - go run ./cmd/discrawl --config "$CONFIG" init --db "$DB" + go run ./cmd/discrawl --config "$CONFIG" update --repo "$BACKUP_REPO" --remote "$BACKUP_REMOTE" fi go run ./cmd/discrawl --config "$CONFIG" sync --all go run ./cmd/discrawl --config "$CONFIG" publish \ --repo "$BACKUP_REPO" \ --remote "$BACKUP_REMOTE" \ + --readme "$BACKUP_REPO/README.md" \ --message "sync: discord archive" \ --push diff --git a/README.md b/README.md index 528f07d..cff9b67 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,7 @@ Publisher: ```bash discrawl publish --remote https://github.com/openclaw/discord-backup.git --push +discrawl publish --readme path/to/discord-backup/README.md --push ``` Subscriber: @@ -347,6 +348,18 @@ discrawl subscribe --no-auto-update https://github.com/openclaw/discord-backup.g Once `share.remote` is configured, read commands auto-fetch and import when the local share import is older than `share.stale_after` (default `15m`). `discrawl update` forces the same pull/import step manually. +### `report` + +Generates the Markdown activity block used by the shared backup repo README. + +```bash +discrawl report +discrawl report --readme path/to/discord-backup/README.md +discrawl report --readme path/to/discord-backup/README.md --ai +``` + +Every scheduled snapshot publish updates deterministic README stats: latest update time, latest archived message, archive totals, and day/week/month activity. The AI field notes are intentionally a separate daily workflow so model latency or quota cannot block the 15-minute data publish path. Configure `OPENAI_API_KEY` in the discrawl repo secrets to enable that daily AI report. + ### `doctor` Checks config, auth, DB, and FTS wiring. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 65361ec..6be13b3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -137,6 +137,8 @@ func (r *runtime) dispatch(rest []string) error { return r.withServices(false, func() error { return r.runChannels(rest[1:]) }) case "status": return r.withServices(false, func() error { return r.runStatus(rest[1:]) }) + case "report": + return r.withServices(false, func() error { return r.runReport(rest[1:]) }) case "publish": return r.withServicesAuto(false, false, func() error { return r.runPublish(rest[1:]) }) case "subscribe": diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index e92a7b1..9611a2e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -125,6 +125,7 @@ func TestStatusSearchSQLAndListings(t *testing.T) { {"--config", cfgPath, "members", "search", "Maintainer"}, {"--config", cfgPath, "members", "show", "u1"}, {"--config", cfgPath, "channels", "list"}, + {"--config", cfgPath, "report"}, } for _, args := range tests { var out bytes.Buffer @@ -196,9 +197,11 @@ func TestShareCommandsPublishSubscribeAndUpdate(t *testing.T) { "publish", "--repo", cfg.Share.RepoPath, "--remote", remoteRepo, + "--readme", filepath.Join(cfg.Share.RepoPath, "README.md"), "--no-commit", }, &out, &bytes.Buffer{})) require.FileExists(t, filepath.Join(cfg.Share.RepoPath, share.ManifestName)) + require.FileExists(t, filepath.Join(cfg.Share.RepoPath, "README.md")) runGit(t, cfg.Share.RepoPath, "config", "user.name", "discrawl test") runGit(t, cfg.Share.RepoPath, "config", "user.email", "discrawl@example.com") diff --git a/internal/cli/output.go b/internal/cli/output.go index 8c9c7ec..46d42ad 100644 --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -84,6 +84,7 @@ Commands: members channels status + report doctor `) } diff --git a/internal/cli/report_commands.go b/internal/cli/report_commands.go new file mode 100644 index 0000000..46873af --- /dev/null +++ b/internal/cli/report_commands.go @@ -0,0 +1,53 @@ +package cli + +import ( + "flag" + "fmt" + "io" + + "github.com/steipete/discrawl/internal/report" +) + +func (r *runtime) runReport(args []string) error { + fs := flag.NewFlagSet("report", flag.ContinueOnError) + fs.SetOutput(io.Discard) + readmePath := fs.String("readme", "", "") + ai := fs.Bool("ai", false, "") + aiModel := fs.String("ai-model", "", "") + aiKeyEnv := fs.String("ai-key-env", "", "") + aiBaseURL := fs.String("ai-base-url", "", "") + if err := fs.Parse(args); err != nil { + return usageErr(err) + } + if fs.NArg() != 0 { + return usageErr(fmt.Errorf("report takes no positional arguments")) + } + activity, err := report.Build(r.ctx, r.store, report.Options{ + AI: report.AIOptions{ + Enabled: *ai, + Model: *aiModel, + APIKeyEnv: *aiKeyEnv, + BaseURL: *aiBaseURL, + }, + }) + if err != nil { + return err + } + section, err := report.RenderMarkdown(activity) + if err != nil { + return err + } + if *readmePath != "" { + if err := report.WriteReadme(*readmePath, section); err != nil { + return err + } + return r.print(map[string]any{ + "readme": *readmePath, + "generated_at": activity.GeneratedAt, + "latest_message_at": activity.LatestMessageAt, + "ai": *ai, + }) + } + _, err = io.WriteString(r.stdout, section) + return err +} diff --git a/internal/cli/share_commands.go b/internal/cli/share_commands.go index f8849d4..3b4b814 100644 --- a/internal/cli/share_commands.go +++ b/internal/cli/share_commands.go @@ -7,6 +7,7 @@ import ( "os" "github.com/steipete/discrawl/internal/config" + "github.com/steipete/discrawl/internal/report" "github.com/steipete/discrawl/internal/share" "github.com/steipete/discrawl/internal/store" ) @@ -20,6 +21,7 @@ func (r *runtime) runPublish(args []string) error { remote := fs.String("remote", r.cfg.Share.Remote, "") branch := fs.String("branch", r.cfg.Share.Branch, "") message := fs.String("message", "", "") + readmePath := fs.String("readme", "", "") noCommit := fs.Bool("no-commit", false, "") push := fs.Bool("push", false, "") if err := fs.Parse(args); err != nil { @@ -36,6 +38,19 @@ func (r *runtime) runPublish(args []string) error { if err != nil { return err } + if *readmePath != "" { + activity, err := report.Build(r.ctx, r.store, report.Options{}) + if err != nil { + return err + } + section, err := report.RenderMarkdown(activity) + if err != nil { + return err + } + if err := report.WriteReadme(*readmePath, section); err != nil { + return err + } + } committed := false if !*noCommit { msg := *message @@ -57,6 +72,7 @@ func (r *runtime) runPublish(args []string) error { "remote": opts.Remote, "generated_at": manifest.GeneratedAt, "tables": manifest.Tables, + "readme": *readmePath, "committed": committed, "pushed": *push, }) diff --git a/internal/report/ai.go b/internal/report/ai.go new file mode 100644 index 0000000..220facb --- /dev/null +++ b/internal/report/ai.go @@ -0,0 +1,133 @@ +package report + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +const ( + defaultAIModel = "gpt-5.2" + defaultAIKeyEnv = "OPENAI_API_KEY" + defaultAIBaseURL = "https://api.openai.com/v1/responses" + defaultHTTPTimeout = 45 * time.Second +) + +func GenerateAISummary(ctx context.Context, report ActivityReport, opts AIOptions) (string, error) { + model := strings.TrimSpace(opts.Model) + if model == "" { + model = defaultAIModel + } + keyEnv := strings.TrimSpace(opts.APIKeyEnv) + if keyEnv == "" { + keyEnv = defaultAIKeyEnv + } + apiKey := strings.TrimSpace(os.Getenv(keyEnv)) + if apiKey == "" { + return "", fmt.Errorf("%s is not set", keyEnv) + } + baseURL := strings.TrimSpace(opts.BaseURL) + if baseURL == "" { + baseURL = defaultAIBaseURL + } + + body := map[string]any{ + "model": model, + "instructions": strings.Join([]string{ + "You write a short private Discord archive field report.", + "Tone: funny, warm, dry, and useful. No cringe. No bullying individual people.", + "Use only the provided statistics and message samples.", + "Do not expose secrets, tokens, private URLs, or raw IDs.", + "Return Markdown with 3 bullets: one funny observation, one useful trend, one thing worth following up.", + }, " "), + "input": aiInput(report), + "max_output_tokens": 500, + } + payload, err := json.Marshal(body) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: defaultHTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("openai response %s: %s", resp.Status, strings.TrimSpace(string(respBody))) + } + text := extractResponseText(respBody) + if strings.TrimSpace(text) == "" { + return "", fmt.Errorf("openai response did not contain output text") + } + return text, nil +} + +func aiInput(report ActivityReport) string { + var b strings.Builder + _, _ = fmt.Fprintf(&b, "Generated at: %s\nLatest message: %s\nArchive totals: %d messages, %d channels, %d members\n\n", + formatTime(report.GeneratedAt), formatTime(report.LatestMessageAt), report.TotalMessages, report.TotalChannels, report.TotalMembers) + _, _ = fmt.Fprintln(&b, "Activity windows:") + for _, window := range report.Windows { + _, _ = fmt.Fprintf(&b, "- last %s: %d messages, %d people, %d channels, %d attachments\n", window.Label, window.Messages, window.ActiveAuthors, window.ActiveChannels, window.Attachments) + } + writeRanks := func(title string, rows []RankedCount) { + _, _ = fmt.Fprintf(&b, "\n%s:\n", title) + for _, row := range rows { + _, _ = fmt.Fprintf(&b, "- %s: %d\n", row.Name, row.Count) + } + } + writeRanks("Top channels this week", report.TopChannels) + writeRanks("Top posters this week", report.TopAuthors) + writeRanks("Busiest days this month", report.BusiestDays) + _, _ = fmt.Fprintln(&b, "\nRecent message samples:") + for _, sample := range report.RecentSamples { + _, _ = fmt.Fprintf(&b, "- [%s] %s: %s\n", sample.Channel, sample.Author, sample.Content) + } + return b.String() +} + +func extractResponseText(body []byte) string { + var decoded struct { + Output []struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"output"` + OutputText string `json:"output_text"` + } + if err := json.Unmarshal(body, &decoded); err != nil { + return "" + } + if strings.TrimSpace(decoded.OutputText) != "" { + return decoded.OutputText + } + var parts []string + for _, output := range decoded.Output { + for _, content := range output.Content { + if strings.TrimSpace(content.Text) != "" { + parts = append(parts, content.Text) + } + } + } + return strings.Join(parts, "\n\n") +} diff --git a/internal/report/report.go b/internal/report/report.go new file mode 100644 index 0000000..88bec04 --- /dev/null +++ b/internal/report/report.go @@ -0,0 +1,393 @@ +package report + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "html" + "os" + "strconv" + "strings" + "text/template" + "time" + + "github.com/steipete/discrawl/internal/store" +) + +const ( + StartMarker = "" + EndMarker = "" +) + +type Options struct { + Now time.Time + AI AIOptions +} + +type AIOptions struct { + Enabled bool + Model string + APIKeyEnv string + BaseURL string +} + +type ActivityReport struct { + GeneratedAt time.Time + LatestMessageAt time.Time + TotalMessages int + TotalChannels int + TotalMembers int + Windows []WindowStats + TopChannels []RankedCount + TopAuthors []RankedCount + BusiestDays []RankedCount + RecentSamples []MessageSample + AISummary string +} + +type WindowStats struct { + Label string + Since time.Time + Messages int + ActiveAuthors int + ActiveChannels int + Attachments int +} + +type RankedCount struct { + Name string + Count int +} + +type MessageSample struct { + Channel string + Author string + Content string + CreatedAt time.Time +} + +func Build(ctx context.Context, s *store.Store, opts Options) (ActivityReport, error) { + now := opts.Now + if now.IsZero() { + now = time.Now().UTC() + } + report := ActivityReport{GeneratedAt: now.UTC()} + if err := scanTotals(ctx, s.DB(), &report); err != nil { + return ActivityReport{}, err + } + anchor := report.LatestMessageAt + if anchor.IsZero() { + anchor = now + } + windows := []struct { + label string + dur time.Duration + }{ + {"24 hours", 24 * time.Hour}, + {"7 days", 7 * 24 * time.Hour}, + {"30 days", 30 * 24 * time.Hour}, + } + for _, window := range windows { + stats, err := scanWindow(ctx, s.DB(), window.label, anchor.Add(-window.dur)) + if err != nil { + return ActivityReport{}, err + } + report.Windows = append(report.Windows, stats) + } + weekSince := anchor.Add(-7 * 24 * time.Hour) + monthSince := anchor.Add(-30 * 24 * time.Hour) + var err error + report.TopChannels, err = topChannels(ctx, s.DB(), weekSince, 8) + if err != nil { + return ActivityReport{}, err + } + report.TopAuthors, err = topAuthors(ctx, s.DB(), weekSince, 8) + if err != nil { + return ActivityReport{}, err + } + report.BusiestDays, err = busiestDays(ctx, s.DB(), monthSince, 7) + if err != nil { + return ActivityReport{}, err + } + report.RecentSamples, err = recentSamples(ctx, s.DB(), weekSince, 40) + if err != nil { + return ActivityReport{}, err + } + if opts.AI.Enabled { + summary, err := GenerateAISummary(ctx, report, opts.AI) + if err != nil { + return ActivityReport{}, err + } + report.AISummary = strings.TrimSpace(summary) + } + return report, nil +} + +func scanTotals(ctx context.Context, db *sql.DB, report *ActivityReport) error { + var latest sql.NullString + if err := db.QueryRowContext(ctx, ` + select + (select count(*) from messages), + (select count(*) from channels), + (select count(*) from members), + (select max(created_at) from messages) + `).Scan(&report.TotalMessages, &report.TotalChannels, &report.TotalMembers, &latest); err != nil { + return fmt.Errorf("scan report totals: %w", err) + } + report.LatestMessageAt = parseTime(latest.String) + return nil +} + +func scanWindow(ctx context.Context, db *sql.DB, label string, since time.Time) (WindowStats, error) { + stats := WindowStats{Label: label, Since: since.UTC()} + if err := db.QueryRowContext(ctx, ` + select + count(*), + count(distinct nullif(author_id, '')), + count(distinct nullif(channel_id, '')), + coalesce(sum(case when has_attachments then 1 else 0 end), 0) + from messages + where created_at >= ? + `, since.UTC().Format(time.RFC3339Nano)).Scan(&stats.Messages, &stats.ActiveAuthors, &stats.ActiveChannels, &stats.Attachments); err != nil { + return WindowStats{}, fmt.Errorf("scan %s stats: %w", label, err) + } + return stats, nil +} + +func topChannels(ctx context.Context, db *sql.DB, since time.Time, limit int) ([]RankedCount, error) { + return ranked(ctx, db, ` + select coalesce(nullif(c.name, ''), m.channel_id) as name, count(*) as total + from messages m + left join channels c on c.id = m.channel_id + where m.created_at >= ? + group by m.channel_id, coalesce(nullif(c.name, ''), m.channel_id) + order by total desc, name asc + limit ? + `, since.UTC().Format(time.RFC3339Nano), limit) +} + +func topAuthors(ctx context.Context, db *sql.DB, since time.Time, 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(json_extract(m.raw_json, '$.author.global_name'), ''), + nullif(json_extract(m.raw_json, '$.author.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 >= ? + group by m.author_id, name + order by total desc, name asc + limit ? + `, since.UTC().Format(time.RFC3339Nano), limit) +} + +func busiestDays(ctx context.Context, db *sql.DB, since time.Time, limit int) ([]RankedCount, error) { + return ranked(ctx, db, ` + select substr(created_at, 1, 10) as name, count(*) as total + from messages + where created_at >= ? + group by substr(created_at, 1, 10) + order by total desc, name desc + limit ? + `, since.UTC().Format(time.RFC3339Nano), limit) +} + +func ranked(ctx context.Context, db *sql.DB, query string, args ...any) ([]RankedCount, error) { + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []RankedCount + for rows.Next() { + var row RankedCount + if err := rows.Scan(&row.Name, &row.Count); err != nil { + return nil, err + } + out = append(out, row) + } + return out, rows.Err() +} + +func recentSamples(ctx context.Context, db *sql.DB, since time.Time, limit int) ([]MessageSample, error) { + rows, err := db.QueryContext(ctx, ` + select + coalesce(nullif(c.name, ''), m.channel_id), + coalesce( + nullif(mem.display_name, ''), + nullif(mem.nick, ''), + nullif(mem.global_name, ''), + nullif(mem.username, ''), + nullif(json_extract(m.raw_json, '$.author.username'), ''), + nullif(m.author_id, ''), + 'unknown' + ), + case + when trim(coalesce(m.content, '')) <> '' then m.content + else m.normalized_content + end, + m.created_at + from messages m + left join channels c on c.id = m.channel_id + left join members mem on mem.guild_id = m.guild_id and mem.user_id = m.author_id + where m.created_at >= ? + and trim(coalesce(m.normalized_content, m.content, '')) <> '' + order by m.created_at desc, m.id desc + limit ? + `, since.UTC().Format(time.RFC3339Nano), limit) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + var out []MessageSample + for rows.Next() { + var row MessageSample + var created string + if err := rows.Scan(&row.Channel, &row.Author, &row.Content, &created); err != nil { + return nil, err + } + row.CreatedAt = parseTime(created) + row.Content = clipWhitespace(row.Content, 220) + out = append(out, row) + } + return out, rows.Err() +} + +func RenderMarkdown(report ActivityReport) (string, error) { + var body bytes.Buffer + if err := reportTemplate.Execute(&body, report); err != nil { + return "", err + } + return strings.TrimSpace(body.String()) + "\n", nil +} + +func UpdateReadme(readme []byte, section string) []byte { + replacement := StartMarker + "\n" + strings.TrimSpace(section) + "\n" + EndMarker + text := string(readme) + start := strings.Index(text, StartMarker) + end := strings.Index(text, EndMarker) + if start >= 0 && end >= start { + end += len(EndMarker) + return []byte(text[:start] + replacement + text[end:]) + } + text = strings.TrimRight(text, "\n") + if text == "" { + return []byte(replacement + "\n") + } + return []byte(text + "\n\n" + replacement + "\n") +} + +func WriteReadme(path string, section string) error { + current, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + updated := UpdateReadme(current, section) + return os.WriteFile(path, updated, 0o600) +} + +func MarkdownTable(rows []RankedCount, nameTitle string) string { + if len(rows) == 0 { + return "_No activity._" + } + var b strings.Builder + _, _ = fmt.Fprintf(&b, "| %s | Messages |\n| --- | ---: |\n", nameTitle) + for _, row := range rows { + _, _ = fmt.Fprintf(&b, "| %s | %s |\n", escapeMD(row.Name), formatInt(row.Count)) + } + return strings.TrimRight(b.String(), "\n") +} + +func formatTime(t time.Time) string { + if t.IsZero() { + return "n/a" + } + return t.UTC().Format("2006-01-02 15:04 UTC") +} + +func formatInt(v int) string { + return strconv.FormatInt(int64(v), 10) +} + +func escapeMD(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "unknown" + } + s = html.EscapeString(s) + s = strings.ReplaceAll(s, "|", "\\|") + return s +} + +func clipWhitespace(s string, limit int) string { + s = strings.Join(strings.Fields(s), " ") + if len(s) <= limit { + return s + } + return strings.TrimSpace(s[:limit]) + "..." +} + +func parseTime(raw string) time.Time { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05"} { + if t, err := time.Parse(layout, raw); err == nil { + return t.UTC() + } + } + return time.Time{} +} + +var reportTemplate = template.Must(template.New("report").Funcs(template.FuncMap{ + "formatTime": formatTime, + "formatInt": formatInt, + "rankedTable": MarkdownTable, + "escapeMD": escapeMD, + "sampleContent": clipWhitespace, +}).Parse(`## Discord Activity Report + +Last updated: {{ formatTime .GeneratedAt }} +Latest archived message: {{ formatTime .LatestMessageAt }} + +Archive size: {{ formatInt .TotalMessages }} messages, {{ formatInt .TotalChannels }} channels, {{ formatInt .TotalMembers }} members. + +### Activity + +| Window | Messages | Active people | Active channels | Attachments | +| --- | ---: | ---: | ---: | ---: | +{{- range .Windows }} +| Last {{ .Label }} | {{ formatInt .Messages }} | {{ formatInt .ActiveAuthors }} | {{ formatInt .ActiveChannels }} | {{ formatInt .Attachments }} | +{{- end }} + +### Hot Channels This Week + +{{ rankedTable .TopChannels "Channel" }} + +### Top Posters This Week + +{{ rankedTable .TopAuthors "Person" }} + +### Busiest Days This Month + +{{ rankedTable .BusiestDays "Day" }} + +### AI Field Notes + +{{- if .AISummary }} +{{ .AISummary }} +{{- else }} +_AI digest not generated in this run. The daily report job fills this in when ` + "`OPENAI_API_KEY`" + ` is configured._ +{{- end }} +`)) diff --git a/internal/report/report_test.go b/internal/report/report_test.go new file mode 100644 index 0000000..22715f7 --- /dev/null +++ b/internal/report/report_test.go @@ -0,0 +1,145 @@ +package report + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/steipete/discrawl/internal/store" +) + +func TestBuildRenderAndUpdateReadme(t *testing.T) { + ctx := context.Background() + s, err := store.Open(ctx, filepath.Join(t.TempDir(), "discrawl.db")) + require.NoError(t, err) + defer func() { _ = s.Close() }() + + now := time.Date(2026, 4, 21, 5, 0, 0, 0, time.UTC) + require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`})) + require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`})) + require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u1", Username: "peter", DisplayName: "Peter", RoleIDsJSON: `[]`, RawJSON: `{}`})) + require.NoError(t, s.UpsertMessages(ctx, []store.MessageMutation{{ + Record: store.MessageRecord{ + ID: "m1", + GuildID: "g1", + ChannelID: "c1", + ChannelName: "general", + AuthorID: "u1", + AuthorName: "Peter", + MessageType: 0, + CreatedAt: now.Add(-time.Hour).Format(time.RFC3339Nano), + Content: "shipping the snapshot report", + NormalizedContent: "shipping the snapshot report", + RawJSON: `{}`, + }, + }})) + + activity, err := Build(ctx, s, Options{Now: now}) + require.NoError(t, err) + require.Equal(t, 1, activity.TotalMessages) + require.Equal(t, "general", activity.TopChannels[0].Name) + require.Equal(t, "Peter", activity.TopAuthors[0].Name) + + section, err := RenderMarkdown(activity) + require.NoError(t, err) + require.Contains(t, section, "Discord Activity Report") + require.Contains(t, section, "Latest archived message: 2026-04-21 04:00 UTC") + + readme := UpdateReadme([]byte("# Backup\n"), section) + require.Contains(t, string(readme), StartMarker) + require.Contains(t, string(readme), EndMarker) + updated := UpdateReadme(readme, "## New\n") + require.Contains(t, string(updated), "## New") + require.NotContains(t, string(updated), "Latest archived message") +} + +func TestWriteReadmeCreatesFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "README.md") + require.NoError(t, WriteReadme(path, "## Report\n")) + data, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(data), "## Report") +} + +func TestExtractResponseText(t *testing.T) { + body := []byte(`{"output":[{"content":[{"type":"output_text","text":"hello"}]}]}`) + require.Equal(t, "hello", extractResponseText(body)) +} + +func TestGenerateAISummary(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want %s", r.Method, http.MethodPost) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("authorization = %q", got) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode body: %v", err) + return + } + if body["model"] != "test-model" { + t.Errorf("model = %v", body["model"]) + } + input, _ := body["input"].(string) + if !strings.Contains(input, "Top channels this week") { + t.Errorf("input missing top channels: %s", input) + } + _, _ = w.Write([]byte(`{"output":[{"content":[{"type":"output_text","text":"- Funny: ship it.\n- Trend: busy week.\n- Follow up: check support."}]}]}`)) + })) + defer server.Close() + t.Setenv("TEST_OPENAI_KEY", "test-key") + + summary, err := GenerateAISummary(context.Background(), ActivityReport{ + GeneratedAt: time.Date(2026, 4, 21, 5, 0, 0, 0, time.UTC), + LatestMessageAt: time.Date(2026, 4, 21, 4, 0, 0, 0, time.UTC), + TotalMessages: 10, + TotalChannels: 2, + TotalMembers: 3, + Windows: []WindowStats{{Label: "7 days", Messages: 10, ActiveAuthors: 2, ActiveChannels: 1, Attachments: 1}}, + TopChannels: []RankedCount{{Name: "general", Count: 10}}, + TopAuthors: []RankedCount{{Name: "Peter", Count: 5}}, + BusiestDays: []RankedCount{{Name: "2026-04-21", Count: 10}}, + RecentSamples: []MessageSample{{Channel: "general", Author: "Peter", Content: "good report"}}, + }, AIOptions{BaseURL: server.URL, APIKeyEnv: "TEST_OPENAI_KEY", Model: "test-model"}) + require.NoError(t, err) + require.Contains(t, summary, "Funny") +} + +func TestGenerateAISummaryMissingKey(t *testing.T) { + _, err := GenerateAISummary(context.Background(), ActivityReport{}, AIOptions{APIKeyEnv: "MISSING_TEST_OPENAI_KEY"}) + require.ErrorContains(t, err, "MISSING_TEST_OPENAI_KEY") +} + +func TestGenerateAISummaryRejectsBadStatusAndEmptyText(t *testing.T) { + t.Setenv("TEST_OPENAI_KEY", "test-key") + badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusTeapot) + })) + defer badStatus.Close() + _, err := GenerateAISummary(context.Background(), ActivityReport{}, AIOptions{BaseURL: badStatus.URL, APIKeyEnv: "TEST_OPENAI_KEY"}) + require.ErrorContains(t, err, "418") + + emptyText := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"output":[{"content":[]}]}`)) + })) + defer emptyText.Close() + _, err = GenerateAISummary(context.Background(), ActivityReport{}, AIOptions{BaseURL: emptyText.URL, APIKeyEnv: "TEST_OPENAI_KEY"}) + require.ErrorContains(t, err, "output text") +} + +func TestHelpersHandleEmptyAndLongContent(t *testing.T) { + require.Equal(t, "unknown", escapeMD("")) + require.Equal(t, "a\\|b", escapeMD("a|b")) + require.True(t, strings.HasSuffix(clipWhitespace(strings.Repeat("x", 300), 20), "...")) + require.Equal(t, "n/a", formatTime(time.Time{})) +}