Skip to content

Commit

Permalink
Add command support (#20)
Browse files Browse the repository at this point in the history
* Add discord webhook handler

* Add documentation

* Add missing parameter to iac test
  • Loading branch information
Hekku2 authored Jul 11, 2024
1 parent b51c2bf commit 4cc27c6
Show file tree
Hide file tree
Showing 30 changed files with 469 additions and 98 deletions.
1 change: 1 addition & 0 deletions .github/workflows/build-iac.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ jobs:
DiscordToken = 'mock token'
DiscordGuildId = 0
DiscordChannelId = 0
DiscordPublicKey = ''
ExistingCognitiveServicesAccountName = $env:COGNITIVE_SERVICES_ACCOUNT_NAME
ExistingCognitiveServicesResourceGroup = $env:COGNITIVE_SERVICES_RESOURCEGROUP
}
Expand Down
1 change: 1 addition & 0 deletions Create-Environment.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ $parameters = @{
token = $settingsJson.DiscordToken
channelId = $settingsJson.DiscordChannelId
guildId = $settingsJson.DiscordGuildId
publicKey = $settingsJson.DiscordPublicKey
}
cognitiveService = @{
existingServiceName = $settingsJson.ExistingCognitiveServicesAccountName
Expand Down
1 change: 1 addition & 0 deletions Create-Settings.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ $dockerEnvFileContent = "
DISCORD_TOKEN=$($settingsJson.DiscordToken)
DISCORD_GUILDID=$($settingsJson.DiscordGuildId)
DISCORD_CHANNELID=$($settingsJson.DiscordChannelId)
DISCORD_PUBLICKEY=$($settingsJson.DiscordPublicKey)
"
Write-Host "Writing $dockerEnvFile"
$dockerEnvFileContent | Out-File -FilePath $dockerEnvFile -Encoding utf8
Expand Down
1 change: 1 addition & 0 deletions developer-settings-sample.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"DiscordToken": "",
"DiscordGuildId": 0,
"DiscordChannelId": 0,
"DiscordPublicKey": "",
"ExistingCognitiveServicesAccountName": "existing-account-name",
"ExistingCognitiveServicesResourceGroup": "existing-resource-group-name",
"Tags": []
Expand Down
64 changes: 64 additions & 0 deletions doc/discord-event-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Discord event handling / commands

Things that need to be done before this App can be commanded from Discord:

1. Application needs to be up and running!
2. `INTERACTIONS ENDPOINT URL` needs to be configured, this requires an
endpoint which can handle `PING` from Discord. See
[Discord API Documentation](https://discord.com/developers/docs/interactions/overview#preparing-for-interactions) for details.
3. Command needs to be registed. This can be done with `ConsoleTester`.

We are using Webhooks / `INTERACTIONS ENDPOINT URL`, not the socket, because we
don't want to keep the Functions app running.

## Registering command

Slash command are given in Discord with `/<command-here>`, that triggers the webhook.

Example interaction from slash command

```json
{
"type": 2,
"token": "A_UNIQUE_TOKEN",
"member": {
"user": {
"id": "53908232506183680",
"username": "Mason",
"avatar": "a_d5efa99b3eeaa7dd43acca82f5692432",
"discriminator": "1337",
"public_flags": 131141
},
"roles": ["539082325061836999"],
"premium_since": null,
"permissions": "2147483647",
"pending": false,
"nick": null,
"mute": false,
"joined_at": "2017-03-13T19:19:14.040000+00:00",
"is_pending": false,
"deaf": false
},
"id": "786008729715212338",
"guild_id": "290926798626357999",
"app_permissions": "442368",
"guild_locale": "en-US",
"locale": "en-US",
"data": {
"options": [{
"type": 3,
"name": "cardname",
"value": "The Gitrog Monster"
}],
"type": 1,
"name": "cardsearch",
"id": "771825006014889984"
},
"channel_id": "645027906669510667"
}
```

## Links

* [Slash command handling](https://discord.com/developers/docs/interactions/application-commands#slash-commands)
* [Command service from Discord.Net](https://docs.discordnet.dev/api/Discord.Commands.CommandService.html)
4 changes: 4 additions & 0 deletions infra/functions.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ resource functionApp 'Microsoft.Web/sites@2023-12-01' = {
name: '${discordSettingsKey}__ChannelId'
value: '@Microsoft.KeyVault(VaultName=${keyVault.name};SecretName=DiscordChannelId)'
}
{
name: '${discordSettingsKey}__PublicKey'
value: '@Microsoft.KeyVault(VaultName=${keyVault.name};SecretName=DiscordPublicKey)'
}
{
name: '${blobStorageKey}__BlobContainerUri'
value: imageStorageSettings.blobContainerUri
Expand Down
4 changes: 4 additions & 0 deletions infra/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ module keyVaultModule 'key-vault.bicep' = {
key: 'DiscordChannelId'
value: '${discordSettings.channelId}'
}
{
key: 'DiscordPublicKey'
value: discordSettings.publicKey
}
]
}
}
Expand Down
1 change: 1 addition & 0 deletions infra/types.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ type DiscordSettings = {
token: string
guildId: int
channelId: int
publicKey: string
}

@export()
Expand Down
14 changes: 14 additions & 0 deletions scripts/Trigger-Interaction.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<#
.SYNOPSIS
Helper script for testing trigger interactions.
#>
param(
[Parameter()][string]$JsonFile
)

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$content = Get-Content -Raw -Path $JsonFile

Invoke-RestMethod -Method Post -Uri 'http://localhost:8080/api/HandleDiscordWebHook?code=mock-secret-for-local-testing' -Body $content -ContentType 'application/json'
75 changes: 41 additions & 34 deletions src/Common/Discord/DiscordConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,34 +1,41 @@
using System.ComponentModel.DataAnnotations;

namespace DiscordImagePoster.Common.Discord;

/// <summary>
/// Represents the configuration for the Discord bot that is required for
/// connecting to the Discord API.
/// </summary>
public class DiscordConfiguration
{
/// <summary>
/// The token of the bot. This is used to authenticate the bot with the
/// Discord API.
/// </summary>
[Required]
[MinLength(1)]
public required string Token { get; set; }

/// <summary>
/// The ID of the guild / server where the bot will post images.
/// This can be fetched from the Discord server url.
/// For example, the ID of the guild in the url https://discord.com/channels/123123/666666 is 123123.
/// </summary>
[Required]
public ulong GuildId { get; set; }

/// <summary>
/// The ID of the channel where the bot will post images.
/// This can be fetched from the Discord server url.
/// For example, the ID of the guild in the url https://discord.com/channels/123123/666666 is 666666.
/// </summary>
[Required]
public ulong ChannelId { get; set; }
}
using System.ComponentModel.DataAnnotations;

namespace DiscordImagePoster.Common.Discord;

/// <summary>
/// Represents the configuration for the Discord bot that is required for
/// connecting to the Discord API.
/// </summary>
public class DiscordConfiguration
{
/// <summary>
/// The token of the bot. This is used to authenticate the bot with the
/// Discord API.
/// </summary>
[Required]
[MinLength(1)]
public required string Token { get; set; }

/// <summary>
/// The ID of the guild / server where the bot will post images.
/// This can be fetched from the Discord server url.
/// For example, the ID of the guild in the url https://discord.com/channels/123123/666666 is 123123.
/// </summary>
[Required]
public ulong GuildId { get; set; }

/// <summary>
/// The ID of the channel where the bot will post images.
/// This can be fetched from the Discord server url.
/// For example, the ID of the guild in the url https://discord.com/channels/123123/666666 is 666666.
/// </summary>
[Required]
public ulong ChannelId { get; set; }

/// <summary>
/// The public key of the bot. This is used to verify the authenticity of
/// the requests sent to the bot.
/// </summary>
[Required]
public required string PublicKey { get; set; }
}
21 changes: 20 additions & 1 deletion src/Common/Discord/DiscordImagePoster.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
using Discord;
using Discord.Net;
using Discord.Rest;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace DiscordImagePoster.Common.Discord;

public class DiscordImagePoster : IDiscordImagePoster
public class DiscordImagePoster : IDiscordImagePoster, IDiscordCommandRegisterer
{
private readonly ILogger<DiscordImagePoster> _logger;
private readonly DiscordConfiguration _options;
Expand Down Expand Up @@ -33,6 +34,24 @@ public async Task SendImageAsync(ImagePostingParameters parameters)
var sentMessage = await textChannel.SendFileAsync(file, parameters.Description ?? parameters.FileName, false);
}

public async Task RegisterCommandsAsync()
{
var guildCommand = new SlashCommandBuilder();
guildCommand.WithName("post-random-image")
.WithDescription("Post a random image to the channel.");

try
{
using var client = await GetAuthenticatedClient();
var guild = await client.GetGuildAsync(_options.GuildId);
await guild.CreateApplicationCommandAsync(guildCommand.Build());
}
catch (HttpException ex)
{
_logger.LogError(ex, "Failed to create command.");
}
}

private async Task<DiscordRestClient> GetAuthenticatedClient()
{
var client = new DiscordRestClient();
Expand Down
1 change: 1 addition & 0 deletions src/Common/Discord/DiscordServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public static IServiceCollection AddDiscordSendingServices(this IServiceCollecti
{
services.AddOptions<DiscordConfiguration>().BindConfiguration(nameof(DiscordConfiguration)).ValidateDataAnnotations().ValidateOnStart();
services.AddTransient<IDiscordImagePoster, DiscordImagePoster>();
services.AddTransient<IDiscordCommandRegisterer, DiscordImagePoster>();
return services;
}
}
6 changes: 6 additions & 0 deletions src/Common/Discord/IDiscordCommandRegisterer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace DiscordImagePoster.Common.Discord;

public interface IDiscordCommandRegisterer
{
Task RegisterCommandsAsync();
}
10 changes: 10 additions & 0 deletions src/Common/RandomizationService/IRandomImagePoster.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace DiscordImagePoster.Common.RandomizationService;

/// <summary>
/// Asbtraction for sending a random image.
/// This is a separate service so this can be used from multiple functions or other places.
/// </summary>
public interface IRandomImagePoster
{
Task PostRandomImageAsync();
}
74 changes: 74 additions & 0 deletions src/Common/RandomizationService/RandomImagePoster.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using DiscordImagePoster.Common.BlobStorageImageService;
using DiscordImagePoster.Common.Discord;
using DiscordImagePoster.Common.ImageAnalysis;
using DiscordImagePoster.Common.IndexService;
using Microsoft.Extensions.Logging;

namespace DiscordImagePoster.Common.RandomizationService;

public class RandomImagePoster : IRandomImagePoster
{
private readonly ILogger<RandomImagePoster> _logger;
private readonly IDiscordImagePoster _discordImagePoster;
private readonly IBlobStorageImageService _imageService;
private readonly IIndexService _indexService;
private readonly IRandomizationService _randomizationService;
private readonly IImageAnalysisService _imageAnalysisService;

public RandomImagePoster(
ILogger<RandomImagePoster> logger,
IDiscordImagePoster discordImagePoster,
IBlobStorageImageService imageService,
IIndexService indexService,
IRandomizationService randomizationService,
IImageAnalysisService imageAnalysisService
)
{
_logger = logger;
_discordImagePoster = discordImagePoster;
_imageService = imageService;
_indexService = indexService;
_randomizationService = randomizationService;
_imageAnalysisService = imageAnalysisService;
}

public async Task PostRandomImageAsync()
{
var index = await _indexService.GetIndexOrCreateNew();
var randomImage = _randomizationService.GetRandomImage(index);

if (randomImage is null)
{
_logger.LogError("No images found in index.");
return;
}

var result = await _imageService.GetImageStream(randomImage.Name);
if (result is null)
{
_logger.LogError("No image found.");
return;
}

var binaryData = BinaryData.FromStream(result.Content);
var analyzationResults = await _imageAnalysisService.AnalyzeImageAsync(binaryData);

_logger.LogInformation("Sending image {ImageName} with caption {Caption} and tags {Tags}", randomImage.Name, analyzationResults?.Caption, string.Join(", ", analyzationResults?.Tags ?? Array.Empty<string>()));
var imageMetadata = new ImageMetadataUpdate
{
Name = randomImage.Name,
Caption = analyzationResults?.Caption,
Tags = analyzationResults?.Tags
};

var parameters = new ImagePostingParameters
{
ImageStream = binaryData.ToStream(),
FileName = randomImage.Name,
Description = analyzationResults?.Caption
};
await _discordImagePoster.SendImageAsync(parameters);

await _indexService.IncreasePostingCountAndUpdateMetadataAsync(imageMetadata);
}
}
1 change: 0 additions & 1 deletion src/ConsoleTester/Options/AnalyzeImageVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,4 @@ public class AnalyzeImageVerb

[Option('o', "output", Required = true, HelpText = "Path to save the output.")]
public required string OutputPath { get; set; }

}
8 changes: 8 additions & 0 deletions src/ConsoleTester/Options/RegisterCommandVerb.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using CommandLine;

namespace DiscordImagePoster.ConsoleTester.Options;

[Verb("register-command", HelpText = "Register Discord commands.")]
public class RegisterCommandVerb
{
}
Loading

0 comments on commit 4cc27c6

Please sign in to comment.