Skip to content

Commit c6fa190

Browse files
konardclaude
andcommitted
Add user links management system with platform support
Implements ability to add, remove and list user links for supported platforms. This generalizes the GitHub profile feature to support multiple link types. Features: - Support for GitHub, StackOverflow, and GitLab platforms - Domain whitelist validation for security - User link storage in database - Three new bot triggers: AddUserLink, RemoveUserLink, ListUserLinks - Command examples and documentation Fixes #36 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 834eab7 commit c6fa190

File tree

8 files changed

+761
-1
lines changed

8 files changed

+761
-1
lines changed

csharp/Platform.Bot/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ private static async Task<int> Main(string[] args)
9595
var dbContext = new FileStorage(databaseFilePath?.FullName ?? new TemporaryFile().Filename);
9696
Console.WriteLine($"Bot has been started. {Environment.NewLine}Press CTRL+C to close");
9797
var githubStorage = new GitHubStorage(githubUserName, githubApiToken, githubApplicationName);
98-
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));
98+
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), new AddUserLinkTrigger(githubStorage, dbContext), new RemoveUserLinkTrigger(githubStorage, dbContext), new ListUserLinksTrigger(githubStorage, dbContext));
9999
var pullRequenstTracker = new PullRequestTracker(githubStorage, new MergeDependabotBumpsTrigger(githubStorage));
100100
var timestampTracker = new DateTimeTracker(githubStorage, new CreateAndSaveOrganizationRepositoriesMigrationTrigger(githubStorage, dbContext, Path.Combine(Directory.GetCurrentDirectory(), "/github-migrations")));
101101
var cancellation = new CancellationTokenSource();
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
using System;
2+
using System.Linq;
3+
using System.Text.RegularExpressions;
4+
using System.Threading.Tasks;
5+
using Interfaces;
6+
using Octokit;
7+
using Storage.Local;
8+
using Storage.Remote.GitHub;
9+
10+
namespace Platform.Bot.Triggers
11+
{
12+
using TContext = Issue;
13+
14+
/// <summary>
15+
/// <para>
16+
/// Represents the add user link trigger for handling user link addition commands.
17+
/// </para>
18+
/// <para></para>
19+
/// </summary>
20+
/// <seealso cref="ITrigger{TContext}"/>
21+
internal class AddUserLinkTrigger : ITrigger<TContext>
22+
{
23+
private readonly GitHubStorage _storage;
24+
private readonly FileStorage _fileStorage;
25+
private readonly Regex _commandPattern;
26+
27+
/// <summary>
28+
/// <para>
29+
/// Initializes a new <see cref="AddUserLinkTrigger"/> instance.
30+
/// </para>
31+
/// <para></para>
32+
/// </summary>
33+
/// <param name="storage">
34+
/// <para>The GitHub storage.</para>
35+
/// <para></para>
36+
/// </param>
37+
/// <param name="fileStorage">
38+
/// <para>The file storage.</para>
39+
/// <para></para>
40+
/// </param>
41+
public AddUserLinkTrigger(GitHubStorage storage, FileStorage fileStorage)
42+
{
43+
_storage = storage;
44+
_fileStorage = fileStorage;
45+
_commandPattern = new Regex(@"^add link (\w+) (.+)$", RegexOptions.IgnoreCase);
46+
}
47+
48+
/// <summary>
49+
/// <para>
50+
/// Determines whether this instance condition matches the context.
51+
/// </para>
52+
/// <para></para>
53+
/// </summary>
54+
/// <param name="context">
55+
/// <para>The issue context.</para>
56+
/// <para></para>
57+
/// </param>
58+
/// <returns>
59+
/// <para>True if the issue title matches the add link pattern.</para>
60+
/// <para></para>
61+
/// </returns>
62+
public async Task<bool> Condition(TContext context)
63+
{
64+
return _commandPattern.IsMatch(context.Title.Trim());
65+
}
66+
67+
/// <summary>
68+
/// <para>
69+
/// Executes the add user link action.
70+
/// </para>
71+
/// <para></para>
72+
/// </summary>
73+
/// <param name="context">
74+
/// <para>The issue context.</para>
75+
/// <para></para>
76+
/// </param>
77+
public async Task Action(TContext context)
78+
{
79+
var match = _commandPattern.Match(context.Title.Trim());
80+
if (!match.Success)
81+
{
82+
await _storage.CreateIssueComment(context.Repository.Id, context.Number, "❌ Invalid command format. Use: `add link <platform> <url>`");
83+
_storage.CloseIssue(context);
84+
return;
85+
}
86+
87+
var platform = match.Groups[1].Value;
88+
var url = match.Groups[2].Value;
89+
var username = context.User.Login;
90+
91+
// Validate platform is supported
92+
if (!DomainWhitelist.GetSupportedPlatforms().Any(p => string.Equals(p, platform, StringComparison.OrdinalIgnoreCase)))
93+
{
94+
var supportedPlatforms = string.Join(", ", DomainWhitelist.GetSupportedPlatforms());
95+
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
96+
$"❌ Platform '{platform}' is not supported. Supported platforms: {supportedPlatforms}");
97+
_storage.CloseIssue(context);
98+
return;
99+
}
100+
101+
// Validate URL is allowed for the platform
102+
if (!DomainWhitelist.IsUrlAllowed(platform, url))
103+
{
104+
var allowedDomains = string.Join(", ", DomainWhitelist.GetAllowedDomains(platform));
105+
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
106+
$"❌ URL '{url}' is not allowed for platform '{platform}'. Allowed domains: {allowedDomains}");
107+
_storage.CloseIssue(context);
108+
return;
109+
}
110+
111+
try
112+
{
113+
// Check if user already has a link for this platform
114+
var existingLinks = _fileStorage.GetUserLinks(username);
115+
var existingLink = existingLinks.Find(link => string.Equals(link.Platform, platform, StringComparison.OrdinalIgnoreCase));
116+
117+
if (existingLink != null)
118+
{
119+
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
120+
$"❌ You already have a {platform} link: {existingLink.Url}. Remove it first if you want to change it.");
121+
_storage.CloseIssue(context);
122+
return;
123+
}
124+
125+
// Add the user link
126+
_fileStorage.AddUserLink(username, platform, url);
127+
128+
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
129+
$"✅ Successfully added {platform} link for @{username}: {url}");
130+
}
131+
catch (Exception ex)
132+
{
133+
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
134+
$"❌ Error adding link: {ex.Message}");
135+
}
136+
137+
_storage.CloseIssue(context);
138+
}
139+
}
140+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
using System;
2+
using System.Linq;
3+
using System.Text;
4+
using System.Text.RegularExpressions;
5+
using System.Threading.Tasks;
6+
using Interfaces;
7+
using Octokit;
8+
using Storage.Local;
9+
using Storage.Remote.GitHub;
10+
11+
namespace Platform.Bot.Triggers
12+
{
13+
using TContext = Issue;
14+
15+
/// <summary>
16+
/// <para>
17+
/// Represents the list user links trigger for displaying user links.
18+
/// </para>
19+
/// <para></para>
20+
/// </summary>
21+
/// <seealso cref="ITrigger{TContext}"/>
22+
internal class ListUserLinksTrigger : ITrigger<TContext>
23+
{
24+
private readonly GitHubStorage _storage;
25+
private readonly FileStorage _fileStorage;
26+
private readonly Regex _listMyLinksPattern;
27+
private readonly Regex _listUserLinksPattern;
28+
29+
/// <summary>
30+
/// <para>
31+
/// Initializes a new <see cref="ListUserLinksTrigger"/> instance.
32+
/// </para>
33+
/// <para></para>
34+
/// </summary>
35+
/// <param name="storage">
36+
/// <para>The GitHub storage.</para>
37+
/// <para></para>
38+
/// </param>
39+
/// <param name="fileStorage">
40+
/// <para>The file storage.</para>
41+
/// <para></para>
42+
/// </param>
43+
public ListUserLinksTrigger(GitHubStorage storage, FileStorage fileStorage)
44+
{
45+
_storage = storage;
46+
_fileStorage = fileStorage;
47+
_listMyLinksPattern = new Regex(@"^(list my links|my links)$", RegexOptions.IgnoreCase);
48+
_listUserLinksPattern = new Regex(@"^list links (\w+)$", RegexOptions.IgnoreCase);
49+
}
50+
51+
/// <summary>
52+
/// <para>
53+
/// Determines whether this instance condition matches the context.
54+
/// </para>
55+
/// <para></para>
56+
/// </summary>
57+
/// <param name="context">
58+
/// <para>The issue context.</para>
59+
/// <para></para>
60+
/// </param>
61+
/// <returns>
62+
/// <para>True if the issue title matches any list links pattern.</para>
63+
/// <para></para>
64+
/// </returns>
65+
public async Task<bool> Condition(TContext context)
66+
{
67+
var title = context.Title.Trim();
68+
return _listMyLinksPattern.IsMatch(title) || _listUserLinksPattern.IsMatch(title);
69+
}
70+
71+
/// <summary>
72+
/// <para>
73+
/// Executes the list user links action.
74+
/// </para>
75+
/// <para></para>
76+
/// </summary>
77+
/// <param name="context">
78+
/// <para>The issue context.</para>
79+
/// <para></para>
80+
/// </param>
81+
public async Task Action(TContext context)
82+
{
83+
var title = context.Title.Trim();
84+
var targetUsername = context.User.Login; // Default to the requesting user
85+
var isRequestingOwnLinks = true;
86+
87+
// Check if requesting links for a specific user
88+
var userMatch = _listUserLinksPattern.Match(title);
89+
if (userMatch.Success)
90+
{
91+
targetUsername = userMatch.Groups[1].Value;
92+
isRequestingOwnLinks = string.Equals(targetUsername, context.User.Login, StringComparison.OrdinalIgnoreCase);
93+
}
94+
95+
try
96+
{
97+
var userLinks = _fileStorage.GetUserLinks(targetUsername);
98+
99+
if (!userLinks.Any())
100+
{
101+
var message = isRequestingOwnLinks
102+
? "You don't have any links registered."
103+
: $"User @{targetUsername} doesn't have any links registered.";
104+
105+
await _storage.CreateIssueComment(context.Repository.Id, context.Number, message);
106+
}
107+
else
108+
{
109+
var messageBuilder = new StringBuilder();
110+
var displayUsername = isRequestingOwnLinks ? "Your" : $"@{targetUsername}'s";
111+
messageBuilder.AppendLine($"## {displayUsername} Links\n");
112+
113+
foreach (var link in userLinks.OrderBy(l => l.Platform))
114+
{
115+
messageBuilder.AppendLine($"**{link.Platform}**: {link.Url}");
116+
}
117+
118+
messageBuilder.AppendLine($"\n*Total: {userLinks.Count} link{(userLinks.Count == 1 ? "" : "s")}*");
119+
120+
if (isRequestingOwnLinks)
121+
{
122+
messageBuilder.AppendLine("\n---");
123+
messageBuilder.AppendLine("**Commands:**");
124+
messageBuilder.AppendLine("- `add link <platform> <url>` - Add a new link");
125+
messageBuilder.AppendLine("- `remove link <platform>` - Remove a link");
126+
messageBuilder.AppendLine("- `my links` - View your links");
127+
128+
var supportedPlatforms = string.Join(", ", DomainWhitelist.GetSupportedPlatforms());
129+
messageBuilder.AppendLine($"\n**Supported platforms:** {supportedPlatforms}");
130+
}
131+
132+
await _storage.CreateIssueComment(context.Repository.Id, context.Number, messageBuilder.ToString());
133+
}
134+
}
135+
catch (Exception ex)
136+
{
137+
await _storage.CreateIssueComment(context.Repository.Id, context.Number,
138+
$"❌ Error retrieving links: {ex.Message}");
139+
}
140+
141+
_storage.CloseIssue(context);
142+
}
143+
}
144+
}

0 commit comments

Comments
 (0)