mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
feat: Add notification sound functionality and embed support
- Introduced NotificationConfig to manage notification settings, including sound file. - Added Notification.mp3 asset for notifications. - Implemented NotificationSoundService to handle playing notification sounds. - Enhanced ChatRenderer and MainWindow to support emoji rendering. - Updated MessageDto to allow multiple embeds per message. - Modified LinkEmbedService to fetch multiple embeds from message content. - Added data migration for legacy embed JSON format to new array format. - Adjusted embed handling in ChatService and IrcMessageFormatter to accommodate multiple embeds. - Updated HubConstants for new embed dimensions and limits.
This commit is contained in:
@@ -175,15 +175,15 @@ public class ChatService : IChatService
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to fetch link embed for URLs in the message
|
||||
EmbedDto? embed = null;
|
||||
// Attempt to fetch link embeds for URLs in the message
|
||||
List<EmbedDto>? embeds = null;
|
||||
try
|
||||
{
|
||||
embed = await _embedService.TryGetEmbedAsync(content);
|
||||
embeds = await _embedService.TryGetEmbedsAsync(content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to fetch link embed for message in '{Channel}'", channelName);
|
||||
_logger.LogWarning(ex, "Failed to fetch link embeds for message in '{Channel}'", channelName);
|
||||
}
|
||||
|
||||
var message = new Message
|
||||
@@ -195,7 +195,7 @@ public class ChatService : IChatService
|
||||
ChannelId = channel.Id,
|
||||
SenderUserId = userId,
|
||||
SenderUsername = username,
|
||||
EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null,
|
||||
EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null,
|
||||
};
|
||||
|
||||
db.Messages.Add(message);
|
||||
@@ -211,7 +211,7 @@ public class ChatService : IChatService
|
||||
null,
|
||||
null,
|
||||
message.SentAt,
|
||||
embed);
|
||||
embeds);
|
||||
|
||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
||||
|
||||
@@ -418,10 +418,10 @@ public class ChatService : IChatService
|
||||
|
||||
return raw.Select(x =>
|
||||
{
|
||||
EmbedDto? embed = null;
|
||||
List<EmbedDto>? embeds = null;
|
||||
if (x.m.EmbedJson is not null)
|
||||
{
|
||||
try { embed = JsonSerializer.Deserialize<EmbedDto>(x.m.EmbedJson); }
|
||||
try { embeds = JsonSerializer.Deserialize<List<EmbedDto>>(x.m.EmbedJson); }
|
||||
catch { /* ignore malformed JSON */ }
|
||||
}
|
||||
|
||||
@@ -435,7 +435,7 @@ public class ChatService : IChatService
|
||||
x.m.AttachmentUrl,
|
||||
x.m.AttachmentFileName,
|
||||
x.m.SentAt,
|
||||
embed);
|
||||
embeds);
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,108 +24,119 @@ public partial class LinkEmbedService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detect the first URL in message content and attempt to fetch OG embed data.
|
||||
/// Returns null if no URL found, fetch fails, or no useful OG data.
|
||||
/// Detect all URLs in message content and attempt to fetch OG embed data for each.
|
||||
/// Returns null if no URLs found or all fetches fail.
|
||||
/// Never throws — all errors are caught internally.
|
||||
/// </summary>
|
||||
public async Task<EmbedDto?> TryGetEmbedAsync(string content)
|
||||
public async Task<List<EmbedDto>?> TryGetEmbedsAsync(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
var url = ExtractFirstUrl(content);
|
||||
if (url is null)
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return null;
|
||||
|
||||
if (IsPrivateHost(uri))
|
||||
return null;
|
||||
|
||||
using var cts = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds));
|
||||
|
||||
var client = _httpClientFactory.CreateClient("OgFetch");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
using var response = await client.SendAsync(request,
|
||||
HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token);
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return null;
|
||||
|
||||
var ogTags = ParseOgTags(html);
|
||||
|
||||
// Try og:title, fallback to <title> tag
|
||||
var title = ogTags.GetValueOrDefault("title");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
var titleMatch = TitleTagRegex().Match(html);
|
||||
if (titleMatch.Success)
|
||||
title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim());
|
||||
}
|
||||
|
||||
// If no title at all, nothing useful to show
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return null;
|
||||
|
||||
var siteName = ogTags.GetValueOrDefault("site_name");
|
||||
var description = ogTags.GetValueOrDefault("description");
|
||||
|
||||
// Truncate description
|
||||
if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength)
|
||||
description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "...";
|
||||
|
||||
// HTML decode text fields
|
||||
title = WebUtility.HtmlDecode(title);
|
||||
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
||||
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
||||
|
||||
// Attempt to fetch OG image thumbnail
|
||||
string? imageAscii = null;
|
||||
var imageUrl = ogTags.GetValueOrDefault("image");
|
||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token);
|
||||
}
|
||||
|
||||
return new EmbedDto(siteName, title, description, imageAscii, url);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogDebug("Embed fetch timed out for message content");
|
||||
var urls = ExtractUrls(content);
|
||||
if (urls.Count == 0)
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var embeds = new List<EmbedDto>();
|
||||
|
||||
foreach (var url in urls)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch embed");
|
||||
return null;
|
||||
try
|
||||
{
|
||||
var embed = await FetchEmbedForUrlAsync(url);
|
||||
if (embed is not null)
|
||||
embeds.Add(embed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to fetch embed for {Url}", url);
|
||||
}
|
||||
}
|
||||
|
||||
return embeds.Count > 0 ? embeds : null;
|
||||
}
|
||||
|
||||
private static string? ExtractFirstUrl(string content)
|
||||
private async Task<EmbedDto?> FetchEmbedForUrlAsync(string url)
|
||||
{
|
||||
var match = UrlRegex().Match(content);
|
||||
if (!match.Success)
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
|
||||
var url = match.Value;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return null;
|
||||
|
||||
// Strip trailing punctuation that's likely not part of the URL
|
||||
url = url.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':');
|
||||
if (IsPrivateHost(uri))
|
||||
return null;
|
||||
|
||||
return url;
|
||||
using var cts = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds));
|
||||
|
||||
var client = _httpClientFactory.CreateClient("OgFetch");
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
||||
using var response = await client.SendAsync(request,
|
||||
HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var contentType = response.Content.Headers.ContentType?.MediaType;
|
||||
if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token);
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return null;
|
||||
|
||||
var ogTags = ParseOgTags(html);
|
||||
|
||||
// Try og:title, fallback to <title> tag
|
||||
var title = ogTags.GetValueOrDefault("title");
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
var titleMatch = TitleTagRegex().Match(html);
|
||||
if (titleMatch.Success)
|
||||
title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim());
|
||||
}
|
||||
|
||||
// If no title at all, nothing useful to show
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return null;
|
||||
|
||||
var siteName = ogTags.GetValueOrDefault("site_name");
|
||||
var description = ogTags.GetValueOrDefault("description");
|
||||
|
||||
// Truncate very long descriptions but keep a generous limit
|
||||
if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength)
|
||||
description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "...";
|
||||
|
||||
// HTML decode text fields
|
||||
title = WebUtility.HtmlDecode(title);
|
||||
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
||||
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
||||
|
||||
// Attempt to fetch OG image as small icon
|
||||
string? imageAscii = null;
|
||||
var imageUrl = ogTags.GetValueOrDefault("image");
|
||||
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||
{
|
||||
imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token);
|
||||
}
|
||||
|
||||
return new EmbedDto(siteName, title, description, imageAscii, url);
|
||||
}
|
||||
|
||||
private static List<string> ExtractUrls(string content)
|
||||
{
|
||||
var urls = new List<string>();
|
||||
|
||||
foreach (Match match in UrlRegex().Matches(content))
|
||||
{
|
||||
var url = match.Value.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':');
|
||||
if (!urls.Contains(url))
|
||||
urls.Add(url);
|
||||
|
||||
if (urls.Count >= HubConstants.EmbedMaxUrlsPerMessage)
|
||||
break;
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
private static bool IsPrivateHost(Uri uri)
|
||||
@@ -220,8 +231,8 @@ public partial class LinkEmbedService
|
||||
memoryStream.Position = 0;
|
||||
|
||||
return _asciiService.ConvertToAscii(memoryStream,
|
||||
HubConstants.EmbedThumbnailWidth,
|
||||
HubConstants.EmbedThumbnailHeight);
|
||||
HubConstants.EmbedIconWidth,
|
||||
HubConstants.EmbedIconHeight);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -16,6 +17,7 @@ public static partial class DataMigrationService
|
||||
|
||||
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||
await MigrateAnsiMessagesAsync(db, logger);
|
||||
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -91,4 +93,47 @@ public static partial class DataMigrationService
|
||||
|
||||
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||
private static partial Regex AnsiColorRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
|
||||
/// </summary>
|
||||
private static async Task MigrateEmbedJsonToArrayAsync(EchoHubDbContext db, ILogger logger)
|
||||
{
|
||||
var messages = await db.Messages
|
||||
.Where(m => m.EmbedJson != null)
|
||||
.ToListAsync();
|
||||
|
||||
var toMigrate = messages
|
||||
.Where(m => m.EmbedJson!.TrimStart().StartsWith('{'))
|
||||
.ToList();
|
||||
|
||||
if (toMigrate.Count == 0)
|
||||
return;
|
||||
|
||||
logger.LogInformation("Found {Count} messages with legacy single-embed JSON. Migrating to array format...", toMigrate.Count);
|
||||
|
||||
var modified = 0;
|
||||
foreach (var message in toMigrate)
|
||||
{
|
||||
try
|
||||
{
|
||||
var single = System.Text.Json.JsonSerializer.Deserialize<EmbedDto>(message.EmbedJson!);
|
||||
if (single is not null)
|
||||
{
|
||||
message.EmbedJson = System.Text.Json.JsonSerializer.Serialize(new[] { single });
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip malformed JSON
|
||||
}
|
||||
}
|
||||
|
||||
if (modified > 0)
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("Migrated {Count} embed records from single-object to array format.", modified);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user