From b3a78b5698c4d943eeae6e11f55a99a03e0020ef Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Sun, 5 Oct 2025 12:41:27 -0400 Subject: [PATCH 1/3] Add comprehensive Discord provider design documentation Complete design specifications for Discord channel monitoring: - Discord-Provider-Design.md: WebSocket Gateway API integration architecture - Discord-Technical-Implementation.md: WebSocket services and bot authentication - Discord-UI-Specification.md: Bot setup wizard and Discord ID helpers - Discord-Configuration-Guide.md: End-user bot creation and setup guide - Discord-Implementation-Roadmap.md: 5-week development timeline Discord provider enables real-time channel monitoring for gaming streams, developer conferences, community events, and live audience engagement. Features: WebSocket connections, bot authentication, Discord API v10 integration, comprehensive bot setup guidance, and real-time message processing. --- doc/Discord-Implementation-Roadmap.md | Bin 0 -> 26542 bytes doc/Discord-Provider-Design.md | Bin 0 -> 57394 bytes doc/Discord-Technical-Implementation.md | Bin 0 -> 113038 bytes doc/Discord-UI-Specification.md | Bin 0 -> 63478 bytes user-docs/Discord-Configuration-Guide.md | Bin 0 -> 27088 bytes 5 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 doc/Discord-Implementation-Roadmap.md create mode 100644 doc/Discord-Provider-Design.md create mode 100644 doc/Discord-Technical-Implementation.md create mode 100644 doc/Discord-UI-Specification.md create mode 100644 user-docs/Discord-Configuration-Guide.md diff --git a/doc/Discord-Implementation-Roadmap.md b/doc/Discord-Implementation-Roadmap.md new file mode 100644 index 0000000000000000000000000000000000000000..aea27e4bae99455829522c2c0531bb991436b481 GIT binary patch literal 26542 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 literal 0 HcmV?d00001 diff --git a/doc/Discord-Technical-Implementation.md b/doc/Discord-Technical-Implementation.md new file mode 100644 index 0000000000000000000000000000000000000000..595d5c315f7363dce224d65dc3585bc66ab10a10 GIT binary patch literal 113038 zcmeI5|8gD2mEUhF|D`JLa78&@cy|Sgl4V<#EQO*d30_f>MM}<=c8g1bAVrHL7yvRw zbNLd8;hC{k~qkW zvyRYR>Uab>LvjynEOE*-oIp3+9r9_rarLI;odtP#=bWWx@Ei}eY0!74Z{R1 zwf1$%Kn`7t(d!VVuAIPsXq>0F{`WE6rg4itxoh+j7T`_zXPEBHBGPAVTFK=Pxq$bO zEa1=ad$<6)DCaFu0$prX$8Mr~9j<>X~|WrIu2tmbxCT$Y6LVLYw_)2MuN-u^A|2+kMMKWq;jSB!<%Ca*I3p4**0z28~R2R6e?Mgu%;blW=} zoO#=;=AVIOJbblu``|5-PRA$0vfnU$f=9UD{VvKJ9`_Aw59}S+oOrKT$0b;CSn2Pf z&DK5TUF&Jr=5xbf#s)^6GK{zN>bLebw*AncDJeeO?%n1Q-s>>?r2AhoQo`+47q8m+ zTJ-an0qHrifBy8a^IDRYUax*jWWi&Fd)8C(_TfKbzS74(wD~xdTj2Z5diO{{5Jl>j zedY81#;ncW>c1Kv-R<QZ>k;3US~vQgp_q7avmM>Y;If7#0zANWku zJlCry1~D-i`UBq8Z%^$nxH9LXZSy*asgDf0NZYfehvy#d+1$|iA1Gr@!B|Fz-K z^A7a+i<4gGU@pP5)4}y3;>~jelG5e4i_-4>IA42Aku*i0hsU@t!nZj*x?~Q^d!ps~ zL>DZP_{d|jjBwix(?;ll*GyXW?dSVW_h5!`i-gp3eDXSCXS?t2J?YV09nA~IE>SA;7BJSIF*duayXh644HLDXPmiO@O zk@_57hqi=u+J4+NS-@l18?_iyycc01BbYo}GGKWmKNo|4viahdoi{kiT)H=dw}bsT z3?t#Aq@2NWZ07Kn(4Q4fzqGlK@5YO`(DBrT)$85A((X-rNLM-x5p90oaaM$Rz53Kf zVJ!Hqg?~S>{#*)5jDm4^`@s5g&ZSRg;u&eY;VJzIHSxq8>IiEN=`^MZZ;5RwKrCte@P2C?int@BL|Q53HdJr#LZL{Tx<7O4KJfr*Y+Lbz}BEQY+UM-&jPv zUj2hXjeS>sf)&a~E56I)%$Sc+mO-8*I&tgR$4c?;FHEcCebieekDC~!txfba+uNnF zt)DtwY7G=)YryyT2Jeow%(?s@fU%cpN?p$ zD04~PcTCQ{HtmErfsf+(w^!^ba*QUuW4UAA`pm2-Iseb?`5#Sxv3kE}HdT4xnzXG~ zKevDD0HtKE<#QPvNv2#5pAQr@jo^^fG*AmRTvC%Z+YWyBy36I_F`aG{sEl=o?$Z@b0$*yeUL=<$APyiS{~ zZiX%)wK)#yo%aos$O$q=FMnfJh#yt9xRj+gKJUCJt<7zB`E7hkB2#R!XJF9{E~CVx zQ4@@F6VR~E?W=cN)>?8;1p+k3?`5|0dPOS`-@qrS&nX#BGosbpF_DIgHYSk~xqY&` z@7q6oQa#OMkaum2q}kr36ws*@sV(i_NU!3y_aWxil6S-GWU<_X2eICs644)s| zL_2WT9JrC+o#S?6kVAXvy8QPx4j_X{Y;iCNTk@AaKWVc$xSU__8;6o>lpUCZKk9&) zSJ}rH%q{DEt|P&8tDHuw{bP|;bPZITq8XMS-%0DeWAw@SJO90n*YB7mpq}=Y`Dt@C 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 Date: Sun, 5 Oct 2025 13:59:35 -0400 Subject: [PATCH 2/3] feat(discord): Implement Discord provider for real-time message monitoring - Added DiscordConfiguration class for provider configuration settings. - Developed DiscordGatewayService for managing WebSocket connections to Discord Gateway. - Created DiscordModels to represent Discord messages, users, embeds, and attachments. - Implemented DiscordProvider to handle message retrieval and processing. - Added README documentation for setup and configuration instructions. - Established service registration for Discord provider in StartDiscord class. - Configured project file with necessary dependencies and settings. --- .../DiscordConfiguration.cs | 206 ++++++++++ .../DiscordGatewayService.cs | 379 ++++++++++++++++++ .../DiscordModels.cs | 309 ++++++++++++++ .../DiscordProvider.cs | 273 +++++++++++++ src/TagzApp.Providers.Discord/README.md | 120 ++++++ src/TagzApp.Providers.Discord/StartDiscord.cs | 51 +++ .../TagzApp.Providers.Discord.csproj | 24 ++ src/TagzApp.sln | 7 + 8 files changed, 1369 insertions(+) create mode 100644 src/TagzApp.Providers.Discord/DiscordConfiguration.cs create mode 100644 src/TagzApp.Providers.Discord/DiscordGatewayService.cs create mode 100644 src/TagzApp.Providers.Discord/DiscordModels.cs create mode 100644 src/TagzApp.Providers.Discord/DiscordProvider.cs create mode 100644 src/TagzApp.Providers.Discord/README.md create mode 100644 src/TagzApp.Providers.Discord/StartDiscord.cs create mode 100644 src/TagzApp.Providers.Discord/TagzApp.Providers.Discord.csproj diff --git a/src/TagzApp.Providers.Discord/DiscordConfiguration.cs b/src/TagzApp.Providers.Discord/DiscordConfiguration.cs new file mode 100644 index 00000000..d1c215cc --- /dev/null +++ b/src/TagzApp.Providers.Discord/DiscordConfiguration.cs @@ -0,0 +1,206 @@ +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; + + [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} From 306ee676601ddcf12fcc8b6819d3460a3d99e152 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Sat, 8 Nov 2025 14:57:39 -0500 Subject: [PATCH 3/3] WIP --- src/TagzApp.Blazor/Service_Providers.cs | 1 + src/TagzApp.Blazor/TagzApp.Blazor.csproj | 1 + src/TagzApp.Providers.Discord/DiscordConfiguration.cs | 7 ++++--- 3 files changed, 6 insertions(+), 3 deletions(-) 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 index d1c215cc..0b6b8627 100644 --- a/src/TagzApp.Providers.Discord/DiscordConfiguration.cs +++ b/src/TagzApp.Providers.Discord/DiscordConfiguration.cs @@ -45,9 +45,10 @@ public class DiscordConfiguration : BaseProviderConfiguration