feat: publish discord activity report
This commit is contained in:
parent
0dba56d0a0
commit
7825ca0edc
57
.github/workflows/discord-backup-report.yml
vendored
Normal file
57
.github/workflows/discord-backup-report.yml
vendored
Normal 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
|
||||
6
.github/workflows/publish-discord-backup.yml
vendored
6
.github/workflows/publish-discord-backup.yml
vendored
@ -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
|
||||
|
||||
13
README.md
13
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.
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -84,6 +84,7 @@ Commands:
|
||||
members
|
||||
channels
|
||||
status
|
||||
report
|
||||
doctor
|
||||
`)
|
||||
}
|
||||
|
||||
53
internal/cli/report_commands.go
Normal file
53
internal/cli/report_commands.go
Normal 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
|
||||
}
|
||||
@ -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
133
internal/report/ai.go
Normal 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
393
internal/report/report.go
Normal 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 }}
|
||||
`))
|
||||
145
internal/report/report_test.go
Normal file
145
internal/report/report_test.go
Normal 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{}))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user