Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ jobs:
dockerfile: src/Dapr/CentralServer.Host/Dockerfile
- image: openmu-game
dockerfile: src/Dapr/GameServer.Host/Dockerfile
- image: openmu-discord
dockerfile: src/Dapr/Discord.Host/Dockerfile

steps:
- uses: actions/checkout@v4
Expand Down
45 changes: 45 additions & 0 deletions deploy/distributed/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,51 @@ services:
"-config", "/components/config.yaml",
"-placement-host-address", "dapr-placement:50006" ]

# The Discord bot and notifications. It's only started with the profile "discord",
# e.g. "docker compose --profile discord up -d". See the documentation for the
# further settings, like the webhooks and channels, which are added as environment variables.
discord:
image: munique/openmu-discord
container_name: discord
restart: unless-stopped
profiles: [ "discord" ]
healthcheck: *openmu-healthcheck
build:
context: ../../src
dockerfile: Dapr/Discord.Host/Dockerfile
depends_on:
discord-dapr:
condition: service_started
centralServer:
condition: service_started
database:
condition: service_healthy
ports:
- "8080"
environment:
DAPR_HTTP_ENDPOINT: http://discord-dapr:3500
DAPR_GRPC_ENDPOINT: http://discord-dapr:50001
ASPNETCORE_URLS: http://+:8080
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-lgtm:4317
DB_HOST: ${DB_HOST:-database}
DB_ADMIN_USER: ${DB_ADMIN_USER:-}
DB_ADMIN_PW: ${DB_ADMIN_PW:-}
Discord__Bot__Token: ${DISCORD_BOT_TOKEN:-}
Discord__Language: ${DISCORD_LANGUAGE:-en}

discord-dapr:
<<: *dapr-sidecar
container_name: discord_dapr
profiles: [ "discord" ]
environment:
POD_NAME: discord
command: [ "./daprd", "-app-id", "discord", "-app-port", "8080",
"-app-channel-address", "discord",
"-dapr-listen-addresses", "0.0.0.0",
"-components-path", "/components",
"-config", "/components/config.yaml",
"-placement-host-address", "dapr-placement:50006" ]

gameServer0:
image: munique/openmu-game
container_name: gameServer0
Expand Down
2 changes: 1 addition & 1 deletion docs-website/docs/admin-panel/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ documented separately:

The *Game Event Publisher* plugin publishes notable events of the game to the
whole server, so that external systems can react on them, for example a Discord
integration (see [Discord notifications](../server-features/discord.md)). It's
integration (see [Discord](../server-features/discord.md)). It's
disabled by default; activate it when such a system is connected.

