From 67ed5589dba4770638018242d4521be673172f3f Mon Sep 17 00:00:00 2001 From: jameswst <67979826+mutilis@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:14:16 +0100 Subject: [PATCH 1/3] feat(admin_logs): add contract summary to guild-specific admin logs channel --- src/boost/boost_admin.go | 289 +++++++++++++++++++++++++++- src/boost/boost_button_reactions.go | 5 + src/boost/boost_menu.go | 33 ++++ 3 files changed, 321 insertions(+), 6 deletions(-) diff --git a/src/boost/boost_admin.go b/src/boost/boost_admin.go index 0da1e38f..0574830d 100644 --- a/src/boost/boost_admin.go +++ b/src/boost/boost_admin.go @@ -297,16 +297,17 @@ func getContractList(guildID string) (string, *discordgo.MessageSend, error) { continue } } - str := fmt.Sprintf("> Coordinator: <@%s> [%s](%s/%s/%s)\n", c.CreatorID[0], c.CoopID, "https://eicoop-carpet.netlify.app", c.ContractID, c.CoopID) + var str strings.Builder + fmt.Fprintf(&str, "> Coordinator: <@%s> [%s](%s/%s/%s)\n", c.CreatorID[0], c.CoopID, "https://eicoop-carpet.netlify.app", c.ContractID, c.CoopID) for _, loc := range c.Location { - str += fmt.Sprintf("> *%s*\t%s\n", loc.GuildName, loc.ChannelMention) + fmt.Fprintf(&str, "> *%s*\t%s\n", loc.GuildName, loc.ChannelMention) } - str += fmt.Sprintf("> Started: \n", c.StartTime.Unix()) - str += fmt.Sprintf("> Contract State: *%s*\n", contractStateNames[c.State]) - str += fmt.Sprintf("> Hash: *%s*\n", c.ContractHash) + fmt.Fprintf(&str, "> Started: \n", c.StartTime.Unix()) + fmt.Fprintf(&str, "> Contract State: *%s*\n", contractStateNames[c.State]) + fmt.Fprintf(&str, "> Hash: *%s*\n", c.ContractHash) field = append(field, &discordgo.MessageEmbedField{ Name: fmt.Sprintf("%d - **%s/%s**\n", i, c.ContractID, c.CoopID), - Value: str, + Value: str.String(), Inline: false, }) i++ @@ -476,3 +477,279 @@ func HandleAdminGetContractData(s *discordgo.Session, i *discordgo.InteractionCr }, }) } + +// adminContractReportJSON holds full admin log contract report for admins +type adminContractReportJSON struct { + CoordinatorID string `json:"coordinator_id,omitempty"` + GuildName string `json:"guild_name"` + GuildID string `json:"guild_id"` + ChannelID string `json:"channel_id"` + ChannelURL string `json:"channel_url"` + ContractHash string `json:"contract_hash"` + ContractID string `json:"contract_id"` + CoopID string `json:"coop_id"` + RunType string `json:"run_type"` + GGType string `json:"gg_type"` + ContractSize int64 `json:"contract_size"` + StartTimestamp int64 `json:"start_timestamp"` + RoleName string `json:"role_name"` + Members []adminContractReportMember `json:"members"` +} + +// adminContractReportMember represents a single booster in the contract report. +type adminContractReportMember struct { + UserID string `json:"user_id"` + Nick string `json:"nick"` + JoinedUnix int64 `json:"joined_unix,omitempty"` +} + +// AdminContractReport sends a contract summary plus a JSON attachment containing +func AdminContractReport(s *discordgo.Session, i *discordgo.InteractionCreate, contract *Contract, targetChannelID string) { + if contract == nil { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "No contract found.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Respond immediately to buy some time for processing and to avoid interaction timeout + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Processing Request...", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + // Get contract data from the list of all contract + eiContract, ok := ei.EggIncContractsAll[contract.ContractID] + if !ok { // ContractID not set + eiContract.MaxCoopSize = len(contract.Boosters) + } + + // Determine run type based on contract valid from date + runType := "Unknown" + validFrom := eiContract.ValidFrom + if !validFrom.IsZero() { + switch validFrom.Weekday() { + case time.Monday: + runType = "Seasonal" + case time.Wednesday: + runType = "Wednesday Leggacy" + case time.Friday: + if eiContract.Ultra { + runType = "Ultra PE Leggacy" + } else { + runType = "Non-ultra PE Leggacy" + } + } + } + + coordinatorID := contract.CreatorID[0] + + // Carpet URL for summary view + carpetURL := fmt.Sprintf("https://eicoop-carpet.netlify.app/%s/%s", contract.ContractID, contract.CoopID) + + // Set contract Start time + startTime := contract.StartTime + if !contract.ActualStartTime.IsZero() { + startTime = contract.ActualStartTime + } + + // Determine whether if GG was active at the start of the contract + ggType := "Non-GG" + if !startTime.IsZero() { + ggEvent := ei.FindGiftEvent(startTime) + if ggEvent.EventType != "" { + if ggEvent.Ultra { + ggType = "Ultra-GG" + } else { + ggType = "GG" + } + } + } + + // Build the list of members sorted by join date + type boosterEntry struct { + userID string + booster *Booster + } + + entries := make([]boosterEntry, 0, len(contract.Boosters)) + for userID, booster := range contract.Boosters { + if booster != nil { + entries = append(entries, boosterEntry{userID, booster}) + } + } + // Sort by join date + sort.Slice(entries, func(i, j int) bool { + return entries[i].booster.Register.Before(entries[j].booster.Register) + }) + + reportMembers := make([]adminContractReportMember, 0, len(entries)) + summaryMemberLines := make([]string, 0, len(entries)) + for _, entry := range entries { + // Use nickname if available, fallback to userID + nick := entry.booster.Nick + if nick == "" { + nick = entry.userID + } + // Joined timestamp for the current booster + joinedUnix := int64(0) + if !entry.booster.Register.IsZero() { + joinedUnix = entry.booster.Register.Unix() + } + + // Build JSON object + member := adminContractReportMember{ + UserID: entry.userID, + Nick: nick, + JoinedUnix: joinedUnix, + } + + reportMembers = append(reportMembers, member) + summaryMemberLines = append(summaryMemberLines, + fmt.Sprintf("%s %s (`%s`) joined: %s", + member.Nick, + entry.booster.Mention, + member.UserID, + bottools.WrapTimestamp(member.JoinedUnix, bottools.TimestampShortTime), + ), + ) + } + + // Guild and channel info for the contract + loc := LocationData{ + GuildName: "Unknown", + GuildID: "Unknown", + ChannelID: "Unknown", + GuildContractRole: discordgo.Role{Name: "Unknown"}, + } + if len(contract.Location) > 0 && contract.Location[0] != nil { + loc = *contract.Location[0] + } + + // Generate a link to contract thread + channelURL := "" + if loc.GuildID != "" && loc.ChannelID != "" { + channelURL = fmt.Sprintf("https://discord.com/channels/%s/%s", loc.GuildID, loc.ChannelID) + } + + reportJSON := adminContractReportJSON{ + CoordinatorID: coordinatorID, + GuildName: loc.GuildName, + GuildID: loc.GuildID, + ChannelID: loc.ChannelID, + ChannelURL: channelURL, + ContractHash: contract.ContractHash, + ContractID: contract.ContractID, + CoopID: contract.CoopID, + RunType: runType, + GGType: ggType, + ContractSize: int64(eiContract.MaxCoopSize), + StartTimestamp: startTime.Unix(), + RoleName: loc.GuildContractRole.Name, + Members: reportMembers, + } + + // Write the summary section of the report + var summary strings.Builder + + fmt.Fprintf(&summary, `### Admin Logs +Coordinator ID: <@%s> (%s) +Guild Name: *%s* +Channel URL: %s +Contract Hash: *%s* +Contract ID: *%s* +Coop ID: [**⧉**](%s)*%s* +Run Type: *%s* +GG Type: *%s* +Contract Size: *%d* +Start Time: %s +Role Name: *%s* +`, + reportJSON.CoordinatorID, reportJSON.CoordinatorID, + reportJSON.GuildName, + reportJSON.ChannelURL, + reportJSON.ContractHash, + reportJSON.ContractID, + carpetURL, reportJSON.CoopID, + reportJSON.RunType, + reportJSON.GGType, + reportJSON.ContractSize, + bottools.WrapTimestamp(reportJSON.StartTimestamp, bottools.TimestampShortDateTime), + reportJSON.RoleName, + ) + + memberContent := strings.Join(summaryMemberLines, "\n") + if memberContent == "" { + memberContent = "No boosters found for this contract.*\n" + } + + components := []discordgo.MessageComponent{ + &discordgo.TextDisplay{ + Content: summary.String(), + }, + &discordgo.TextDisplay{ + Content: fmt.Sprintf("## %s\n%s", reportJSON.RoleName, memberContent), + }, + } + + // Shouldn't ever happen but just to be safe sanitize file names + sanitizedID := strings.ToLower(fmt.Sprintf("%s-%s", reportJSON.ContractID, reportJSON.CoopID)) + sanitizedID = strings.NewReplacer( + " ", "-", + "/", "-", + "\\", "-", + ":", "-", + ";", "-", + "\t", "-", + "\n", "-", + "\r", "-", + ).Replace(sanitizedID) + + filename := "contract-report-" + sanitizedID + ".json" + + jsonData, err := json.MarshalIndent(reportJSON, "", " ") + if err != nil { + log.Println("Error marshaling contract report JSON:", err) + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Error formatting contract JSON: " + err.Error(), + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + _, err = s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Flags: discordgo.MessageFlagsEphemeral | discordgo.MessageFlagsIsComponentsV2, + Components: components, + }) + if err != nil { + log.Println("Error sending admin contract summary:", err) + return + } + + _, err = s.ChannelMessageSendComplex(targetChannelID, &discordgo.MessageSend{ + Content: summary.String(), + Files: []*discordgo.File{ + { + Name: filename, + ContentType: "application/json", + Reader: bytes.NewReader(jsonData), + }, + }, + Flags: discordgo.MessageFlagsSuppressEmbeds, + }) + if restErr, ok := err.(*discordgo.RESTError); ok && restErr.Message != nil { + log.Printf("Failed to send JSON file to channel %s: HTTP %d, Discord message: %s\n", + targetChannelID, restErr.Response.StatusCode, restErr.Message.Message) + return + } +} diff --git a/src/boost/boost_button_reactions.go b/src/boost/boost_button_reactions.go index 53f2b2e5..a99c19b4 100644 --- a/src/boost/boost_button_reactions.go +++ b/src/boost/boost_button_reactions.go @@ -753,6 +753,11 @@ func getContractReactionsComponents(contract *Contract) []discordgo.MessageCompo Value: "grange", Emoji: &discordgo.ComponentEmoji{Name: "🧑‍🧑‍🧒‍🧒"}, }) + menuOptions = append(menuOptions, discordgo.SelectMenuOption{ + Label: "Admin Logs", + Value: "adminlogs", + Emoji: &discordgo.ComponentEmoji{Name: "📜"}, + }) minValues := 0 out = append(out, discordgo.ActionsRow{ diff --git a/src/boost/boost_menu.go b/src/boost/boost_menu.go index e10e8a96..44fd4129 100644 --- a/src/boost/boost_menu.go +++ b/src/boost/boost_menu.go @@ -2,6 +2,7 @@ package boost import ( "fmt" + "slices" "sort" "strings" "time" @@ -9,6 +10,7 @@ import ( "github.com/bwmarrin/discordgo" "github.com/mkmccarty/TokenTimeBoostBot/src/bottools" "github.com/mkmccarty/TokenTimeBoostBot/src/ei" + "github.com/mkmccarty/TokenTimeBoostBot/src/guildstate" ) // HandleMenuReactions handles the menu reactions for the contract @@ -259,5 +261,36 @@ func HandleMenuReactions(s *discordgo.Session, i *discordgo.InteractionCreate) { Flags: discordgo.MessageFlagsEphemeral, }, }) + case "adminlogs": + + // Check if the user is a coordinator for the contract + userID := i.Member.User.ID + if !slices.Contains(contract.CreatorID, userID) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "You are not a coordinator for this contract.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Fetch target channel from guild settings + guildID := contract.Location[0].GuildID + targetChannelID := guildstate.GetGuildSettingString(guildID, "admin_logs") + if targetChannelID == "" { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "Admin logs are not configured for this server. Contact an admin to set the `admin_logs` channel.", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + return + } + + // Pull up the contract data in the target channel + AdminContractReport(s, i, contract, targetChannelID) } } From c7eb0d6df37d13ff28d90b74edced427cd81e82f Mon Sep 17 00:00:00 2001 From: jameswst <67979826+mutilis@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:19:36 +0100 Subject: [PATCH 2/3] fix(admin_logs): fix staticcheck --- src/boost/boost_admin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/boost/boost_admin.go b/src/boost/boost_admin.go index 0574830d..ffe173ea 100644 --- a/src/boost/boost_admin.go +++ b/src/boost/boost_admin.go @@ -517,7 +517,7 @@ func AdminContractReport(s *discordgo.Session, i *discordgo.InteractionCreate, c } // Respond immediately to buy some time for processing and to avoid interaction timeout - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ Content: "Processing Request...", From b26a35054849ef4c18a5ad033cbf8c40512708b5 Mon Sep 17 00:00:00 2001 From: jameswst <67979826+mutilis@users.noreply.github.com> Date: Sat, 21 Mar 2026 18:26:22 +0100 Subject: [PATCH 3/3] refactor(admin_logs) update guild setting string --- src/boost/boost_menu.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/boost/boost_menu.go b/src/boost/boost_menu.go index 44fd4129..20ae6350 100644 --- a/src/boost/boost_menu.go +++ b/src/boost/boost_menu.go @@ -278,12 +278,12 @@ func HandleMenuReactions(s *discordgo.Session, i *discordgo.InteractionCreate) { // Fetch target channel from guild settings guildID := contract.Location[0].GuildID - targetChannelID := guildstate.GetGuildSettingString(guildID, "admin_logs") + targetChannelID := guildstate.GetGuildSettingString(guildID, "admin_logs_channel") if targetChannelID == "" { _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, Data: &discordgo.InteractionResponseData{ - Content: "Admin logs are not configured for this server. Contact an admin to set the `admin_logs` channel.", + Content: "Admin logs are not configured for this server. Contact an admin to set the `admin_logs_channel` guildstate.", Flags: discordgo.MessageFlagsEphemeral, }, })