11 Commits
Author SHA1 Message Date
Hue 963c6d3384 Merge pull request #3 from HueByte/dev
Dev merge
2026-02-19 11:39:54 +01:00
HueByte fbc75ec744 fix: Update CI workflow to trigger only on master branch for push events 2026-02-19 11:38:56 +01:00
HueByte 7bd99be41e feat: Add IRC gateway functionality and update documentation 2026-02-19 11:34:26 +01:00
HueByte cb6b9d1e55 feat: Implement IRC Gateway Service and related functionality
- Add IrcGatewayService to handle IRC connections and commands.
- Create IrcMessage class for parsing IRC protocol lines.
- Implement IrcMessageFormatter for formatting messages as IRC lines.
- Define IrcNumericReply constants for IRC numeric replies.
- Add IrcOptions class for configuration settings related to IRC.
- Create IrcServiceExtensions for adding IRC services to the application.
- Refactor ChannelsController to use IChatService for broadcasting messages and channel updates.
- Update ChatHub to utilize IChatService for user connection and message handling.
- Introduce ChatService to manage chat-related operations and interactions.
- Implement SignalRBroadcaster for broadcasting messages to SignalR clients.
- Update PresenceTracker to retrieve usernames for connections.
- Modify appsettings.example.json to include IRC configuration options.
- Update solution file to include the new IRC project.
2026-02-19 11:31:20 +01:00
Hue 441aa679f6 Merge pull request #2 from HueByte/dev
dev merge
2026-02-19 11:21:12 +01:00
HueByte da7c16d5d0 feat: Add CI and Release workflows for automated markdown linting, formatting checks, and deployment 2026-02-19 11:18:30 +01:00
HueByte defc5873fe fix: Update git log command to reference HEAD for more accurate commit history 2026-02-19 11:03:20 +01:00
HueByte 7952c13c2b feat: Allow PR checks on dev branch and standardize dotnet version formatting 2026-02-19 11:01:11 +01:00
Hue 844313a8c6 Merge pull request #1 from HueByte/dev
feat: Enhance server connection handling with exponential backoff and…
2026-02-19 10:52:05 +01:00
HueByte efc6aa8a02 feat: Update changelog for v0.1.1 release and increment version to 0.1.1 2026-02-19 10:46:45 +01:00
HueByte ff184da095 feat: Enhance server connection handling with exponential backoff and improved reconnection logic 2026-02-19 10:44:00 +01:00
39 changed files with 2284 additions and 383 deletions
@@ -1,8 +1,11 @@
name: PR Check
name: CI
on:
pull_request:
push:
branches: [master]
pull_request:
branches: [master, dev]
workflow_dispatch:
jobs:
lint-markdown:
@@ -28,25 +31,30 @@ jobs:
- name: Check formatting
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
test:
build-and-test:
name: Build & Test
needs: [lint-markdown, format-check]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for src/ changes
id: changes
env:
EVENT: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
BASE_REF: ${{ github.base_ref }}
run: |
git fetch origin "$BASE_REF" --depth=1
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
if [ "$CHANGED" -gt 0 ]; then
echo "src_changed=true" >> "$GITHUB_OUTPUT"
if [ "$EVENT" = "pull_request" ]; then
git fetch origin "$BASE_REF" --depth=1
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
elif [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
CHANGED=1
else
echo "src_changed=false" >> "$GITHUB_OUTPUT"
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
fi
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
- name: Setup .NET 10
if: steps.changes.outputs.src_changed == 'true'
@@ -1,4 +1,4 @@
name: CI / Release
name: Release
on:
push:
@@ -9,96 +9,28 @@ permissions:
contents: write
jobs:
lint-markdown:
name: Markdown Lint
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run markdownlint
run: bash scripts/lint-markdown.sh
format-check:
name: Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Check formatting
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
detect-changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
src_changed: ${{ steps.check.outputs.src_changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for changes
id: check
- name: Check for src/ changes
id: changes
env:
BEFORE: ${{ github.event.before }}
run: |
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
# Initial push — treat everything as changed
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
echo "src_changed=true" >> "$GITHUB_OUTPUT"
else
SRC_CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
[ "$SRC_CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
fi
test:
name: Build & Test
needs: [lint-markdown, format-check, detect-changes]
if: needs.detect-changes.outputs.src_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj') }}
restore-keys: nuget-${{ runner.os }}-
- name: Restore dependencies
run: dotnet restore src/EchoHub.slnx
- name: Build
run: dotnet build src/EchoHub.slnx --no-restore --configuration Release
- name: Test
run: dotnet test src/EchoHub.slnx --no-build --configuration Release --verbosity normal
release:
name: Create Release
needs: [lint-markdown, format-check, detect-changes, test]
if: needs.detect-changes.outputs.src_changed == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Read version
if: steps.changes.outputs.src_changed == 'true'
id: version
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
@@ -106,6 +38,7 @@ jobs:
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Check if release exists
if: steps.changes.outputs.src_changed == 'true'
id: check_release
run: |
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
@@ -116,40 +49,46 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup .NET 10
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Publish Server win-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r win-x64 --self-contained true -o publish/server-win-x64
- name: Publish Server linux-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-x64 --self-contained true -o publish/server-linux-x64
- name: Publish Server osx-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-x64 --self-contained true -o publish/server-osx-x64
- name: Publish Server osx-arm64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64
- name: Publish Client win-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64
- name: Publish Client linux-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-x64 --self-contained true -o publish/client-linux-x64
- name: Publish Client osx-x64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-x64 --self-contained true -o publish/client-osx-x64
- name: Publish Client osx-arm64
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64
- name: Zip artifacts
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: |
cd publish
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
@@ -161,12 +100,44 @@ jobs:
zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
- name: Build release notes
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
id: notes
run: |
TAG="${{ steps.version.outputs.tag }}"
CHANGELOG_URL="https://huebyte.github.io/EchoHub/changelog/${TAG}.html"
REPO="https://github.com/${{ github.repository }}"
PREV_TAG=$(git tag --sort=-v:refname | grep -v "^${TAG}$" | head -n 1)
{
echo "body<<RELEASE_EOF"
echo "📋 **[Full Changelog](${CHANGELOG_URL})**"
echo ""
echo "---"
echo ""
echo "### Commits"
echo ""
if [ -n "$PREV_TAG" ]; then
git log "${PREV_TAG}..HEAD" --pretty=format:"- %s (%h)" --no-merges
else
git log HEAD --pretty=format:"- %s (%h)" --no-merges
fi
echo ""
echo ""
if [ -n "$PREV_TAG" ]; then
echo "*Version diff: [${PREV_TAG}...${TAG}](${REPO}/compare/${PREV_TAG}...${TAG})*"
else
echo "*Version diff: [${TAG}](${REPO}/commits/${TAG})*"
fi
echo "RELEASE_EOF"
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
if: steps.check_release.outputs.exists == 'false'
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: |
gh release create "${{ steps.version.outputs.tag }}" \
--title "EchoHub ${{ steps.version.outputs.tag }}" \
--generate-notes \
--notes "${{ steps.notes.outputs.body }}" \
EchoHub-Server-win-x64.zip \
EchoHub-Server-linux-x64.zip \
EchoHub-Server-osx-x64.zip \
+170 -25
View File
@@ -13,11 +13,19 @@
<p align="center">
<a href="#what-is-this">What</a> •
<a href="#getting-started">Setup</a> •
<a href="#irc-gateway">IRC</a> •
<a href="#deployment-with-nginx">Deploy</a> •
<a href="#client-commands">Commands</a> •
<a href="#configuration">Config</a> •
<a href="#license">License</a>
</p>
<p align="center">
<a href="https://echohub.voidcube.cloud/">Website</a> •
<a href="https://echohub.voidcube.cloud/servers">Public Servers</a> •
<a href="https://huebyte.github.io/EchoHub/">Documentation</a>
</p>
<p align="center">
<img alt=".NET 10" src="https://img.shields.io/badge/.NET-10-512BD4?style=flat-square&logo=dotnet&logoColor=white" />
<img alt="SignalR" src="https://img.shields.io/badge/SignalR-Real--time-0078D4?style=flat-square" />
@@ -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 <password> -n <username>
# WeeChat
/server add echohub your-server.com/6667 -password=<password> -nicks=<username>
/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
+1 -1
View File
@@ -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
+6 -2
View File
@@ -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.
+2
View File
@@ -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
+4
View File
@@ -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
+38 -6
View File
@@ -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<ChatHub>`, 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.
+33
View File
@@ -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 <password> -n <username>
```
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
+2
View File
@@ -4,4 +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
+4
View File
@@ -1,4 +1,8 @@
- 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
href: v0.1.0.md
+7
View File
@@ -0,0 +1,7 @@
# v0.1.1 - Directory Connection Self-Healing
## Fixes
- Server now reconnects to the EchoHubSpace directory indefinitely when the API goes down, using exponential backoff (2s → 30s cap)
- If automatic reconnect is exhausted, the connection is rebuilt from scratch automatically
- Initial connection attempts also use exponential backoff instead of a fixed 30s delay
+21
View File
@@ -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`
+16
View File
@@ -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": [
+4 -2
View File
@@ -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
+94
View File
@@ -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;
# }
# }
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>0.1.0</Version>
<Version>0.1.1</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
@@ -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<string> channelNames, UserPresenceDto presence);
Task SendErrorAsync(string connectionId, string message);
}
@@ -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<string?> UserDisconnectedAsync(string connectionId);
// Channel operations
Task<(List<MessageDto> History, string? Error)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName);
Task LeaveChannelAsync(string connectionId, string username, string channelName);
// Messaging
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count);
// Presence
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
Task<List<UserPresenceDto>> 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<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);
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.1.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+67
View File
@@ -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<string> 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}");
}
}
}
@@ -0,0 +1,87 @@
using System.Net.Sockets;
using System.Text;
namespace EchoHub.Server.Irc;
/// <summary>
/// Manages a single IRC client TCP connection.
/// </summary>
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<string> 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<string?> 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();
}
}
+641
View File
@@ -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;
}
}
+157
View File
@@ -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<IrcGatewayService> _logger;
private readonly ConcurrentDictionary<string, IrcClientConnection> _connections = new();
public IrcOptions Options => _options;
public IReadOnlyDictionary<string, IrcClientConnection> Connections => _connections;
public IrcGatewayService(
IOptions<IrcOptions> options,
IChatService chatService,
ILogger<IrcGatewayService> logger)
{
_options = options.Value;
_chatService = chatService;
_logger = logger;
}
public IEnumerable<IrcClientConnection> 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<Task>();
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);
}
}
+69
View File
@@ -0,0 +1,69 @@
namespace EchoHub.Server.Irc;
/// <summary>
/// Parsed representation of an IRC protocol line.
/// Format: [:prefix] COMMAND [params...] [:trailing]
/// </summary>
public sealed class IrcMessage
{
public string? Prefix { get; init; }
public string Command { get; init; } = "";
public List<string> Parameters { get; init; } = [];
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
/// <summary>
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
/// </summary>
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<string>();
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,
};
}
}
@@ -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;
/// <summary>
/// Format a MessageDto as one or more IRC PRIVMSG lines.
/// </summary>
public static List<string> FormatMessage(MessageDto message)
{
var lines = new List<string>();
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;
}
/// <summary>
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
/// </summary>
public static List<string> SplitMessage(string content, int maxBytes)
{
if (Encoding.UTF8.GetByteCount(content) <= maxBytes)
return [content];
var chunks = new List<string>();
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;
}
}
+65
View File
@@ -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";
}
+15
View File
@@ -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; }
}
@@ -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<IrcOptions>(
builder.Configuration.GetSection(IrcOptions.SectionName));
if (builder.Configuration.GetValue<bool>("Irc:Enabled"))
{
builder.Services.AddSingleton<IrcGatewayService>();
builder.Services.AddSingleton<IChatBroadcaster>(sp =>
new IrcBroadcaster(sp.GetRequiredService<IrcGatewayService>()));
builder.Services.AddHostedService(sp =>
sp.GetRequiredService<IrcGatewayService>());
}
return builder;
}
}
@@ -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<ChatHub, IEchoHubClient> hubContext) : ControllerBase
IChatService chatService) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> 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);
}
+1
View File
@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
</ItemGroup>
<ItemGroup>
+17 -208
View File
@@ -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<ChatHub> logger, PresenceTracker presenceTracker) : Hub<IEchoHubClient>
public class ChatHub(IChatService chatService, ILogger<ChatHub> logger) : Hub<IEchoHubClient>
{
private Guid CurrentUserId =>
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
@@ -26,19 +23,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> 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<ChatHub> 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<ChatHub> 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<ChatHub> 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<ChatHub> 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<ChatHub> 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<ChatHub> 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<ChatHub> 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)
{
+10
View File
@@ -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<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddHostedService<ServerDirectoryService>();
// ── Chat Service + Broadcasters ─────────────────────────────────────
builder.Services.AddSingleton<IChatBroadcaster, SignalRBroadcaster>();
builder.Services.AddSingleton<IChatService, ChatService>();
// ── IRC Gateway (optional) ──────────────────────────────────────────
builder.AddIrcGateway();
builder.Services.AddHttpClient("ImageDownload", client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
+330
View File
@@ -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<IChatBroadcaster> broadcasters,
ILogger<ChatService> 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<EchoHubDbContext>();
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<string?> 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<EchoHubDbContext>();
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<MessageDto> 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<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.");
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<string?> 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<EchoHubDbContext>();
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<List<MessageDto>> 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<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count);
}
public async Task<string?> 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<EchoHubDbContext>();
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<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
var onlineUsernames = presenceTracker.GetOnlineUsersInChannel(channelName);
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<IChatBroadcaster, Task> 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<UserProfileDto?> GetUserProfileAsync(string username)
{
username = username.ToLowerInvariant();
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
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<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));
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<EchoHubDbContext>();
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<List<MessageDto>> 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;
}
}
@@ -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;
@@ -9,6 +9,8 @@ public sealed class ServerDirectoryService(
{
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
private HubConnection? _connection;
private int _lastReportedUserCount = -1;
@@ -38,52 +40,97 @@ public sealed class ServerDirectoryService(
logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
_connection = new HubConnectionBuilder()
.WithUrl(DirectoryHubUrl)
.WithAutomaticReconnect()
.Build();
_connection.Reconnected += async _ =>
{
logger.LogInformation("Reconnected to directory — re-registering server");
await RegisterAsync(serverName, description, host);
};
_connection.Closed += ex =>
{
if (ex is not null)
logger.LogWarning(ex, "Directory connection closed with error");
return Task.CompletedTask;
};
// Initial connection with retry
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
while (!stoppingToken.IsCancellationRequested)
{
await using var connection = BuildConnection();
_connection = connection;
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
connection.Reconnected += async _ =>
{
logger.LogInformation("Reconnected to directory — re-registering server");
_lastReportedUserCount = -1;
await RegisterAsync(serverName, description, host);
};
connection.Closed += ex =>
{
if (ex is not null)
logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
else
logger.LogWarning("Directory connection permanently closed — will rebuild");
connectionPermanentlyClosed.TrySetResult();
return Task.CompletedTask;
};
// Connect with retry
if (!await ConnectWithRetryAsync(connection, stoppingToken))
return;
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
await RegisterAsync(serverName, description, host);
// Poll user count until the connection is permanently closed or cancellation
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
if (stoppingToken.IsCancellationRequested)
return;
// Connection was permanently closed — wait briefly then rebuild
_connection = null;
logger.LogInformation("Rebuilding directory connection...");
await Task.Delay(ReconnectBaseDelay, stoppingToken);
}
}
private HubConnection BuildConnection()
{
return new HubConnectionBuilder()
.WithUrl(DirectoryHubUrl)
.WithAutomaticReconnect(new InfiniteRetryPolicy())
.Build();
}
private async Task<bool> ConnectWithRetryAsync(HubConnection connection, CancellationToken ct)
{
var attempt = 0;
while (!ct.IsCancellationRequested)
{
try
{
await _connection.StartAsync(stoppingToken);
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
break;
await connection.StartAsync(ct);
return true;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s");
await Task.Delay(UpdateInterval, stoppingToken);
attempt++;
var delay = GetBackoffDelay(attempt);
logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds);
await Task.Delay(delay, ct);
}
}
if (stoppingToken.IsCancellationRequested)
return;
return false;
}
// Register on first connect
await RegisterAsync(serverName, description, host);
// Poll user count and send updates
while (!stoppingToken.IsCancellationRequested)
private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(UpdateInterval, stoppingToken);
var delayTask = Task.Delay(UpdateInterval, ct);
var completed = await Task.WhenAny(delayTask, connectionClosed);
if (_connection.State != HubConnectionState.Connected)
if (completed == connectionClosed)
return;
// Observe the delay task (may throw if cancelled)
try { await delayTask; }
catch (OperationCanceledException) { return; }
if (connection.State != HubConnectionState.Connected)
continue;
var currentCount = presenceTracker.GetOnlineUserCount();
@@ -92,7 +139,7 @@ public sealed class ServerDirectoryService(
try
{
await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken);
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
_lastReportedUserCount = currentCount;
logger.LogDebug("Updated directory user count to {Count}", currentCount);
}
@@ -103,6 +150,12 @@ public sealed class ServerDirectoryService(
}
}
private static TimeSpan GetBackoffDelay(int attempt)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, Math.Min(attempt, 10)));
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
}
private async Task RegisterAsync(string name, string? description, string host)
{
if (_connection?.State != HubConnectionState.Connected)
@@ -132,6 +185,18 @@ public sealed class ServerDirectoryService(
await base.StopAsync(cancellationToken);
}
/// <summary>
/// Retries indefinitely with exponential backoff capped at 30 seconds.
/// </summary>
private sealed class InfiniteRetryPolicy : IRetryPolicy
{
public TimeSpan? NextRetryDelay(RetryContext retryContext)
{
var delay = TimeSpan.FromSeconds(Math.Pow(2, Math.Min(retryContext.PreviousRetryCount, 10)));
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
}
}
}
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount);
@@ -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<ChatHub, IEchoHubClient> 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<string> 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);
}
}
@@ -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",
+1
View File
@@ -2,5 +2,6 @@
<Project Path="EchoHub.Client/EchoHub.Client.csproj" />
<Project Path="EchoHub.Core/EchoHub.Core.csproj" />
<Project Path="EchoHub.Server/EchoHub.Server.csproj" />
<Project Path="EchoHub.Server.Irc/EchoHub.Server.Irc.csproj" />
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
</Solution>