It publishes:
Expand Down
11 changes: 11 additions & 0 deletions docs-website/docs/deployment/distributed.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ docker compose restart centralServer gameServer0 gameServer1
```
:::

### Discord

The Discord bot and the Discord notifications run in their own container,
`discord`. It's only started with the `discord` profile:

```bash
DISCORD_BOT_TOKEN=... docker compose --profile discord up -d
```

See [Discord](../server-features/discord.md) for how to set it up.

## Differences to the all-in-one deployment

Some functions of the admin panel behave differently, because the panel runs in
Expand Down
122 changes: 93 additions & 29 deletions docs-website/docs/server-features/discord.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
---
title: Discord notifications
title: Discord
sidebar_label: Discord
sidebar_position: 5
description: Post events of the game, like mini games, castle siege and rare drops, to Discord channels.
description: A Discord bot and notifications, which bring the game to your Discord server.
---

# Discord notifications
# Discord

OpenMU can post what happens in the game to the channels of your Discord server:
OpenMU can bring your game into your Discord server, in two ways which can be
combined:

* **Notifications:** what happens in the game, like mini games, the castle siege
or rare drops, is posted into your channels. This works with simple *webhooks*,
or through the bot.
* **The bot:** it shows how many players are online, keeps a status message of
the game servers up to date, and answers slash commands like `/who`.

## Notifications

| Category | What is posted |
|---|---|
Expand All @@ -16,22 +25,22 @@ OpenMU can post what happens in the game to the channels of your Discord server:
| `WorldNews` | Boss kills, excellent and ancient items which monsters dropped, characters which reach a level milestone |
| `Notices` | Global notices which game masters send with the `!` chat prefix |

Each category is posted to its own channel through a Discord *webhook*. Categories
without webhook aren't posted.
Each category is posted into its own channel. A category without channel isn't
posted.

For the notifications, the *Game Event Publisher* plugin has to be active
([Plugins](../admin-panel/plugins.md#game-event-publisher)). It's disabled by
default. In its configuration, you choose which events are published, which
monsters are bosses and which levels are milestones.

:::note
This works in the all-in-one deployment. In the distributed deployment, the
events are already published, but there is no service yet which posts them to
Discord.
:::
### With webhooks

## Setting it up
Webhooks are the simplest way, because you don't need a bot:

1. **Create the webhooks.** In Discord, open the settings of a channel, go to
*Integrations → Webhooks*, create a webhook and copy its URL. Do this for
each channel which should receive messages.
2. **Configure the webhooks** for the server, through environment variables,
e.g. in your `docker-compose.override.yml`:
1. In Discord, open the settings of a channel, go to *Integrations → Webhooks*,
create a webhook and copy its URL. Do this for each channel which should
receive messages.
2. Configure the URLs, e.g. as environment variables:

```yaml
environment:
Expand All @@ -41,38 +50,93 @@ Discord.
- Discord__Webhooks__Notices=https://discord.com/api/webhooks/...
```

Treat the URLs like passwords: everybody who knows one can post into the
channel. That's why they are not part of the game configuration in the
database, but of the configuration of the server process.
3. **Activate the *Game Event Publisher* plugin** in the admin panel
([Plugins](../admin-panel/plugins.md#game-event-publisher)). It's disabled by
default. In its configuration, you choose which events are published, which
monsters are bosses and which levels are milestones.
4. Restart the server.
Treat the URLs like passwords: everybody who knows one can post into the channel.
That's why they are not part of the game configuration in the database, but of
the configuration of the server process.

### With the bot

When the bot runs, it can post the notifications as well. Configure the
identifiers of the channels instead of webhooks, e.g.
`Discord__Bot__Channels__Events=123456789012345678`. A category with a channel of
the bot is posted by the bot, even when it also has a webhook.

To copy the identifier of a channel, enable the *Developer Mode* in the advanced
settings of Discord, then right-click the channel and select *Copy Channel ID*.

## The bot

### Creating the bot

1. Open the [Discord developer portal](https://discord.com/developers/applications),
create a *New Application* and give it the name of your server.
2. Under *Bot*, click *Reset Token* and copy the token. Treat it like a password.
The bot doesn't need any of the *Privileged Gateway Intents*.
3. Under *OAuth2 → URL Generator*, select the scopes `bot` and
`applications.commands`, and the permissions *View Channels*, *Send Messages*,
*Embed Links* and *Read Message History*. Open the generated URL to invite the
bot to your Discord server.
4. Configure the token, e.g. as environment variable `Discord__Bot__Token`.
5. Restart the server. In the all-in-one deployment, the bot appears in the
server list of the admin panel.

In the distributed deployment, the bot runs in its own container, see
[Distributed deployment](../deployment/distributed.md#discord).

### Slash commands

| Command | Shows |
|---|---|
| `/online` | The online players of each game server, and in total |
| `/who <character>` | The class, the levels and the online state of a character. Characters which are invisible in the game appear offline. |
| `/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 |

The commands only show information; they don't change anything in the game.

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
to show them.

### Status

The presence of the bot shows how many players are online. When you configure a
status channel (`Discord__Bot__StatusChannelId`), the bot keeps a message with
the status of the game servers up to date in it. Use a channel in which only the
bot writes; it finds its message again after a restart.

## Settings

All settings are part of the `Discord` section of the configuration, so they can
also be set in the `appsettings.json` of the server.
also be set in the `appsettings.json` of the server. As environment variables,
the parts are separated by two underscores.

| Setting | Default | Description |
|---|---|---|
| `Webhooks__<Category>` | — | The webhook URL per category, see the table above. |
| `Language` | `en` | The language of the messages. English (`en`) and German (`de`) are available. Names of maps, monsters and items are translated, too. |
| `Language` | `en` | The language of the messages and the bot. English (`en`) and German (`de`) are available. Names of maps, monsters and items are translated, too. |
| `EventAnnouncingServerIds__0`, `__1`, … | all | The identifiers of the game servers which announce mini games and invasions. When several game servers run the same events, set it to one of them to avoid duplicate messages. |
| `MaximumQueuedMessages` | `100` | The number of messages per webhook which can wait to be sent. When there are more, the oldest ones are dropped. |
| `MaximumQueuedMessages` | `100` | The number of messages per channel which can wait to be sent. When there are more, the oldest ones are dropped. |
| `Bot__Token` | — | The token of the bot. Without it, the bot doesn't run. |
| `Bot__GuildId` | — | The identifier of your Discord server, for the slash commands. |
| `Bot__StatusChannelId` | — | The identifier of the channel with the status message. |
| `Bot__Channels__<Category>` | — | The identifier of the channel per category, into which the bot posts the notifications. |
| `Bot__StatusUpdateInterval` | `00:01:00` | How often the status message and the presence are updated. |

## Good to know

