mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +02:00
@@ -22,6 +22,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-dotnet@v4
|
||||
@@ -29,7 +35,7 @@ jobs:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- 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:
|
||||
name: Build & Test
|
||||
@@ -38,6 +44,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
id: changes
|
||||
|
||||
@@ -16,6 +16,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-dotnet@v4
|
||||
|
||||
@@ -16,6 +16,11 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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
|
||||
id: changes
|
||||
|
||||
@@ -433,3 +433,6 @@ src/EchoHub.Server/uploads/*
|
||||
# DocFx generated output
|
||||
docs/_site/
|
||||
docs/_api_meta/
|
||||
|
||||
# Diff files
|
||||
*.diff
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -5,7 +5,9 @@ Articles related to the EchoHub TUI client built with Terminal.Gui v2.
|
||||
## Topics
|
||||
|
||||
- Terminal.Gui v2 patterns and conventions
|
||||
- Theme system and customization
|
||||
- Theme system and customization (including transparent theme)
|
||||
- Command system reference
|
||||
- Configuration management
|
||||
- Configuration and session persistence
|
||||
- Audio playback and file downloads
|
||||
- Automatic update checking
|
||||
- [Notification sounds](../../articles/notification-sounds.md)
|
||||
|
||||
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
|
||||
## Topics
|
||||
|
||||
- Data models and DTOs
|
||||
- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient)
|
||||
- Contract interfaces (IChatService, IChannelService, IChatBroadcaster, IEchoHubClient)
|
||||
- Validation constants and shared rules
|
||||
|
||||
+2
-2
@@ -10,11 +10,11 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
|
||||
|
||||
### Core
|
||||
|
||||
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`).
|
||||
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChannelService`, `IChatBroadcaster`, `IEchoHubClient`).
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ Articles related to the EchoHub server built with ASP.NET Core.
|
||||
- SignalR hub and real-time messaging
|
||||
- IRC gateway and protocol bridging
|
||||
- ChatService and broadcaster pattern
|
||||
- File upload and validation
|
||||
- ChannelService and channel CRUD
|
||||
- File upload, validation, and cleanup
|
||||
- Rate limiting configuration
|
||||
- Database schema and migrations
|
||||
- Database schema, migrations, and DataMigrationService
|
||||
|
||||
@@ -22,7 +22,7 @@ Shared library containing:
|
||||
|
||||
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
|
||||
- **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`
|
||||
|
||||
### EchoHub.Server
|
||||
@@ -33,7 +33,7 @@ ASP.NET Core web application:
|
||||
- **Hubs**: SignalR `ChatHub` -- thin adapter delegating to `IChatService`
|
||||
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
|
||||
- **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
|
||||
|
||||
@@ -51,9 +51,9 @@ IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` o
|
||||
Terminal.Gui v2 TUI application:
|
||||
|
||||
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
||||
- **Services**: API client with automatic token refresh, SignalR connection wrapper
|
||||
- **Themes**: 13 built-in color themes
|
||||
- **Config**: Client configuration management
|
||||
- **Services**: API client with automatic token refresh, SignalR connection wrapper, audio playback (NetCoreAudio), automatic update checker (AlwaysUpToDate)
|
||||
- **Themes**: 13 built-in color themes (including transparent theme with true terminal transparency)
|
||||
- **Config**: Client configuration management with session persistence ("Remember Me" refresh tokens)
|
||||
|
||||
## Communication
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ Release history for EchoHub.
|
||||
|
||||
## 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.2](v0.2.2.md) - Startup & Shutdown Fixes
|
||||
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
- name: Overview
|
||||
href: index.md
|
||||
- name: v0.2.5
|
||||
href: v0.2.5.md
|
||||
- name: v0.2.4
|
||||
href: v0.2.4.md
|
||||
- name: v0.2.3
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
- `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
|
||||
- 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`
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.4</Version>
|
||||
<Version>0.2.5</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class AppOrchestrator : IDisposable
|
||||
private readonly MainWindow _mainWindow;
|
||||
private readonly CommandHandler _commandHandler;
|
||||
private readonly NotificationSoundService _notificationSound;
|
||||
private readonly AudioPlaybackService _audioPlayback = new();
|
||||
private readonly UpdateChecker _updateService;
|
||||
|
||||
private EchoHubConnection? _connection;
|
||||
private ApiClient? _apiClient;
|
||||
@@ -44,10 +46,13 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow = new MainWindow(app);
|
||||
_commandHandler = new CommandHandler();
|
||||
_notificationSound = new NotificationSoundService(config.Notifications);
|
||||
_updateService = new UpdateChecker(app);
|
||||
|
||||
WireMainWindowEvents();
|
||||
WireCommandHandlerEvents();
|
||||
|
||||
_updateService.Start();
|
||||
|
||||
_mainWindow.UpdateStatusBar("Disconnected");
|
||||
}
|
||||
|
||||
@@ -55,6 +60,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
{
|
||||
_connection?.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
_apiClient?.Dispose();
|
||||
_updateService.Dispose();
|
||||
}
|
||||
|
||||
// ── Convenience Helpers ────────────────────────────────────────────────
|
||||
@@ -72,6 +78,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
{
|
||||
_mainWindow.OnConnectRequested += HandleConnect;
|
||||
_mainWindow.OnDisconnectRequested += HandleDisconnect;
|
||||
_mainWindow.OnLogoutRequested += HandleLogout;
|
||||
_mainWindow.OnMessageSubmitted += HandleMessageSubmitted;
|
||||
_mainWindow.OnChannelSelected += HandleChannelSelected;
|
||||
_mainWindow.OnProfileRequested += HandleProfileRequested;
|
||||
@@ -80,6 +87,8 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow.OnSavedServersRequested += HandleSavedServersRequested;
|
||||
_mainWindow.OnCreateChannelRequested += HandleCreateChannelRequested;
|
||||
_mainWindow.OnDeleteChannelRequested += HandleDeleteChannelRequested;
|
||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||
}
|
||||
|
||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||
@@ -235,6 +244,12 @@ public sealed class AppOrchestrator : IDisposable
|
||||
var channel = _mainWindow.CurrentChannel;
|
||||
if (string.IsNullOrEmpty(channel)) return;
|
||||
|
||||
if (channel == HubConstants.DefaultChannel)
|
||||
{
|
||||
InvokeUI(() => _mainWindow.ShowError($"You cannot leave the #{HubConstants.DefaultChannel} channel."));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _connection!.LeaveChannelAsync(channel);
|
||||
@@ -364,6 +379,16 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
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);
|
||||
if (result is null) return;
|
||||
|
||||
@@ -377,12 +402,55 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
InvokeUI(() => _mainWindow.UpdateStatusBar("Authenticating..."));
|
||||
|
||||
var loginResponse = result.IsRegister
|
||||
? await _apiClient.RegisterAsync(result.Username, result.Password)
|
||||
: await _apiClient.LoginAsync(result.Username, result.Password);
|
||||
LoginResponse loginResponse;
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
InvokeUI(() => _mainWindow.UpdateStatusBar("Fetching encryption key..."));
|
||||
try
|
||||
@@ -437,18 +505,6 @@ public sealed class AppOrchestrator : IDisposable
|
||||
|
||||
FetchAndUpdateOnlineUsers();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -477,6 +533,38 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}, "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)
|
||||
{
|
||||
if (!IsConnected)
|
||||
@@ -722,7 +810,11 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
MessageBox.Query(_app, "Saved Servers", string.Join("\n", serverLines), "OK");
|
||||
@@ -798,6 +890,40 @@ public sealed class AppOrchestrator : IDisposable
|
||||
}, "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 ────────────────────────────────────────────
|
||||
|
||||
private void WireConnectionEvents(EchoHubConnection connection)
|
||||
@@ -897,7 +1023,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
InvokeUI(() =>
|
||||
{
|
||||
if (channel.IsPublic)
|
||||
_mainWindow.EnsureChannelInList(channel.Name);
|
||||
_mainWindow.EnsureChannelInList(channel.Name, channel.IsPublic);
|
||||
_mainWindow.SetChannelTopic(channel.Name, channel.Topic);
|
||||
});
|
||||
};
|
||||
@@ -957,11 +1083,25 @@ public sealed class AppOrchestrator : IDisposable
|
||||
Name = new Uri(result.ServerUrl).Host,
|
||||
Url = result.ServerUrl,
|
||||
Username = result.Username,
|
||||
Token = _apiClient!.Token,
|
||||
RefreshToken = result.RememberMe ? _apiClient!.RefreshToken : null,
|
||||
RememberMe = result.RememberMe,
|
||||
LastConnected = DateTimeOffset.Now
|
||||
};
|
||||
ConfigManager.SaveServer(savedServer);
|
||||
_config = ConfigManager.Load();
|
||||
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
|
||||
/color <#hex> - Set nickname color
|
||||
/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
|
||||
/profile [username] - View a profile
|
||||
/servers - Open saved servers
|
||||
|
||||
@@ -20,7 +20,8 @@ public class SavedServer
|
||||
public required string Name { get; set; }
|
||||
public required string Url { 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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,19 +5,22 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AlwaysUpToDate" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.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>
|
||||
<Content Include="appsettings.json" Condition="Exists('appsettings.json')">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="hue_icon.ico" />
|
||||
<Content Include="Assets\**">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
@@ -31,6 +34,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<PackageIcon></PackageIcon>
|
||||
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -18,6 +18,8 @@ public sealed class ApiClient : IDisposable
|
||||
public string? RefreshToken => _refreshToken;
|
||||
public string BaseUrl { get; }
|
||||
|
||||
public event Action? OnTokensRefreshed;
|
||||
|
||||
public ApiClient(string baseUrl)
|
||||
{
|
||||
BaseUrl = baseUrl.TrimEnd('/');
|
||||
@@ -68,6 +70,19 @@ public sealed class ApiClient : IDisposable
|
||||
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()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_refreshToken))
|
||||
@@ -196,6 +211,23 @@ public sealed class ApiClient : IDisposable
|
||||
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)
|
||||
{
|
||||
EnsureAuthenticated();
|
||||
@@ -296,6 +328,7 @@ public sealed class ApiClient : IDisposable
|
||||
_refreshToken = result.RefreshToken;
|
||||
_expiresAt = result.ExpiresAt;
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _accessToken);
|
||||
OnTokensRefreshed?.Invoke();
|
||||
}
|
||||
|
||||
/// <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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,106 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AlwaysUpToDate;
|
||||
|
||||
using EchoHub.Client.UI;
|
||||
|
||||
using Serilog;
|
||||
|
||||
using Terminal.Gui.App;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
public static class UpdateChecker
|
||||
public sealed class UpdateChecker : IDisposable
|
||||
{
|
||||
private static readonly Uri ReleaseUrl =
|
||||
new("https://api.github.com/repos/HueByte/EchoHub/releases/latest");
|
||||
private const string ManifestUrl = "https://echohub.voidcube.cloud/api/app/version";
|
||||
|
||||
/// <summary>
|
||||
/// Checks GitHub for a newer release. Returns the new version string if one exists, or null.
|
||||
/// Never throws — all errors are silently swallowed.
|
||||
/// </summary>
|
||||
public static async Task<string?> CheckForUpdateAsync()
|
||||
private readonly Updater _updater;
|
||||
private readonly IApplication _app;
|
||||
private UpdateProgressDialog? _progressDialog;
|
||||
|
||||
public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
|
||||
public UpdateChecker(IApplication app)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
|
||||
http.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub-Client");
|
||||
_app = app;
|
||||
_updater = new Updater(TimeSpan.FromHours(1), ManifestUrl, false);
|
||||
|
||||
var release = await http.GetFromJsonAsync<GitHubRelease>(ReleaseUrl);
|
||||
if (release?.TagName is null)
|
||||
return null;
|
||||
|
||||
var tag = release.TagName.TrimStart('v', 'V');
|
||||
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;
|
||||
}
|
||||
_updater.UpdateAvailable += OnUpdateAvailable;
|
||||
_updater.ProgressChanged += OnProgressChanged;
|
||||
_updater.UpdateStarted += OnUpdateStarted;
|
||||
_updater.NoUpdateAvailable += OnNoUpdateAvailable;
|
||||
_updater.OnException += OnException;
|
||||
}
|
||||
|
||||
private sealed class GitHubRelease
|
||||
public void Start()
|
||||
{
|
||||
[JsonPropertyName("tag_name")]
|
||||
public string? TagName { get; set; }
|
||||
#if RELEASE
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,30 +418,30 @@ public static class ThemeManager
|
||||
Base = new ThemeColors
|
||||
{
|
||||
Foreground = "White",
|
||||
Background = "Black",
|
||||
Background = "None",
|
||||
FocusForeground = "BrightCyan",
|
||||
FocusBackground = "Black"
|
||||
FocusBackground = "None"
|
||||
},
|
||||
Menu = new ThemeColors
|
||||
{
|
||||
Foreground = "White",
|
||||
Background = "Black",
|
||||
Background = "None",
|
||||
FocusForeground = "BrightCyan",
|
||||
FocusBackground = "Black"
|
||||
FocusBackground = "None"
|
||||
},
|
||||
Dialog = new ThemeColors
|
||||
{
|
||||
Foreground = "White",
|
||||
Background = "Black",
|
||||
Background = "DarkGray",
|
||||
FocusForeground = "BrightCyan",
|
||||
FocusBackground = "Black"
|
||||
},
|
||||
Status = new ThemeColors
|
||||
{
|
||||
Foreground = "Gray",
|
||||
Background = "Black",
|
||||
Background = "None",
|
||||
FocusForeground = "Gray",
|
||||
FocusBackground = "Black"
|
||||
FocusBackground = "None"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using System.Text.RegularExpressions;
|
||||
using EchoHub.Core.Models;
|
||||
using Terminal.Gui.Drawing;
|
||||
using Terminal.Gui.Text;
|
||||
using Terminal.Gui.Views;
|
||||
@@ -22,6 +23,9 @@ public partial class ChatLine
|
||||
public int TextLength { get; }
|
||||
public Guid? MessageId { 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)
|
||||
{
|
||||
@@ -92,6 +96,15 @@ public partial class ChatLine
|
||||
if (currentSegments.Count > 0)
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -118,7 +131,7 @@ public partial class ChatLine
|
||||
Color? currentFg = null;
|
||||
Color? currentBg = null;
|
||||
var defaultFg = defaultAttr?.Foreground;
|
||||
var defaultBg = defaultAttr?.Background ?? Color.Black;
|
||||
var defaultBg = defaultAttr?.Background ?? Color.None;
|
||||
|
||||
Attribute? BuildAttr()
|
||||
{
|
||||
@@ -217,6 +230,8 @@ public class ChatListSource : IListDataSource
|
||||
RaiseCollectionChanged();
|
||||
}
|
||||
|
||||
public ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
|
||||
|
||||
public bool IsMarked(int item) => false;
|
||||
public void SetMark(int item, bool value) { }
|
||||
public IList ToList() => _lines.Select(l => l.ToString()).ToList();
|
||||
@@ -235,8 +250,10 @@ public class ChatListSource : IListDataSource
|
||||
foreach (var segment in chatLine.Segments)
|
||||
{
|
||||
var attr = segment.Color ?? normalAttr;
|
||||
if (attr.Background == Color.None)
|
||||
attr = attr with { Background = normalAttr.Background };
|
||||
if (mentionBg.HasValue)
|
||||
attr = new Attribute(attr.Foreground, mentionBg.Value);
|
||||
attr = attr with { Background = mentionBg.Value };
|
||||
listView.SetAttribute(attr);
|
||||
|
||||
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
|
||||
@@ -287,10 +304,10 @@ public class ChannelListSource : IListDataSource
|
||||
public int MaxItemLength { get; private set; }
|
||||
public bool SuspendCollectionChangedEvent { get; set; }
|
||||
|
||||
private static readonly Attribute ActiveAttr = new(Color.White, Color.Black);
|
||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.Black);
|
||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.Black);
|
||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.Black);
|
||||
private static readonly Attribute ActiveAttr = new(Color.White, Color.None);
|
||||
private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None);
|
||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||
|
||||
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);
|
||||
var hasUnread = unread > 0;
|
||||
|
||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||
var prefix = isActive ? "> " : " ";
|
||||
var channelText = $"#{name}";
|
||||
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;
|
||||
|
||||
if (selected)
|
||||
@@ -332,20 +354,20 @@ public class ChannelListSource : IListDataSource
|
||||
}
|
||||
else
|
||||
{
|
||||
listView.SetAttribute(isActive ? ActiveAttr : NormalAttr);
|
||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
||||
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);
|
||||
|
||||
if (hasUnread)
|
||||
{
|
||||
listView.SetAttribute(BadgeAttr);
|
||||
listView.SetAttribute(Resolve(BadgeAttr));
|
||||
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||
}
|
||||
}
|
||||
|
||||
var fillAttr = selected ? focusAttr : listView.GetAttributeForRole(VisualRole.Normal);
|
||||
var fillAttr = selected ? focusAttr : normalAttr;
|
||||
listView.SetAttribute(fillAttr);
|
||||
for (int i = drawnChars; i < width; i++)
|
||||
listView.AddStr(" ");
|
||||
@@ -457,14 +479,16 @@ static class RenderHelpers
|
||||
/// </summary>
|
||||
public static partial class ChatColors
|
||||
{
|
||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
|
||||
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
|
||||
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
|
||||
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 MentionTextAttr = new(new Color(255, 180, 50), Color.Black);
|
||||
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black);
|
||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black);
|
||||
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black);
|
||||
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), 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.None);
|
||||
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
|
||||
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.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>
|
||||
/// 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 g = Convert.ToInt32(hex[2..4], 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
|
||||
{
|
||||
|
||||
@@ -9,7 +9,9 @@ namespace EchoHub.Client.UI;
|
||||
/// <summary>
|
||||
/// Result returned from the connect dialog.
|
||||
/// </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>
|
||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||
@@ -27,11 +29,12 @@ public sealed class ConnectDialog
|
||||
savedServers ??= [];
|
||||
|
||||
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 };
|
||||
|
||||
int yOffset = 0;
|
||||
SavedServer? selectedSavedServer = null;
|
||||
|
||||
// -- Saved Servers section (if any) -----------------------------------
|
||||
ListView? savedServerList = null;
|
||||
@@ -46,7 +49,11 @@ public sealed class ConnectDialog
|
||||
dialog.Add(savedLabel);
|
||||
|
||||
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();
|
||||
|
||||
savedServerList = new ListView
|
||||
@@ -115,17 +122,34 @@ public sealed class ConnectDialog
|
||||
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
|
||||
{
|
||||
Text = "Display Name:",
|
||||
X = 1,
|
||||
Y = yOffset + 7
|
||||
Y = yOffset + 9
|
||||
};
|
||||
var displayField = new TextField
|
||||
{
|
||||
Text = "",
|
||||
X = 15,
|
||||
Y = yOffset + 7,
|
||||
Y = yOffset + 9,
|
||||
Width = Dim.Fill(2)
|
||||
};
|
||||
|
||||
@@ -134,21 +158,21 @@ public sealed class ConnectDialog
|
||||
Text = "Login",
|
||||
IsDefault = true,
|
||||
X = Pos.Center() - 20,
|
||||
Y = yOffset + 9
|
||||
Y = yOffset + 11
|
||||
};
|
||||
|
||||
var registerButton = new Button
|
||||
{
|
||||
Text = "Register",
|
||||
X = Pos.Center() - 5,
|
||||
Y = yOffset + 9
|
||||
Y = yOffset + 11
|
||||
};
|
||||
|
||||
var cancelButton = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
X = Pos.Center() + 10,
|
||||
Y = yOffset + 9
|
||||
Y = yOffset + 11
|
||||
};
|
||||
|
||||
// Wire saved server selection to auto-fill fields
|
||||
@@ -159,15 +183,32 @@ public sealed class ConnectDialog
|
||||
var index = e.NewValue;
|
||||
if (index.HasValue && index.Value >= 0 && index.Value < savedServers.Count)
|
||||
{
|
||||
var server = savedServers[index.Value];
|
||||
urlField.Text = server.Url;
|
||||
userField.Text = server.Username ?? "";
|
||||
selectedSavedServer = savedServers[index.Value];
|
||||
urlField.Text = selectedSavedServer.Url;
|
||||
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
|
||||
selectedSavedServer = savedServers[0];
|
||||
urlField.Text = savedServers[0].Url;
|
||||
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) =>
|
||||
@@ -175,15 +216,34 @@ public sealed class ConnectDialog
|
||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||
var user = userField.Text?.Trim() ?? 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;
|
||||
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;
|
||||
app.RequestStop();
|
||||
};
|
||||
@@ -193,6 +253,7 @@ public sealed class ConnectDialog
|
||||
var url = urlField.Text?.Trim() ?? string.Empty;
|
||||
var user = userField.Text?.Trim() ?? string.Empty;
|
||||
var pass = passField.Text ?? string.Empty;
|
||||
var rememberMe = rememberMeCheckbox.Value == CheckState.Checked;
|
||||
|
||||
if (string.IsNullOrEmpty(url) || string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
||||
{
|
||||
@@ -201,7 +262,7 @@ public sealed class ConnectDialog
|
||||
return;
|
||||
}
|
||||
|
||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true);
|
||||
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
|
||||
e.Handled = true;
|
||||
app.RequestStop();
|
||||
};
|
||||
@@ -214,7 +275,8 @@ public sealed class ConnectDialog
|
||||
};
|
||||
|
||||
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)
|
||||
savedServerList.SetFocus();
|
||||
|
||||
@@ -58,9 +58,11 @@ public sealed class MainWindow : Runnable
|
||||
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
|
||||
private readonly Dictionary<string, int> _channelUnread = [];
|
||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||
private readonly ChannelListSource _channelListSource;
|
||||
private string _currentChannel = string.Empty;
|
||||
private string _currentUser = string.Empty;
|
||||
private string _connectionStatus = "Disconnected";
|
||||
private int _lastChatWidth;
|
||||
|
||||
/// <summary>
|
||||
@@ -83,6 +85,11 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public event Action? OnDisconnectRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to logout (disconnect + revoke session).
|
||||
/// </summary>
|
||||
public event Action? OnLogoutRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to open their profile panel.
|
||||
/// </summary>
|
||||
@@ -113,6 +120,16 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
_app = app;
|
||||
@@ -175,6 +192,7 @@ public sealed class MainWindow : Runnable
|
||||
Height = Dim.Fill()
|
||||
};
|
||||
_messageList.Source = new ChatListSource();
|
||||
_messageList.Accepting += OnMessageListAccepting;
|
||||
_chatFrame.Add(_messageList);
|
||||
Add(_chatFrame);
|
||||
|
||||
@@ -223,16 +241,17 @@ public sealed class MainWindow : Runnable
|
||||
_usersFrame.Add(_usersList);
|
||||
Add(_usersFrame);
|
||||
|
||||
// Status bar at the very bottom
|
||||
// Status bar at the very bottom — custom drawing for colored connection state
|
||||
_statusLabel = new Label
|
||||
{
|
||||
Text = "Disconnected",
|
||||
Text = "",
|
||||
X = 0,
|
||||
Y = Pos.AnchorEnd(1),
|
||||
Width = Dim.Fill(),
|
||||
Height = 1
|
||||
};
|
||||
_statusLabel.SetScheme(SchemeManager.GetScheme("Menu"));
|
||||
_statusLabel.DrawingContent += OnStatusBarDrawContent;
|
||||
Add(_statusLabel);
|
||||
|
||||
// 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("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty),
|
||||
new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty),
|
||||
new Line(),
|
||||
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),
|
||||
@@ -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)
|
||||
{
|
||||
if (e.KeyCode == TabKey.KeyCode)
|
||||
@@ -600,10 +645,12 @@ public sealed class MainWindow : Runnable
|
||||
{
|
||||
_channelNames.Clear();
|
||||
_channelTopics.Clear();
|
||||
_channelPublic.Clear();
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
_channelNames.Add(ch.Name);
|
||||
_channelTopics[ch.Name] = ch.Topic;
|
||||
_channelPublic[ch.Name] = ch.IsPublic;
|
||||
if (!_channelMessages.ContainsKey(ch.Name))
|
||||
_channelMessages[ch.Name] = [];
|
||||
}
|
||||
@@ -613,8 +660,11 @@ public sealed class MainWindow : Runnable
|
||||
/// <summary>
|
||||
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
||||
/// </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))
|
||||
return;
|
||||
|
||||
@@ -631,6 +681,7 @@ public sealed class MainWindow : Runnable
|
||||
{
|
||||
_channelNames.Remove(channelName);
|
||||
_channelTopics.Remove(channelName);
|
||||
_channelPublic.Remove(channelName);
|
||||
RefreshChannelList();
|
||||
}
|
||||
|
||||
@@ -657,9 +708,76 @@ public sealed class MainWindow : Runnable
|
||||
/// </summary>
|
||||
public void UpdateStatusBar(string status)
|
||||
{
|
||||
var userPart = string.IsNullOrEmpty(_currentUser) ? "" : $" \u2502 User: {_currentUser}";
|
||||
var channelPart = string.IsNullOrEmpty(_currentChannel) ? "" : $" \u2502 #{_currentChannel}";
|
||||
_statusLabel.Text = $" v{AppVersion} \u2502 {status}{userPart}{channelPart}";
|
||||
_connectionStatus = status;
|
||||
_statusLabel.SetNeedsDraw();
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -694,6 +812,7 @@ public sealed class MainWindow : Runnable
|
||||
|
||||
RefreshMessages();
|
||||
UpdateTopicBar();
|
||||
_statusLabel.SetNeedsDraw();
|
||||
|
||||
// Update channel list selection
|
||||
var idx = _channelNames.IndexOf(channelName);
|
||||
@@ -725,6 +844,7 @@ public sealed class MainWindow : Runnable
|
||||
_channelMessages.Clear();
|
||||
_channelUnread.Clear();
|
||||
_channelTopics.Clear();
|
||||
_channelPublic.Clear();
|
||||
_currentChannel = string.Empty;
|
||||
_currentUser = string.Empty;
|
||||
_channelListSource.Update([], [], string.Empty);
|
||||
@@ -900,10 +1020,26 @@ public sealed class MainWindow : Runnable
|
||||
}
|
||||
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:
|
||||
var fileName = message.AttachmentFileName ?? "unknown";
|
||||
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
|
||||
lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}"));
|
||||
var fileSize = FormatFileSize(message.AttachmentFileSize);
|
||||
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;
|
||||
|
||||
case MessageType.Text:
|
||||
@@ -962,6 +1098,20 @@ public sealed class MainWindow : Runnable
|
||||
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>
|
||||
/// Build a chat line with @mention highlighting in the suffix text.
|
||||
/// </summary>
|
||||
@@ -1055,4 +1205,18 @@ public sealed class MainWindow : Runnable
|
||||
|
||||
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 |
@@ -6,7 +6,9 @@ public static class HubConstants
|
||||
public const string DefaultChannel = "general";
|
||||
public const int DefaultHistoryCount = 100;
|
||||
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 MaxMessageNewlines = 30;
|
||||
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);
|
||||
@@ -25,12 +25,8 @@ public interface IChatService
|
||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||
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<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
|
||||
Task<List<ChannelListItem>> GetChannelListAsync();
|
||||
Task<List<string>> GetChannelsForUserAsync(string username);
|
||||
Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
|
||||
}
|
||||
|
||||
public record ChannelListItem(string Name, string? Topic, int OnlineCount);
|
||||
|
||||
@@ -12,6 +12,7 @@ public record MessageDto(
|
||||
string? AttachmentUrl,
|
||||
string? AttachmentFileName,
|
||||
DateTimeOffset SentAt,
|
||||
long? AttachmentFileSize = null,
|
||||
List<EmbedDto>? Embeds = null);
|
||||
|
||||
public record ChannelDto(
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public class Message
|
||||
public MessageType Type { get; set; } = MessageType.Text;
|
||||
public string? AttachmentUrl { get; set; }
|
||||
public string? AttachmentFileName { get; set; }
|
||||
public long? AttachmentFileSize { get; set; }
|
||||
public string? EmbedJson { get; set; }
|
||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
|
||||
@@ -4,5 +4,6 @@ public enum MessageType
|
||||
{
|
||||
Text,
|
||||
Image,
|
||||
File
|
||||
File,
|
||||
Audio
|
||||
}
|
||||
|
||||
@@ -27,14 +27,20 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
||||
public bool IsSasl { get; set; }
|
||||
public bool CapNegotiating { get; set; }
|
||||
|
||||
// Channel state
|
||||
public HashSet<string> JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
// Channel state — thread-safe: written by command handler, read by broadcaster threads
|
||||
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _channelLock = new();
|
||||
|
||||
// Away state
|
||||
public string? AwayMessage { get; set; }
|
||||
|
||||
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)
|
||||
{
|
||||
_tcpClient = tcpClient;
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed class IrcCommandHandler
|
||||
private readonly IrcClientConnection _conn;
|
||||
private readonly IrcOptions _options;
|
||||
private readonly IChatService _chatService;
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
private string ServerName => _options.ServerName;
|
||||
@@ -20,11 +22,15 @@ public sealed class IrcCommandHandler
|
||||
IrcClientConnection conn,
|
||||
IrcOptions options,
|
||||
IChatService chatService,
|
||||
IChannelService channelService,
|
||||
IMessageEncryptionService encryption,
|
||||
ILogger logger)
|
||||
{
|
||||
_conn = conn;
|
||||
_options = options;
|
||||
_chatService = chatService;
|
||||
_channelService = channelService;
|
||||
_encryption = encryption;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -319,7 +325,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandleJoinAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
|
||||
if (msg.Parameters.Count < 1)
|
||||
{
|
||||
@@ -350,7 +356,7 @@ public sealed class IrcCommandHandler
|
||||
continue;
|
||||
}
|
||||
|
||||
_conn.JoinedChannels.Add(channelName);
|
||||
_conn.JoinChannel(channelName);
|
||||
|
||||
// Confirm JOIN to the client
|
||||
await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
|
||||
@@ -361,10 +367,11 @@ public sealed class IrcCommandHandler
|
||||
// Send NAMES list
|
||||
await SendNamesReplyAsync(channelName);
|
||||
|
||||
// Replay history
|
||||
// Replay history (decrypt — history is encrypted for SignalR transport)
|
||||
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)
|
||||
await _conn.SendAsync(line);
|
||||
}
|
||||
@@ -373,7 +380,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandlePartAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
@@ -385,7 +392,7 @@ public sealed class IrcCommandHandler
|
||||
if (channelName is null) continue;
|
||||
|
||||
await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
|
||||
_conn.JoinedChannels.Remove(channelName);
|
||||
_conn.LeaveChannel(channelName);
|
||||
|
||||
await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
|
||||
(partMessage is not null ? $" :{partMessage}" : ""));
|
||||
@@ -394,7 +401,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandlePrivmsgAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
|
||||
if (msg.Parameters.Count < 2)
|
||||
{
|
||||
@@ -436,7 +443,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandleNamesAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
||||
@@ -458,7 +465,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandleTopicAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
||||
@@ -477,7 +484,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task SendChannelTopicAsync(string channelName)
|
||||
{
|
||||
var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName);
|
||||
var (topic, exists) = await _channelService.GetChannelTopicAsync(channelName);
|
||||
|
||||
if (!exists) return;
|
||||
|
||||
@@ -495,7 +502,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandleWhoAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
|
||||
@@ -516,7 +523,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandleWhoisAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var nick = msg.Parameters[^1].ToLowerInvariant();
|
||||
@@ -559,7 +566,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandleAwayAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
|
||||
if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
|
||||
{
|
||||
@@ -581,9 +588,9 @@ public sealed class IrcCommandHandler
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -597,7 +604,7 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private async Task HandleModeAsync(IrcMessage msg)
|
||||
{
|
||||
if (!RequireRegistered()) return;
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var target = msg.Parameters[0];
|
||||
@@ -621,11 +628,11 @@ public sealed class IrcCommandHandler
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private bool RequireRegistered()
|
||||
private async Task<bool> RequireRegisteredAsync()
|
||||
{
|
||||
if (_conn.IsRegistered) return true;
|
||||
|
||||
_ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
|
||||
":You have not registered");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class IrcGatewayService : BackgroundService
|
||||
public IEnumerable<IrcClientConnection> GetConnectionsInChannel(string channelName)
|
||||
{
|
||||
return _connections.Values
|
||||
.Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
|
||||
.Where(c => c.IsAuthenticated && c.IsInChannel(channelName));
|
||||
}
|
||||
|
||||
public IEnumerable<IrcClientConnection> GetAllConnections()
|
||||
@@ -120,8 +120,10 @@ public sealed class IrcGatewayService : BackgroundService
|
||||
try
|
||||
{
|
||||
chatService = _services.GetRequiredService<IChatService>();
|
||||
var channelService = _services.GetRequiredService<IChannelService>();
|
||||
var encryption = _services.GetRequiredService<IMessageEncryptionService>();
|
||||
var handler = new IrcCommandHandler(
|
||||
connection, _options, chatService, _logger);
|
||||
connection, _options, chatService, channelService, encryption, _logger);
|
||||
|
||||
await handler.RunAsync(ct);
|
||||
}
|
||||
@@ -133,7 +135,7 @@ public sealed class IrcGatewayService : BackgroundService
|
||||
{
|
||||
if (connection.IsAuthenticated)
|
||||
{
|
||||
foreach (var ch in connection.JoinedChannels.ToList())
|
||||
foreach (var ch in connection.GetJoinedChannels())
|
||||
{
|
||||
if (chatService is null) break;
|
||||
await chatService.LeaveChannelAsync(
|
||||
|
||||
@@ -48,6 +48,10 @@ public static partial class IrcMessageFormatter
|
||||
case MessageType.File:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||
break;
|
||||
|
||||
case MessageType.Audio:
|
||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {message.AttachmentFileName}] {message.AttachmentUrl}");
|
||||
break;
|
||||
}
|
||||
|
||||
return lines;
|
||||
|
||||
@@ -8,7 +8,6 @@ using EchoHub.Server.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EchoHub.Server.Controllers;
|
||||
|
||||
@@ -18,6 +17,7 @@ namespace EchoHub.Server.Controllers;
|
||||
[EnableRateLimiting("general")]
|
||||
public class ChannelsController : ControllerBase
|
||||
{
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly EchoHubDbContext _db;
|
||||
private readonly FileStorageService _fileStorage;
|
||||
private readonly ImageToAsciiService _asciiService;
|
||||
@@ -26,6 +26,7 @@ public class ChannelsController : ControllerBase
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
|
||||
public ChannelsController(
|
||||
IChannelService channelService,
|
||||
EchoHubDbContext db,
|
||||
FileStorageService fileStorage,
|
||||
ImageToAsciiService asciiService,
|
||||
@@ -33,6 +34,7 @@ public class ChannelsController : ControllerBase
|
||||
IChatService chatService,
|
||||
IMessageEncryptionService encryption)
|
||||
{
|
||||
_channelService = channelService;
|
||||
_db = db;
|
||||
_fileStorage = fileStorage;
|
||||
_asciiService = asciiService;
|
||||
@@ -48,74 +50,29 @@ public class ChannelsController : ControllerBase
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var userId = Guid.Parse(userIdClaim);
|
||||
offset = Math.Max(0, offset);
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
// Public channels + private channels the user has joined
|
||||
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 Ok(new PaginatedResponse<ChannelDto>(channels, total, offset, limit));
|
||||
var result = await _channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
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);
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channel = new Channel
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = channelName,
|
||||
Topic = request.Topic?.Trim(),
|
||||
IsPublic = request.IsPublic,
|
||||
CreatedByUserId = Guid.Parse(userIdClaim),
|
||||
};
|
||||
var result = await _channelService.CreateChannelAsync(
|
||||
Guid.Parse(userIdClaim), request.Name, request.Topic, request.IsPublic);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
_db.Channels.Add(channel);
|
||||
if (result.Channel!.IsPublic)
|
||||
await _chatService.BroadcastChannelUpdatedAsync(result.Channel);
|
||||
|
||||
// Creator automatically becomes a member
|
||||
_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);
|
||||
return Created($"/api/channels/{result.Channel.Name}", result.Channel);
|
||||
}
|
||||
|
||||
[HttpPut("{channel}/topic")]
|
||||
@@ -125,26 +82,13 @@ public class ChannelsController : ControllerBase
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
var result = await _channelService.UpdateTopicAsync(
|
||||
Guid.Parse(userIdClaim), channel, request.Topic);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
if (dbChannel is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
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);
|
||||
await _chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim());
|
||||
return Ok(result.Channel);
|
||||
}
|
||||
|
||||
[HttpDelete("{channel}")]
|
||||
@@ -154,29 +98,17 @@ public class ChannelsController : ControllerBase
|
||||
if (userIdClaim is null)
|
||||
return Unauthorized(new ErrorResponse("Authentication required."));
|
||||
|
||||
var channelName = channel.ToLowerInvariant().Trim();
|
||||
|
||||
if (channelName == HubConstants.DefaultChannel)
|
||||
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();
|
||||
var result = await _channelService.DeleteChannelAsync(Guid.Parse(userIdClaim), channel);
|
||||
if (!result.IsSuccess)
|
||||
return MapChannelError(result);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{channel}/upload")]
|
||||
[EnableRateLimiting("upload")]
|
||||
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
|
||||
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
|
||||
{
|
||||
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
@@ -190,8 +122,8 @@ public class ChannelsController : ControllerBase
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
|
||||
if (channelDto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
|
||||
@@ -199,16 +131,23 @@ public class ChannelsController : ControllerBase
|
||||
|
||||
var file = Request.Form.Files[0];
|
||||
|
||||
if (file.Length > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
// Detect if file is an image by checking magic bytes
|
||||
// Detect file type early so we can apply the correct size limit
|
||||
using var stream = file.OpenReadStream();
|
||||
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 messageType = isImage ? MessageType.Image : MessageType.File;
|
||||
var messageType = isImage ? MessageType.Image
|
||||
: isAudio ? MessageType.Audio
|
||||
: MessageType.File;
|
||||
string content;
|
||||
|
||||
if (isImage)
|
||||
@@ -233,8 +172,9 @@ public class ChannelsController : ControllerBase
|
||||
Type = messageType,
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = file.FileName,
|
||||
AttachmentFileSize = file.Length,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = dbChannel.Id,
|
||||
ChannelId = channelDto.Id,
|
||||
SenderUserId = userId,
|
||||
SenderUsername = usernameClaim,
|
||||
};
|
||||
@@ -252,7 +192,8 @@ public class ChannelsController : ControllerBase
|
||||
messageType,
|
||||
attachmentUrl,
|
||||
file.FileName,
|
||||
message.SentAt);
|
||||
message.SentAt,
|
||||
file.Length);
|
||||
|
||||
await _chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||
|
||||
@@ -274,8 +215,8 @@ public class ChannelsController : ControllerBase
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return BadRequest(new ErrorResponse("Invalid channel name format."));
|
||||
|
||||
var dbChannel = await _db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||
if (dbChannel is null)
|
||||
var channelDto = await _channelService.GetChannelByNameAsync(channelName);
|
||||
if (channelDto is null)
|
||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Url))
|
||||
@@ -295,13 +236,13 @@ public class ChannelsController : ControllerBase
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength;
|
||||
if (contentLength > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
if (contentLength > HubConstants.MaxImageSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
imageBytes = await response.Content.ReadAsByteArrayAsync();
|
||||
|
||||
if (imageBytes.Length > HubConstants.MaxFileSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB."));
|
||||
if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
|
||||
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
|
||||
|
||||
fileName = Path.GetFileName(uri.LocalPath);
|
||||
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
|
||||
@@ -353,8 +294,9 @@ public class ChannelsController : ControllerBase
|
||||
Type = MessageType.Image,
|
||||
AttachmentUrl = attachmentUrl,
|
||||
AttachmentFileName = fileName,
|
||||
AttachmentFileSize = imageBytes.Length,
|
||||
SentAt = DateTimeOffset.UtcNow,
|
||||
ChannelId = dbChannel.Id,
|
||||
ChannelId = channelDto.Id,
|
||||
SenderUserId = userId,
|
||||
SenderUsername = usernameClaim,
|
||||
};
|
||||
@@ -372,10 +314,21 @@ public class ChannelsController : ControllerBase
|
||||
MessageType.Image,
|
||||
attachmentUrl,
|
||||
fileName,
|
||||
message.SentAt);
|
||||
message.SentAt,
|
||||
imageBytes.Length);
|
||||
|
||||
await _chatService.BroadcastMessageAsync(channelName, 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",
|
||||
".gif" => "image/gif",
|
||||
".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",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
|
||||
+267
@@ -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)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("AttachmentFileSize")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AttachmentUrl")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="hue_icon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
||||
@@ -22,6 +26,8 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PackageIcon></PackageIcon>
|
||||
<ApplicationIcon>hue_icon.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -108,12 +108,14 @@ while (true)
|
||||
builder.Services.AddSingleton<FileStorageService>();
|
||||
builder.Services.AddSingleton<LinkEmbedService>();
|
||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||
builder.Services.AddHostedService<FileCleanupService>();
|
||||
|
||||
// ── Encryption ─────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
||||
|
||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
|
||||
builder.Services.AddSingleton<IChannelService, ChannelService>();
|
||||
builder.Services.AddSingleton<IChatService, ChatService>();
|
||||
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class ChatService : IChatService
|
||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||
private readonly LinkEmbedService _embedService;
|
||||
private readonly IMessageEncryptionService _encryption;
|
||||
private readonly IChannelService _channelService;
|
||||
private readonly ILogger<ChatService> _logger;
|
||||
|
||||
public ChatService(
|
||||
@@ -25,6 +26,7 @@ public class ChatService : IChatService
|
||||
IEnumerable<IChatBroadcaster> broadcasters,
|
||||
LinkEmbedService embedService,
|
||||
IMessageEncryptionService encryption,
|
||||
IChannelService channelService,
|
||||
ILogger<ChatService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
@@ -32,6 +34,7 @@ public class ChatService : IChatService
|
||||
_broadcasters = broadcasters;
|
||||
_embedService = embedService;
|
||||
_encryption = encryption;
|
||||
_channelService = channelService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -95,28 +98,10 @@ public class ChatService : IChatService
|
||||
{
|
||||
channelName = channelName.ToLowerInvariant().Trim();
|
||||
|
||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||
return ([], "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)
|
||||
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();
|
||||
}
|
||||
// Delegate channel validation + membership to ChannelService
|
||||
var (success, error) = await _channelService.EnsureChannelMembershipAsync(userId, channelName);
|
||||
if (!success)
|
||||
return ([], error);
|
||||
|
||||
var isNewJoin = _presenceTracker.JoinChannel(username, channelName);
|
||||
|
||||
@@ -126,7 +111,7 @@ public class ChatService : IChatService
|
||||
_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);
|
||||
}
|
||||
|
||||
@@ -228,7 +213,7 @@ public class ChatService : IChatService
|
||||
null,
|
||||
null,
|
||||
message.SentAt,
|
||||
embeds);
|
||||
Embeds: embeds);
|
||||
|
||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
||||
|
||||
@@ -335,32 +320,6 @@ public class ChatService : IChatService
|
||||
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)
|
||||
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username));
|
||||
|
||||
@@ -457,6 +416,7 @@ public class ChatService : IChatService
|
||||
x.m.AttachmentUrl,
|
||||
x.m.AttachmentFileName,
|
||||
x.m.SentAt,
|
||||
x.m.AttachmentFileSize,
|
||||
embeds);
|
||||
}).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)
|
||||
{
|
||||
if (length < magic.Length)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using EchoHub.Core.Constants;
|
||||
using EchoHub.Core.DTOs;
|
||||
using EchoHub.Core.Models;
|
||||
using EchoHub.Server.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -12,12 +13,14 @@ public static partial class DataMigrationService
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||
var config = scope.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("EchoHub.Server.Setup.DataMigration");
|
||||
|
||||
await EnsureDefaultChannelsPublicAsync(db, logger);
|
||||
await MigrateAnsiMessagesAsync(db, logger);
|
||||
await MigrateEmbedJsonToArrayAsync(db, logger);
|
||||
await EnsureConfiguredAdminsAsync(db, config, logger);
|
||||
}
|
||||
|
||||
/// <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")]
|
||||
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>
|
||||
/// Migrate old single-object EmbedJson ("{...}") to array format ("[{...}]").
|
||||
/// </summary>
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
"Name": "My EchoHub Server",
|
||||
"Description": "A self-hosted EchoHub chat server",
|
||||
"PublicServer": false,
|
||||
"PublicHost": ""
|
||||
"PublicHost": "",
|
||||
"Admins": []
|
||||
},
|
||||
"Storage": {
|
||||
"CleanupIntervalHours": 1,
|
||||
"RetentionDays": 30
|
||||
},
|
||||
"Encryption": {
|
||||
"Key": "",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@
|
||||
<ProjectReference Include="..\EchoHub.Client\EchoHub.Client.csproj" />
|
||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||
<ProjectReference Include="..\EchoHub.Server\EchoHub.Server.csproj" />
|
||||
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -53,4 +53,48 @@ public class FileValidationHelperTests
|
||||
FileValidationHelper.IsValidImage(stream);
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
Submodule
+1
Submodule src/Terminal.Gui added at 0061d03558
Reference in New Issue
Block a user