mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87e8df4ec5 | ||
|
|
4c192717de | ||
|
|
1bf32450fd | ||
|
|
963c6d3384 | ||
|
|
fbc75ec744 | ||
|
|
7bd99be41e | ||
|
|
cb6b9d1e55 | ||
|
|
441aa679f6 | ||
|
|
da7c16d5d0 | ||
|
|
defc5873fe | ||
|
|
7952c13c2b | ||
|
|
844313a8c6 | ||
|
|
efc6aa8a02 | ||
|
|
ff184da095 |
@@ -1,8 +1,11 @@
|
|||||||
name: PR Check
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, dev]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-markdown:
|
lint-markdown:
|
||||||
@@ -28,25 +31,30 @@ jobs:
|
|||||||
- name: Check formatting
|
- name: Check formatting
|
||||||
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
|
run: dotnet format src/EchoHub.slnx --verify-no-changes --verbosity diagnostic
|
||||||
|
|
||||||
test:
|
build-and-test:
|
||||||
name: Build & Test
|
name: Build & Test
|
||||||
needs: [lint-markdown, format-check]
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check for src/ changes
|
- name: Check for src/ changes
|
||||||
id: changes
|
id: changes
|
||||||
env:
|
env:
|
||||||
|
EVENT: ${{ github.event_name }}
|
||||||
|
BEFORE: ${{ github.event.before }}
|
||||||
BASE_REF: ${{ github.base_ref }}
|
BASE_REF: ${{ github.base_ref }}
|
||||||
run: |
|
run: |
|
||||||
|
if [ "$EVENT" = "pull_request" ]; then
|
||||||
git fetch origin "$BASE_REF" --depth=1
|
git fetch origin "$BASE_REF" --depth=1
|
||||||
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
|
CHANGED=$(git diff --name-only "origin/$BASE_REF"...HEAD -- 'src/' | wc -l)
|
||||||
if [ "$CHANGED" -gt 0 ]; then
|
elif [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
CHANGED=1
|
||||||
else
|
else
|
||||||
echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
||||||
fi
|
fi
|
||||||
|
[ "$CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Setup .NET 10
|
- name: Setup .NET 10
|
||||||
if: steps.changes.outputs.src_changed == 'true'
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: CI / Release
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -9,96 +9,28 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-markdown:
|
release:
|
||||||
name: Markdown Lint
|
name: Create Release
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for src/ changes
|
||||||
id: check
|
id: changes
|
||||||
env:
|
env:
|
||||||
BEFORE: ${{ github.event.before }}
|
BEFORE: ${{ github.event.before }}
|
||||||
run: |
|
run: |
|
||||||
if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
if [ -z "$BEFORE" ] || [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then
|
||||||
# Initial push — treat everything as changed
|
|
||||||
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
echo "src_changed=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
SRC_CHANGED=$(git diff --name-only "$BEFORE" HEAD -- 'src/' | wc -l)
|
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"
|
||||||
[ "$SRC_CHANGED" -gt 0 ] && echo "src_changed=true" >> "$GITHUB_OUTPUT" || echo "src_changed=false" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
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
|
- name: Read version
|
||||||
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
id: version
|
id: version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
|
||||||
@@ -106,6 +38,7 @@ jobs:
|
|||||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Check if release exists
|
- name: Check if release exists
|
||||||
|
if: steps.changes.outputs.src_changed == 'true'
|
||||||
id: check_release
|
id: check_release
|
||||||
run: |
|
run: |
|
||||||
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
if gh release view "${{ steps.version.outputs.tag }}" &>/dev/null; then
|
||||||
@@ -116,40 +49,46 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- 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
|
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
|
- 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
|
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
|
- 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
|
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
|
- 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
|
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
|
- 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
|
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
|
- 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
|
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
|
- 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
|
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
|
- 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
|
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
|
- name: Zip artifacts
|
||||||
if: steps.check_release.outputs.exists == 'false'
|
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
|
||||||
run: |
|
run: |
|
||||||
cd publish
|
cd publish
|
||||||
zip -r ../EchoHub-Server-win-x64.zip server-win-x64/
|
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-x64.zip client-osx-x64/
|
||||||
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
|
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
|
- 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: |
|
run: |
|
||||||
gh release create "${{ steps.version.outputs.tag }}" \
|
gh release create "${{ steps.version.outputs.tag }}" \
|
||||||
--title "EchoHub ${{ steps.version.outputs.tag }}" \
|
--title "EchoHub ${{ steps.version.outputs.tag }}" \
|
||||||
--generate-notes \
|
--notes "${{ steps.notes.outputs.body }}" \
|
||||||
EchoHub-Server-win-x64.zip \
|
EchoHub-Server-win-x64.zip \
|
||||||
EchoHub-Server-linux-x64.zip \
|
EchoHub-Server-linux-x64.zip \
|
||||||
EchoHub-Server-osx-x64.zip \
|
EchoHub-Server-osx-x64.zip \
|
||||||
@@ -13,11 +13,19 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="#what-is-this">What</a> •
|
<a href="#what-is-this">What</a> •
|
||||||
<a href="#getting-started">Setup</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="#client-commands">Commands</a> •
|
||||||
<a href="#configuration">Config</a> •
|
<a href="#configuration">Config</a> •
|
||||||
<a href="#license">License</a>
|
<a href="#license">License</a>
|
||||||
</p>
|
</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">
|
<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=".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" />
|
<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
|
```mermaid
|
||||||
graph TD
|
graph TD
|
||||||
subgraph Server["Server"]
|
subgraph Server["Server"]
|
||||||
|
ChatSvc["ChatService"]
|
||||||
Hub["SignalR ChatHub"]
|
Hub["SignalR ChatHub"]
|
||||||
|
IRC["IRC Gateway :6667"]
|
||||||
Auth["JWT Auth"]
|
Auth["JWT Auth"]
|
||||||
DB["SQLite DB (EF Core)"]
|
DB["SQLite DB (EF Core)"]
|
||||||
Files["File Storage"]
|
Files["File Storage"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Client["Client"]
|
subgraph Clients["Clients"]
|
||||||
TUI["Terminal GUI (TUI)"]
|
TUI["Terminal GUI (TUI)"]
|
||||||
Theme["Theme Engine"]
|
IRCClient["IRC Client (irssi, WeeChat, ...)"]
|
||||||
API["API Client (auto-refresh)"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
API -- "WebSocket" --> Hub
|
TUI -- "WebSocket" --> Hub
|
||||||
API -- "REST" --> Auth
|
TUI -- "REST" --> Auth
|
||||||
Hub --> DB
|
IRCClient -- "TCP" --> IRC
|
||||||
|
Hub --> ChatSvc
|
||||||
|
IRC --> ChatSvc
|
||||||
|
ChatSvc --> DB
|
||||||
Auth --> DB
|
Auth --> DB
|
||||||
Files --> DB
|
Files --> DB
|
||||||
```
|
```
|
||||||
@@ -65,6 +77,7 @@ graph TD
|
|||||||
|
|
||||||
- **Self-hostable** — your server, your rules, your data
|
- **Self-hostable** — your server, your rules, your data
|
||||||
- **Real-time messaging** via SignalR WebSockets
|
- **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
|
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
||||||
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
||||||
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
|
- **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
|
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
|
## Client Commands
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
@@ -172,9 +245,75 @@ dotnet build src/EchoHub.slnx
|
|||||||
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
||||||
| `Server:Name` | `My EchoHub Server` | Server display name |
|
| `Server:Name` | `My EchoHub Server` | Server display name |
|
||||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
| `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 |
|
| `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
|
## Project Structure
|
||||||
|
|
||||||
@@ -182,7 +321,7 @@ Logging uses Serilog — console + daily rolling files with 14-day retention. Co
|
|||||||
src/
|
src/
|
||||||
├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
|
├── EchoHub.Core/ # Shared models, DTOs, contracts, validation
|
||||||
│ ├── Constants/ # ValidationConstants, HubConstants
|
│ ├── Constants/ # ValidationConstants, HubConstants
|
||||||
│ ├── Contracts/ # IEchoHubClient (SignalR interface)
|
│ ├── Contracts/ # IChatService, IChatBroadcaster, IEchoHubClient
|
||||||
│ ├── DTOs/ # Record DTOs
|
│ ├── DTOs/ # Record DTOs
|
||||||
│ └── Models/ # Entity models
|
│ └── Models/ # Entity models
|
||||||
│
|
│
|
||||||
@@ -191,13 +330,19 @@ src/
|
|||||||
│ ├── Controllers/ # REST API endpoints
|
│ ├── Controllers/ # REST API endpoints
|
||||||
│ ├── Data/ # EF Core DbContext + migrations
|
│ ├── Data/ # EF Core DbContext + migrations
|
||||||
│ ├── Hubs/ # SignalR ChatHub
|
│ ├── Hubs/ # SignalR ChatHub
|
||||||
│ ├── Services/ # Presence, file storage, image processing
|
│ ├── Services/ # ChatService, presence, file storage, image processing
|
||||||
│ └── Setup/ # First-run setup, DB initialization
|
│ └── Setup/ # First-run setup, DB initialization
|
||||||
│
|
│
|
||||||
|
├── 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.Client/ # Terminal.Gui TUI client
|
├── EchoHub.Client/ # Terminal.Gui TUI client
|
||||||
│ ├── Config/ # Client configuration
|
│ ├── Config/ # Client configuration
|
||||||
│ ├── Services/ # API client, SignalR connection
|
│ ├── Services/ # API client, SignalR connection
|
||||||
│ ├── Themes/ # 6 built-in themes
|
│ ├── Themes/ # 13 built-in themes
|
||||||
│ └── UI/ # MainWindow, dialogs, chat renderer
|
│ └── UI/ # MainWindow, dialogs, chat renderer
|
||||||
│
|
│
|
||||||
└── EchoHub.slnx # Solution file
|
└── EchoHub.slnx # Solution file
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ Articles related to the EchoHub.Core shared library.
|
|||||||
## Topics
|
## Topics
|
||||||
|
|
||||||
- Data models and DTOs
|
- Data models and DTOs
|
||||||
- SignalR contract interface
|
- Contract interfaces (IChatService, IChatBroadcaster, IEchoHubClient)
|
||||||
- Validation constants and shared rules
|
- Validation constants and shared rules
|
||||||
|
|||||||
+6
-2
@@ -10,8 +10,12 @@ Terminal.Gui v2 TUI application -- UI components, services, themes, and configur
|
|||||||
|
|
||||||
### Core
|
### Core
|
||||||
|
|
||||||
Shared library -- DTOs, models, constants, and the SignalR client contract.
|
Shared library -- DTOs, models, constants, and contracts (`IChatService`, `IChatBroadcaster`, `IEchoHubClient`).
|
||||||
|
|
||||||
### Server
|
### 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.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Articles related to the EchoHub server built with ASP.NET Core.
|
|||||||
|
|
||||||
- Authentication and JWT tokens
|
- Authentication and JWT tokens
|
||||||
- SignalR hub and real-time messaging
|
- SignalR hub and real-time messaging
|
||||||
|
- IRC gateway and protocol bridging
|
||||||
|
- ChatService and broadcaster pattern
|
||||||
- File upload and validation
|
- File upload and validation
|
||||||
- Rate limiting configuration
|
- Rate limiting configuration
|
||||||
- Database schema and migrations
|
- Database schema and migrations
|
||||||
|
|||||||
@@ -16,3 +16,7 @@
|
|||||||
href: ../_api_meta/server/toc.yml
|
href: ../_api_meta/server/toc.yml
|
||||||
- name: Articles
|
- name: Articles
|
||||||
href: server-articles/
|
href: server-articles/
|
||||||
|
- name: Server.Irc
|
||||||
|
items:
|
||||||
|
- name: API Reference
|
||||||
|
href: ../_api_meta/server-irc/toc.yml
|
||||||
|
|||||||
@@ -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.
|
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
|
## Components
|
||||||
|
|
||||||
### EchoHub.Core
|
### EchoHub.Core
|
||||||
@@ -12,7 +22,7 @@ Shared library containing:
|
|||||||
|
|
||||||
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
|
- **Models**: `User`, `Channel`, `Message`, `RefreshToken`
|
||||||
- **DTOs**: Record types for API requests/responses
|
- **DTOs**: Record types for API requests/responses
|
||||||
- **Contracts**: `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`
|
- **Constants**: `ValidationConstants` (shared regex patterns), `HubConstants`
|
||||||
|
|
||||||
### EchoHub.Server
|
### EchoHub.Server
|
||||||
@@ -20,10 +30,21 @@ Shared library containing:
|
|||||||
ASP.NET Core web application:
|
ASP.NET Core web application:
|
||||||
|
|
||||||
- **Controllers**: REST API endpoints for auth, channels, users, files, server info
|
- **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)
|
- **Auth**: JWT token service (15-min access tokens, 30-day refresh tokens)
|
||||||
- **Data**: EF Core with SQLite
|
- **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
|
### EchoHub.Client
|
||||||
|
|
||||||
@@ -31,11 +52,22 @@ Terminal.Gui v2 TUI application:
|
|||||||
|
|
||||||
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
- **UI**: Main window, dialogs, chat renderer with ANSI color support
|
||||||
- **Services**: API client with automatic token refresh, SignalR connection wrapper
|
- **Services**: API client with automatic token refresh, SignalR connection wrapper
|
||||||
- **Themes**: 6 built-in color themes
|
- **Themes**: 13 built-in color themes
|
||||||
- **Config**: Client configuration management
|
- **Config**: Client configuration management
|
||||||
|
|
||||||
## Communication
|
## Communication
|
||||||
|
|
||||||
- REST API for authentication, profile management, channel CRUD, file uploads
|
- **REST API** for authentication, profile management, channel CRUD, file uploads
|
||||||
- SignalR WebSocket for real-time messaging and presence updates
|
- **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
|
- 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.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
- [.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
|
## Run the Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -24,6 +26,37 @@ dotnet run --project src/EchoHub.Client
|
|||||||
|
|
||||||
Connect to a server, register an account, and start chatting.
|
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
|
## Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.1](v0.2.1.md) - Shutdown & CI Fixes
|
||||||
|
- [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
|
- [v0.1.0](v0.1.0.md) - Initial Release
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.1
|
||||||
|
href: v0.2.1.md
|
||||||
|
- name: v0.2.0
|
||||||
|
href: v0.2.0.md
|
||||||
|
- name: v0.1.1
|
||||||
|
href: v0.1.1.md
|
||||||
- name: v0.1.0
|
- name: v0.1.0
|
||||||
href: v0.1.0.md
|
href: v0.1.0.md
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# v0.2.1 - Shutdown & CI Fixes
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Fixed server hanging on Ctrl+C when the directory server is unreachable — `StopAsync` now cancels background services before disposing connections
|
||||||
|
- Fixed IRC gateway shutdown blocking indefinitely on unresponsive clients — send operations are now bounded to 2 seconds
|
||||||
|
- Fixed CI release workflow not having full git history for building release notes (`fetch-depth: 0`)
|
||||||
|
- Fixed `workflow_dispatch` trigger breaking change detection when `github.event.before` is empty
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- GitHub releases now include a commit list and version diff link instead of generic auto-generated notes
|
||||||
|
- GitHub releases link to the full changelog on the docs site
|
||||||
@@ -30,6 +30,16 @@
|
|||||||
],
|
],
|
||||||
"dest": "_api_meta/client",
|
"dest": "_api_meta/client",
|
||||||
"filter": "filterConfig.yml"
|
"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": {
|
"build": {
|
||||||
@@ -55,6 +65,12 @@
|
|||||||
"dest": "api/client",
|
"dest": "api/client",
|
||||||
"files": ["*.yml"],
|
"files": ["*.yml"],
|
||||||
"exclude": ["toc.yml"]
|
"exclude": ["toc.yml"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "_api_meta/server-irc",
|
||||||
|
"dest": "api/server-irc",
|
||||||
|
"files": ["*.yml"],
|
||||||
|
"exclude": ["toc.yml"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resource": [
|
"resource": [
|
||||||
|
|||||||
+4
-2
@@ -4,11 +4,13 @@ _layout: landing
|
|||||||
|
|
||||||
# EchoHub Documentation
|
# 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
|
## Quick Links
|
||||||
|
|
||||||
- [Getting Started](articles/getting-started.md) - Set up and run EchoHub
|
- [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
|
- [API Reference](api/index.md) - Generated C# API documentation
|
||||||
- [Changelog](changelog/index.md) - Release history
|
- [Changelog](changelog/index.md) - Release history
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.1.0</Version>
|
<Version>0.2.1</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
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)
|
||||||
|
{
|
||||||
|
// Cancel ExecuteAsync first so listeners stop accepting
|
||||||
|
await base.StopAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Force-close any remaining client connections
|
||||||
|
foreach (var (_, conn) in _connections)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await conn.SendAsync("ERROR :Server shutting down")
|
||||||
|
.WaitAsync(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { await conn.DisposeAsync(); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_connections.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Hubs;
|
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
@@ -23,7 +21,7 @@ public class ChannelsController(
|
|||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
ImageToAsciiService asciiService,
|
ImageToAsciiService asciiService,
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IHubContext<ChatHub, IEchoHubClient> hubContext) : ControllerBase
|
IChatService chatService) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||||
@@ -78,7 +76,7 @@ public class ChannelsController(
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var dto = new ChannelDto(channel.Id, channel.Name, channel.Topic, 0, channel.CreatedAt);
|
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);
|
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 messageCount = await db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id);
|
||||||
var dto = new ChannelDto(dbChannel.Id, dbChannel.Name, dbChannel.Topic, messageCount, dbChannel.CreatedAt);
|
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);
|
return Ok(dto);
|
||||||
}
|
}
|
||||||
@@ -214,7 +212,7 @@ public class ChannelsController(
|
|||||||
file.FileName,
|
file.FileName,
|
||||||
message.SentAt);
|
message.SentAt);
|
||||||
|
|
||||||
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
await chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
}
|
}
|
||||||
@@ -331,7 +329,7 @@ public class ChannelsController(
|
|||||||
fileName,
|
fileName,
|
||||||
message.SentAt);
|
message.SentAt);
|
||||||
|
|
||||||
await hubContext.Clients.Group(channelName).ReceiveMessage(messageDto);
|
await chatService.BroadcastMessageAsync(channelName, messageDto);
|
||||||
|
|
||||||
return Ok(messageDto);
|
return Ok(messageDto);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
<ProjectReference Include="..\EchoHub.Core\EchoHub.Core.csproj" />
|
||||||
|
<ProjectReference Include="..\EchoHub.Server.Irc\EchoHub.Server.Irc.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -3,16 +3,13 @@ using EchoHub.Core.Constants;
|
|||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
|
||||||
using EchoHub.Server.Services;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace EchoHub.Server.Hubs;
|
namespace EchoHub.Server.Hubs;
|
||||||
|
|
||||||
[Authorize]
|
[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 =>
|
private Guid CurrentUserId =>
|
||||||
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
Guid.Parse(Context.User?.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
@@ -26,19 +23,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
presenceTracker.UserConnected(Context.ConnectionId, CurrentUserId, CurrentUsername);
|
await chatService.UserConnectedAsync(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 base.OnConnectedAsync();
|
await base.OnConnectedAsync();
|
||||||
|
|
||||||
logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", CurrentUsername, Context.ConnectionId);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -51,39 +37,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var preDisconnectUsername = Context.User?.FindFirstValue("username");
|
await chatService.UserDisconnectedAsync(Context.ConnectionId);
|
||||||
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 base.OnDisconnectedAsync(exception);
|
await base.OnDisconnectedAsync(exception);
|
||||||
|
|
||||||
logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", Context.ConnectionId);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -96,32 +51,16 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
try
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
await Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim());
|
||||||
|
|
||||||
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);
|
|
||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -137,13 +76,8 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
await chatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName);
|
||||||
presenceTracker.LeaveChannel(CurrentUsername, channelName);
|
|
||||||
|
|
||||||
await Groups.RemoveFromGroupAsync(Context.ConnectionId, 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -156,64 +90,9 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
var error = await chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
|
||||||
|
if (error is not null)
|
||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
await Clients.Caller.Error(error);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -226,35 +105,7 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
return await chatService.GetChannelHistoryAsync(channelName, count);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -268,37 +119,9 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
var error = await chatService.UpdateStatusAsync(CurrentUserId, CurrentUsername, status, statusMessage);
|
||||||
{
|
if (error is not null)
|
||||||
await Clients.Caller.Error($"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.");
|
await Clients.Caller.Error(error);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -311,21 +134,7 @@ public class ChatHub(EchoHubDbContext db, ILogger<ChatHub> logger, PresenceTrack
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
return await chatService.GetOnlineUsersAsync(channelName);
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Auth;
|
using EchoHub.Server.Auth;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Hubs;
|
using EchoHub.Server.Hubs;
|
||||||
|
using EchoHub.Server.Irc;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using EchoHub.Server.Setup;
|
using EchoHub.Server.Setup;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
@@ -101,6 +103,14 @@ while (true)
|
|||||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
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 =>
|
builder.Services.AddHttpClient("ImageDownload", client =>
|
||||||
{
|
{
|
||||||
client.Timeout = TimeSpan.FromSeconds(15);
|
client.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
|||||||
@@ -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)
|
public bool IsOnline(string username)
|
||||||
{
|
{
|
||||||
return _userConnections.TryGetValue(username, out var connections) && connections.Count > 0;
|
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 const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
|
||||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
|
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 HubConnection? _connection;
|
||||||
private int _lastReportedUserCount = -1;
|
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);
|
logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host);
|
||||||
|
|
||||||
_connection = new HubConnectionBuilder()
|
// Outer loop: rebuilds the connection if automatic reconnect permanently fails
|
||||||
.WithUrl(DirectoryHubUrl)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
.WithAutomaticReconnect()
|
{
|
||||||
.Build();
|
await using var connection = BuildConnection();
|
||||||
|
_connection = connection;
|
||||||
|
|
||||||
_connection.Reconnected += async _ =>
|
var connectionPermanentlyClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
connection.Reconnected += async _ =>
|
||||||
{
|
{
|
||||||
logger.LogInformation("Reconnected to directory — re-registering server");
|
logger.LogInformation("Reconnected to directory — re-registering server");
|
||||||
|
_lastReportedUserCount = -1;
|
||||||
await RegisterAsync(serverName, description, host);
|
await RegisterAsync(serverName, description, host);
|
||||||
};
|
};
|
||||||
|
|
||||||
_connection.Closed += ex =>
|
connection.Closed += ex =>
|
||||||
{
|
{
|
||||||
if (ex is not null)
|
if (ex is not null)
|
||||||
logger.LogWarning(ex, "Directory connection closed with error");
|
logger.LogWarning(ex, "Directory connection permanently closed — will rebuild");
|
||||||
|
else
|
||||||
|
logger.LogWarning("Directory connection permanently closed — will rebuild");
|
||||||
|
|
||||||
|
connectionPermanentlyClosed.TrySetResult();
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initial connection with retry
|
// Connect with retry
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
if (!await ConnectWithRetryAsync(connection, stoppingToken))
|
||||||
{
|
return;
|
||||||
try
|
|
||||||
{
|
|
||||||
await _connection.StartAsync(stoppingToken);
|
|
||||||
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
|
||||||
break;
|
await RegisterAsync(serverName, description, host);
|
||||||
}
|
|
||||||
catch (Exception ex)
|
// Poll user count until the connection is permanently closed or cancellation
|
||||||
{
|
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
|
||||||
logger.LogWarning(ex, "Failed to connect to directory — retrying in 30s");
|
|
||||||
await Task.Delay(UpdateInterval, stoppingToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stoppingToken.IsCancellationRequested)
|
if (stoppingToken.IsCancellationRequested)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Register on first connect
|
// Connection was permanently closed — wait briefly then rebuild
|
||||||
await RegisterAsync(serverName, description, host);
|
_connection = null;
|
||||||
|
logger.LogInformation("Rebuilding directory connection...");
|
||||||
|
await Task.Delay(ReconnectBaseDelay, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Poll user count and send updates
|
private HubConnection BuildConnection()
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
|
||||||
{
|
{
|
||||||
await Task.Delay(UpdateInterval, stoppingToken);
|
return new HubConnectionBuilder()
|
||||||
|
.WithUrl(DirectoryHubUrl)
|
||||||
|
.WithAutomaticReconnect(new InfiniteRetryPolicy())
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
|
||||||
if (_connection.State != HubConnectionState.Connected)
|
private async Task<bool> ConnectWithRetryAsync(HubConnection connection, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var attempt = 0;
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await connection.StartAsync(ct);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
attempt++;
|
||||||
|
var delay = GetBackoffDelay(attempt);
|
||||||
|
logger.LogWarning(ex, "Failed to connect to directory — retrying in {Delay}s", delay.TotalSeconds);
|
||||||
|
await Task.Delay(delay, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var delayTask = Task.Delay(UpdateInterval, ct);
|
||||||
|
var completed = await Task.WhenAny(delayTask, connectionClosed);
|
||||||
|
|
||||||
|
if (completed == connectionClosed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Observe the delay task (may throw if cancelled)
|
||||||
|
try { await delayTask; }
|
||||||
|
catch (OperationCanceledException) { return; }
|
||||||
|
|
||||||
|
if (connection.State != HubConnectionState.Connected)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var currentCount = presenceTracker.GetOnlineUserCount();
|
var currentCount = presenceTracker.GetOnlineUserCount();
|
||||||
@@ -92,7 +139,7 @@ public sealed class ServerDirectoryService(
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _connection.InvokeAsync("UpdateUserCount", currentCount, stoppingToken);
|
await connection.InvokeAsync("UpdateUserCount", currentCount, ct);
|
||||||
_lastReportedUserCount = currentCount;
|
_lastReportedUserCount = currentCount;
|
||||||
logger.LogDebug("Updated directory user count to {Count}", 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)
|
private async Task RegisterAsync(string name, string? description, string host)
|
||||||
{
|
{
|
||||||
if (_connection?.State != HubConnectionState.Connected)
|
if (_connection?.State != HubConnectionState.Connected)
|
||||||
@@ -124,13 +177,21 @@ public sealed class ServerDirectoryService(
|
|||||||
|
|
||||||
public override async Task StopAsync(CancellationToken cancellationToken)
|
public override async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_connection is not null)
|
// Cancel ExecuteAsync first — it disposes the connection via await using
|
||||||
{
|
await base.StopAsync(cancellationToken);
|
||||||
await _connection.DisposeAsync();
|
|
||||||
_connection = null;
|
_connection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
"PublicServer": false,
|
||||||
"PublicHost": ""
|
"PublicHost": ""
|
||||||
},
|
},
|
||||||
|
"Irc": {
|
||||||
|
"Enabled": false,
|
||||||
|
"Port": 6667,
|
||||||
|
"TlsEnabled": false,
|
||||||
|
"TlsPort": 6697,
|
||||||
|
"TlsCertPath": "",
|
||||||
|
"TlsCertPassword": "",
|
||||||
|
"ServerName": "echohub",
|
||||||
|
"Motd": "Welcome to EchoHub IRC Gateway!"
|
||||||
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
<Project Path="EchoHub.Client/EchoHub.Client.csproj" />
|
<Project Path="EchoHub.Client/EchoHub.Client.csproj" />
|
||||||
<Project Path="EchoHub.Core/EchoHub.Core.csproj" />
|
<Project Path="EchoHub.Core/EchoHub.Core.csproj" />
|
||||||
<Project Path="EchoHub.Server/EchoHub.Server.csproj" />
|
<Project Path="EchoHub.Server/EchoHub.Server.csproj" />
|
||||||
|
<Project Path="EchoHub.Server.Irc/EchoHub.Server.Irc.csproj" />
|
||||||
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
|
<Project Path="EchoHub.Tests/EchoHub.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
Reference in New Issue
Block a user