feat: publish discord activity report

This commit is contained in:
Peter Steinberger 2026-04-21 05:38:38 +01:00
parent 0dba56d0a0
commit 7825ca0edc
No known key found for this signature in database
11 changed files with 819 additions and 3 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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":

View File

@ -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")

View File

@ -84,6 +84,7 @@ Commands:
members
channels
status
report
doctor
`)
}

View File

@ -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
}

View File

@ -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,
})

133
internal/report/ai.go Normal file
View File

@ -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")
}

393
internal/report/report.go Normal file
View File

@ -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 = "<!-- discrawl-report:start -->"
EndMarker = "<!-- discrawl-report:end -->"
)
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 }}
`))

View File

@ -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{}))
}