Merge pull request #18 from HueByte/dev

Dev merge
This commit is contained in:
Hue
2026-02-22 05:56:01 +01:00
committed by GitHub
77 changed files with 5512 additions and 309 deletions
+12 -1
View File
@@ -22,6 +22,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10 - name: Setup .NET 10
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
@@ -29,7 +35,7 @@ jobs:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Check formatting - name: Check formatting
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic --exclude src/Terminal.Gui/
build-and-test: build-and-test:
name: Build & Test name: Build & Test
@@ -38,6 +44,11 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes - name: Check for src/ changes
id: changes id: changes
+6
View File
@@ -16,6 +16,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Setup .NET 10 - name: Setup .NET 10
uses: actions/setup-dotnet@v4 uses: actions/setup-dotnet@v4
+5
View File
@@ -16,6 +16,11 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
submodules: recursive
# TEMPORARY: Terminal.Gui submodule's nuget.config breaks restore — remove until PR #4234 is merged
- name: Remove submodule NuGet config
run: rm -f src/Terminal.Gui/nuget.config
- name: Check for src/ changes - name: Check for src/ changes
id: changes id: changes
+3
View File
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
# DocFx generated output # DocFx generated output
docs/_site/ docs/_site/
docs/_api_meta/ docs/_api_meta/
# Diff files
*.diff
+3
View File
@@ -0,0 +1,3 @@
[submodule "src/Terminal.Gui"]
path = src/Terminal.Gui
url = https://github.com/HueByte/Terminal.Gui.git
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg width="32" height="32" viewBox="0 0 1000 1000" xmlns="http://www.w3.org/2000/svg">
<g stroke="#E6C06E" stroke-width="28" fill="none" stroke-linecap="round" stroke-linejoin="round">
<circle cx="500" cy="500" r="420" />
<circle cx="500" cy="500" r="180" />
<polygon points="500,440 550,532 450,532" fill="#E6C06E" />
<line x1="500" y1="80" x2="500" y2="320" />
<circle cx="285" cy="390" r="8" fill="#E6C06E" />
<circle cx="715" cy="390" r="8" fill="#E6C06E" />
<circle cx="500" cy="750" r="8" fill="#E6C06E" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 590 B

