From b787a9e3c6236b0119b7f0cc7f44db8e6bfc7b3f Mon Sep 17 00:00:00 2001 From: skyjoshua Date: Wed, 15 Apr 2026 23:54:00 +0100 Subject: [PATCH] marriage. --- COMMANDS.md | 14 ++ SkyBot/Commands/RP/Marriage.cs | 346 +++++++++++++++++++++++++++++ SkyBot/Helpers/DatabaseHelper.cs | 49 ++++ SkyBot/Models/DatabaseHelper.cs | 32 --- SkyBot/Services/MarriageService.cs | 203 +++++++++++++++++ SkyBot/SkyBot.cs | 1 + 6 files changed, 613 insertions(+), 32 deletions(-) create mode 100644 SkyBot/Commands/RP/Marriage.cs create mode 100644 SkyBot/Helpers/DatabaseHelper.cs delete mode 100644 SkyBot/Models/DatabaseHelper.cs create mode 100644 SkyBot/Services/MarriageService.cs diff --git a/COMMANDS.md b/COMMANDS.md index 0a480c2..0c9b40f 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -61,6 +61,20 @@ All commands are prefixed with your configured prefix (e.g. `s/`). Arguments in All RP commands accept an optional `[@user]` argument or a message reply to target someone. +### Marriage + +| Command | Description | +|---|---| +| `marriage propose @user` | Propose to someone (reply to their message or mention them). Proposal expires after 5 minutes. | +| `marriage accept` | Accept a pending proposal | +| `marriage decline` | Decline a pending proposal | +| `marriage status` | Show your current marriage status | +| `marriage divorce` | End your current marriage | + +Marriages persist across bot restarts. + +### GIF Commands + | Command | Description | |---|---| | `angry` | Express anger | diff --git a/SkyBot/Commands/RP/Marriage.cs b/SkyBot/Commands/RP/Marriage.cs new file mode 100644 index 0000000..53433fc --- /dev/null +++ b/SkyBot/Commands/RP/Marriage.cs @@ -0,0 +1,346 @@ +using System.Collections.Concurrent; +using SkyBot.Helpers; +using SkyBot.Models; +using SkyBot.Services; +using Valour.Sdk.Models; + +namespace SkyBot.Commands +{ + public class Marriage : ICommand + { + public string Name => "marriage"; + public string[] Aliases => ["marry"]; + public string Description => "Marriage system — propose, accept, decline, check status, or divorce."; + public string Section => "RP"; + public string Usage => "marriage "; + + public async Task Execute(CommandContext ctx) + { + ConcurrentDictionary channelCache = ctx.ChannelCache; + long channelId = ctx.ChannelId; + + if (!channelCache.TryGetValue(channelId, out var channel)) return; + + string sub = ctx.Args.Length > 0 ? ctx.Args[0].ToLower() : "status"; + + switch (sub) + { + case "propose": + await HandlePropose(ctx, channel); + break; + case "accept": + await HandleAccept(ctx, channel); + break; + case "decline": + await HandleDecline(ctx, channel); + break; + case "divorce": + await HandleDivorce(ctx, channel); + break; + case "force": + await HandleForce(ctx, channel); + break; + case "status": + default: + await HandleStatus(ctx, channel); + break; + } + } + + private static async Task HandlePropose(CommandContext ctx, Channel channel) + { + long proposerId = ctx.Member.UserId; + string sender = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown"; + + long? targetUserId = null; + string? targetMention = null; + + // Prefer reply-to for target resolution + if (ctx.Message.ReplyToId is not null) + { + var replied = await ctx.Message.FetchReplyMessageAsync(); + if (replied is not null) + { + var author = await replied.FetchAuthorAsync(); + if (author is not null) + { + targetUserId = author.Id; + targetMention = $"«@u-{author.Id}»"; + } + } + } + + // Fall back to message mention + if (targetUserId is null && ctx.Message.Mentions is not null && ctx.Message.Mentions.Any()) + { + long memberId = ctx.Message.Mentions.First().TargetId; + var mentioned = await ctx.Planet.FetchMemberAsync(memberId); + if (mentioned is not null) + { + targetUserId = mentioned.UserId; + targetMention = $"«@u-{mentioned.UserId}»"; + } + } + + if (targetUserId is null) + { + await MessageHelper.ReplyAsync(ctx, channel, + $"Please reply to someone's message or mention them. Usage: `{Config.Prefix}marriage propose @user`"); + return; + } + + var result = MarriageService.Propose(proposerId, targetUserId.Value); + + switch (result) + { + case MarriageService.ProposeResult.SelfPropose: + await MessageHelper.ReplyAsync(ctx, channel, "You can't propose to yourself!"); + break; + case MarriageService.ProposeResult.AlreadyMarried: + await MessageHelper.ReplyAsync(ctx, channel, + $"You're already married, {sender}! You'd need to `{Config.Prefix}marriage divorce` first."); + break; + case MarriageService.ProposeResult.TargetAlreadyMarried: + await MessageHelper.ReplyAsync(ctx, channel, + $"{targetMention} is already married!"); + break; + case MarriageService.ProposeResult.AlreadyProposed: + await MessageHelper.ReplyAsync(ctx, channel, + $"You've already proposed to {targetMention}! Waiting for their response..."); + break; + case MarriageService.ProposeResult.Ok: + await MessageHelper.ReplyAsync(ctx, channel, + $"💍 {sender} gets down on one knee and proposes to {targetMention}!\n" + + $"{targetMention}, type `{Config.Prefix}marriage accept` to accept or `{Config.Prefix}marriage decline` to decline.\n" + + $"This proposal expires in 5 minutes."); + break; + } + } + + private static async Task HandleAccept(CommandContext ctx, Channel channel) + { + long acceptorId = ctx.Member.UserId; + string acceptorName = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown"; + + var (result, proposerId) = await MarriageService.AcceptAsync(acceptorId); + + switch (result) + { + case MarriageService.AcceptResult.NoPendingProposal: + await MessageHelper.ReplyAsync(ctx, channel, + "You don't have any pending proposals to accept."); + break; + case MarriageService.AcceptResult.Expired: + await MessageHelper.ReplyAsync(ctx, channel, + $"That proposal from «@u-{proposerId}» has expired."); + break; + case MarriageService.AcceptResult.AlreadyMarried: + await MessageHelper.ReplyAsync(ctx, channel, + "One of you is already married! The proposal has been cancelled."); + break; + case MarriageService.AcceptResult.Ok: + await MessageHelper.ReplyAsync(ctx, channel, + $"💒 {acceptorName} has accepted «@u-{proposerId}»'s proposal! They are now married! 🎉"); + break; + } + } + + private static async Task HandleDecline(CommandContext ctx, Channel channel) + { + long acceptorId = ctx.Member.UserId; + string acceptorName = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown"; + + var (result, proposerId) = MarriageService.Decline(acceptorId); + + switch (result) + { + case MarriageService.DeclineResult.NoPendingProposal: + await MessageHelper.ReplyAsync(ctx, channel, + "You don't have any pending proposals to decline."); + break; + case MarriageService.DeclineResult.Ok: + await MessageHelper.ReplyAsync(ctx, channel, + $"💔 {acceptorName} has declined «@u-{proposerId}»'s proposal."); + break; + } + } + + private static async Task HandleStatus(CommandContext ctx, Channel channel) + { + long userId = ctx.Member.UserId; + string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown"; + + if (ctx.Message.Mentions is not null && ctx.Message.Mentions.Any()) + { + long memberId = ctx.Message.Mentions.First().TargetId; + var mentioned = await ctx.Planet.FetchMemberAsync(memberId); + if (mentioned is not null) + { + userId = mentioned.UserId; + name = mentioned.Nickname ?? mentioned.User?.Name ?? "Unknown"; + } + } + + long? partnerId = MarriageService.GetPartner(userId); + + if (partnerId is null) + { + await MessageHelper.ReplyAsync(ctx, channel, + $"💔 {name} is not currently married."); + } + else + { + await MessageHelper.ReplyAsync(ctx, channel, + $"💑 {name} is married to «@u-{partnerId}»!"); + } + } + + private static async Task HandleForce(CommandContext ctx, Channel channel) + { + if (ctx.Member.UserId != Config.OwnerId) + { + await MessageHelper.ReplyAsync(ctx, channel, "You don't have permission to use this command."); + return; + } + + string action = ctx.Args.Length > 1 ? ctx.Args[1].ToLower() : ""; + + if (action is not ("marry" or "divorce")) + { + await MessageHelper.ReplyAsync(ctx, channel, + $"Usage: `{Config.Prefix}marriage force marry @user1 @user2` or `{Config.Prefix}marriage force divorce @user1`"); + return; + } + + var mentions = ctx.Message.Mentions ?? []; + + if (action == "marry") + { + if (mentions.Count < 2) + { + await MessageHelper.ReplyAsync(ctx, channel, + $"Please mention two users. Usage: `{Config.Prefix}marriage force marry @user1 @user2`"); + return; + } + + var member1 = await ctx.Planet.FetchMemberAsync(mentions[0].TargetId); + var member2 = await ctx.Planet.FetchMemberAsync(mentions[1].TargetId); + + if (member1 is null || member2 is null) + { + await MessageHelper.ReplyAsync(ctx, channel, "Could not find one or both of those members."); + return; + } + + var result = await MarriageService.ForceMarryAsync(member1.UserId, member2.UserId); + + switch (result) + { + case MarriageService.ForceMarryResult.SameUser: + await MessageHelper.ReplyAsync(ctx, channel, "You can't marry someone to themselves!"); + break; + case MarriageService.ForceMarryResult.User1AlreadyMarried: + await MessageHelper.ReplyAsync(ctx, channel, + $"«@u-{member1.UserId}» is already married."); + break; + case MarriageService.ForceMarryResult.User2AlreadyMarried: + await MessageHelper.ReplyAsync(ctx, channel, + $"«@u-{member2.UserId}» is already married."); + break; + case MarriageService.ForceMarryResult.Ok: + await MessageHelper.ReplyAsync(ctx, channel, + $"💒 «@u-{member1.UserId}» and «@u-{member2.UserId}» are now married! 🎉"); + break; + } + } + else // divorce + { + if (mentions.Count < 1) + { + await MessageHelper.ReplyAsync(ctx, channel, + $"Please mention a user. Usage: `{Config.Prefix}marriage force divorce @user`"); + return; + } + + var member = await ctx.Planet.FetchMemberAsync(mentions[0].TargetId); + + if (member is null) + { + await MessageHelper.ReplyAsync(ctx, channel, "Could not find that member."); + return; + } + + var (result, partnerId) = await MarriageService.DivorceAsync(member.UserId); + + switch (result) + { + case MarriageService.DivorceResult.NotMarried: + await MessageHelper.ReplyAsync(ctx, channel, + $"«@u-{member.UserId}» is not married."); + break; + case MarriageService.DivorceResult.Ok: + await MessageHelper.ReplyAsync(ctx, channel, + $"💔 «@u-{member.UserId}» and «@u-{partnerId}» have been forcefully divorced."); + break; + } + } + } + + private static async Task HandleDivorce(CommandContext ctx, Channel channel) + { + long userId = ctx.Member.UserId; + string name = ctx.Member.Nickname ?? ctx.Member.User?.Name ?? "Unknown"; + string action = ctx.Args.Length > 1 ? ctx.Args[1].ToLower() : ""; + + if (action == "cancel") + { + if (MarriageService.CancelDivorce(userId)) + await MessageHelper.ReplyAsync(ctx, channel, $"Divorce cancelled, {name}."); + else + await MessageHelper.ReplyAsync(ctx, channel, "You don't have a pending divorce to cancel."); + return; + } + + if (action == "confirm") + { + var (result, partnerId) = await MarriageService.DivorceAsync(userId, confirmed: true); + + switch (result) + { + case MarriageService.DivorceResult.NotMarried: + await MessageHelper.ReplyAsync(ctx, channel, $"You're not married, {name}."); + break; + case MarriageService.DivorceResult.NoConfirmation: + await MessageHelper.ReplyAsync(ctx, channel, + $"No pending divorce found. Run `{Config.Prefix}marriage divorce` first."); + break; + case MarriageService.DivorceResult.ConfirmationExpired: + await MessageHelper.ReplyAsync(ctx, channel, + $"Your divorce confirmation expired. Run `{Config.Prefix}marriage divorce` again to start over."); + break; + case MarriageService.DivorceResult.Ok: + await MessageHelper.ReplyAsync(ctx, channel, + $"💔 {name} and «@u-{partnerId}» have divorced."); + break; + } + return; + } + + // Initial divorce request — ask for confirmation + long? partnerId2 = MarriageService.GetPartner(userId); + if (partnerId2 is null) + { + await MessageHelper.ReplyAsync(ctx, channel, $"You're not married, {name}."); + return; + } + + bool requested = MarriageService.RequestDivorceConfirmation(userId); + if (requested) + { + await MessageHelper.ReplyAsync(ctx, channel, + $"Are you sure you want to divorce «@u-{partnerId2}»?\n" + + $"Type `{Config.Prefix}marriage divorce confirm` within 60 seconds to confirm, or `{Config.Prefix}marriage divorce cancel` to cancel."); + } + } + } +} diff --git a/SkyBot/Helpers/DatabaseHelper.cs b/SkyBot/Helpers/DatabaseHelper.cs new file mode 100644 index 0000000..e186b3c --- /dev/null +++ b/SkyBot/Helpers/DatabaseHelper.cs @@ -0,0 +1,49 @@ +using Microsoft.Data.Sqlite; + +namespace SkyBot.Helpers +{ + public static class DatabaseHelper + { + private const string ConnectionString = "Data Source=database.db"; + + public static SqliteConnection GetConnection() + { + SqliteConnection connection = new SqliteConnection(ConnectionString); + connection.Open(); + return connection; + } + + public static async Task InitializeAsync() + { + using SqliteConnection connection = GetConnection(); + + using (SqliteCommand cmd = connection.CreateCommand()) + { + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS WelcomeConfigs ( + PlanetId INTEGER PRIMARY KEY, + ChannelId INTEGER NOT NULL DEFAULT 0, + Message TEXT NOT NULL DEFAULT 'Welcome to the planet, {username}!', + Active INTEGER NOT NULL DEFAULT 0 + ); + "; + await cmd.ExecuteNonQueryAsync(); + } + + using (SqliteCommand cmd = connection.CreateCommand()) + { + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS Marriages ( + UserId1 INTEGER NOT NULL, + UserId2 INTEGER NOT NULL, + MarriedAt TEXT NOT NULL, + PRIMARY KEY (UserId1, UserId2) + ); + "; + await cmd.ExecuteNonQueryAsync(); + } + + Console.WriteLine("Database initialized."); + } + } +} \ No newline at end of file diff --git a/SkyBot/Models/DatabaseHelper.cs b/SkyBot/Models/DatabaseHelper.cs deleted file mode 100644 index 32864ba..0000000 --- a/SkyBot/Models/DatabaseHelper.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace SkyBot.Helpers -{ - public static class DatabaseHelper - { - private const string ConnectionString = "Data Source=database.db"; - - public static SqliteConnection GetConnection() - { - SqliteConnection connection = new SqliteConnection(ConnectionString); - connection.Open(); - return connection; - } - - public static async Task InitializeAsync() - { - using SqliteConnection connection = GetConnection(); - using SqliteCommand cmd = connection.CreateCommand(); - cmd.CommandText = @" - CREATE TABLE IF NOT EXISTS WelcomeConfigs ( - PlanetId INTEGER PRIMARY KEY, - ChannelId INTEGER NOT NULL DEFAULT 0, - Message TEXT NOT NULL DEFAULT 'Welcome to the planet, {username}!', - Active INTEGER NOT NULL DEFAULT 0 - ); - "; - await cmd.ExecuteNonQueryAsync(); - Console.WriteLine("Database initialized."); - } - } -} \ No newline at end of file diff --git a/SkyBot/Services/MarriageService.cs b/SkyBot/Services/MarriageService.cs new file mode 100644 index 0000000..99d50f5 --- /dev/null +++ b/SkyBot/Services/MarriageService.cs @@ -0,0 +1,203 @@ +using System.Collections.Concurrent; +using Microsoft.Data.Sqlite; +using SkyBot.Helpers; + +namespace SkyBot.Services +{ + public static class MarriageService + { + // userId -> partnerId (stored in both directions) + private static readonly ConcurrentDictionary _marriages = new(); + + // targetId -> (proposerId, expiresAt) + private static readonly ConcurrentDictionary _pendingProposals = new(); + + private static readonly TimeSpan ProposalTimeout = TimeSpan.FromMinutes(5); + + public static async Task InitializeAsync() + { + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT UserId1, UserId2 FROM Marriages"; + using SqliteDataReader reader = await cmd.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + long u1 = (long)reader["UserId1"]; + long u2 = (long)reader["UserId2"]; + _marriages[u1] = u2; + _marriages[u2] = u1; + } + Console.WriteLine($"MarriageService initialized. Loaded {_marriages.Count / 2} marriages from database."); + } + + public static long? GetPartner(long userId) + { + return _marriages.TryGetValue(userId, out long partnerId) ? partnerId : null; + } + + public enum ProposeResult + { + Ok, + SelfPropose, + AlreadyMarried, + TargetAlreadyMarried, + AlreadyProposed + } + + public static ProposeResult Propose(long proposerId, long targetId) + { + if (proposerId == targetId) return ProposeResult.SelfPropose; + if (_marriages.ContainsKey(proposerId)) return ProposeResult.AlreadyMarried; + if (_marriages.ContainsKey(targetId)) return ProposeResult.TargetAlreadyMarried; + + // Check if there's already a non-expired proposal from this proposer to this target + if (_pendingProposals.TryGetValue(targetId, out var existing) + && existing.ProposerId == proposerId + && existing.ExpiresAt > DateTime.UtcNow) + return ProposeResult.AlreadyProposed; + + _pendingProposals[targetId] = (proposerId, DateTime.UtcNow.Add(ProposalTimeout)); + return ProposeResult.Ok; + } + + public enum AcceptResult + { + Ok, + NoPendingProposal, + Expired, + AlreadyMarried + } + + public static async Task<(AcceptResult Result, long ProposerId)> AcceptAsync(long acceptorId) + { + if (!_pendingProposals.TryRemove(acceptorId, out var proposal)) + return (AcceptResult.NoPendingProposal, 0); + + if (proposal.ExpiresAt <= DateTime.UtcNow) + return (AcceptResult.Expired, proposal.ProposerId); + + if (_marriages.ContainsKey(acceptorId) || _marriages.ContainsKey(proposal.ProposerId)) + return (AcceptResult.AlreadyMarried, proposal.ProposerId); + + long u1 = Math.Min(acceptorId, proposal.ProposerId); + long u2 = Math.Max(acceptorId, proposal.ProposerId); + string marriedAt = DateTime.UtcNow.ToString("o"); + + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT OR IGNORE INTO Marriages (UserId1, UserId2, MarriedAt) VALUES ($u1, $u2, $at)"; + cmd.Parameters.AddWithValue("$u1", u1); + cmd.Parameters.AddWithValue("$u2", u2); + cmd.Parameters.AddWithValue("$at", marriedAt); + await cmd.ExecuteNonQueryAsync(); + + _marriages[acceptorId] = proposal.ProposerId; + _marriages[proposal.ProposerId] = acceptorId; + + return (AcceptResult.Ok, proposal.ProposerId); + } + + public enum DeclineResult + { + Ok, + NoPendingProposal + } + + public static (DeclineResult Result, long ProposerId) Decline(long acceptorId) + { + if (!_pendingProposals.TryRemove(acceptorId, out var proposal)) + return (DeclineResult.NoPendingProposal, 0); + + return (DeclineResult.Ok, proposal.ProposerId); + } + + public enum ForceMarryResult + { + Ok, + SameUser, + User1AlreadyMarried, + User2AlreadyMarried + } + + public static async Task ForceMarryAsync(long userId1, long userId2) + { + if (userId1 == userId2) return ForceMarryResult.SameUser; + if (_marriages.ContainsKey(userId1)) return ForceMarryResult.User1AlreadyMarried; + if (_marriages.ContainsKey(userId2)) return ForceMarryResult.User2AlreadyMarried; + + long u1 = Math.Min(userId1, userId2); + long u2 = Math.Max(userId1, userId2); + string marriedAt = DateTime.UtcNow.ToString("o"); + + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = "INSERT OR IGNORE INTO Marriages (UserId1, UserId2, MarriedAt) VALUES ($u1, $u2, $at)"; + cmd.Parameters.AddWithValue("$u1", u1); + cmd.Parameters.AddWithValue("$u2", u2); + cmd.Parameters.AddWithValue("$at", marriedAt); + await cmd.ExecuteNonQueryAsync(); + + _marriages[userId1] = userId2; + _marriages[userId2] = userId1; + + return ForceMarryResult.Ok; + } + + // userId -> confirmationExpiresAt + private static readonly ConcurrentDictionary _pendingDivorces = new(); + + private static readonly TimeSpan DivorceConfirmTimeout = TimeSpan.FromSeconds(60); + + public static bool RequestDivorceConfirmation(long userId) + { + if (!_marriages.ContainsKey(userId)) return false; + _pendingDivorces[userId] = DateTime.UtcNow.Add(DivorceConfirmTimeout); + return true; + } + + public static bool CancelDivorce(long userId) + { + return _pendingDivorces.TryRemove(userId, out _); + } + + public enum DivorceResult + { + Ok, + NotMarried, + NoConfirmation, + ConfirmationExpired + } + + public static async Task<(DivorceResult Result, long PartnerId)> DivorceAsync(long userId, bool confirmed = false) + { + if (!_marriages.ContainsKey(userId)) + return (DivorceResult.NotMarried, 0); + + if (!confirmed) + return (DivorceResult.NoConfirmation, 0); + + if (!_pendingDivorces.TryRemove(userId, out var expiresAt)) + return (DivorceResult.NoConfirmation, 0); + + if (expiresAt <= DateTime.UtcNow) + return (DivorceResult.ConfirmationExpired, 0); + + if (!_marriages.TryRemove(userId, out long partnerId)) + return (DivorceResult.NotMarried, 0); + + _marriages.TryRemove(partnerId, out _); + + long u1 = Math.Min(userId, partnerId); + long u2 = Math.Max(userId, partnerId); + + using SqliteConnection connection = DatabaseHelper.GetConnection(); + using SqliteCommand cmd = connection.CreateCommand(); + cmd.CommandText = "DELETE FROM Marriages WHERE UserId1 = $u1 AND UserId2 = $u2"; + cmd.Parameters.AddWithValue("$u1", u1); + cmd.Parameters.AddWithValue("$u2", u2); + await cmd.ExecuteNonQueryAsync(); + + return (DivorceResult.Ok, partnerId); + } + } +} diff --git a/SkyBot/SkyBot.cs b/SkyBot/SkyBot.cs index 58fd5b7..7cc6fac 100644 --- a/SkyBot/SkyBot.cs +++ b/SkyBot/SkyBot.cs @@ -24,6 +24,7 @@ namespace SkyBot StartTime = DateTime.UtcNow; await DatabaseHelper.InitializeAsync(); await WelcomeService.InitializeAsync(); + await MarriageService.InitializeAsync(); await BotService.InitializeBotAsync(_client, _channelCache, _initializedPlanets); } }