diff --git a/src/VainBot/Classes/FriendTime/FriendTime.cs b/src/VainBot/Classes/FriendTime/FriendTime.cs new file mode 100644 index 0000000..a0f9087 --- /dev/null +++ b/src/VainBot/Classes/FriendTime/FriendTime.cs @@ -0,0 +1,17 @@ +using System; + +namespace VainBot.Classes.FriendTime +{ + public class FriendTime + { + public int Id { get; set; } + + public long GuildId { get; set; } + + public long ChannelId { get; set; } + + public long UserId { get; set; } + + public string Timezone { get; set; } + } +} diff --git a/src/VainBot/Program.cs b/src/VainBot/Program.cs index 7d93722..98fe577 100644 --- a/src/VainBot/Program.cs +++ b/src/VainBot/Program.cs @@ -1,4 +1,4 @@ -using Discord; +using Discord; using Discord.Commands; using Discord.Interactions; using Discord.Rest; @@ -111,6 +111,7 @@ private IServiceProvider ConfigureServices() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton(httpClient) .AddSingleton(googleYtSvc) .AddLogging(o => diff --git a/src/VainBot/Services/FriendTimeService.cs b/src/VainBot/Services/FriendTimeService.cs new file mode 100644 index 0000000..1ee9f92 --- /dev/null +++ b/src/VainBot/Services/FriendTimeService.cs @@ -0,0 +1,101 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using VainBot.Classes.FriendTime; +using VainBot.Infrastructure; + +namespace VainBot.Services +{ + public class FriendTimeService + { + private readonly ILogger _logger; + private readonly IServiceProvider _provider; + + public FriendTimeService(ILogger logger, IServiceProvider provider) + { + _logger = logger; + _provider = provider; + } + + public async Task AddOrUpdateFriendTimeAsync(ulong guildId, ulong channelId, ulong userId, string timezoneId) + { + try + { + using var db = _provider.GetRequiredService(); + + var existing = await db.FriendTimes + .FirstOrDefaultAsync(f => f.GuildId == (long)guildId && f.ChannelId == (long)channelId && f.UserId == (long)userId); + + if (existing != null) + { + existing.Timezone = timezoneId; + db.FriendTimes.Update(existing); + } + else + { + var newFt = new FriendTime + { + GuildId = (long)guildId, + ChannelId = (long)channelId, + UserId = (long)userId, + Timezone = timezoneId + }; + db.FriendTimes.Add(newFt); + } + + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Error updating database in friend time service: add/update friend time"); + throw; + } + } + + public async Task RemoveFriendTimeAsync(ulong guildId, ulong channelId, ulong userId) + { + try + { + using var db = _provider.GetRequiredService(); + + var existing = await db.FriendTimes + .FirstOrDefaultAsync(f => f.GuildId == (long)guildId && f.ChannelId == (long)channelId && f.UserId == (long)userId); + + if (existing != null) + { + db.FriendTimes.Remove(existing); + await db.SaveChangesAsync(); + return true; + } + + return false; + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Error updating database in friend time service: remove friend time"); + throw; + } + } + + public async Task> GetFriendTimesForChannelAsync(ulong guildId, ulong channelId) + { + try + { + using var db = _provider.GetRequiredService(); + + return await db.FriendTimes + .Where(f => f.GuildId == (long)guildId && f.ChannelId == (long)channelId) + .ToListAsync(); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "Error querying database in friend time service: get friend times for channel"); + return new List(); + } + } + } +} diff --git a/src/VainBot/SlashCommandModules/FriendTimeSlashCommandModule.cs b/src/VainBot/SlashCommandModules/FriendTimeSlashCommandModule.cs new file mode 100644 index 0000000..fef0cf2 --- /dev/null +++ b/src/VainBot/SlashCommandModules/FriendTimeSlashCommandModule.cs @@ -0,0 +1,185 @@ +using Discord; +using Discord.Interactions; +using Discord.WebSocket; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using VainBot.Services; + +namespace VainBot.SlashCommandModules +{ + [Group("friendtime", "Display or configure timezone for users in a channel")] + public class FriendTimeSlashCommandModule : InteractionModuleBase + { + private readonly FriendTimeService _friendTimeSvc; + private readonly DiscordSocketClient _client; + private readonly ILogger _logger; + + public FriendTimeSlashCommandModule( + FriendTimeService friendTimeSvc, + DiscordSocketClient client, + ILogger logger) + { + _friendTimeSvc = friendTimeSvc; + _client = client; + _logger = logger; + } + + private ulong EffectiveChannelId => Context.Channel is SocketThreadChannel threadChannel + ? threadChannel.ParentChannel.Id + : Context.Channel.Id; + + [SlashCommand("now", "Show the local time for everyone in this channel who configured their timezone")] + [CommandContextType(InteractionContextType.Guild)] + public async Task FriendTimeNow() + { + if (Context.Guild == null) + { + await RespondAsync("This command can only be used in a server channel.", ephemeral: true); + return; + } + + await DeferAsync(); + + var friends = await _friendTimeSvc.GetFriendTimesForChannelAsync(Context.Guild.Id, EffectiveChannelId); + + if (friends == null || friends.Count == 0) + { + await FollowupAsync("No one in this channel has configured their timezone yet. Use `/friendtime add` to add yourself!"); + return; + } + + var lines = new List(); + var sortedFriends = friends.OrderBy(f => + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(f.Timezone).GetUtcOffset(DateTimeOffset.UtcNow); + } + catch + { + return TimeSpan.Zero; + } + }); + + foreach (var friend in sortedFriends) + { + TimeZoneInfo tz; + try + { + tz = TimeZoneInfo.FindSystemTimeZoneById(friend.Timezone); + } + catch + { + _logger.LogWarning($"Invalid timezone '{friend.Timezone}' configured for user {friend.UserId} in channel {friend.ChannelId}."); + continue; + } + + var localTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, tz); + + // Format: h:mm tt (e.g. 3:42 AM or 3:42 PM) + var timeString = localTime.ToString("h:mm tt"); + + // Compact representation using user mention (renders name and enables hover avatar) + lines.Add($"<@{(ulong)friend.UserId}> • **{timeString}**"); + } + + if (lines.Count == 0) + { + await FollowupAsync("No configured timezones could be resolved. Use `/friendtime add` to configure a valid timezone!"); + return; + } + + var embed = new EmbedBuilder() + .WithTitle("Friend Times") + .WithDescription(string.Join("\n", lines)) + .WithColor(Color.Blue) + .WithFooter("Use /friendtime add to add yourself") + .Build(); + + await FollowupAsync(embed: embed); + } + + [SlashCommand("add", "Add or update a timezone for a user (only admins can add for other users)")] + [CommandContextType(InteractionContextType.Guild)] + public async Task FriendTimeAdd( + [Summary(description: "IANA Time Zone ID (e.g., Europe/Bucharest, America/New_York)")] string timezone, + [Summary(description: "The user to add/update (admin only)")] SocketGuildUser user = null) + { + if (Context.Guild == null) + { + await RespondAsync("This command can only be used in a server channel.", ephemeral: true); + return; + } + + var targetUser = user ?? (SocketGuildUser)Context.User; + var isSelf = targetUser.Id == Context.User.Id; + var isAdmin = Context.User is SocketGuildUser guildUser && guildUser.GuildPermissions.Administrator; + + if (!isSelf && !isAdmin) + { + await RespondAsync("You do not have permission to add or overwrite another user's timezone. Only server administrators can do this.", ephemeral: true); + return; + } + + // Validate Time Zone + TimeZoneInfo tz; + try + { + tz = TimeZoneInfo.FindSystemTimeZoneById(timezone); + } + catch (TimeZoneNotFoundException) + { + await RespondAsync($"`{timezone}` is not a valid IANA Time Zone ID (e.g. `Europe/Bucharest`, `America/New_York`). Please check the ID and try again.", ephemeral: true); + return; + } + catch (InvalidTimeZoneException) + { + await RespondAsync($"`{timezone}` is an invalid timezone on this system.", ephemeral: true); + return; + } + + await _friendTimeSvc.AddOrUpdateFriendTimeAsync(Context.Guild.Id, EffectiveChannelId, targetUser.Id, tz.Id); + + var userDisplay = isSelf ? "Your" : $"{targetUser.Mention}'s"; + await RespondAsync($"{userDisplay} timezone for this channel has been set to `{tz.Id}`."); + } + + [SlashCommand("remove", "Remove a user's timezone configuration (only admins can remove for other users)")] + [CommandContextType(InteractionContextType.Guild)] + public async Task FriendTimeRemove( + [Summary(description: "The user to remove (admin only)")] SocketGuildUser user = null) + { + if (Context.Guild == null) + { + await RespondAsync("This command can only be used in a server channel.", ephemeral: true); + return; + } + + var targetUser = user ?? (SocketGuildUser)Context.User; + var isSelf = targetUser.Id == Context.User.Id; + var isAdmin = Context.User is SocketGuildUser guildUser && guildUser.GuildPermissions.Administrator; + + if (!isSelf && !isAdmin) + { + await RespondAsync("You do not have permission to remove another user's timezone. Only server administrators can do this.", ephemeral: true); + return; + } + + var removed = await _friendTimeSvc.RemoveFriendTimeAsync(Context.Guild.Id, EffectiveChannelId, targetUser.Id); + + if (removed) + { + var userDisplay = isSelf ? "Your" : $"{targetUser.Mention}'s"; + await RespondAsync($"{userDisplay} timezone configuration has been removed from this channel."); + } + else + { + var userDisplay = isSelf ? "You do not have" : $"{targetUser.Mention} does not have"; + await RespondAsync($"{userDisplay} a timezone configuration set up in this channel.", ephemeral: true); + } + } + } +}