package cli import ( "encoding/json" "errors" "fmt" "io" "sort" "strconv" "strings" "text/tabwriter" "time" "github.com/openclaw/discrawl/internal/discorddesktop" "github.com/openclaw/discrawl/internal/report" "github.com/openclaw/discrawl/internal/store" "github.com/openclaw/discrawl/internal/syncer" ) func (r *runtime) print(value any) error { if r.json { enc := json.NewEncoder(r.stdout) enc.SetIndent("", " ") return enc.Encode(value) } if r.plain { if err := printPlain(r.stdout, value); err == nil { return nil } } if err := printHuman(r.stdout, value); err == nil { return nil } enc := json.NewEncoder(r.stdout) enc.SetIndent("", " ") return enc.Encode(value) } func printPlain(w io.Writer, value any) error { switch v := value.(type) { case []store.SearchResult: for _, row := range v { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", row.GuildID, row.ChannelID, row.AuthorID, row.Content) } return nil case []store.MemberRow: for _, row := range v { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", row.GuildID, row.UserID, row.Username) } return nil case store.MemberProfile: _, _ = fmt.Fprintf(w, "%s\t%s\t%s\n", v.Member.GuildID, v.Member.UserID, v.Member.Username) return nil case []store.ChannelRow: for _, row := range v { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", row.GuildID, row.ID, row.Kind, row.Name) } return nil case []store.MessageRow: for _, row := range v { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", formatTime(row.CreatedAt), row.GuildID, row.ChannelID, row.AuthorID, row.MessageID, row.Content) } return nil case []store.DirectMessageConversationRow: for _, row := range v { _, _ = fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\t%s\n", row.ChannelID, row.Name, row.MessageCount, row.AuthorCount, formatTime(row.FirstMessageAt), formatTime(row.LastMessageAt)) } return nil case []store.MentionRow: for _, row := range v { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", formatTime(row.CreatedAt), row.GuildID, row.ChannelID, row.AuthorID, row.TargetType, row.TargetID, row.Content) } return nil case report.Digest: for _, row := range v.Channels { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%d\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.Messages, row.Replies, row.ActiveAuthors) } return nil case report.Quiet: for _, row := range v.Channels { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", row.ChannelID, row.ChannelName, row.Kind, row.GuildID, row.LastMessage, row.DaysSilent) } return nil case report.Trends: for _, row := range v.Rows { for _, week := range row.Weekly { _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%d\n", row.GuildID, row.ChannelID, row.ChannelName, row.Kind, formatTime(week.WeekStart), week.Messages) } } return nil default: return errors.New("no plain printer") } } func printUsage(w io.Writer) { _, _ = fmt.Fprint(w, `discrawl archives Discord guild data into local SQLite. Usage: discrawl [global flags] [args] Commands: metadata version init sync tail tap cache-import wiretap search tui messages digest analytics dms mentions embed sql members channels status report doctor `) } func printRows(w io.Writer, cols []string, rows [][]string) error { tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) _, _ = fmt.Fprintln(tw, strings.Join(cols, "\t")) for _, row := range rows { _, _ = fmt.Fprintln(tw, strings.Join(row, "\t")) } return tw.Flush() } func printHuman(w io.Writer, value any) error { switch v := value.(type) { case syncRunStats: if _, err := fmt.Fprintf(w, "source=%s\n", v.Source); err != nil { return err } if v.Discord != nil { if _, err := fmt.Fprintf(w, "discord_guilds=%d\ndiscord_channels=%d\ndiscord_threads=%d\ndiscord_members=%d\ndiscord_messages=%d\n", v.Discord.Guilds, v.Discord.Channels, v.Discord.Threads, v.Discord.Members, v.Discord.Messages); err != nil { return err } } if v.Wiretap != nil { if _, err := fmt.Fprintf(w, "wiretap_visited=%d\nwiretap_files=%d\nwiretap_unchanged=%d\nwiretap_fast_skipped=%d\nwiretap_messages=%d\nwiretap_dm_messages=%d\nwiretap_dm_channels=%d\nwiretap_guild_messages=%d\nwiretap_skipped_messages=%d\nwiretap_skipped_channels=%d\nwiretap_checkpoints=%d\n", v.Wiretap.FilesVisited, v.Wiretap.FilesScanned, v.Wiretap.FilesUnchanged, v.Wiretap.CacheFilesFastSkipped, v.Wiretap.Messages, v.Wiretap.DMMessages, v.Wiretap.DMChannels, v.Wiretap.GuildMessages, v.Wiretap.SkippedMessages, v.Wiretap.SkippedChannels, v.Wiretap.Checkpoints); err != nil { return err } } return nil case syncer.SyncStats: _, err := fmt.Fprintf(w, "guilds=%d channels=%d threads=%d members=%d messages=%d\n", v.Guilds, v.Channels, v.Threads, v.Members, v.Messages) return err case discorddesktop.Stats: _, err := fmt.Fprintf(w, "path=%s\nvisited=%d\nfiles=%d\nskipped=%d\nunchanged=%d\nfast_skipped=%d\nobjects=%d\nguilds=%d\nchannels=%d\nmessages=%d\ndm_messages=%d\ndm_channels=%d\nguild_messages=%d\nskipped_messages=%d\nskipped_channels=%d\ncheckpoints=%d\nfull_cache=%t\ndry_run=%t\n", v.Path, v.FilesVisited, v.FilesScanned, v.FilesSkipped, v.FilesUnchanged, v.CacheFilesFastSkipped, v.JSONObjects, v.Guilds, v.Channels, v.Messages, v.DMMessages, v.DMChannels, v.GuildMessages, v.SkippedMessages, v.SkippedChannels, v.Checkpoints, v.FullCache, v.DryRun) return err case store.Status: _, err := fmt.Fprintf(w, "db=%s\nguilds=%d\nchannels=%d\nthreads=%d\nmessages=%d\nmembers=%d\nembedding_backlog=%d\nlast_sync=%s\nlast_tail_event=%s\n", v.DBPath, v.GuildCount, v.ChannelCount, v.ThreadCount, v.MessageCount, v.MemberCount, v.EmbeddingBacklog, formatTime(v.LastSyncAt), formatTime(v.LastTailEventAt)) return err case store.EmbeddingDrainStats: _, err := fmt.Fprintf(w, "processed=%d\nsucceeded=%d\nfailed=%d\nskipped=%d\nremaining_backlog=%d\nprovider=%s\nmodel=%s\ninput_version=%s\n", v.Processed, v.Succeeded, v.Failed, v.Skipped, v.RemainingBacklog, v.Provider, v.Model, v.InputVersion) if err != nil { return err } if v.Requeued > 0 { if _, err := fmt.Fprintf(w, "requeued=%d\n", v.Requeued); err != nil { return err } } if v.RateLimited { _, err = fmt.Fprintln(w, "rate_limited=true") } return err case []store.SearchResult: for _, row := range v { if _, err := fmt.Fprintf(w, "[%s/%s] %s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, formatTime(row.CreatedAt), row.Content); err != nil { return err } } return nil case []store.MessageRow: for _, row := range v { if _, err := fmt.Fprintf(w, "[%s/%s] %s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, formatTime(row.CreatedAt), row.Content); err != nil { return err } } return nil case []store.DirectMessageConversationRow: tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) _, _ = fmt.Fprintln(tw, "CHANNEL\tNAME\tMESSAGES\tAUTHORS\tFIRST\tLAST") for _, row := range v { _, _ = fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%s\t%s\n", row.ChannelID, row.Name, row.MessageCount, row.AuthorCount, formatTime(row.FirstMessageAt), formatTime(row.LastMessageAt), ) } return tw.Flush() case []store.MentionRow: for _, row := range v { if _, err := fmt.Fprintf(w, "[%s/%s] %s -> %s:%s %s\n%s\n\n", row.GuildID, row.ChannelName, row.AuthorName, row.TargetType, firstNonEmpty(row.TargetName, row.TargetID), formatTime(row.CreatedAt), row.Content); err != nil { return err } } return nil case []store.MemberRow: tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) _, _ = fmt.Fprintln(tw, "GUILD\tUSER\tNAME\tDISPLAY\tPROFILE") for _, row := range v { _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", row.GuildID, row.UserID, row.Username, firstNonEmpty(row.DisplayName, row.Nick, row.GlobalName), memberProfileSummary(row), ) } return tw.Flush() case store.MemberProfile: if _, err := fmt.Fprintf(w, "guild=%s\nuser=%s\nusername=%s\ndisplay=%s\njoined=%s\nbot=%t\n", v.Member.GuildID, v.Member.UserID, v.Member.Username, firstNonEmpty(v.Member.DisplayName, v.Member.Nick, v.Member.GlobalName), formatTime(v.Member.JoinedAt), v.Member.Bot, ); err != nil { return err } if v.Member.XHandle != "" { if _, err := fmt.Fprintf(w, "x=%s\n", v.Member.XHandle); err != nil { return err } } if v.Member.GitHubLogin != "" { if _, err := fmt.Fprintf(w, "github=%s\n", v.Member.GitHubLogin); err != nil { return err } } if v.Member.Website != "" { if _, err := fmt.Fprintf(w, "website=%s\n", v.Member.Website); err != nil { return err } } if v.Member.Pronouns != "" { if _, err := fmt.Fprintf(w, "pronouns=%s\n", v.Member.Pronouns); err != nil { return err } } if v.Member.Location != "" { if _, err := fmt.Fprintf(w, "location=%s\n", v.Member.Location); err != nil { return err } } if v.Member.Bio != "" { if _, err := fmt.Fprintf(w, "bio=%s\n", v.Member.Bio); err != nil { return err } } if len(v.Member.URLs) > 0 { if _, err := fmt.Fprintf(w, "urls=%s\n", strings.Join(v.Member.URLs, ", ")); err != nil { return err } } if _, err := fmt.Fprintf(w, "message_count=%d\nfirst_message=%s\nlast_message=%s\n", v.MessageCount, formatTime(v.FirstMessageAt), formatTime(v.LastMessageAt), ); err != nil { return err } if len(v.RecentMessages) == 0 { return nil } if _, err := fmt.Fprintln(w, "\nRecent messages:"); err != nil { return err } for _, row := range v.RecentMessages { if _, err := fmt.Fprintf(w, "[%s] %s\n%s\n\n", row.ChannelName, formatTime(row.CreatedAt), row.Content); err != nil { return err } } return nil case []store.ChannelRow: tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) _, _ = fmt.Fprintln(tw, "GUILD\tCHANNEL\tKIND\tNAME") for _, row := range v { _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.GuildID, row.ID, row.Kind, row.Name) } return tw.Flush() case report.Digest: for _, channel := range v.Channels { if _, err := fmt.Fprintf(w, "%s (%s)\n", channel.ChannelName, firstNonEmpty(channel.Kind, "unknown")); err != nil { return err } if _, err := fmt.Fprintf(w, " messages=%d replies=%d authors=%d\n", channel.Messages, channel.Replies, channel.ActiveAuthors); err != nil { return err } if _, err := fmt.Fprintf(w, " top posters %s\n", formatRankedCounts(channel.TopPosters)); err != nil { return err } if _, err := fmt.Fprintf(w, " top mentions %s\n\n", formatRankedCounts(channel.TopMentions)); err != nil { return err } } if _, err := fmt.Fprintf(w, "Window: %s to %s (%s)\n", formatTime(v.Since), formatTime(v.Until), v.WindowLabel); err != nil { return err } _, err := fmt.Fprintf(w, "Totals: messages=%d replies=%d channels=%d authors=%d\n", v.Totals.Messages, v.Totals.Replies, v.Totals.Channels, v.Totals.ActiveAuthors) return err case report.Quiet: tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) _, _ = fmt.Fprintln(tw, "CHANNEL\tKIND\tLAST MESSAGE\tDAYS SILENT") for _, row := range v.Channels { _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", row.ChannelName, firstNonEmpty(row.Kind, "unknown"), firstNonEmpty(row.LastMessage, "never"), formatDaysSilent(row.DaysSilent), ) } if err := tw.Flush(); err != nil { return err } if _, err := fmt.Fprintf(w, "\nWindow: %s to %s (%s)\n", formatTime(v.Since), formatTime(v.Until), formatWindowDuration(v.Until.Sub(v.Since))); err != nil { return err } _, err := fmt.Fprintf(w, "Totals: channels=%d\n", v.Totals.Channels) return err case report.Trends: tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) header := []string{"CHANNEL", "KIND", "TOTAL"} weekStarts := make([]time.Time, 0, v.Weeks) if len(v.Rows) > 0 { for _, week := range v.Rows[0].Weekly { weekStarts = append(weekStarts, week.WeekStart) } } else { for i := range v.Weeks { weekStarts = append(weekStarts, v.Since.AddDate(0, 0, 7*i)) } } for _, start := range weekStarts { header = append(header, start.Format(time.DateOnly)) } _, _ = fmt.Fprintln(tw, strings.Join(header, "\t")) for _, row := range v.Rows { cols := []string{row.ChannelName, firstNonEmpty(row.Kind, "unknown"), strconv.Itoa(trendsRowTotal(row.Weekly))} for _, week := range row.Weekly { cols = append(cols, strconv.Itoa(week.Messages)) } _, _ = fmt.Fprintln(tw, strings.Join(cols, "\t")) } if err := tw.Flush(); err != nil { return err } _, err := fmt.Fprintf(w, "\nWindow: %s to %s (%d weeks)\n", formatTime(v.Since), formatTime(v.Until), v.Weeks) return err case map[string]any: keys := make([]string, 0, len(v)) for key := range v { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { if _, err := fmt.Fprintf(w, "%s=%v\n", key, v[key]); err != nil { return err } } return nil default: return errors.New("no human printer") } } func formatTime(t time.Time) string { if t.IsZero() { return "" } return t.Format(time.RFC3339) } func memberProfileSummary(row store.MemberRow) string { parts := []string{} if row.XHandle != "" { parts = append(parts, "x:"+row.XHandle) } if row.GitHubLogin != "" { parts = append(parts, "gh:"+row.GitHubLogin) } if row.Website != "" { parts = append(parts, row.Website) } if row.Bio != "" { parts = append(parts, trimForTable(row.Bio)) } return strings.Join(parts, " | ") } func trimForTable(value string) string { value = strings.TrimSpace(value) if len(value) <= 40 { return value } return value[:37] + "..." } func formatRankedCounts(rows []report.RankedCount) string { if len(rows) == 0 { return "-" } parts := make([]string, 0, len(rows)) for _, row := range rows { parts = append(parts, fmt.Sprintf("%s (%d)", firstNonEmpty(row.Name, "unknown"), row.Count)) } return strings.Join(parts, ", ") } func formatDaysSilent(days int) string { if days < 0 { return "-" } return strconv.Itoa(days) } func formatWindowDuration(d time.Duration) string { if d <= 0 { return "0" } if d%(24*time.Hour) == 0 { return fmt.Sprintf("%dd", int(d/(24*time.Hour))) } if d%time.Hour == 0 { return fmt.Sprintf("%dh", int(d/time.Hour)) } return d.String() } func trendsRowTotal(weekly []report.WeeklyCount) int { total := 0 for _, row := range weekly { total += row.Messages } return total }