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