mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +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:
@@ -0,0 +1,22 @@
|
|||||||
|
# v0.2.4 - Link Embeds
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### OpenGraph Link Embeds
|
||||||
|
- Messages containing URLs now show a rich preview below the message text, similar to Discord
|
||||||
|
- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`)
|
||||||
|
- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer
|
||||||
|
- Embeds are persisted in the database and included in channel history
|
||||||
|
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail
|
||||||
|
- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean
|
||||||
|
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
|
||||||
|
- 3-second fetch timeout ensures message delivery is never significantly delayed
|
||||||
|
- SSRF protection rejects private/loopback IP addresses before fetching
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation
|
||||||
|
- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible)
|
||||||
|
- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB)
|
||||||
|
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
|
||||||
|
- New EF Core migration: `AddMessageEmbed`
|
||||||
Binary file not shown.
@@ -5,6 +5,13 @@ public class ClientConfig
|
|||||||
public List<SavedServer> SavedServers { get; set; } = [];
|
public List<SavedServer> SavedServers { get; set; } = [];
|
||||||
public AccountPreset DefaultPreset { get; set; } = new();
|
public AccountPreset DefaultPreset { get; set; } = new();
|
||||||
public string ActiveTheme { get; set; } = "Default";
|
public string ActiveTheme { get; set; } = "Default";
|
||||||
|
public NotificationConfig Notifications { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotificationConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
public string? SoundFile { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SavedServer
|
public class SavedServer
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||||
|
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
@@ -17,6 +18,9 @@
|
|||||||
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Include="Assets\**">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
<EmbeddedResource Include="appsettings.example.json">
|
<EmbeddedResource Include="appsettings.example.json">
|
||||||
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
<LogicalName>EchoHub.Client.appsettings.example.json</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using EchoHub.Client.Config;
|
||||||
|
using NetCoreAudio;
|
||||||
|
using Serilog;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.Services;
|
||||||
|
|
||||||
|
public class NotificationSoundService
|
||||||
|
{
|
||||||
|
private readonly Player _player = new();
|
||||||
|
private readonly NotificationConfig _config;
|
||||||
|
private string? _resolvedSoundPath;
|
||||||
|
|
||||||
|
public NotificationSoundService(NotificationConfig config)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
ResolveSoundPath();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PlayAsync()
|
||||||
|
{
|
||||||
|
if (!_config.Enabled || _resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player.Playing)
|
||||||
|
await _player.Stop();
|
||||||
|
|
||||||
|
await _player.Play(_resolvedSoundPath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to play notification sound");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResolveSoundPath()
|
||||||
|
{
|
||||||
|
// 1. Explicit path from config (~/.echohub/config.json)
|
||||||
|
if (!string.IsNullOrWhiteSpace(_config.SoundFile))
|
||||||
|
{
|
||||||
|
if (File.Exists(_config.SoundFile))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = Path.GetFullPath(_config.SoundFile);
|
||||||
|
Log.Debug("Notification sound: {Path} (from config)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Warning("Configured sound file not found: {Path}", _config.SoundFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Default: Notification.mp3 bundled next to the executable
|
||||||
|
var defaultPath = Path.Combine(AppContext.BaseDirectory, "Assets", "Notification.mp3");
|
||||||
|
|
||||||
|
if (File.Exists(defaultPath))
|
||||||
|
{
|
||||||
|
_resolvedSoundPath = defaultPath;
|
||||||
|
Log.Debug("Notification sound: {Path} (default)", _resolvedSoundPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.Information("No notification sound file found — notifications will be silent");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,12 @@ public partial class ChatLine
|
|||||||
public static bool HasColorTags(string text) =>
|
public static bool HasColorTags(string text) =>
|
||||||
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Remove all color tags from text, returning only the visible characters.
|
||||||
|
/// </summary>
|
||||||
|
public static string StripColorTags(string text) =>
|
||||||
|
ColorTagRegex().Replace(text, "");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a string containing printable color tags into colored segments.
|
/// Parse a string containing printable color tags into colored segments.
|
||||||
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
|
||||||
@@ -458,6 +464,7 @@ public static partial class ChatColors
|
|||||||
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black);
|
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black);
|
||||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
|
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
|
||||||
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
|
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
|
||||||
|
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace EchoHub.Client.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts emoji grapheme clusters to text shortcodes for safe TUI rendering.
|
||||||
|
/// Terminal width calculations for emoji are unreliable across different terminals,
|
||||||
|
/// so we replace them with fixed-width ASCII shortcodes for display only.
|
||||||
|
/// </summary>
|
||||||
|
public static class EmojiHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Replace emoji graphemes in the text with :shortcode: equivalents.
|
||||||
|
/// Non-emoji text passes through unchanged.
|
||||||
|
/// </summary>
|
||||||
|
public static string ReplaceEmoji(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
return text;
|
||||||
|
|
||||||
|
// Quick check: if no characters above BMP or supplementary emoji ranges, skip processing
|
||||||
|
bool hasEmoji = false;
|
||||||
|
foreach (var rune in text.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
hasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasEmoji)
|
||||||
|
return text;
|
||||||
|
|
||||||
|
var sb = new StringBuilder(text.Length);
|
||||||
|
var enumerator = StringInfo.GetTextElementEnumerator(text);
|
||||||
|
|
||||||
|
while (enumerator.MoveNext())
|
||||||
|
{
|
||||||
|
var grapheme = enumerator.GetTextElement();
|
||||||
|
|
||||||
|
// Check if this grapheme contains emoji runes
|
||||||
|
bool graphemeHasEmoji = false;
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
{
|
||||||
|
graphemeHasEmoji = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphemeHasEmoji)
|
||||||
|
{
|
||||||
|
// Try to find a shortcode for the whole grapheme first
|
||||||
|
if (EmojiShortcodes.TryGetValue(grapheme, out var shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Try the base emoji (first rune only, stripping modifiers/ZWJ)
|
||||||
|
var baseRune = GetBaseEmoji(grapheme);
|
||||||
|
if (baseRune is not null && EmojiShortcodes.TryGetValue(baseRune, out shortcode))
|
||||||
|
{
|
||||||
|
sb.Append(shortcode);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Unknown emoji — use generic placeholder
|
||||||
|
sb.Append("[emoji]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(grapheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsEmojiRune(Rune rune)
|
||||||
|
{
|
||||||
|
var value = rune.Value;
|
||||||
|
|
||||||
|
// Common emoji ranges
|
||||||
|
if (value >= 0x1F600 && value <= 0x1F64F) return true; // Emoticons
|
||||||
|
if (value >= 0x1F300 && value <= 0x1F5FF) return true; // Misc Symbols & Pictographs
|
||||||
|
if (value >= 0x1F680 && value <= 0x1F6FF) return true; // Transport & Map
|
||||||
|
if (value >= 0x1F900 && value <= 0x1F9FF) return true; // Supplemental Symbols
|
||||||
|
if (value >= 0x1FA00 && value <= 0x1FA6F) return true; // Chess Symbols
|
||||||
|
if (value >= 0x1FA70 && value <= 0x1FAFF) return true; // Symbols Extended-A
|
||||||
|
if (value >= 0x2600 && value <= 0x26FF) return true; // Misc Symbols
|
||||||
|
if (value >= 0x2700 && value <= 0x27BF) return true; // Dingbats
|
||||||
|
if (value >= 0xFE00 && value <= 0xFE0F) return true; // Variation Selectors
|
||||||
|
if (value >= 0x200D && value <= 0x200D) return true; // ZWJ
|
||||||
|
if (value >= 0x1F1E0 && value <= 0x1F1FF) return true; // Regional Indicators (flags)
|
||||||
|
if (value >= 0x231A && value <= 0x23F3) return true; // Misc Technical (watch, hourglass)
|
||||||
|
if (value >= 0x2934 && value <= 0x2935) return true; // Arrows
|
||||||
|
if (value >= 0x25AA && value <= 0x25FE) return true; // Geometric Shapes
|
||||||
|
if (value >= 0x2B05 && value <= 0x2B55) return true; // Misc Symbols & Arrows
|
||||||
|
if (value >= 0x3030 && value <= 0x303D) return true; // CJK Symbols
|
||||||
|
if (value == 0x00A9 || value == 0x00AE) return true; // © ®
|
||||||
|
if (value == 0x2122) return true; // ™
|
||||||
|
if (value >= 0x1F000 && value <= 0x1F02F) return true; // Mahjong & Dominos
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extract the base emoji string (first non-modifier, non-ZWJ rune) for lookup.
|
||||||
|
/// </summary>
|
||||||
|
private static string? GetBaseEmoji(string grapheme)
|
||||||
|
{
|
||||||
|
foreach (var rune in grapheme.EnumerateRunes())
|
||||||
|
{
|
||||||
|
// Skip ZWJ, variation selectors, skin tone modifiers
|
||||||
|
if (rune.Value == 0x200D) continue;
|
||||||
|
if (rune.Value >= 0xFE00 && rune.Value <= 0xFE0F) continue;
|
||||||
|
if (rune.Value >= 0x1F3FB && rune.Value <= 0x1F3FF) continue;
|
||||||
|
|
||||||
|
if (IsEmojiRune(rune))
|
||||||
|
return rune.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common emoji → shortcode mapping (display-only, covers most frequently used emoji)
|
||||||
|
private static readonly Dictionary<string, string> EmojiShortcodes = new()
|
||||||
|
{
|
||||||
|
// Smileys & Emotion
|
||||||
|
["\U0001F600"] = ":grinning:",
|
||||||
|
["\U0001F601"] = ":grin:",
|
||||||
|
["\U0001F602"] = ":joy:",
|
||||||
|
["\U0001F603"] = ":smiley:",
|
||||||
|
["\U0001F604"] = ":smile:",
|
||||||
|
["\U0001F605"] = ":sweat_smile:",
|
||||||
|
["\U0001F606"] = ":laughing:",
|
||||||
|
["\U0001F607"] = ":innocent:",
|
||||||
|
["\U0001F608"] = ":smiling_imp:",
|
||||||
|
["\U0001F609"] = ":wink:",
|
||||||
|
["\U0001F60A"] = ":blush:",
|
||||||
|
["\U0001F60B"] = ":yum:",
|
||||||
|
["\U0001F60C"] = ":relieved:",
|
||||||
|
["\U0001F60D"] = ":heart_eyes:",
|
||||||
|
["\U0001F60E"] = ":sunglasses:",
|
||||||
|
["\U0001F60F"] = ":smirk:",
|
||||||
|
["\U0001F610"] = ":neutral_face:",
|
||||||
|
["\U0001F611"] = ":expressionless:",
|
||||||
|
["\U0001F612"] = ":unamused:",
|
||||||
|
["\U0001F613"] = ":sweat:",
|
||||||
|
["\U0001F614"] = ":pensive:",
|
||||||
|
["\U0001F615"] = ":confused:",
|
||||||
|
["\U0001F616"] = ":confounded:",
|
||||||
|
["\U0001F617"] = ":kissing:",
|
||||||
|
["\U0001F618"] = ":kissing_heart:",
|
||||||
|
["\U0001F619"] = ":kissing_smiling_eyes:",
|
||||||
|
["\U0001F61A"] = ":kissing_closed_eyes:",
|
||||||
|
["\U0001F61B"] = ":stuck_out_tongue:",
|
||||||
|
["\U0001F61C"] = ":stuck_out_tongue_winking_eye:",
|
||||||
|
["\U0001F61D"] = ":stuck_out_tongue_closed_eyes:",
|
||||||
|
["\U0001F61E"] = ":disappointed:",
|
||||||
|
["\U0001F61F"] = ":worried:",
|
||||||
|
["\U0001F620"] = ":angry:",
|
||||||
|
["\U0001F621"] = ":rage:",
|
||||||
|
["\U0001F622"] = ":cry:",
|
||||||
|
["\U0001F623"] = ":persevere:",
|
||||||
|
["\U0001F624"] = ":triumph:",
|
||||||
|
["\U0001F625"] = ":disappointed_relieved:",
|
||||||
|
["\U0001F626"] = ":frowning:",
|
||||||
|
["\U0001F627"] = ":anguished:",
|
||||||
|
["\U0001F628"] = ":fearful:",
|
||||||
|
["\U0001F629"] = ":weary:",
|
||||||
|
["\U0001F62A"] = ":sleepy:",
|
||||||
|
["\U0001F62B"] = ":tired_face:",
|
||||||
|
["\U0001F62C"] = ":grimacing:",
|
||||||
|
["\U0001F62D"] = ":sob:",
|
||||||
|
["\U0001F62E"] = ":open_mouth:",
|
||||||
|
["\U0001F62F"] = ":hushed:",
|
||||||
|
["\U0001F630"] = ":cold_sweat:",
|
||||||
|
["\U0001F631"] = ":scream:",
|
||||||
|
["\U0001F632"] = ":astonished:",
|
||||||
|
["\U0001F633"] = ":flushed:",
|
||||||
|
["\U0001F634"] = ":sleeping:",
|
||||||
|
["\U0001F635"] = ":dizzy_face:",
|
||||||
|
["\U0001F636"] = ":no_mouth:",
|
||||||
|
["\U0001F637"] = ":mask:",
|
||||||
|
["\U0001F641"] = ":slightly_frowning_face:",
|
||||||
|
["\U0001F642"] = ":slightly_smiling_face:",
|
||||||
|
["\U0001F643"] = ":upside_down_face:",
|
||||||
|
["\U0001F644"] = ":roll_eyes:",
|
||||||
|
["\U0001F910"] = ":zipper_mouth:",
|
||||||
|
["\U0001F911"] = ":money_mouth:",
|
||||||
|
["\U0001F912"] = ":thermometer_face:",
|
||||||
|
["\U0001F913"] = ":nerd:",
|
||||||
|
["\U0001F914"] = ":thinking:",
|
||||||
|
["\U0001F915"] = ":head_bandage:",
|
||||||
|
["\U0001F920"] = ":cowboy:",
|
||||||
|
["\U0001F921"] = ":clown:",
|
||||||
|
["\U0001F922"] = ":nauseated:",
|
||||||
|
["\U0001F923"] = ":rofl:",
|
||||||
|
["\U0001F924"] = ":drooling:",
|
||||||
|
["\U0001F925"] = ":lying:",
|
||||||
|
["\U0001F929"] = ":star_struck:",
|
||||||
|
["\U0001F92A"] = ":zany:",
|
||||||
|
["\U0001F92B"] = ":shushing:",
|
||||||
|
["\U0001F92C"] = ":cursing:",
|
||||||
|
["\U0001F92D"] = ":hand_over_mouth:",
|
||||||
|
["\U0001F92E"] = ":vomiting:",
|
||||||
|
["\U0001F92F"] = ":exploding_head:",
|
||||||
|
["\U0001F970"] = ":smiling_face_with_hearts:",
|
||||||
|
["\U0001F971"] = ":yawning:",
|
||||||
|
["\U0001F972"] = ":smiling_with_tear:",
|
||||||
|
["\U0001F973"] = ":partying:",
|
||||||
|
["\U0001F974"] = ":woozy:",
|
||||||
|
["\U0001F975"] = ":hot_face:",
|
||||||
|
["\U0001F976"] = ":cold_face:",
|
||||||
|
["\U0001F979"] = ":holding_back_tears:",
|
||||||
|
["\U0001F97A"] = ":pleading:",
|
||||||
|
["\U0001FAE0"] = ":melting:",
|
||||||
|
["\U0001FAE1"] = ":saluting:",
|
||||||
|
["\U0001FAE2"] = ":face_with_open_eyes_hand_over_mouth:",
|
||||||
|
["\U0001FAE3"] = ":face_with_peeking_eye:",
|
||||||
|
["\U0001FAE4"] = ":face_with_diagonal_mouth:",
|
||||||
|
|
||||||
|
// Gestures
|
||||||
|
["\U0001F44D"] = ":+1:",
|
||||||
|
["\U0001F44E"] = ":-1:",
|
||||||
|
["\U0001F44B"] = ":wave:",
|
||||||
|
["\U0001F44C"] = ":ok_hand:",
|
||||||
|
["\U0001F44F"] = ":clap:",
|
||||||
|
["\U0001F44A"] = ":fist:",
|
||||||
|
["\U0001F91D"] = ":handshake:",
|
||||||
|
["\U0001F91E"] = ":crossed_fingers:",
|
||||||
|
["\U0001F91F"] = ":love_you:",
|
||||||
|
["\U0001F918"] = ":metal:",
|
||||||
|
["\U0001F919"] = ":call_me:",
|
||||||
|
["\U0001F590"] = ":raised_hand:",
|
||||||
|
["\U0001F4AA"] = ":muscle:",
|
||||||
|
["\U0001F926"] = ":facepalm:",
|
||||||
|
["\U0001F937"] = ":shrug:",
|
||||||
|
["\U0001F64F"] = ":pray:",
|
||||||
|
["\U0001F64C"] = ":raised_hands:",
|
||||||
|
["\U0001F64B"] = ":raising_hand:",
|
||||||
|
|
||||||
|
// Hearts & Symbols
|
||||||
|
["\u2764"] = "<3",
|
||||||
|
["\U0001F494"] = "</3",
|
||||||
|
["\U0001F495"] = ":two_hearts:",
|
||||||
|
["\U0001F496"] = ":sparkling_heart:",
|
||||||
|
["\U0001F497"] = ":heartpulse:",
|
||||||
|
["\U0001F498"] = ":cupid:",
|
||||||
|
["\U0001F499"] = ":blue_heart:",
|
||||||
|
["\U0001F49A"] = ":green_heart:",
|
||||||
|
["\U0001F49B"] = ":yellow_heart:",
|
||||||
|
["\U0001F49C"] = ":purple_heart:",
|
||||||
|
["\U0001F49D"] = ":gift_heart:",
|
||||||
|
["\U0001F49E"] = ":revolving_hearts:",
|
||||||
|
["\U0001F49F"] = ":heart_decoration:",
|
||||||
|
["\U0001F90D"] = ":white_heart:",
|
||||||
|
["\U0001F90E"] = ":brown_heart:",
|
||||||
|
["\U0001F5A4"] = ":black_heart:",
|
||||||
|
["\U0001F9E1"] = ":orange_heart:",
|
||||||
|
|
||||||
|
// Objects & Nature
|
||||||
|
["\U0001F525"] = ":fire:",
|
||||||
|
["\U0001F4A9"] = ":poop:",
|
||||||
|
["\U0001F480"] = ":skull:",
|
||||||
|
["\U0001F47B"] = ":ghost:",
|
||||||
|
["\U0001F47D"] = ":alien:",
|
||||||
|
["\U0001F916"] = ":robot:",
|
||||||
|
["\U0001F4AF"] = ":100:",
|
||||||
|
["\U0001F4A5"] = ":boom:",
|
||||||
|
["\U0001F4A4"] = ":zzz:",
|
||||||
|
["\U0001F4A2"] = ":anger:",
|
||||||
|
["\U0001F4AC"] = ":speech_balloon:",
|
||||||
|
["\U0001F440"] = ":eyes:",
|
||||||
|
["\U0001F3B5"] = ":musical_note:",
|
||||||
|
["\U0001F3B6"] = ":notes:",
|
||||||
|
["\U0001F389"] = ":tada:",
|
||||||
|
["\U0001F38A"] = ":confetti:",
|
||||||
|
["\U0001F381"] = ":gift:",
|
||||||
|
["\U0001F3C6"] = ":trophy:",
|
||||||
|
["\U0001F4B0"] = ":money_bag:",
|
||||||
|
["\U0001F4BB"] = ":computer:",
|
||||||
|
["\U0001F4F1"] = ":phone:",
|
||||||
|
["\U0001F4E7"] = ":email:",
|
||||||
|
["\U0001F511"] = ":key:",
|
||||||
|
["\U0001F512"] = ":lock:",
|
||||||
|
["\U0001F513"] = ":unlock:",
|
||||||
|
["\U0001F6A8"] = ":rotating_light:",
|
||||||
|
["\U0001F6AB"] = ":no_entry:",
|
||||||
|
|
||||||
|
// Animals
|
||||||
|
["\U0001F436"] = ":dog:",
|
||||||
|
["\U0001F431"] = ":cat:",
|
||||||
|
["\U0001F42D"] = ":mouse:",
|
||||||
|
["\U0001F430"] = ":rabbit:",
|
||||||
|
["\U0001F43B"] = ":bear:",
|
||||||
|
["\U0001F427"] = ":penguin:",
|
||||||
|
["\U0001F41D"] = ":bee:",
|
||||||
|
["\U0001F40D"] = ":snake:",
|
||||||
|
["\U0001F422"] = ":turtle:",
|
||||||
|
|
||||||
|
// Food & Drink
|
||||||
|
["\U0001F355"] = ":pizza:",
|
||||||
|
["\U0001F354"] = ":hamburger:",
|
||||||
|
["\U0001F37A"] = ":beer:",
|
||||||
|
["\U0001F377"] = ":wine:",
|
||||||
|
["\U0001F370"] = ":cake:",
|
||||||
|
["\u2615"] = ":coffee:",
|
||||||
|
["\U0001F382"] = ":birthday:",
|
||||||
|
|
||||||
|
// Misc symbols (BMP)
|
||||||
|
["\u2705"] = ":white_check_mark:",
|
||||||
|
["\u274C"] = ":x:",
|
||||||
|
["\u274E"] = ":negative_squared_cross_mark:",
|
||||||
|
["\u2714"] = ":heavy_check_mark:",
|
||||||
|
["\u2716"] = ":heavy_multiplication_x:",
|
||||||
|
["\u26A0"] = ":warning:",
|
||||||
|
["\u2B50"] = ":star:",
|
||||||
|
["\u2728"] = ":sparkles:",
|
||||||
|
["\u267B"] = ":recycle:",
|
||||||
|
["\u2611"] = ":ballot_box_with_check:",
|
||||||
|
["\u23F0"] = ":alarm_clock:",
|
||||||
|
["\u231A"] = ":watch:",
|
||||||
|
["\u231B"] = ":hourglass:",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ using EchoHub.Core.DTOs;
|
|||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.App;
|
using Terminal.Gui.App;
|
||||||
using Terminal.Gui.Configuration;
|
using Terminal.Gui.Configuration;
|
||||||
|
using Terminal.Gui.Drawing;
|
||||||
using Terminal.Gui.Input;
|
using Terminal.Gui.Input;
|
||||||
|
using Terminal.Gui.Text;
|
||||||
using Terminal.Gui.ViewBase;
|
using Terminal.Gui.ViewBase;
|
||||||
using Terminal.Gui.Views;
|
using Terminal.Gui.Views;
|
||||||
using Attribute = Terminal.Gui.Drawing.Attribute;
|
using Attribute = Terminal.Gui.Drawing.Attribute;
|
||||||
@@ -880,7 +882,8 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
default:
|
default:
|
||||||
var contentLines = message.Content.Split('\n');
|
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
|
var contentLines = displayContent.Split('\n');
|
||||||
var firstLine = contentLines[0].TrimEnd('\r');
|
var firstLine = contentLines[0].TrimEnd('\r');
|
||||||
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
|
||||||
// Continuation lines indented to align with first line's content
|
// Continuation lines indented to align with first line's content
|
||||||
@@ -891,9 +894,12 @@ public sealed class MainWindow : Runnable
|
|||||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render link embed if present
|
// Render link embeds if present
|
||||||
if (message.Embed is not null)
|
if (message.Embeds is { Count: > 0 })
|
||||||
lines.AddRange(FormatEmbed(message.Embed, indent));
|
{
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(embed, indent));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -944,65 +950,152 @@ public sealed class MainWindow : Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a link embed as indented chat lines with a left border bar.
|
/// Format a link embed as indented chat lines with a left border bar,
|
||||||
|
/// optional description wrapping, and a small icon on the right (Discord-style).
|
||||||
|
/// Layout: ▏ {text column} {icon column}
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent)
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent)
|
||||||
{
|
{
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
const string border = "\u258f "; // ▏ + space
|
const string border = "\u258f "; // ▏ + space
|
||||||
|
const int borderCols = 2; // ▏ = 1 col + space = 1 col
|
||||||
|
const int iconGap = 1; // space between text and icon
|
||||||
|
|
||||||
// Site name
|
// Parse icon lines if present
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
var iconLines = new List<string>();
|
||||||
{
|
int iconWidth = 0;
|
||||||
lines.Add(new ChatLine(new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new(indent, null),
|
|
||||||
new(border, ChatColors.EmbedBorderAttr),
|
|
||||||
new(embed.SiteName, ChatColors.EmbedBorderAttr)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
|
||||||
{
|
|
||||||
lines.Add(new ChatLine(new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new(indent, null),
|
|
||||||
new(border, ChatColors.EmbedBorderAttr),
|
|
||||||
new(embed.Title, ChatColors.EmbedTitleAttr)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Description (truncated)
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
|
||||||
{
|
|
||||||
var desc = embed.Description.Length > 120
|
|
||||||
? embed.Description[..117] + "..."
|
|
||||||
: embed.Description;
|
|
||||||
|
|
||||||
lines.Add(new ChatLine(new List<ChatSegment>
|
|
||||||
{
|
|
||||||
new(indent, null),
|
|
||||||
new(border, ChatColors.EmbedBorderAttr),
|
|
||||||
new(desc, ChatColors.EmbedDescAttr)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ASCII image thumbnail
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.ImageAscii))
|
if (!string.IsNullOrWhiteSpace(embed.ImageAscii))
|
||||||
{
|
{
|
||||||
foreach (var artLine in embed.ImageAscii.Split('\n'))
|
foreach (var artLine in embed.ImageAscii.Split('\n'))
|
||||||
{
|
{
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
var trimmed = artLine.TrimEnd('\r');
|
||||||
if (string.IsNullOrEmpty(trimmed)) continue;
|
if (!string.IsNullOrEmpty(trimmed))
|
||||||
|
iconLines.Add(trimmed);
|
||||||
if (ChatLine.HasColorTags(trimmed))
|
|
||||||
lines.Add(ChatLine.FromColoredText(indent + border + trimmed));
|
|
||||||
else
|
|
||||||
lines.Add(new ChatLine(indent + border + trimmed));
|
|
||||||
}
|
}
|
||||||
|
if (iconLines.Count > 0)
|
||||||
|
{
|
||||||
|
// Measure icon width from the first line (strip color tags for measurement)
|
||||||
|
var stripped = ChatLine.StripColorTags(iconLines[0]);
|
||||||
|
iconWidth = stripped.GetColumns();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasIcon = iconLines.Count > 0 && iconWidth > 0;
|
||||||
|
int indentCols = indent.GetColumns();
|
||||||
|
|
||||||
|
// We don't know the terminal width at format time, so use a reasonable default
|
||||||
|
// for text wrapping. The ChatListSource.Render will handle final clipping.
|
||||||
|
const int estimatedWidth = 80;
|
||||||
|
int availableForText = estimatedWidth - indentCols - borderCols;
|
||||||
|
int textColWidth = hasIcon
|
||||||
|
? availableForText - iconWidth - iconGap
|
||||||
|
: availableForText;
|
||||||
|
if (textColWidth < 20) textColWidth = 20;
|
||||||
|
|
||||||
|
// Collect all text rows (site name, title, wrapped description, URL)
|
||||||
|
var textRows = new List<(string Text, Attribute? Color)>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
|
textRows.Add((embed.SiteName, ChatColors.EmbedBorderAttr));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
textRows.Add((embed.Title, ChatColors.EmbedTitleAttr));
|
||||||
|
|
||||||
|
// Word-wrap description
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
|
{
|
||||||
|
foreach (var wrappedLine in WordWrap(embed.Description, textColWidth))
|
||||||
|
textRows.Add((wrappedLine, ChatColors.EmbedDescAttr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dim URL at the bottom
|
||||||
|
textRows.Add((embed.Url, ChatColors.EmbedUrlAttr));
|
||||||
|
|
||||||
|
// Merge text rows with icon rows side-by-side
|
||||||
|
int totalLines = Math.Max(textRows.Count, iconLines.Count);
|
||||||
|
for (int i = 0; i < totalLines; i++)
|
||||||
|
{
|
||||||
|
var segments = new List<ChatSegment>();
|
||||||
|
segments.Add(new ChatSegment(indent, null));
|
||||||
|
segments.Add(new ChatSegment(border, ChatColors.EmbedBorderAttr));
|
||||||
|
|
||||||
|
if (i < textRows.Count)
|
||||||
|
{
|
||||||
|
var (text, color) = textRows[i];
|
||||||
|
segments.Add(new ChatSegment(text, color));
|
||||||
|
|
||||||
|
// Pad to align icon column
|
||||||
|
if (hasIcon && i < iconLines.Count)
|
||||||
|
{
|
||||||
|
int textCols = text.GetColumns();
|
||||||
|
int padding = textColWidth - textCols + iconGap;
|
||||||
|
if (padding > 0)
|
||||||
|
segments.Add(new ChatSegment(new string(' ', padding), null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (hasIcon && i < iconLines.Count)
|
||||||
|
{
|
||||||
|
// No text row, pad the full text column + gap
|
||||||
|
segments.Add(new ChatSegment(new string(' ', textColWidth + iconGap), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append icon line
|
||||||
|
if (hasIcon && i < iconLines.Count)
|
||||||
|
{
|
||||||
|
var iconLine = iconLines[i];
|
||||||
|
if (ChatLine.HasColorTags(iconLine))
|
||||||
|
{
|
||||||
|
// Build a composite: plain segments + colored icon
|
||||||
|
var plainPart = new ChatLine(segments);
|
||||||
|
var iconPart = ChatLine.FromColoredText(iconLine);
|
||||||
|
var merged = new List<ChatSegment>(plainPart.Segments);
|
||||||
|
merged.AddRange(iconPart.Segments);
|
||||||
|
lines.Add(new ChatLine(merged));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
segments.Add(new ChatSegment(iconLine, null));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Add(new ChatLine(segments));
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple word-wrap: splits text into lines that fit within maxCols display columns.
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> WordWrap(string text, int maxCols)
|
||||||
|
{
|
||||||
|
if (maxCols <= 0)
|
||||||
|
return [text];
|
||||||
|
|
||||||
|
var result = new List<string>();
|
||||||
|
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var currentLine = "";
|
||||||
|
|
||||||
|
foreach (var word in words)
|
||||||
|
{
|
||||||
|
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
|
||||||
|
if (candidate.GetColumns() <= maxCols)
|
||||||
|
{
|
||||||
|
currentLine = candidate;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
// If a single word exceeds maxCols, just add it as-is
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentLine.Length > 0)
|
||||||
|
result.Add(currentLine);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ public static class HubConstants
|
|||||||
public const int AsciiArtHeightHalfBlock = 80;
|
public const int AsciiArtHeightHalfBlock = 80;
|
||||||
|
|
||||||
// Link embed constants
|
// Link embed constants
|
||||||
public const int EmbedThumbnailWidth = 24;
|
public const int EmbedIconWidth = 12;
|
||||||
public const int EmbedThumbnailHeight = 12;
|
public const int EmbedIconHeight = 6;
|
||||||
public const int EmbedMaxDescriptionLength = 200;
|
public const int EmbedMaxDescriptionLength = 500;
|
||||||
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
|
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
|
||||||
public const int EmbedFetchTimeoutSeconds = 3;
|
public const int EmbedFetchTimeoutSeconds = 3;
|
||||||
|
public const int EmbedMaxUrlsPerMessage = 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public record MessageDto(
|
|||||||
string? AttachmentUrl,
|
string? AttachmentUrl,
|
||||||
string? AttachmentFileName,
|
string? AttachmentFileName,
|
||||||
DateTimeOffset SentAt,
|
DateTimeOffset SentAt,
|
||||||
EmbedDto? Embed = null);
|
List<EmbedDto>? Embeds = null);
|
||||||
|
|
||||||
public record ChannelDto(
|
public record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ public static partial class IrcMessageFormatter
|
|||||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
||||||
|
|
||||||
// Append embed preview if present
|
// Append embed previews if present
|
||||||
if (message.Embed is not null)
|
if (message.Embeds is { Count: > 0 })
|
||||||
lines.AddRange(FormatEmbed(prefix, ircChannel, message.Embed));
|
{
|
||||||
|
foreach (var embed in message.Embeds)
|
||||||
|
lines.AddRange(FormatEmbed(prefix, ircChannel, embed));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MessageType.Image:
|
case MessageType.Image:
|
||||||
|
|||||||
@@ -175,15 +175,15 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to fetch link embed for URLs in the message
|
// Attempt to fetch link embeds for URLs in the message
|
||||||
EmbedDto? embed = null;
|
List<EmbedDto>? embeds = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
embed = await _embedService.TryGetEmbedAsync(content);
|
embeds = await _embedService.TryGetEmbedsAsync(content);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
var message = new Message
|
||||||
@@ -195,7 +195,7 @@ public class ChatService : IChatService
|
|||||||
ChannelId = channel.Id,
|
ChannelId = channel.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = username,
|
SenderUsername = username,
|
||||||
EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null,
|
EmbedJson = embeds is not null ? JsonSerializer.Serialize(embeds) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
db.Messages.Add(message);
|
||||||
@@ -211,7 +211,7 @@ public class ChatService : IChatService
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
embed);
|
embeds);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
||||||
|
|
||||||
@@ -418,10 +418,10 @@ public class ChatService : IChatService
|
|||||||
|
|
||||||
return raw.Select(x =>
|
return raw.Select(x =>
|
||||||
{
|
{
|
||||||
EmbedDto? embed = null;
|
List<EmbedDto>? embeds = null;
|
||||||
if (x.m.EmbedJson is not 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 */ }
|
catch { /* ignore malformed JSON */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +435,7 @@ public class ChatService : IChatService
|
|||||||
x.m.AttachmentUrl,
|
x.m.AttachmentUrl,
|
||||||
x.m.AttachmentFileName,
|
x.m.AttachmentFileName,
|
||||||
x.m.SentAt,
|
x.m.SentAt,
|
||||||
embed);
|
embeds);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,108 +24,119 @@ public partial class LinkEmbedService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Detect the first URL in message content and attempt to fetch OG embed data.
|
/// Detect all URLs in message content and attempt to fetch OG embed data for each.
|
||||||
/// Returns null if no URL found, fetch fails, or no useful OG data.
|
/// Returns null if no URLs found or all fetches fail.
|
||||||
/// Never throws — all errors are caught internally.
|
/// Never throws — all errors are caught internally.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<EmbedDto?> TryGetEmbedAsync(string content)
|
public async Task<List<EmbedDto>?> TryGetEmbedsAsync(string content)
|
||||||
{
|
{
|
||||||
try
|
var urls = ExtractUrls(content);
|
||||||
{
|
if (urls.Count == 0)
|
||||||
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");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
catch (Exception ex)
|
var embeds = new List<EmbedDto>();
|
||||||
|
|
||||||
|
foreach (var url in urls)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to fetch embed");
|
try
|
||||||
return null;
|
{
|
||||||
|
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 (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||||
if (!match.Success)
|
|
||||||
return null;
|
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
|
if (IsPrivateHost(uri))
|
||||||
url = url.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':');
|
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)
|
private static bool IsPrivateHost(Uri uri)
|
||||||
@@ -220,8 +231,8 @@ public partial class LinkEmbedService
|
|||||||
memoryStream.Position = 0;
|
memoryStream.Position = 0;
|
||||||
|
|
||||||
return _asciiService.ConvertToAscii(memoryStream,
|
return _asciiService.ConvertToAscii(memoryStream,
|
||||||
HubConstants.EmbedThumbnailWidth,
|
HubConstants.EmbedIconWidth,
|
||||||
HubConstants.EmbedThumbnailHeight);
|
HubConstants.EmbedIconHeight);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ public static partial class DataMigrationService
|
|||||||
|
|
||||||
await EnsureDefaultChannelsPublicAsync(db, logger);
|
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||||
await MigrateAnsiMessagesAsync(db, logger);
|
await MigrateAnsiMessagesAsync(db, logger);
|
||||||
|
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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")]
|
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
|
||||||
private static partial Regex AnsiColorRegex();
|
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