diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f74fc77..fc58994 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@ name: CI
on:
push:
- branches: [master, dev]
+ branches: [master]
pull_request:
branches: [master, dev]
workflow_dispatch:
diff --git a/README.md b/README.md
index 5c91d9f..0f24a3a 100644
--- a/README.md
+++ b/README.md
@@ -13,11 +13,19 @@
What •
Setup •
+ IRC •
+ Deploy •
Commands •
Config •
License
+
+ Website •
+ Public Servers •
+ Documentation
+
+
@@ -40,21 +48,25 @@ Each server is fully independent — no central authority, no account federation
```mermaid
graph TD
subgraph Server["Server"]
+ ChatSvc["ChatService"]
Hub["SignalR ChatHub"]
+ IRC["IRC Gateway :6667"]
Auth["JWT Auth"]
DB["SQLite DB (EF Core)"]
Files["File Storage"]
end
- subgraph Client["Client"]
+ subgraph Clients["Clients"]
TUI["Terminal GUI (TUI)"]
- Theme["Theme Engine"]
- API["API Client (auto-refresh)"]
+ IRCClient["IRC Client (irssi, WeeChat, ...)"]
end
- API -- "WebSocket" --> Hub
- API -- "REST" --> Auth
- Hub --> DB
+ TUI -- "WebSocket" --> Hub
+ TUI -- "REST" --> Auth
+ IRCClient -- "TCP" --> IRC
+ Hub --> ChatSvc
+ IRC --> ChatSvc
+ ChatSvc --> DB
Auth --> DB
Files --> DB
```
@@ -65,6 +77,7 @@ graph TD
- **Self-hostable** — your server, your rules, your data
- **Real-time messaging** via SignalR WebSockets
+- **IRC gateway** — native IRC clients connect alongside TUI users, full cross-protocol messaging
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
@@ -122,6 +135,66 @@ Connect, register, chat. That's the whole onboarding flow.
dotnet build src/EchoHub.slnx
```
+## IRC Gateway
+
+EchoHub includes a built-in IRC protocol gateway. Any standard IRC client can connect to the same server and chat alongside TUI users — messages flow both ways in real time.
+
+### Enable It
+
+In the server's `appsettings.json`:
+
+```json
+{
+ "Irc": {
+ "Enabled": true,
+ "Port": 6667,
+ "ServerName": "echohub",
+ "Motd": "Welcome to EchoHub IRC Gateway!"
+ }
+}
+```
+
+### Connect
+
+```bash
+# irssi
+irssi -c your-server.com -p 6667 -w -n
+
+# WeeChat
+/server add echohub your-server.com/6667 -password= -nicks=
+/connect echohub
+```
+
+IRC users must have an existing EchoHub account (no registration via IRC). Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN.
+
+### What Works
+
+| Feature | How it maps to IRC |
+| ------- | ------------------ |
+| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) |
+| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
+| File uploads | `[File: filename] /api/files/{id}` |
+| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
+| Presence | `AWAY`, `WHO`, `WHOIS` |
+| Status | Maps to IRC away/here |
+
+### TLS
+
+If running behind **nginx** (recommended), let nginx handle TLS -- see [Deployment with nginx](#deployment-with-nginx) below.
+
+For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself:
+
+```json
+{
+ "Irc": {
+ "TlsEnabled": true,
+ "TlsPort": 6697,
+ "TlsCertPath": "/path/to/cert.pfx",
+ "TlsCertPassword": "your-password"
+ }
+}
+```
+
## Client Commands
| Command | Description |
@@ -172,35 +245,107 @@ dotnet build src/EchoHub.slnx
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
| `Server:Name` | `My EchoHub Server` | Server display name |
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
+| `Server:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
+| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
+| `Irc:Enabled` | `false` | Enable the IRC gateway |
+| `Irc:Port` | `6667` | IRC listen port |
+| `Irc:TlsEnabled` | `false` | Enable TLS for IRC |
+| `Irc:TlsPort` | `6697` | IRC TLS port |
+| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
+| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
| `Cors:AllowedOrigins` | *(all origins)* | CORS whitelist |
-Logging uses Serilog — console + daily rolling files with 14-day retention. Configure it in the `Serilog` section of appsettings.
+Logging uses Serilog — console + daily rolling files with 14-day retention. Configure in the `Serilog` section.
+
+## Deployment with nginx
+
+Most production deployments run behind nginx. Here's a config that handles both the HTTP/WebSocket server and the IRC gateway:
+
+```nginx
+# HTTP + WebSocket (EchoHub Server API + SignalR)
+server {
+ listen 443 ssl;
+ server_name echohub.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
+
+ location / {
+ proxy_pass http://127.0.0.1:5000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Required for SignalR WebSocket
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $http_connection;
+
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+ }
+
+ # Increase max upload size for file sharing
+ client_max_body_size 10m;
+}
+
+# HTTP → HTTPS redirect
+server {
+ listen 80;
+ server_name echohub.example.com;
+ return 301 https://$host$request_uri;
+}
+
+# IRC TLS (port 6697 → plain IRC on 6667)
+stream {
+ upstream irc_backend {
+ server 127.0.0.1:6667;
+ }
+
+ server {
+ listen 6697 ssl;
+ proxy_pass irc_backend;
+
+ ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
+ }
+}
+```
+
+With this setup, keep the EchoHub IRC gateway's `TlsEnabled` set to `false` — nginx terminates TLS. See the full example at [`examples/nginx.conf`](examples/nginx.conf).
## Project Structure
```text
src/
-├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
-│ ├── Constants/ # ValidationConstants, HubConstants
-│ ├── Contracts/ # IEchoHubClient (SignalR interface)
-│ ├── DTOs/ # Record DTOs
-│ └── Models/ # Entity models
+├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
+│ ├── Constants/ # ValidationConstants, HubConstants
+│ ├── Contracts/ # IChatService, IChatBroadcaster, IEchoHubClient
+│ ├── DTOs/ # Record DTOs
+│ └── Models/ # Entity models
│
-├── EchoHub.Server/ # ASP.NET Core server
-│ ├── Auth/ # JWT token service
-│ ├── Controllers/ # REST API endpoints
-│ ├── Data/ # EF Core DbContext + migrations
-│ ├── Hubs/ # SignalR ChatHub
-│ ├── Services/ # Presence, file storage, image processing
-│ └── Setup/ # First-run setup, DB initialization
+├── EchoHub.Server/ # ASP.NET Core server
+│ ├── Auth/ # JWT token service
+│ ├── Controllers/ # REST API endpoints
+│ ├── Data/ # EF Core DbContext + migrations
+│ ├── Hubs/ # SignalR ChatHub
+│ ├── Services/ # ChatService, presence, file storage, image processing
+│ └── Setup/ # First-run setup, DB initialization
│
-├── EchoHub.Client/ # Terminal.Gui TUI client
-│ ├── Config/ # Client configuration
-│ ├── Services/ # API client, SignalR connection
-│ ├── Themes/ # 6 built-in themes
-│ └── UI/ # MainWindow, dialogs, chat renderer
+├── EchoHub.Server.Irc/ # IRC protocol gateway
+│ ├── IrcGatewayService # TCP listener (BackgroundService)
+│ ├── IrcCommandHandler # IRC command dispatch (JOIN, PRIVMSG, etc.)
+│ ├── IrcBroadcaster # Fans chat events to IRC connections
+│ └── IrcMessageFormatter # MessageDto → IRC PRIVMSG lines
│
-└── EchoHub.slnx # Solution file
+├── EchoHub.Client/ # Terminal.Gui TUI client
+│ ├── Config/ # Client configuration
+│ ├── Services/ # API client, SignalR connection
+│ ├── Themes/ # 13 built-in themes
+│ └── UI/ # MainWindow, dialogs, chat renderer
+│
+└── EchoHub.slnx # Solution file
```
## License
diff --git a/docs/api/core-articles/index.md b/docs/api/core-articles/index.md
index 852da7d..d8545b1 100644
--- a/docs/api/core-articles/index.md
+++ b/docs/api/core-articles/index.md
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
## Topics
- Data models and DTOs
-- SignalR contract interface
+- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient)
- Validation constants and shared rules
diff --git a/docs/api/index.md b/docs/api/index.md
index 3978198..b62904c 100644
--- a/docs/api/index.md
+++ b/docs/api/index.md
@@ -10,8 +10,12 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
### Core
-Shared library -- DTOs, models, constants, and the SignalR client contract.
+Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`).
### Server
-ASP.NET Core server -- controllers, hubs, authentication, and data access.
+ASP.NET Core server -- controllers, hubs, ChatService, SignalRBroadcaster, authentication, and data access.
+
+### Server.Irc
+
+IRC protocol gateway -- TCP listener, command handler, IrcBroadcaster, and message formatter.
diff --git a/docs/api/server-articles/index.md b/docs/api/server-articles/index.md
index cafad01..6cafdf8 100644
--- a/docs/api/server-articles/index.md
+++ b/docs/api/server-articles/index.md
@@ -6,6 +6,8 @@ Articles related to the EchoHub server built with ASP.NET Core.
- Authentication and JWT tokens
- SignalR hub and real-time messaging
+- IRC gateway and protocol bridging
+- ChatService and broadcaster pattern
- File upload and validation
- Rate limiting configuration
- Database schema and migrations
diff --git a/docs/api/toc.yml b/docs/api/toc.yml
index 78334db..79092df 100644
--- a/docs/api/toc.yml
+++ b/docs/api/toc.yml
@@ -16,3 +16,7 @@
href: ../_api_meta/server/toc.yml
- name: Articles
href: server-articles/
+- name: Server.Irc
+ items:
+ - name: API Reference
+ href: ../_api_meta/server-irc/toc.yml
diff --git a/docs/articles/architecture.md b/docs/articles/architecture.md
index 9b67ffd..bc59652 100644
--- a/docs/articles/architecture.md
+++ b/docs/articles/architecture.md
@@ -4,6 +4,16 @@
EchoHub follows a decentralized model where each server is fully independent. There is no central authority or account federation. Users create one account per server.
+The server exposes two protocol interfaces to the same chat backend:
+
+```text
+IRC Client ──► TCP :6667 ──► IrcGateway ──┐
+ ├──► ChatService ──► DB + PresenceTracker
+TUI Client ──► WebSocket ──► ChatHub ─────┘
+```
+
+Both protocols call into a shared `IChatService` for business logic. Events fan out to all registered `IChatBroadcaster` implementations (SignalR and IRC).
+
## Components
### EchoHub.Core
@@ -12,7 +22,7 @@ Shared library containing:
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
- **DTOs**: Record types for API requests/responses
-- **Contracts**: `IEchoHubClient` -- the strongly-typed SignalR client interface
+- **Contracts**: `IChatService` (protocol-agnostic chat operations), `IChatBroadcaster` (event fan-out interface), `IEchoHubClient` (SignalR client interface)
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
### EchoHub.Server
@@ -20,10 +30,21 @@ Shared library containing:
ASP.NET Core web application:
- **Controllers**: REST API endpoints for auth, channels, users, files, server info
-- **Hubs**: SignalR `ChatHub` for real-time messaging
+- **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**: Presence tracking, file storage, image-to-ASCII conversion
+- **Services**: `ChatService` (core business logic), `SignalRBroadcaster`, presence tracking, file storage, image-to-ASCII conversion
+
+### EchoHub.Server.Irc
+
+IRC protocol gateway (separate project for clean separation of concerns):
+
+- **IrcGatewayService**: `BackgroundService` with TCP listener on configured port(s), optional TLS
+- **IrcCommandHandler**: Per-client IRC command dispatch -- handles `CAP`/`SASL`, `NICK`/`USER`/`PASS`, `JOIN`/`PART`/`PRIVMSG`/`QUIT`, `NAMES`/`TOPIC`/`WHO`/`WHOIS`/`AWAY`/`LIST`/`MODE`/`MOTD`
+- **IrcBroadcaster**: `IChatBroadcaster` implementation that formats chat events as IRC protocol lines, with echo suppression (IRC convention)
+- **IrcMessageFormatter**: Converts `MessageDto` to IRC `PRIVMSG` lines -- splits long text at word boundaries (~400 byte chunks), sends images as ASCII art line-by-line
+
+IRC users authenticate with existing EchoHub accounts via `PASS`/`NICK`/`USER` or SASL PLAIN (BCrypt verification against the database).
### EchoHub.Client
@@ -31,11 +52,22 @@ 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**: 6 built-in color themes
+- **Themes**: 13 built-in color themes
- **Config**: Client configuration management
## Communication
-- REST API for authentication, profile management, channel CRUD, file uploads
-- SignalR WebSocket for real-time messaging and presence updates
+- **REST API** for authentication, profile management, channel CRUD, file uploads
+- **SignalR WebSocket** for real-time messaging and presence updates (TUI client)
+- **IRC TCP** for real-time messaging via standard IRC protocol (IRC clients)
- JWT tokens passed via query string for SignalR authentication
+- IRC authentication via PASS/SASL PLAIN against BCrypt password hashes
+
+## Broadcaster Pattern
+
+The `IChatBroadcaster` interface allows multiple protocols to receive chat events:
+
+- **SignalRBroadcaster**: Wraps `IHubContext`, filters out IRC connections
+- **IrcBroadcaster**: Iterates live IRC connections in a channel, formats events as IRC protocol lines
+
+Both are registered in DI and called by `ChatService` when events occur. This means a message sent from an IRC client appears in the TUI client, and vice versa.
diff --git a/docs/articles/getting-started.md b/docs/articles/getting-started.md
index 9f31bf1..2d45cad 100644
--- a/docs/articles/getting-started.md
+++ b/docs/articles/getting-started.md
@@ -4,6 +4,8 @@
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
+Or grab a self-contained binary from [Releases](https://github.com/HueByte/EchoHub/releases) -- no runtime needed.
+
## Run the Server
```bash
@@ -24,6 +26,37 @@ dotnet run --project src/EchoHub.Client
Connect to a server, register an account, and start chatting.
+## Connect via IRC
+
+Enable the IRC gateway in the server's `appsettings.json`:
+
+```json
+{
+ "Irc": {
+ "Enabled": true,
+ "Port": 6667
+ }
+}
+```
+
+Then connect with any standard IRC client:
+
+```bash
+irssi -c localhost -p 6667 -w -n
+```
+
+IRC users must have an existing EchoHub account. Authentication works via `PASS`/`NICK`/`USER` or SASL PLAIN. Messages flow bidirectionally between IRC and TUI clients.
+
+For TLS, set `TlsEnabled: true`, `TlsPort: 6697`, and provide a PKCS#12 certificate path.
+
+See the [Architecture](architecture.md) page for details on how the IRC gateway integrates with the chat service.
+
+## Configuration
+
+Server configuration is in `appsettings.json` (auto-generated on first run). See the [example config](https://github.com/HueByte/EchoHub/blob/master/src/EchoHub.Server/appsettings.example.json) for all available options.
+
+To list your server on the [public directory](https://echohub.voidcube.cloud/servers), set `Server:PublicServer` to `true` and `Server:PublicHost` to your server's public address.
+
## Build from Source
```bash
diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index 90c9628..f34555b 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -4,5 +4,6 @@ Release history for EchoHub.
## Releases
+- [v0.2.0](v0.2.0.md) - IRC Gateway
- [v0.1.1](v0.1.1.md) - Directory Connection Self-Healing
- [v0.1.0](v0.1.0.md) - Initial Release
diff --git a/docs/changelog/toc.yml b/docs/changelog/toc.yml
index 32cbada..b3b5122 100644
--- a/docs/changelog/toc.yml
+++ b/docs/changelog/toc.yml
@@ -1,5 +1,7 @@
- name: Overview
href: index.md
+- name: v0.2.0
+ href: v0.2.0.md
- name: v0.1.1
href: v0.1.1.md
- name: v0.1.0
diff --git a/docs/changelog/v0.2.0.md b/docs/changelog/v0.2.0.md
new file mode 100644
index 0000000..1e9b7cd
--- /dev/null
+++ b/docs/changelog/v0.2.0.md
@@ -0,0 +1,21 @@
+# v0.2.0 - IRC Gateway
+
+## Features
+
+- **IRC protocol gateway** -- native IRC clients (irssi, WeeChat, HexChat, etc.) can connect to EchoHub servers
+- **Cross-protocol messaging** -- messages flow bidirectionally between IRC and TUI clients in real time
+- **SASL PLAIN authentication** -- IRC clients can authenticate via SASL or traditional PASS/NICK/USER
+- **Full IRC command support** -- JOIN, PART, PRIVMSG, QUIT, NAMES, TOPIC, WHO, WHOIS, AWAY, LIST, MODE, MOTD
+- **TLS support** -- optional encrypted IRC connections on port 6697
+- **Image-to-IRC formatting** -- images appear as ASCII art line-by-line with download URLs
+- **Message splitting** -- long messages automatically split at word boundaries (~400 byte chunks)
+- **Configurable MOTD** -- server message of the day for IRC clients
+
+## Architecture Changes
+
+- Extracted shared business logic from `ChatHub` into protocol-agnostic `IChatService`
+- Introduced `IChatBroadcaster` pattern for multi-protocol event fan-out
+- `ChatHub` refactored to thin adapter delegating to `IChatService`
+- `ChannelsController` updated to use `IChatService` for broadcasts
+- New `EchoHub.Server.Irc` project for clean separation of concerns
+- `IChatService` and `IChatBroadcaster` interfaces live in `EchoHub.Core/Contracts`
diff --git a/docs/docfx.json b/docs/docfx.json
index 7e28fca..52df4c8 100644
--- a/docs/docfx.json
+++ b/docs/docfx.json
@@ -30,6 +30,16 @@
],
"dest": "_api_meta/client",
"filter": "filterConfig.yml"
+ },
+ {
+ "src": [
+ {
+ "src": "../src/EchoHub.Server.Irc/bin/Release/net10.0",
+ "files": ["EchoHub.Server.Irc.dll"]
+ }
+ ],
+ "dest": "_api_meta/server-irc",
+ "filter": "filterConfig.yml"
}
],
"build": {
@@ -55,6 +65,12 @@
"dest": "api/client",
"files": ["*.yml"],
"exclude": ["toc.yml"]
+ },
+ {
+ "src": "_api_meta/server-irc",
+ "dest": "api/server-irc",
+ "files": ["*.yml"],
+ "exclude": ["toc.yml"]
}
],
"resource": [
diff --git a/docs/index.md b/docs/index.md
index 6c8b7cd..cf75e8f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,11 +4,13 @@ _layout: landing
# EchoHub Documentation
-Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-like chat application built with .NET 10 and SignalR.
+Welcome to the EchoHub documentation. EchoHub is a decentralized, IRC-style chat platform. Self-hosted, terminal-first, with a built-in IRC gateway so native IRC clients can connect alongside the TUI client.
+
+**Website:** [echohub.voidcube.cloud](https://echohub.voidcube.cloud/) | **Public Servers:** [Server Directory](https://echohub.voidcube.cloud/servers)
## Quick Links
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
-- [Architecture](articles/architecture.md) - Understand the system design
+- [Architecture](articles/architecture.md) - System design and IRC gateway
- [API Reference](api/index.md) - Generated C# API documentation
- [Changelog](changelog/index.md) - Release history
diff --git a/examples/nginx.conf b/examples/nginx.conf
new file mode 100644
index 0000000..ca07536
--- /dev/null
+++ b/examples/nginx.conf
@@ -0,0 +1,94 @@
+# EchoHub nginx configuration example
+#
+# This config handles:
+# - HTTPS reverse proxy for the HTTP API + SignalR WebSocket
+# - TLS termination for IRC on port 6697
+# - HTTP → HTTPS redirect
+#
+# Prerequisites:
+# - EchoHub Server running on 127.0.0.1:5000
+# - IRC gateway enabled on port 6667 (Irc:Enabled = true, Irc:TlsEnabled = false)
+# - TLS certificate (e.g. from Let's Encrypt)
+#
+# Usage:
+# 1. Copy this file to /etc/nginx/sites-available/echohub
+# 2. Replace "echohub.example.com" with your domain
+# 3. Update certificate paths
+# 4. ln -s /etc/nginx/sites-available/echohub /etc/nginx/sites-enabled/
+# 5. nginx -t && systemctl reload nginx
+#
+# Note: The "stream" block for IRC TLS must go in the main nginx.conf
+# (outside the http block), not in sites-available. See the bottom of
+# this file for the stream config.
+
+# --- Place this in /etc/nginx/sites-available/echohub ---
+
+# HTTP → HTTPS redirect
+server {
+ listen 80;
+ listen [::]:80;
+ server_name echohub.example.com;
+
+ return 301 https://$host$request_uri;
+}
+
+# HTTPS — API + SignalR WebSocket
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name echohub.example.com;
+
+ ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+ ssl_ciphers HIGH:!aNULL:!MD5;
+
+ # File upload limit (match EchoHub's MaxFileSizeBytes)
+ client_max_body_size 10m;
+
+ location / {
+ proxy_pass http://127.0.0.1:5000;
+ proxy_http_version 1.1;
+
+ # Standard proxy headers
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Required for SignalR WebSocket upgrade
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $http_connection;
+
+ # Long timeout for WebSocket connections
+ proxy_read_timeout 86400s;
+ proxy_send_timeout 86400s;
+
+ # Disable buffering for real-time
+ proxy_buffering off;
+ }
+}
+
+# --- Place this in /etc/nginx/nginx.conf (outside the http block) ---
+
+# IRC TLS termination (port 6697 → plain IRC on 6667)
+# The stream module handles raw TCP, not HTTP.
+#
+# stream {
+# upstream irc_backend {
+# server 127.0.0.1:6667;
+# }
+#
+# server {
+# listen 6697 ssl;
+# listen [::]:6697 ssl;
+# proxy_pass irc_backend;
+#
+# ssl_certificate /etc/letsencrypt/live/echohub.example.com/fullchain.pem;
+# ssl_certificate_key /etc/letsencrypt/live/echohub.example.com/privkey.pem;
+# ssl_protocols TLSv1.2 TLSv1.3;
+#
+# # Timeout for idle IRC connections (24 hours)
+# proxy_timeout 86400s;
+# }
+# }
diff --git a/src/EchoHub.Core/Contracts/IChatBroadcaster.cs b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs
new file mode 100644
index 0000000..6e2a11c
--- /dev/null
+++ b/src/EchoHub.Core/Contracts/IChatBroadcaster.cs
@@ -0,0 +1,13 @@
+using EchoHub.Core.DTOs;
+
+namespace EchoHub.Core.Contracts;
+
+public interface IChatBroadcaster
+{
+ Task SendMessageToChannelAsync(string channelName, MessageDto message);
+ Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null);
+ Task SendUserLeftAsync(string channelName, string username);
+ Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
+ Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence);
+ Task SendErrorAsync(string connectionId, string message);
+}
diff --git a/src/EchoHub.Core/Contracts/IChatService.cs b/src/EchoHub.Core/Contracts/IChatService.cs
new file mode 100644
index 0000000..c4102fb
--- /dev/null
+++ b/src/EchoHub.Core/Contracts/IChatService.cs
@@ -0,0 +1,36 @@
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+
+namespace EchoHub.Core.Contracts;
+
+public interface IChatService
+{
+ // Connection lifecycle
+ Task UserConnectedAsync(string connectionId, Guid userId, string username);
+ Task UserDisconnectedAsync(string connectionId);
+
+ // Channel operations
+ Task<(List History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName);
+ Task LeaveChannelAsync(string connectionId, string username, string channelName);
+
+ // Messaging
+ Task SendMessageAsync(Guid userId, string username, string channelName, string content);
+ Task> GetChannelHistoryAsync(string channelName, int count);
+
+ // Presence
+ Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
+ Task> GetOnlineUsersAsync(string channelName);
+
+ // Broadcasting (used by controllers and IRC gateway)
+ Task BroadcastMessageAsync(string channelName, MessageDto message);
+ Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
+
+ // Query operations (used by IRC gateway for WHOIS, TOPIC, LIST, AUTH)
+ Task GetUserProfileAsync(string username);
+ Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
+ Task> GetChannelListAsync();
+ Task> GetChannelsForUserAsync(string username);
+ Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password);
+}
+
+public record ChannelListItem(string Name, string? Topic, int OnlineCount);
diff --git a/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj b/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj
new file mode 100644
index 0000000..750fb0f
--- /dev/null
+++ b/src/EchoHub.Server.Irc/EchoHub.Server.Irc.csproj
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/src/EchoHub.Server.Irc/IrcBroadcaster.cs b/src/EchoHub.Server.Irc/IrcBroadcaster.cs
new file mode 100644
index 0000000..9f70d3d
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcBroadcaster.cs
@@ -0,0 +1,67 @@
+using EchoHub.Core.Contracts;
+using EchoHub.Core.DTOs;
+
+namespace EchoHub.Server.Irc;
+
+public class IrcBroadcaster(IrcGatewayService gateway) : IChatBroadcaster
+{
+ public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
+ {
+ var lines = IrcMessageFormatter.FormatMessage(message);
+
+ foreach (var conn in gateway.GetConnectionsInChannel(channelName))
+ {
+ // IRC convention: don't echo sender's own message
+ if (conn.Nickname == message.SenderUsername)
+ continue;
+
+ foreach (var line in lines)
+ await conn.SendAsync(line);
+ }
+ }
+
+ public async Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
+ {
+ foreach (var conn in gateway.GetConnectionsInChannel(channelName))
+ {
+ if (conn.ConnectionId == excludeConnectionId) continue;
+ await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
+ }
+ }
+
+ public async Task SendUserLeftAsync(string channelName, string username)
+ {
+ foreach (var conn in gateway.GetConnectionsInChannel(channelName))
+ {
+ if (conn.Nickname == username) continue;
+ await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
+ }
+ }
+
+ public async Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
+ {
+ var target = channelName ?? channel.Name;
+ if (channel.Topic is null) return;
+
+ foreach (var conn in gateway.GetConnectionsInChannel(target))
+ {
+ await conn.SendAsync($":{gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
+ }
+ }
+
+ public Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence)
+ {
+ // IRC has no active status broadcast. Clients discover away via WHOIS/WHO.
+ return Task.CompletedTask;
+ }
+
+ public async Task SendErrorAsync(string connectionId, string message)
+ {
+ if (!connectionId.StartsWith("irc-")) return;
+
+ if (gateway.Connections.TryGetValue(connectionId, out var conn))
+ {
+ await conn.SendAsync($":{gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :{message}");
+ }
+ }
+}
diff --git a/src/EchoHub.Server.Irc/IrcClientConnection.cs b/src/EchoHub.Server.Irc/IrcClientConnection.cs
new file mode 100644
index 0000000..8566268
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcClientConnection.cs
@@ -0,0 +1,87 @@
+using System.Net.Sockets;
+using System.Text;
+
+namespace EchoHub.Server.Irc;
+
+///
+/// Manages a single IRC client TCP connection.
+///
+public sealed class IrcClientConnection : IAsyncDisposable
+{
+ private readonly TcpClient _tcpClient;
+ private readonly StreamReader _reader;
+ private readonly StreamWriter _writer;
+ private readonly SemaphoreSlim _writeLock = new(1, 1);
+
+ // Connection identity
+ public string ConnectionId { get; } = $"irc-{Guid.NewGuid()}";
+
+ // Registration state
+ public string? Nickname { get; set; }
+ public string? Username { get; set; }
+ public string? RealName { get; set; }
+ public string? Password { get; set; }
+ public Guid? UserId { get; set; }
+ public bool IsRegistered { get; set; }
+ public bool IsAuthenticated { get; set; }
+ public bool IsSasl { get; set; }
+ public bool CapNegotiating { get; set; }
+
+ // Channel state
+ public HashSet JoinedChannels { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ // Away state
+ public string? AwayMessage { get; set; }
+
+ public string Hostmask => $"{Nickname}!{Username ?? Nickname}@echohub";
+
+ public IrcClientConnection(TcpClient tcpClient, Stream stream)
+ {
+ _tcpClient = tcpClient;
+ _reader = new StreamReader(stream, Encoding.UTF8);
+ _writer = new StreamWriter(stream, Encoding.UTF8) { AutoFlush = true, NewLine = "\r\n" };
+ }
+
+ public async Task ReadLineAsync(CancellationToken ct)
+ {
+ try
+ {
+ return await _reader.ReadLineAsync(ct);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ public async Task SendAsync(string line)
+ {
+ await _writeLock.WaitAsync();
+ try
+ {
+ await _writer.WriteLineAsync(line);
+ }
+ catch
+ {
+ // Connection lost — swallow
+ }
+ finally
+ {
+ _writeLock.Release();
+ }
+ }
+
+ public Task SendNumericAsync(string serverName, string numeric, string target, string text)
+ => SendAsync($":{serverName} {numeric} {target} {text}");
+
+ public Task SendNumericAsync(string serverName, string numeric, string text)
+ => SendNumericAsync(serverName, numeric, Nickname ?? "*", text);
+
+ public async ValueTask DisposeAsync()
+ {
+ try { _tcpClient.Close(); } catch { }
+ _reader.Dispose();
+ _writer.Dispose();
+ _writeLock.Dispose();
+ }
+}
diff --git a/src/EchoHub.Server.Irc/IrcCommandHandler.cs b/src/EchoHub.Server.Irc/IrcCommandHandler.cs
new file mode 100644
index 0000000..1bf1a6b
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcCommandHandler.cs
@@ -0,0 +1,641 @@
+using System.Text;
+using EchoHub.Core.Constants;
+using EchoHub.Core.Contracts;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+using Microsoft.Extensions.Logging;
+
+namespace EchoHub.Server.Irc;
+
+public sealed class IrcCommandHandler
+{
+ private readonly IrcClientConnection _conn;
+ private readonly IrcOptions _options;
+ private readonly IChatService _chatService;
+ private readonly ILogger _logger;
+
+ private string ServerName => _options.ServerName;
+
+ public IrcCommandHandler(
+ IrcClientConnection conn,
+ IrcOptions options,
+ IChatService chatService,
+ ILogger logger)
+ {
+ _conn = conn;
+ _options = options;
+ _chatService = chatService;
+ _logger = logger;
+ }
+
+ public async Task RunAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var line = await _conn.ReadLineAsync(ct);
+ if (line is null) break;
+
+ line = line.TrimEnd('\r', '\n');
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ var msg = IrcMessage.Parse(line);
+
+ try
+ {
+ await HandleCommandAsync(msg);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error handling IRC command {Command} for {Nick}",
+ msg.Command, _conn.Nickname ?? "unregistered");
+ }
+ }
+ }
+
+ private Task HandleCommandAsync(IrcMessage msg)
+ {
+ var command = msg.Command.ToUpperInvariant();
+
+ return command switch
+ {
+ // Pre-registration
+ "CAP" => HandleCapAsync(msg),
+ "AUTHENTICATE" => HandleAuthenticateAsync(msg),
+ "PASS" => HandlePassAsync(msg),
+ "NICK" => HandleNickAsync(msg),
+ "USER" => HandleUserAsync(msg),
+
+ // Post-registration
+ "PING" => HandlePingAsync(msg),
+ "PONG" => Task.CompletedTask,
+ "JOIN" => HandleJoinAsync(msg),
+ "PART" => HandlePartAsync(msg),
+ "PRIVMSG" => HandlePrivmsgAsync(msg),
+ "QUIT" => HandleQuitAsync(msg),
+ "NAMES" => HandleNamesAsync(msg),
+ "TOPIC" => HandleTopicAsync(msg),
+ "WHO" => HandleWhoAsync(msg),
+ "WHOIS" => HandleWhoisAsync(msg),
+ "AWAY" => HandleAwayAsync(msg),
+ "LIST" => HandleListAsync(msg),
+ "MODE" => HandleModeAsync(msg),
+ "MOTD" => SendMotdAsync(),
+ "USERHOST" or "LUSERS" => Task.CompletedTask,
+
+ _ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
+ $"{command} :Unknown command"),
+ };
+ }
+
+ // ── Authentication ──────────────────────────────────────────────────────
+
+ private async Task HandleCapAsync(IrcMessage msg)
+ {
+ if (msg.Parameters.Count < 1) return;
+
+ switch (msg.Parameters[0].ToUpperInvariant())
+ {
+ case "LS":
+ await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
+ _conn.CapNegotiating = true;
+ break;
+
+ case "REQ":
+ if (msg.Parameters.Count >= 2 &&
+ msg.Parameters[1].Trim().Equals("sasl", StringComparison.OrdinalIgnoreCase))
+ {
+ await _conn.SendAsync($":{ServerName} CAP * ACK :sasl");
+ _conn.IsSasl = true;
+ }
+ else
+ {
+ var requested = msg.Parameters.ElementAtOrDefault(1) ?? "";
+ await _conn.SendAsync($":{ServerName} CAP * NAK :{requested}");
+ }
+ break;
+
+ case "END":
+ _conn.CapNegotiating = false;
+ if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
+ await TryCompleteRegistrationAsync();
+ break;
+ }
+ }
+
+ private async Task HandleAuthenticateAsync(IrcMessage msg)
+ {
+ if (msg.Parameters.Count < 1) return;
+
+ if (msg.Parameters[0].Equals("PLAIN", StringComparison.OrdinalIgnoreCase))
+ {
+ await _conn.SendAsync("AUTHENTICATE +");
+ return;
+ }
+
+ try
+ {
+ var decoded = Convert.FromBase64String(msg.Parameters[0]);
+ var text = Encoding.UTF8.GetString(decoded);
+ var parts = text.Split('\0');
+
+ if (parts.Length < 3)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
+ ":SASL authentication failed (malformed payload)");
+ return;
+ }
+
+ var username = (parts[1].Length > 0 ? parts[1] : parts[0]).ToLowerInvariant();
+ var password = parts[2];
+
+ var result = await _chatService.AuthenticateUserAsync(username, password);
+
+ if (result is null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
+ ":SASL authentication failed");
+ return;
+ }
+
+ _conn.Nickname = result.Value.Username;
+ _conn.UserId = result.Value.UserId;
+ _conn.IsAuthenticated = true;
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LOGGEDIN,
+ $"{_conn.Hostmask} {username} :You are now logged in as {username}");
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_SASLSUCCESS,
+ ":SASL authentication successful");
+ }
+ catch
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_SASLFAIL,
+ ":SASL authentication failed");
+ }
+ }
+
+ private Task HandlePassAsync(IrcMessage msg)
+ {
+ if (_conn.IsRegistered)
+ return _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED,
+ ":You may not reregister");
+
+ if (msg.Parameters.Count >= 1)
+ _conn.Password = msg.Parameters[0];
+
+ return Task.CompletedTask;
+ }
+
+ private async Task HandleNickAsync(IrcMessage msg)
+ {
+ if (msg.Parameters.Count < 1)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NONICKNAMEGIVEN,
+ ":No nickname given");
+ return;
+ }
+
+ var nick = msg.Parameters[0];
+
+ if (!ValidationConstants.UsernameRegex().IsMatch(nick))
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ERRONEUSNICKNAME,
+ $"{nick} :Erroneous nickname (must be 3-50 chars: a-z, 0-9, _, -)");
+ return;
+ }
+
+ _conn.Nickname = nick.ToLowerInvariant();
+
+ if (!_conn.IsRegistered && _conn.Username is not null)
+ await TryCompleteRegistrationAsync();
+ }
+
+ private async Task HandleUserAsync(IrcMessage msg)
+ {
+ if (_conn.IsRegistered)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_ALREADYREGISTERED,
+ ":You may not reregister");
+ return;
+ }
+
+ if (msg.Parameters.Count < 4)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
+ "USER :Not enough parameters");
+ return;
+ }
+
+ _conn.Username = msg.Parameters[0];
+ _conn.RealName = msg.Parameters[3];
+
+ if (_conn.Nickname is not null)
+ await TryCompleteRegistrationAsync();
+ }
+
+ private async Task TryCompleteRegistrationAsync()
+ {
+ if (_conn.CapNegotiating || _conn.IsRegistered) return;
+
+ // SASL already authenticated
+ if (_conn.IsAuthenticated && _conn.UserId is not null)
+ {
+ _conn.IsRegistered = true;
+ await _chatService.UserConnectedAsync(_conn.ConnectionId, _conn.UserId.Value, _conn.Nickname!);
+ await SendWelcomeBurstAsync();
+ return;
+ }
+
+ // PASS-based authentication
+ if (string.IsNullOrEmpty(_conn.Password))
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
+ ":Password required. Use PASS command or SASL PLAIN.");
+ await _conn.SendAsync("ERROR :Authentication failed - no password provided");
+ return;
+ }
+
+ var result = await _chatService.AuthenticateUserAsync(_conn.Nickname!, _conn.Password);
+
+ if (result is null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_PASSWDMISMATCH,
+ ":Password incorrect or account not found. Register via the EchoHub client first.");
+ await _conn.SendAsync("ERROR :Authentication failed");
+ return;
+ }
+
+ _conn.UserId = result.Value.UserId;
+ _conn.Nickname = result.Value.Username;
+ _conn.IsAuthenticated = true;
+ _conn.IsRegistered = true;
+
+ await _chatService.UserConnectedAsync(_conn.ConnectionId, result.Value.UserId, result.Value.Username);
+ await SendWelcomeBurstAsync();
+ }
+
+ // ── Welcome / MOTD ──────────────────────────────────────────────────────
+
+ private async Task SendWelcomeBurstAsync()
+ {
+ var nick = _conn.Nickname!;
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WELCOME,
+ $":Welcome to the EchoHub IRC Gateway, {nick}!");
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_YOURHOST,
+ $":Your host is {ServerName}, running EchoHub IRC Gateway");
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CREATED,
+ $":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
+ $"{ServerName} EchoHub-IRC o o");
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
+ "CHANTYPES=# NICKLEN=50 CHANNELLEN=100 :are supported by this server");
+
+ await SendMotdAsync();
+ }
+
+ private async Task SendMotdAsync()
+ {
+ if (string.IsNullOrWhiteSpace(_options.Motd))
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOMOTD,
+ ":MOTD File is missing");
+ return;
+ }
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTDSTART,
+ $":- {ServerName} Message of the day - ");
+
+ foreach (var line in _options.Motd.Split('\n'))
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MOTD,
+ $":- {line.TrimEnd('\r')}");
+ }
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFMOTD,
+ ":End of MOTD command");
+ }
+
+ // ── Channel Operations ──────────────────────────────────────────────────
+
+ private async Task HandleJoinAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+
+ if (msg.Parameters.Count < 1)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
+ "JOIN :Not enough parameters");
+ return;
+ }
+
+ var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
+
+ foreach (var rawChannel in channels)
+ {
+ var channelName = IrcToEchoHubChannel(rawChannel);
+ if (channelName is null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
+ $"{rawChannel} :Invalid channel name");
+ continue;
+ }
+
+ var (history, error) = await _chatService.JoinChannelAsync(
+ _conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName);
+
+ if (error is not null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
+ $"#{channelName} :{error}");
+ continue;
+ }
+
+ _conn.JoinedChannels.Add(channelName);
+
+ // Confirm JOIN to the client
+ await _conn.SendAsync($":{_conn.Hostmask} JOIN #{channelName}");
+
+ // Send topic
+ await SendChannelTopicAsync(channelName);
+
+ // Send NAMES list
+ await SendNamesReplyAsync(channelName);
+
+ // Replay history
+ foreach (var m in history)
+ {
+ var lines = IrcMessageFormatter.FormatMessage(m);
+ foreach (var line in lines)
+ await _conn.SendAsync(line);
+ }
+ }
+ }
+
+ private async Task HandlePartAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+ if (msg.Parameters.Count < 1) return;
+
+ var channels = msg.Parameters[0].Split(',', StringSplitOptions.RemoveEmptyEntries);
+ var partMessage = msg.Parameters.Count > 1 ? msg.Parameters[1] : null;
+
+ foreach (var rawChannel in channels)
+ {
+ var channelName = IrcToEchoHubChannel(rawChannel);
+ if (channelName is null) continue;
+
+ await _chatService.LeaveChannelAsync(_conn.ConnectionId, _conn.Nickname!, channelName);
+ _conn.JoinedChannels.Remove(channelName);
+
+ await _conn.SendAsync($":{_conn.Hostmask} PART #{channelName}" +
+ (partMessage is not null ? $" :{partMessage}" : ""));
+ }
+ }
+
+ private async Task HandlePrivmsgAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+
+ if (msg.Parameters.Count < 2)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NEEDMOREPARAMS,
+ "PRIVMSG :Not enough parameters");
+ return;
+ }
+
+ var target = msg.Parameters[0];
+ var content = msg.Parameters[1];
+
+ if (!target.StartsWith('#'))
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK,
+ $"{target} :Private messages are not supported. Use channels.");
+ return;
+ }
+
+ var channelName = IrcToEchoHubChannel(target);
+ if (channelName is null) return;
+
+ var error = await _chatService.SendMessageAsync(
+ _conn.UserId!.Value, _conn.Nickname!, channelName, content);
+
+ if (error is not null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CANNOTSENDTOCHAN,
+ $"#{channelName} :{error}");
+ }
+ }
+
+ private async Task HandleQuitAsync(IrcMessage msg)
+ {
+ var quitMessage = msg.Parameters.Count > 0 ? msg.Parameters[0] : "Client quit";
+ await _conn.SendAsync($"ERROR :Closing Link: {_conn.Nickname} ({quitMessage})");
+ }
+
+ // ── Query Commands ──────────────────────────────────────────────────────
+
+ private async Task HandleNamesAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+ if (msg.Parameters.Count < 1) return;
+
+ var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
+ if (channelName is null) return;
+
+ await SendNamesReplyAsync(channelName);
+ }
+
+ private async Task SendNamesReplyAsync(string channelName)
+ {
+ var users = await _chatService.GetOnlineUsersAsync(channelName);
+ var nicks = string.Join(" ", users.Select(u => u.Username));
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NAMREPLY,
+ $"= #{channelName} :{nicks}");
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFNAMES,
+ $"#{channelName} :End of /NAMES list");
+ }
+
+ private async Task HandleTopicAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+ if (msg.Parameters.Count < 1) return;
+
+ var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
+ if (channelName is null) return;
+
+ if (msg.Parameters.Count == 1)
+ {
+ await SendChannelTopicAsync(channelName);
+ }
+ else
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CHANOPRIVSNEEDED,
+ $"#{channelName} :Topic can only be changed by the channel creator via the API");
+ }
+ }
+
+ private async Task SendChannelTopicAsync(string channelName)
+ {
+ var (topic, exists) = await _chatService.GetChannelTopicAsync(channelName);
+
+ if (!exists) return;
+
+ if (topic is not null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_TOPIC,
+ $"#{channelName} :{topic}");
+ }
+ else
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOTOPIC,
+ $"#{channelName} :No topic is set");
+ }
+ }
+
+ private async Task HandleWhoAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+ if (msg.Parameters.Count < 1) return;
+
+ var channelName = IrcToEchoHubChannel(msg.Parameters[0]);
+ if (channelName is null) return;
+
+ var users = await _chatService.GetOnlineUsersAsync(channelName);
+
+ foreach (var u in users)
+ {
+ var awayFlag = u.Status == UserStatus.Away ? "G" : "H";
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOREPLY,
+ $"#{channelName} {u.Username} echohub {ServerName} {u.Username} {awayFlag} :0 {u.DisplayName ?? u.Username}");
+ }
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHO,
+ $"#{channelName} :End of WHO list");
+ }
+
+ private async Task HandleWhoisAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+ if (msg.Parameters.Count < 1) return;
+
+ var nick = msg.Parameters[^1].ToLowerInvariant();
+ var profile = await _chatService.GetUserProfileAsync(nick);
+
+ if (profile is null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHNICK,
+ $"{nick} :No such nick/channel");
+ return;
+ }
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISUSER,
+ $"{nick} {nick} echohub * :{profile.DisplayName ?? nick}");
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISSERVER,
+ $"{nick} {ServerName} :EchoHub IRC Gateway");
+
+ var channels = await _chatService.GetChannelsForUserAsync(nick);
+ if (channels.Count > 0)
+ {
+ var chanList = string.Join(" ", channels.Select(c => $"#{c}"));
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISCHANNELS,
+ $"{nick} :{chanList}");
+ }
+
+ if (profile.Status == UserStatus.Away && profile.StatusMessage is not null)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_AWAY,
+ $"{nick} :{profile.StatusMessage}");
+ }
+
+ var idleSeconds = (long)(DateTimeOffset.UtcNow - profile.LastSeenAt).TotalSeconds;
+ var signonUnix = profile.CreatedAt.ToUnixTimeSeconds();
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_WHOISIDLE,
+ $"{nick} {idleSeconds} {signonUnix} :seconds idle, signon time");
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ENDOFWHOIS,
+ $"{nick} :End of WHOIS list");
+ }
+
+ private async Task HandleAwayAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+
+ if (msg.Parameters.Count > 0 && !string.IsNullOrWhiteSpace(msg.Parameters[0]))
+ {
+ _conn.AwayMessage = msg.Parameters[0];
+ await _chatService.UpdateStatusAsync(
+ _conn.UserId!.Value, _conn.Nickname!, UserStatus.Away, _conn.AwayMessage);
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_NOWAWAY,
+ ":You have been marked as being away");
+ }
+ else
+ {
+ _conn.AwayMessage = null;
+ await _chatService.UpdateStatusAsync(
+ _conn.UserId!.Value, _conn.Nickname!, UserStatus.Online, null);
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UNAWAY,
+ ":You are no longer marked as being away");
+ }
+ }
+
+ private async Task HandleListAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+
+ var channels = await _chatService.GetChannelListAsync();
+
+ foreach (var ch in channels)
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LIST,
+ $"#{ch.Name} {ch.OnlineCount} :{ch.Topic ?? ""}");
+ }
+
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_LISTEND,
+ ":End of LIST");
+ }
+
+ private async Task HandleModeAsync(IrcMessage msg)
+ {
+ if (!RequireRegistered()) return;
+ if (msg.Parameters.Count < 1) return;
+
+ var target = msg.Parameters[0];
+
+ if (target.StartsWith('#'))
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_CHANNELMODEIS,
+ $"{target} +");
+ }
+ else
+ {
+ await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_UMODEIS, "+");
+ }
+ }
+
+ private async Task HandlePingAsync(IrcMessage msg)
+ {
+ var token = msg.Parameters.Count > 0 ? msg.Parameters[0] : ServerName;
+ await _conn.SendAsync($":{ServerName} PONG {ServerName} :{token}");
+ }
+
+ // ── Helpers ──────────────────────────────────────────────────────────────
+
+ private bool RequireRegistered()
+ {
+ if (_conn.IsRegistered) return true;
+
+ _ = _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOTREGISTERED,
+ ":You have not registered");
+ return false;
+ }
+
+ private static string? IrcToEchoHubChannel(string ircChannel)
+ {
+ if (!ircChannel.StartsWith('#') || ircChannel.Length < 2)
+ return null;
+
+ var name = ircChannel[1..].ToLowerInvariant().Trim();
+ return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
+ }
+}
diff --git a/src/EchoHub.Server.Irc/IrcGatewayService.cs b/src/EchoHub.Server.Irc/IrcGatewayService.cs
new file mode 100644
index 0000000..65c02ae
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcGatewayService.cs
@@ -0,0 +1,157 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Cryptography.X509Certificates;
+using EchoHub.Core.Contracts;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace EchoHub.Server.Irc;
+
+public sealed class IrcGatewayService : BackgroundService
+{
+ private readonly IrcOptions _options;
+ private readonly IChatService _chatService;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _connections = new();
+
+ public IrcOptions Options => _options;
+ public IReadOnlyDictionary Connections => _connections;
+
+ public IrcGatewayService(
+ IOptions options,
+ IChatService chatService,
+ ILogger logger)
+ {
+ _options = options.Value;
+ _chatService = chatService;
+ _logger = logger;
+ }
+
+ public IEnumerable GetConnectionsInChannel(string channelName)
+ {
+ return _connections.Values
+ .Where(c => c.IsAuthenticated && c.JoinedChannels.Contains(channelName));
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ await Task.Yield();
+
+ if (!_options.Enabled)
+ {
+ _logger.LogInformation("IRC gateway is disabled");
+ return;
+ }
+
+ var listeners = new List();
+
+ listeners.Add(RunListenerAsync(_options.Port, useTls: false, stoppingToken));
+
+ if (_options.TlsEnabled && !string.IsNullOrWhiteSpace(_options.TlsCertPath))
+ {
+ listeners.Add(RunListenerAsync(_options.TlsPort, useTls: true, stoppingToken));
+ }
+
+ await Task.WhenAll(listeners);
+ }
+
+ private async Task RunListenerAsync(int port, bool useTls, CancellationToken ct)
+ {
+ var listener = new TcpListener(IPAddress.Any, port);
+ listener.Start();
+ _logger.LogInformation("IRC gateway listening on port {Port} ({Mode})",
+ port, useTls ? "TLS" : "plain");
+
+ ct.Register(() => listener.Stop());
+
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ var tcpClient = await listener.AcceptTcpClientAsync(ct);
+ _ = HandleClientAsync(tcpClient, useTls, ct);
+ }
+ }
+ catch (OperationCanceledException) { }
+ catch (ObjectDisposedException) { }
+ finally
+ {
+ listener.Stop();
+ }
+ }
+
+ private async Task HandleClientAsync(TcpClient tcpClient, bool useTls, CancellationToken ct)
+ {
+ Stream stream = tcpClient.GetStream();
+
+ if (useTls)
+ {
+ try
+ {
+ var cert = X509CertificateLoader.LoadPkcs12FromFile(_options.TlsCertPath!, _options.TlsCertPassword);
+ var sslStream = new SslStream(stream, leaveInnerStreamOpen: false);
+ await sslStream.AuthenticateAsServerAsync(cert);
+ stream = sslStream;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "TLS handshake failed");
+ tcpClient.Close();
+ return;
+ }
+ }
+
+ var connection = new IrcClientConnection(tcpClient, stream);
+ _connections[connection.ConnectionId] = connection;
+
+ _logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
+
+ try
+ {
+ var handler = new IrcCommandHandler(
+ connection, _options, _chatService, _logger);
+
+ await handler.RunAsync(ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "IRC client {Id} error", connection.ConnectionId);
+ }
+ finally
+ {
+ if (connection.IsAuthenticated)
+ {
+ foreach (var ch in connection.JoinedChannels.ToList())
+ {
+ await _chatService.LeaveChannelAsync(
+ connection.ConnectionId, connection.Nickname!, ch);
+ }
+ await _chatService.UserDisconnectedAsync(connection.ConnectionId);
+ }
+
+ _connections.TryRemove(connection.ConnectionId, out _);
+ await connection.DisposeAsync();
+ _logger.LogInformation("IRC client {Id} ({Nick}) disconnected",
+ connection.ConnectionId, connection.Nickname ?? "unregistered");
+ }
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ foreach (var (_, conn) in _connections)
+ {
+ try
+ {
+ await conn.SendAsync("ERROR :Server shutting down");
+ await conn.DisposeAsync();
+ }
+ catch { }
+ }
+ _connections.Clear();
+
+ await base.StopAsync(cancellationToken);
+ }
+}
diff --git a/src/EchoHub.Server.Irc/IrcMessage.cs b/src/EchoHub.Server.Irc/IrcMessage.cs
new file mode 100644
index 0000000..f84160f
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcMessage.cs
@@ -0,0 +1,69 @@
+namespace EchoHub.Server.Irc;
+
+///
+/// Parsed representation of an IRC protocol line.
+/// Format: [:prefix] COMMAND [params...] [:trailing]
+///
+public sealed class IrcMessage
+{
+ public string? Prefix { get; init; }
+ public string Command { get; init; } = "";
+ public List Parameters { get; init; } = [];
+
+ public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
+
+ ///
+ /// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
+ ///
+ public static IrcMessage Parse(string line)
+ {
+ var span = line.AsSpan().TrimEnd("\r\n");
+ string? prefix = null;
+ var pos = 0;
+
+ // Parse optional prefix
+ if (span.Length > 0 && span[0] == ':')
+ {
+ var spaceIdx = span.IndexOf(' ');
+ if (spaceIdx == -1)
+ return new IrcMessage { Prefix = span[1..].ToString() };
+
+ prefix = span[1..spaceIdx].ToString();
+ pos = spaceIdx + 1;
+ }
+
+ // Skip whitespace
+ while (pos < span.Length && span[pos] == ' ') pos++;
+
+ // Parse command
+ var cmdStart = pos;
+ while (pos < span.Length && span[pos] != ' ') pos++;
+ var command = span[cmdStart..pos].ToString();
+
+ // Parse parameters
+ var parameters = new List();
+ while (pos < span.Length)
+ {
+ while (pos < span.Length && span[pos] == ' ') pos++;
+ if (pos >= span.Length) break;
+
+ if (span[pos] == ':')
+ {
+ // Trailing parameter (rest of line)
+ parameters.Add(span[(pos + 1)..].ToString());
+ break;
+ }
+
+ var paramStart = pos;
+ while (pos < span.Length && span[pos] != ' ') pos++;
+ parameters.Add(span[paramStart..pos].ToString());
+ }
+
+ return new IrcMessage
+ {
+ Prefix = prefix,
+ Command = command,
+ Parameters = parameters,
+ };
+ }
+}
diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs
new file mode 100644
index 0000000..a161313
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs
@@ -0,0 +1,80 @@
+using System.Text;
+using EchoHub.Core.DTOs;
+using EchoHub.Core.Models;
+
+namespace EchoHub.Server.Irc;
+
+public static class IrcMessageFormatter
+{
+ private const int MaxIrcLineContentBytes = 400;
+
+ ///
+ /// Format a MessageDto as one or more IRC PRIVMSG lines.
+ ///
+ public static List FormatMessage(MessageDto message)
+ {
+ var lines = new List();
+ var ircChannel = $"#{message.ChannelName}";
+ var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
+
+ switch (message.Type)
+ {
+ case MessageType.Text:
+ foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
+ lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
+ break;
+
+ case MessageType.Image:
+ lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {message.AttachmentFileName}]");
+ if (message.AttachmentUrl is not null)
+ lines.Add($"{prefix} PRIVMSG {ircChannel} :Download: {message.AttachmentUrl}");
+
+ foreach (var line in message.Content.Split('\n'))
+ {
+ var trimmed = line.TrimEnd('\r');
+ if (trimmed.Length > 0)
+ lines.Add($"{prefix} PRIVMSG {ircChannel} :{trimmed}");
+ }
+ break;
+
+ case MessageType.File:
+ lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {message.AttachmentFileName}] {message.AttachmentUrl}");
+ break;
+ }
+
+ return lines;
+ }
+
+ ///
+ /// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
+ ///
+ public static List SplitMessage(string content, int maxBytes)
+ {
+ if (Encoding.UTF8.GetByteCount(content) <= maxBytes)
+ return [content];
+
+ var chunks = new List();
+ var current = new StringBuilder();
+ var currentBytes = 0;
+
+ foreach (var word in content.Split(' '))
+ {
+ var wordBytes = Encoding.UTF8.GetByteCount(word) + 1; // +1 for space
+
+ if (currentBytes + wordBytes > maxBytes && current.Length > 0)
+ {
+ chunks.Add(current.ToString().TrimEnd());
+ current.Clear();
+ currentBytes = 0;
+ }
+
+ current.Append(word).Append(' ');
+ currentBytes += wordBytes;
+ }
+
+ if (current.Length > 0)
+ chunks.Add(current.ToString().TrimEnd());
+
+ return chunks;
+ }
+}
diff --git a/src/EchoHub.Server.Irc/IrcNumericReply.cs b/src/EchoHub.Server.Irc/IrcNumericReply.cs
new file mode 100644
index 0000000..60fdb21
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcNumericReply.cs
@@ -0,0 +1,65 @@
+namespace EchoHub.Server.Irc;
+
+public static class IrcNumericReply
+{
+ // Connection registration
+ public const string RPL_WELCOME = "001";
+ public const string RPL_YOURHOST = "002";
+ public const string RPL_CREATED = "003";
+ public const string RPL_MYINFO = "004";
+ public const string RPL_ISUPPORT = "005";
+
+ // MOTD
+ public const string RPL_MOTDSTART = "375";
+ public const string RPL_MOTD = "372";
+ public const string RPL_ENDOFMOTD = "376";
+ public const string ERR_NOMOTD = "422";
+
+ // Channel operations
+ public const string RPL_NOTOPIC = "331";
+ public const string RPL_TOPIC = "332";
+ public const string RPL_NAMREPLY = "353";
+ public const string RPL_ENDOFNAMES = "366";
+
+ // LIST
+ public const string RPL_LIST = "322";
+ public const string RPL_LISTEND = "323";
+
+ // WHO / WHOIS
+ public const string RPL_WHOREPLY = "352";
+ public const string RPL_ENDOFWHO = "315";
+ public const string RPL_WHOISUSER = "311";
+ public const string RPL_WHOISSERVER = "312";
+ public const string RPL_WHOISIDLE = "317";
+ public const string RPL_ENDOFWHOIS = "318";
+ public const string RPL_WHOISCHANNELS = "319";
+
+ // AWAY
+ public const string RPL_UNAWAY = "305";
+ public const string RPL_NOWAWAY = "306";
+ public const string RPL_AWAY = "301";
+
+ // MODE
+ public const string RPL_CHANNELMODEIS = "324";
+ public const string RPL_UMODEIS = "221";
+
+ // Errors
+ public const string ERR_NOSUCHNICK = "401";
+ public const string ERR_NOSUCHCHANNEL = "403";
+ public const string ERR_CANNOTSENDTOCHAN = "404";
+ public const string ERR_UNKNOWNCOMMAND = "421";
+ public const string ERR_NONICKNAMEGIVEN = "431";
+ public const string ERR_ERRONEUSNICKNAME = "432";
+ public const string ERR_NICKNAMEINUSE = "433";
+ public const string ERR_NOTONCHANNEL = "442";
+ public const string ERR_NOTREGISTERED = "451";
+ public const string ERR_NEEDMOREPARAMS = "461";
+ public const string ERR_ALREADYREGISTERED = "462";
+ public const string ERR_PASSWDMISMATCH = "464";
+ public const string ERR_CHANOPRIVSNEEDED = "482";
+
+ // SASL
+ public const string RPL_LOGGEDIN = "900";
+ public const string RPL_SASLSUCCESS = "903";
+ public const string ERR_SASLFAIL = "904";
+}
diff --git a/src/EchoHub.Server.Irc/IrcOptions.cs b/src/EchoHub.Server.Irc/IrcOptions.cs
new file mode 100644
index 0000000..2bfa9bd
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcOptions.cs
@@ -0,0 +1,15 @@
+namespace EchoHub.Server.Irc;
+
+public sealed class IrcOptions
+{
+ public const string SectionName = "Irc";
+
+ public bool Enabled { get; set; }
+ public int Port { get; set; } = 6667;
+ public bool TlsEnabled { get; set; }
+ public int TlsPort { get; set; } = 6697;
+ public string? TlsCertPath { get; set; }
+ public string? TlsCertPassword { get; set; }
+ public string ServerName { get; set; } = "echohub";
+ public string? Motd { get; set; }
+}
diff --git a/src/EchoHub.Server.Irc/IrcServiceExtensions.cs b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs
new file mode 100644
index 0000000..14c096b
--- /dev/null
+++ b/src/EchoHub.Server.Irc/IrcServiceExtensions.cs
@@ -0,0 +1,26 @@
+using EchoHub.Core.Contracts;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EchoHub.Server.Irc;
+
+public static class IrcServiceExtensions
+{
+ public static WebApplicationBuilder AddIrcGateway(this WebApplicationBuilder builder)
+ {
+ builder.Services.Configure(
+ builder.Configuration.GetSection(IrcOptions.SectionName));
+
+ if (builder.Configuration.GetValue("Irc:Enabled"))
+ {
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(sp =>
+ new IrcBroadcaster(sp.GetRequiredService()));
+ builder.Services.AddHostedService(sp =>
+ sp.GetRequiredService());
+ }
+
+ return builder;
+ }
+}
diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs
index 93f4770..5d853d0 100644
--- a/src/EchoHub.Server/Controllers/ChannelsController.cs
+++ b/src/EchoHub.Server/Controllers/ChannelsController.cs
@@ -4,12 +4,10 @@ using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
-using EchoHub.Server.Hubs;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
-using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -23,7 +21,7 @@ public class ChannelsController(
FileStorageService fileStorage,
ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory,
- IHubContext hubContext) : ControllerBase
+ IChatService chatService) : ControllerBase
{
[HttpGet]
public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
@@ -78,7 +76,7 @@ public class ChannelsController(
await db.SaveChangesAsync();
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
- await hubContext.Clients.All.ChannelUpdated(dto);
+ await chatService.BroadcastChannelUpdatedAsync(dto);
return Created($"/api/channels/{channelName}", dto);
}
@@ -107,7 +105,7 @@ public class ChannelsController(
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
- await hubContext.Clients.Group(channelName).ChannelUpdated(dto);
+ await chatService.BroadcastChannelUpdatedAsync(dto, channelName);
return Ok(dto);
}
@@ -214,7 +212,7 @@ public class ChannelsController(
file.FileName,
message.SentAt);
- await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
+ await chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto);
}
@@ -331,7 +329,7 @@ public class ChannelsController(
fileName,
message.SentAt);
- await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
+ await chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto);
}
diff --git a/src/EchoHub.Server/EchoHub.Server.csproj b/src/EchoHub.Server/EchoHub.Server.csproj
index 446b65c..93bbe03 100644
--- a/src/EchoHub.Server/EchoHub.Server.csproj
+++ b/src/EchoHub.Server/EchoHub.Server.csproj
@@ -2,6 +2,7 @@
+
diff --git a/src/EchoHub.Server/Hubs/ChatHub.cs b/src/EchoHub.Server/Hubs/ChatHub.cs
index c9c42de..eac6401 100644
--- a/src/EchoHub.Server/Hubs/ChatHub.cs
+++ b/src/EchoHub.Server/Hubs/ChatHub.cs
@@ -3,16 +3,13 @@ using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
-using EchoHub.Server.Data;
-using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
-using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Hubs;
[Authorize]
-public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTracker presenceTracker) : Hub
+public class ChatHub(IChatService chatService, ILogger logger) : Hub
{
private Guid CurrentUserId =>
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -26,19 +23,8 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
{
try
{
- presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
-
- var user = await db.Users.FindAsync(CurrentUserId);
- if (user is not null)
- {
- user.LastSeenAt = DateTimeOffset.UtcNow;
- user.Status = UserStatus.Online;
- await db.SaveChangesAsync();
- }
-
+ await chatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername);
await base.OnConnectedAsync();
-
- logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
}
catch (Exception ex)
{
@@ -51,39 +37,8 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
{
try
{
- var preDisconnectUsername = Context.User?.FindFirstValue("username");
- var channelsBeforeDisconnect = preDisconnectUsername is not null
- ? presenceTracker.GetChannelsForUser(preDisconnectUsername)
- : [];
-
- var username = presenceTracker.UserDisconnected(Context.ConnectionId);
-
- if (username is not null && !presenceTracker.IsOnline(username))
- {
- var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
- if (user is not null)
- {
- user.LastSeenAt = DateTimeOffset.UtcNow;
- user.Status = UserStatus.Invisible;
- await db.SaveChangesAsync();
-
- var presence = new UserPresenceDto(
- username,
- user.DisplayName,
- user.NicknameColor,
- UserStatus.Invisible,
- user.StatusMessage);
-
- foreach (var channel in channelsBeforeDisconnect)
- {
- await Clients.Group(channel).UserStatusChanged(presence);
- }
- }
- }
-
+ await chatService.UserDisconnectedAsync(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
-
- logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
}
catch (Exception ex)
{
@@ -96,32 +51,16 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
{
try
{
- channelName = channelName.ToLowerInvariant().Trim();
+ var (history, error) = await chatService.JoinChannelAsync(
+ Context.ConnectionId, CurrentUserId, CurrentUsername, channelName);
- if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
+ if (error is not null)
{
- await Clients.Caller.Error("Invalid channel name. Use 2-100 characters: letters, digits, underscores, or hyphens.");
+ await Clients.Caller.Error(error);
return [];
}
- var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
-
- if (channel is null)
- {
- await Clients.Caller.Error($"Channel '{channelName}' does not exist. Create it first via the channel list.");
- return [];
- }
-
- var isNewJoin = presenceTracker.JoinChannel(CurrentUsername, channelName);
-
- if (isNewJoin)
- {
- await Groups.AddToGroupAsync(Context.ConnectionId, channelName);
- await Clients.OthersInGroup(channelName).UserJoined(channelName, CurrentUsername);
- logger.LogInformation("{User} joined channel '{Channel}'", CurrentUsername, channelName);
- }
-
- var history = await GetChannelHistory(channelName, HubConstants.DefaultHistoryCount);
+ await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
return history;
}
catch (Exception ex)
@@ -137,13 +76,8 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
try
{
channelName = channelName.ToLowerInvariant().Trim();
-
- presenceTracker.LeaveChannel(CurrentUsername, channelName);
-
+ await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName);
- await Clients.OthersInGroup(channelName).UserLeft(channelName, CurrentUsername);
-
- logger.LogInformation("{User} left channel '{Channel}'", CurrentUsername, channelName);
}
catch (Exception ex)
{
@@ -156,64 +90,9 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
{
try
{
- channelName = channelName.ToLowerInvariant().Trim();
-
- if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
- {
- await Clients.Caller.Error("Invalid channel name.");
- return;
- }
-
- if (string.IsNullOrWhiteSpace(content))
- {
- await Clients.Caller.Error("Message content cannot be empty.");
- return;
- }
-
- if (content.Length > HubConstants.MaxMessageLength)
- {
- await Clients.Caller.Error($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.");
- return;
- }
-
- var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
-
- if (channel is null)
- {
- await Clients.Caller.Error($"Channel '{channelName}' does not exist.");
- return;
- }
-
- var sender = await db.Users.FindAsync(CurrentUserId);
-
- var message = new Message
- {
- Id = Guid.NewGuid(),
- Content = content,
- Type = MessageType.Text,
- SentAt = DateTimeOffset.UtcNow,
- ChannelId = channel.Id,
- SenderUserId = CurrentUserId,
- SenderUsername = CurrentUsername,
- };
-
- db.Messages.Add(message);
- await db.SaveChangesAsync();
-
- var messageDto = new MessageDto(
- message.Id,
- message.Content,
- message.SenderUsername,
- sender?.NicknameColor,
- channelName,
- MessageType.Text,
- null,
- null,
- message.SentAt);
-
- await Clients.Group(channelName).ReceiveMessage(messageDto);
-
- logger.LogDebug("{User} sent message in '{Channel}'", CurrentUsername, channelName);
+ var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
+ if (error is not null)
+ await Clients.Caller.Error(error);
}
catch (Exception ex)
{
@@ -226,35 +105,7 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
{
try
{
- channelName = channelName.ToLowerInvariant().Trim();
- count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
-
- var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
-
- if (channel is null)
- return [];
-
- var messages = await db.Messages
- .Where(m => m.ChannelId == channel.Id)
- .OrderByDescending(m => m.SentAt)
- .Take(count)
- .Join(db.Users,
- m => m.SenderUserId,
- u => u.Id,
- (m, u) => new MessageDto(
- m.Id,
- m.Content,
- m.SenderUsername,
- u.NicknameColor,
- channelName,
- m.Type,
- m.AttachmentUrl,
- m.AttachmentFileName,
- m.SentAt))
- .ToListAsync();
-
- messages.Reverse();
- return messages;
+ return await chatService.GetChannelHistoryAsync(channelName, count);
}
catch (Exception ex)
{
@@ -268,37 +119,9 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
{
try
{
- if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
- {
- await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
- return;
- }
-
- var user = await db.Users.FindAsync(CurrentUserId);
-
- if (user is null)
- {
- await Clients.Caller.Error("User not found.");
- return;
- }
-
- user.Status = status;
- user.StatusMessage = statusMessage?.Trim();
- user.LastSeenAt = DateTimeOffset.UtcNow;
- await db.SaveChangesAsync();
-
- var presence = new UserPresenceDto(
- user.Username,
- user.DisplayName,
- user.NicknameColor,
- status,
- statusMessage);
-
- var channels = presenceTracker.GetChannelsForUser(CurrentUsername);
- var connections = presenceTracker.GetConnectionsInChannels(channels);
-
- if (connections.Count > 0)
- await Clients.Clients(connections).UserStatusChanged(presence);
+ var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
+ if (error is not null)
+ await Clients.Caller.Error(error);
}
catch (Exception ex)
{
@@ -311,21 +134,7 @@ public class ChatHub(EchoHubDbContext db, ILogger logger, PresenceTrack
{
try
{
- channelName = channelName.ToLowerInvariant().Trim();
-
- var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
-
- var users = await db.Users
- .Where(u => onlineUsernames.Contains(u.Username))
- .Select(u => new UserPresenceDto(
- u.Username,
- u.DisplayName,
- u.NicknameColor,
- u.Status,
- u.StatusMessage))
- .ToListAsync();
-
- return users;
+ return await chatService.GetOnlineUsersAsync(channelName);
}
catch (Exception ex)
{
diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs
index ccbae80..33ca3b8 100644
--- a/src/EchoHub.Server/Program.cs
+++ b/src/EchoHub.Server/Program.cs
@@ -1,10 +1,12 @@
using System.Text;
using System.Threading.RateLimiting;
using EchoHub.Core.Constants;
+using EchoHub.Core.Contracts;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
+using EchoHub.Server.Irc;
using EchoHub.Server.Services;
using EchoHub.Server.Setup;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -101,6 +103,14 @@ while (true)
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
+
+ // ── Chat Service + Broadcasters ─────────────────────────────────────
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
+
+ // ── IRC Gateway (optional) ──────────────────────────────────────────
+ builder.AddIrcGateway();
+
builder.Services.AddHttpClient("ImageDownload", client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs
new file mode 100644
index 0000000..900adcc
--- /dev/null
+++ b/src/EchoHub.Server/Services/ChatService.cs
@@ -0,0 +1,330 @@
+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 ChatService(
+ IServiceScopeFactory scopeFactory,
+ PresenceTracker presenceTracker,
+ IEnumerable broadcasters,
+ ILogger logger) : IChatService
+{
+ public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
+ {
+ presenceTracker.UserConnected(connectionId, userId, username);
+
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var user = await db.Users.FindAsync(userId);
+ if (user is not null)
+ {
+ user.LastSeenAt = DateTimeOffset.UtcNow;
+ user.Status = UserStatus.Online;
+ await db.SaveChangesAsync();
+ }
+
+ logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
+ }
+
+ public async Task UserDisconnectedAsync(string connectionId)
+ {
+ var preDisconnectUsername = presenceTracker.GetUsernameForConnection(connectionId);
+ var channelsBeforeDisconnect = preDisconnectUsername is not null
+ ? presenceTracker.GetChannelsForUser(preDisconnectUsername)
+ : [];
+
+ var username = presenceTracker.UserDisconnected(connectionId);
+
+ if (username is not null && !presenceTracker.IsOnline(username))
+ {
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
+ if (user is not null)
+ {
+ user.LastSeenAt = DateTimeOffset.UtcNow;
+ user.Status = UserStatus.Invisible;
+ await db.SaveChangesAsync();
+
+ var presence = new UserPresenceDto(
+ username,
+ user.DisplayName,
+ user.NicknameColor,
+ UserStatus.Invisible,
+ user.StatusMessage);
+
+ await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channelsBeforeDisconnect, presence));
+ }
+ }
+
+ logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
+ return username;
+ }
+
+ public async Task<(List History, string? Error)> JoinChannelAsync(
+ string connectionId, Guid userId, string username, string channelName)
+ {
+ 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();
+
+ 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.");
+
+ var isNewJoin = presenceTracker.JoinChannel(username, channelName);
+
+ if (isNewJoin)
+ {
+ await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, connectionId));
+ logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
+ }
+
+ var history = await GetChannelHistoryInternalAsync(db, channelName, HubConstants.DefaultHistoryCount);
+ return (history, null);
+ }
+
+ public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+ presenceTracker.LeaveChannel(username, channelName);
+ await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
+ logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
+ }
+
+ public async Task SendMessageAsync(Guid userId, string username, string channelName, string content)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+
+ if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
+ return "Invalid channel name.";
+
+ if (string.IsNullOrWhiteSpace(content))
+ return "Message content cannot be empty.";
+
+ if (content.Length > HubConstants.MaxMessageLength)
+ return $"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters.";
+
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ if (channel is null)
+ return $"Channel '{channelName}' does not exist.";
+
+ var sender = await db.Users.FindAsync(userId);
+
+ var message = new Message
+ {
+ Id = Guid.NewGuid(),
+ Content = content,
+ Type = MessageType.Text,
+ SentAt = DateTimeOffset.UtcNow,
+ ChannelId = channel.Id,
+ SenderUserId = userId,
+ SenderUsername = username,
+ };
+
+ db.Messages.Add(message);
+ await db.SaveChangesAsync();
+
+ var messageDto = new MessageDto(
+ message.Id,
+ message.Content,
+ message.SenderUsername,
+ sender?.NicknameColor,
+ channelName,
+ MessageType.Text,
+ null,
+ null,
+ message.SentAt);
+
+ await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
+
+ logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
+ return null;
+ }
+
+ public async Task> GetChannelHistoryAsync(string channelName, int count)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+ count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
+
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ return await GetChannelHistoryInternalAsync(db, channelName, count);
+ }
+
+ public async Task UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
+ {
+ if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
+ return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
+
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var user = await db.Users.FindAsync(userId);
+ if (user is null)
+ return "User not found.";
+
+ user.Status = status;
+ user.StatusMessage = statusMessage?.Trim();
+ user.LastSeenAt = DateTimeOffset.UtcNow;
+ await db.SaveChangesAsync();
+
+ var presence = new UserPresenceDto(
+ user.Username,
+ user.DisplayName,
+ user.NicknameColor,
+ status,
+ statusMessage);
+
+ var channels = presenceTracker.GetChannelsForUser(username);
+ await BroadcastToAllAsync(b => b.SendUserStatusChangedAsync(channels, presence));
+
+ return null;
+ }
+
+ public async Task> GetOnlineUsersAsync(string channelName)
+ {
+ channelName = channelName.ToLowerInvariant().Trim();
+ var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
+
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ return await db.Users
+ .Where(u => onlineUsernames.Contains(u.Username))
+ .Select(u => new UserPresenceDto(
+ u.Username,
+ u.DisplayName,
+ u.NicknameColor,
+ u.Status,
+ u.StatusMessage))
+ .ToListAsync();
+ }
+
+ public Task BroadcastMessageAsync(string channelName, MessageDto message)
+ => BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, message));
+
+ public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
+ => BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName));
+
+ private async Task BroadcastToAllAsync(Func action)
+ {
+ foreach (var broadcaster in broadcasters)
+ {
+ try
+ {
+ await action(broadcaster);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Broadcaster {Type} failed", broadcaster.GetType().Name);
+ }
+ }
+ }
+
+ public async Task GetUserProfileAsync(string username)
+ {
+ username = username.ToLowerInvariant();
+
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
+ if (user is null) return null;
+
+ return new UserProfileDto(
+ user.Id, user.Username, user.DisplayName, user.Bio,
+ user.NicknameColor, user.AvatarAscii, user.Status,
+ user.StatusMessage, 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();
+
+ var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ if (channel is null) return (null, false);
+
+ return (channel.Topic, true);
+ }
+
+ public async Task> GetChannelListAsync()
+ {
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ 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> GetChannelsForUserAsync(string username)
+ => Task.FromResult(presenceTracker.GetChannelsForUser(username));
+
+ public async Task<(Guid UserId, string Username)?> AuthenticateUserAsync(string username, string password)
+ {
+ username = username.ToLowerInvariant();
+
+ using var scope = scopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var user = await db.Users.FirstOrDefaultAsync(u => u.Username == username);
+ if (user is null) return null;
+
+ if (!BCrypt.Net.BCrypt.Verify(password, user.PasswordHash))
+ return null;
+
+ return (user.Id, user.Username);
+ }
+
+ private static async Task> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count)
+ {
+ var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
+ if (channel is null)
+ return [];
+
+ var messages = await db.Messages
+ .Where(m => m.ChannelId == channel.Id)
+ .OrderByDescending(m => m.SentAt)
+ .Take(count)
+ .Join(db.Users,
+ m => m.SenderUserId,
+ u => u.Id,
+ (m, u) => new MessageDto(
+ m.Id,
+ m.Content,
+ m.SenderUsername,
+ u.NicknameColor,
+ channelName,
+ m.Type,
+ m.AttachmentUrl,
+ m.AttachmentFileName,
+ m.SentAt))
+ .ToListAsync();
+
+ messages.Reverse();
+ return messages;
+ }
+}
diff --git a/src/EchoHub.Server/Services/PresenceTracker.cs b/src/EchoHub.Server/Services/PresenceTracker.cs
index eccb9f4..627718d 100644
--- a/src/EchoHub.Server/Services/PresenceTracker.cs
+++ b/src/EchoHub.Server/Services/PresenceTracker.cs
@@ -137,6 +137,11 @@ public class PresenceTracker
}
}
+ public string? GetUsernameForConnection(string connectionId)
+ {
+ return _connections.TryGetValue(connectionId, out var info) ? info.username : null;
+ }
+
public bool IsOnline(string username)
{
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
diff --git a/src/EchoHub.Server/Services/SignalRBroadcaster.cs b/src/EchoHub.Server/Services/SignalRBroadcaster.cs
new file mode 100644
index 0000000..29d29a5
--- /dev/null
+++ b/src/EchoHub.Server/Services/SignalRBroadcaster.cs
@@ -0,0 +1,53 @@
+using EchoHub.Core.Contracts;
+using EchoHub.Core.DTOs;
+using EchoHub.Server.Hubs;
+using Microsoft.AspNetCore.SignalR;
+
+namespace EchoHub.Server.Services;
+
+public class SignalRBroadcaster(
+ IHubContext hubContext,
+ PresenceTracker presenceTracker) : IChatBroadcaster
+{
+ public Task SendMessageToChannelAsync(string channelName, MessageDto message)
+ => hubContext.Clients.Group(channelName).ReceiveMessage(message);
+
+ public Task SendUserJoinedAsync(string channelName, string username, string? excludeConnectionId = null)
+ {
+ if (excludeConnectionId is not null && !excludeConnectionId.StartsWith("irc-"))
+ return hubContext.Clients.GroupExcept(channelName, [excludeConnectionId]).UserJoined(channelName, username);
+
+ return hubContext.Clients.Group(channelName).UserJoined(channelName, username);
+ }
+
+ public Task SendUserLeftAsync(string channelName, string username)
+ => hubContext.Clients.Group(channelName).UserLeft(channelName, username);
+
+ public Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
+ {
+ if (channelName is not null)
+ return hubContext.Clients.Group(channelName).ChannelUpdated(channel);
+
+ return hubContext.Clients.All.ChannelUpdated(channel);
+ }
+
+ public Task SendUserStatusChangedAsync(List channelNames, UserPresenceDto presence)
+ {
+ var connections = presenceTracker.GetConnectionsInChannels(channelNames)
+ .Where(c => !c.StartsWith("irc-"))
+ .ToList();
+
+ if (connections.Count == 0)
+ return Task.CompletedTask;
+
+ return hubContext.Clients.Clients(connections).UserStatusChanged(presence);
+ }
+
+ public Task SendErrorAsync(string connectionId, string message)
+ {
+ if (connectionId.StartsWith("irc-"))
+ return Task.CompletedTask;
+
+ return hubContext.Clients.Client(connectionId).Error(message);
+ }
+}
diff --git a/src/EchoHub.Server/appsettings.example.json b/src/EchoHub.Server/appsettings.example.json
index 46200f0..16d0607 100644
--- a/src/EchoHub.Server/appsettings.example.json
+++ b/src/EchoHub.Server/appsettings.example.json
@@ -14,6 +14,16 @@
"PublicServer": false,
"PublicHost": ""
},
+ "Irc": {
+ "Enabled": false,
+ "Port": 6667,
+ "TlsEnabled": false,
+ "TlsPort": 6697,
+ "TlsCertPath": "",
+ "TlsCertPassword": "",
+ "ServerName": "echohub",
+ "Motd": "Welcome to EchoHub IRC Gateway!"
+ },
"Serilog": {
"MinimumLevel": {
"Default": "Information",
diff --git a/src/EchoHub.slnx b/src/EchoHub.slnx
index 6abeb04..6478113 100644
--- a/src/EchoHub.slnx
+++ b/src/EchoHub.slnx
@@ -2,5 +2,6 @@
+