@@ -19,6 +19,7 @@ import (
1919
2020 "github.com/codeGROOVE-dev/discordian/internal/bot"
2121 "github.com/codeGROOVE-dev/discordian/internal/config"
22+ "github.com/codeGROOVE-dev/discordian/internal/dailyreport"
2223 "github.com/codeGROOVE-dev/discordian/internal/discord"
2324 "github.com/codeGROOVE-dev/discordian/internal/format"
2425 "github.com/codeGROOVE-dev/discordian/internal/github"
@@ -185,23 +186,23 @@ func (ca *configAdapter) Config(org string) (usermapping.OrgConfig, bool) {
185186
186187// coordinatorManager manages bot coordinators for multiple GitHub orgs.
187188type coordinatorManager struct {
188- cfg config.ServerConfig
189- githubManager * github.Manager
190- configManager * config.Manager
191- guildManager * discord.GuildManager
189+ startTime time.Time
192190 store state.Store
191+ slashHandlers map [string ]* discord.SlashCommandHandler
192+ discordClients map [string ]* discord.Client
193+ lastEventTime map [string ]time.Time
193194 notifyMgr * notify.Manager
194- reverseMapper * usermapping.ReverseMapper // Discord ID -> GitHub username
195- active map [string ]context.CancelFunc // org -> cancel func
196- failed map [ string ]time. Time // org -> last failure time
197- discordClients map [ string ] * discord. Client // guildID -> client
198- slashHandlers map [string ]* discord. SlashCommandHandler // guildID -> handler
199- coordinators map [ string ] * bot. Coordinator // org -> coordinator
200- startTime time. Time
201- lastEventTime map [ string ]time. Time // org -> last event time
202- dmsSent int64 // Total DMs sent since start
203- dailyReports int64 // Total daily reports sent since start
204- channelMsgs int64 // Total channel messages sent since start
195+ reverseMapper * usermapping.ReverseMapper
196+ active map [string ]context.CancelFunc
197+ guildManager * discord. GuildManager
198+ failed map [ string ]time. Time
199+ coordinators map [string ]* bot. Coordinator
200+ configManager * config. Manager
201+ githubManager * github. Manager
202+ cfg config. ServerConfig
203+ dmsSent int64
204+ dailyReports int64
205+ channelMsgs int64
205206 mu sync.Mutex
206207}
207208
@@ -390,7 +391,7 @@ func (cm *coordinatorManager) startSingleCoordinator(ctx context.Context, org st
390391 "sprinkler_url" , sprinklerURL )
391392
392393 // Create sprinkler client with token provider (will fetch fresh tokens automatically)
393- sprinklerClient , err := bot .NewSprinklerClient (bot.SprinklerConfig {
394+ sprinklerClient , err := bot .NewSprinklerClient (orgCtx , bot.SprinklerConfig {
394395 ServerURL : sprinklerURL ,
395396 TokenProvider : tokenProvider ,
396397 Organization : org ,
@@ -480,6 +481,7 @@ func (cm *coordinatorManager) discordClientForGuild(_ context.Context, guildID s
480481 slashHandler .SetReportGetter (cm )
481482 slashHandler .SetUserMapGetter (cm )
482483 slashHandler .SetChannelMapGetter (cm )
484+ slashHandler .SetDailyReportGetter (cm )
483485
484486 // Register slash commands with Discord
485487 if err := slashHandler .RegisterCommands (guildID ); err != nil {
@@ -772,6 +774,180 @@ func (cm *coordinatorManager) Report(ctx context.Context, guildID, userID string
772774 }, nil
773775}
774776
777+ // DailyReport implements discord.DailyReportGetter interface.
778+ func (cm * coordinatorManager ) DailyReport (ctx context.Context , guildID , userID string , force bool ) (* discord.DailyReportDebug , error ) {
779+ slog .Info ("daily report requested" ,
780+ "guild_id" , guildID ,
781+ "user_id" , userID ,
782+ "force" , force )
783+
784+ cm .mu .Lock ()
785+ defer cm .mu .Unlock ()
786+
787+ // Find orgs for this guild
788+ var orgsForGuild []string
789+ for org := range cm .active {
790+ cfg , exists := cm .configManager .Config (org )
791+ if exists && cfg .Global .GuildID == guildID {
792+ orgsForGuild = append (orgsForGuild , org )
793+ }
794+ }
795+
796+ if len (orgsForGuild ) == 0 {
797+ return nil , fmt .Errorf ("no org found for guild %s" , guildID )
798+ }
799+
800+ // Find GitHub username
801+ var githubUsername string
802+ for _ , org := range orgsForGuild {
803+ coord , exists := cm .coordinators [org ]
804+ if ! exists {
805+ continue
806+ }
807+ forwardCache := coord .ExportUserMapperCache ()
808+ for ghUser , discordID := range forwardCache {
809+ if discordID == userID {
810+ githubUsername = ghUser
811+ break
812+ }
813+ }
814+ if githubUsername != "" {
815+ break
816+ }
817+ }
818+
819+ // Try reverse mapper if not found
820+ if githubUsername == "" {
821+ githubUsername = cm .reverseMapper .GitHubUsername (ctx , userID , & configAdapter {cm .configManager }, orgsForGuild )
822+ }
823+
824+ if githubUsername == "" {
825+ return nil , errors .New ("no GitHub username mapping found for Discord user" )
826+ }
827+
828+ // Get Discord client for this guild
829+ discordClient , exists := cm .discordClients [guildID ]
830+ if ! exists {
831+ return nil , fmt .Errorf ("no Discord client for guild %s" , guildID )
832+ }
833+
834+ // Check if user is currently online
835+ isOnline := discordClient .IsUserActive (ctx , userID )
836+
837+ // Get daily report state
838+ reportInfo , _ := cm .store .DailyReportInfo (ctx , userID )
839+ now := time .Now ()
840+
841+ // Calculate timing info
842+ hoursSinceLastSent := float64 (0 )
843+ if ! reportInfo .LastSentAt .IsZero () {
844+ hoursSinceLastSent = now .Sub (reportInfo .LastSentAt ).Hours ()
845+ }
846+
847+ nextEligibleAt := reportInfo .LastSentAt .Add (20 * time .Hour )
848+
849+ // Search for PRs (same as Report method)
850+ var incomingPRs []discord.PRSummary
851+ var outgoingPRs []discord.PRSummary
852+
853+ searcher := github .NewSearcher (cm .githubManager .AppClient (), slog .Default ())
854+
855+ for _ , org := range orgsForGuild {
856+ client , exists := cm .githubManager .ClientForOrg (org )
857+ if ! exists {
858+ continue
859+ }
860+
861+ turn := bot .NewTurnClient (cm .cfg .TurnURL , client )
862+
863+ // Search authored PRs
864+ authored , err := searcher .ListAuthoredPRs (ctx , org , githubUsername )
865+ if err == nil {
866+ for _ , pr := range authored {
867+ summary := analyzePRForReport (ctx , pr , githubUsername , turn )
868+ if summary != nil {
869+ outgoingPRs = append (outgoingPRs , * summary )
870+ }
871+ }
872+ }
873+
874+ // Search review-requested PRs
875+ review , err := searcher .ListReviewRequestedPRs (ctx , org , githubUsername )
876+ if err == nil {
877+ for _ , pr := range review {
878+ summary := analyzePRForReport (ctx , pr , githubUsername , turn )
879+ if summary != nil {
880+ incomingPRs = append (incomingPRs , * summary )
881+ }
882+ }
883+ }
884+ }
885+
886+ // Determine eligibility
887+ eligible := true
888+ reason := "All conditions met"
889+ reportSent := false
890+
891+ // Check conditions
892+ switch {
893+ case len (incomingPRs ) == 0 && len (outgoingPRs ) == 0 :
894+ eligible = false
895+ reason = "No PRs found for user"
896+ case ! force && hoursSinceLastSent < 20 :
897+ eligible = false
898+ reason = fmt .Sprintf ("Rate limited: only %.1f hours since last report (need 20)" , hoursSinceLastSent )
899+ case ! force && ! isOnline :
900+ eligible = false
901+ reason = "User is offline"
902+ default :
903+ // eligible and reason already set
904+ }
905+
906+ // Send report if forced or eligible
907+ if force || eligible {
908+ // Create daily report sender
909+ sender := dailyreport .NewSender (cm .store , slog .Default ())
910+
911+ // Register guild DM sender
912+ sender .RegisterGuild (guildID , discordClient )
913+
914+ userInfo := dailyreport.UserBlockingInfo {
915+ GitHubUsername : githubUsername ,
916+ DiscordUserID : userID ,
917+ GuildID : guildID ,
918+ IncomingPRs : incomingPRs ,
919+ OutgoingPRs : outgoingPRs ,
920+ }
921+
922+ // Send the report
923+ if err := sender .SendReport (ctx , userInfo ); err != nil {
924+ slog .Error ("failed to send daily report" ,
925+ "error" , err ,
926+ "github_user" , githubUsername ,
927+ "discord_id" , userID )
928+ reason = fmt .Sprintf ("Failed to send: %s" , err )
929+ } else {
930+ reportSent = true
931+ reason = "Report sent successfully"
932+ cm .dailyReports ++
933+ }
934+ }
935+
936+ return & discord.DailyReportDebug {
937+ UserOnline : isOnline ,
938+ LastSentAt : reportInfo .LastSentAt ,
939+ NextEligibleAt : nextEligibleAt ,
940+ LastSeenActiveAt : time.Time {}, // Not tracked yet
941+ HoursSinceLastSent : hoursSinceLastSent ,
942+ MinutesActive : 0 , // Not tracked yet
943+ Eligible : eligible ,
944+ Reason : reason ,
945+ IncomingPRCount : len (incomingPRs ),
946+ OutgoingPRCount : len (outgoingPRs ),
947+ ReportSent : reportSent ,
948+ }, nil
949+ }
950+
775951// UserMappings implements discord.UserMapGetter interface.
776952func (cm * coordinatorManager ) UserMappings (ctx context.Context , guildID string ) (* discord.UserMappings , error ) {
777953 slog .Info ("user mappings requested" ,
0 commit comments