* **Times** like "the entrance closes in 5 minutes" are posted in a way that each
Discord user sees them in their own time zone and language.
* **Rate limits:** Discord accepts about 30 messages per minute per webhook.
* **Rate limits:** Discord accepts about 30 messages per minute per channel.
When more happens, OpenMU combines up to 10 messages into one post and waits
for Discord, so nothing gets lost unless the queue overflows.
* **Mini games** like Blood Castle open all their levels at the same time; this
is announced only once.
* **Mentions** are disabled, so a text of a player like `@everyone` can't ping
anybody.
* When Discord rejects the token, the bot stops and logs an error, instead of
trying again and again.
* Make your notices channel an *Announcement channel*: other Discord servers,
e.g. the ones of guilds, can then *follow* it and get the messages copied into
their own channel.
11 changes: 11 additions & 0 deletions src/Dapr/CentralServer.Host/FriendServerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ public async Task FriendResponseAsync([FromBody] FriendResponseArguments data)
await this._friendServer.FriendResponseAsync(data.CharacterName, data.FriendName, data.Accepted).ConfigureAwait(false);
}

/// <summary>
/// Gets the identifier of the game server on which the character is online.
/// </summary>
/// <param name="characterName">The name of the character.</param>
/// <returns>The identifier of the game server; or <see langword="null"/>, if the character is offline or invisible.</returns>
[HttpPost(nameof(IFriendServer.GetOnlineServerIdAsync))]
public async Task<byte?> GetOnlineServerIdAsync([FromBody] string characterName)
{
return await this._friendServer.GetOnlineServerIdAsync(characterName).ConfigureAwait(false);
}

/// <summary>
/// Determines whether two players are friends.
/// </summary>
Expand Down
27 changes: 27 additions & 0 deletions src/Dapr/Discord.Host/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS base
WORKDIR /app

# OpenMU uses localized texts, which require the culture data of ICU.
# The alpine images don't contain it and run in the globalization-invariant mode by default.
RUN apk add --no-cache icu-libs
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
EXPOSE 8080

FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
COPY ["Directory.Packages.props", "."]
COPY ["Directory.Build.props", "."]
COPY ["Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj", "Dapr/Discord.Host/"]
RUN dotnet restore "Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj"
COPY . .
WORKDIR "/src/Dapr/Discord.Host"
RUN dotnet build "MUnique.OpenMU.Discord.Host.csproj" -c Release -o /app/build -p:ci=true

FROM build AS publish
RUN dotnet publish "MUnique.OpenMU.Discord.Host.csproj" -c Release -o /app/publish -p:ci=true

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "MUnique.OpenMU.Discord.Host.dll"]
54 changes: 54 additions & 0 deletions src/Dapr/Discord.Host/GameEventController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// <copyright file="GameEventController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Discord.Host;

using global::Dapr;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;

/// <summary>
/// Receives the <see cref="GameEvent"/>s which the game servers publish, and passes them to the <see cref="IGameEventListener"/>s.
/// </summary>
[ApiController]
[Route("events")]
public class GameEventController : ControllerBase
{
private readonly IEnumerable<IGameEventListener> _listeners;
private readonly ILogger<GameEventController> _logger;

/// <summary>
/// Initializes a new instance of the <see cref="GameEventController"/> class.
/// </summary>
/// <param name="listeners">The listeners.</param>
/// <param name="logger">The logger.</param>
public GameEventController(IEnumerable<IGameEventListener> listeners, ILogger<GameEventController> logger)
{
this._listeners = listeners;
this._logger = logger;
}

/// <summary>
/// Passes the game event to the listeners.
/// </summary>
/// <param name="gameEvent">The game event.</param>
/// <returns>The task.</returns>
[Topic("pubsub", nameof(IEventPublisher.GameEventAsync))]
[HttpPost(nameof(IEventPublisher.GameEventAsync))]
public async Task GameEventAsync([FromBody] GameEvent gameEvent)
{
foreach (var listener in this._listeners)
{
try
{
await listener.OnGameEventAsync(gameEvent).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger.LogError(ex, "Error when passing the game event {gameEvent} to {listener}.", gameEvent, listener);
}
}
}
}
32 changes: 32 additions & 0 deletions src/Dapr/Discord.Host/MUnique.OpenMU.Discord.Host.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<DocumentationFile></DocumentationFile>
<DockerComposeProjectPath>..\..\..\deploy\distributed\docker-compose.dcproj</DockerComposeProjectPath>
<UserSecretsId>4b3c355b-89d4-4bb3-b612-57b3e826b0dd</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>..\..</DockerfileContext>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\Discord\MUnique.OpenMU.Discord.csproj" />
<ProjectReference Include="..\Common\MUnique.OpenMU.Dapr.Common.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
Loading
Loading