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..c9b127a3 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,18 @@ private static async Task Main(string[] args) description: "Minimum interaction interval in seconds.", getDefaultValue: () => 60); + var discordTokenOption = new Option( + name: "--discord-token", + description: "Discord bot token (optional)."); + + var discordGuildIdOption = new Option( + name: "--discord-guild-id", + description: "Discord guild/server ID (optional)."); + + var discordChannelIdOption = new Option( + name: "--discord-channel-id", + description: "Discord channel ID for invites (optional)."); + var rootCommand = new RootCommand("Sample app for System.CommandLine") { githubUserNameOption, @@ -80,22 +93,48 @@ private static async Task Main(string[] args) githubApplicationNameOption, databaseFilePathOption, fileSetNameOption, - minimumInteractionIntervalOption + minimumInteractionIntervalOption, + discordTokenOption, + discordGuildIdOption, + discordChannelIdOption }; - rootCommand.SetHandler(async (githubUserName, githubApiToken, githubApplicationName, databaseFilePath, fileSetName, minimumInteractionInterval) => + rootCommand.SetHandler(async (context) => { + var githubUserName = context.ParseResult.GetValueForOption(githubUserNameOption)!; + var githubApiToken = context.ParseResult.GetValueForOption(githubApiTokenOption)!; + var githubApplicationName = context.ParseResult.GetValueForOption(githubApplicationNameOption)!; + var databaseFilePath = context.ParseResult.GetValueForOption(databaseFilePathOption); + var fileSetName = context.ParseResult.GetValueForOption(fileSetNameOption); + var minimumInteractionInterval = context.ParseResult.GetValueForOption(minimumInteractionIntervalOption); + var discordToken = context.ParseResult.GetValueForOption(discordTokenOption); + var discordGuildId = context.ParseResult.GetValueForOption(discordGuildIdOption); + var discordChannelId = context.ParseResult.GetValueForOption(discordChannelIdOption); + Debug.WriteLine($"Nickname: {githubUserName}"); Debug.WriteLine($"GitHub API Token: {githubApiToken}"); Debug.WriteLine($"Application Name: {githubApplicationName}"); Debug.WriteLine($"Database File Path: {databaseFilePath?.FullName}"); Debug.WriteLine($"File Set Name: {fileSetName}"); Debug.WriteLine($"Minimum Interaction Interval: {minimumInteractionInterval} seconds"); + Debug.WriteLine($"Discord Token: {(string.IsNullOrEmpty(discordToken) ? "Not provided" : "Provided")}"); + Debug.WriteLine($"Discord Guild ID: {discordGuildId}"); + Debug.WriteLine($"Discord Channel ID: {discordChannelId}"); 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 discordService = !string.IsNullOrEmpty(discordToken) && discordGuildId.HasValue && discordChannelId.HasValue + ? new Platform.Bot.Services.DiscordService(discordToken, discordGuildId.Value, discordChannelId.Value) + : null; + + if (discordService != null) + { + await discordService.ConnectAsync(); + } + + var issueTracker = new IssueTracker(githubStorage, new HelloWorldTrigger(githubStorage, dbContext, fileSetName ?? "HelloWorldSet"), 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), new OwnerKeeperApprovalTriggerDecorator(new TeamInvitationTrigger(githubStorage, discordService!), githubStorage)); 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(); @@ -113,8 +152,7 @@ private static async Task Main(string[] args) Console.WriteLine(ex.ToStringWithAllInnerExceptions()); } } - }, - githubUserNameOption, githubApiTokenOption, githubApplicationNameOption, databaseFilePathOption, fileSetNameOption, minimumInteractionIntervalOption); + }); return await rootCommand.InvokeAsync(args); } diff --git a/csharp/Platform.Bot/Services/DiscordService.cs b/csharp/Platform.Bot/Services/DiscordService.cs new file mode 100644 index 00000000..2ff51fe1 --- /dev/null +++ b/csharp/Platform.Bot/Services/DiscordService.cs @@ -0,0 +1,85 @@ +using Discord; +using Discord.WebSocket; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace Platform.Bot.Services +{ + /// + /// Service for Discord operations including creating invite links + /// + public class DiscordService + { + private readonly DiscordSocketClient _client; + private readonly string? _token; + private readonly ulong? _guildId; + private readonly ulong? _channelId; + private bool _isConnected = false; + + public DiscordService(string? token = null, ulong? guildId = null, ulong? channelId = null) + { + _token = token; + _guildId = guildId; + _channelId = channelId; + _client = new DiscordSocketClient(); + } + + public async Task ConnectAsync() + { + if (string.IsNullOrEmpty(_token)) + return false; + + try + { + await _client.LoginAsync(TokenType.Bot, _token); + await _client.StartAsync(); + _isConnected = true; + return true; + } + catch (Exception) + { + return false; + } + } + + public async Task CreateInviteLink() + { + if (!_isConnected || !_guildId.HasValue) + return null; + + try + { + var guild = _client.GetGuild(_guildId.Value); + if (guild == null) + return null; + + var channel = guild.DefaultChannel ?? guild.TextChannels.FirstOrDefault(); + if (channel == null) + return null; + + var invite = await channel.CreateInviteAsync(maxAge: (int)TimeSpan.FromDays(1).TotalSeconds, maxUses: 1, isTemporary: false, isUnique: true); + return invite.Url; + } + catch (Exception) + { + return null; + } + } + + public async Task DisconnectAsync() + { + if (_isConnected) + { + await _client.StopAsync(); + await _client.LogoutAsync(); + _isConnected = false; + } + } + + public void Dispose() + { + _client?.Dispose(); + } + } +} \ No newline at end of file diff --git a/csharp/Platform.Bot/Triggers/Decorators/OwnerKeeperApprovalTriggerDecorator.cs b/csharp/Platform.Bot/Triggers/Decorators/OwnerKeeperApprovalTriggerDecorator.cs new file mode 100644 index 00000000..b00f62a7 --- /dev/null +++ b/csharp/Platform.Bot/Triggers/Decorators/OwnerKeeperApprovalTriggerDecorator.cs @@ -0,0 +1,51 @@ +using System.Threading.Tasks; +using Interfaces; +using Octokit; +using Storage.Remote.GitHub; + +namespace Platform.Bot.Triggers.Decorators +{ + /// + /// Decorator that ensures only organization owners or repository admins can approve team invitations + /// + public class OwnerKeeperApprovalTriggerDecorator : ITrigger + { + private readonly ITrigger _trigger; + private readonly GitHubStorage _githubStorage; + + public OwnerKeeperApprovalTriggerDecorator(ITrigger trigger, GitHubStorage githubStorage) + { + _trigger = trigger; + _githubStorage = githubStorage; + } + + public async Task Condition(Issue issue) + { + if (!await _trigger.Condition(issue)) + return false; + + try + { + var issueAuthorLogin = issue.User.Login; + + var organizationMembership = await _githubStorage.Client.Organization.Member.GetOrganizationMembership(_githubStorage.Owner, issueAuthorLogin); + if (organizationMembership.Role.Value == MembershipRole.Admin) + { + return true; + } + + var repositoryPermission = await _githubStorage.Client.Repository.Collaborator.ReviewPermission(issue.Repository.Id, issueAuthorLogin); + return repositoryPermission.Permission == "admin" || repositoryPermission.Permission == "maintain"; + } + catch + { + return false; + } + } + + public async Task Action(Issue issue) + { + await _trigger.Action(issue); + } + } +} \ No newline at end of file diff --git a/csharp/Platform.Bot/Triggers/TeamInvitationTrigger.cs b/csharp/Platform.Bot/Triggers/TeamInvitationTrigger.cs new file mode 100644 index 00000000..a3bba008 --- /dev/null +++ b/csharp/Platform.Bot/Triggers/TeamInvitationTrigger.cs @@ -0,0 +1,72 @@ +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Interfaces; +using Octokit; +using Storage.Remote.GitHub; +using Platform.Bot.Services; + +namespace Platform.Bot.Triggers +{ + /// + /// Handles team invitation requests via GitHub issues + /// Automatically invites approved users to both GitHub organization and Discord server + /// + public class TeamInvitationTrigger : ITrigger + { + private readonly GitHubStorage _githubStorage; + private readonly DiscordService _discordService; + private readonly Regex _invitationPattern = new(@"@bot\s+invite\s+@?(\w+)", RegexOptions.IgnoreCase); + + public TeamInvitationTrigger(GitHubStorage githubStorage, DiscordService discordService) + { + _githubStorage = githubStorage; + _discordService = discordService; + } + + public async Task Condition(Issue issue) + { + if (issue.State.Value != ItemState.Open) + return false; + + var match = _invitationPattern.Match(issue.Body ?? ""); + if (!match.Success) + return false; + + var issueAuthorPermission = await _githubStorage.Client.Repository.Collaborator.ReviewPermission(issue.Repository.Id, issue.User.Login); + return issueAuthorPermission.Permission == "admin" || issueAuthorPermission.Permission == "maintain"; + } + + public async Task Action(Issue issue) + { + var match = _invitationPattern.Match(issue.Body ?? ""); + if (!match.Success) + return; + + var usernameToInvite = match.Groups[1].Value; + + try + { + await _githubStorage.InviteToOrganization(_githubStorage.Owner, usernameToInvite); + await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number, + $"✅ Successfully sent GitHub organization invitation to @{usernameToInvite}"); + + if (_discordService != null) + { + var inviteLink = await _discordService.CreateInviteLink(); + if (!string.IsNullOrEmpty(inviteLink)) + { + await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number, + $"🎮 Discord invite link for @{usernameToInvite}: {inviteLink}"); + } + } + + _githubStorage.CloseIssue(issue); + } + catch (System.Exception ex) + { + await _githubStorage.CreateIssueComment(issue.Repository.Id, issue.Number, + $"❌ Failed to invite @{usernameToInvite}: {ex.Message}"); + } + } + } +} \ No newline at end of file diff --git a/csharp/Storage/RemoteStorage/GitHubStorage.cs b/csharp/Storage/RemoteStorage/GitHubStorage.cs index 888a7426..4e04abed 100644 --- a/csharp/Storage/RemoteStorage/GitHubStorage.cs +++ b/csharp/Storage/RemoteStorage/GitHubStorage.cs @@ -392,6 +392,10 @@ public async Task> GetAllOrganizationMembers(string organizationName) return allMembers; } + public async Task InviteToOrganization(string organizationName, string username) + { + await Client.Organization.Member.AddOrUpdateOrganizationMembership(organizationName, username, new OrganizationMembershipUpdate()); + } #endregion