From fb1be7519089f0df2dd08f517078a10ece2be721 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 03:23:33 +0300 Subject: [PATCH 1/3] Initial commit with task details for issue #119 Adding CLAUDE.md with task information for AI processing. This file will be removed when the task is complete. Issue: https://github.com/linksplatform/Bot/issues/119 --- CLAUDE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..a2100722 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/linksplatform/Bot/issues/119 +Your prepared branch: issue-119-6f7557a6 +Your prepared working directory: /tmp/gh-issue-solver-1757723009187 + +Proceed. \ No newline at end of file From 5171ee4b7cd159b1e3247fa35c72b69e13f08470 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 03:46:11 +0300 Subject: [PATCH 2/3] Implement automated team invitation system for GitHub and Discord MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TeamInvitationTrigger to handle @bot invite @username requests in GitHub issues - Add GitHubStorage.InviteToOrganization() method for GitHub org invitations - Add DiscordService for creating temporary Discord invite links - Add OwnerKeeperApprovalTriggerDecorator to ensure only owners/admins can approve invitations - Update Program.cs to support Discord bot token, guild ID, and channel ID configuration - Add Discord.Net NuGet package dependency The bot now automatically processes team invitation requests when: 1. Issue contains "@bot invite @username" pattern 2. Issue author has admin or maintainer permissions 3. Bot invites user to GitHub organization 4. Bot creates Discord invite link if Discord is configured 5. Issue is automatically closed after successful invitation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- csharp/Platform.Bot/Platform.Bot.csproj | 1 + csharp/Platform.Bot/Program.cs | 48 +++++++++-- .../Platform.Bot/Services/DiscordService.cs | 85 +++++++++++++++++++ .../OwnerKeeperApprovalTriggerDecorator.cs | 51 +++++++++++ .../Triggers/TeamInvitationTrigger.cs | 72 ++++++++++++++++ csharp/Storage/RemoteStorage/GitHubStorage.cs | 4 + 6 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 csharp/Platform.Bot/Services/DiscordService.cs create mode 100644 csharp/Platform.Bot/Triggers/Decorators/OwnerKeeperApprovalTriggerDecorator.cs create mode 100644 csharp/Platform.Bot/Triggers/TeamInvitationTrigger.cs 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 From 6d6675bc5aff9db94d50914aa60b868bb07b6a69 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 03:47:24 +0300 Subject: [PATCH 3/3] Remove CLAUDE.md - Claude command completed --- CLAUDE.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index a2100722..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/linksplatform/Bot/issues/119 -Your prepared branch: issue-119-6f7557a6 -Your prepared working directory: /tmp/gh-issue-solver-1757723009187 - -Proceed. \ No newline at end of file