From 2d613674db121ed742a4790310dd052a6201bb44 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 07:13:07 +0300 Subject: [PATCH 1/3] Initial commit with task details for issue #107 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/107 --- 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..a32ae52e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +Issue to solve: https://github.com/linksplatform/Bot/issues/107 +Your prepared branch: issue-107-74ce9552 +Your prepared working directory: /tmp/gh-issue-solver-1757736784011 + +Proceed. \ No newline at end of file From c899674f4b095cdf4383b9df8d3b26ab979d64a4 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 07:23:08 +0300 Subject: [PATCH 2/3] Implement Discord bot with programming language role synchronization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds a comprehensive Discord bot that synchronizes user roles with programming languages detected from their GitHub repositories. Features: - Analyzes public GitHub repositories to detect programming languages - Automatically assigns/removes Discord roles based on language usage - Supports 15+ programming languages with configurable role mappings - User-friendly commands: !sync-roles, !my-sync, !check-languages, !help - Rich Discord embeds with detailed language statistics - Comprehensive logging and error handling - Integration with existing LinksPlatform storage system Technical implementation: - Modern .NET 8 architecture with dependency injection - Discord.Net library for Discord API integration - Octokit for GitHub API integration - Microsoft.Extensions.Hosting for service lifecycle management - Configurable through appsettings.json - Added supporting methods to FileStorage for key-value mappings 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- csharp/Bot.sln | 6 + csharp/DiscordBot/DiscordBot.csproj | 22 ++ csharp/DiscordBot/DiscordBotSettings.cs | 14 ++ .../DiscordBot/Modules/LanguageRoleModule.cs | 227 ++++++++++++++++++ csharp/DiscordBot/Program.cs | 40 +++ csharp/DiscordBot/README.md | 79 ++++++ .../Services/DiscordBotHostedService.cs | 42 ++++ .../DiscordBot/Services/DiscordBotService.cs | 141 +++++++++++ .../GitHubLanguageDetectionService.cs | 78 ++++++ .../ProgrammingLanguageRoleService.cs | 147 ++++++++++++ csharp/DiscordBot/appsettings.json | 35 +++ csharp/Storage/LocalStorage/FileStorage.cs | 95 ++++++++ 12 files changed, 926 insertions(+) create mode 100644 csharp/DiscordBot/DiscordBot.csproj create mode 100644 csharp/DiscordBot/DiscordBotSettings.cs create mode 100644 csharp/DiscordBot/Modules/LanguageRoleModule.cs create mode 100644 csharp/DiscordBot/Program.cs create mode 100644 csharp/DiscordBot/README.md create mode 100644 csharp/DiscordBot/Services/DiscordBotHostedService.cs create mode 100644 csharp/DiscordBot/Services/DiscordBotService.cs create mode 100644 csharp/DiscordBot/Services/GitHubLanguageDetectionService.cs create mode 100644 csharp/DiscordBot/Services/ProgrammingLanguageRoleService.cs create mode 100644 csharp/DiscordBot/appsettings.json diff --git a/csharp/Bot.sln b/csharp/Bot.sln index 612998b2..5f0503db 100755 --- a/csharp/Bot.sln +++ b/csharp/Bot.sln @@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Storage", "Storage\Storage. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TraderBot", "TraderBot\TraderBot.csproj", "{FAE89FE2-17C5-4AD6-98EC-84002CC4C672}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DiscordBot", "DiscordBot\DiscordBot.csproj", "{8B5F7E91-2D44-4F6C-8A5A-1E9C3B4D5F7E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,5 +38,9 @@ Global {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Debug|Any CPU.Build.0 = Debug|Any CPU {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Release|Any CPU.ActiveCfg = Release|Any CPU {FAE89FE2-17C5-4AD6-98EC-84002CC4C672}.Release|Any CPU.Build.0 = Release|Any CPU + {8B5F7E91-2D44-4F6C-8A5A-1E9C3B4D5F7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B5F7E91-2D44-4F6C-8A5A-1E9C3B4D5F7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B5F7E91-2D44-4F6C-8A5A-1E9C3B4D5F7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B5F7E91-2D44-4F6C-8A5A-1E9C3B4D5F7E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/csharp/DiscordBot/DiscordBot.csproj b/csharp/DiscordBot/DiscordBot.csproj new file mode 100644 index 00000000..c2c53cdf --- /dev/null +++ b/csharp/DiscordBot/DiscordBot.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + Exe + enable + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/csharp/DiscordBot/DiscordBotSettings.cs b/csharp/DiscordBot/DiscordBotSettings.cs new file mode 100644 index 00000000..c5617301 --- /dev/null +++ b/csharp/DiscordBot/DiscordBotSettings.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace DiscordBot +{ + public class DiscordBotSettings + { + public string Token { get; set; } = string.Empty; + public string GitHubToken { get; set; } = string.Empty; + public ulong GuildId { get; set; } + public Dictionary LanguageRoles { get; set; } = new(); + public string DatabasePath { get; set; } = "discord_bot.db"; + public int SyncIntervalHours { get; set; } = 24; + } +} \ No newline at end of file diff --git a/csharp/DiscordBot/Modules/LanguageRoleModule.cs b/csharp/DiscordBot/Modules/LanguageRoleModule.cs new file mode 100644 index 00000000..1c1f5213 --- /dev/null +++ b/csharp/DiscordBot/Modules/LanguageRoleModule.cs @@ -0,0 +1,227 @@ +using Discord; +using Discord.Commands; +using DiscordBot.Services; +using Microsoft.Extensions.Logging; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace DiscordBot.Modules +{ + public class LanguageRoleModule : ModuleBase + { + private readonly DiscordBotService _botService; + private readonly GitHubLanguageDetectionService _languageService; + private readonly ILogger _logger; + + public LanguageRoleModule( + DiscordBotService botService, + GitHubLanguageDetectionService languageService, + ILogger logger) + { + _botService = botService; + _languageService = languageService; + _logger = logger; + } + + [Command("sync-roles")] + [Summary("Synchronizes your Discord roles with programming languages from your GitHub profile")] + public async Task SyncRolesAsync([Summary("Your GitHub username")] string githubUsername) + { + try + { + await Context.Channel.TriggerTypingAsync(); + + _logger.LogInformation("User {DiscordUser} requested role sync with GitHub user {GitHubUser}", + Context.User.Username, githubUsername); + + var embed = new EmbedBuilder() + .WithTitle("🔄 Synchronizing Roles...") + .WithDescription($"Analyzing GitHub profile: **{githubUsername}**\nThis may take a moment...") + .WithColor(Color.Blue) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + var message = await ReplyAsync(embed: embed); + + _botService.StoreUserGitHubMapping(Context.User.Id, githubUsername); + + var languageStats = await _languageService.GetUserProgrammingLanguagesAsync(githubUsername); + + if (!languageStats.Any()) + { + var errorEmbed = new EmbedBuilder() + .WithTitle("❌ No Programming Languages Found") + .WithDescription($"No programming languages found for GitHub user **{githubUsername}**.\n\n" + + "This could mean:\n" + + "• The user doesn't exist\n" + + "• The user has no public repositories\n" + + "• The repositories don't contain detectable programming languages") + .WithColor(Color.Red) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + await message.ModifyAsync(m => m.Embed = errorEmbed); + return; + } + + await _botService.SyncUserRolesAsync(Context.User.Id, githubUsername); + + var topLanguages = GitHubLanguageDetectionService.GetTopLanguages(languageStats); + var languageList = topLanguages.Take(10).Select(lang => + { + var bytes = languageStats[lang]; + var size = bytes < 1024 ? $"{bytes} B" : + bytes < 1024 * 1024 ? $"{bytes / 1024:N0} KB" : + $"{bytes / (1024 * 1024):N1} MB"; + return $"• **{lang}** ({size})"; + }); + + var successEmbed = new EmbedBuilder() + .WithTitle("✅ Roles Synchronized Successfully!") + .WithDescription($"**GitHub Profile:** {githubUsername}\n\n" + + $"**Top Programming Languages:**\n{string.Join("\n", languageList)}\n\n" + + "Your Discord roles have been updated to reflect your programming language expertise!") + .WithColor(Color.Green) + .WithTimestamp(DateTimeOffset.Now) + .WithFooter("Roles are synchronized based on your public repositories") + .Build(); + + await message.ModifyAsync(m => m.Embed = successEmbed); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during role synchronization for user {User} with GitHub {GitHub}", + Context.User.Username, githubUsername); + + var errorEmbed = new EmbedBuilder() + .WithTitle("❌ Synchronization Failed") + .WithDescription("An error occurred while synchronizing your roles. Please try again later or contact an administrator.") + .WithColor(Color.Red) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + await ReplyAsync(embed: errorEmbed); + } + } + + [Command("check-languages")] + [Summary("Shows programming languages detected from a GitHub profile without changing roles")] + public async Task CheckLanguagesAsync([Summary("GitHub username to check")] string githubUsername) + { + try + { + await Context.Channel.TriggerTypingAsync(); + + _logger.LogInformation("User {DiscordUser} requested language check for GitHub user {GitHubUser}", + Context.User.Username, githubUsername); + + var embed = new EmbedBuilder() + .WithTitle("🔍 Analyzing GitHub Profile...") + .WithDescription($"Scanning repositories for: **{githubUsername}**") + .WithColor(Color.Blue) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + var message = await ReplyAsync(embed: embed); + + var languageStats = await _languageService.GetUserProgrammingLanguagesAsync(githubUsername); + + if (!languageStats.Any()) + { + var errorEmbed = new EmbedBuilder() + .WithTitle("❌ No Programming Languages Found") + .WithDescription($"No programming languages detected for GitHub user **{githubUsername}**.") + .WithColor(Color.Red) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + await message.ModifyAsync(m => m.Embed = errorEmbed); + return; + } + + var topLanguages = GitHubLanguageDetectionService.GetTopLanguages(languageStats, maxLanguages: 15); + var totalBytes = languageStats.Values.Sum(); + + var languageList = topLanguages.Select((lang, index) => + { + var bytes = languageStats[lang]; + var percentage = (double)bytes / totalBytes * 100; + var size = bytes < 1024 ? $"{bytes} B" : + bytes < 1024 * 1024 ? $"{bytes / 1024:N0} KB" : + $"{bytes / (1024 * 1024):N1} MB"; + return $"{index + 1}. **{lang}** - {percentage:F1}% ({size})"; + }); + + var resultEmbed = new EmbedBuilder() + .WithTitle($"📊 Programming Languages for {githubUsername}") + .WithDescription($"**Total Repositories Analyzed:** {languageStats.Count}\n\n" + + $"**Languages Found:**\n{string.Join("\n", languageList)}") + .WithColor(Color.Blue) + .WithTimestamp(DateTimeOffset.Now) + .WithFooter("Analysis based on public repositories only") + .Build(); + + await message.ModifyAsync(m => m.Embed = resultEmbed); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during language check for GitHub user {GitHub}", githubUsername); + + var errorEmbed = new EmbedBuilder() + .WithTitle("❌ Analysis Failed") + .WithDescription("An error occurred while analyzing the GitHub profile. Please check the username and try again.") + .WithColor(Color.Red) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + await ReplyAsync(embed: errorEmbed); + } + } + + [Command("my-sync")] + [Summary("Synchronizes your roles using your previously linked GitHub account")] + public async Task MySyncAsync() + { + var githubUsername = _botService.GetUserGitHubUsername(Context.User.Id); + + if (string.IsNullOrEmpty(githubUsername)) + { + var embed = new EmbedBuilder() + .WithTitle("❌ No GitHub Account Linked") + .WithDescription("You haven't linked a GitHub account yet. Use:\n`!sync-roles `") + .WithColor(Color.Red) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + await ReplyAsync(embed: embed); + return; + } + + await SyncRolesAsync(githubUsername); + } + + [Command("help")] + [Summary("Shows available commands for role synchronization")] + public async Task HelpAsync() + { + var embed = new EmbedBuilder() + .WithTitle("🤖 Programming Language Role Bot - Commands") + .WithDescription("This bot automatically assigns Discord roles based on your GitHub programming language usage.") + .WithColor(Color.Blue) + .AddField("!sync-roles ", + "Links your GitHub account and synchronizes your Discord roles", false) + .AddField("!my-sync", + "Re-synchronizes your roles using your previously linked GitHub account", false) + .AddField("!check-languages ", + "Shows programming languages for a GitHub user without changing roles", false) + .AddField("!help", + "Shows this help message", false) + .WithTimestamp(DateTimeOffset.Now) + .WithFooter("LinksPlatform Discord Bot") + .Build(); + + await ReplyAsync(embed: embed); + } + } +} \ No newline at end of file diff --git a/csharp/DiscordBot/Program.cs b/csharp/DiscordBot/Program.cs new file mode 100644 index 00000000..766c0e8a --- /dev/null +++ b/csharp/DiscordBot/Program.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Configuration; +using DiscordBot.Services; +using System.Threading.Tasks; + +namespace DiscordBot +{ + internal class Program + { + private static async Task Main(string[] args) + { + var host = CreateHostBuilder(args).Build(); + await host.RunAsync(); + } + + private static IHostBuilder CreateHostBuilder(string[] args) => + Host.CreateDefaultBuilder(args) + .ConfigureAppConfiguration((context, config) => + { + config.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true); + config.AddCommandLine(args); + config.AddEnvironmentVariables(); + }) + .ConfigureServices((context, services) => + { + services.Configure(context.Configuration.GetSection("DiscordBot")); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); + }) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }); + } +} \ No newline at end of file diff --git a/csharp/DiscordBot/README.md b/csharp/DiscordBot/README.md new file mode 100644 index 00000000..0d1a3919 --- /dev/null +++ b/csharp/DiscordBot/README.md @@ -0,0 +1,79 @@ +# LinksPlatform Discord Bot - Programming Language Role Synchronization + +This Discord bot automatically synchronizes programming language roles with the languages users actually know based on their GitHub repositories. + +## Features + +- **Automatic Role Assignment**: Assigns Discord roles based on programming languages detected from GitHub repositories +- **GitHub Integration**: Analyzes public repositories to determine language expertise +- **User-Friendly Commands**: Simple commands for role synchronization and language checking +- **Configurable**: Supports custom role mappings and thresholds +- **Logging**: Comprehensive logging for monitoring and debugging + +## Commands + +- `!sync-roles ` - Links GitHub account and synchronizes Discord roles +- `!my-sync` - Re-synchronizes roles using previously linked GitHub account +- `!check-languages ` - Shows detected programming languages without changing roles +- `!help` - Shows available commands + +## Setup + +### Prerequisites + +1. **Discord Bot Token**: Create a bot application in Discord Developer Portal +2. **GitHub Token**: Generate a personal access token for GitHub API access +3. **Discord Server**: Bot needs to be added to your Discord server with appropriate permissions + +### Required Permissions + +The bot needs the following Discord permissions: +- View Channels +- Send Messages +- Manage Roles +- Use Slash Commands +- Read Message History + +### Configuration + +1. Copy `appsettings.json` and update the following values: + - `Token`: Your Discord bot token + - `GitHubToken`: Your GitHub personal access token + - `GuildId`: Your Discord server (guild) ID + - `LanguageRoles`: Map programming languages to Discord role IDs + +2. Create the corresponding roles in your Discord server and note their IDs + +### Running the Bot + +```bash +cd csharp/DiscordBot +dotnet run +``` + +## How It Works + +1. **Language Detection**: The bot analyzes a user's public GitHub repositories to determine which programming languages they use +2. **Role Mapping**: Based on the detected languages and configured thresholds, the bot determines which roles the user should have +3. **Role Synchronization**: The bot adds relevant roles and removes roles for languages the user doesn't actively use +4. **Persistence**: User-GitHub mappings are stored locally for future synchronizations + +## Language Support + +The bot can detect and assign roles for the following languages: +- C#, C++, JavaScript, TypeScript, Python, Java +- Go, Rust, Ruby, PHP, Swift, Kotlin +- Dart, Scala, F#, Clojure, Haskell +- And more (easily configurable) + +## Architecture + +- **DiscordBotService**: Main bot service handling Discord events and commands +- **GitHubLanguageDetectionService**: Analyzes GitHub repositories for programming languages +- **ProgrammingLanguageRoleService**: Manages role synchronization logic +- **LanguageRoleModule**: Discord command handlers +- **Storage Integration**: Uses existing FileStorage for persistence + +## Contributing + +This bot follows the existing LinksPlatform Bot patterns and integrates with the existing storage and interface systems. \ No newline at end of file diff --git a/csharp/DiscordBot/Services/DiscordBotHostedService.cs b/csharp/DiscordBot/Services/DiscordBotHostedService.cs new file mode 100644 index 00000000..27571c61 --- /dev/null +++ b/csharp/DiscordBot/Services/DiscordBotHostedService.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace DiscordBot.Services +{ + public class DiscordBotHostedService : BackgroundService + { + private readonly DiscordBotService _discordBotService; + private readonly ILogger _logger; + + public DiscordBotHostedService(DiscordBotService discordBotService, ILogger logger) + { + _discordBotService = discordBotService; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await _discordBotService.StartAsync(); + _logger.LogInformation("Discord bot started successfully"); + + await Task.Delay(Timeout.Infinite, stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while running Discord bot"); + throw; + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await _discordBotService.StopAsync(); + await base.StopAsync(cancellationToken); + } + } +} \ No newline at end of file diff --git a/csharp/DiscordBot/Services/DiscordBotService.cs b/csharp/DiscordBot/Services/DiscordBotService.cs new file mode 100644 index 00000000..bd2b955b --- /dev/null +++ b/csharp/DiscordBot/Services/DiscordBotService.cs @@ -0,0 +1,141 @@ +using Discord; +using Discord.Commands; +using Discord.WebSocket; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using System.Reflection; +using Storage.Local; +using System; +using System.Linq; +using System.Threading.Tasks; + +namespace DiscordBot.Services +{ + public class DiscordBotService + { + private readonly DiscordSocketClient _client; + private readonly CommandService _commands; + private readonly DiscordBotSettings _settings; + private readonly ProgrammingLanguageRoleService _roleService; + private readonly ILogger _logger; + private readonly FileStorage _storage; + + public DiscordBotService( + IOptions settings, + ProgrammingLanguageRoleService roleService, + ILogger logger) + { + _settings = settings.Value; + _roleService = roleService; + _logger = logger; + _storage = new FileStorage(_settings.DatabasePath); + + var config = new DiscordSocketConfig + { + LogLevel = LogSeverity.Info, + MessageCacheSize = 100, + GatewayIntents = GatewayIntents.Guilds | GatewayIntents.GuildMembers | GatewayIntents.GuildMessages | GatewayIntents.MessageContent + }; + + _client = new DiscordSocketClient(config); + _commands = new CommandService(); + + _client.Log += LogAsync; + _client.Ready += ReadyAsync; + _client.MessageReceived += HandleCommandAsync; + _client.UserJoined += OnUserJoinedAsync; + } + + public async Task StartAsync() + { + await _commands.AddModulesAsync(Assembly.GetEntryAssembly(), null); + + await _client.LoginAsync(TokenType.Bot, _settings.Token); + await _client.StartAsync(); + } + + public async Task StopAsync() + { + await _client.LogoutAsync(); + await _client.StopAsync(); + } + + private Task LogAsync(LogMessage log) + { + _logger.LogInformation("{Source}: {Message}", log.Source, log.Message); + return Task.CompletedTask; + } + + private async Task ReadyAsync() + { + _logger.LogInformation("Discord bot is ready! Logged in as {Username}#{Discriminator}", + _client.CurrentUser.Username, _client.CurrentUser.Discriminator); + + var guild = _client.GetGuild(_settings.GuildId); + if (guild != null) + { + _logger.LogInformation("Connected to guild: {GuildName} ({GuildId})", guild.Name, guild.Id); + } + + await _client.SetGameAsync("Synchronizing programming language roles", type: ActivityType.Watching); + } + + private async Task HandleCommandAsync(SocketMessage messageParam) + { + if (messageParam is not SocketUserMessage message || message.Author.IsBot) + return; + + int argPos = 0; + if (!message.HasStringPrefix("!", ref argPos) && !message.HasMentionPrefix(_client.CurrentUser, ref argPos)) + return; + + var context = new SocketCommandContext(_client, message); + + var result = await _commands.ExecuteAsync(context, argPos, null); + + if (!result.IsSuccess && result.Error != CommandError.UnknownCommand) + { + _logger.LogWarning("Command execution failed: {Error} - {ErrorReason}", result.Error, result.ErrorReason); + await context.Channel.SendMessageAsync($"Error: {result.ErrorReason}"); + } + } + + private async Task OnUserJoinedAsync(SocketGuildUser user) + { + _logger.LogInformation("User {Username} joined the guild", user.Username); + + var channel = user.Guild.SystemChannel ?? user.Guild.DefaultChannel; + if (channel != null) + { + var embed = new EmbedBuilder() + .WithTitle("Welcome to LinksPlatform!") + .WithDescription($"Welcome {user.Mention}! 🎉\n\n" + + "To get programming language roles based on your GitHub activity, use:\n" + + "`!sync-roles `\n\n" + + "Use `!help` to see all available commands.") + .WithColor(Color.Green) + .WithThumbnailUrl(user.GetAvatarUrl() ?? user.GetDefaultAvatarUrl()) + .WithTimestamp(DateTimeOffset.Now) + .Build(); + + await channel.SendMessageAsync(embed: embed); + } + } + + public void StoreUserGitHubMapping(ulong discordUserId, string githubUsername) + { + _storage.AddLink(discordUserId.ToString(), githubUsername); + } + + public string? GetUserGitHubUsername(ulong discordUserId) + { + var links = _storage.GetLinks(); + return links.FirstOrDefault(l => l.Split(':')[0] == discordUserId.ToString())?.Split(':')[1]; + } + + public async Task SyncUserRolesAsync(ulong userId, string githubUsername) + { + await _roleService.SynchronizeUserRolesAsync(_client, userId, githubUsername); + } + } +} \ No newline at end of file diff --git a/csharp/DiscordBot/Services/GitHubLanguageDetectionService.cs b/csharp/DiscordBot/Services/GitHubLanguageDetectionService.cs new file mode 100644 index 00000000..60b6de49 --- /dev/null +++ b/csharp/DiscordBot/Services/GitHubLanguageDetectionService.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Octokit; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace DiscordBot.Services +{ + public class GitHubLanguageDetectionService + { + private readonly GitHubClient _gitHubClient; + private readonly ILogger _logger; + + public GitHubLanguageDetectionService(IOptions settings, ILogger logger) + { + _logger = logger; + _gitHubClient = new GitHubClient(new ProductHeaderValue("LinksPlatform-DiscordBot")) + { + Credentials = new Credentials(settings.Value.GitHubToken) + }; + } + + public async Task> GetUserProgrammingLanguagesAsync(string githubUsername) + { + try + { + _logger.LogInformation("Fetching programming languages for GitHub user: {Username}", githubUsername); + + var repositories = await _gitHubClient.Repository.GetAllForUser(githubUsername); + var languageStats = new Dictionary(); + + foreach (var repo in repositories.Where(r => !r.Fork)) + { + try + { + var languages = await _gitHubClient.Repository.GetAllLanguages(repo.Id); + + foreach (var language in languages) + { + if (languageStats.ContainsKey(language.Name)) + { + languageStats[language.Name] += (int)language.NumberOfBytes; + } + else + { + languageStats[language.Name] = (int)language.NumberOfBytes; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to get languages for repository {RepoName}", repo.Name); + } + } + + _logger.LogInformation("Found {Count} programming languages for user {Username}", languageStats.Count, githubUsername); + return languageStats; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch programming languages for user {Username}", githubUsername); + return new Dictionary(); + } + } + + public static List GetTopLanguages(Dictionary languageStats, int minBytes = 1000, int maxLanguages = 10) + { + return languageStats + .Where(kvp => kvp.Value >= minBytes) + .OrderByDescending(kvp => kvp.Value) + .Take(maxLanguages) + .Select(kvp => kvp.Key) + .ToList(); + } + } +} \ No newline at end of file diff --git a/csharp/DiscordBot/Services/ProgrammingLanguageRoleService.cs b/csharp/DiscordBot/Services/ProgrammingLanguageRoleService.cs new file mode 100644 index 00000000..c6c319d1 --- /dev/null +++ b/csharp/DiscordBot/Services/ProgrammingLanguageRoleService.cs @@ -0,0 +1,147 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace DiscordBot.Services +{ + public class ProgrammingLanguageRoleService + { + private readonly DiscordBotSettings _settings; + private readonly GitHubLanguageDetectionService _languageDetectionService; + private readonly ILogger _logger; + + public ProgrammingLanguageRoleService( + IOptions settings, + GitHubLanguageDetectionService languageDetectionService, + ILogger logger) + { + _settings = settings.Value; + _languageDetectionService = languageDetectionService; + _logger = logger; + } + + public async Task SynchronizeUserRolesAsync(DiscordSocketClient client, ulong userId, string githubUsername) + { + try + { + var guild = client.GetGuild(_settings.GuildId); + if (guild == null) + { + _logger.LogWarning("Guild with ID {GuildId} not found", _settings.GuildId); + return; + } + + var user = guild.GetUser(userId); + if (user == null) + { + _logger.LogWarning("User with ID {UserId} not found in guild", userId); + return; + } + + _logger.LogInformation("Synchronizing roles for user {Username} (Discord: {UserId}, GitHub: {GitHubUsername})", + user.Username, userId, githubUsername); + + var languageStats = await _languageDetectionService.GetUserProgrammingLanguagesAsync(githubUsername); + var topLanguages = GitHubLanguageDetectionService.GetTopLanguages(languageStats); + + var rolesToAdd = new List(); + var rolesToRemove = new List(); + + foreach (var language in _settings.LanguageRoles.Keys) + { + var roleId = _settings.LanguageRoles[language]; + var role = guild.GetRole(roleId); + + if (role == null) + { + _logger.LogWarning("Role with ID {RoleId} for language {Language} not found", roleId, language); + continue; + } + + var shouldHaveRole = ShouldUserHaveLanguageRole(language, topLanguages); + var currentlyHasRole = user.Roles.Any(r => r.Id == roleId); + + if (shouldHaveRole && !currentlyHasRole) + { + rolesToAdd.Add(role); + } + else if (!shouldHaveRole && currentlyHasRole) + { + rolesToRemove.Add(role); + } + } + + if (rolesToAdd.Any()) + { + await user.AddRolesAsync(rolesToAdd); + _logger.LogInformation("Added {Count} roles to user {Username}: {Roles}", + rolesToAdd.Count, user.Username, string.Join(", ", rolesToAdd.Select(r => r.Name))); + } + + if (rolesToRemove.Any()) + { + await user.RemoveRolesAsync(rolesToRemove); + _logger.LogInformation("Removed {Count} roles from user {Username}: {Roles}", + rolesToRemove.Count, user.Username, string.Join(", ", rolesToRemove.Select(r => r.Name))); + } + + if (!rolesToAdd.Any() && !rolesToRemove.Any()) + { + _logger.LogInformation("No role changes needed for user {Username}", user.Username); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to synchronize roles for user {UserId} with GitHub username {GitHubUsername}", + userId, githubUsername); + } + } + + private static bool ShouldUserHaveLanguageRole(string language, List userTopLanguages) + { + return userTopLanguages.Any(userLang => + string.Equals(userLang, language, StringComparison.OrdinalIgnoreCase) || + IsLanguageVariant(userLang, language)); + } + + private static bool IsLanguageVariant(string userLanguage, string roleLanguage) + { + var languageVariants = new Dictionary + { + ["C#"] = new[] { "csharp", "c-sharp" }, + ["C++"] = new[] { "cpp", "c-plus-plus" }, + ["JavaScript"] = new[] { "js", "javascript" }, + ["TypeScript"] = new[] { "ts", "typescript" }, + ["Python"] = new[] { "python", "py" }, + ["Java"] = new[] { "java" }, + ["Go"] = new[] { "golang", "go" }, + ["Rust"] = new[] { "rust", "rs" }, + ["Ruby"] = new[] { "ruby", "rb" }, + ["PHP"] = new[] { "php" }, + ["Swift"] = new[] { "swift" }, + ["Kotlin"] = new[] { "kotlin", "kt" }, + ["Dart"] = new[] { "dart" }, + ["Scala"] = new[] { "scala" }, + ["F#"] = new[] { "fsharp", "f-sharp" }, + ["Clojure"] = new[] { "clojure", "clj" }, + ["Haskell"] = new[] { "haskell", "hs" } + }; + + foreach (var variants in languageVariants.Values) + { + if (variants.Contains(userLanguage.ToLowerInvariant()) && + variants.Contains(roleLanguage.ToLowerInvariant())) + { + return true; + } + } + + return false; + } + } +} \ No newline at end of file diff --git a/csharp/DiscordBot/appsettings.json b/csharp/DiscordBot/appsettings.json new file mode 100644 index 00000000..980948de --- /dev/null +++ b/csharp/DiscordBot/appsettings.json @@ -0,0 +1,35 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "DiscordBot": { + "Token": "YOUR_DISCORD_BOT_TOKEN_HERE", + "GitHubToken": "YOUR_GITHUB_TOKEN_HERE", + "GuildId": 0, + "DatabasePath": "discord_bot.db", + "SyncIntervalHours": 24, + "LanguageRoles": { + "C#": 0, + "JavaScript": 0, + "TypeScript": 0, + "Python": 0, + "Java": 0, + "C++": 0, + "Go": 0, + "Rust": 0, + "Ruby": 0, + "PHP": 0, + "Swift": 0, + "Kotlin": 0, + "Dart": 0, + "Scala": 0, + "F#": 0, + "Clojure": 0, + "Haskell": 0 + } + } +} \ No newline at end of file diff --git a/csharp/Storage/LocalStorage/FileStorage.cs b/csharp/Storage/LocalStorage/FileStorage.cs index aa68fd6f..2228d140 100644 --- a/csharp/Storage/LocalStorage/FileStorage.cs +++ b/csharp/Storage/LocalStorage/FileStorage.cs @@ -327,6 +327,101 @@ public List GetFilesFromSet(string set) return files; } + /// + /// + /// Adds a simple key-value link mapping. + /// + /// + /// + /// + /// The key. + /// + /// + /// + /// The value. + /// + /// + public void AddLink(string key, string value) + { + var keyAddress = CreateString(key); + var valueAddress = CreateString(value); + _synchronizedLinks.GetOrCreate(keyAddress, valueAddress); + } + + /// + /// + /// Gets all key-value links as strings. + /// + /// + /// + /// + /// The list of key:value strings. + /// + /// + public List GetLinks() + { + var links = new List(); + var query = new Link(index: Any, source: Any, target: Any); + _synchronizedLinks.Each(link => + { + try + { + var source = _synchronizedLinks.GetSource(link); + var target = _synchronizedLinks.GetTarget(link); + + if (source != _fileMarker && source != _setMarker && source != _unicodeSequenceMarker && + source != _unicodeSymbolMarker && source != _meaningRoot && source != _negativeNumberIndex && + target != _fileMarker && target != _setMarker && target != _unicodeSequenceMarker && + target != _unicodeSymbolMarker && target != _meaningRoot && target != _negativeNumberIndex && + source != target) + { + var keyString = GetString(source); + var valueString = GetString(target); + if (!string.IsNullOrEmpty(keyString) && !string.IsNullOrEmpty(valueString)) + { + links.Add($"{keyString}:{valueString}"); + } + } + } + catch + { + // Skip links that can't be converted to strings + } + return _synchronizedLinks.Constants.Continue; + }, query); + return links; + } + + /// + /// + /// Adds a link to invite (for compatibility with existing bot). + /// + /// + /// + /// + /// The link. + /// + /// + public void AddLinkToIvite(string link) + { + AddLink("invite", link); + } + + /// + /// + /// Gets links to invite (for compatibility with existing bot). + /// + /// + /// + /// + /// The list of invite links. + /// + /// + public List GetLinksToInvite() + { + return GetLinks().Where(l => l.StartsWith("invite:")).Select(l => l.Substring(7)).ToList(); + } + // public void SetLastGithubMigrationTimeStamp() protected override void Dispose(bool manual, bool wasDisposed) From 38154c214e9a78cb214fa440db9fced44b2e157c Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 13 Sep 2025 07:24:09 +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 a32ae52e..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,5 +0,0 @@ -Issue to solve: https://github.com/linksplatform/Bot/issues/107 -Your prepared branch: issue-107-74ce9552 -Your prepared working directory: /tmp/gh-issue-solver-1757736784011 - -Proceed. \ No newline at end of file