Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions docs-website/docs/server-features/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,44 @@ In the distributed deployment, the bot runs in its own container, see
| `/guild <name>` | The guild master, the number of members and the members which are online |
| `/events` | The events of the next 24 hours, like mini games and invasions |
| `/rank` | The ten best characters by resets, master level and level |
| `/link <code>` | Links the Discord user to the game account, see [Linking accounts](#linking-accounts) |
| `/unlink` | Removes the link to the game account |
| `/character <name>` | Selects the character of the account, as which the player appears |

The commands only show information; they don't change anything in the game.
Additionally, administrators of the Discord server can use
[`/openmu setup`](#setting-up-your-discord-server).
Additionally, players can [link their account](#linking-accounts) with
`/link`, `/unlink` and `/character`, and administrators of the Discord server
can use [`/openmu setup`](#setting-up-your-discord-server).

### Linking accounts

Players can link their Discord user to their game account:

1. In the game, the player enters `/discord link`. The game shows a one-time
code like `ABCD-EFGH`, which is valid for 10 minutes.
2. In Discord, the player enters `/link ABCD-EFGH`.

The link is to the account, not to a character. The player appears as the
character which requested the code, and chooses another character of the account
with `/character <name>` in Discord. A Discord user can be linked to one account,
and an account to one Discord user; linking again replaces the previous link.
Because Discord users are the same on every Discord server, the link counts on
every Discord server the bot is in.

`/discord` in the game shows the link, and `/discord unlink` in the game or
`/unlink` in Discord removes it. In the admin panel, the *Links* button in the
account list shows the links of an account and can remove them.

Linked users get the role *Linked Player* of the
[layout](#setting-up-your-discord-server) on your Discord server, so you can,
for example, open channels only for players. The bot needs the *Manage Roles*
permission for it, and its own role has to be above *Linked Player* in the role
list. When a link is removed, also in the admin panel of the all-in-one
deployment, the role is removed again.

The answers to these commands are only visible to the player who used them.
The codes are stored as hash in the database, and every code can only be used
once.

When you configure the identifier of your Discord server (`Discord__Bot__GuildId`),
the commands are available immediately. Otherwise, Discord may take up to an hour
Expand Down
67 changes: 67 additions & 0 deletions src/DataModel/Entities/AccountExternalLink.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// <copyright file="AccountExternalLink.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.DataModel.Entities;

/// <summary>
/// A link of an <see cref="Account"/> to a user of an external service, e.g. Discord.
/// </summary>
/// <remarks>
/// The link is created in two steps: In the game, the player requests a one-time code, which
/// is stored as hash with an expiration. In the external service, the user enters the code,
/// which links the user to the account.
/// It's not part of the <see cref="Account"/> aggregate, because the external service links it
/// while a game server may hold the account in memory. That's why it refers to its account by identifier.
/// </remarks>
[AggregateRoot]
public class AccountExternalLink
{
/// <summary>
/// Gets or sets the identifier.
/// </summary>
public Guid Id { get; set; }

/// <summary>
/// Gets or sets the identifier of the linked account.
/// </summary>
public Guid AccountId { get; set; }

/// <summary>
/// Gets or sets the name of the external service, e.g. <c>discord</c>.
/// An account can be linked to one user per service.
/// </summary>
public string Provider { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the identifier of the user in the external service;
/// <c>null</c>, if the link isn't confirmed yet.
/// A user can be linked to one account.
/// </summary>
public string? ExternalUserId { get; set; }

/// <summary>
/// Gets or sets the name of the user in the external service, at the time of linking.
/// </summary>
public string? ExternalUserName { get; set; }

/// <summary>
/// Gets or sets the name of the character, as which the user appears, e.g. when chatting from the external service.
/// </summary>
public string? CharacterName { get; set; }

/// <summary>
/// Gets or sets the timestamp at which the user was linked.
/// </summary>
public DateTime? LinkedAt { get; set; }

/// <summary>
/// Gets or sets the hash of the pending one-time code; <c>null</c>, if there is none.
/// </summary>
public string? CodeHash { get; set; }

/// <summary>
/// Gets or sets the timestamp until which the pending code is valid.
/// </summary>
public DateTime? CodeExpiresAt { get; set; }
}
126 changes: 126 additions & 0 deletions src/Discord/DiscordAccountCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// <copyright file="DiscordAccountCommands.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Discord;

using System.Globalization;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Discord.Properties;
using MUnique.OpenMU.GameLogic.AccountLinking;

/// <summary>
/// The slash commands with which Discord users link themselves to their game account.
/// Their answers are only shown to the user who used them.
/// </summary>
public sealed class DiscordAccountCommands
{
private const string Provider = AccountLinkService.DiscordProvider;

private readonly AccountLinkService _linkService;
private readonly CultureInfo _culture;
private readonly ILogger<DiscordAccountCommands> _logger;

/// <summary>
/// Initializes a new instance of the <see cref="DiscordAccountCommands"/> class.
/// </summary>
/// <param name="linkService">The service which links the accounts.</param>
/// <param name="culture">The culture of the answers.</param>
/// <param name="logger">The logger.</param>
public DiscordAccountCommands(AccountLinkService linkService, CultureInfo culture, ILogger<DiscordAccountCommands> logger)
{
this._linkService = linkService;
this._culture = culture;
this._logger = logger;
}

/// <summary>
/// Gets the definitions of the commands.
/// </summary>
public static IReadOnlyList<DiscordCommandDefinition> Definitions { get; } =
[
new("link", nameof(Resources.Command_Link_Description), "code", nameof(Resources.Command_Link_CodeOption)),
new("unlink", nameof(Resources.Command_Unlink_Description)),
new("character", nameof(Resources.Command_Character_Description), "name", nameof(Resources.Command_Character_NameOption)),
];

/// <summary>
/// Determines whether the command is one of these commands.
/// </summary>
/// <param name="commandName">The name of the command.</param>
/// <returns><c>true</c>, if the command is one of these commands.</returns>
public static bool IsAccountCommand(string commandName) => Definitions.Any(definition => definition.Name == commandName);

/// <summary>
/// Executes a command.
/// </summary>
/// <param name="commandName">The name of the command.</param>
/// <param name="argument">The value of the option of the command, if it has one.</param>
/// <param name="userId">The identifier of the Discord user who used the command.</param>
/// <param name="userName">The name of the Discord user who used the command.</param>
/// <returns>The answer.</returns>
public async ValueTask<DiscordCommandAnswer> ExecuteAsync(string commandName, string? argument, ulong userId, string userName)
{
try
{
return commandName switch
{
"link" => await this.LinkAsync(argument ?? string.Empty, userId, userName).ConfigureAwait(false),
"unlink" => await this.UnlinkAsync(userId).ConfigureAwait(false),
"character" => await this.SelectCharacterAsync(argument ?? string.Empty, userId).ConfigureAwait(false),
_ => this.Answer(nameof(Resources.Command_Failed)),
};
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when executing the Discord command {command}.", commandName);
return this.Answer(nameof(Resources.Command_Failed));
}
}

private async ValueTask<DiscordCommandAnswer> LinkAsync(string code, ulong userId, string userName)
{
var userIdText = userId.ToString(CultureInfo.InvariantCulture);
if (await this._linkService.LinkAsync(Provider, code, userIdText, userName).ConfigureAwait(false) is not { } result)
{
return this.Answer(nameof(Resources.Link_InvalidCode));
}

var roleChanges = new List<DiscordRoleChange> { new(userId, true) };
if (ulong.TryParse(result.ReplacedExternalUserId, NumberStyles.None, CultureInfo.InvariantCulture, out var replacedUserId))
{
roleChanges.Add(new(replacedUserId, false));
}

return this.Answer(nameof(Resources.Link_Linked), roleChanges, DiscordMessageFormatter.Escape(result.CharacterName ?? "-"));
}

private async ValueTask<DiscordCommandAnswer> UnlinkAsync(ulong userId)
{
var wasLinked = await this._linkService.UnlinkUserAsync(Provider, userId.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
return wasLinked
? this.Answer(nameof(Resources.Link_Unlinked), [new(userId, false)])
: this.Answer(nameof(Resources.Link_NotLinked));
}

private async ValueTask<DiscordCommandAnswer> SelectCharacterAsync(string characterName, ulong userId)
{
var result = await this._linkService.SelectCharacterAsync(Provider, userId.ToString(CultureInfo.InvariantCulture), characterName.Trim()).ConfigureAwait(false);
return result switch
{
CharacterSelectionResult.Selected => this.Answer(nameof(Resources.Character_Selected), [], DiscordMessageFormatter.Escape(characterName.Trim())),
CharacterSelectionResult.CharacterNotFound => this.Answer(nameof(Resources.Character_NotOfAccount), [], DiscordMessageFormatter.Escape(characterName.Trim())),
_ => this.Answer(nameof(Resources.Link_NotLinked)),
};
}

private DiscordCommandAnswer Answer(string resourceKey) => this.Answer(resourceKey, []);

private DiscordCommandAnswer Answer(string resourceKey, IReadOnlyList<DiscordRoleChange> roleChanges, params object[] args)
{
var text = DiscordCommands.GetText(this._culture, resourceKey, args);
return new DiscordCommandAnswer(
new DiscordEmbed(DiscordCommands.GetText(this._culture, nameof(Resources.Link_Title)), text, DiscordCommands.AnswerColor, DateTime.UtcNow, null),
roleChanges);
}
}
Loading
Loading