diff --git a/doc/Discord-Implementation-Roadmap.md b/doc/Discord-Implementation-Roadmap.md
new file mode 100644
index 00000000..aea27e4b
Binary files /dev/null and b/doc/Discord-Implementation-Roadmap.md differ
diff --git a/doc/Discord-Provider-Design.md b/doc/Discord-Provider-Design.md
new file mode 100644
index 00000000..d1d56cba
Binary files /dev/null and b/doc/Discord-Provider-Design.md differ
diff --git a/doc/Discord-Technical-Implementation.md b/doc/Discord-Technical-Implementation.md
new file mode 100644
index 00000000..595d5c31
Binary files /dev/null and b/doc/Discord-Technical-Implementation.md differ
diff --git a/doc/Discord-UI-Specification.md b/doc/Discord-UI-Specification.md
new file mode 100644
index 00000000..fb01bd5f
Binary files /dev/null and b/doc/Discord-UI-Specification.md differ
diff --git a/src/TagzApp.Blazor/Service_Providers.cs b/src/TagzApp.Blazor/Service_Providers.cs
index 958ce13f..67a96930 100644
--- a/src/TagzApp.Blazor/Service_Providers.cs
+++ b/src/TagzApp.Blazor/Service_Providers.cs
@@ -17,6 +17,7 @@ public static class Service_Providers
new StartAzureQueue(),
new StartBlazot(),
new StartBluesky(),
+ new StartDiscord(),
new StartMastodon(),
new StartTwitchChat(),
new StartTwitter(),
diff --git a/src/TagzApp.Blazor/TagzApp.Blazor.csproj b/src/TagzApp.Blazor/TagzApp.Blazor.csproj
index 5df31320..adf2015f 100644
--- a/src/TagzApp.Blazor/TagzApp.Blazor.csproj
+++ b/src/TagzApp.Blazor/TagzApp.Blazor.csproj
@@ -41,6 +41,7 @@
+
diff --git a/src/TagzApp.Providers.Discord/DiscordConfiguration.cs b/src/TagzApp.Providers.Discord/DiscordConfiguration.cs
new file mode 100644
index 00000000..0b6b8627
--- /dev/null
+++ b/src/TagzApp.Providers.Discord/DiscordConfiguration.cs
@@ -0,0 +1,207 @@
+using System.ComponentModel.DataAnnotations;
+using System.Text.Json.Serialization;
+
+namespace TagzApp.Providers.Discord;
+
+public class DiscordConfiguration : BaseProviderConfiguration
+{
+ ///
+ /// The configuration key used to store this configuration in the TagzApp configuration system
+ ///
+ protected override string ConfigurationKey => "provider-discord";
+
+ [JsonPropertyOrder(1)]
+ [Required]
+ [Display(Name = "Bot Token", Description = "Discord bot token for API access")]
+ public string BotToken { get; set; } = string.Empty;
+
+ [JsonPropertyOrder(2)]
+ [Required]
+ [Display(Name = "Guild ID", Description = "Discord server (guild) ID")]
+ public string GuildId { get; set; } = string.Empty;
+
+ [JsonPropertyOrder(3)]
+ [Required]
+ [Display(Name = "Channel ID", Description = "Discord channel ID to monitor")]
+ public string ChannelId { get; set; } = string.Empty;
+
+ [JsonPropertyOrder(4)]
+ [Display(Name = "Guild Name", Description = "Discord server name (auto-populated)")]
+ public string GuildName { get; set; } = string.Empty;
+
+ [JsonPropertyOrder(5)]
+ [Display(Name = "Channel Name", Description = "Discord channel name (auto-populated)")]
+ public string ChannelName { get; set; } = string.Empty;
+
+ [JsonPropertyOrder(6)]
+ [Display(Name = "Include Bot Messages", Description = "Show messages from other bots")]
+ public bool IncludeBotMessages { get; set; } = false;
+
+ [JsonPropertyOrder(7)]
+ [Display(Name = "Include System Messages", Description = "Show join/leave and system messages")]
+ public bool IncludeSystemMessages { get; set; } = false;
+
+ [JsonPropertyOrder(8)]
+ [Display(Name = "Minimum Message Length", Description = "Hide messages shorter than this")]
+ public int MinMessageLength { get; set; } = 1;
+
+ // NOTE: We have blocked users at the application level in TagzApp
+ // [JsonPropertyOrder(9)]
+ // [Display(Name = "Blocked Users", Description = "Comma-separated list of user IDs to block")]
+ // public string BlockedUsers { get; set; } = string.Empty;
+
+ [JsonPropertyOrder(10)]
+ [Display(Name = "Max Queue Size", Description = "Maximum messages to keep in memory")]
+ public int MaxQueueSize { get; set; } = 1000;
+
+ [JsonPropertyOrder(11)]
+ [Display(Name = "Reconnect Attempts", Description = "Max reconnection attempts")]
+ public int MaxReconnectAttempts { get; set; } = 5;
+
+ [JsonPropertyOrder(12)]
+ [Display(Name = "Enable Rich Embeds", Description = "Include Discord embed content")]
+ public bool EnableRichEmbeds { get; set; } = true;
+
+ public static DiscordConfiguration Empty => new()
+ {
+ BotToken = string.Empty,
+ GuildId = string.Empty,
+ ChannelId = string.Empty,
+ GuildName = string.Empty,
+ ChannelName = string.Empty
+ };
+
+ [JsonIgnore]
+ public override string Name => "Discord";
+
+ [JsonIgnore]
+ public override string Description => "Monitor Discord channels for live messages";
+
+ public override bool Enabled { get; set; }
+
+ [JsonIgnore]
+ public override string[] Keys => [
+ nameof(BotToken),
+ nameof(GuildId),
+ nameof(ChannelId),
+ nameof(GuildName),
+ nameof(ChannelName),
+ nameof(IncludeBotMessages),
+ nameof(IncludeSystemMessages),
+ nameof(MinMessageLength),
+ nameof(BlockedUsers),
+ nameof(MaxQueueSize),
+ nameof(MaxReconnectAttempts),
+ nameof(EnableRichEmbeds)
+ ];
+
+ ///
+ /// Validation check for required configuration
+ ///
+ [JsonIgnore]
+ public bool IsValid =>
+ !string.IsNullOrEmpty(BotToken) &&
+ !string.IsNullOrEmpty(GuildId) &&
+ !string.IsNullOrEmpty(ChannelId) &&
+ BotToken.Length > 50; // Discord tokens are ~70 characters
+
+ public override string GetConfigurationByKey(string key)
+ {
+ return key switch
+ {
+ nameof(BotToken) => BotToken,
+ nameof(GuildId) => GuildId,
+ nameof(ChannelId) => ChannelId,
+ nameof(GuildName) => GuildName,
+ nameof(ChannelName) => ChannelName,
+ nameof(IncludeBotMessages) => IncludeBotMessages.ToString(),
+ nameof(IncludeSystemMessages) => IncludeSystemMessages.ToString(),
+ nameof(MinMessageLength) => MinMessageLength.ToString(),
+ nameof(BlockedUsers) => BlockedUsers,
+ nameof(MaxQueueSize) => MaxQueueSize.ToString(),
+ nameof(MaxReconnectAttempts) => MaxReconnectAttempts.ToString(),
+ nameof(EnableRichEmbeds) => EnableRichEmbeds.ToString(),
+ nameof(Enabled) => Enabled.ToString(),
+ _ => string.Empty
+ };
+ }
+
+ public override void SetConfigurationByKey(string key, string value)
+ {
+ switch (key)
+ {
+ case nameof(BotToken):
+ BotToken = value;
+ break;
+ case nameof(GuildId):
+ GuildId = value;
+ break;
+ case nameof(ChannelId):
+ ChannelId = value;
+ break;
+ case nameof(GuildName):
+ GuildName = value;
+ break;
+ case nameof(ChannelName):
+ ChannelName = value;
+ break;
+ case nameof(IncludeBotMessages):
+ IncludeBotMessages = bool.Parse(value);
+ break;
+ case nameof(IncludeSystemMessages):
+ IncludeSystemMessages = bool.Parse(value);
+ break;
+ case nameof(MinMessageLength):
+ MinMessageLength = int.Parse(value);
+ break;
+ case nameof(BlockedUsers):
+ BlockedUsers = value;
+ break;
+ case nameof(MaxQueueSize):
+ MaxQueueSize = int.Parse(value);
+ break;
+ case nameof(MaxReconnectAttempts):
+ MaxReconnectAttempts = int.Parse(value);
+ break;
+ case nameof(EnableRichEmbeds):
+ EnableRichEmbeds = bool.Parse(value);
+ break;
+ case nameof(Enabled):
+ Enabled = bool.Parse(value);
+ break;
+ default:
+ throw new NotImplementedException($"Unable to set value for key '{key}'");
+ }
+ }
+
+ ///
+ /// Updates this instance with values from another configuration instance
+ ///
+ /// The source configuration to copy from
+ protected override void UpdateFromConfiguration(DiscordConfiguration source)
+ {
+ BotToken = source.BotToken;
+ GuildId = source.GuildId;
+ ChannelId = source.ChannelId;
+ GuildName = source.GuildName;
+ ChannelName = source.ChannelName;
+ IncludeBotMessages = source.IncludeBotMessages;
+ IncludeSystemMessages = source.IncludeSystemMessages;
+ MinMessageLength = source.MinMessageLength;
+ BlockedUsers = source.BlockedUsers;
+ MaxQueueSize = source.MaxQueueSize;
+ MaxReconnectAttempts = source.MaxReconnectAttempts;
+ EnableRichEmbeds = source.EnableRichEmbeds;
+ Enabled = source.Enabled;
+ }
+
+ ///
+ /// Get blocked users list as array
+ ///
+ /// Array of user IDs to block
+ public string[] GetBlockedUsersList() =>
+ BlockedUsers?.Split(',', StringSplitOptions.RemoveEmptyEntries)
+ .Select(s => s.Trim())
+ .Where(s => !string.IsNullOrEmpty(s))
+ .ToArray() ?? Array.Empty();
+}
\ No newline at end of file
diff --git a/src/TagzApp.Providers.Discord/DiscordGatewayService.cs b/src/TagzApp.Providers.Discord/DiscordGatewayService.cs
new file mode 100644
index 00000000..3b97e7ef
--- /dev/null
+++ b/src/TagzApp.Providers.Discord/DiscordGatewayService.cs
@@ -0,0 +1,379 @@
+using Microsoft.Extensions.Logging;
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+
+namespace TagzApp.Providers.Discord;
+
+///
+/// Service for managing Discord Gateway WebSocket connection
+///
+public class DiscordGatewayService : IDisposable
+{
+ private readonly ILogger _Logger;
+ private readonly DiscordConfiguration _Config;
+ private readonly ConcurrentQueue _MessageQueue;
+
+ private ClientWebSocket? _WebSocket;
+ private CancellationTokenSource? _CancellationTokenSource;
+ private Timer? _HeartbeatTimer;
+ private int? _LastSequence;
+ private bool _HeartbeatAcknowledged = true;
+ private int _ReconnectAttempts = 0;
+ private bool _IsDisposed = false;
+
+ private const string GatewayUrl = "wss://gateway.discord.gg/?v=10&encoding=json";
+
+ public event EventHandler? MessageReceived;
+ public event EventHandler? ConnectionStatusChanged;
+
+ public DiscordGatewayService(ILogger logger, DiscordConfiguration config, ConcurrentQueue messageQueue)
+ {
+ _Logger = logger;
+ _Config = config;
+ _MessageQueue = messageQueue;
+ }
+
+ ///
+ /// Start the Gateway connection
+ ///
+ public async Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ if (_IsDisposed) return;
+
+ _CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+
+ try
+ {
+ await ConnectAsync(_CancellationTokenSource.Token);
+ _ = Task.Run(() => ListenAsync(_CancellationTokenSource.Token), _CancellationTokenSource.Token);
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogError(ex, "Failed to start Discord Gateway connection");
+ ConnectionStatusChanged?.Invoke(this, $"Failed to connect: {ex.Message}");
+
+ // Try to reconnect
+ _ = Task.Run(() => ReconnectAsync(_CancellationTokenSource.Token), _CancellationTokenSource.Token);
+ }
+ }
+
+ ///
+ /// Stop the Gateway connection
+ ///
+ public async Task StopAsync()
+ {
+ if (_IsDisposed) return;
+
+ _HeartbeatTimer?.Dispose();
+ _CancellationTokenSource?.Cancel();
+
+ if (_WebSocket?.State == WebSocketState.Open)
+ {
+ try
+ {
+ await _WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Stopping", CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogWarning(ex, "Error closing WebSocket connection");
+ }
+ }
+
+ _WebSocket?.Dispose();
+ _CancellationTokenSource?.Dispose();
+ }
+
+ private async Task ConnectAsync(CancellationToken cancellationToken)
+ {
+ _WebSocket?.Dispose();
+ _WebSocket = new ClientWebSocket();
+
+ _Logger.LogInformation("Connecting to Discord Gateway...");
+ ConnectionStatusChanged?.Invoke(this, "Connecting...");
+
+ await _WebSocket.ConnectAsync(new Uri(GatewayUrl), cancellationToken);
+
+ _Logger.LogInformation("Connected to Discord Gateway");
+ ConnectionStatusChanged?.Invoke(this, "Connected");
+ }
+
+ private async Task ListenAsync(CancellationToken cancellationToken)
+ {
+ var buffer = new byte[4096];
+ var messageBuffer = new StringBuilder();
+
+ try
+ {
+ while (_WebSocket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
+ {
+ messageBuffer.Clear();
+ WebSocketReceiveResult result;
+
+ do
+ {
+ result = await _WebSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ messageBuffer.Append(Encoding.UTF8.GetString(buffer, 0, result.Count));
+ }
+ } while (!result.EndOfMessage);
+
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var message = messageBuffer.ToString();
+ await HandleGatewayMessage(message, cancellationToken);
+ }
+ else if (result.MessageType == WebSocketMessageType.Close)
+ {
+ _Logger.LogWarning("WebSocket connection closed by Discord");
+ ConnectionStatusChanged?.Invoke(this, "Disconnected");
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ _Logger.LogInformation("Discord Gateway connection cancelled");
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogError(ex, "Error in Discord Gateway listener");
+ ConnectionStatusChanged?.Invoke(this, $"Error: {ex.Message}");
+ }
+
+ // Attempt reconnection if not disposed and not cancelled
+ if (!_IsDisposed && !cancellationToken.IsCancellationRequested)
+ {
+ _ = Task.Run(() => ReconnectAsync(cancellationToken), cancellationToken);
+ }
+ }
+
+ private async Task HandleGatewayMessage(string message, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var payload = JsonSerializer.Deserialize(message);
+ if (payload == null) return;
+
+ _LastSequence = payload.Sequence ?? _LastSequence;
+
+ switch (payload.Op)
+ {
+ case DiscordGatewayOpcodes.Hello:
+ await HandleHelloPayload(payload.Data, cancellationToken);
+ break;
+
+ case DiscordGatewayOpcodes.HeartbeatAck:
+ _HeartbeatAcknowledged = true;
+ break;
+
+ case DiscordGatewayOpcodes.Dispatch:
+ await HandleDispatchPayload(payload.Type, payload.Data);
+ break;
+
+ case DiscordGatewayOpcodes.Reconnect:
+ _Logger.LogInformation("Discord requested reconnection");
+ _ = Task.Run(() => ReconnectAsync(cancellationToken), cancellationToken);
+ break;
+
+ case DiscordGatewayOpcodes.InvalidSession:
+ _Logger.LogWarning("Invalid session, reconnecting...");
+ _ = Task.Run(() => ReconnectAsync(cancellationToken), cancellationToken);
+ break;
+ }
+ }
+ catch (JsonException ex)
+ {
+ _Logger.LogWarning(ex, "Failed to parse Gateway message: {Message}", message);
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogError(ex, "Error handling Gateway message");
+ }
+ }
+
+ private async Task HandleHelloPayload(object? data, CancellationToken cancellationToken)
+ {
+ if (data == null) return;
+
+ var helloData = JsonSerializer.Deserialize(data.ToString()!);
+ if (helloData == null) return;
+
+ _Logger.LogInformation("Received Hello payload, heartbeat interval: {Interval}ms", helloData.HeartbeatInterval);
+
+ // Start heartbeat timer
+ _HeartbeatTimer?.Dispose();
+ _HeartbeatTimer = new Timer(SendHeartbeat, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(helloData.HeartbeatInterval));
+
+ // Send identify payload
+ await SendIdentifyPayload(cancellationToken);
+ }
+
+ private async Task SendIdentifyPayload(CancellationToken cancellationToken)
+ {
+ var identify = new DiscordGatewayPayload
+ {
+ Op = DiscordGatewayOpcodes.Identify,
+ Data = new DiscordIdentifyPayload
+ {
+ Token = _Config.BotToken,
+ Intents = DiscordGatewayIntents.GuildMessages | DiscordGatewayIntents.MessageContent,
+ Properties = new DiscordConnectionProperties()
+ }
+ };
+
+ await SendPayload(identify, cancellationToken);
+ _Logger.LogInformation("Sent identify payload");
+ }
+
+ private async Task HandleDispatchPayload(string? eventType, object? data)
+ {
+ if (eventType == null || data == null) return;
+
+ switch (eventType)
+ {
+ case "READY":
+ _Logger.LogInformation("Discord Gateway ready");
+ ConnectionStatusChanged?.Invoke(this, "Ready");
+ _ReconnectAttempts = 0; // Reset reconnect attempts on successful connection
+ break;
+
+ case "MESSAGE_CREATE":
+ var message = JsonSerializer.Deserialize(data.ToString()!);
+ if (message != null && ShouldProcessMessage(message))
+ {
+ _MessageQueue.Enqueue(message);
+ MessageReceived?.Invoke(this, message);
+ }
+ break;
+ }
+ }
+
+ private bool ShouldProcessMessage(DiscordMessage message)
+ {
+ // Check if message is from the configured channel
+ if (message.ChannelId != _Config.ChannelId)
+ return false;
+
+ // Check if message is from the configured guild
+ if (message.GuildId != _Config.GuildId)
+ return false;
+
+ // Filter bot messages if not allowed
+ if (message.Author.Bot && !_Config.IncludeBotMessages)
+ return false;
+
+ // Filter system messages if not allowed
+ if (message.Author.System && !_Config.IncludeSystemMessages)
+ return false;
+
+ // Filter message types
+ if (!_Config.IncludeSystemMessages && message.Type != DiscordMessageType.Default && message.Type != DiscordMessageType.Reply)
+ return false;
+
+ // Check minimum message length
+ if (message.Content.Length < _Config.MinMessageLength)
+ return false;
+
+ // Check blocked users
+ var blockedUsers = _Config.GetBlockedUsersList();
+ if (blockedUsers.Contains(message.Author.Id))
+ return false;
+
+ return true;
+ }
+
+ private void SendHeartbeat(object? state)
+ {
+ if (_WebSocket?.State != WebSocketState.Open || _IsDisposed)
+ return;
+
+ if (!_HeartbeatAcknowledged)
+ {
+ _Logger.LogWarning("Heartbeat not acknowledged, reconnecting...");
+ _ = Task.Run(() => ReconnectAsync(CancellationToken.None));
+ return;
+ }
+
+ _HeartbeatAcknowledged = false;
+
+ var heartbeat = new DiscordGatewayPayload
+ {
+ Op = DiscordGatewayOpcodes.Heartbeat,
+ Data = _LastSequence
+ };
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await SendPayload(heartbeat, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogWarning(ex, "Failed to send heartbeat");
+ }
+ });
+ }
+
+ private async Task SendPayload(DiscordGatewayPayload payload, CancellationToken cancellationToken)
+ {
+ if (_WebSocket?.State != WebSocketState.Open)
+ return;
+
+ var json = JsonSerializer.Serialize(payload);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ await _WebSocket.SendAsync(new ArraySegment(bytes), WebSocketMessageType.Text, true, cancellationToken);
+ }
+
+ private async Task ReconnectAsync(CancellationToken cancellationToken)
+ {
+ if (_IsDisposed || _ReconnectAttempts >= _Config.MaxReconnectAttempts)
+ {
+ if (_ReconnectAttempts >= _Config.MaxReconnectAttempts)
+ {
+ _Logger.LogError("Max reconnection attempts reached, giving up");
+ ConnectionStatusChanged?.Invoke(this, "Failed - Max reconnect attempts reached");
+ }
+ return;
+ }
+
+ _ReconnectAttempts++;
+ var delay = TimeSpan.FromSeconds(Math.Pow(2, _ReconnectAttempts)); // Exponential backoff
+
+ _Logger.LogInformation("Reconnecting to Discord Gateway (attempt {Attempt}/{Max}) in {Delay}s...",
+ _ReconnectAttempts, _Config.MaxReconnectAttempts, delay.TotalSeconds);
+
+ ConnectionStatusChanged?.Invoke(this, $"Reconnecting (attempt {_ReconnectAttempts})...");
+
+ try
+ {
+ await Task.Delay(delay, cancellationToken);
+ await StopAsync();
+ await ConnectAsync(cancellationToken);
+ _ = Task.Run(() => ListenAsync(cancellationToken), cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogError(ex, "Reconnection attempt {Attempt} failed", _ReconnectAttempts);
+
+ // Schedule another reconnect attempt
+ _ = Task.Run(() => ReconnectAsync(cancellationToken), cancellationToken);
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_IsDisposed) return;
+
+ _IsDisposed = true;
+
+ _HeartbeatTimer?.Dispose();
+ _CancellationTokenSource?.Cancel();
+ _CancellationTokenSource?.Dispose();
+ _WebSocket?.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/src/TagzApp.Providers.Discord/DiscordModels.cs b/src/TagzApp.Providers.Discord/DiscordModels.cs
new file mode 100644
index 00000000..9642f2eb
--- /dev/null
+++ b/src/TagzApp.Providers.Discord/DiscordModels.cs
@@ -0,0 +1,309 @@
+using System.Text.Json.Serialization;
+
+namespace TagzApp.Providers.Discord;
+
+///
+/// Represents a Discord message
+///
+public class DiscordMessage
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("channel_id")]
+ public string ChannelId { get; set; } = string.Empty;
+
+ [JsonPropertyName("guild_id")]
+ public string? GuildId { get; set; }
+
+ [JsonPropertyName("content")]
+ public string Content { get; set; } = string.Empty;
+
+ [JsonPropertyName("timestamp")]
+ public DateTimeOffset Timestamp { get; set; }
+
+ [JsonPropertyName("edited_timestamp")]
+ public DateTimeOffset? EditedTimestamp { get; set; }
+
+ [JsonPropertyName("author")]
+ public DiscordUser Author { get; set; } = new();
+
+ [JsonPropertyName("type")]
+ public DiscordMessageType Type { get; set; }
+
+ [JsonPropertyName("embeds")]
+ public DiscordEmbed[] Embeds { get; set; } = Array.Empty();
+
+ [JsonPropertyName("attachments")]
+ public DiscordAttachment[] Attachments { get; set; } = Array.Empty();
+
+ [JsonPropertyName("message_reference")]
+ public DiscordMessageReference? MessageReference { get; set; }
+
+ [JsonPropertyName("pinned")]
+ public bool Pinned { get; set; }
+
+ [JsonPropertyName("mention_everyone")]
+ public bool MentionEveryone { get; set; }
+}
+
+///
+/// Represents a Discord user
+///
+public class DiscordUser
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("username")]
+ public string Username { get; set; } = string.Empty;
+
+ [JsonPropertyName("discriminator")]
+ public string Discriminator { get; set; } = string.Empty;
+
+ [JsonPropertyName("global_name")]
+ public string? GlobalName { get; set; }
+
+ [JsonPropertyName("avatar")]
+ public string? Avatar { get; set; }
+
+ [JsonPropertyName("bot")]
+ public bool Bot { get; set; }
+
+ [JsonPropertyName("system")]
+ public bool System { get; set; }
+
+ ///
+ /// Display name (global name or username)
+ ///
+ public string DisplayName => GlobalName ?? Username;
+
+ ///
+ /// Full username with discriminator if not 0
+ ///
+ public string FullUsername => Discriminator == "0" ? Username : $"{Username}#{Discriminator}";
+
+ ///
+ /// Avatar URL with fallback to default
+ ///
+ public string AvatarUrl => !string.IsNullOrEmpty(Avatar) ?
+ $"https://cdn.discordapp.com/avatars/{Id}/{Avatar}.png?size=128" :
+ $"https://cdn.discordapp.com/embed/avatars/{(string.IsNullOrEmpty(Discriminator) || Discriminator == "0" ? 0 : int.Parse(Discriminator) % 5)}.png";
+}
+
+///
+/// Represents a Discord embed
+///
+public class DiscordEmbed
+{
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("url")]
+ public string? Url { get; set; }
+
+ [JsonPropertyName("timestamp")]
+ public DateTimeOffset? Timestamp { get; set; }
+
+ [JsonPropertyName("color")]
+ public int Color { get; set; }
+
+ [JsonPropertyName("author")]
+ public DiscordEmbedAuthor? Author { get; set; }
+
+ [JsonPropertyName("fields")]
+ public DiscordEmbedField[] Fields { get; set; } = Array.Empty();
+}
+
+///
+/// Represents a Discord embed author
+///
+public class DiscordEmbedAuthor
+{
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("url")]
+ public string? Url { get; set; }
+
+ [JsonPropertyName("icon_url")]
+ public string? IconUrl { get; set; }
+}
+
+///
+/// Represents a Discord embed field
+///
+public class DiscordEmbedField
+{
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("value")]
+ public string Value { get; set; } = string.Empty;
+
+ [JsonPropertyName("inline")]
+ public bool Inline { get; set; }
+}
+
+///
+/// Represents a Discord attachment
+///
+public class DiscordAttachment
+{
+ [JsonPropertyName("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonPropertyName("filename")]
+ public string Filename { get; set; } = string.Empty;
+
+ [JsonPropertyName("url")]
+ public string Url { get; set; } = string.Empty;
+
+ [JsonPropertyName("proxy_url")]
+ public string ProxyUrl { get; set; } = string.Empty;
+
+ [JsonPropertyName("size")]
+ public int Size { get; set; }
+
+ [JsonPropertyName("width")]
+ public int? Width { get; set; }
+
+ [JsonPropertyName("height")]
+ public int? Height { get; set; }
+
+ [JsonPropertyName("content_type")]
+ public string? ContentType { get; set; }
+}
+
+///
+/// Represents a Discord message reference (reply)
+///
+public class DiscordMessageReference
+{
+ [JsonPropertyName("message_id")]
+ public string? MessageId { get; set; }
+
+ [JsonPropertyName("channel_id")]
+ public string? ChannelId { get; set; }
+
+ [JsonPropertyName("guild_id")]
+ public string? GuildId { get; set; }
+}
+
+///
+/// Discord message types
+///
+public enum DiscordMessageType
+{
+ Default = 0,
+ RecipientAdd = 1,
+ RecipientRemove = 2,
+ Call = 3,
+ ChannelNameChange = 4,
+ ChannelIconChange = 5,
+ ChannelPinnedMessage = 6,
+ GuildMemberJoin = 7,
+ UserPremiumGuildSubscription = 8,
+ UserPremiumGuildSubscriptionTier1 = 9,
+ UserPremiumGuildSubscriptionTier2 = 10,
+ UserPremiumGuildSubscriptionTier3 = 11,
+ ChannelFollowAdd = 12,
+ GuildDiscoveryDisqualified = 14,
+ GuildDiscoveryRequalified = 15,
+ GuildDiscoveryGracePeriodInitialWarning = 16,
+ GuildDiscoveryGracePeriodFinalWarning = 17,
+ ThreadCreated = 18,
+ Reply = 19,
+ ChatInputCommand = 20,
+ ThreadStarterMessage = 21,
+ GuildInviteReminder = 22,
+ ContextMenuCommand = 23,
+ AutoModerationAction = 24
+}
+
+///
+/// Discord Gateway payload wrapper
+///
+public class DiscordGatewayPayload
+{
+ [JsonPropertyName("op")]
+ public int Op { get; set; }
+
+ [JsonPropertyName("d")]
+ public object? Data { get; set; }
+
+ [JsonPropertyName("s")]
+ public int? Sequence { get; set; }
+
+ [JsonPropertyName("t")]
+ public string? Type { get; set; }
+}
+
+///
+/// Discord Gateway Hello payload
+///
+public class DiscordHelloPayload
+{
+ [JsonPropertyName("heartbeat_interval")]
+ public int HeartbeatInterval { get; set; }
+}
+
+///
+/// Discord Gateway Identify payload
+///
+public class DiscordIdentifyPayload
+{
+ [JsonPropertyName("token")]
+ public string Token { get; set; } = string.Empty;
+
+ [JsonPropertyName("intents")]
+ public int Intents { get; set; }
+
+ [JsonPropertyName("properties")]
+ public DiscordConnectionProperties Properties { get; set; } = new();
+}
+
+///
+/// Discord connection properties
+///
+public class DiscordConnectionProperties
+{
+ [JsonPropertyName("$os")]
+ public string Os { get; set; } = Environment.OSVersion.Platform.ToString();
+
+ [JsonPropertyName("$browser")]
+ public string Browser { get; set; } = "TagzApp";
+
+ [JsonPropertyName("$device")]
+ public string Device { get; set; } = "TagzApp";
+}
+
+///
+/// Discord Gateway opcodes
+///
+public static class DiscordGatewayOpcodes
+{
+ public const int Dispatch = 0;
+ public const int Heartbeat = 1;
+ public const int Identify = 2;
+ public const int PresenceUpdate = 3;
+ public const int VoiceStateUpdate = 4;
+ public const int Resume = 6;
+ public const int Reconnect = 7;
+ public const int RequestGuildMembers = 8;
+ public const int InvalidSession = 9;
+ public const int Hello = 10;
+ public const int HeartbeatAck = 11;
+}
+
+///
+/// Discord Gateway intents
+///
+public static class DiscordGatewayIntents
+{
+ public const int GuildMessages = 1 << 9;
+ public const int MessageContent = 1 << 15;
+}
\ No newline at end of file
diff --git a/src/TagzApp.Providers.Discord/DiscordProvider.cs b/src/TagzApp.Providers.Discord/DiscordProvider.cs
new file mode 100644
index 00000000..c5639692
--- /dev/null
+++ b/src/TagzApp.Providers.Discord/DiscordProvider.cs
@@ -0,0 +1,273 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.Collections.Concurrent;
+using TagzApp.Common.Telemetry;
+
+namespace TagzApp.Providers.Discord;
+
+///
+/// Discord provider for TagzApp that monitors Discord channels for messages
+///
+public class DiscordProvider : ISocialMediaProvider, IDisposable
+{
+ private bool _DisposedValue;
+ private DiscordGatewayService? _GatewayService;
+ private readonly IOptionsMonitor _ConfigMonitor;
+ private readonly IDisposable? _ConfigChangeSubscription;
+
+ public string Id => "DISCORD";
+ public string DisplayName => "Discord";
+ internal const string AppSettingsSection = "provider-discord";
+ public TimeSpan NewContentRetrievalFrequency => TimeSpan.FromSeconds(1);
+ public string Description { get; init; } = "Monitor Discord channels for live messages and community interactions.";
+ public bool Enabled => _ConfigMonitor.CurrentValue.Enabled;
+
+ private SocialMediaStatus _Status = SocialMediaStatus.Unhealthy;
+ private string _StatusMessage = "Not started";
+
+ private static readonly ConcurrentQueue _Contents = new();
+ private static readonly ConcurrentQueue _MessageQueue = new();
+ private static readonly CancellationTokenSource _CancellationTokenSource = new();
+ private readonly ILogger _Logger;
+ private readonly HttpClient _HttpClient;
+ private readonly ProviderInstrumentation? _Instrumentation;
+
+ public DiscordProvider(
+ ILogger logger,
+ IConfiguration configuration,
+ HttpClient client,
+ IOptionsMonitor configMonitor,
+ ProviderInstrumentation? instrumentation = null)
+ {
+ _ConfigMonitor = configMonitor;
+ _Logger = logger;
+ _HttpClient = client;
+ _Instrumentation = instrumentation;
+
+ // Subscribe to configuration changes
+ _ConfigChangeSubscription = _ConfigMonitor.OnChange(async (config, name) =>
+ {
+ await HandleConfigurationChange(config);
+ });
+
+ var currentConfig = _ConfigMonitor.CurrentValue;
+ if (!string.IsNullOrWhiteSpace(currentConfig.Description))
+ {
+ Description = currentConfig.Description;
+ }
+ }
+
+ public SocialMediaStatus Status => _Status;
+ public string StatusMessage => _StatusMessage;
+
+ public async Task> GetContentForHashtag(Hashtag tag, DateTimeOffset since)
+ {
+ var outContent = new List();
+
+ // Process messages from the queue
+ while (_MessageQueue.TryDequeue(out var message))
+ {
+ var content = MapDiscordMessageToContent(message, tag);
+ if (content != null)
+ {
+ outContent.Add(content);
+ _Contents.Enqueue(content);
+
+ // Maintain queue size limit
+ while (_Contents.Count > _ConfigMonitor.CurrentValue.MaxQueueSize && _Contents.TryDequeue(out _))
+ {
+ // Remove excess items
+ }
+ }
+ }
+
+ return outContent;
+ }
+
+ public async Task StartAsync()
+ {
+ var config = _ConfigMonitor.CurrentValue;
+
+ if (!config.Enabled || !config.IsValid)
+ {
+ _Status = SocialMediaStatus.Disabled;
+ _StatusMessage = config.Enabled ? "Invalid configuration" : "Disabled";
+ _Logger.LogInformation("Discord provider is disabled or has invalid configuration");
+ return;
+ }
+
+ try
+ {
+ _Status = SocialMediaStatus.Connecting;
+ _StatusMessage = "Starting Discord Gateway connection...";
+
+ _Logger.LogInformation("Starting Discord provider for guild {GuildId}, channel {ChannelId}",
+ config.GuildId, config.ChannelId);
+
+ // Initialize Gateway service
+ _GatewayService = new DiscordGatewayService(_Logger, config, _MessageQueue);
+ _GatewayService.ConnectionStatusChanged += OnConnectionStatusChanged;
+ _GatewayService.MessageReceived += OnMessageReceived;
+
+ // Start the Gateway connection
+ await _GatewayService.StartAsync(_CancellationTokenSource.Token);
+
+ _Status = SocialMediaStatus.Healthy;
+ _StatusMessage = "Connected and monitoring channel";
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogError(ex, "Failed to start Discord provider");
+ _Status = SocialMediaStatus.Unhealthy;
+ _StatusMessage = $"Failed to start: {ex.Message}";
+ }
+ }
+
+ public async Task StopAsync()
+ {
+ if (_GatewayService != null)
+ {
+ _GatewayService.ConnectionStatusChanged -= OnConnectionStatusChanged;
+ _GatewayService.MessageReceived -= OnMessageReceived;
+ await _GatewayService.StopAsync();
+ _GatewayService.Dispose();
+ _GatewayService = null;
+ }
+
+ _Status = SocialMediaStatus.Disabled;
+ _StatusMessage = "Stopped";
+ _Logger.LogInformation("Discord provider stopped");
+ }
+
+ private async Task HandleConfigurationChange(DiscordConfiguration newConfig)
+ {
+ _Logger.LogInformation("Discord provider configuration changed");
+
+ // Restart the service with new configuration
+ await StopAsync();
+
+ if (newConfig.Enabled && newConfig.IsValid)
+ {
+ await StartAsync();
+ }
+ }
+
+ private void OnConnectionStatusChanged(object? sender, string status)
+ {
+ _Logger.LogInformation("Discord Gateway status: {Status}", status);
+
+ _Status = status switch
+ {
+ "Connected" or "Ready" => SocialMediaStatus.Healthy,
+ "Connecting..." or "Reconnecting" => SocialMediaStatus.Connecting,
+ var s when s.StartsWith("Failed") => SocialMediaStatus.Unhealthy,
+ _ => SocialMediaStatus.Unknown
+ };
+
+ _StatusMessage = status;
+ }
+
+ private void OnMessageReceived(object? sender, DiscordMessage message)
+ {
+ _Logger.LogDebug("Received Discord message from {Author} in channel {ChannelId}",
+ message.Author.DisplayName, message.ChannelId);
+
+ _Instrumentation?.IncrementContentCounter("DISCORD");
+ }
+
+ private Content? MapDiscordMessageToContent(DiscordMessage message, Hashtag tag)
+ {
+ try
+ {
+ var messageText = BuildMessageText(message);
+
+ var content = new Content
+ {
+ Provider = "DISCORD",
+ ProviderId = message.Id,
+ Type = ContentType.Message,
+ Timestamp = message.Timestamp,
+ SourceUri = new Uri($"https://discord.com/channels/{message.GuildId}/{message.ChannelId}/{message.Id}"),
+ Author = new Creator
+ {
+ DisplayName = message.Author.DisplayName,
+ UserName = message.Author.FullUsername,
+ ProfileUri = new Uri($"https://discord.com/users/{message.Author.Id}"),
+ ProfileImageUri = new Uri(message.Author.AvatarUrl)
+ },
+ Text = messageText,
+ HashtagSought = tag.Text.ToLowerInvariant(),
+
+ // Discord-specific metadata
+ ExtendedMetadata = new Dictionary
+ {
+ ["guildId"] = message.GuildId ?? string.Empty,
+ ["channelId"] = message.ChannelId,
+ ["messageType"] = message.Type.ToString(),
+ ["isBot"] = message.Author.Bot,
+ ["hasEmbeds"] = message.Embeds.Length > 0,
+ ["hasAttachments"] = message.Attachments.Length > 0,
+ ["isReply"] = message.MessageReference != null,
+ ["isEdited"] = message.EditedTimestamp.HasValue
+ }
+ };
+
+ return content;
+ }
+ catch (Exception ex)
+ {
+ _Logger.LogWarning(ex, "Failed to map Discord message {MessageId} to content", message.Id);
+ return null;
+ }
+ }
+
+ private string BuildMessageText(DiscordMessage message)
+ {
+ var text = message.Content;
+
+ // Add attachment information
+ if (message.Attachments.Length > 0)
+ {
+ var attachmentInfo = string.Join(", ", message.Attachments.Select(a =>
+ $"📎 {a.Filename}"));
+ text += $"\n\n{attachmentInfo}";
+ }
+
+ // Add embed information if enabled
+ if (_ConfigMonitor.CurrentValue.EnableRichEmbeds && message.Embeds.Length > 0)
+ {
+ foreach (var embed in message.Embeds)
+ {
+ if (!string.IsNullOrEmpty(embed.Title))
+ text += $"\n\n**{embed.Title}**";
+ if (!string.IsNullOrEmpty(embed.Description))
+ text += $"\n{embed.Description}";
+ }
+ }
+
+ return text;
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_DisposedValue)
+ {
+ if (disposing)
+ {
+ _ConfigChangeSubscription?.Dispose();
+ _GatewayService?.Dispose();
+ _CancellationTokenSource?.Cancel();
+ _CancellationTokenSource?.Dispose();
+ }
+
+ _DisposedValue = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/src/TagzApp.Providers.Discord/README.md b/src/TagzApp.Providers.Discord/README.md
new file mode 100644
index 00000000..99702ec9
--- /dev/null
+++ b/src/TagzApp.Providers.Discord/README.md
@@ -0,0 +1,120 @@
+# Discord Provider for TagzApp
+
+The Discord Provider enables real-time monitoring of Discord channels for live messages in TagzApp. This provider connects to Discord using the Discord Gateway API to receive messages as they are posted.
+
+## Features
+
+- **Real-time Message Monitoring**: Receives messages instantly via WebSocket connection
+- **Channel-based Filtering**: Monitors specific Discord channels in specific servers
+- **Message Type Filtering**: Options to include/exclude bot messages and system messages
+- **Rich Content Support**: Includes Discord embeds and attachment information
+- **User Blocking**: Block specific users from appearing in the feed
+- **Automatic Reconnection**: Handles connection drops with exponential backoff retry logic
+
+## Configuration
+
+### Required Settings
+
+- **Bot Token**: Discord bot token for API access
+- **Guild ID**: The Discord server (guild) ID to monitor
+- **Channel ID**: The specific channel ID to monitor
+
+### Optional Settings
+
+- **Include Bot Messages**: Whether to show messages from bots (default: false)
+- **Include System Messages**: Whether to show join/leave messages (default: false)
+- **Minimum Message Length**: Hide messages shorter than this length (default: 1)
+- **Blocked Users**: Comma-separated list of user IDs to block
+- **Max Queue Size**: Maximum messages to keep in memory (default: 1000)
+- **Max Reconnect Attempts**: Maximum reconnection attempts (default: 5)
+- **Enable Rich Embeds**: Include Discord embed content (default: true)
+
+## Discord Bot Setup
+
+### 1. Create a Discord Application
+
+1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
+2. Click "New Application" and give it a name
+3. Go to the "Bot" section and click "Add Bot"
+4. Copy the bot token and save it securely
+
+### 2. Configure Bot Permissions
+
+Required bot permissions:
+- **View Channel**: To see the channel
+- **Read Message History**: To receive messages
+
+Required intents:
+- **Message Content Intent**: To read message text (must be enabled in Discord Developer Portal)
+
+### 3. Invite Bot to Server
+
+1. In the Discord Developer Portal, go to OAuth2 > URL Generator
+2. Select scopes: `bot`
+3. Select permissions: `View Channel`, `Read Message History`
+4. Use the generated URL to invite the bot to your server
+
+### 4. Get Server and Channel IDs
+
+1. Enable Developer Mode in Discord (Settings > Advanced > Developer Mode)
+2. Right-click on the server name and select "Copy Server ID"
+3. Right-click on the channel name and select "Copy Channel ID"
+
+## Technical Implementation
+
+### WebSocket Connection
+
+The provider uses Discord's Gateway API v10 with WebSocket connection:
+- Gateway URL: `wss://gateway.discord.gg/?v=10&encoding=json`
+- Automatic heartbeat management
+- Reconnection with exponential backoff
+- Session resumption support
+
+### Message Processing
+
+Messages are processed in real-time and filtered based on:
+- Channel membership
+- Message type
+- User preferences (bots, system messages)
+- User blocking list
+- Message length requirements
+
+### Content Mapping
+
+Discord messages are mapped to TagzApp's Content model:
+- **Provider**: "DISCORD"
+- **ProviderId**: Discord message ID
+- **Type**: ContentType.Message
+- **Author**: Discord user information with avatar
+- **Text**: Message content with attachments and embeds
+- **SourceUri**: Direct link to the Discord message
+- **ExtendedMetadata**: Discord-specific information
+
+## Status Monitoring
+
+The provider reports status through the TagzApp status system:
+- **Healthy**: Connected and receiving messages
+- **Connecting**: Establishing connection
+- **Unhealthy**: Connection failed or configuration invalid
+- **Disabled**: Provider disabled in configuration
+
+## Performance Considerations
+
+- Messages are queued in memory with configurable size limits
+- WebSocket connection is maintained with automatic heartbeat
+- Reconnection uses exponential backoff to avoid overwhelming Discord's servers
+- Message filtering happens before content creation to reduce memory usage
+
+## Security
+
+- Bot tokens should be stored securely and encrypted in production
+- The bot only requires minimal permissions (View Channel, Read Message History)
+- No sensitive user data is stored beyond what's necessary for display
+- WebSocket connections use TLS encryption
+
+## Limitations
+
+- Cannot read message history before the bot joins the channel
+- Requires Message Content Intent for full message text (may require verification for large bots)
+- Limited to one channel per provider instance
+- Rate limited by Discord's Gateway limits (no action required, handled automatically)
\ No newline at end of file
diff --git a/src/TagzApp.Providers.Discord/StartDiscord.cs b/src/TagzApp.Providers.Discord/StartDiscord.cs
new file mode 100644
index 00000000..05fc4f39
--- /dev/null
+++ b/src/TagzApp.Providers.Discord/StartDiscord.cs
@@ -0,0 +1,51 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using TagzApp.Communication.Configuration;
+using TagzApp.Communication.Extensions;
+
+namespace TagzApp.Providers.Discord;
+
+///
+/// Service registration for Discord provider
+///
+public class StartDiscord : IConfigureProvider
+{
+ private const string _DisplayName = "Discord";
+
+ public async Task RegisterServices(IServiceCollection services, CancellationToken cancellationToken = default)
+ {
+ // Load initial configuration
+ var initialConfig = await BaseProviderConfiguration.CreateFromConfigurationAsync(ConfigureTagzAppFactory.Current);
+
+ // Configure options for IOptionsMonitor
+ services.Configure(options =>
+ {
+ options.UpdateFrom(initialConfig);
+ });
+
+ // Add a configuration reload service that can be used to update the options
+ services.AddSingleton, DiscordConfigurationSetup>();
+
+ services.AddHttpClient(new());
+ services.AddSingleton();
+
+ return services;
+ }
+}
+
+///
+/// Handles configuration setup for DiscordConfiguration
+///
+public class DiscordConfigurationSetup : IConfigureOptions
+{
+ public void Configure(DiscordConfiguration options)
+ {
+ // This will be called when the configuration is first accessed
+ var config = BaseProviderConfiguration
+ .CreateFromConfigurationAsync(ConfigureTagzAppFactory.Current)
+ .GetAwaiter()
+ .GetResult();
+
+ options.UpdateFrom(config);
+ }
+}
\ No newline at end of file
diff --git a/src/TagzApp.Providers.Discord/TagzApp.Providers.Discord.csproj b/src/TagzApp.Providers.Discord/TagzApp.Providers.Discord.csproj
new file mode 100644
index 00000000..9b97731d
--- /dev/null
+++ b/src/TagzApp.Providers.Discord/TagzApp.Providers.Discord.csproj
@@ -0,0 +1,24 @@
+
+
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/TagzApp.sln b/src/TagzApp.sln
index 2b7984a3..b8ffb995 100644
--- a/src/TagzApp.sln
+++ b/src/TagzApp.sln
@@ -61,6 +61,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.Components", "TagzA
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.Providers.Bluesky", "TagzApp.Providers.Bluesky\TagzApp.Providers.Bluesky.csproj", "{8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}"
EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.Providers.Discord", "TagzApp.Providers.Discord\TagzApp.Providers.Discord.csproj", "{2F8E1234-5678-9ABC-DEF0-123456789ABC}"
+EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ZZ. Archive", "ZZ. Archive", "{940E2AF6-DD9B-44E9-A4AD-1DC0CA73964A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TagzApp.TwitchRelay", "TagzApp.TwitchRelay\TagzApp.TwitchRelay.csproj", "{A64B6AB5-2F9E-4FBD-A622-49598B904E64}"
@@ -167,6 +169,10 @@ Global
{8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2F8E1234-5678-9ABC-DEF0-123456789ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2F8E1234-5678-9ABC-DEF0-123456789ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2F8E1234-5678-9ABC-DEF0-123456789ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2F8E1234-5678-9ABC-DEF0-123456789ABC}.Release|Any CPU.Build.0 = Release|Any CPU
{A64B6AB5-2F9E-4FBD-A622-49598B904E64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A64B6AB5-2F9E-4FBD-A622-49598B904E64}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A64B6AB5-2F9E-4FBD-A622-49598B904E64}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -220,6 +226,7 @@ Global
{D135C491-2B45-49A1-B7CD-B41141475C7C} = {370455D5-6EA6-44C1-B315-B621F7B6A954}
{6751D128-B522-4C1A-9B37-5271C0E6C263} = {370455D5-6EA6-44C1-B315-B621F7B6A954}
{8E5E8ED0-7261-45E0-AE2A-22975FD1EDA5} = {99E0BB3F-9591-4C7A-A8EE-C54B0C3DE7A5}
+ {2F8E1234-5678-9ABC-DEF0-123456789ABC} = {99E0BB3F-9591-4C7A-A8EE-C54B0C3DE7A5}
{A64B6AB5-2F9E-4FBD-A622-49598B904E64} = {370455D5-6EA6-44C1-B315-B621F7B6A954}
{70CA3D81-8CBB-4C63-9D8D-ADE58CCE5FE8} = {6B73C62C-6CC8-4C85-A24F-FA84489B3137}
{290F15DE-B3E8-4FCA-ABF5-C223D611BA6B} = {6B73C62C-6CC8-4C85-A24F-FA84489B3137}
diff --git a/user-docs/Discord-Configuration-Guide.md b/user-docs/Discord-Configuration-Guide.md
new file mode 100644
index 00000000..816fb639
Binary files /dev/null and b/user-docs/Discord-Configuration-Guide.md differ