discrawl/internal/report/trends.go
2026-05-05 10:07:56 +01:00

250 lines
6.5 KiB
Go

package report
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"time"
"github.com/openclaw/discrawl/internal/store"
)
const (
quietChannelKindPredicate = "c.kind in ('text', 'announcement')"
messageChannelKindPredicate = "c.kind in ('text', 'announcement', 'thread_public', 'thread_private', 'thread_announcement')"
)
// TrendsOptions controls how a Trends report is built.
type TrendsOptions struct {
Now time.Time
Weeks int
GuildID string
Channel string
}
// Trends summarizes week-over-week message volume per channel.
type Trends struct {
GeneratedAt time.Time `json:"generated_at"`
Since time.Time `json:"since"`
Until time.Time `json:"until"`
Weeks int `json:"weeks"`
Guild string `json:"guild,omitempty"`
Channel string `json:"channel,omitempty"`
Rows []TrendsRow `json:"rows"`
}
// TrendsRow is one channel's weekly message trend.
type TrendsRow struct {
GuildID string `json:"guild_id"`
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name"`
Kind string `json:"kind,omitempty"`
Weekly []WeeklyCount `json:"weekly"`
}
// WeeklyCount is the message count for one Monday-start UTC week.
type WeeklyCount struct {
WeekStart time.Time `json:"week_start"`
Messages int `json:"messages"`
}
// BuildTrends computes Monday-start UTC weekly message counts per channel.
func BuildTrends(ctx context.Context, s *store.Store, opts TrendsOptions) (Trends, error) {
now := opts.Now
if now.IsZero() {
now = time.Now().UTC()
}
now = now.UTC()
weeks := opts.Weeks
if weeks <= 0 {
weeks = 8
}
currentWeek := startOfWeekUTC(now)
since := currentWeek.AddDate(0, 0, -7*(weeks-1))
until := now
guildID := strings.TrimSpace(opts.GuildID)
channel := strings.TrimSpace(opts.Channel)
rows, err := trendsRows(ctx, s.DB(), since, until, weeks, guildID, channel)
if err != nil {
return Trends{}, err
}
return Trends{
GeneratedAt: now,
Since: since,
Until: until,
Weeks: weeks,
Guild: guildID,
Channel: channel,
Rows: rows,
}, nil
}
type trendChannel struct {
guildID string
channelID string
channelName string
kind string
}
func (c trendChannel) key() string {
return c.guildID + "\x00" + c.channelID
}
func trendsRows(ctx context.Context, db *sql.DB, since, until time.Time, weeks int, guildID, channel string) ([]TrendsRow, error) {
channels, err := listTrendChannels(ctx, db, guildID, channel)
if err != nil {
return nil, err
}
counts, err := listTrendCounts(ctx, db, since, until, guildID, channel)
if err != nil {
return nil, err
}
out := make([]TrendsRow, 0, len(channels))
for _, ch := range channels {
weekly := make([]WeeklyCount, 0, weeks)
for i := range weeks {
start := since.AddDate(0, 0, 7*i)
weekly = append(weekly, WeeklyCount{
WeekStart: start,
Messages: counts[ch.key()][start.Format(time.DateOnly)],
})
}
out = append(out, TrendsRow{
GuildID: ch.guildID,
ChannelID: ch.channelID,
ChannelName: ch.channelName,
Kind: ch.kind,
Weekly: weekly,
})
}
sort.SliceStable(out, func(i, j int) bool {
it := weeklyTotal(out[i].Weekly)
jt := weeklyTotal(out[j].Weekly)
if it != jt {
return it > jt
}
if out[i].ChannelName != out[j].ChannelName {
return out[i].ChannelName < out[j].ChannelName
}
if out[i].GuildID != out[j].GuildID {
return out[i].GuildID < out[j].GuildID
}
return out[i].ChannelID < out[j].ChannelID
})
return out, nil
}
func listTrendChannels(ctx context.Context, db *sql.DB, guildID, channel string) ([]trendChannel, error) {
query := &strings.Builder{}
query.WriteString(`
select
c.guild_id,
c.id,
coalesce(nullif(c.name, ''), c.id) as channel_name,
coalesce(c.kind, '') as kind
from channels c
where ` + messageChannelKindPredicate + `
`)
args := make([]any, 0, 3)
if guildID != "" {
query.WriteString(" and c.guild_id = ?\n")
args = append(args, guildID)
}
if channel != "" {
query.WriteString(" and (c.id = ? or c.name = ?)\n")
args = append(args, channel, channel)
}
query.WriteString("order by channel_name asc, c.guild_id asc, c.id asc\n")
rows, err := db.QueryContext(ctx, query.String(), args...)
if err != nil {
return nil, fmt.Errorf("trends channels query: %w", err)
}
defer func() { _ = rows.Close() }()
out := make([]trendChannel, 0)
for rows.Next() {
var row trendChannel
if err := rows.Scan(&row.guildID, &row.channelID, &row.channelName, &row.kind); err != nil {
return nil, fmt.Errorf("trends channels scan: %w", err)
}
out = append(out, row)
}
return out, rows.Err()
}
func listTrendCounts(ctx context.Context, db *sql.DB, since, until time.Time, guildID, channel string) (map[string]map[string]int, error) {
query := &strings.Builder{}
query.WriteString(`
select
m.guild_id,
m.channel_id,
date(m.created_at, '-' || ((cast(strftime('%w', m.created_at) as integer) + 6) % 7) || ' days') as week_start,
count(*) as messages
from messages m
left join channels c on c.id = m.channel_id and c.guild_id = m.guild_id
where m.created_at >= ?
and m.created_at < ?
`)
args := []any{since.UTC().Format(time.RFC3339Nano), until.UTC().Format(time.RFC3339Nano)}
if guildID != "" {
query.WriteString(" and m.guild_id = ?\n")
args = append(args, guildID)
}
if channel != "" {
query.WriteString(" and (m.channel_id = ? or c.name = ?)\n")
args = append(args, channel, channel)
}
query.WriteString(`group by m.guild_id, m.channel_id, week_start
order by m.guild_id asc, m.channel_id asc, week_start asc
`)
rows, err := db.QueryContext(ctx, query.String(), args...)
if err != nil {
return nil, fmt.Errorf("trends counts query: %w", err)
}
defer func() { _ = rows.Close() }()
out := make(map[string]map[string]int)
for rows.Next() {
var guildID, channelID, weekStart string
var messages int
if err := rows.Scan(&guildID, &channelID, &weekStart, &messages); err != nil {
return nil, fmt.Errorf("trends counts scan: %w", err)
}
key := guildID + "\x00" + channelID
if out[key] == nil {
out[key] = make(map[string]int)
}
out[key][weekStart] = messages
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
func startOfWeekUTC(t time.Time) time.Time {
t = t.UTC()
dayStart := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
offset := (int(dayStart.Weekday()) + 6) % 7
return dayStart.AddDate(0, 0, -offset)
}
func weeklyTotal(weekly []WeeklyCount) int {
total := 0
for _, w := range weekly {
total += w.Messages
}
return total
}