+4 -2
View File
@@ -5,7 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
## Topics ## Topics
- Terminal.Gui v2 patterns and conventions - Terminal.Gui v2 patterns and conventions
- Theme system and customization - Theme system and customization (including transparent theme)
- Command system reference - Command system reference
- Configuration management - Configuration and session persistence
- Audio playback and file downloads
- Automatic update checking
- [Notification sounds](../../articles/notification-sounds.md) - [Notification sounds](../../articles/notification-sounds.md)
+1 -1
View File
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
## Topics ## Topics
- Data models and DTOs - Data models and DTOs
- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient) - Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
- Validation constants and shared rules - Validation constants and shared rules
+2 -2
View File
@@ -10,11 +10,11 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
### Core ### Core
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`). Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
### Server ### Server
ASP.NET Core server -- controllers, hubs, ChatService, SignalRBroadcaster, authentication, and data access. ASP.NET Core server -- controllers, hubs, ChatService, ChannelService, SignalRBroadcaster, authentication, file cleanup, and data access.
### Server.Irc ### Server.Irc
+3 -2
View File
@@ -8,6 +8,7 @@ Articles related to the EchoHub server built with ASP.NET Core.
- SignalR hub and real-time messaging - SignalR hub and real-time messaging
- IRC gateway and protocol bridging - IRC gateway and protocol bridging
- ChatService and broadcaster pattern - ChatService and broadcaster pattern
- File upload and validation - ChannelService and channel CRUD
- File upload, validation, and cleanup
- Rate limiting configuration - Rate limiting configuration
- Database schema and migrations - Database schema, migrations, and DataMigrationService
+5 -5
View File
@@ -22,7 +22,7 @@ Shared library containing:
- **Models**: `User`, `Channel`, `Message`, `RefreshToken` - **Models**: `User`, `Channel`, `Message`, `RefreshToken`
- **DTOs**: Record types for API requests/responses - **DTOs**: Record types for API requests/responses
- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface) - **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChannelService` (channel CRUD and membership), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants` - **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
### EchoHub.Server ### EchoHub.Server
@@ -33,7 +33,7 @@ ASP.NET Core web application:
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService` - **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens) - **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
- **Data**: EF Core with SQLite - **Data**: EF Core with SQLite
- **Services**: `ChatService` (core business logic), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion - **Services**: `ChatService` (core business logic), `ChannelService` (channel CRUD and membership), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion, `FileCleanupService` (periodic removal of expired uploads), `DataMigrationService` (startup schema/data evolution)
### EchoHub.Server.Irc ### EchoHub.Server.Irc
@@ -51,9 +51,9 @@ IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` o
Terminal.Gui v2 TUI application: Terminal.Gui v2 TUI application:
- **UI**: Main window, dialogs, chat renderer with ANSI color support - **UI**: Main window, dialogs, chat renderer with ANSI color support
- **Services**: API client with automatic token refresh, SignalR connection wrapper - **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
- **Themes**: 13 built-in color themes - **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
- **Config**: Client configuration management - **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
## Communication ## Communication
+2
View File
@@ -4,6 +4,8 @@ Release history for EchoHub.
## Releases ## Releases
- [v0.2.5](v0.2.5.md) - Session Persistence, Auto-Updates, Audio & Transparent Theme
- [v0.2.4](v0.2.4.md) - E2E Message Encryption
- [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul - [v0.2.3](v0.2.3.md) - Moderation, Embeds & UI Overhaul
- [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes - [v0.2.2](v0.2.2.md) - Startup & Shutdown Fixes
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes - [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
+2
View File
@@ -1,5 +1,7 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.2.5
href: v0.2.5.md
- name: v0.2.4 - name: v0.2.4
href: v0.2.4.md href: v0.2.4.md
- name: v0.2.3 - name: v0.2.3
+1 -1
View File
@@ -28,5 +28,5 @@
- `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest - `Encryption:EncryptDatabase` server setting (default `false`) controls whether messages are encrypted at rest
- DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000 - DB column max lengths increased for encrypted content: `Message.Content` 2000 → 16000, `Message.EmbedJson` 8000 → 32000
- EF Core migration: `AddEncryptionSupport` - EF Core migration: `AddEncryptionSupport`
- Encryption test suite: server-side, client-side, and cross-compatibility tests (87 total) - Encryption test suite: server-side, client-side, and cross-compatibility tests
- Documentation article: `docs/articles/encryption.md` - Documentation article: `docs/articles/encryption.md`
+119
View File
@@ -0,0 +1,119 @@
# v0.2.5 - Session Persistence, Auto-Updates, Audio & Transparent Theme
## Features
### Audio Message Support
- New `Audio` message type — uploaded audio files (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.wma`) are automatically detected and categorized
- TUI client renders audio messages with `♪ [Audio: filename] (Enter to play)` indicator
- Press Enter on an audio message to open the **Audio Player dialog** with animated wave visualization, play/pause/stop controls, and volume slider
- Per-type upload limits: **10 MB** images, **10 MB** audio, **100 MB** generic files (with Kestrel request size configured to match)
- `AudioPlaybackService` enhanced with pause/resume, volume control, and playback-finished events
- Fixed: wrapped audio/file messages now remain clickable on all lines (attachment metadata propagated through word-wrap)
- IRC gateway formats audio messages as `♪ [Audio: filename] url`
- Server detects audio files by extension via `FileValidationHelper.IsAudioFile()`
### File Downloads
- Press Enter on a file message in the TUI client to download and open it with the system default application
- `ApiClient.DownloadFileAsync()` streams file downloads from the server
- File and audio messages in the chat list now show colored indicators with interaction hints
### File Cleanup Service
- `FileCleanupService` (BackgroundService) periodically removes old uploaded files
- Configurable via `Storage:CleanupIntervalHours` (default 1h) and `Storage:RetentionDays` (default 30d)
### Data Migration Service
- `DataMigrationService` runs at startup to handle schema/data evolution
- Ensures `#general` channel and pre-existing channels are marked public
- Migrates legacy ANSI escape codes in image messages to printable color tags (`{F:RRGGBB}`, `{B:RRGGBB}`, `{X}`)
- Migrates legacy single-object `EmbedJson` to array format
- Promotes usernames listed in `Server:Admins` config to Admin role
### True Transparent Background
- Transparent theme now uses the terminal's native background instead of solid black
- Powered by `Color.None` (alpha=0) which emits ANSI `CSI 49m` (default background) instead of explicit RGB
- Terminal transparency, acrylic, wallpaper effects now show through the TUI
- Dialogs retain solid `DarkGray` background for readability
- Uses local Terminal.Gui fork (submodule) with transparent color support pending upstream merge ([gui-cs/Terminal.Gui#4234](https://github.com/gui-cs/Terminal.Gui/pull/4234))
### Enhanced Status Bar
- Status bar now shows **EchoHub** branding at the start
- Connection state is color-coded: green (Connected), red (Disconnected), yellow (transitional states like Connecting, Reconnecting, Authenticating)
- Current channel shows its type: `#channel - public` or `#channel - private`
### Remember Me (Session Persistence)
- "Remember me" checkbox in the connect dialog — saves a 30-day refresh token so users can reconnect without entering their password
- Saved servers with active sessions show `[session]` indicator in the connect dialog and saved servers list
- Token-based login: selecting a saved server with a session lets you click Login with an empty password
- Graceful expiry handling: if the saved session is expired or revoked, shows an error and prompts for password
- Refresh token rotation: rotated tokens are automatically persisted to config so the session stays valid across refreshes
- New "Logout" menu item (Server menu): revokes the refresh token server-side and clears the saved session
- Removed dead `SavedServer.Token` field (stored 15-min access token that was never read back)
### Automatic Update Checking
- `UpdateChecker` rewritten to use the [AlwaysUpToDate](https://github.com/AuriRex/AlwaysUpToDate) library for self-updating
- Polls the EchoHub version manifest hourly (active only in `RELEASE` builds)
- `UpdateConfirmDialog` shows current vs. available version with Update/Cancel buttons
- `UpdateProgressDialog` displays real-time download progress bar
- Errors are logged via Serilog instead of being silently swallowed
### Windows Installer
- New Inno Setup script (`installer/Installer.iss`) to build a Windows installer
- Auto-reads product version from the built EXE
- User-level install by default (no admin required), with admin override option
- Creates desktop and quick-launch shortcuts (optional)
- Supports English and German languages
### Application Icons
- New `hue_icon` branding assets (ICO, PNG, SVG) in `assets/`
- Application icon set on both Client and Server projects
### #general Channel Protection
- `#general` channel is now auto-recreated if somehow missing (both in `GetChannels` endpoint and `JoinChannel` flow)
- Users cannot `/leave` the #general channel (client-side guard)
- Connecting while already connected now prompts to disconnect first instead of silently leaking the previous connection
## Fixes
### IRC Gateway
- **Fixed: IRC JOIN history replay showed encrypted gibberish** — `IrcCommandHandler.HandleJoinAsync` now decrypts channel history before formatting for IRC clients (history was encrypted for SignalR transport but sent raw to IRC)
- **Fixed: `JoinedChannels` race condition** — `IrcClientConnection.JoinedChannels` replaced with thread-safe methods (`JoinChannel`, `LeaveChannel`, `IsInChannel`, `GetJoinedChannels`) using lock synchronization; prevents crashes when broadcaster threads read while the command handler writes
- **Fixed: `RequireRegistered` fire-and-forget** — converted from sync `bool` to `async Task<bool>` (`RequireRegisteredAsync`) so the error reply is properly awaited before the handler returns
### ChannelService Extraction
- Extracted channel management logic from `ChannelsController` and `ChatService` into a dedicated `IChannelService` / `ChannelService`
- `ChannelsController` is now a thin adapter — delegates CRUD operations to `IChannelService` and maps `ChannelError` to HTTP status codes
- `ChatService.JoinChannelAsync` delegates channel validation + membership to `IChannelService.EnsureChannelMembershipAsync()`
- New `ChannelOperationResult` result type with `ChannelError` enum for typed error handling across service boundaries
- IRC gateway uses `IChannelService` for topic queries and channel listing (instead of `IChatService`)
### EchoHub Branding
- Status bar "EchoHub" text now uses golden color (218, 165, 32)
## Infrastructure
- Audio MIME types in `FilesController` (mp3, wav, ogg, flac, aac, m4a, wma)
- CI workflows updated to handle Terminal.Gui submodule `nuget.config` workaround (`rm -f` step)
- Root `nuget.config` added for package source management
- Terminal.Gui formatting excluded from CI format checks
- Recursive submodule fetching enabled in CI workflows
- `Server:Admins` config array in `appsettings.example.json` for designating admin usernames
- `Storage:CleanupIntervalHours` and `Storage:RetentionDays` added to `appsettings.example.json`
- `FakeChannelService` test helper added for IRC unit tests
- Test suites: ChatLine, CommandHandler, DataMigrationService, FileValidationHelper, ImageToAsciiService, IrcMessageFormatter, JwtTokenService, LinkEmbedService
- IRC abstraction layer test suite: IrcMessage parsing, IrcMessageFormatter, IrcClientConnection, IrcCommandHandler, IrcBroadcaster
- Test helpers: `TestDuplexStream`, `TestIrcConnectionFactory`, `FakeChatService`, `FakeChannelService`, `FakeEncryptionService` for IRC unit testing without network I/O
- 346 total tests
Binary file not shown.
+52
View File
@@ -0,0 +1,52 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
#expr Exec('cmd.exe', '/C dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\EchoHub.Client\')
#define MyAppName "EchoHub"
#define MyAppVersion GetStringFileInfo("/publish/EchoHub.Client.exe","ProductVersion")
#define MyAppPublisher "Hue"
#define MyAppExeName "EchoHub.Client.exe"
[Setup]
; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications.
; (To generate a new GUID, click Tools | Generate GUID inside the IDE.)
AppId=c95b1292-3022-4c62-a131-4d46ace370f5
AppName={#MyAppName}
AppVersion={#MyAppVersion}
;AppVerName={#MyAppName} {#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DisableProgramGroupPage=yes
SetupIconFile=../assets/hue_icon.ico
; The [Icons] "quicklaunchicon" entry uses {userappdata} but its [Tasks] entry has a proper IsAdminInstallMode Check.
UsedUserAreasWarning=no
; Remove the following line to run in administrative install mode (install for all users.)
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
OutputBaseFilename={#MyAppName}-Installer
OutputDir=.
Compression=lzma
SolidCompression=yes
WizardStyle=modern
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "quicklaunchicon"; Description: "{cm:CreateQuickLaunchIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked; OnlyBelowVersion: 6.1; Check: not IsAdminInstallMode
[Files]
Source: "publish\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: quicklaunchicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.4</Version> <Version>0.2.5</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
+158 -18
View File
@@ -22,6 +22,8 @@ public sealed class AppOrchestrator : IDisposable
private readonly MainWindow _mainWindow; private readonly MainWindow _mainWindow;
private readonly CommandHandler _commandHandler; private readonly CommandHandler _commandHandler;
private readonly NotificationSoundService _notificationSound; private readonly NotificationSoundService _notificationSound;
private readonly AudioPlaybackService _audioPlayback = new();
private readonly UpdateChecker _updateService;
private EchoHubConnection? _connection; private EchoHubConnection? _connection;
private ApiClient? _apiClient; private ApiClient? _apiClient;
@@ -44,10 +46,13 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow = new MainWindow(app); _mainWindow = new MainWindow(app);
_commandHandler = new CommandHandler(); _commandHandler = new CommandHandler();
_notificationSound = new NotificationSoundService(config.Notifications); _notificationSound = new NotificationSoundService(config.Notifications);
_updateService = new UpdateChecker(app);
WireMainWindowEvents(); WireMainWindowEvents();
WireCommandHandlerEvents(); WireCommandHandlerEvents();
_updateService.Start();
_mainWindow.UpdateStatusBar("Disconnected"); _mainWindow.UpdateStatusBar("Disconnected");
} }
@@ -55,6 +60,7 @@ public sealed class AppOrchestrator : IDisposable
{ {
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult(); _connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
_apiClient?.Dispose(); _apiClient?.Dispose();
_updateService.Dispose();
} }
// ── Convenience Helpers ──────────────────────────────────────────────── // ── Convenience Helpers ────────────────────────────────────────────────
@@ -72,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable
{ {
_mainWindow.OnConnectRequested += HandleConnect; _mainWindow.OnConnectRequested += HandleConnect;
_mainWindow.OnDisconnectRequested += HandleDisconnect; _mainWindow.OnDisconnectRequested += HandleDisconnect;
_mainWindow.OnLogoutRequested += HandleLogout;
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted; _mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
_mainWindow.OnChannelSelected += HandleChannelSelected; _mainWindow.OnChannelSelected += HandleChannelSelected;
_mainWindow.OnProfileRequested += HandleProfileRequested; _mainWindow.OnProfileRequested += HandleProfileRequested;
@@ -80,6 +87,8 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested; _mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested; _mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested; _mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
} }
// ── Command Handler Wiring ───────────────────────────────────────────── // ── Command Handler Wiring ─────────────────────────────────────────────
@@ -235,6 +244,12 @@ public sealed class AppOrchestrator : IDisposable
var channel = _mainWindow.CurrentChannel; var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return; if (string.IsNullOrEmpty(channel)) return;
if (channel == HubConstants.DefaultChannel)
{
InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
return;
}
try try
{ {
await _connection!.LeaveChannelAsync(channel); await _connection!.LeaveChannelAsync(channel);
@@ -364,6 +379,16 @@ public sealed class AppOrchestrator : IDisposable
private void HandleConnect() private void HandleConnect()
{ {
if (IsConnected)
{
var confirm = MessageBox.Query(_app, "Already Connected",
"You are already connected to a server.\nDisconnect and connect to a new one?", "Yes", "Cancel");
if (confirm != 0) return;
HandleDisconnect();
}
var result = ConnectDialog.Show(_app, _config.SavedServers); var result = ConnectDialog.Show(_app, _config.SavedServers);
if (result is null) return; if (result is null) return;
@@ -377,12 +402,55 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating...")); InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
var loginResponse = result.IsRegister LoginResponse loginResponse;
? await _apiClient.RegisterAsync(result.Username, result.Password)
: await _apiClient.LoginAsync(result.Username, result.Password); if (result.SavedRefreshToken is not null)
{
try
{
loginResponse = await _apiClient.LoginWithRefreshTokenAsync(result.SavedRefreshToken);
Log.Information("Authenticated via saved session for {User}", loginResponse.Username);
}
catch (Exception ex)
{
Log.Warning(ex, "Saved session expired or revoked");
ClearSavedToken(result.ServerUrl);
InvokeUI(() =>
{
_mainWindow.UpdateStatusBar("Disconnected");
MessageBox.ErrorQuery(_app, "Session Expired",
"Your saved session has expired or was revoked.\nPlease log in with your password.", "OK");
});
_apiClient.Dispose();
_apiClient = null;
return;
}
}
else if (result.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(result.Username, result.Password);
}
else
{
loginResponse = await _apiClient.LoginAsync(result.Username, result.Password);
}
_currentUsername = loginResponse.Username; _currentUsername = loginResponse.Username;
// Persist rotated refresh tokens for Remember Me
_apiClient.OnTokensRefreshed += () =>
{
if (_apiClient?.RefreshToken is null) return;
var config = ConfigManager.Load();
var server = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, _apiClient.BaseUrl, StringComparison.OrdinalIgnoreCase));
if (server is not null && server.RememberMe)
{
server.RefreshToken = _apiClient.RefreshToken;
ConfigManager.Save(config);
}
};
// Fetch encryption key for E2E message encryption // Fetch encryption key for E2E message encryption
InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key...")); InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
try try
@@ -437,18 +505,6 @@ public sealed class AppOrchestrator : IDisposable
FetchAndUpdateOnlineUsers(); FetchAndUpdateOnlineUsers();
SaveServerToConfig(result); SaveServerToConfig(result);
// Check for newer version in the background
_ = Task.Run(async () =>
{
var newVersion = await UpdateChecker.CheckForUpdateAsync();
if (newVersion is not null)
{
InvokeUI(() => _mainWindow.AddSystemMessage(
HubConstants.DefaultChannel,
$"A new version of EchoHub is available: v{newVersion} (current: v{MainWindow.AppVersion}). Visit https://github.com/HueByte/EchoHub/releases"));
}
});
}, "Connection failed", "Connect"); }, "Connection failed", "Connect");
} }
@@ -477,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable
}, "Disconnect error", "Disconnect"); }, "Disconnect error", "Disconnect");
} }
private void HandleLogout()
{
Log.Information("Logging out from server");
RunAsync(async () =>
{
if (_apiClient is not null)
{
var baseUrl = _apiClient.BaseUrl;
await _apiClient.LogoutAsync();
ClearSavedToken(baseUrl);
}
if (_connection is not null)
{
await _connection.DisconnectAsync();
await _connection.DisposeAsync();
_connection = null;
}
_apiClient?.Dispose();
_apiClient = null;
_joinedChannels.Clear();
InvokeUI(() =>
{
_mainWindow.ClearAll();
_mainWindow.UpdateStatusBar("Disconnected");
});
}, "Logout error", "Logout");
}
private void HandleMessageSubmitted(string channelName, string content) private void HandleMessageSubmitted(string channelName, string content)
{ {
if (!IsConnected) if (!IsConnected)
@@ -722,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable
} }
var serverLines = _config.SavedServers var serverLines = _config.SavedServers
.Select(s => $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}") .Select(s =>
{
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session saved]" : "";
return $"{s.Name} ({s.Url}) - {s.Username ?? "?"} - {s.LastConnected:yyyy-MM-dd}{session}";
})
.ToList(); .ToList();
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK"); MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
@@ -798,6 +890,40 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to delete channel"); }, "Failed to delete channel");
} }
private void HandleAudioPlayRequested(string attachmentUrl, string fileName)
{
if (!IsAuthenticated) return;
RunAsync(async () =>
{
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
InvokeUI(() => AudioPlayerDialog.Show(_app, _audioPlayback, tempPath, fileName));
}, "Failed to play audio");
}
private void HandleFileDownloadRequested(string attachmentUrl, string fileName)
{
if (!IsAuthenticated) return;
RunAsync(async () =>
{
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloading {fileName}..."));
var tempPath = await _apiClient!.DownloadFileToTempAsync(attachmentUrl, fileName);
try
{
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to open file with default app: {Path}", tempPath);
InvokeUI(() => _mainWindow.AddSystemMessage(_mainWindow.CurrentChannel, $"Downloaded to: {tempPath}"));
}
}, "Failed to download file");
}
// ── Connection Event Wiring ──────────────────────────────────────────── // ── Connection Event Wiring ────────────────────────────────────────────
private void WireConnectionEvents(EchoHubConnection connection) private void WireConnectionEvents(EchoHubConnection connection)
@@ -897,7 +1023,7 @@ public sealed class AppOrchestrator : IDisposable
InvokeUI(() => InvokeUI(() =>
{ {
if (channel.IsPublic) if (channel.IsPublic)
_mainWindow.EnsureChannelInList(channel.Name); _mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
_mainWindow.SetChannelTopic(channel.Name, channel.Topic); _mainWindow.SetChannelTopic(channel.Name, channel.Topic);
}); });
}; };
@@ -957,11 +1083,25 @@ public sealed class AppOrchestrator : IDisposable
Name = new Uri(result.ServerUrl).Host, Name = new Uri(result.ServerUrl).Host,
Url = result.ServerUrl, Url = result.ServerUrl,
Username = result.Username, Username = result.Username,
Token = _apiClient!.Token, RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null,
RememberMe = result.RememberMe,
LastConnected = DateTimeOffset.Now LastConnected = DateTimeOffset.Now
}; };
ConfigManager.SaveServer(savedServer); ConfigManager.SaveServer(savedServer);
_config = ConfigManager.Load(); _config = ConfigManager.Load();
Log.Information("Connected successfully to {Url}", result.ServerUrl); Log.Information("Connected successfully to {Url}", result.ServerUrl);
} }
private void ClearSavedToken(string serverUrl)
{
var config = ConfigManager.Load();
var server = config.SavedServers.FirstOrDefault(s =>
string.Equals(s.Url, serverUrl, StringComparison.OrdinalIgnoreCase));
if (server is not null)
{
server.RefreshToken = null;
ConfigManager.Save(config);
_config = config;
}
}
} }
@@ -339,7 +339,7 @@ public class CommandHandler
/nick <name> - Set display name /nick <name> - Set display name
/color <#hex> - Set nickname color /color <#hex> - Set nickname color
/theme <name> - Switch theme /theme <name> - Switch theme
/send <filepath or URL> [-s|-m|-l] - Send a file or image (size: small/medium/large) /send <filepath or URL> [-s|-m|-l] - Send file/image/audio (size flag for images)
/avatar <URL or filepath> - Set your avatar /avatar <URL or filepath> - Set your avatar
/profile [username] - View a profile /profile [username] - View a profile
/servers - Open saved servers /servers - Open saved servers
+2 -1
View File
@@ -20,7 +20,8 @@ public class SavedServer
public required string Name { get; set; } public required string Name { get; set; }
public required string Url { get; set; } public required string Url { get; set; }
public string? Username { get; set; } public string? Username { get; set; }
public string? Token { get; set; } public string? RefreshToken { get; set; }
public bool RememberMe { get; set; }
public DateTimeOffset LastConnected { get; set; } public DateTimeOffset LastConnected { get; set; }
} }
+6 -1
View File
@@ -5,19 +5,22 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
<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="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" />
<PackageReference Include="Terminal.Gui" Version="2.0.0-develop.5027" /> <!-- Using local fork until transparent color support is merged upstream (gui-cs/Terminal.Gui#4234) -->
<ProjectReference Include="..\Terminal.Gui\Terminal.Gui\Terminal.Gui.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<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="hue_icon.ico" />
<Content Include="Assets\**"> <Content Include="Assets\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
@@ -31,6 +34,8 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<PackageIcon></PackageIcon>
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+33
View File
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
public string? RefreshToken => _refreshToken; public string? RefreshToken => _refreshToken;
public string BaseUrl { get; } public string BaseUrl { get; }
public event Action? OnTokensRefreshed;
public ApiClient(string baseUrl) public ApiClient(string baseUrl)
{ {
BaseUrl = baseUrl.TrimEnd('/'); BaseUrl = baseUrl.TrimEnd('/');
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
SetTokens(result); SetTokens(result);
} }
public async Task<LoginResponse> LoginWithRefreshTokenAsync(string refreshToken)
{
var request = new RefreshRequest(refreshToken);
var response = await _http.PostAsJsonAsync("/api/auth/refresh", request);
await EnsureSuccessAsync(response);
var result = await response.Content.ReadFromJsonAsync<LoginResponse>()
?? throw new InvalidOperationException("Token refresh returned empty response.");
SetTokens(result);
return result;
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
if (!string.IsNullOrEmpty(_refreshToken)) if (!string.IsNullOrEmpty(_refreshToken))
@@ -196,6 +211,23 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync<MessageDto>(); return await response.Content.ReadFromJsonAsync<MessageDto>();
} }
public async Task<string> DownloadFileToTempAsync(string relativeUrl, string fileName)
{
EnsureAuthenticated();
var response = await AuthenticatedGetAsync(relativeUrl);
await EnsureSuccessAsync(response);
var tempDir = Path.Combine(Path.GetTempPath(), "EchoHub");
Directory.CreateDirectory(tempDir);
var tempPath = Path.Combine(tempDir, $"{Guid.NewGuid():N}_{fileName}");
await using var stream = await response.Content.ReadAsStreamAsync();
await using var file = File.Create(tempPath);
await stream.CopyToAsync(file);
return tempPath;
}
public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true) public async Task<ChannelDto?> CreateChannelAsync(string name, string? topic = null, bool isPublic = true)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
@@ -296,6 +328,7 @@ public sealed class ApiClient : IDisposable
_refreshToken = result.RefreshToken; _refreshToken = result.RefreshToken;
_expiresAt = result.ExpiresAt; _expiresAt = result.ExpiresAt;
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken); _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
OnTokensRefreshed?.Invoke();
} }
/// <summary> /// <summary>
@@ -0,0 +1,85 @@
using NetCoreAudio;
using Serilog;
namespace EchoHub.Client.Services;
public class AudioPlaybackService
{
private readonly Player _player = new();
public bool IsPlaying => _player.Playing;
public bool IsPaused => _player.Paused;
public event EventHandler? PlaybackFinished;
public AudioPlaybackService()
{
_player.PlaybackFinished += (s, e) => PlaybackFinished?.Invoke(this, EventArgs.Empty);
}
public async Task PlayAsync(string filePath)
{
try
{
if (_player.Playing)
await _player.Stop();
await _player.Play(filePath);
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to play audio file: {Path}", filePath);
}
}
public async Task PauseAsync()
{
try
{
if (_player.Playing && !_player.Paused)
await _player.Pause();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to pause audio playback");
}
}
public async Task ResumeAsync()
{
try
{
if (_player.Paused)
await _player.Resume();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to resume audio playback");
}
}
public async Task StopAsync()
{
try
{
if (_player.Playing)
await _player.Stop();
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to stop audio playback");
}
}
public async Task SetVolumeAsync(byte volume)
{
try
{
await _player.SetVolume(Math.Min(volume, (byte)100));
}
catch (Exception ex)
{
Log.Warning(ex, "Failed to set audio volume");
}
}
}
+94 -35
View File
@@ -1,47 +1,106 @@
using System.Net.Http.Json; using AlwaysUpToDate;
using System.Text.Json.Serialization;
using EchoHub.Client.UI;
using Serilog;
using Terminal.Gui.App;
namespace EchoHub.Client.Services; namespace EchoHub.Client.Services;
public static class UpdateChecker public sealed class UpdateChecker : IDisposable
{ {
private static readonly Uri ReleaseUrl = private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
/// <summary> private readonly Updater _updater;
/// Checks GitHub for a newer release. Returns the new version string if one exists, or null. private readonly IApplication _app;
/// Never throws — all errors are silently swallowed. private UpdateProgressDialog? _progressDialog;
/// </summary>
public static async Task<string?> CheckForUpdateAsync() public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
public UpdateChecker(IApplication app)
{ {
try _app = app;
{ _updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
var release = await http.GetFromJsonAsync<GitHubRelease>(ReleaseUrl); _updater.UpdateAvailable += OnUpdateAvailable;
if (release?.TagName is null) _updater.ProgressChanged += OnProgressChanged;
return null; _updater.UpdateStarted += OnUpdateStarted;
_updater.NoUpdateAvailable += OnNoUpdateAvailable;
var tag = release.TagName.TrimStart('v', 'V'); _updater.OnException += OnException;
if (!Version.TryParse(tag, out var latest))
return null;
var currentStr = typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3);
if (currentStr is null || !Version.TryParse(currentStr, out var current))
return null;
return latest > current ? tag : null;
}
catch
{
return null;
}
} }
private sealed class GitHubRelease public void Start()
{ {
[JsonPropertyName("tag_name")] #if RELEASE
public string? TagName { get; set; } _updater.Start();
#endif
}
private async void OnUpdateAvailable(string version, string changelogUrl)
{
Log.Information("Update available: v{Version}", version);
var confirmed = false;
_app.Invoke(() =>
{
confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
if (confirmed)
{
_progressDialog = new UpdateProgressDialog(_app, version);
// Start the update; progress is reported via OnProgressChanged
_ = Task.Run(async () =>
{
await _updater.UpdateAsync();
});
_progressDialog?.Show();
}
});
}
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
{
var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f;
var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)";
if (!progressPercentage.HasValue)
{
statusText = $"{step}...";
}
_progressDialog?.UpdateProgress(fraction, statusText);
}
private void OnUpdateStarted(string version)
{
Log.Information("Update started: v{Version}", version);
}
private void OnNoUpdateAvailable()
{
Log.Debug("No update available");
}
private void OnException(Exception exception)
{
Log.Error(exception, "Update check failed");
_app.Invoke(() =>
{
_progressDialog?.Close();
_progressDialog = null;
});
}
public void Dispose()
{
_updater.UpdateAvailable -= OnUpdateAvailable;
_updater.ProgressChanged -= OnProgressChanged;
_updater.UpdateStarted -= OnUpdateStarted;
_updater.NoUpdateAvailable -= OnNoUpdateAvailable;
_updater.OnException -= OnException;
} }
} }
+7 -7
View File
@@ -418,30 +418,30 @@ public static class ThemeManager
Base = new ThemeColors Base = new ThemeColors
{ {
Foreground = "White", Foreground = "White",
Background = "Black", Background = "None",
FocusForeground = "BrightCyan", FocusForeground = "BrightCyan",
FocusBackground = "Black" FocusBackground = "None"
}, },
Menu = new ThemeColors Menu = new ThemeColors
{ {
Foreground = "White", Foreground = "White",
Background = "Black", Background = "None",
FocusForeground = "BrightCyan", FocusForeground = "BrightCyan",
FocusBackground = "Black" FocusBackground = "None"
}, },
Dialog = new ThemeColors Dialog = new ThemeColors
{ {
Foreground = "White", Foreground = "White",
Background = "Black", Background = "DarkGray",
FocusForeground = "BrightCyan", FocusForeground = "BrightCyan",
FocusBackground = "Black" FocusBackground = "Black"
}, },
Status = new ThemeColors Status = new ThemeColors
{ {
Foreground = "Gray", Foreground = "Gray",
Background = "Black", Background = "None",
FocusForeground = "Gray", FocusForeground = "Gray",
FocusBackground = "Black" FocusBackground = "None"
} }
}; };
+410
View File
@@ -0,0 +1,410 @@
using EchoHub.Client.Services;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI;
public sealed class AudioPlayerDialog
{
// Block characters for wave animation (increasing height)
private static readonly string[] WaveBlocks = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
private const int WaveBarCount = 24;
private const int AnimationIntervalMs = 150;
private static readonly Attribute WaveActiveAttr = new(new Color(180, 100, 255), Color.None);
private static readonly Attribute WaveIdleAttr = new(new Color(80, 50, 120), Color.None);
private static readonly Attribute FileNameAttr = new(new Color(180, 100, 255), Color.None);
private static readonly Attribute StatusPlayingAttr = new(new Color(0, 200, 0), Color.None);
private static readonly Attribute StatusPausedAttr = new(new Color(220, 180, 0), Color.None);
private static readonly Attribute StatusStoppedAttr = new(new Color(160, 160, 160), Color.None);
public static void Show(IApplication app, AudioPlaybackService audioService, string filePath, string fileName)
{
var dialog = new Dialog { Title = "Audio Player", Width = 52, Height = 14 };
// ── File name ──
var fileLabel = new Label
{
Text = $"\u266a {TruncateFileName(fileName, 44)}",
X = 2,
Y = 1,
Width = Dim.Fill(2)
};
// ── Wave visualization ──
var waveLabel = new Label
{
X = 2,
Y = 3,
Width = Dim.Fill(2),
Height = 1
};
// ── Status label ──
var statusLabel = new Label
{
Text = "Stopped",
X = 2,
Y = 5,
Width = 20
};
// ── Volume controls ──
var volumeHeaderLabel = new Label
{
Text = "Volume:",
X = 2,
Y = 7
};
byte currentVolume = 50;
var volumeBar = new ProgressBar
{
X = 14,
Y = 7,
Width = 20,
Height = 1,
Fraction = currentVolume / 100f,
ProgressBarStyle = ProgressBarStyle.Continuous
};
var volumePercentLabel = new Label
{
Text = $"{currentVolume}%",
X = 35,
Y = 7,
Width = 5
};
var volDownButton = new Button
{
Text = "-",
X = 10,
Y = 7,
Width = 3
};
var volUpButton = new Button
{
Text = "+",
X = 41,
Y = 7,
Width = 3
};
// ── Playback controls ──
var playButton = new Button
{
Text = "\u25b6 Play",
X = 2,
Y = 10,
IsDefault = true
};
var stopButton = new Button
{
Text = "\u25a0 Stop",
X = Pos.Right(playButton) + 2,
Y = 10
};
var closeButton = new Button
{
Text = "Close",
X = Pos.Right(stopButton) + 2,
Y = 10
};
// ── Animation state ──
var animationOffset = 0;
var random = new Random();
// Pre-generate a repeating wave pattern
var wavePattern = new int[WaveBarCount + 8];
for (int i = 0; i < wavePattern.Length; i++)
wavePattern[i] = random.Next(0, WaveBlocks.Length);
Timer? animationTimer = null;
var isDisposed = false;
// ── Helper functions ──
void UpdateWave(bool isActive)
{
if (isDisposed) return;
var bars = new string[WaveBarCount];
for (int i = 0; i < WaveBarCount; i++)
{
if (isActive)
{
var idx = wavePattern[(i + animationOffset) % wavePattern.Length];
bars[i] = WaveBlocks[idx];
}
else
{
bars[i] = WaveBlocks[1]; // low idle bars
}
}
waveLabel.Text = string.Join(" ", bars);
}
void UpdateStatus()
{
if (isDisposed) return;
if (audioService.IsPlaying && !audioService.IsPaused)
{
statusLabel.Text = "Playing";
playButton.Text = "\u23f8 Pause";
}
else if (audioService.IsPaused)
{
statusLabel.Text = "Paused";
playButton.Text = "\u25b6 Resume";
}
else
{
statusLabel.Text = "Stopped";
playButton.Text = "\u25b6 Play";
}
}
void StartAnimation()
{
animationTimer?.Dispose();
animationTimer = new Timer(_ =>
{
if (isDisposed) return;
animationOffset++;
// Shuffle a few bars each tick for organic movement
var idx = random.Next(0, wavePattern.Length);
wavePattern[idx] = random.Next(0, WaveBlocks.Length);
app.Invoke(() =>
{
if (isDisposed) return;
UpdateWave(true);
});
}, null, 0, AnimationIntervalMs);
}
void StopAnimation()
{
animationTimer?.Dispose();
animationTimer = null;
if (!isDisposed)
UpdateWave(false);
}
async Task UpdateVolume(byte newVolume)
{
currentVolume = Math.Clamp(newVolume, (byte)0, (byte)100);
await audioService.SetVolumeAsync(currentVolume);
if (!isDisposed)
{
volumeBar.Fraction = currentVolume / 100f;
volumePercentLabel.Text = $"{currentVolume}%";
}
}
// ── Custom drawing for colored elements ──
fileLabel.DrawingContent += (s, e) =>
{
var normalAttr = fileLabel.GetAttributeForRole(VisualRole.Normal);
var resolvedAttr = FileNameAttr.Background == Color.None
? FileNameAttr with { Background = normalAttr.Background }
: FileNameAttr;
fileLabel.SetAttribute(resolvedAttr);
fileLabel.Move(0, 0);
var text = fileLabel.Text ?? "";
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
fileLabel.AddStr(g);
// Fill remaining width
var width = fileLabel.Viewport.Width;
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
for (int i = textCols; i < width; i++)
fileLabel.AddStr(" ");
e.Cancel = true;
};
waveLabel.DrawingContent += (s, e) =>
{
var normalAttr = waveLabel.GetAttributeForRole(VisualRole.Normal);
var attr = (audioService.IsPlaying && !audioService.IsPaused) ? WaveActiveAttr : WaveIdleAttr;
var resolvedAttr = attr.Background == Color.None
? attr with { Background = normalAttr.Background }
: attr;
waveLabel.SetAttribute(resolvedAttr);
waveLabel.Move(0, 0);
var text = waveLabel.Text ?? "";
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
waveLabel.AddStr(g);
var width = waveLabel.Viewport.Width;
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
for (int i = textCols; i < width; i++)
waveLabel.AddStr(" ");
e.Cancel = true;
};
statusLabel.DrawingContent += (s, e) =>
{
var normalAttr = statusLabel.GetAttributeForRole(VisualRole.Normal);
Attribute attr;
if (audioService.IsPlaying && !audioService.IsPaused)
attr = StatusPlayingAttr;
else if (audioService.IsPaused)
attr = StatusPausedAttr;
else
attr = StatusStoppedAttr;
var resolvedAttr = attr.Background == Color.None
? attr with { Background = normalAttr.Background }
: attr;
statusLabel.SetAttribute(resolvedAttr);
statusLabel.Move(0, 0);
var text = statusLabel.Text ?? "";
foreach (var g in Terminal.Gui.Drawing.GraphemeHelper.GetGraphemes(text))
statusLabel.AddStr(g);
var width = statusLabel.Viewport.Width;
var textCols = Terminal.Gui.Text.StringExtensions.GetColumns(text);
for (int i = textCols; i < width; i++)
statusLabel.AddStr(" ");
e.Cancel = true;
};
// ── Event handlers ──
playButton.Accepting += (s, e) =>
{
e.Handled = true;
Task.Run(async () =>
{
if (audioService.IsPaused)
{
await audioService.ResumeAsync();
app.Invoke(() =>
{
UpdateStatus();
StartAnimation();
});
}
else if (audioService.IsPlaying)
{
await audioService.PauseAsync();
app.Invoke(() =>
{
UpdateStatus();
StopAnimation();
});
}
else
{
await audioService.SetVolumeAsync(currentVolume);
await audioService.PlayAsync(filePath);
app.Invoke(() =>
{
UpdateStatus();
StartAnimation();
});
}
});
};
stopButton.Accepting += (s, e) =>
{
e.Handled = true;
Task.Run(async () =>
{
await audioService.StopAsync();
app.Invoke(() =>
{
UpdateStatus();
StopAnimation();
});
});
};
closeButton.Accepting += (s, e) =>
{
e.Handled = true;
isDisposed = true;
animationTimer?.Dispose();
_ = audioService.StopAsync(); // fire-and-forget
app.RequestStop();
};
volDownButton.Accepting += (s, e) =>
{
e.Handled = true;
var newVol = (byte)Math.Max(0, currentVolume - 10);
Task.Run(async () =>
{
await UpdateVolume(newVol);
app.Invoke(() =>
{
volumeBar.SetNeedsDraw();
volumePercentLabel.SetNeedsDraw();
});
});
};
volUpButton.Accepting += (s, e) =>
{
e.Handled = true;
var newVol = (byte)Math.Min(100, currentVolume + 10);
Task.Run(async () =>
{
await UpdateVolume(newVol);
app.Invoke(() =>
{
volumeBar.SetNeedsDraw();
volumePercentLabel.SetNeedsDraw();
});
});
};
// Handle playback finishing naturally
EventHandler? finishedHandler = null;
finishedHandler = (s, e) =>
{
app.Invoke(() =>
{
if (isDisposed) return;
UpdateStatus();
StopAnimation();
});
};
audioService.PlaybackFinished += finishedHandler;
// ── Initial state ──
UpdateWave(false);
UpdateStatus();
dialog.Add(fileLabel, waveLabel, statusLabel,
volumeHeaderLabel, volDownButton, volumeBar, volumePercentLabel, volUpButton,
playButton, stopButton, closeButton);
playButton.SetFocus();
app.Run(dialog);
// Cleanup
isDisposed = true;
animationTimer?.Dispose();
audioService.PlaybackFinished -= finishedHandler;
}
private static string TruncateFileName(string name, int maxLen)
{
if (name.Length <= maxLen)
return name;
var ext = Path.GetExtension(name);
var stem = Path.GetFileNameWithoutExtension(name);
var available = maxLen - ext.Length - 3; // 3 for "..."
if (available < 1)
return name[..maxLen];
return stem[..available] + "..." + ext;
}
}
+42 -18
View File
@@ -1,6 +1,7 @@
using System.Collections; using System.Collections;
using System.Collections.Specialized; using System.Collections.Specialized;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using EchoHub.Core.Models;
using Terminal.Gui.Drawing; using Terminal.Gui.Drawing;
using Terminal.Gui.Text; using Terminal.Gui.Text;
using Terminal.Gui.Views; using Terminal.Gui.Views;
@@ -22,6 +23,9 @@ public partial class ChatLine
public int TextLength { get; } public int TextLength { get; }
public Guid? MessageId { get; set; } public Guid? MessageId { get; set; }
public bool IsMention { get; set; } public bool IsMention { get; set; }
public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; }
public MessageType? Type { get; set; }
public ChatLine(string plainText) public ChatLine(string plainText)
{ {
@@ -92,6 +96,15 @@ public partial class ChatLine
if (currentSegments.Count > 0) if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments)); results.Add(new ChatLine(currentSegments));
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
foreach (var wrapped in results)
{
wrapped.AttachmentUrl = AttachmentUrl;
wrapped.AttachmentFileName = AttachmentFileName;
wrapped.Type = Type;
wrapped.MessageId = MessageId;
}
return results; return results;
} }
@@ -118,7 +131,7 @@ public partial class ChatLine
Color? currentFg = null; Color? currentFg = null;
Color? currentBg = null; Color? currentBg = null;
var defaultFg = defaultAttr?.Foreground; var defaultFg = defaultAttr?.Foreground;
var defaultBg = defaultAttr?.Background ?? Color.Black; var defaultBg = defaultAttr?.Background ?? Color.None;
Attribute? BuildAttr() Attribute? BuildAttr()
{ {
@@ -217,6 +230,8 @@ public class ChatListSource : IListDataSource
RaiseCollectionChanged(); RaiseCollectionChanged();
} }
public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
public bool IsMarked(int item) => false; public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { } public void SetMark(int item, bool value) { }
public IList ToList() => _lines.Select(l => l.ToString()).ToList(); public IList ToList() => _lines.Select(l => l.ToString()).ToList();
@@ -235,8 +250,10 @@ public class ChatListSource : IListDataSource
foreach (var segment in chatLine.Segments) foreach (var segment in chatLine.Segments)
{ {
var attr = segment.Color ?? normalAttr; var attr = segment.Color ?? normalAttr;
if (attr.Background == Color.None)
attr = attr with { Background = normalAttr.Background };
if (mentionBg.HasValue) if (mentionBg.HasValue)
attr = new Attribute(attr.Foreground, mentionBg.Value); attr = attr with { Background = mentionBg.Value };
listView.SetAttribute(attr); listView.SetAttribute(attr);
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text)) foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
@@ -287,10 +304,10 @@ public class ChannelListSource : IListDataSource
public int MaxItemLength { get; private set; } public int MaxItemLength { get; private set; }
public bool SuspendCollectionChangedEvent { get; set; } public bool SuspendCollectionChangedEvent { get; set; }
private static readonly Attribute ActiveAttr = new(Color.White, Color.Black); private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black); private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black); private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black); private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel) public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel)
{ {
@@ -318,11 +335,16 @@ public class ChannelListSource : IListDataSource
_unreadCounts.TryGetValue(name, out var unread); _unreadCounts.TryGetValue(name, out var unread);
var hasUnread = unread > 0; var hasUnread = unread > 0;
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus); var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
var prefix = isActive ? "> " : " "; var prefix = isActive ? "> " : " ";
var channelText = $"#{name}"; var channelText = $"#{name}";
var badge = hasUnread ? $" ({unread})" : ""; var badge = hasUnread ? $" ({unread})" : "";
// Resolve Transparent backgrounds to the view's actual background
Attribute Resolve(Attribute attr) =>
attr.Background == Color.None ? attr with { Background = normalAttr.Background } : attr;
int drawnChars = 0; int drawnChars = 0;
if (selected) if (selected)
@@ -332,20 +354,20 @@ public class ChannelListSource : IListDataSource
} }
else else
{ {
listView.SetAttribute(isActive ? ActiveAttr : NormalAttr); listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width); drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
listView.SetAttribute(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr); listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr));
drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width); drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width);
if (hasUnread) if (hasUnread)
{ {
listView.SetAttribute(BadgeAttr); listView.SetAttribute(Resolve(BadgeAttr));
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width); drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
} }
} }
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal); var fillAttr = selected ? focusAttr : normalAttr;
listView.SetAttribute(fillAttr); listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++) for (int i = drawnChars; i < width; i++)
listView.AddStr(" "); listView.AddStr(" ");
@@ -457,14 +479,16 @@ static class RenderHelpers
/// </summary> /// </summary>
public static partial class ChatColors public static partial class ChatColors
{ {
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0)); public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.Black); public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
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.None);
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black); public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
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.None);
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.Black); public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
/// <summary> /// <summary>
/// Split text around @mentions, giving each @word the MentionTextAttr accent color. /// Split text around @mentions, giving each @word the MentionTextAttr accent color.
@@ -513,7 +537,7 @@ public static class ColorHelper
var r = Convert.ToInt32(hex[..2], 16); var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16); var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16); var b = Convert.ToInt32(hex[4..6], 16);
return new Attribute(new Color(r, g, b), Color.Black); return new Attribute(new Color(r, g, b), Color.None);
} }
catch catch
{ {
+78 -16
View File
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
/// <summary> /// <summary>
/// Result returned from the connect dialog. /// Result returned from the connect dialog.
/// </summary> /// </summary>
public record ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister); public record ConnectDialogResult(
string ServerUrl, string Username, string Password,
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
/// <summary> /// <summary>
/// A Terminal.Gui dialog for entering server connection and authentication details. /// A Terminal.Gui dialog for entering server connection and authentication details.
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
savedServers ??= []; savedServers ??= [];
var hasSavedServers = savedServers.Count > 0; var hasSavedServers = savedServers.Count > 0;
var dialogHeight = hasSavedServers ? 20 : 16; var dialogHeight = hasSavedServers ? 22 : 18;
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight }; var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
int yOffset = 0; int yOffset = 0;
SavedServer? selectedSavedServer = null;
// -- Saved Servers section (if any) ----------------------------------- // -- Saved Servers section (if any) -----------------------------------
ListView? savedServerList = null; ListView? savedServerList = null;
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
dialog.Add(savedLabel); dialog.Add(savedLabel);
var serverDisplayNames = savedServers var serverDisplayNames = savedServers
.Select(s => $"{s.Name} ({s.Username ?? "?"})") .Select(s =>
{
var session = !string.IsNullOrEmpty(s.RefreshToken) ? " [session]" : "";
return $"{s.Name} ({s.Username ?? "?"}){session}";
})
.ToList(); .ToList();
savedServerList = new ListView savedServerList = new ListView
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
Secret = true Secret = true
}; };
var tokenHintLabel = new Label
{
Text = "Session saved \u2014 password optional",
X = 15,
Y = yOffset + 6,
Width = Dim.Fill(2),
Visible = false
};
var rememberMeCheckbox = new CheckBox
{
Text = "Remember me",
X = 15,
Y = yOffset + 7,
Value = CheckState.UnChecked
};
var displayLabel = new Label var displayLabel = new Label
{ {
Text = "Display Name:", Text = "Display Name:",
X = 1, X = 1,
Y = yOffset + 7 Y = yOffset + 9
}; };
var displayField = new TextField var displayField = new TextField
{ {
Text = "", Text = "",
X = 15, X = 15,
Y = yOffset + 7, Y = yOffset + 9,
Width = Dim.Fill(2) Width = Dim.Fill(2)
}; };
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
Text = "Login", Text = "Login",
IsDefault = true, IsDefault = true,
X = Pos.Center() - 20, X = Pos.Center() - 20,
Y = yOffset + 9 Y = yOffset + 11
}; };
var registerButton = new Button var registerButton = new Button
{ {
Text = "Register", Text = "Register",
X = Pos.Center() - 5, X = Pos.Center() - 5,
Y = yOffset + 9 Y = yOffset + 11
}; };
var cancelButton = new Button var cancelButton = new Button
{ {
Text = "Cancel", Text = "Cancel",
X = Pos.Center() + 10, X = Pos.Center() + 10,
Y = yOffset + 9 Y = yOffset + 11
}; };
// Wire saved server selection to auto-fill fields // Wire saved server selection to auto-fill fields
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
var index = e.NewValue; var index = e.NewValue;
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count) if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
{ {
var server = savedServers[index.Value]; selectedSavedServer = savedServers[index.Value];
urlField.Text = server.Url; urlField.Text = selectedSavedServer.Url;
userField.Text = server.Username ?? ""; userField.Text = selectedSavedServer.Username ?? "";
rememberMeCheckbox.Value = selectedSavedServer.RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(selectedSavedServer.RefreshToken))
{
passField.Text = "";
tokenHintLabel.Visible = true;
}
else
{
tokenHintLabel.Visible = false;
}
} }
}; };
// Pre-fill with the first saved server // Pre-fill with the first saved server
selectedSavedServer = savedServers[0];
urlField.Text = savedServers[0].Url; urlField.Text = savedServers[0].Url;
userField.Text = savedServers[0].Username ?? ""; userField.Text = savedServers[0].Username ?? "";
rememberMeCheckbox.Value = savedServers[0].RememberMe
? CheckState.Checked : CheckState.UnChecked;
if (!string.IsNullOrEmpty(savedServers[0].RefreshToken))
tokenHintLabel.Visible = true;
} }
loginButton.Accepting += (s, e) => loginButton.Accepting += (s, e) =>
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty; var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty; var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user))
{ {
MessageBox.ErrorQuery(app, "Validation", "Server URL, username, and password are required.", "OK"); MessageBox.ErrorQuery(app, "Validation", "Server URL and username are required.", "OK");
e.Handled = true; e.Handled = true;
return; return;
} }
result = new ConnectDialogResult(url, user, pass, IsRegister: false); // Determine if we can use a saved token
string? savedRefreshToken = null;
if (string.IsNullOrEmpty(pass)
&& selectedSavedServer is not null
&& !string.IsNullOrEmpty(selectedSavedServer.RefreshToken)
&& string.Equals(selectedSavedServer.Url, url, StringComparison.OrdinalIgnoreCase)
&& string.Equals(selectedSavedServer.Username, user, StringComparison.OrdinalIgnoreCase))
{
savedRefreshToken = selectedSavedServer.RefreshToken;
}
if (string.IsNullOrEmpty(pass) && savedRefreshToken is null)
{
MessageBox.ErrorQuery(app, "Validation", "Password is required.", "OK");
e.Handled = true;
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: false, rememberMe, savedRefreshToken);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
var url = urlField.Text?.Trim() ?? string.Empty; var url = urlField.Text?.Trim() ?? string.Empty;
var user = userField.Text?.Trim() ?? string.Empty; var user = userField.Text?.Trim() ?? string.Empty;
var pass = passField.Text ?? string.Empty; var pass = passField.Text ?? string.Empty;
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
{ {
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
return; return;
} }
result = new ConnectDialogResult(url, user, pass, IsRegister: true); result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
e.Handled = true; e.Handled = true;
app.RequestStop(); app.RequestStop();
}; };
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
}; };
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField, dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
displayLabel, displayField, loginButton, registerButton, cancelButton); tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
loginButton, registerButton, cancelButton);
if (hasSavedServers && savedServerList is not null) if (hasSavedServers && savedServerList is not null)
savedServerList.SetFocus(); savedServerList.SetFocus();
+172 -8
View File
@@ -58,9 +58,11 @@ public sealed class MainWindow : Runnable
private readonly Dictionary<string, List<ChatLine>> _channelMessages = []; private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = []; private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = []; private readonly Dictionary<string, string?> _channelTopics = [];
private readonly Dictionary<string, bool> _channelPublic = [];
private readonly ChannelListSource _channelListSource; private readonly ChannelListSource _channelListSource;
private string _currentChannel = string.Empty; private string _currentChannel = string.Empty;
private string _currentUser = string.Empty; private string _currentUser = string.Empty;
private string _connectionStatus = "Disconnected";
private int _lastChatWidth; private int _lastChatWidth;
/// <summary> /// <summary>
@@ -83,6 +85,11 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnDisconnectRequested; public event Action? OnDisconnectRequested;
/// <summary>
/// Fired when the user requests to logout (disconnect + revoke session).
/// </summary>
public event Action? OnLogoutRequested;
/// <summary> /// <summary>
/// Fired when the user requests to open their profile panel. /// Fired when the user requests to open their profile panel.
/// </summary> /// </summary>
@@ -113,6 +120,16 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnDeleteChannelRequested; public event Action? OnDeleteChannelRequested;
/// <summary>
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnAudioPlayRequested;
/// <summary>
/// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName.
/// </summary>
public event Action<string, string>? OnFileDownloadRequested;
public MainWindow(IApplication app) public MainWindow(IApplication app)
{ {
_app = app; _app = app;
@@ -175,6 +192,7 @@ public sealed class MainWindow : Runnable
Height = Dim.Fill() Height = Dim.Fill()
}; };
_messageList.Source = new ChatListSource(); _messageList.Source = new ChatListSource();
_messageList.Accepting += OnMessageListAccepting;
_chatFrame.Add(_messageList); _chatFrame.Add(_messageList);
Add(_chatFrame); Add(_chatFrame);
@@ -223,16 +241,17 @@ public sealed class MainWindow : Runnable
_usersFrame.Add(_usersList); _usersFrame.Add(_usersList);
Add(_usersFrame); Add(_usersFrame);
// Status bar at the very bottom // Status bar at the very bottom — custom drawing for colored connection state
_statusLabel = new Label _statusLabel = new Label
{ {
Text = "Disconnected", Text = "",
X = 0, X = 0,
Y = Pos.AnchorEnd(1), Y = Pos.AnchorEnd(1),
Width = Dim.Fill(), Width = Dim.Fill(),
Height = 1 Height = 1
}; };
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu")); _statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
_statusLabel.DrawingContent += OnStatusBarDrawContent;
Add(_statusLabel); Add(_statusLabel);
// Apply our custom color schemes to all views // Apply our custom color schemes to all views
@@ -308,6 +327,7 @@ public sealed class MainWindow : Runnable
{ {
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty),
new Line(), new Line(),
new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty),
new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty), new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty),
@@ -360,6 +380,31 @@ public sealed class MainWindow : Runnable
} }
} }
private void OnMessageListAccepting(object? sender, CommandEventArgs e)
{
if (_messageList.Source is not ChatListSource source)
return;
var index = _messageList.SelectedItem;
if (!index.HasValue || index.Value < 0 || index.Value >= source.Count)
return;
var line = source.GetLine(index.Value);
if (line?.AttachmentUrl is null || line.AttachmentFileName is null)
return;
if (line.Type == MessageType.Audio)
{
OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
}
else if (line.Type == MessageType.File)
{
OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
e.Handled = true;
}
}
private void OnInputKeyDown(object? sender, Key e) private void OnInputKeyDown(object? sender, Key e)
{ {
if (e.KeyCode == TabKey.KeyCode) if (e.KeyCode == TabKey.KeyCode)
@@ -600,10 +645,12 @@ public sealed class MainWindow : Runnable
{ {
_channelNames.Clear(); _channelNames.Clear();
_channelTopics.Clear(); _channelTopics.Clear();
_channelPublic.Clear();
foreach (var ch in channels) foreach (var ch in channels)
{ {
_channelNames.Add(ch.Name); _channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic; _channelTopics[ch.Name] = ch.Topic;
_channelPublic[ch.Name] = ch.IsPublic;
if (!_channelMessages.ContainsKey(ch.Name)) if (!_channelMessages.ContainsKey(ch.Name))
_channelMessages[ch.Name] = []; _channelMessages[ch.Name] = [];
} }
@@ -613,8 +660,11 @@ public sealed class MainWindow : Runnable
/// <summary> /// <summary>
/// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// Ensure a channel exists in the left panel list (used for private channels joined via /join).
/// </summary> /// </summary>
public void EnsureChannelInList(string channelName) public void EnsureChannelInList(string channelName, bool? isPublic = null)
{ {
if (isPublic.HasValue)
_channelPublic[channelName] = isPublic.Value;
if (_channelNames.Contains(channelName)) if (_channelNames.Contains(channelName))
return; return;
@@ -631,6 +681,7 @@ public sealed class MainWindow : Runnable
{ {
_channelNames.Remove(channelName); _channelNames.Remove(channelName);
_channelTopics.Remove(channelName); _channelTopics.Remove(channelName);
_channelPublic.Remove(channelName);
RefreshChannelList(); RefreshChannelList();
} }
@@ -657,9 +708,76 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public void UpdateStatusBar(string status) public void UpdateStatusBar(string status)
{ {
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}"; _connectionStatus = status;
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}"; _statusLabel.SetNeedsDraw();
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}"; }
private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.None);
private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None);
private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None);
private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None);
private void OnStatusBarDrawContent(object? sender, DrawEventArgs e)
{
var menuScheme = SchemeManager.GetScheme("Menu");
var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal);
var width = _statusLabel.Viewport.Width;
if (width <= 0) return;
// Resolve None background for colored segments
var bg = normalAttr.Background;
Attribute Resolve(Attribute a) => a.Background == Color.None ? a with { Background = bg } : a;
int col = 0;
void Write(string text, Attribute attr)
{
_statusLabel.SetAttribute(Resolve(attr));
foreach (var g in GraphemeHelper.GetGraphemes(text))
{
var cols = Math.Max(g.GetColumns(), 1);
if (col + cols > width) return;
_statusLabel.Move(col, 0);
_statusLabel.AddStr(g);
col += cols;
}
}
// EchoHub branding
Write(" EchoHub", Resolve(StatusBrandAttr));
Write($" \u2502 v{AppVersion} \u2502 ", normalAttr);
// Connection state with color
var statusAttr = _connectionStatus switch
{
"Connected" => StatusConnectedAttr,
"Disconnected" => StatusDisconnectedAttr,
_ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc.
};
Write(_connectionStatus, Resolve(statusAttr));
// User
if (!string.IsNullOrEmpty(_currentUser))
Write($" \u2502 User: {_currentUser}", normalAttr);
// Channel + type
if (!string.IsNullOrEmpty(_currentChannel))
{
_channelPublic.TryGetValue(_currentChannel, out var isPublic);
var typeSuffix = isPublic ? "public" : "private";
Write($" \u2502 #{_currentChannel} - {typeSuffix}", normalAttr);
}
// Fill remaining space
_statusLabel.SetAttribute(normalAttr);
while (col < width)
{
_statusLabel.Move(col, 0);
_statusLabel.AddStr(" ");
col++;
}
e.Cancel = true;
} }
/// <summary> /// <summary>
@@ -694,6 +812,7 @@ public sealed class MainWindow : Runnable
RefreshMessages(); RefreshMessages();
UpdateTopicBar(); UpdateTopicBar();
_statusLabel.SetNeedsDraw();
// Update channel list selection // Update channel list selection
var idx = _channelNames.IndexOf(channelName); var idx = _channelNames.IndexOf(channelName);
@@ -725,6 +844,7 @@ public sealed class MainWindow : Runnable
_channelMessages.Clear(); _channelMessages.Clear();
_channelUnread.Clear(); _channelUnread.Clear();
_channelTopics.Clear(); _channelTopics.Clear();
_channelPublic.Clear();
_currentChannel = string.Empty; _currentChannel = string.Empty;
_currentUser = string.Empty; _currentUser = string.Empty;
_channelListSource.Update([], [], string.Empty); _channelListSource.Update([], [], string.Empty);
@@ -900,10 +1020,26 @@ public sealed class MainWindow : Runnable
} }
break; break;
case MessageType.Audio:
var audioName = message.AttachmentFileName ?? "unknown";
var audioSize = FormatFileSize(message.AttachmentFileSize);
var audioLine = BuildChatLineColored(time, senderName, senderColor,
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
audioLine.AttachmentUrl = message.AttachmentUrl;
audioLine.AttachmentFileName = audioName;
audioLine.Type = MessageType.Audio;
lines.Add(audioLine);
break;
case MessageType.File: case MessageType.File:
var fileName = message.AttachmentFileName ?? "unknown"; var fileName = message.AttachmentFileName ?? "unknown";
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : ""; var fileSize = FormatFileSize(message.AttachmentFileSize);
lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}")); var fileLine = BuildChatLineColored(time, senderName, senderColor,
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
fileLine.AttachmentUrl = message.AttachmentUrl;
fileLine.AttachmentFileName = fileName;
fileLine.Type = MessageType.File;
lines.Add(fileLine);
break; break;
case MessageType.Text: case MessageType.Text:
@@ -962,6 +1098,20 @@ public sealed class MainWindow : Runnable
return new ChatLine(segments); return new ChatLine(segments);
} }
/// <summary>
/// Build a chat line with a colored suffix (used for audio/file indicators).
/// </summary>
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, suffixColor)
};
return new ChatLine(segments);
}
/// <summary> /// <summary>
/// Build a chat line with @mention highlighting in the suffix text. /// Build a chat line with @mention highlighting in the suffix text.
/// </summary> /// </summary>
@@ -1055,4 +1205,18 @@ public sealed class MainWindow : Runnable
return result; return result;
} }
private static string FormatFileSize(long? bytes)
{
if (bytes is null or 0)
return "?";
return bytes.Value switch
{
< 1024 => $"{bytes.Value} B",
< 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
< 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
_ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
};
}
} }
@@ -0,0 +1,58 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public sealed class UpdateConfirmDialog
{
public static bool Show(IApplication app, string currentVersion, string newVersion)
{
var confirmed = false;
var dialog = new Dialog { Title = "Update Available", Width = 50, Height = 10 };
var messageLabel = new Label
{
Text = $"A new version of EchoHub is available.\n\n Current: {currentVersion}\n Latest: {newVersion}",
X = 1,
Y = 1,
Width = Dim.Fill(2),
Height = 4
};
var updateButton = new Button
{
Text = "Update",
IsDefault = true,
X = Pos.Center() - 10,
Y = 6
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 5,
Y = 6
};
updateButton.Accepting += (s, e) =>
{
confirmed = true;
e.Handled = true;
app.RequestStop();
};
cancelButton.Accepting += (s, e) =>
{
confirmed = false;
e.Handled = true;
app.RequestStop();
};
dialog.Add(messageLabel, updateButton, cancelButton);
app.Run(dialog);
return confirmed;
}
}
@@ -0,0 +1,61 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI;
public sealed class UpdateProgressDialog
{
private readonly Dialog _dialog;
private readonly ProgressBar _progressBar;
private readonly Label _infoLabel;
private readonly IApplication _app;
public UpdateProgressDialog(IApplication app, string newVersion)
{
_app = app;
_dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
_infoLabel = new Label
{
Text = "Preparing update...",
X = 1,
Y = 1,
Width = Dim.Fill(2)
};
_progressBar = new ProgressBar
{
X = 1,
Y = 3,
Width = Dim.Fill(2),
Fraction = 0f
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center(),
Y = 6
};
_dialog.Add(_infoLabel, _progressBar);
}
public void UpdateProgress(float fraction, string statusText)
{
_progressBar.Fraction = fraction;
_infoLabel.Text = statusText;
}
public void Show()
{
_app.Run(_dialog);
}
public void Close()
{
_app.RequestStop();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+3 -1
View File
@@ -6,7 +6,9 @@ public static class HubConstants
public const string DefaultChannel = "general"; public const string DefaultChannel = "general";
public const int DefaultHistoryCount = 100; public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000; public const int MaxMessageLength = 2000;
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxAudioFileSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
public const int MaxMessageNewlines = 30; public const int MaxMessageNewlines = 30;
public const int MaxConsecutiveNewlines = 1; public const int MaxConsecutiveNewlines = 1;
@@ -0,0 +1,22 @@
using EchoHub.Core.DTOs;
namespace EchoHub.Core.Contracts;
public interface IChannelService
{
// Channel CRUD
Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit);
Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic);
Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic);
Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName);
// Channel queries
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
// Membership
Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName);
}
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
+1 -5
View File
@@ -25,12 +25,8 @@ public interface IChatService
Task BroadcastMessageAsync(string channelName, MessageDto message); Task BroadcastMessageAsync(string channelName, MessageDto message);
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null); Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
// Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH) // Query operations (used by IRC gateway for WHOIS, AUTH)
Task<UserProfileDto?> GetUserProfileAsync(string username); Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync();
Task<List<string>> GetChannelsForUserAsync(string username); Task<List<string>> GetChannelsForUserAsync(string username);
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password); Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
} }
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
+1
View File
@@ -12,6 +12,7 @@ public record MessageDto(
string? AttachmentUrl, string? AttachmentUrl,
string? AttachmentFileName, string? AttachmentFileName,
DateTimeOffset SentAt, DateTimeOffset SentAt,
long? AttachmentFileSize = null,
List<EmbedDto>? Embeds = null); List<EmbedDto>? Embeds = null);
public record ChannelDto( public record ChannelDto(
+21
View File
@@ -1,5 +1,26 @@
namespace EchoHub.Core.DTOs; namespace EchoHub.Core.DTOs;
public record ApiResponse(bool Success, string? Message = null, List<string>? Errors = null);
public record ApiResponse<T>(bool Success, string? Message = null, List<string>? Errors = null, T? Data = default);
public record ErrorResponse(string Error, string? Detail = null); public record ErrorResponse(string Error, string? Detail = null);
public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit); public record PaginatedResponse<T>(List<T> Items, int Total, int Offset, int Limit);
public enum ChannelError
{
ValidationFailed,
AlreadyExists,
NotFound,
Forbidden,
Protected
}
public record ChannelOperationResult(ChannelDto? Channel, ChannelError? Error, string? ErrorMessage)
{
public bool IsSuccess => Error is null;
public static ChannelOperationResult Success(ChannelDto channel) => new(channel, null, null);
public static ChannelOperationResult Fail(ChannelError error, string message) => new(null, error, message);
}
+1
View File
@@ -7,6 +7,7 @@ public class Message
public MessageType Type { get; set; } = MessageType.Text; public MessageType Type { get; set; } = MessageType.Text;
public string? AttachmentUrl { get; set; } public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; } public string? AttachmentFileName { get; set; }
public long? AttachmentFileSize { get; set; }
public string? EmbedJson { get; set; } public string? EmbedJson { get; set; }
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
+2 -1
View File
@@ -4,5 +4,6 @@ public enum MessageType
{ {
Text, Text,
Image, Image,
File File,
Audio
} }
@@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable
public bool IsSasl { get; set; } public bool IsSasl { get; set; }
public bool CapNegotiating { get; set; } public bool CapNegotiating { get; set; }
// Channel state // Channel state — thread-safe: written by command handler, read by broadcaster threads
public HashSet<string> JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
private readonly object _channelLock = new();
// Away state // Away state
public string? AwayMessage { get; set; } public string? AwayMessage { get; set; }
public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub"; public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub";
public void JoinChannel(string channel) { lock (_channelLock) _joinedChannels.Add(channel); }
public void LeaveChannel(string channel) { lock (_channelLock) _joinedChannels.Remove(channel); }
public bool IsInChannel(string channel) { lock (_channelLock) return _joinedChannels.Contains(channel); }
public List<string> GetJoinedChannels() { lock (_channelLock) return [.. _joinedChannels]; }
public IrcClientConnection(TcpClient tcpClient, Stream stream) public IrcClientConnection(TcpClient tcpClient, Stream stream)
{ {
_tcpClient = tcpClient; _tcpClient = tcpClient;
+25 -18
View File
@@ -12,6 +12,8 @@ public sealed class IrcCommandHandler
private readonly IrcClientConnection _conn; private readonly IrcClientConnection _conn;
private readonly IrcOptions _options; private readonly IrcOptions _options;
private readonly IChatService _chatService; private readonly IChatService _chatService;
private readonly IChannelService _channelService;
private readonly IMessageEncryptionService _encryption;
private readonly ILogger _logger; private readonly ILogger _logger;
private string ServerName => _options.ServerName; private string ServerName => _options.ServerName;
@@ -20,11 +22,15 @@ public sealed class IrcCommandHandler
IrcClientConnection conn, IrcClientConnection conn,
IrcOptions options, IrcOptions options,
IChatService chatService, IChatService chatService,
IChannelService channelService,
IMessageEncryptionService encryption,
ILogger logger) ILogger logger)
{ {
_conn = conn; _conn = conn;
_options = options; _options = options;
_chatService = chatService; _chatService = chatService;
_channelService = channelService;
_encryption = encryption;
_logger = logger; _logger = logger;
} }
@@ -319,7 +325,7 @@ public sealed class IrcCommandHandler
private async Task HandleJoinAsync(IrcMessage msg) private async Task HandleJoinAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) if (msg.Parameters.Count < 1)
{ {
@@ -350,7 +356,7 @@ public sealed class IrcCommandHandler
continue; continue;
} }
_conn.JoinedChannels.Add(channelName); _conn.JoinChannel(channelName);
// Confirm JOIN to the client // Confirm JOIN to the client
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}"); await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
@@ -361,10 +367,11 @@ public sealed class IrcCommandHandler
// Send NAMES list // Send NAMES list
await SendNamesReplyAsync(channelName); await SendNamesReplyAsync(channelName);
// Replay history // Replay history (decrypt — history is encrypted for SignalR transport)
foreach (var m in history) foreach (var m in history)
{ {
var lines = IrcMessageFormatter.FormatMessage(m); var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
var lines = IrcMessageFormatter.FormatMessage(decrypted);
foreach (var line in lines) foreach (var line in lines)
await _conn.SendAsync(line); await _conn.SendAsync(line);
} }
@@ -373,7 +380,7 @@ public sealed class IrcCommandHandler
private async Task HandlePartAsync(IrcMessage msg) private async Task HandlePartAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries); var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
@@ -385,7 +392,7 @@ public sealed class IrcCommandHandler
if (channelName is null) continue; if (channelName is null) continue;
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName); await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
_conn.JoinedChannels.Remove(channelName); _conn.LeaveChannel(channelName);
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" + await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
(partMessage is not null ? $" :{partMessage}" : "")); (partMessage is not null ? $" :{partMessage}" : ""));
@@ -394,7 +401,7 @@ public sealed class IrcCommandHandler
private async Task HandlePrivmsgAsync(IrcMessage msg) private async Task HandlePrivmsgAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 2) if (msg.Parameters.Count < 2)
{ {
@@ -436,7 +443,7 @@ public sealed class IrcCommandHandler
private async Task HandleNamesAsync(IrcMessage msg) private async Task HandleNamesAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]); var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -458,7 +465,7 @@ public sealed class IrcCommandHandler
private async Task HandleTopicAsync(IrcMessage msg) private async Task HandleTopicAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]); var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -477,7 +484,7 @@ public sealed class IrcCommandHandler
private async Task SendChannelTopicAsync(string channelName) private async Task SendChannelTopicAsync(string channelName)
{ {
var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName); var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
if (!exists) return; if (!exists) return;
@@ -495,7 +502,7 @@ public sealed class IrcCommandHandler
private async Task HandleWhoAsync(IrcMessage msg) private async Task HandleWhoAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var channelName = IrcToEchoHubChannel(msg.Parameters[0]); var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
@@ -516,7 +523,7 @@ public sealed class IrcCommandHandler
private async Task HandleWhoisAsync(IrcMessage msg) private async Task HandleWhoisAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var nick = msg.Parameters[^1].ToLowerInvariant(); var nick = msg.Parameters[^1].ToLowerInvariant();
@@ -559,7 +566,7 @@ public sealed class IrcCommandHandler
private async Task HandleAwayAsync(IrcMessage msg) private async Task HandleAwayAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0])) if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
{ {
@@ -581,9 +588,9 @@ public sealed class IrcCommandHandler
private async Task HandleListAsync(IrcMessage msg) private async Task HandleListAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
var channels = await _chatService.GetChannelListAsync(); var channels = await _channelService.GetChannelListAsync();
foreach (var ch in channels) foreach (var ch in channels)
{ {
@@ -597,7 +604,7 @@ public sealed class IrcCommandHandler
private async Task HandleModeAsync(IrcMessage msg) private async Task HandleModeAsync(IrcMessage msg)
{ {
if (!RequireRegistered()) return; if (!await RequireRegisteredAsync()) return;
if (msg.Parameters.Count < 1) return; if (msg.Parameters.Count < 1) return;
var target = msg.Parameters[0]; var target = msg.Parameters[0];
@@ -621,11 +628,11 @@ public sealed class IrcCommandHandler
// ── Helpers ────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────
private bool RequireRegistered() private async Task<bool> RequireRegisteredAsync()
{ {
if (_conn.IsRegistered) return true; if (_conn.IsRegistered) return true;
_ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED, await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
":You have not registered"); ":You have not registered");
return false; return false;
} }
+5 -3
View File
@@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName) public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
{ {
return _connections.Values return _connections.Values
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName)); .Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
} }
public IEnumerable<IrcClientConnection> GetAllConnections() public IEnumerable<IrcClientConnection> GetAllConnections()
@@ -120,8 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
try try
{ {
chatService = _services.GetRequiredService<IChatService>(); chatService = _services.GetRequiredService<IChatService>();
var channelService = _services.GetRequiredService<IChannelService>();
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
var handler = new IrcCommandHandler( var handler = new IrcCommandHandler(
connection, _options, chatService, _logger); connection, _options, chatService, channelService, encryption, _logger);
await handler.RunAsync(ct); await handler.RunAsync(ct);
} }
@@ -133,7 +135,7 @@ public sealed class IrcGatewayService : BackgroundService
{ {
if (connection.IsAuthenticated) if (connection.IsAuthenticated)
{ {
foreach (var ch in connection.JoinedChannels.ToList()) foreach (var ch in connection.GetJoinedChannels())
{ {
if (chatService is null) break; if (chatService is null) break;
await chatService.LeaveChannelAsync( await chatService.LeaveChannelAsync(
@@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter
case MessageType.File: case MessageType.File:
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}"); lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
break; break;
case MessageType.Audio:
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
break;
} }
return lines; return lines;
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers; namespace EchoHub.Server.Controllers;
@@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")] [EnableRateLimiting("general")]
public class ChannelsController : ControllerBase public class ChannelsController : ControllerBase
{ {
private readonly IChannelService _channelService;
private readonly EchoHubDbContext _db; private readonly EchoHubDbContext _db;
private readonly FileStorageService _fileStorage; private readonly FileStorageService _fileStorage;
private readonly ImageToAsciiService _asciiService; private readonly ImageToAsciiService _asciiService;
@@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
public ChannelsController( public ChannelsController(
IChannelService channelService,
EchoHubDbContext db, EchoHubDbContext db,
FileStorageService fileStorage, FileStorageService fileStorage,
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
@@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase
IChatService chatService, IChatService chatService,
IMessageEncryptionService encryption) IMessageEncryptionService encryption)
{ {
_channelService = channelService;
_db = db; _db = db;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_asciiService = asciiService; _asciiService = asciiService;
@@ -48,74 +50,29 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
offset = Math.Max(0, offset); offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, 100); limit = Math.Clamp(limit, 1, 100);
// Public channels + private channels the user has joined var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
var query = _db.Channels.Where(c => return Ok(result);
c.IsPublic || _db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id,
c.Name,
c.Topic,
c.IsPublic,
c.Messages.Count,
c.CreatedAt))
.ToListAsync();
return Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
} }
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request) public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
{ {
if (string.IsNullOrWhiteSpace(request.Name))
return BadRequest(new ErrorResponse("Channel name is required."));
var channelName = request.Name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens."));
if (await _db.Channels.AnyAsync(c => c.Name == channelName))
return Conflict(new ErrorResponse($"Channel '{channelName}' already exists."));
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channel = new Channel var result = await _channelService.CreateChannelAsync(
{ Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
Id = Guid.NewGuid(), if (!result.IsSuccess)
Name = channelName, return MapChannelError(result);
Topic = request.Topic?.Trim(),
IsPublic = request.IsPublic,
CreatedByUserId = Guid.Parse(userIdClaim),
};
_db.Channels.Add(channel); if (result.Channel!.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
// Creator automatically becomes a member return Created($"/api/channels/{result.Channel.Name}", result.Channel);
_db.ChannelMemberships.Add(new ChannelMembership
{
UserId = Guid.Parse(userIdClaim),
ChannelId = channel.Id,
});
await _db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
if (channel.IsPublic)
await _chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto);
} }
[HttpPut("{channel}/topic")] [HttpPut("{channel}/topic")]
@@ -125,26 +82,13 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var result = await _channelService.UpdateTopicAsync(
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); Guid.Parse(userIdClaim), channel, request.Topic);
if (!result.IsSuccess)
return MapChannelError(result);
if (dbChannel is null) await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return Ok(result.Channel);
if (dbChannel.CreatedByUserId != Guid.Parse(userIdClaim))
return StatusCode(403, new ErrorResponse("Only the channel creator can update the topic."));
if (request.Topic is not null && request.Topic.Length > ValidationConstants.MaxChannelTopicLength)
return BadRequest(new ErrorResponse($"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters."));
dbChannel.Topic = request.Topic?.Trim();
await _db.SaveChangesAsync();
var messageCount = await _db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
await _chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto);
} }
[HttpDelete("{channel}")] [HttpDelete("{channel}")]
@@ -154,29 +98,17 @@ public class ChannelsController : ControllerBase
if (userIdClaim is null) if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required.")); return Unauthorized(new ErrorResponse("Authentication required."));
var channelName = channel.ToLowerInvariant().Trim(); var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
if (!result.IsSuccess)
if (channelName == HubConstants.DefaultChannel) return MapChannelError(result);
return BadRequest(new ErrorResponse($"The '{HubConstants.DefaultChannel}' channel cannot be deleted."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
var userId = Guid.Parse(userIdClaim);
var caller = await _db.Users.FindAsync(userId);
if (dbChannel.CreatedByUserId != userId && (caller is null || caller.Role < ServerRole.Admin))
return StatusCode(403, new ErrorResponse("Only the channel creator or an admin can delete the channel."));
_db.Channels.Remove(dbChannel);
await _db.SaveChangesAsync();
return NoContent(); return NoContent();
} }
[HttpPost("{channel}/upload")] [HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null) public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -190,8 +122,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (dbChannel is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0) if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
@@ -199,16 +131,23 @@ public class ChannelsController : ControllerBase
var file = Request.Form.Files[0]; var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxFileSizeBytes) // Detect file type early so we can apply the correct size limit
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
// Detect if file is an image by checking magic bytes
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream); var isImage = FileValidationHelper.IsValidImage(stream);
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
: isAudio ? HubConstants.MaxAudioFileSizeBytes
: HubConstants.MaxFileSizeBytes;
if (file.Length > maxSize)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image : MessageType.File; var messageType = isImage ? MessageType.Image
: isAudio ? MessageType.Audio
: MessageType.File;
string content; string content;
if (isImage) if (isImage)
@@ -233,8 +172,9 @@ public class ChannelsController : ControllerBase
Type = messageType, Type = messageType,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName, AttachmentFileName = file.FileName,
AttachmentFileSize = file.Length,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
@@ -252,7 +192,8 @@ public class ChannelsController : ControllerBase
messageType, messageType,
attachmentUrl, attachmentUrl,
file.FileName, file.FileName,
message.SentAt); message.SentAt,
file.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -274,8 +215,8 @@ public class ChannelsController : ControllerBase
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return BadRequest(new ErrorResponse("Invalid channel name format.")); return BadRequest(new ErrorResponse("Invalid channel name format."));
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channelDto = await _channelService.GetChannelByNameAsync(channelName);
if (dbChannel is null) if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist.")); return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (string.IsNullOrWhiteSpace(request.Url)) if (string.IsNullOrWhiteSpace(request.Url))
@@ -295,13 +236,13 @@ public class ChannelsController : ControllerBase
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength; var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxFileSizeBytes) if (contentLength > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync(); imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxFileSizeBytes) if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath); fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
@@ -353,8 +294,9 @@ public class ChannelsController : ControllerBase
Type = MessageType.Image, Type = MessageType.Image,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName, AttachmentFileName = fileName,
AttachmentFileSize = imageBytes.Length,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = dbChannel.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
SenderUsername = usernameClaim, SenderUsername = usernameClaim,
}; };
@@ -372,10 +314,21 @@ public class ChannelsController : ControllerBase
MessageType.Image, MessageType.Image,
attachmentUrl, attachmentUrl,
fileName, fileName,
message.SentAt); message.SentAt,
imageBytes.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto); return Ok(messageDto);
} }
private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch
{
ChannelError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
ChannelError.AlreadyExists => Conflict(new ErrorResponse(result.ErrorMessage!)),
ChannelError.NotFound => NotFound(new ErrorResponse(result.ErrorMessage!)),
ChannelError.Forbidden => StatusCode(403, new ErrorResponse(result.ErrorMessage!)),
ChannelError.Protected => BadRequest(new ErrorResponse(result.ErrorMessage!)),
_ => BadRequest(new ErrorResponse(result.ErrorMessage ?? "Unknown error.")),
};
} }
@@ -36,6 +36,13 @@ public class FilesController : ControllerBase
".png" => "image/png", ".png" => "image/png",
".gif" => "image/gif", ".gif" => "image/gif",
".webp" => "image/webp", ".webp" => "image/webp",
".mp3" => "audio/mpeg",
".wav" => "audio/wav",
".ogg" => "audio/ogg",
".flac" => "audio/flac",
".aac" => "audio/aac",
".m4a" => "audio/mp4",
".wma" => "audio/x-ms-wma",
".pdf" => "application/pdf", ".pdf" => "application/pdf",
".txt" => "text/plain", ".txt" => "text/plain",
_ => "application/octet-stream" _ => "application/octet-stream"
@@ -0,0 +1,267 @@
// <auto-generated />
using System;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
[DbContext(typeof(EchoHubDbContext))]
[Migration("20260221193444_AddAttachmentFileSize")]
partial class AddAttachmentFileSize
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", null)
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EchoHub.Core.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
.WithMany("Messages")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.HasOne("EchoHub.Core.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddAttachmentFileSize : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "AttachmentFileSize",
table: "Messages",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AttachmentFileSize",
table: "Messages");
}
}
}
@@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl") b.Property<string>("AttachmentUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+6
View File
@@ -1,5 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<Content Include="hue_icon.ico" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" /> <ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
@@ -22,6 +26,8 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<PackageIcon></PackageIcon>
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+2
View File
@@ -108,12 +108,14 @@ while (true)
builder.Services.AddSingleton<FileStorageService>(); builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddSingleton<LinkEmbedService>(); builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>(); builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHostedService<FileCleanupService>();
// ── Encryption ───────────────────────────────────────────────────── // ── Encryption ─────────────────────────────────────────────────────
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>(); builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
// ── Chat Service + Broadcasters ───────────────────────────────────── // ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>(); builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChannelService, ChannelService>();
builder.Services.AddSingleton<IChatService, ChatService>(); builder.Services.AddSingleton<IChatService, ChatService>();
// ── IRC Gateway (optional) ────────────────────────────────────────── // ── IRC Gateway (optional) ──────────────────────────────────────────
@@ -0,0 +1,247 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace EchoHub.Server.Services;
public class ChannelService : IChannelService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly PresenceTracker _presenceTracker;
private readonly ILogger<ChannelService> _logger;
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
ILogger<ChannelService> logger)
{
_scopeFactory = scopeFactory;
_presenceTracker = presenceTracker;
_logger = logger;
}
public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
await EnsureDefaultChannelAsync(db);
var query = db.Channels.Where(c =>
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
var total = await query.CountAsync();
var channels = await query
.OrderBy(c => c.Name)
.Skip(offset)
.Take(limit)
.Select(c => new ChannelDto(
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt))
.ToListAsync();
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
}
public async Task<ChannelOperationResult> CreateChannelAsync(
Guid creatorUserId, string name, string? topic, bool isPublic)
{
if (string.IsNullOrWhiteSpace(name))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Channel name is required.");
var channelName = name.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
if (await db.Channels.AnyAsync(c => c.Name == channelName))
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
var channel = new Channel
{
Id = Guid.NewGuid(),
Name = channelName,
Topic = topic?.Trim(),
IsPublic = isPublic,
CreatedByUserId = creatorUserId,
};
db.Channels.Add(channel);
// Creator automatically becomes a member
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = creatorUserId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<ChannelOperationResult> UpdateTopicAsync(
Guid callerUserId, string channelName, string? topic)
{
channelName = channelName.ToLowerInvariant().Trim();
if (topic is not null && topic.Length > ValidationConstants.MaxChannelTopicLength)
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
$"Topic must not exceed {ValidationConstants.MaxChannelTopicLength} characters.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
if (dbChannel.CreatedByUserId != callerUserId)
return ChannelOperationResult.Fail(ChannelError.Forbidden, "Only the channel creator can update the topic.");
dbChannel.Topic = topic?.Trim();
await db.SaveChangesAsync();
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, messageCount, dbChannel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
if (channelName == HubConstants.DefaultChannel)
return ChannelOperationResult.Fail(ChannelError.Protected,
$"The '{HubConstants.DefaultChannel}' channel cannot be deleted.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var dbChannel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (dbChannel is null)
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
var caller = await db.Users.FindAsync(callerUserId);
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
return ChannelOperationResult.Fail(ChannelError.Forbidden,
"Only the channel creator or an admin can delete the channel.");
db.Channels.Remove(dbChannel);
await db.SaveChangesAsync();
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, dbChannel.IsPublic, 0, dbChannel.CreatedAt);
return ChannelOperationResult.Success(dto);
}
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
return channels.Select(c => new ChannelListItem(
c.Name, c.Topic,
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
}
public async Task<ChannelDto?> GetChannelByNameAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
if (c is null) return null;
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt);
}
public async Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
return (false, "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
{
// Auto-recreate #general if it was somehow removed
if (channelName == HubConstants.DefaultChannel)
{
channel = new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
};
db.Channels.Add(channel);
await db.SaveChangesAsync();
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
}
else
{
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.");
}
}
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
if (!hasMembership)
{
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = userId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
}
return (true, null);
}
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
{
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
{
db.Channels.Add(new Channel
{
Id = Guid.NewGuid(),
Name = HubConstants.DefaultChannel,
Topic = "General discussion",
CreatedByUserId = Guid.Empty,
});
await db.SaveChangesAsync();
}
}
}
+10 -50
View File
@@ -17,6 +17,7 @@ public class ChatService : IChatService
private readonly IEnumerable<IChatBroadcaster> _broadcasters; private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly LinkEmbedService _embedService; private readonly LinkEmbedService _embedService;
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
private readonly IChannelService _channelService;
private readonly ILogger<ChatService> _logger; private readonly ILogger<ChatService> _logger;
public ChatService( public ChatService(
@@ -25,6 +26,7 @@ public class ChatService : IChatService
IEnumerable<IChatBroadcaster> broadcasters, IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService, LinkEmbedService embedService,
IMessageEncryptionService encryption, IMessageEncryptionService encryption,
IChannelService channelService,
ILogger<ChatService> logger) ILogger<ChatService> logger)
{ {
_scopeFactory = scopeFactory; _scopeFactory = scopeFactory;
@@ -32,6 +34,7 @@ public class ChatService : IChatService
_broadcasters = broadcasters; _broadcasters = broadcasters;
_embedService = embedService; _embedService = embedService;
_encryption = encryption; _encryption = encryption;
_channelService = channelService;
_logger = logger; _logger = logger;
} }
@@ -95,28 +98,10 @@ public class ChatService : IChatService
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName)) // Delegate channel validation + membership to ChannelService
return ([], "Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens."); var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
if (!success)
using var scope = _scopeFactory.CreateScope(); return ([], error);
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null)
return ([], $"Channel '{channelName}' does not exist. Create it first via the channel list.");
// Persist membership so the channel shows in the user's channel list
var hasMembership = await db.ChannelMemberships
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
if (!hasMembership)
{
db.ChannelMemberships.Add(new ChannelMembership
{
UserId = userId,
ChannelId = channel.Id,
});
await db.SaveChangesAsync();
}
var isNewJoin = _presenceTracker.JoinChannel(username, channelName); var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
@@ -126,7 +111,7 @@ public class ChatService : IChatService
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName); _logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
} }
var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount); var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
return (history, null); return (history, null);
} }
@@ -228,7 +213,7 @@ public class ChatService : IChatService
null, null,
null, null,
message.SentAt, message.SentAt,
embeds); Embeds: embeds);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -335,32 +320,6 @@ public class ChatService : IChatService
user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt); user.StatusMessage, user.Role, user.CreatedAt, user.LastSeenAt);
} }
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) return (null, false);
return (channel.Topic, true);
}
public async Task<List<ChannelListItem>> GetChannelListAsync()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var channels = await db.Channels.OrderBy(c => c.Name).ToListAsync();
return channels.Select(c => new ChannelListItem(
c.Name,
c.Topic,
_presenceTracker.GetOnlineUsersInChannel(c.Name).Count)).ToList();
}
public Task<List<string>> GetChannelsForUserAsync(string username) public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username)); => Task.FromResult(_presenceTracker.GetChannelsForUser(username));
@@ -457,6 +416,7 @@ public class ChatService : IChatService
x.m.AttachmentUrl, x.m.AttachmentUrl,
x.m.AttachmentFileName, x.m.AttachmentFileName,
x.m.SentAt, x.m.SentAt,
x.m.AttachmentFileSize,
embeds); embeds);
}).ToList(); }).ToList();
} }
@@ -0,0 +1,69 @@
namespace EchoHub.Server.Services;
public sealed class FileCleanupService : BackgroundService
{
private readonly IConfiguration _configuration;
private readonly ILogger<FileCleanupService> _logger;
public FileCleanupService(IConfiguration configuration, ILogger<FileCleanupService> logger)
{
_configuration = configuration;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
var storagePath = _configuration["Storage:Path"]
?? Path.Combine(AppContext.BaseDirectory, "uploads");
_logger.LogInformation(
"File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
intervalHours, retentionDays, storagePath);
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
try
{
CleanupOldFiles(storagePath, retentionDays);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during file cleanup");
}
}
}
private void CleanupOldFiles(string storagePath, int retentionDays)
{
if (!Directory.Exists(storagePath))
return;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
var files = Directory.GetFiles(storagePath);
var deleted = 0;
foreach (var file in files)
{
var createdAt = File.GetCreationTimeUtc(file);
if (createdAt < cutoff)
{
try
{
File.Delete(file);
deleted++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete old file: {File}", file);
}
}
}
if (deleted > 0)
_logger.LogInformation("File cleanup: deleted {Count} files older than {Days} days", deleted, retentionDays);
}
}
@@ -52,6 +52,20 @@ public static class FileValidationHelper
} }
} }
private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma"
};
/// <summary>
/// Checks whether the file name has a recognized audio extension.
/// </summary>
public static bool IsAudioFile(string fileName)
{
var ext = Path.GetExtension(fileName);
return !string.IsNullOrEmpty(ext) && AudioExtensions.Contains(ext);
}
private static bool StartsWith(byte[] buffer, int length, byte[] magic) private static bool StartsWith(byte[] buffer, int length, byte[] magic)
{ {
if (length < magic.Length) if (length < magic.Length)
@@ -1,6 +1,7 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using EchoHub.Core.Constants; using EchoHub.Core.Constants;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -12,12 +13,14 @@ public static partial class DataMigrationService
{ {
using var scope = services.CreateScope(); using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>() var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("EchoHub.Server.Setup.DataMigration"); .CreateLogger("EchoHub.Server.Setup.DataMigration");
await EnsureDefaultChannelsPublicAsync(db, logger); await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger); await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger); await MigrateEmbedJsonToArrayAsync(db, logger);
await EnsureConfiguredAdminsAsync(db, config, logger);
} }
/// <summary> /// <summary>
@@ -94,6 +97,40 @@ 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>
/// Ensure usernames listed in Server:Admins config are at least Admin role.
/// Acts as a safety net in case the first registered user didn't get Owner role.
/// </summary>
private static async Task EnsureConfiguredAdminsAsync(EchoHubDbContext db, IConfiguration config, ILogger logger)
{
var adminUsernames = config.GetSection("Server:Admins").Get<string[]>();
if (adminUsernames is not { Length: > 0 })
return;
var promoted = 0;
foreach (var username in adminUsernames)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
if (user is null)
{
logger.LogWarning("Configured admin '{Username}' not found in database (not registered yet).", username);
continue;
}
if (user.Role < ServerRole.Admin)
{
var oldRole = user.Role;
user.Role = ServerRole.Admin;
promoted++;
logger.LogInformation("Promoted '{Username}' from {OldRole} to Admin (configured in Server:Admins).",
username, oldRole);
}
}
if (promoted > 0)
await db.SaveChangesAsync();
}
/// <summary> /// <summary>
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]"). /// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
/// </summary> /// </summary>
+6 -1
View File
@@ -12,7 +12,12 @@
"Name": "My EchoHub Server", "Name": "My EchoHub Server",
"Description": "A self-hosted EchoHub chat server", "Description": "A self-hosted EchoHub chat server",
"PublicServer": false, "PublicServer": false,
"PublicHost": "" "PublicHost": "",
"Admins": []
},
"Storage": {
"CleanupIntervalHours": 1,
"RetentionDays": 30
}, },
"Encryption": { "Encryption": {
"Key": "", "Key": "",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+107
View File
@@ -0,0 +1,107 @@
using EchoHub.Client.UI;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// Tests for static string-utility methods on ChatLine.
/// Note: Tests that construct ChatLine/ChatListSource or use Terminal.Gui types
/// (Attribute, Color) are excluded because Terminal.Gui's module initializer
/// requires a display driver which is unavailable in CI/test environments.
/// </summary>
public class ChatLineTests
{
// ── HasColorTags ──────────────────────────────────────────────────
[Fact]
public void HasColorTags_ForegroundTag_ReturnsTrue()
{
Assert.True(ChatLine.HasColorTags("Hello {F:FF0000}world"));
}
[Fact]
public void HasColorTags_BackgroundTag_ReturnsTrue()
{
Assert.True(ChatLine.HasColorTags("Hello {B:00FF00}world"));
}
[Fact]
public void HasColorTags_ResetTag_ReturnsTrue()
{
Assert.True(ChatLine.HasColorTags("Hello{X}"));
}
[Fact]
public void HasColorTags_NoTags_ReturnsFalse()
{
Assert.False(ChatLine.HasColorTags("Hello world"));
}
[Fact]
public void HasColorTags_PartialTag_ReturnsFalse()
{
// {Z:...} is not a valid tag (only F or B)
Assert.False(ChatLine.HasColorTags("Hello {Z:000000}"));
}
[Fact]
public void HasColorTags_EmptyString_ReturnsFalse()
{
Assert.False(ChatLine.HasColorTags(""));
}
[Theory]
[InlineData("{F:AABBCC}text")]
[InlineData("prefix{B:112233}suffix")]
[InlineData("a{X}b")]
[InlineData("{F:000000}{B:FFFFFF}{X}")]
public void HasColorTags_VariousValidTags_ReturnsTrue(string input)
{
Assert.True(ChatLine.HasColorTags(input));
}
// ── StripColorTags ────────────────────────────────────────────────
[Fact]
public void StripColorTags_RemovesAllTags()
{
var result = ChatLine.StripColorTags("{F:FF0000}red{B:00FF00}green{X}");
Assert.Equal("redgreen", result);
}
[Fact]
public void StripColorTags_NoTags_ReturnsOriginal()
{
var result = ChatLine.StripColorTags("plain text");
Assert.Equal("plain text", result);
}
[Fact]
public void StripColorTags_OnlyTags_ReturnsEmpty()
{
var result = ChatLine.StripColorTags("{F:AABBCC}{B:112233}{X}");
Assert.Equal("", result);
}
[Fact]
public void StripColorTags_MixedContent_KeepsText()
{
var result = ChatLine.StripColorTags("before{F:FF0000}middle{X}after");
Assert.Equal("beforemiddleafter", result);
}
[Fact]
public void StripColorTags_MultipleConsecutiveTags_AllStripped()
{
var result = ChatLine.StripColorTags("{F:FF0000}{B:00FF00}{X}{F:0000FF}text{X}");
Assert.Equal("text", result);
}
[Fact]
public void StripColorTags_PreservesNonTagBraces()
{
// {Hello} is not a valid tag and should be preserved
var result = ChatLine.StripColorTags("{Hello} world");
Assert.Equal("{Hello} world", result);
}
}
+462
View File
@@ -0,0 +1,462 @@
using EchoHub.Client.Commands;
using EchoHub.Core.Models;
using Xunit;
namespace EchoHub.Tests;
public class CommandHandlerTests
{
private CommandHandler CreateHandler() => new();
// ── IsCommand ─────────────────────────────────────────────────────
[Fact]
public void IsCommand_StartsWithSlash_ReturnsTrue()
{
var handler = CreateHandler();
Assert.True(handler.IsCommand("/help"));
}
[Fact]
public void IsCommand_NoSlash_ReturnsFalse()
{
var handler = CreateHandler();
Assert.False(handler.IsCommand("hello"));
}
[Fact]
public void IsCommand_EmptyString_ReturnsFalse()
{
var handler = CreateHandler();
Assert.False(handler.IsCommand(""));
}
// ── HandleAsync — not a command ───────────────────────────────────
[Fact]
public async Task HandleAsync_NotCommand_ReturnsFalse()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("hello world");
Assert.False(result.Handled);
}
// ── HandleAsync — unknown command ─────────────────────────────────
[Fact]
public async Task HandleAsync_UnknownCommand_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/doesnotexist");
Assert.True(result.Handled);
Assert.True(result.IsError);
Assert.Contains("Unknown command", result.Message);
}
// ── /status ───────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_StatusOnline_SetsOnlineStatus()
{
var handler = CreateHandler();
UserStatus? capturedStatus = null;
handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status online");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal(UserStatus.Online, capturedStatus);
}
[Fact]
public async Task HandleAsync_StatusAway_SetsAwayStatus()
{
var handler = CreateHandler();
UserStatus? capturedStatus = null;
handler.OnSetStatus += (status, msg) => { capturedStatus = status; return Task.CompletedTask; };
await handler.HandleAsync("/status away");
Assert.Equal(UserStatus.Away, capturedStatus);
}
[Fact]
public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage()
{
var handler = CreateHandler();
string? capturedMessage = null;
handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status brb lunch");
Assert.True(result.Handled);
Assert.Contains("brb lunch", result.Message);
Assert.Equal("brb lunch", capturedMessage);
}
[Fact]
public async Task HandleAsync_StatusNoArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/status");
Assert.True(result.IsError);
Assert.Contains("Usage", result.Message);
}
// ── /nick ─────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Nick_SetsDisplayName()
{
var handler = CreateHandler();
string? capturedNick = null;
handler.OnSetNick += nick => { capturedNick = nick; return Task.CompletedTask; };
var result = await handler.HandleAsync("/nick Bob Smith");
Assert.True(result.Handled);
Assert.Equal("Bob Smith", capturedNick);
}
[Fact]
public async Task HandleAsync_Nick_EmptyArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/nick");
Assert.True(result.IsError);
Assert.Contains("Usage", result.Message);
}
// ── /color ────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Color_ValidHex_Succeeds()
{
var handler = CreateHandler();
string? capturedColor = null;
handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
var result = await handler.HandleAsync("/color #FF5733");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal("#FF5733", capturedColor);
}
[Fact]
public async Task HandleAsync_Color_WithoutHash_AddsHash()
{
var handler = CreateHandler();
string? capturedColor = null;
handler.OnSetColor += color => { capturedColor = color; return Task.CompletedTask; };
await handler.HandleAsync("/color FF5733");
Assert.Equal("#FF5733", capturedColor);
}
[Fact]
public async Task HandleAsync_Color_InvalidHex_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/color #ZZZZZZ");
Assert.True(result.IsError);
Assert.Contains("Invalid color", result.Message);
}
[Fact]
public async Task HandleAsync_Color_TooShort_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/color #FFF");
Assert.True(result.IsError);
}
[Fact]
public async Task HandleAsync_Color_NoArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/color");
Assert.True(result.IsError);
}
// ── /join ─────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Join_StripsHashPrefix()
{
var handler = CreateHandler();
string? capturedChannel = null;
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
await handler.HandleAsync("/join #random");
Assert.Equal("random", capturedChannel);
}
[Fact]
public async Task HandleAsync_Join_NoHash_PassedDirectly()
{
var handler = CreateHandler();
string? capturedChannel = null;
handler.OnJoinChannel += ch => { capturedChannel = ch; return Task.CompletedTask; };
await handler.HandleAsync("/join random");
Assert.Equal("random", capturedChannel);
}
[Fact]
public async Task HandleAsync_Join_NoArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/join");
Assert.True(result.IsError);
}
// ── /kick ─────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Kick_WithReason_ParsesUsernameAndReason()
{
var handler = CreateHandler();
string? capturedUser = null;
string? capturedReason = null;
handler.OnKickUser += (user, reason) =>
{
capturedUser = user;
capturedReason = reason;
return Task.CompletedTask;
};
await handler.HandleAsync("/kick baduser being rude");
Assert.Equal("baduser", capturedUser);
Assert.Equal("being rude", capturedReason);
}
[Fact]
public async Task HandleAsync_Kick_WithoutReason_NullReason()
{
var handler = CreateHandler();
string? capturedReason = "initial";
handler.OnKickUser += (user, reason) =>
{
capturedReason = reason;
return Task.CompletedTask;
};
await handler.HandleAsync("/kick baduser");
Assert.Null(capturedReason);
}
[Fact]
public async Task HandleAsync_Kick_NoArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/kick");
Assert.True(result.IsError);
}
// ── /mute ─────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Mute_WithDuration_ParsesDuration()
{
var handler = CreateHandler();
int? capturedDuration = null;
handler.OnMuteUser += (user, duration) =>
{
capturedDuration = duration;
return Task.CompletedTask;
};
await handler.HandleAsync("/mute alice 30");
Assert.Equal(30, capturedDuration);
}
[Fact]
public async Task HandleAsync_Mute_WithoutDuration_NullDuration()
{
var handler = CreateHandler();
int? capturedDuration = -1;
handler.OnMuteUser += (user, duration) =>
{
capturedDuration = duration;
return Task.CompletedTask;
};
await handler.HandleAsync("/mute alice");
Assert.Null(capturedDuration);
}
// ── /role ─────────────────────────────────────────────────────────
[Theory]
[InlineData("admin")]
[InlineData("mod")]
[InlineData("member")]
public async Task HandleAsync_Role_ValidRole_Succeeds(string role)
{
var handler = CreateHandler();
string? capturedRole = null;
handler.OnAssignRole += (user, r) => { capturedRole = r; return Task.CompletedTask; };
var result = await handler.HandleAsync($"/role alice {role}");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal(role, capturedRole);
}
[Fact]
public async Task HandleAsync_Role_InvalidRole_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/role alice superadmin");
Assert.True(result.IsError);
Assert.Contains("Invalid role", result.Message);
}
[Fact]
public async Task HandleAsync_Role_MissingRole_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/role alice");
Assert.True(result.IsError);
}
// ── /send ─────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Send_NoArgs_ReturnsUsageError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/send");
Assert.True(result.IsError);
Assert.Contains("Usage", result.Message);
}
[Fact]
public async Task HandleAsync_Send_UrlInput_RecognizedAsUrl()
{
var handler = CreateHandler();
string? capturedTarget = null;
handler.OnSendFile += (target, size) => { capturedTarget = target; return Task.CompletedTask; };
var result = await handler.HandleAsync("/send https://example.com/image.png");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal("https://example.com/image.png", capturedTarget);
}
[Fact]
public async Task HandleAsync_Send_UrlWithSizeFlag_ExtractsSizeCorrectly()
{
var handler = CreateHandler();
string? capturedSize = null;
handler.OnSendFile += (target, size) => { capturedSize = size; return Task.CompletedTask; };
await handler.HandleAsync("/send https://example.com/photo.jpg -s");
Assert.Equal("s", capturedSize);
}
// ── /help ─────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Help_ReturnsHelpText()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/help");
Assert.True(result.Handled);
Assert.Contains("Available commands", result.Message);
}
[Fact]
public async Task HandleAsync_QuestionMark_ReturnsHelp()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/?");
Assert.True(result.Handled);
Assert.Contains("Available commands", result.Message);
}
// ── /ban ──────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Ban_WithReason_ParsesUsernameAndReason()
{
var handler = CreateHandler();
string? capturedUser = null;
string? capturedReason = null;
handler.OnBanUser += (user, reason) =>
{
capturedUser = user;
capturedReason = reason;
return Task.CompletedTask;
};
await handler.HandleAsync("/ban troll spamming links");
Assert.Equal("troll", capturedUser);
Assert.Equal("spamming links", capturedReason);
}
// ── /topic ────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Topic_SetsTopic()
{
var handler = CreateHandler();
string? capturedTopic = null;
handler.OnSetTopic += topic => { capturedTopic = topic; return Task.CompletedTask; };
var result = await handler.HandleAsync("/topic Welcome to our channel!");
Assert.True(result.Handled);
Assert.Equal("Welcome to our channel!", capturedTopic);
}
[Fact]
public async Task HandleAsync_Topic_NoArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/topic");
Assert.True(result.IsError);
}
// ── /quit and /exit ───────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Quit_Handled()
{
var handler = CreateHandler();
var quitCalled = false;
handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/quit");
Assert.True(result.Handled);
Assert.True(quitCalled);
}
[Fact]
public async Task HandleAsync_Exit_Handled()
{
var handler = CreateHandler();
var quitCalled = false;
handler.OnQuit += () => { quitCalled = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/exit");
Assert.True(result.Handled);
Assert.True(quitCalled);
}
}
@@ -0,0 +1,74 @@
using EchoHub.Server.Setup;
using Xunit;
namespace EchoHub.Tests;
public class DataMigrationServiceTests
{
// ── AnsiToColorTags ───────────────────────────────────────────────
[Fact]
public void AnsiToColorTags_ForegroundEscape_ConvertedToColorTag()
{
var ansi = "\x1b[38;2;255;0;0mred text";
var result = DataMigrationService.AnsiToColorTags(ansi);
Assert.Equal("{F:FF0000}red text", result);
}
[Fact]
public void AnsiToColorTags_BackgroundEscape_ConvertedToColorTag()
{
var ansi = "\x1b[48;2;0;255;0mgreen bg";
var result = DataMigrationService.AnsiToColorTags(ansi);
Assert.Equal("{B:00FF00}green bg", result);
}
[Fact]
public void AnsiToColorTags_ResetEscape_ConvertedToResetTag()
{
var ansi = "\x1b[0m";
var result = DataMigrationService.AnsiToColorTags(ansi);
Assert.Equal("{X}", result);
}
[Fact]
public void AnsiToColorTags_NoEscapes_ReturnsUnchanged()
{
var text = "Hello, world!";
var result = DataMigrationService.AnsiToColorTags(text);
Assert.Equal("Hello, world!", result);
}
[Fact]
public void AnsiToColorTags_MixedContent_ConvertsEscapesOnly()
{
var ansi = "before\x1b[38;2;100;200;50mtextafter";
var result = DataMigrationService.AnsiToColorTags(ansi);
Assert.Equal("before{F:64C832}textafter", result);
}
[Fact]
public void AnsiToColorTags_MultipleTags_AllConverted()
{
var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;0;255mblue bg\x1b[0mreset";
var result = DataMigrationService.AnsiToColorTags(ansi);
Assert.Equal("{F:FF0000}red{B:0000FF}blue bg{X}reset", result);
}
[Fact]
public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi()
{
// AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses
var original = "{F:FF0000}red{B:00FF00}green{X}";
var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original);
var backToTags = DataMigrationService.AnsiToColorTags(ansi);
Assert.Equal(original, backToTags);
}
}
+1
View File
@@ -21,6 +21,7 @@
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" /> <ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" /> <ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" /> <ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -53,4 +53,48 @@ public class FileValidationHelperTests
FileValidationHelper.IsValidImage(stream); FileValidationHelper.IsValidImage(stream);
Assert.Equal(0, stream.Position); Assert.Equal(0, stream.Position);
} }
// ── IsAudioFile tests ─────────────────────────────────────────────
[Theory]
[InlineData("song.mp3")]
[InlineData("track.wav")]
[InlineData("audio.ogg")]
[InlineData("music.flac")]
[InlineData("clip.aac")]
[InlineData("podcast.m4a")]
[InlineData("old.wma")]
public void IsAudioFile_SupportedExtensions_ReturnsTrue(string fileName)
{
Assert.True(FileValidationHelper.IsAudioFile(fileName));
}
[Theory]
[InlineData("song.MP3")]
[InlineData("track.Wav")]
[InlineData("audio.OGG")]
[InlineData("music.FLAC")]
public void IsAudioFile_CaseInsensitive_ReturnsTrue(string fileName)
{
Assert.True(FileValidationHelper.IsAudioFile(fileName));
}
[Theory]
[InlineData("document.txt")]
[InlineData("report.pdf")]
[InlineData("app.exe")]
[InlineData("photo.jpg")]
[InlineData("image.png")]
public void IsAudioFile_NonAudioExtension_ReturnsFalse(string fileName)
{
Assert.False(FileValidationHelper.IsAudioFile(fileName));
}
[Theory]
[InlineData("")]
[InlineData("noextension")]
public void IsAudioFile_EmptyOrNoExtension_ReturnsFalse(string fileName)
{
Assert.False(FileValidationHelper.IsAudioFile(fileName));
}
} }
@@ -0,0 +1,62 @@
using EchoHub.Core.Constants;
using EchoHub.Server.Services;
using Xunit;
namespace EchoHub.Tests;
public class ImageToAsciiServiceTests
{
[Fact]
public void GetDimensions_Small_Returns40x40()
{
var (w, h) = ImageToAsciiService.GetDimensions("s");
Assert.Equal(40, w);
Assert.Equal(40, h);
}
[Fact]
public void GetDimensions_Large_Returns120x120()
{
var (w, h) = ImageToAsciiService.GetDimensions("l");
Assert.Equal(120, w);
Assert.Equal(120, h);
}
[Fact]
public void GetDimensions_Default_Returns80x80()
{
var (w, h) = ImageToAsciiService.GetDimensions("m");
Assert.Equal(HubConstants.AsciiArtWidth, w);
Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
}
[Fact]
public void GetDimensions_Null_ReturnsDefault()
{
var (w, h) = ImageToAsciiService.GetDimensions(null);
Assert.Equal(HubConstants.AsciiArtWidth, w);
Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
}
[Fact]
public void GetDimensions_CaseInsensitive()
{
var (w1, h1) = ImageToAsciiService.GetDimensions("S");
var (w2, h2) = ImageToAsciiService.GetDimensions("s");
Assert.Equal(w1, w2);
Assert.Equal(h1, h2);
var (w3, h3) = ImageToAsciiService.GetDimensions("L");
var (w4, h4) = ImageToAsciiService.GetDimensions("l");
Assert.Equal(w3, w4);
Assert.Equal(h3, h4);
}
[Fact]
public void GetDimensions_UnknownSize_ReturnsDefault()
{
var (w, h) = ImageToAsciiService.GetDimensions("xl");
Assert.Equal(HubConstants.AsciiArtWidth, w);
Assert.Equal(HubConstants.AsciiArtHeightHalfBlock, h);
}
}
@@ -0,0 +1,316 @@
using System.Collections.Concurrent;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Irc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Xunit;
namespace EchoHub.Tests.Irc;
public class IrcBroadcasterTests
{
private readonly IrcOptions _options = new() { ServerName = "testserver", Enabled = true };
private readonly FakeEncryptionService _encryption = new();
private readonly IrcGatewayService _gateway;
private readonly IrcBroadcaster _broadcaster;
public IrcBroadcasterTests()
{
var services = new ServiceCollection()
.AddSingleton<IOptions<IrcOptions>>(Options.Create(_options))
.BuildServiceProvider();
_gateway = new IrcGatewayService(
Options.Create(_options), services, NullLogger<IrcGatewayService>.Instance);
_broadcaster = new IrcBroadcaster(_gateway, _encryption);
}
/// <summary>
/// Injects a test connection into the gateway's internal connection map.
/// </summary>
private IrcClientConnection AddConnection(string nickname, params string[] channels)
{
var (conn, _) = TestIrcConnectionFactory.CreateAuthenticated(nickname);
foreach (var ch in channels)
conn.JoinChannel(ch);
// Insert into gateway's ConcurrentDictionary via the public IReadOnlyDictionary
var connections = (ConcurrentDictionary<string, IrcClientConnection>)_gateway.Connections;
connections[conn.ConnectionId] = conn;
return conn;
}
private static List<string> CaptureOutput(IrcClientConnection conn)
{
// We need to get the stream from the connection — but it's private.
// Since we used TestIrcConnectionFactory, the TestDuplexStream was passed to the constructor.
// We can't easily access it. Instead, we create connections differently for these tests.
// Let's use a different approach.
throw new NotSupportedException("Use AddConnectionWithCapture instead");
}
/// <summary>
/// Creates a connection that can capture output and injects it into the gateway.
/// </summary>
private (IrcClientConnection Connection, TestDuplexStream Stream) AddConnectionWithCapture(
string nickname, params string[] channels)
{
var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname);
foreach (var ch in channels)
conn.JoinChannel(ch);
var connections = (ConcurrentDictionary<string, IrcClientConnection>)_gateway.Connections;
connections[conn.ConnectionId] = conn;
return (conn, stream);
}
// ── SendMessageToChannelAsync ────────────────────────────────────────
[Fact]
public async Task SendMessage_DecryptsContent()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
var encryptedContent = _encryption.Encrypt("Hello world!");
var message = new MessageDto(
Guid.NewGuid(), encryptedContent, "alice", null, "general",
MessageType.Text, null, null, DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("Hello world!"));
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
}
[Fact]
public async Task SendMessage_SkipsSender()
{
var (_, aliceStream) = AddConnectionWithCapture("alice", "general");
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
MessageType.Text, null, null, DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
// Alice (sender) should NOT receive the message
Assert.Empty(aliceStream.GetOutputLines());
// Bob should receive it
Assert.NotEmpty(bobStream.GetOutputLines());
}
[Fact]
public async Task SendMessage_OnlySendsToChannelMembers()
{
var (_, generalStream) = AddConnectionWithCapture("bob", "general");
var (_, randomStream) = AddConnectionWithCapture("charlie", "random");
var message = new MessageDto(
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
MessageType.Text, null, null, DateTimeOffset.UtcNow);
await _broadcaster.SendMessageToChannelAsync("general", message);
Assert.NotEmpty(generalStream.GetOutputLines());
Assert.Empty(randomStream.GetOutputLines());
}
// ── SendUserJoinedAsync ──────────────────────────────────────────────
[Fact]
public async Task SendUserJoined_NotifiesOtherMembers()
{
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
await _broadcaster.SendUserJoinedAsync("general", "alice");
var output = bobStream.GetOutputLines();
Assert.Contains(output, l => l.Contains("JOIN #general") && l.Contains("alice"));
}
[Fact]
public async Task SendUserJoined_ExcludesConnectionId()
{
var (conn, excludedStream) = AddConnectionWithCapture("alice", "general");
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
await _broadcaster.SendUserJoinedAsync("general", "alice", conn.ConnectionId);
// Excluded connection should not get the message
Assert.Empty(excludedStream.GetOutputLines());
Assert.NotEmpty(bobStream.GetOutputLines());
}
// ── SendUserLeftAsync ────────────────────────────────────────────────
[Fact]
public async Task SendUserLeft_NotifiesOtherMembers()
{
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
AddConnectionWithCapture("alice", "general");
await _broadcaster.SendUserLeftAsync("general", "alice");
var output = bobStream.GetOutputLines();
Assert.Contains(output, l => l.Contains("PART #general") && l.Contains("alice"));
}
[Fact]
public async Task SendUserLeft_SkipsSender()
{
var (_, aliceStream) = AddConnectionWithCapture("alice", "general");
await _broadcaster.SendUserLeftAsync("general", "alice");
Assert.Empty(aliceStream.GetOutputLines());
}
// ── SendChannelUpdatedAsync ──────────────────────────────────────────
[Fact]
public async Task SendChannelUpdated_WithTopic_SendsTopicMessage()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
var channel = new ChannelDto(
Guid.NewGuid(), "general", "New topic!", true, 0, DateTimeOffset.UtcNow);
await _broadcaster.SendChannelUpdatedAsync(channel, "general");
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("TOPIC #general") && l.Contains("New topic!"));
}
[Fact]
public async Task SendChannelUpdated_NullTopic_DoesNotSend()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
var channel = new ChannelDto(
Guid.NewGuid(), "general", null, true, 0, DateTimeOffset.UtcNow);
await _broadcaster.SendChannelUpdatedAsync(channel, "general");
Assert.Empty(stream.GetOutputLines());
}
// ── SendErrorAsync ───────────────────────────────────────────────────
[Fact]
public async Task SendError_IrcConnection_SendsNotice()
{
var (conn, stream) = AddConnectionWithCapture("alice", "general");
await _broadcaster.SendErrorAsync(conn.ConnectionId, "Something went wrong");
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("Something went wrong"));
}
[Fact]
public async Task SendError_NonIrcConnection_DoesNothing()
{
// SignalR connection IDs don't start with "irc-"
await _broadcaster.SendErrorAsync("signalr-connection-123", "Error");
// No crash, no output — the method silently returns
}
// ── SendUserKickedAsync ──────────────────────────────────────────────
[Fact]
public async Task SendUserKicked_NotifiesChannel()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
await _broadcaster.SendUserKickedAsync("general", "alice", "Spam");
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("KICK #general alice") && l.Contains("Spam"));
}
// ── SendUserBannedAsync ──────────────────────────────────────────────
[Fact]
public async Task SendUserBanned_NotifiesBannedUser()
{
var (_, stream) = AddConnectionWithCapture("alice", "general");
await _broadcaster.SendUserBannedAsync("alice", "Repeated violations");
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("NOTICE") && l.Contains("banned"));
}
// ── SendMessageDeletedAsync ──────────────────────────────────────────
[Fact]
public async Task SendMessageDeleted_NotifiesChannel()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
var msgId = Guid.NewGuid();
await _broadcaster.SendMessageDeletedAsync("general", msgId);
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("deleted") && l.Contains(msgId.ToString()));
}
// ── SendChannelNukedAsync ────────────────────────────────────────────
[Fact]
public async Task SendChannelNuked_NotifiesChannel()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
await _broadcaster.SendChannelNukedAsync("general");
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("cleared"));
}
// ── ForceDisconnectUserAsync ─────────────────────────────────────────
[Fact]
public async Task ForceDisconnect_IrcConnection_SendsErrorAndCloses()
{
var (conn, stream) = AddConnectionWithCapture("alice", "general");
await _broadcaster.ForceDisconnectUserAsync([conn.ConnectionId], "Banned");
var output = stream.GetOutputLines();
Assert.Contains(output, l => l.Contains("ERROR") && l.Contains("Banned"));
}
[Fact]
public async Task ForceDisconnect_NonIrcConnection_Ignores()
{
// Should not throw when given SignalR connection IDs
await _broadcaster.ForceDisconnectUserAsync(["signalr-abc", "signalr-def"], "Banned");
}
// ── SendUserStatusChangedAsync ───────────────────────────────────────
[Fact]
public async Task SendUserStatusChanged_IsNoOp()
{
var (_, stream) = AddConnectionWithCapture("bob", "general");
var presence = new UserPresenceDto(
"alice", null, null, UserStatus.Away, "brb", ServerRole.Member);
await _broadcaster.SendUserStatusChangedAsync(["general"], presence);
// IRC doesn't push status changes — clients use WHOIS/WHO
Assert.Empty(stream.GetOutputLines());
}
}
@@ -0,0 +1,213 @@
using EchoHub.Server.Irc;
using Xunit;
namespace EchoHub.Tests.Irc;
public class IrcClientConnectionTests
{
// ── Connection identity ──────────────────────────────────────────────
[Fact]
public void ConnectionId_StartsWithIrcPrefix()
{
var (conn, _) = TestIrcConnectionFactory.Create();
Assert.StartsWith("irc-", conn.ConnectionId);
}
[Fact]
public void ConnectionId_IsUnique()
{
var (conn1, _) = TestIrcConnectionFactory.Create();
var (conn2, _) = TestIrcConnectionFactory.Create();
Assert.NotEqual(conn1.ConnectionId, conn2.ConnectionId);
}
// ── Hostmask ─────────────────────────────────────────────────────────
[Fact]
public void Hostmask_WithNicknameAndUsername_FormatsCorrectly()
{
var (conn, _) = TestIrcConnectionFactory.Create();
conn.Nickname = "alice";
conn.Username = "alice_user";
Assert.Equal("alice!alice_user@echohub", conn.Hostmask);
}
[Fact]
public void Hostmask_WithoutUsername_FallsBackToNickname()
{
var (conn, _) = TestIrcConnectionFactory.Create();
conn.Nickname = "alice";
Assert.Equal("alice!alice@echohub", conn.Hostmask);
}
// ── I/O ──────────────────────────────────────────────────────────────
[Fact]
public async Task ReadLineAsync_ReturnsInputLines()
{
var (conn, _) = TestIrcConnectionFactory.Create("PING", "PONG");
var line1 = await conn.ReadLineAsync(CancellationToken.None);
var line2 = await conn.ReadLineAsync(CancellationToken.None);
Assert.Equal("PING", line1);
Assert.Equal("PONG", line2);
}
[Fact]
public async Task ReadLineAsync_EndOfStream_ReturnsNull()
{
var (conn, _) = TestIrcConnectionFactory.Create("PING");
await conn.ReadLineAsync(CancellationToken.None); // consume "PING"
var result = await conn.ReadLineAsync(CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task SendAsync_WritesToOutput()
{
var (conn, stream) = TestIrcConnectionFactory.Create();
await conn.SendAsync(":server 001 alice :Welcome!");
var output = stream.GetOutputLines();
Assert.Single(output);
Assert.Equal(":server 001 alice :Welcome!", output[0]);
}
[Fact]
public async Task SendAsync_MultipleLines_AllCaptured()
{
var (conn, stream) = TestIrcConnectionFactory.Create();
await conn.SendAsync("line1");
await conn.SendAsync("line2");
await conn.SendAsync("line3");
var output = stream.GetOutputLines();
Assert.Equal(3, output.Count);
}
[Fact]
public async Task SendNumericAsync_FormatsCorrectly()
{
var (conn, stream) = TestIrcConnectionFactory.Create();
conn.Nickname = "alice";
await conn.SendNumericAsync("testserver", "001", ":Welcome!");
var output = stream.GetOutputLines();
Assert.Single(output);
Assert.Equal(":testserver 001 alice :Welcome!", output[0]);
}
[Fact]
public async Task SendNumericAsync_NoNickname_UsesStar()
{
var (conn, stream) = TestIrcConnectionFactory.Create();
await conn.SendNumericAsync("testserver", "451", ":Not registered");
var output = stream.GetOutputLines();
Assert.Contains("*", output[0]);
}
// ── Thread-safe channel operations ───────────────────────────────────
[Fact]
public void JoinChannel_AddsChannel()
{
var (conn, _) = TestIrcConnectionFactory.Create();
conn.JoinChannel("general");
Assert.True(conn.IsInChannel("general"));
}
[Fact]
public void LeaveChannel_RemovesChannel()
{
var (conn, _) = TestIrcConnectionFactory.Create();
conn.JoinChannel("general");
conn.LeaveChannel("general");
Assert.False(conn.IsInChannel("general"));
}
[Fact]
public void IsInChannel_CaseInsensitive()
{
var (conn, _) = TestIrcConnectionFactory.Create();
conn.JoinChannel("General");
Assert.True(conn.IsInChannel("general"));
Assert.True(conn.IsInChannel("GENERAL"));
}
[Fact]
public void GetJoinedChannels_ReturnsSnapshot()
{
var (conn, _) = TestIrcConnectionFactory.Create();
conn.JoinChannel("general");
conn.JoinChannel("random");
var channels = conn.GetJoinedChannels();
Assert.Equal(2, channels.Count);
Assert.Contains("general", channels);
Assert.Contains("random", channels);
// Modifying the returned list shouldn't affect the connection state
channels.Clear();
Assert.True(conn.IsInChannel("general"));
}
[Fact]
public async Task JoinedChannels_ConcurrentAccess_DoesNotThrow()
{
var (conn, _) = TestIrcConnectionFactory.Create();
// Simulate concurrent reads and writes (broadcaster reads while handler writes)
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var writerTask = Task.Run(async () =>
{
for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++)
{
conn.JoinChannel($"channel-{i}");
await Task.Yield();
if (i % 3 == 0) conn.LeaveChannel($"channel-{i}");
}
}, cts.Token);
var readerTask = Task.Run(async () =>
{
for (int i = 0; i < 1000 && !cts.IsCancellationRequested; i++)
{
_ = conn.IsInChannel($"channel-{i}");
_ = conn.GetJoinedChannels();
await Task.Yield();
}
}, cts.Token);
// Should complete without exceptions
await Task.WhenAll(writerTask, readerTask);
}
// ── Default state ────────────────────────────────────────────────────
[Fact]
public void NewConnection_IsNotRegistered()
{
var (conn, _) = TestIrcConnectionFactory.Create();
Assert.False(conn.IsRegistered);
Assert.False(conn.IsAuthenticated);
Assert.Null(conn.Nickname);
Assert.Null(conn.Username);
Assert.Null(conn.UserId);
Assert.Null(conn.AwayMessage);
}
}
@@ -0,0 +1,621 @@
using System.Text;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Irc;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace EchoHub.Tests.Irc;
public class IrcCommandHandlerTests
{
private readonly IrcOptions _options = new() { ServerName = "testserver", Motd = null };
private readonly FakeChatService _chatService = new();
private readonly FakeChannelService _channelService = new();
private readonly FakeEncryptionService _encryption = new();
private IrcCommandHandler CreateHandler(IrcClientConnection conn) =>
new(conn, _options, _chatService, _channelService, _encryption, NullLogger.Instance);
private async Task<List<string>> RunAndCapture(string[] inputLines,
Action<IrcClientConnection>? setup = null)
{
var (conn, stream) = TestIrcConnectionFactory.Create(inputLines);
setup?.Invoke(conn);
var handler = CreateHandler(conn);
await handler.RunAsync(CancellationToken.None);
return stream.GetOutputLines();
}
private async Task<List<string>> RunAuthenticated(string[] inputLines,
string nickname = "alice", Guid? userId = null,
Action<IrcClientConnection>? setup = null)
{
var (conn, stream) = TestIrcConnectionFactory.CreateAuthenticated(nickname, userId, inputLines);
setup?.Invoke(conn);
var handler = CreateHandler(conn);
await handler.RunAsync(CancellationToken.None);
return stream.GetOutputLines();
}
// ── PING / PONG ──────────────────────────────────────────────────────
[Fact]
public async Task Ping_RespondsWithPong()
{
var lines = await RunAuthenticated(["PING :mytoken"]);
Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("mytoken"));
}
[Fact]
public async Task Ping_NoToken_UsesServerName()
{
var lines = await RunAuthenticated(["PING"]);
Assert.Contains(lines, l => l.Contains("PONG") && l.Contains("testserver"));
}
// ── Unregistered commands ────────────────────────────────────────────
[Fact]
public async Task UnregisteredUser_ChannelCommand_GetsNotRegisteredError()
{
var lines = await RunAndCapture(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("451") && l.Contains("not registered"));
}
[Fact]
public async Task UnregisteredUser_PrivmsgCommand_GetsNotRegisteredError()
{
var lines = await RunAndCapture(["PRIVMSG #general :hello"]);
Assert.Contains(lines, l => l.Contains("451"));
}
// ── PASS / NICK / USER registration ──────────────────────────────────
[Fact]
public async Task PassNickUser_ValidCredentials_Registers()
{
var userId = Guid.NewGuid();
_chatService.AuthResult = (userId, "alice");
var lines = await RunAndCapture([
"PASS secret123",
"NICK alice",
"USER alice 0 * :Alice Smith"
]);
// Should get welcome burst (001)
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
Assert.Contains("alice", _chatService.ConnectedUsers);
}
[Fact]
public async Task NickUser_NoPassword_GetsPasswordError()
{
var lines = await RunAndCapture([
"NICK alice",
"USER alice 0 * :Alice Smith"
]);
Assert.Contains(lines, l => l.Contains("464") && l.Contains("Password required"));
}
[Fact]
public async Task PassNickUser_WrongPassword_GetsAuthError()
{
_chatService.AuthResult = null;
var lines = await RunAndCapture([
"PASS wrongpassword",
"NICK alice",
"USER alice 0 * :Alice Smith"
]);
Assert.Contains(lines, l => l.Contains("464") && l.Contains("incorrect"));
}
[Fact]
public async Task Nick_InvalidNickname_GetsError()
{
var lines = await RunAndCapture(["NICK a"]); // too short
Assert.Contains(lines, l => l.Contains("432") && l.Contains("Erroneous nickname"));
}
[Fact]
public async Task Nick_NoParam_GetsNoNicknameError()
{
var lines = await RunAndCapture(["NICK"]);
Assert.Contains(lines, l => l.Contains("431") && l.Contains("No nickname given"));
}
[Fact]
public async Task User_AlreadyRegistered_GetsError()
{
var lines = await RunAuthenticated([
"USER alice 0 * :Alice"
]);
Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister"));
}
[Fact]
public async Task Pass_AlreadyRegistered_GetsError()
{
var lines = await RunAuthenticated([
"PASS newpassword"
]);
Assert.Contains(lines, l => l.Contains("462") && l.Contains("reregister"));
}
// ── CAP / SASL ──────────────────────────────────────────────────────
[Fact]
public async Task CapLs_AdvertisesSasl()
{
var lines = await RunAndCapture(["CAP LS"]);
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
}
[Fact]
public async Task CapReqSasl_Acknowledged()
{
var lines = await RunAndCapture(["CAP REQ :sasl"]);
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
}
[Fact]
public async Task CapReqUnknown_GetsNak()
{
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
Assert.Contains(lines, l => l.Contains("NAK"));
}
[Fact]
public async Task SaslPlain_ValidCredentials_Authenticates()
{
var userId = Guid.NewGuid();
_chatService.AuthResult = (userId, "alice");
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0password123"));
var lines = await RunAndCapture([
"CAP LS",
"CAP REQ :sasl",
$"AUTHENTICATE PLAIN",
$"AUTHENTICATE {saslPayload}",
"NICK alice",
"USER alice 0 * :Alice",
"CAP END"
]);
Assert.Contains(lines, l => l.Contains("903") && l.Contains("SASL authentication successful"));
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
}
[Fact]
public async Task SaslPlain_InvalidCredentials_GetsError()
{
_chatService.AuthResult = null;
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("\0alice\0wrongpwd"));
var lines = await RunAndCapture([
"CAP LS",
"CAP REQ :sasl",
"AUTHENTICATE PLAIN",
$"AUTHENTICATE {saslPayload}",
]);
Assert.Contains(lines, l => l.Contains("904") && l.Contains("SASL authentication failed"));
}
[Fact]
public async Task SaslPlain_MalformedPayload_GetsError()
{
var saslPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes("malformed"));
var lines = await RunAndCapture([
"CAP REQ :sasl",
"AUTHENTICATE PLAIN",
$"AUTHENTICATE {saslPayload}",
]);
Assert.Contains(lines, l => l.Contains("904"));
}
// ── JOIN ─────────────────────────────────────────────────────────────
[Fact]
public async Task Join_ValidChannel_ConfirmsJoin()
{
_channelService.TopicResult = ("Welcome!", true);
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("JOIN #general"));
Assert.Single(_chatService.JoinedChannels);
Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
}
[Fact]
public async Task Join_SendsTopic()
{
_channelService.TopicResult = ("Welcome to general!", true);
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("332") && l.Contains("Welcome to general!"));
}
[Fact]
public async Task Join_NoTopic_SendsNoTopicReply()
{
_channelService.TopicResult = (null, true);
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("331") && l.Contains("No topic is set"));
}
[Fact]
public async Task Join_SendsNamesReply()
{
_chatService.OnlineUsersToReturn =
[
new("alice", null, null, UserStatus.Online, null, ServerRole.Member),
new("bob", null, null, UserStatus.Online, null, ServerRole.Member),
];
var lines = await RunAuthenticated(["JOIN #general"]);
Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob"));
Assert.Contains(lines, l => l.Contains("366") && l.Contains("End of /NAMES"));
}
[Fact]
public async Task Join_DecryptsHistoryForIrc()
{
// Simulate encrypted history (as ChatService returns it)
var encryptedContent = _encryption.Encrypt("Hello from history!");
_chatService.HistoryToReturn =
[
new(Guid.NewGuid(), encryptedContent, "bob", null, "general",
MessageType.Text, null, null, DateTimeOffset.UtcNow)
];
var lines = await RunAuthenticated(["JOIN #general"]);
// Should contain the DECRYPTED text, not the encrypted version
Assert.Contains(lines, l => l.Contains("Hello from history!"));
Assert.DoesNotContain(lines, l => l.Contains("$ENC$Hello from history!"));
}
[Fact]
public async Task Join_NonexistentChannel_GetsError()
{
_chatService.JoinError = "Channel 'nope' does not exist.";
var lines = await RunAuthenticated(["JOIN #nope"]);
Assert.Contains(lines, l => l.Contains("403") && l.Contains("does not exist"));
}
[Fact]
public async Task Join_InvalidChannelName_GetsError()
{
var lines = await RunAuthenticated(["JOIN invalid"]);
Assert.Contains(lines, l => l.Contains("403") && l.Contains("Invalid channel name"));
}
[Fact]
public async Task Join_MultipleChannels_JoinsAll()
{
var lines = await RunAuthenticated(["JOIN #general,#random"]);
Assert.Equal(2, _chatService.JoinedChannels.Count);
Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "general");
Assert.Contains(_chatService.JoinedChannels, j => j.Channel == "random");
}
[Fact]
public async Task Join_NoParams_GetsNeedMoreParamsError()
{
var lines = await RunAuthenticated(["JOIN"]);
Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters"));
}
// ── PART ─────────────────────────────────────────────────────────────
[Fact]
public async Task Part_ValidChannel_ConfirmsPart()
{
var lines = await RunAuthenticated(["PART #general"]);
Assert.Contains(lines, l => l.Contains("PART #general"));
Assert.Single(_chatService.LeftChannels);
Assert.Equal("general", _chatService.LeftChannels[0].Channel);
}
[Fact]
public async Task Part_WithReason_IncludesReason()
{
var lines = await RunAuthenticated(["PART #general :Leaving for now"]);
Assert.Contains(lines, l => l.Contains("PART #general") && l.Contains("Leaving for now"));
}
// ── PRIVMSG ──────────────────────────────────────────────────────────
[Fact]
public async Task Privmsg_ChannelMessage_SendsViaService()
{
var lines = await RunAuthenticated(["PRIVMSG #general :Hello everyone!"]);
Assert.Single(_chatService.SentMessages);
Assert.Equal("general", _chatService.SentMessages[0].Channel);
Assert.Equal("Hello everyone!", _chatService.SentMessages[0].Content);
}
[Fact]
public async Task Privmsg_PrivateMessage_GetsError()
{
var lines = await RunAuthenticated(["PRIVMSG bob :Hey bob"]);
Assert.Contains(lines, l => l.Contains("401") && l.Contains("Private messages are not supported"));
Assert.Empty(_chatService.SentMessages);
}
[Fact]
public async Task Privmsg_ServiceError_ReturnsError()
{
_chatService.SendMessageError = "You are muted.";
var lines = await RunAuthenticated(["PRIVMSG #general :Hello"]);
Assert.Contains(lines, l => l.Contains("404") && l.Contains("muted"));
}
[Fact]
public async Task Privmsg_NoParams_GetsNeedMoreParamsError()
{
var lines = await RunAuthenticated(["PRIVMSG"]);
Assert.Contains(lines, l => l.Contains("461") && l.Contains("Not enough parameters"));
}
// ── QUIT ─────────────────────────────────────────────────────────────
[Fact]
public async Task Quit_WithMessage_SendsClosingLink()
{
var lines = await RunAuthenticated(["QUIT :Goodbye!"]);
Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Goodbye!"));
}
[Fact]
public async Task Quit_NoMessage_UsesDefault()
{
var lines = await RunAuthenticated(["QUIT"]);
Assert.Contains(lines, l => l.Contains("ERROR") && l.Contains("Client quit"));
}
// ── NAMES ────────────────────────────────────────────────────────────
[Fact]
public async Task Names_ReturnsUserList()
{
_chatService.OnlineUsersToReturn =
[
new("alice", null, null, UserStatus.Online, null, ServerRole.Member),
new("bob", "Bob", null, UserStatus.Away, null, ServerRole.Mod),
];
var lines = await RunAuthenticated(["NAMES #general"]);
Assert.Contains(lines, l => l.Contains("353") && l.Contains("alice") && l.Contains("bob"));
Assert.Contains(lines, l => l.Contains("366"));
}
// ── TOPIC ────────────────────────────────────────────────────────────
[Fact]
public async Task Topic_Query_ReturnsTopic()
{
_channelService.TopicResult = ("Chat about everything", true);
var lines = await RunAuthenticated(["TOPIC #general"]);
Assert.Contains(lines, l => l.Contains("332") && l.Contains("Chat about everything"));
}
[Fact]
public async Task Topic_SetAttempt_GetsPermissionDenied()
{
var lines = await RunAuthenticated(["TOPIC #general :New topic"]);
Assert.Contains(lines, l => l.Contains("482") && l.Contains("channel creator"));
}
// ── WHO ──────────────────────────────────────────────────────────────
[Fact]
public async Task Who_ReturnsUserListWithAwayFlags()
{
_chatService.OnlineUsersToReturn =
[
new("alice", "Alice", null, UserStatus.Online, null, ServerRole.Member),
new("bob", "Bob", null, UserStatus.Away, "brb", ServerRole.Member),
];
var lines = await RunAuthenticated(["WHO #general"]);
Assert.Contains(lines, l => l.Contains("352") && l.Contains("alice") && l.Contains("H")); // Here
Assert.Contains(lines, l => l.Contains("352") && l.Contains("bob") && l.Contains("G")); // Gone
Assert.Contains(lines, l => l.Contains("315") && l.Contains("End of WHO"));
}
// ── WHOIS ────────────────────────────────────────────────────────────
[Fact]
public async Task Whois_ExistingUser_ReturnsInfo()
{
_chatService.ProfileToReturn = new UserProfileDto(
Guid.NewGuid(), "bob", "Bob S.", "Hello!", null, null,
UserStatus.Online, null, ServerRole.Member,
DateTimeOffset.UtcNow.AddDays(-30), DateTimeOffset.UtcNow);
_chatService.ChannelsForUserToReturn = ["general", "random"];
var lines = await RunAuthenticated(["WHOIS bob"]);
Assert.Contains(lines, l => l.Contains("311") && l.Contains("bob") && l.Contains("Bob S."));
Assert.Contains(lines, l => l.Contains("312") && l.Contains("testserver"));
Assert.Contains(lines, l => l.Contains("319") && l.Contains("#general") && l.Contains("#random"));
Assert.Contains(lines, l => l.Contains("317")); // idle
Assert.Contains(lines, l => l.Contains("318") && l.Contains("End of WHOIS"));
}
[Fact]
public async Task Whois_NonexistentUser_GetsNoSuchNickError()
{
_chatService.ProfileToReturn = null;
var lines = await RunAuthenticated(["WHOIS ghost"]);
Assert.Contains(lines, l => l.Contains("401") && l.Contains("No such nick"));
}
[Fact]
public async Task Whois_AwayUser_ShowsAwayMessage()
{
_chatService.ProfileToReturn = new UserProfileDto(
Guid.NewGuid(), "bob", null, null, null, null,
UserStatus.Away, "Gone fishing", ServerRole.Member,
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow);
_chatService.ChannelsForUserToReturn = [];
var lines = await RunAuthenticated(["WHOIS bob"]);
Assert.Contains(lines, l => l.Contains("301") && l.Contains("Gone fishing"));
}
// ── AWAY ─────────────────────────────────────────────────────────────
[Fact]
public async Task Away_WithMessage_SetsAway()
{
var lines = await RunAuthenticated(["AWAY :Be right back"]);
Assert.Contains(lines, l => l.Contains("306") && l.Contains("marked as being away"));
Assert.Single(_chatService.StatusUpdates);
Assert.Equal(UserStatus.Away, _chatService.StatusUpdates[0].Status);
}
[Fact]
public async Task Away_NoMessage_ClearsAway()
{
var lines = await RunAuthenticated(["AWAY"]);
Assert.Contains(lines, l => l.Contains("305") && l.Contains("no longer marked"));
Assert.Single(_chatService.StatusUpdates);
Assert.Equal(UserStatus.Online, _chatService.StatusUpdates[0].Status);
}
// ── LIST ─────────────────────────────────────────────────────────────
[Fact]
public async Task List_ReturnsChannels()
{
_channelService.ChannelListToReturn =
[
new("general", "General chat", 5),
new("random", null, 2),
];
var lines = await RunAuthenticated(["LIST"]);
Assert.Contains(lines, l => l.Contains("322") && l.Contains("#general") && l.Contains("General chat"));
Assert.Contains(lines, l => l.Contains("322") && l.Contains("#random"));
Assert.Contains(lines, l => l.Contains("323") && l.Contains("End of LIST"));
}
// ── MODE ─────────────────────────────────────────────────────────────
[Fact]
public async Task Mode_Channel_ReturnsChannelModes()
{
var lines = await RunAuthenticated(["MODE #general"]);
Assert.Contains(lines, l => l.Contains("324") && l.Contains("#general"));
}
[Fact]
public async Task Mode_User_ReturnsUserModes()
{
var lines = await RunAuthenticated(["MODE alice"]);
Assert.Contains(lines, l => l.Contains("221"));
}
// ── MOTD ─────────────────────────────────────────────────────────────
[Fact]
public async Task Motd_NoMotdConfigured_GetsNoMotdError()
{
var lines = await RunAuthenticated(["MOTD"]);
Assert.Contains(lines, l => l.Contains("422") && l.Contains("MOTD File is missing"));
}
[Fact]
public async Task Motd_WithMotd_DisplaysMotd()
{
_options.Motd = "Welcome to EchoHub!\nEnjoy your stay.";
var lines = await RunAuthenticated(["MOTD"]);
Assert.Contains(lines, l => l.Contains("375")); // MOTDSTART
Assert.Contains(lines, l => l.Contains("372") && l.Contains("Welcome to EchoHub!"));
Assert.Contains(lines, l => l.Contains("372") && l.Contains("Enjoy your stay."));
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
}
// ── Unknown command ──────────────────────────────────────────────────
[Fact]
public async Task UnknownCommand_GetsError()
{
var lines = await RunAuthenticated(["FOOBAR"]);
Assert.Contains(lines, l => l.Contains("421") && l.Contains("FOOBAR") && l.Contains("Unknown command"));
}
// ── Channel name conversion ──────────────────────────────────────────
[Fact]
public async Task Join_ChannelNameNormalized_ToLowerCase()
{
var lines = await RunAuthenticated(["JOIN #General"]);
Assert.Single(_chatService.JoinedChannels);
Assert.Equal("general", _chatService.JoinedChannels[0].Channel);
}
}
@@ -0,0 +1,294 @@
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Irc;
using Xunit;
namespace EchoHub.Tests.Irc;
public class IrcMessageFormatterTests
{
private static MessageDto CreateTextMessage(string content, string sender = "alice",
string channel = "general", List<EmbedDto>? embeds = null)
{
return new MessageDto(
Guid.NewGuid(), content, sender, null, channel,
MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds);
}
private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",
string url = "https://example.com/image.png", string sender = "alice", string channel = "general")
{
return new MessageDto(
Guid.NewGuid(), asciiArt, sender, null, channel,
MessageType.Image, url, fileName, DateTimeOffset.UtcNow);
}
private static MessageDto CreateFileMessage(string fileName = "doc.pdf",
string url = "https://example.com/doc.pdf", string sender = "alice", string channel = "general")
{
return new MessageDto(
Guid.NewGuid(), "", sender, null, channel,
MessageType.File, url, fileName, DateTimeOffset.UtcNow);
}
private static MessageDto CreateAudioMessage(string fileName = "song.mp3",
string url = "https://example.com/song.mp3", string sender = "alice", string channel = "general")
{
return new MessageDto(
Guid.NewGuid(), "", sender, null, channel,
MessageType.Audio, url, fileName, DateTimeOffset.UtcNow);
}
// ── FormatMessage ────────────────────────────────────────────────────
[Fact]
public void FormatMessage_TextMessage_FormatsAsPrivmsg()
{
var msg = CreateTextMessage("Hello world");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Equal(":alice!alice@echohub PRIVMSG #general :Hello world", lines[0]);
}
[Fact]
public void FormatMessage_TextMessage_IncludesChannelHash()
{
var msg = CreateTextMessage("test", channel: "random");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains("#random", lines[0]);
}
[Fact]
public void FormatMessage_TextWithEmbeds_AppendsEmbedLines()
{
var embeds = new List<EmbedDto>
{
new("Example Site", "Page Title", "A description of the page", null, "https://example.com")
};
var msg = CreateTextMessage("Check this: https://example.com", embeds: embeds);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.True(lines.Count >= 2);
Assert.Contains("Check this: https://example.com", lines[0]);
// Embed header
Assert.Contains("Example Site", lines[1]);
Assert.Contains("Page Title", lines[1]);
}
[Fact]
public void FormatMessage_EmbedWithDescription_IncludesDescription()
{
var embeds = new List<EmbedDto>
{
new("Site", "Title", "This is a description", null, "https://example.com")
};
var msg = CreateTextMessage("url", embeds: embeds);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.True(lines.Count >= 3);
Assert.Contains("This is a description", lines[2]);
}
[Fact]
public void FormatMessage_EmbedWithLongDescription_Truncates()
{
var longDesc = new string('x', 300);
var embeds = new List<EmbedDto>
{
new("Site", "Title", longDesc, null, "https://example.com")
};
var msg = CreateTextMessage("url", embeds: embeds);
var lines = IrcMessageFormatter.FormatMessage(msg);
var descLine = lines.First(l => l.Contains("xxx"));
Assert.Contains("...", descLine);
// Should be truncated to ~200 chars
var descContent = descLine[(descLine.LastIndexOf(':') + 2)..]; // after ":│ "
Assert.True(descContent.Length <= 210);
}
[Fact]
public void FormatMessage_ImageMessage_IncludesFileNameAndUrl()
{
var msg = CreateImageMessage("##\n##", "photo.jpg", "https://example.com/photo.jpg");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains(lines, l => l.Contains("[Image: photo.jpg]"));
Assert.Contains(lines, l => l.Contains("Download: https://example.com/photo.jpg"));
}
[Fact]
public void FormatMessage_ImageMessage_IncludesAsciiArt()
{
var msg = CreateImageMessage("line1\nline2");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains(lines, l => l.Contains("line1"));
Assert.Contains(lines, l => l.Contains("line2"));
}
[Fact]
public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines()
{
var msg = CreateImageMessage("line1\n\nline2");
var lines = IrcMessageFormatter.FormatMessage(msg);
// Empty lines should be skipped
var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList();
Assert.Equal(2, asciiLines.Count);
}
[Fact]
public void FormatMessage_FileMessage_FormatsCorrectly()
{
var msg = CreateFileMessage("report.pdf", "https://example.com/report.pdf");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("[File: report.pdf]", lines[0]);
Assert.Contains("https://example.com/report.pdf", lines[0]);
}
[Fact]
public void FormatMessage_AudioMessage_FormatsWithMusicNote()
{
var msg = CreateAudioMessage("track.mp3", "https://example.com/track.mp3");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("\u266a", lines[0]); // ♪
Assert.Contains("[Audio: track.mp3]", lines[0]);
Assert.Contains("https://example.com/track.mp3", lines[0]);
}
// ── SplitMessage ─────────────────────────────────────────────────────
[Fact]
public void SplitMessage_ShortMessage_ReturnsSingleChunk()
{
var chunks = IrcMessageFormatter.SplitMessage("Hello", 400);
Assert.Single(chunks);
Assert.Equal("Hello", chunks[0]);
}
[Fact]
public void SplitMessage_ExactlyAtLimit_ReturnsSingleChunk()
{
var msg = new string('a', 400);
var chunks = IrcMessageFormatter.SplitMessage(msg, 400);
Assert.Single(chunks);
}
[Fact]
public void SplitMessage_LongMessage_SplitsAtWordBoundary()
{
// Create a message that's longer than 50 bytes
var words = string.Join(" ", Enumerable.Repeat("hello", 20)); // 20 * 6 - 1 = 119 bytes
var chunks = IrcMessageFormatter.SplitMessage(words, 50);
Assert.True(chunks.Count > 1);
// Each chunk should be roughly <= 50 bytes
foreach (var chunk in chunks)
{
Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 55,
$"Chunk too long: {chunk.Length} chars");
}
// Reassembled content should match original
var reassembled = string.Join(" ", chunks);
Assert.Equal(words, reassembled);
}
[Fact]
public void SplitMessage_SingleLongWord_ForcedIntoOneChunk()
{
var longWord = new string('a', 500);
var chunks = IrcMessageFormatter.SplitMessage(longWord, 400);
// A single word can't be split at word boundaries, so it stays as one chunk
Assert.Single(chunks);
Assert.Equal(longWord, chunks[0]);
}
[Fact]
public void SplitMessage_EmptyString_ReturnsSingleEmpty()
{
var chunks = IrcMessageFormatter.SplitMessage("", 400);
Assert.Single(chunks);
Assert.Equal("", chunks[0]);
}
[Fact]
public void SplitMessage_UnicodeContent_CountsUtf8Bytes()
{
// Japanese text: each char is 3 bytes in UTF-8
var text = string.Join(" ", Enumerable.Repeat("\u3042\u3044\u3046", 50));
var chunks = IrcMessageFormatter.SplitMessage(text, 100);
Assert.True(chunks.Count > 1);
foreach (var chunk in chunks)
{
Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 110,
$"Chunk too long in bytes: {System.Text.Encoding.UTF8.GetByteCount(chunk)}");
}
}
// ── ColorTagsToAnsi ──────────────────────────────────────────────────
[Fact]
public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
{
Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world"));
}
[Fact]
public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text");
Assert.Equal("\x1b[38;2;255;0;0mRed text", result);
}
[Fact]
public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg");
Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result);
}
[Fact]
public void ColorTagsToAnsi_ResetTag_ConvertsToReset()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal");
Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", result);
}
[Fact]
public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red {F:0000FF}Blue{X}");
Assert.Contains("\x1b[38;2;255;0;0m", result);
Assert.Contains("\x1b[38;2;0;0;255m", result);
Assert.Contains("\x1b[0m", result);
}
[Fact]
public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text");
Assert.Equal("\x1b[38;2;255;136;0mtext", result);
}
[Fact]
public void ColorTagsToAnsi_NoBraces_SkipsProcessing()
{
var text = "plain text without braces";
Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
}
[Fact]
public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged()
{
var text = "\x1b[31mAlready colored\x1b[0m";
Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
}
}
+183
View File
@@ -0,0 +1,183 @@
using EchoHub.Server.Irc;
using Xunit;
namespace EchoHub.Tests.Irc;
public class IrcMessageTests
{
[Fact]
public void Parse_SimpleCommand_ExtractsCommand()
{
var msg = IrcMessage.Parse("PING");
Assert.Equal("PING", msg.Command);
Assert.Null(msg.Prefix);
Assert.Empty(msg.Parameters);
}
[Fact]
public void Parse_CommandWithOneParam_ExtractsParam()
{
var msg = IrcMessage.Parse("NICK alice");
Assert.Equal("NICK", msg.Command);
Assert.Single(msg.Parameters);
Assert.Equal("alice", msg.Parameters[0]);
}
[Fact]
public void Parse_CommandWithTrailing_ExtractsTrailingAsLastParam()
{
var msg = IrcMessage.Parse("PRIVMSG #general :Hello world!");
Assert.Equal("PRIVMSG", msg.Command);
Assert.Equal(2, msg.Parameters.Count);
Assert.Equal("#general", msg.Parameters[0]);
Assert.Equal("Hello world!", msg.Parameters[1]);
Assert.Equal("Hello world!", msg.Trailing);
}
[Fact]
public void Parse_MessageWithPrefix_ExtractsPrefix()
{
var msg = IrcMessage.Parse(":alice!alice@echohub PRIVMSG #general :hi");
Assert.Equal("alice!alice@echohub", msg.Prefix);
Assert.Equal("PRIVMSG", msg.Command);
Assert.Equal(2, msg.Parameters.Count);
Assert.Equal("#general", msg.Parameters[0]);
Assert.Equal("hi", msg.Parameters[1]);
}
[Fact]
public void Parse_MultipleParams_ExtractsAll()
{
var msg = IrcMessage.Parse("USER alice 0 * :Alice Smith");
Assert.Equal("USER", msg.Command);
Assert.Equal(4, msg.Parameters.Count);
Assert.Equal("alice", msg.Parameters[0]);
Assert.Equal("0", msg.Parameters[1]);
Assert.Equal("*", msg.Parameters[2]);
Assert.Equal("Alice Smith", msg.Parameters[3]);
}
[Fact]
public void Parse_PingWithToken_ExtractsToken()
{
var msg = IrcMessage.Parse("PING :server.example.com");
Assert.Equal("PING", msg.Command);
Assert.Single(msg.Parameters);
Assert.Equal("server.example.com", msg.Parameters[0]);
}
[Fact]
public void Parse_CapLs_ParsesSubcommand()
{
var msg = IrcMessage.Parse("CAP LS 302");
Assert.Equal("CAP", msg.Command);
Assert.Equal(2, msg.Parameters.Count);
Assert.Equal("LS", msg.Parameters[0]);
Assert.Equal("302", msg.Parameters[1]);
}
[Fact]
public void Parse_CapReqWithTrailing_ParsesSasl()
{
var msg = IrcMessage.Parse("CAP REQ :sasl");
Assert.Equal("CAP", msg.Command);
Assert.Equal(2, msg.Parameters.Count);
Assert.Equal("REQ", msg.Parameters[0]);
Assert.Equal("sasl", msg.Parameters[1]);
}
[Fact]
public void Parse_JoinMultipleChannels_ExtractsCsv()
{
var msg = IrcMessage.Parse("JOIN #general,#random");
Assert.Equal("JOIN", msg.Command);
Assert.Single(msg.Parameters);
Assert.Equal("#general,#random", msg.Parameters[0]);
}
[Fact]
public void Parse_PartWithReason_ExtractsReason()
{
var msg = IrcMessage.Parse("PART #general :Leaving for now");
Assert.Equal("PART", msg.Command);
Assert.Equal(2, msg.Parameters.Count);
Assert.Equal("#general", msg.Parameters[0]);
Assert.Equal("Leaving for now", msg.Parameters[1]);
}
[Fact]
public void Parse_EmptyTrailing_ExtractsEmptyString()
{
var msg = IrcMessage.Parse("PRIVMSG #general :");
Assert.Equal("PRIVMSG", msg.Command);
Assert.Equal(2, msg.Parameters.Count);
Assert.Equal("#general", msg.Parameters[0]);
Assert.Equal("", msg.Parameters[1]);
}
[Fact]
public void Parse_TrailingWithColons_PreservesColons()
{
var msg = IrcMessage.Parse("PRIVMSG #general :time is 12:30:00");
Assert.Equal("time is 12:30:00", msg.Parameters[1]);
}
[Fact]
public void Parse_CrLfTrimmed()
{
var msg = IrcMessage.Parse("PING\r\n");
Assert.Equal("PING", msg.Command);
Assert.Empty(msg.Parameters);
}
[Fact]
public void Parse_ExtraSpaces_Handled()
{
var msg = IrcMessage.Parse("NICK alice");
Assert.Equal("NICK", msg.Command);
Assert.Single(msg.Parameters);
Assert.Equal("alice", msg.Parameters[0]);
}
[Fact]
public void Parse_Authenticate_Base64Payload()
{
var payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("\0alice\0secret"));
var msg = IrcMessage.Parse($"AUTHENTICATE {payload}");
Assert.Equal("AUTHENTICATE", msg.Command);
Assert.Single(msg.Parameters);
Assert.Equal(payload, msg.Parameters[0]);
}
[Fact]
public void Parse_PassCommand_ExtractsPassword()
{
var msg = IrcMessage.Parse("PASS mysecretpassword");
Assert.Equal("PASS", msg.Command);
Assert.Single(msg.Parameters);
Assert.Equal("mysecretpassword", msg.Parameters[0]);
}
[Fact]
public void Parse_QuitWithMessage_ExtractsMessage()
{
var msg = IrcMessage.Parse("QUIT :Goodbye!");
Assert.Equal("QUIT", msg.Command);
Assert.Single(msg.Parameters);
Assert.Equal("Goodbye!", msg.Parameters[0]);
}
[Fact]
public void Trailing_NoParams_ReturnsNull()
{
var msg = IrcMessage.Parse("PING");
Assert.Null(msg.Trailing);
}
[Fact]
public void Trailing_WithParams_ReturnsLastParam()
{
var msg = IrcMessage.Parse("MODE #channel +o alice");
Assert.Equal("alice", msg.Trailing);
}
}
+260
View File
@@ -0,0 +1,260 @@
using System.Net.Sockets;
using System.Text;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Irc;
namespace EchoHub.Tests.Irc;
/// <summary>
/// A duplex stream that reads from one buffer and writes to another,
/// allowing test code to inject input and capture output.
/// </summary>
internal sealed class TestDuplexStream : Stream
{
private readonly MemoryStream _readBuffer;
private readonly MemoryStream _writeBuffer = new();
public TestDuplexStream(string input = "")
{
_readBuffer = new MemoryStream(Encoding.UTF8.GetBytes(input));
}
public string GetOutput()
{
var raw = Encoding.UTF8.GetString(_writeBuffer.ToArray());
// Strip UTF-8 BOM emitted by StreamWriter
return raw.TrimStart('\uFEFF');
}
public List<string> GetOutputLines() =>
GetOutput().Split("\r\n", StringSplitOptions.RemoveEmptyEntries).ToList();
// Read from the input buffer
public override int Read(byte[] buffer, int offset, int count) =>
_readBuffer.Read(buffer, offset, count);
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
_readBuffer.ReadAsync(buffer, offset, count, ct);
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken ct = default) =>
_readBuffer.ReadAsync(buffer, ct);
// Write to the output buffer
public override void Write(byte[] buffer, int offset, int count) =>
_writeBuffer.Write(buffer, offset, count);
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) =>
_writeBuffer.WriteAsync(buffer, offset, count, ct);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken ct = default) =>
_writeBuffer.WriteAsync(buffer, ct);
public override void Flush() => _writeBuffer.Flush();
public override Task FlushAsync(CancellationToken ct) => _writeBuffer.FlushAsync(ct);
public override bool CanRead => true;
public override bool CanWrite => true;
public override bool CanSeek => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing)
{
_readBuffer.Dispose();
_writeBuffer.Dispose();
}
base.Dispose(disposing);
}
}
/// <summary>
/// Creates IrcClientConnections backed by test streams for unit testing.
/// </summary>
internal static class TestIrcConnectionFactory
{
/// <summary>
/// Creates a test IRC connection with the given input lines.
/// Returns the connection and the test stream (for inspecting output).
/// </summary>
public static (IrcClientConnection Connection, TestDuplexStream Stream) Create(params string[] inputLines)
{
var input = string.Join("\r\n", inputLines);
if (inputLines.Length > 0) input += "\r\n";
var stream = new TestDuplexStream(input);
var tcpClient = new TcpClient();
var conn = new IrcClientConnection(tcpClient, stream);
return (conn, stream);
}
/// <summary>
/// Creates a pre-authenticated, registered IRC connection.
/// </summary>
public static (IrcClientConnection Connection, TestDuplexStream Stream) CreateAuthenticated(
string nickname = "alice", Guid? userId = null, params string[] inputLines)
{
var (conn, stream) = Create(inputLines);
conn.Nickname = nickname;
conn.Username = nickname;
conn.UserId = userId ?? Guid.NewGuid();
conn.IsRegistered = true;
conn.IsAuthenticated = true;
return (conn, stream);
}
}
/// <summary>
/// Fake encryption service that uses a simple reversible prefix-based scheme.
/// Encrypt("hello") → "$ENC$hello", Decrypt("$ENC$hello") → "hello".
/// </summary>
internal sealed class FakeEncryptionService : IMessageEncryptionService
{
private const string Prefix = "$ENC$";
public bool EncryptDatabaseEnabled => true;
public string Encrypt(string plaintext) => $"{Prefix}{plaintext}";
public string Decrypt(string content)
{
if (content.StartsWith(Prefix))
return content[Prefix.Length..];
return content;
}
public string? EncryptNullable(string? value) =>
value is not null ? Encrypt(value) : null;
public string? DecryptNullable(string? value) =>
value is not null ? Decrypt(value) : null;
}
/// <summary>
/// Fake chat service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChatService : IChatService
{
// Recorded calls
public List<string> ConnectedUsers { get; } = [];
public List<string> DisconnectedConnections { get; } = [];
public List<(string Channel, string Username)> JoinedChannels { get; } = [];
public List<(string Channel, string Username)> LeftChannels { get; } = [];
public List<(string Channel, string Content)> SentMessages { get; } = [];
public List<(string Username, UserStatus Status)> StatusUpdates { get; } = [];
// Configurable results
public List<MessageDto> HistoryToReturn { get; set; } = [];
public string? JoinError { get; set; }
public string? SendMessageError { get; set; }
public (Guid UserId, string Username)? AuthResult { get; set; }
public UserProfileDto? ProfileToReturn { get; set; }
public List<string> ChannelsForUserToReturn { get; set; } = [];
public List<UserPresenceDto> OnlineUsersToReturn { get; set; } = [];
public Task UserConnectedAsync(string connectionId, Guid userId, string username)
{
ConnectedUsers.Add(username);
return Task.CompletedTask;
}
public Task<string?> UserDisconnectedAsync(string connectionId)
{
DisconnectedConnections.Add(connectionId);
return Task.FromResult<string?>(null);
}
public Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName)
{
JoinedChannels.Add((channelName, username));
return Task.FromResult((HistoryToReturn, JoinError));
}
public Task LeaveChannelAsync(string connectionId, string username, string channelName)
{
LeftChannels.Add((channelName, username));
return Task.CompletedTask;
}
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
{
SentMessages.Add((channelName, content));
return Task.FromResult(SendMessageError);
}
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) =>
Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
{
StatusUpdates.Add((username, status));
return Task.FromResult<string?>(null);
}
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName) =>
Task.FromResult(OnlineUsersToReturn);
public Task BroadcastMessageAsync(string channelName, MessageDto message) =>
Task.CompletedTask;
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
Task.CompletedTask;
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
public Task<List<string>> GetChannelsForUserAsync(string username) =>
Task.FromResult(ChannelsForUserToReturn);
public Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password) =>
Task.FromResult(AuthResult);
}
/// <summary>
/// Fake channel service that records method calls and returns pre-configured results.
/// </summary>
internal sealed class FakeChannelService : IChannelService
{
// Configurable results
public (string? Topic, bool Exists) TopicResult { get; set; } = (null, true);
public List<ChannelListItem> ChannelListToReturn { get; set; } = [];
public ChannelDto? ChannelByNameToReturn { get; set; }
public ChannelOperationResult? CreateResult { get; set; }
public ChannelOperationResult? UpdateTopicResult { get; set; }
public ChannelOperationResult? DeleteResult { get; set; }
public (bool Success, string? Error) MembershipResult { get; set; } = (true, null);
public Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit) =>
Task.FromResult(new PaginatedResponse<ChannelDto>([], 0, offset, limit));
public Task<ChannelOperationResult> CreateChannelAsync(Guid creatorUserId, string name, string? topic, bool isPublic) =>
Task.FromResult(CreateResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> UpdateTopicAsync(Guid callerUserId, string channelName, string? topic) =>
Task.FromResult(UpdateTopicResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName) =>
Task.FromResult(DeleteResult ?? ChannelOperationResult.Fail(ChannelError.ValidationFailed, "Not configured"));
public Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName) =>
Task.FromResult(TopicResult);
public Task<List<ChannelListItem>> GetChannelListAsync() =>
Task.FromResult(ChannelListToReturn);
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
Task.FromResult(ChannelByNameToReturn);
public Task<(bool Success, string? Error)> EnsureChannelMembershipAsync(Guid userId, string channelName) =>
Task.FromResult(MembershipResult);
}
@@ -0,0 +1,186 @@
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Irc;
using Xunit;
namespace EchoHub.Tests;
public class IrcMessageFormatterTests
{
private static MessageDto CreateMessage(
MessageType type = MessageType.Text,
string content = "hello",
string sender = "alice",
string channel = "general",
string? attachmentUrl = null,
string? attachmentFileName = null,
List<EmbedDto>? embeds = null) => new(
Id: Guid.NewGuid(),
Content: content,
SenderUsername: sender,
SenderNicknameColor: null,
ChannelName: channel,
Type: type,
AttachmentUrl: attachmentUrl,
AttachmentFileName: attachmentFileName,
SentAt: DateTimeOffset.UtcNow,
Embeds: embeds);
// ── FormatMessage ─────────────────────────────────────────────────
[Fact]
public void FormatMessage_TextMessage_FormatsAsPRIVMSG()
{
var msg = CreateMessage(content: "Hello world");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("PRIVMSG #general :Hello world", lines[0]);
Assert.StartsWith(":alice!alice@echohub", lines[0]);
}
[Fact]
public void FormatMessage_TextMessage_WithEmbeds_AppendsEmbedLines()
{
var embeds = new List<EmbedDto>
{
new("GitHub", "Repo Title", "A description", null, "https://github.com/test")
};
var msg = CreateMessage(content: "check this out https://github.com/test", embeds: embeds);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.True(lines.Count >= 2);
Assert.Contains("PRIVMSG #general :check this out", lines[0]);
// Embed lines contain the Unicode pipe char and site/title
Assert.Contains("GitHub", lines[1]);
Assert.Contains("Repo Title", lines[1]);
}
[Fact]
public void FormatMessage_ImageMessage_IncludesImageTagAndDownloadUrl()
{
var msg = CreateMessage(
type: MessageType.Image,
content: "{F:FF0000}\u2588{X}",
attachmentUrl: "/api/files/abc",
attachmentFileName: "photo.png");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.True(lines.Count >= 2);
Assert.Contains("[Image: photo.png]", lines[0]);
Assert.Contains("Download: /api/files/abc", lines[1]);
}
[Fact]
public void FormatMessage_FileMessage_IncludesFileTag()
{
var msg = CreateMessage(
type: MessageType.File,
content: "report.pdf",
attachmentUrl: "/api/files/xyz",
attachmentFileName: "report.pdf");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("[File: report.pdf]", lines[0]);
Assert.Contains("/api/files/xyz", lines[0]);
}
[Fact]
public void FormatMessage_AudioMessage_IncludesMusicNoteAndAudioTag()
{
var msg = CreateMessage(
type: MessageType.Audio,
content: "song.mp3",
attachmentUrl: "/api/files/def",
attachmentFileName: "song.mp3");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("\u266a", lines[0]); // ♪
Assert.Contains("[Audio: song.mp3]", lines[0]);
Assert.Contains("/api/files/def", lines[0]);
}
// ── ColorTagsToAnsi ───────────────────────────────────────────────
[Fact]
public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text");
Assert.Contains("\x1b[38;2;255;0;0m", result);
Assert.Contains("text", result);
}
[Fact]
public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text");
Assert.Contains("\x1b[48;2;0;255;0m", result);
}
[Fact]
public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{X}");
Assert.Equal("\x1b[0m", result);
}
[Fact]
public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("plain text");
Assert.Equal("plain text", result);
}
[Fact]
public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
{
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}");
Assert.Contains("\x1b[38;2;255;0;0m", result);
Assert.Contains("\x1b[38;2;0;0;255m", result);
Assert.Contains("\x1b[0m", result);
Assert.Contains("red", result);
Assert.Contains("blue", result);
}
// ── SplitMessage ──────────────────────────────────────────────────
[Fact]
public void SplitMessage_ShortMessage_ReturnsSingleChunk()
{
var result = IrcMessageFormatter.SplitMessage("Hello", 400);
Assert.Single(result);
Assert.Equal("Hello", result[0]);
}
[Fact]
public void SplitMessage_LongMessage_SplitsAtWordBoundary()
{
var words = string.Join(" ", Enumerable.Repeat("word", 200));
var result = IrcMessageFormatter.SplitMessage(words, 50);
Assert.True(result.Count > 1);
foreach (var chunk in result)
Assert.True(System.Text.Encoding.UTF8.GetByteCount(chunk) <= 50);
}
[Fact]
public void SplitMessage_EmptyMessage_ReturnsSingleEmptyChunk()
{
var result = IrcMessageFormatter.SplitMessage("", 400);
Assert.Single(result);
Assert.Equal("", result[0]);
}
[Fact]
public void SplitMessage_SingleLongWord_KeptAsOneChunk()
{
var longWord = new string('a', 500);
var result = IrcMessageFormatter.SplitMessage(longWord, 400);
// Single word can't be split at word boundary, so it stays as one chunk
Assert.Single(result);
Assert.Equal(longWord, result[0]);
}
}
+195
View File
@@ -0,0 +1,195 @@
using System.IdentityModel.Tokens.Jwt;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace EchoHub.Tests;
public class JwtTokenServiceTests
{
private const string TestSecret = "this_is_a_test_secret_key_that_is_long_enough_for_hmac_sha256";
private const string TestIssuer = "TestIssuer";
private const string TestAudience = "TestAudience";
private static JwtTokenService CreateService(
string? secret = null, string? issuer = null, string? audience = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = secret ?? TestSecret,
["Jwt:Issuer"] = issuer ?? TestIssuer,
["Jwt:Audience"] = audience ?? TestAudience,
})
.Build();
return new JwtTokenService(config);
}
private static User CreateUser(
string username = "alice",
ServerRole role = ServerRole.Member,
string? displayName = null) => new()
{
Id = Guid.NewGuid(),
Username = username,
PasswordHash = "hash",
DisplayName = displayName,
Role = role,
};
// ── Constructor ───────────────────────────────────────────────────
[Fact]
public void Constructor_MissingSecret_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Issuer"] = TestIssuer,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingIssuer_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Audience"] = TestAudience,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
[Fact]
public void Constructor_MissingAudience_Throws()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:Secret"] = TestSecret,
["Jwt:Issuer"] = TestIssuer,
})
.Build();
Assert.Throws<InvalidOperationException>(() => new JwtTokenService(config));
}
// ── GenerateAccessToken ───────────────────────────────────────────
[Fact]
public void GenerateAccessToken_ContainsExpectedClaims()
{
var service = CreateService();
var user = CreateUser(username: "bob", role: ServerRole.Admin, displayName: "Bob Smith");
var (token, _) = service.GenerateAccessToken(user);
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
Assert.Equal(user.Id.ToString(), jwt.Claims.First(c => c.Type == "sub").Value);
Assert.Equal("bob", jwt.Claims.First(c => c.Type == "username").Value);
Assert.Equal("Bob Smith", jwt.Claims.First(c => c.Type == "display_name").Value);
Assert.Equal("Admin", jwt.Claims.First(c => c.Type == "role").Value);
Assert.NotNull(jwt.Claims.FirstOrDefault(c => c.Type == "jti"));
}
[Fact]
public void GenerateAccessToken_ExpiresIn15Minutes()
{
var service = CreateService();
var user = CreateUser();
var (_, expiresAt) = service.GenerateAccessToken(user);
var diff = expiresAt - DateTimeOffset.UtcNow;
// Should be approximately 15 minutes (allow 30s tolerance)
Assert.InRange(diff.TotalMinutes, 14.5, 15.5);
}
[Fact]
public void GenerateAccessToken_DifferentTokensForSameUser()
{
var service = CreateService();
var user = CreateUser();
var (token1, _) = service.GenerateAccessToken(user);
var (token2, _) = service.GenerateAccessToken(user);
Assert.NotEqual(token1, token2);
}
[Fact]
public void GenerateAccessToken_DisplayNameFallsBackToUsername()
{
var service = CreateService();
var user = CreateUser(username: "alice"); // DisplayName is null
var (token, _) = service.GenerateAccessToken(user);
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(token);
Assert.Equal("alice", jwt.Claims.First(c => c.Type == "display_name").Value);
}
// ── GenerateRefreshToken ──────────────────────────────────────────
[Fact]
public void GenerateRefreshToken_Returns88CharBase64()
{
var token = JwtTokenService.GenerateRefreshToken();
// 64 bytes → 88 base64 characters
Assert.Equal(88, token.Length);
// Should be valid base64
var bytes = Convert.FromBase64String(token);
Assert.Equal(64, bytes.Length);
}
[Fact]
public void GenerateRefreshToken_UniqueBetweenCalls()
{
var token1 = JwtTokenService.GenerateRefreshToken();
var token2 = JwtTokenService.GenerateRefreshToken();
Assert.NotEqual(token1, token2);
}
// ── HashToken ─────────────────────────────────────────────────────
[Fact]
public void HashToken_DeterministicForSameInput()
{
var hash1 = JwtTokenService.HashToken("test-token");
var hash2 = JwtTokenService.HashToken("test-token");
Assert.Equal(hash1, hash2);
}
[Fact]
public void HashToken_DifferentForDifferentInput()
{
var hash1 = JwtTokenService.HashToken("token-a");
var hash2 = JwtTokenService.HashToken("token-b");
Assert.NotEqual(hash1, hash2);
}
[Fact]
public void HashToken_ReturnsBase64String()
{
var hash = JwtTokenService.HashToken("test-token");
// SHA256 → 32 bytes → 44 base64 characters
var bytes = Convert.FromBase64String(hash);
Assert.Equal(32, bytes.Length);
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Reflection;
using EchoHub.Server.Services;
using Xunit;
namespace EchoHub.Tests;
public class LinkEmbedServiceTests
{
private static readonly MethodInfo ExtractUrlsMethod = typeof(LinkEmbedService)
.GetMethod("ExtractUrls", BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo IsPrivateHostMethod = typeof(LinkEmbedService)
.GetMethod("IsPrivateHost", BindingFlags.NonPublic | BindingFlags.Static)!;
private static readonly MethodInfo ParseOgTagsMethod = typeof(LinkEmbedService)
.GetMethod("ParseOgTags", BindingFlags.NonPublic | BindingFlags.Static)!;
private static List<string> ExtractUrls(string content) =>
(List<string>)ExtractUrlsMethod.Invoke(null, [content])!;
private static bool IsPrivateHost(Uri uri) =>
(bool)IsPrivateHostMethod.Invoke(null, [uri])!;
private static Dictionary<string, string> ParseOgTags(string html) =>
(Dictionary<string, string>)ParseOgTagsMethod.Invoke(null, [html])!;
// ── ExtractUrls ───────────────────────────────────────────────────
[Fact]
public void ExtractUrls_SingleUrl_ReturnsIt()
{
var urls = ExtractUrls("Check this: https://example.com");
Assert.Single(urls);
Assert.Equal("https://example.com", urls[0]);
}
[Fact]
public void ExtractUrls_MultipleUrls_ReturnsAll()
{
var urls = ExtractUrls("See https://a.com and https://b.com");
Assert.Equal(2, urls.Count);
Assert.Contains("https://a.com", urls);
Assert.Contains("https://b.com", urls);
}
[Fact]
public void ExtractUrls_UrlWithTrailingPunctuation_Trimmed()
{
var urls = ExtractUrls("Visit https://example.com.");
Assert.Single(urls);
Assert.Equal("https://example.com", urls[0]);
}
[Fact]
public void ExtractUrls_NoUrls_ReturnsEmpty()
{
var urls = ExtractUrls("No links here");
Assert.Empty(urls);
}
[Fact]
public void ExtractUrls_MaxUrlsLimit_Respected()
{
// EmbedMaxUrlsPerMessage = 3
var text = "https://a.com https://b.com https://c.com https://d.com https://e.com";
var urls = ExtractUrls(text);
Assert.Equal(3, urls.Count);
}
[Fact]
public void ExtractUrls_DuplicateUrls_Deduped()
{
var urls = ExtractUrls("https://example.com and https://example.com again");
Assert.Single(urls);
}
[Fact]
public void ExtractUrls_HttpUrl_Extracted()
{
var urls = ExtractUrls("http://example.com");
Assert.Single(urls);
Assert.StartsWith("http://", urls[0]);
}
// ── IsPrivateHost ─────────────────────────────────────────────────
[Fact]
public void IsPrivateHost_Localhost_ReturnsTrue()
{
Assert.True(IsPrivateHost(new Uri("http://localhost/test")));
}
[Fact]
public void IsPrivateHost_LoopbackIP_ReturnsTrue()
{
Assert.True(IsPrivateHost(new Uri("http://127.0.0.1/test")));
}
[Fact]
public void IsPrivateHost_10Network_ReturnsTrue()
{
Assert.True(IsPrivateHost(new Uri("http://10.0.0.1/test")));
}
[Fact]
public void IsPrivateHost_172_16Network_ReturnsTrue()
{
Assert.True(IsPrivateHost(new Uri("http://172.16.0.1/test")));
}
[Fact]
public void IsPrivateHost_192_168Network_ReturnsTrue()
{
Assert.True(IsPrivateHost(new Uri("http://192.168.1.1/test")));
}
[Fact]
public void IsPrivateHost_PublicIP_ReturnsFalse()
{
Assert.False(IsPrivateHost(new Uri("http://8.8.8.8/test")));
}
[Fact]
public void IsPrivateHost_PublicDomain_ReturnsFalse()
{
Assert.False(IsPrivateHost(new Uri("https://example.com/test")));
}
[Fact]
public void IsPrivateHost_ZeroIP_ReturnsTrue()
{
Assert.True(IsPrivateHost(new Uri("http://0.0.0.0/test")));
}
// ── ParseOgTags ───────────────────────────────────────────────────
[Fact]
public void ParseOgTags_StandardOgTags_ParsedCorrectly()
{
var html = """
<html><head>
<meta property="og:title" content="Test Title" />
<meta property="og:description" content="A description" />
<meta property="og:site_name" content="TestSite" />
</head></html>
""";
var tags = ParseOgTags(html);
Assert.Equal("Test Title", tags["title"]);
Assert.Equal("A description", tags["description"]);
Assert.Equal("TestSite", tags["site_name"]);
}
[Fact]
public void ParseOgTags_ReversedOrder_ParsedCorrectly()
{
var html = """<meta content="Reversed Title" property="og:title" />""";
var tags = ParseOgTags(html);
Assert.Equal("Reversed Title", tags["title"]);
}
[Fact]
public void ParseOgTags_NoOgTags_ReturnsEmptyDictionary()
{
var html = "<html><head><title>Page</title></head></html>";
var tags = ParseOgTags(html);
Assert.Empty(tags);
}
[Fact]
public void ParseOgTags_SingleQuotes_ParsedCorrectly()
{
var html = """<meta property='og:title' content='Single Quoted' />""";
var tags = ParseOgTags(html);
Assert.Equal("Single Quoted", tags["title"]);
}
[Fact]
public void ParseOgTags_DuplicateKeys_FirstWins()
{
var html = """
<meta property="og:title" content="First" />
<meta property="og:title" content="Second" />
""";
var tags = ParseOgTags(html);
Assert.Equal("First", tags["title"]);
}
}
+1
Submodule src/Terminal.Gui added at 0061d03558