diff --git a/csharp/Platform.Bot/Platform.Bot.csproj b/csharp/Platform.Bot/Platform.Bot.csproj index 2828772d..59924a96 100644 --- a/csharp/Platform.Bot/Platform.Bot.csproj +++ b/csharp/Platform.Bot/Platform.Bot.csproj @@ -8,6 +8,7 @@ + diff --git a/csharp/Platform.Bot/Program.cs b/csharp/Platform.Bot/Program.cs index 521a6b95..111460d0 100644 --- a/csharp/Platform.Bot/Program.cs +++ b/csharp/Platform.Bot/Program.cs @@ -15,6 +15,7 @@ using Platform.Bot.Trackers; using Platform.Bot.Triggers; using Platform.Bot.Triggers.Decorators; +using Platform.Bot.Services; namespace Platform.Bot { @@ -73,6 +74,15 @@ private static async Task Main(string[] args) description: "Minimum interaction interval in seconds.", getDefaultValue: () => 60); + var discordBotTokenOption = new Option( + name: "--discord-bot-token", + description: "Discord bot token for role synchronization."); + + var enableFlowsSyncOption = new Option( + name: "--enable-flows-sync", + description: "Enable flows order sync functionality.", + getDefaultValue: () => true); + var rootCommand = new RootCommand("Sample app for System.CommandLine") { githubUserNameOption, @@ -80,10 +90,12 @@ private static async Task Main(string[] args) githubApplicationNameOption, databaseFilePathOption, fileSetNameOption, - minimumInteractionIntervalOption + minimumInteractionIntervalOption, + discordBotTokenOption, + enableFlowsSyncOption }; - rootCommand.SetHandler(async (githubUserName, githubApiToken, githubApplicationName, databaseFilePath, fileSetName, minimumInteractionInterval) => + rootCommand.SetHandler(async (githubUserName, githubApiToken, githubApplicationName, databaseFilePath, fileSetName, minimumInteractionInterval, discordBotToken, enableFlowsSync) => { Debug.WriteLine($"Nickname: {githubUserName}"); Debug.WriteLine($"GitHub API Token: {githubApiToken}"); @@ -91,11 +103,30 @@ private static async Task Main(string[] args) Debug.WriteLine($"Database File Path: {databaseFilePath?.FullName}"); Debug.WriteLine($"File Set Name: {fileSetName}"); Debug.WriteLine($"Minimum Interaction Interval: {minimumInteractionInterval} seconds"); + Debug.WriteLine($"Discord Bot Token: {(string.IsNullOrEmpty(discordBotToken) ? "Not provided" : "Provided")}"); + Debug.WriteLine($"Flows Sync Enabled: {enableFlowsSync}"); var dbContext = new FileStorage(databaseFilePath?.FullName ?? new TemporaryFile().Filename); Console.WriteLine($"Bot has been started. {Environment.NewLine}Press CTRL+C to close"); var githubStorage = new GitHubStorage(githubUserName, githubApiToken, githubApplicationName); - var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName), new OrganizationLastMonthActivityTrigger(githubStorage), new LastCommitActivityTrigger(githubStorage), new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage)); + + var triggers = new List> + { + new HelloWorldTrigger(githubStorage, dbContext, fileSetName), + new OrganizationLastMonthActivityTrigger(githubStorage), + new LastCommitActivityTrigger(githubStorage), + new AdminAuthorIssueTriggerDecorator(new ProtectDefaultBranchTrigger(githubStorage), githubStorage), + new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationRepositoriesDefaultBranchTrigger(githubStorage, dbContext), githubStorage), + new AdminAuthorIssueTriggerDecorator(new ChangeOrganizationPullRequestsBaseBranchTrigger(githubStorage, dbContext), githubStorage) + }; + + if (enableFlowsSync) + { + var discordService = new DiscordRoleSyncService(discordBotToken ?? string.Empty); + triggers.Add(new AdminAuthorIssueTriggerDecorator(new FlowsOrderSyncTrigger(githubStorage, dbContext, discordService), githubStorage)); + } + + var issueTracker = new IssueTracker(githubStorage, triggers.ToArray()); var pullRequenstTracker = new PullRequestTracker(githubStorage, new MergeDependabotBumpsTrigger(githubStorage)); var timestampTracker = new DateTimeTracker(githubStorage, new CreateAndSaveOrganizationRepositoriesMigrationTrigger(githubStorage, dbContext, Path.Combine(Directory.GetCurrentDirectory(), "/github-migrations"))); var cancellation = new CancellationTokenSource(); @@ -114,7 +145,7 @@ private static async Task Main(string[] args) } } }, - githubUserNameOption, githubApiTokenOption, githubApplicationNameOption, databaseFilePathOption, fileSetNameOption, minimumInteractionIntervalOption); + githubUserNameOption, githubApiTokenOption, githubApplicationNameOption, databaseFilePathOption, fileSetNameOption, minimumInteractionIntervalOption, discordBotTokenOption, enableFlowsSyncOption); return await rootCommand.InvokeAsync(args); } diff --git a/csharp/Platform.Bot/Services/ContributionTrackingService.cs b/csharp/Platform.Bot/Services/ContributionTrackingService.cs new file mode 100644 index 00000000..8956ef67 --- /dev/null +++ b/csharp/Platform.Bot/Services/ContributionTrackingService.cs @@ -0,0 +1,169 @@ +using Octokit; +using Storage.Remote.GitHub; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Platform.Bot.Services +{ + public class UserContribution + { + public User User { get; set; } = null!; + public int CommitCount { get; set; } + public int PullRequestCount { get; set; } + public int IssueCount { get; set; } + public int CodeReviewCount { get; set; } + public double WorkScore { get; set; } + public int Rank { get; set; } + } + + public class ContributionTrackingService + { + private readonly GitHubStorage _githubStorage; + + public ContributionTrackingService(GitHubStorage githubStorage) + { + _githubStorage = githubStorage; + } + + public async Task> GetOrganizationContributions(string organizationName, DateTime since) + { + var allMembers = await _githubStorage.GetAllOrganizationMembers(organizationName); + var allRepositories = await _githubStorage.GetAllRepositories(organizationName); + + var contributions = new Dictionary(); + + foreach (var member in allMembers) + { + contributions[member.Id] = new UserContribution + { + User = member, + CommitCount = 0, + PullRequestCount = 0, + IssueCount = 0, + CodeReviewCount = 0 + }; + } + + foreach (var repository in allRepositories.Where(r => !r.Private)) + { + await TrackCommitContributions(repository, contributions, since); + await TrackPullRequestContributions(repository, contributions, since); + await TrackIssueContributions(repository, contributions, since); + await TrackCodeReviewContributions(repository, contributions, since); + } + + var contributionList = contributions.Values.ToList(); + CalculateWorkScores(contributionList); + AssignRanks(contributionList); + + return contributionList.OrderByDescending(c => c.WorkScore).ToList(); + } + + private async Task TrackCommitContributions(Repository repository, Dictionary contributions, DateTime since) + { + try + { + var commits = await _githubStorage.GetCommits(repository.Id, new CommitRequest { Since = since }); + foreach (var commit in commits) + { + if (commit.Author != null && contributions.ContainsKey(commit.Author.Id)) + { + contributions[commit.Author.Id].CommitCount++; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error tracking commits for repository {repository.Name}: {ex.Message}"); + } + } + + private async Task TrackPullRequestContributions(Repository repository, Dictionary contributions, DateTime since) + { + try + { + var pullRequests = _githubStorage.GetPullRequests(repository.Owner.Login, repository.Name); + foreach (var pr in pullRequests.Where(pr => pr.CreatedAt >= since)) + { + if (pr.User != null && contributions.ContainsKey(pr.User.Id)) + { + contributions[pr.User.Id].PullRequestCount++; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error tracking pull requests for repository {repository.Name}: {ex.Message}"); + } + } + + private async Task TrackIssueContributions(Repository repository, Dictionary contributions, DateTime since) + { + try + { + var issues = _githubStorage.GetIssues(repository.Owner.Login, repository.Name); + foreach (var issue in issues.Where(i => i.CreatedAt >= since)) + { + if (issue.User != null && contributions.ContainsKey(issue.User.Id)) + { + contributions[issue.User.Id].IssueCount++; + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error tracking issues for repository {repository.Name}: {ex.Message}"); + } + } + + private async Task TrackCodeReviewContributions(Repository repository, Dictionary contributions, DateTime since) + { + try + { + var pullRequests = _githubStorage.GetPullRequests(repository.Owner.Login, repository.Name); + foreach (var pr in pullRequests.Where(pr => pr.CreatedAt >= since)) + { + foreach (var reviewer in pr.RequestedReviewers) + { + if (contributions.ContainsKey(reviewer.Id)) + { + contributions[reviewer.Id].CodeReviewCount++; + } + } + } + } + catch (Exception ex) + { + Console.WriteLine($"Error tracking code reviews for repository {repository.Name}: {ex.Message}"); + } + } + + private void CalculateWorkScores(List contributions) + { + const double commitWeight = 1.0; + const double pullRequestWeight = 3.0; + const double issueWeight = 1.5; + const double codeReviewWeight = 2.0; + + foreach (var contribution in contributions) + { + contribution.WorkScore = + (contribution.CommitCount * commitWeight) + + (contribution.PullRequestCount * pullRequestWeight) + + (contribution.IssueCount * issueWeight) + + (contribution.CodeReviewCount * codeReviewWeight); + } + } + + private void AssignRanks(List contributions) + { + var sortedContributions = contributions.OrderByDescending(c => c.WorkScore).ToList(); + for (int i = 0; i < sortedContributions.Count; i++) + { + sortedContributions[i].Rank = i + 1; + } + } + } +} \ No newline at end of file diff --git a/csharp/Platform.Bot/Services/DiscordRoleSyncService.cs b/csharp/Platform.Bot/Services/DiscordRoleSyncService.cs new file mode 100644 index 00000000..45e930b7 --- /dev/null +++ b/csharp/Platform.Bot/Services/DiscordRoleSyncService.cs @@ -0,0 +1,202 @@ +using Discord; +using Discord.WebSocket; +using Platform.Bot.Services; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Platform.Bot.Services +{ + public class RoleTier + { + public string RoleName { get; set; } = null!; + public double MinWorkScore { get; set; } + public Color RoleColor { get; set; } + public string Description { get; set; } = null!; + } + + public class DiscordRoleSyncService + { + private readonly DiscordSocketClient _discordClient; + private readonly List _roleTiers; + + public DiscordRoleSyncService(string discordToken) + { + _discordClient = new DiscordSocketClient(); + _roleTiers = InitializeRoleTiers(); + } + + private List InitializeRoleTiers() + { + return new List + { + new RoleTier { RoleName = "Contributor Legend", MinWorkScore = 100, RoleColor = Color.Gold, Description = "Exceptional contributors with outstanding work" }, + new RoleTier { RoleName = "Senior Contributor", MinWorkScore = 50, RoleColor = Color.Purple, Description = "Highly active contributors" }, + new RoleTier { RoleName = "Active Contributor", MinWorkScore = 20, RoleColor = Color.Blue, Description = "Regular contributors" }, + new RoleTier { RoleName = "Contributor", MinWorkScore = 5, RoleColor = Color.Green, Description = "Getting started contributors" }, + new RoleTier { RoleName = "Member", MinWorkScore = 0, RoleColor = Color.LightGrey, Description = "Organization members" } + }; + } + + public async Task ConnectAsync(string token) + { + await _discordClient.LoginAsync(TokenType.Bot, token); + await _discordClient.StartAsync(); + + _discordClient.Ready += OnReady; + _discordClient.Log += LogAsync; + } + + private async Task OnReady() + { + Console.WriteLine($"Discord bot {_discordClient.CurrentUser} is connected!"); + } + + private Task LogAsync(LogMessage log) + { + Console.WriteLine(log.ToString()); + return Task.CompletedTask; + } + + public async Task SyncRolesWithContributions(ulong guildId, List contributions, Dictionary githubToDiscordMapping) + { + var guild = _discordClient.GetGuild(guildId); + if (guild == null) + { + Console.WriteLine($"Guild with ID {guildId} not found."); + return; + } + + await EnsureRolesExist(guild); + + var roles = guild.Roles.Where(r => _roleTiers.Any(tier => tier.RoleName == r.Name)).ToList(); + + foreach (var contribution in contributions) + { + var githubLogin = contribution.User.Login; + if (!githubToDiscordMapping.ContainsKey(githubLogin)) + { + Console.WriteLine($"No Discord mapping found for GitHub user: {githubLogin}"); + continue; + } + + var discordUserId = githubToDiscordMapping[githubLogin]; + var discordUser = guild.GetUser(discordUserId); + + if (discordUser == null) + { + Console.WriteLine($"Discord user with ID {discordUserId} not found in guild."); + continue; + } + + await AssignAppropriateRole(discordUser, contribution.WorkScore, roles); + } + } + + private async Task EnsureRolesExist(SocketGuild guild) + { + foreach (var tier in _roleTiers) + { + var existingRole = guild.Roles.FirstOrDefault(r => r.Name == tier.RoleName); + if (existingRole == null) + { + await guild.CreateRoleAsync(tier.RoleName, + color: tier.RoleColor, + isMentionable: true, + options: new RequestOptions { AuditLogReason = $"Created role for contribution tier: {tier.Description}" }); + + Console.WriteLine($"Created role: {tier.RoleName}"); + } + } + } + + private async Task AssignAppropriateRole(SocketGuildUser user, double workScore, List roles) + { + var appropriateTier = _roleTiers + .Where(tier => workScore >= tier.MinWorkScore) + .OrderByDescending(tier => tier.MinWorkScore) + .FirstOrDefault(); + + if (appropriateTier == null) + { + appropriateTier = _roleTiers.Last(); + } + + var targetRole = roles.FirstOrDefault(r => r.Name == appropriateTier.RoleName); + if (targetRole == null) + { + Console.WriteLine($"Target role {appropriateTier.RoleName} not found."); + return; + } + + var tierRoles = roles.Where(r => _roleTiers.Any(tier => tier.RoleName == r.Name)).ToList(); + var currentTierRoles = user.Roles.Where(r => tierRoles.Contains(r)).ToList(); + + if (currentTierRoles.Any(r => r.Id == targetRole.Id)) + { + Console.WriteLine($"User {user.Username} already has appropriate role: {targetRole.Name}"); + return; + } + + try + { + foreach (var roleToRemove in currentTierRoles) + { + await user.RemoveRoleAsync(roleToRemove); + } + + await user.AddRoleAsync(targetRole); + Console.WriteLine($"Updated {user.Username} to role: {targetRole.Name} (Work Score: {workScore:F1})"); + } + catch (Exception ex) + { + Console.WriteLine($"Error updating roles for {user.Username}: {ex.Message}"); + } + } + + public async Task GenerateContributionReport(ulong channelId, List contributions) + { + var channel = _discordClient.GetChannel(channelId) as IMessageChannel; + if (channel == null) + { + Console.WriteLine($"Channel with ID {channelId} not found."); + return; + } + + var embed = new EmbedBuilder() + .WithTitle("🏆 Organization Contribution Leaderboard") + .WithDescription("Based on activity in public repositories") + .WithColor(Color.Gold) + .WithTimestamp(DateTimeOffset.Now); + + var topContributors = contributions.Take(10).ToList(); + + for (int i = 0; i < topContributors.Count; i++) + { + var contributor = topContributors[i]; + var trophy = i switch + { + 0 => "🥇", + 1 => "🥈", + 2 => "🥉", + _ => "🏅" + }; + + var fieldValue = $"**Score:** {contributor.WorkScore:F1}\n" + + $"Commits: {contributor.CommitCount} | PRs: {contributor.PullRequestCount}\n" + + $"Issues: {contributor.IssueCount} | Reviews: {contributor.CodeReviewCount}"; + + embed.AddField($"{trophy} #{contributor.Rank} {contributor.User.Login}", fieldValue, true); + } + + await channel.SendMessageAsync(embed: embed.Build()); + } + + public async Task DisconnectAsync() + { + await _discordClient.LogoutAsync(); + await _discordClient.StopAsync(); + } + } +} \ No newline at end of file diff --git a/csharp/Platform.Bot/Triggers/FlowsOrderSyncTrigger.cs b/csharp/Platform.Bot/Triggers/FlowsOrderSyncTrigger.cs new file mode 100644 index 00000000..7d43a6b9 --- /dev/null +++ b/csharp/Platform.Bot/Triggers/FlowsOrderSyncTrigger.cs @@ -0,0 +1,245 @@ +using Interfaces; +using Octokit; +using Platform.Bot.Services; +using Platform.Communication.Protocol.Lino; +using Storage.Local; +using Storage.Remote.GitHub; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Platform.Bot.Triggers +{ + using TContext = Issue; + + public class FlowsOrderSyncTrigger : ITrigger + { + private readonly GitHubStorage _githubStorage; + private readonly FileStorage _fileStorage; + private readonly ContributionTrackingService _contributionService; + private readonly DiscordRoleSyncService _discordService; + private readonly Parser _parser = new(); + + public FlowsOrderSyncTrigger(GitHubStorage githubStorage, FileStorage fileStorage, DiscordRoleSyncService discordService) + { + _githubStorage = githubStorage; + _fileStorage = fileStorage; + _contributionService = new ContributionTrackingService(githubStorage); + _discordService = discordService; + } + + public async Task Condition(TContext context) + { + var title = context.Title.ToLower(); + return title.Contains("flows order sync") || + title.Contains("sync discord roles") || + title.Contains("contribution sync"); + } + + public async Task Action(TContext context) + { + try + { + Console.WriteLine($"Starting flows order sync for issue: {context.Title}"); + + var organizationName = context.Repository.Owner.Login; + var parsedBody = _parser.Parse(context.Body); + var config = ExtractConfigurationFromIssue(parsedBody); + + var sinceDate = DateTime.Now.AddMonths(-config.MonthsToAnalyze); + Console.WriteLine($"Analyzing contributions since: {sinceDate:yyyy-MM-dd}"); + + var contributions = await _contributionService.GetOrganizationContributions(organizationName, sinceDate); + + await GenerateContributionReport(context, contributions); + + if (config.SyncDiscordRoles && !string.IsNullOrEmpty(config.DiscordBotToken)) + { + await SyncDiscordRoles(contributions, config); + } + + await SaveContributionData(organizationName, contributions); + + await _githubStorage.CreateIssueComment(context.Repository.Id, context.Number, + "✅ Flows order sync completed successfully!\n\n" + + $"📊 Analyzed {contributions.Count} contributors\n" + + $"📅 Time period: {config.MonthsToAnalyze} months\n" + + $"🔄 Discord sync: {(config.SyncDiscordRoles ? "Enabled" : "Disabled")}"); + + _githubStorage.CloseIssue(context); + } + catch (Exception ex) + { + Console.WriteLine($"Error in FlowsOrderSyncTrigger: {ex.Message}"); + await _githubStorage.CreateIssueComment(context.Repository.Id, context.Number, + $"❌ Error during flows order sync: {ex.Message}"); + } + } + + private FlowsSyncConfig ExtractConfigurationFromIssue(IList links) + { + var config = new FlowsSyncConfig(); + + foreach (var link in links) + { + if (link.Values?.Count >= 3) + { + var key = link.Values[0].Id.ToLower(); + var value = link.Values[2].Id; + + switch (key) + { + case "months": + if (int.TryParse(value, out int months)) + config.MonthsToAnalyze = months; + break; + case "discord_token": + config.DiscordBotToken = value; + break; + case "discord_guild_id": + if (ulong.TryParse(value, out ulong guildId)) + config.DiscordGuildId = guildId; + break; + case "discord_channel_id": + if (ulong.TryParse(value, out ulong channelId)) + config.DiscordChannelId = channelId; + break; + case "sync_roles": + config.SyncDiscordRoles = value.ToLower() == "true" || value == "1"; + break; + } + } + } + + return config; + } + + private async Task GenerateContributionReport(TContext context, List contributions) + { + var report = new StringBuilder(); + report.AppendLine("# 🏆 Organization Contribution Report"); + report.AppendLine($"*Generated on {DateTime.Now:yyyy-MM-dd HH:mm:ss} UTC*"); + report.AppendLine(); + + report.AppendLine("## Top Contributors"); + report.AppendLine("| Rank | User | Work Score | Commits | PRs | Issues | Reviews |"); + report.AppendLine("|------|------|------------|---------|-----|--------|---------|"); + + var topContributors = contributions.Take(20).ToList(); + foreach (var contributor in topContributors) + { + var trophy = contributor.Rank switch + { + 1 => "🥇", + 2 => "🥈", + 3 => "🥉", + _ => "" + }; + + report.AppendLine($"| {trophy} #{contributor.Rank} | [{contributor.User.Login}]({contributor.User.HtmlUrl}) | {contributor.WorkScore:F1} | {contributor.CommitCount} | {contributor.PullRequestCount} | {contributor.IssueCount} | {contributor.CodeReviewCount} |"); + } + + report.AppendLine(); + report.AppendLine("## Scoring System"); + report.AppendLine("- **Commits**: 1.0 point each"); + report.AppendLine("- **Pull Requests**: 3.0 points each"); + report.AppendLine("- **Issues Created**: 1.5 points each"); + report.AppendLine("- **Code Reviews**: 2.0 points each"); + + await _githubStorage.CreateIssueComment(context.Repository.Id, context.Number, report.ToString()); + } + + private async Task SyncDiscordRoles(List contributions, FlowsSyncConfig config) + { + if (config.DiscordGuildId == 0) + { + Console.WriteLine("Discord Guild ID not provided, skipping Discord sync."); + return; + } + + await _discordService.ConnectAsync(config.DiscordBotToken); + + var githubToDiscordMapping = LoadGitHubToDiscordMapping(); + + await _discordService.SyncRolesWithContributions(config.DiscordGuildId, contributions, githubToDiscordMapping); + + if (config.DiscordChannelId != 0) + { + await _discordService.GenerateContributionReport(config.DiscordChannelId, contributions); + } + + await _discordService.DisconnectAsync(); + } + + private Dictionary LoadGitHubToDiscordMapping() + { + try + { + var mappingKey = "github_discord_mapping"; + var mappingKeyLink = _fileStorage.CreateString(mappingKey); + var filesInSet = _fileStorage.GetFilesFromSet(mappingKey); + + if (filesInSet.Any()) + { + var jsonString = filesInSet.First().Content; + return JsonSerializer.Deserialize>(jsonString) ?? new Dictionary(); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error loading GitHub to Discord mapping: {ex.Message}"); + } + + return new Dictionary(); + } + + private async Task SaveContributionData(string organizationName, List contributions) + { + try + { + var data = new + { + Organization = organizationName, + GeneratedAt = DateTime.UtcNow, + Contributions = contributions.Select(c => new + { + GitHubLogin = c.User.Login, + GitHubId = c.User.Id, + WorkScore = c.WorkScore, + Rank = c.Rank, + CommitCount = c.CommitCount, + PullRequestCount = c.PullRequestCount, + IssueCount = c.IssueCount, + CodeReviewCount = c.CodeReviewCount + }).ToList() + }; + + var jsonData = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true }); + var setName = $"contribution_data_{organizationName}"; + var fileName = $"{DateTime.UtcNow:yyyyMMdd_HHmmss}.json"; + + var fileSet = _fileStorage.CreateFileSet(setName); + var fileLink = _fileStorage.AddFile(jsonData); + _fileStorage.AddFileToSet(fileSet, fileLink, fileName); + + Console.WriteLine($"Saved contribution data to set: {setName}, file: {fileName}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error saving contribution data: {ex.Message}"); + } + } + } + + public class FlowsSyncConfig + { + public int MonthsToAnalyze { get; set; } = 3; + public string DiscordBotToken { get; set; } = string.Empty; + public ulong DiscordGuildId { get; set; } = 0; + public ulong DiscordChannelId { get; set; } = 0; + public bool SyncDiscordRoles { get; set; } = true; + } +} \ No newline at end of file