mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a247f458b | ||
|
|
fba8fbe49b | ||
|
|
ac067b2c0b | ||
|
|
d1d16f6f0e | ||
|
|
338946382f | ||
|
|
5d1934e2c6 | ||
|
|
441a97e8c9 | ||
|
|
11ce586f29 | ||
|
|
b28b98e336 | ||
|
|
87f2cbb011 |
File diff suppressed because it is too large
Load Diff
@@ -18,16 +18,15 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
||||
```
|
||||
|
||||
|
||||
Owns the full client-side connection lifecycle: authenticating via the `ApiClient`, establishing and wiring an `EchoHubConnection` (SignalR) for realtime events, enabling end-to-end encryption via the `ClientEncryptionService`, and tracking channel membership in `RoomKeyStore` and `_joinedChannels`. Reach for `ConnectionManager` when you want a single, high-level component to manage connection setup, token refresh handling, event forwarding, and channel join/leave logic instead of manipulating `ApiClient` and `EchoHubConnection` directly.
|
||||
Manages a server connection end-to-end: handles authentication via [`ApiClient`](ApiClient.cs.md), establishes end-to-end encryption, creates and wires an [`EchoHubConnection`](EchoHubConnection.cs.md), tracks joined channels, and exposes SignalR events so higher-level orchestrators can react without touching connection internals. Reach for `ConnectionManager` when you want UI code (for example an [`AppOrchestrator`](../AppOrchestrator.cs.md)) to observe connection and chat events through simple events rather than managing [`ApiClient`](ApiClient.cs.md) and [`EchoHubConnection`](EchoHubConnection.cs.md) yourself.
|
||||
|
||||
## Remarks
|
||||
`ConnectionManager` is the orchestration point between the networking primitives (`ApiClient` and `EchoHubConnection`) and the UI layer. It centralizes responsibility for: authenticating (including login, register, and refresh-token flows), persisting rotated refresh tokens via `OnTokensRefreshed`/`HandleTokensRefreshed`, attempting to establish an E2E encryption key with `ClientEncryptionService`, and forwarding SignalR events to consumers through its public events (for example `MessageReceived`, `UserJoined`, `ChannelUpdated`, and `ConnectionStatusChanged`). By exposing `IsConnected`, `IsAuthenticated`, `Api`, and `RoomKeys`, it gives callers enough state to update UI and perform API operations without needing to manage low-level connection state or event wiring.
|
||||
`ConnectionManager` centralizes lifecycle concerns: it authenticates (login/registration/refresh), subscribes to token rotation, attempts to fetch and apply the E2E encryption key, constructs and registers handlers on the [`EchoHubConnection`](EchoHubConnection.cs.md), and ensures channel membership state is tracked. It forwards the hub's runtime events (for example `MessageReceived`, `UserJoined`, `ChannelUpdated`) so callers receive high-level notifications and do not need to bind SignalR handlers directly. The class is intended as the single place that composes [`ApiClient`](ApiClient.cs.md), [`ClientEncryptionService`](ClientEncryptionService.cs.md)/[`RoomKeyStore`](RoomKeyStore.cs.md), and [`EchoHubConnection`](EchoHubConnection.cs.md) into a usable connection for the UI.
|
||||
|
||||
## Notes
|
||||
- `ConnectAsync` throws on authentication failure — callers are expected to handle saved-session expiry and related UI flows. See the `ConnectAsync` progress messages for how the method reports intermediate status.
|
||||
- The class disposes and replaces the internal `ApiClient` during `ConnectAsync` (it calls `_apiClient?.Dispose()`), and implements `IAsyncDisposable`; callers should ensure `DisposeAsync` is invoked when the manager is no longer needed to avoid resource leaks.
|
||||
- Encryption is best-effort: if fetching the encryption key fails (`GetEncryptionKeyAsync`), the manager logs a warning and continues with an unencrypted session — consumers should not assume messages are always encrypted.
|
||||
- The implementation mutates internal fields like `_apiClient`, `_connection`, and `_joinedChannels` without visible synchronization. The class appears intended for single-threaded/UI-thread usage; consumers that access it from multiple threads should serialize calls externally to avoid race conditions.
|
||||
- `ConnectAsync` reports progress via the `onStatus` callback and will throw on authentication failure — callers are expected to handle saved-session expiry and similar error flows.
|
||||
- Event handlers (for example `MessageReceived`, `UserJoined`, `ConnectionStatusChanged`) may be invoked from signalr/connection threads; subscribers should not assume they run on the UI thread and must marshal to the UI thread when necessary.
|
||||
- Always `await` disposing the manager (it implements `IAsyncDisposable`) so underlying resources such as the [`EchoHubConnection`](EchoHubConnection.cs.md) and [`ApiClient`](ApiClient.cs.md) are cleanly released; failing to do so can leave connections or background work active.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,27 +38,24 @@ Owns the full client-side connection lifecycle: authenticating via the `ApiClien
|
||||
internal record ConnectResult(
|
||||
LoginResponse Login,
|
||||
List<ChannelDto> Channels,
|
||||
Dictionary<string, List<MessageDto>> Histories,
|
||||
ServerStatusDto? ServerInfo = null)
|
||||
Dictionary<string, List<MessageDto>> Histories)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
|
||||
| Parameter | Type | Default |
|
||||
|-----------|------|---------|
|
||||
| `Login` | `LoginResponse` | — |
|
||||
| `Login` | [`LoginResponse`](../../EchoHub.Core/DTOs/AuthDtos.cs.md) | — |
|
||||
| `Channels` | `List<ChannelDto>` | — |
|
||||
| `Histories` | `Dictionary<string, List<MessageDto>>` | — |
|
||||
| `ServerInfo` | `ServerStatusDto?` | `null` |
|
||||
|
||||
|
||||
ConnectResult is an internal, immutable `record` that represents the successful outcome of establishing a connection and is returned to the `AppOrchestrator` to drive UI updates. It bundles the login information (`Login`) of type `LoginResponse`, the joined channels (`Channels`) as `List<ChannelDto>`, the initial per-channel histories (`Histories`) as `Dictionary<string, List<MessageDto>>`, and optional server status (`ServerInfo`) as `ServerStatusDto?`. The `Histories` dictionary maps channel names to their corresponding history lists and always includes the default channel.
|
||||
ConnectResult represents the payload returned after a successful connection, carrying everything the [`AppOrchestrator`](../AppOrchestrator.cs.md) needs to update the UI. It includes the authenticated login information (`Login`), the collection of available channels (`Channels`), and the initial per-channel histories (`Histories`), where each channel name maps to its starting list of messages, always including the default channel.
|
||||
|
||||
## Remarks
|
||||
ConnectResult acts as a single, UI-facing snapshot of the connected state. It collects authentication results, channel roster, initial per-channel histories, and optional server health/status so the `AppOrchestrator` can immediately render the connected view without issuing further requests.
|
||||
ConnectResult is a `record`, so it participates in value-based equality and can be treated as a single unit when comparing connection outcomes. Note that its `Channels` and `Histories` collections are mutable (`List<ChannelDto>` and `Dictionary<string, List<MessageDto>>`); if you need true immutability, expose read-only wrappers or clone the collections when passing them onward.
|
||||
|
||||
## Notes
|
||||
- ConnectResult is immutable; use a `with` expression to derive a modified copy rather than mutating the existing instance.
|
||||
- `ServerInfo` may be null; callers should handle absence gracefully.
|
||||
- The contained `List<ChannelDto>` and `Dictionary<string, List<MessageDto>>` are mutable; avoid mutating them in place and consider treating the `ConnectResult` as a snapshot that should be cloned if you require immutability downstream.
|
||||
|
||||
---
|
||||
@@ -24,13 +24,11 @@ public record EncryptionKeyResponse(string Key)
|
||||
| `Key` | `string` | — |
|
||||
|
||||
|
||||
`EncryptionKeyResponse` is a concise data-transfer `record` that carries a single string property named `Key`, representing an encryption key. Use this type when an API response or internal boundary needs to convey the key as a structured envelope rather than a raw string, benefiting from the immutability and value-based equality of a `record`.
|
||||
EncryptionKeyResponse is a minimal, strongly-typed envelope used to return an encryption key from server-side DTOs. It is implemented as a C# `record` with a single property `string Key`, providing value-based equality and convenient deconstruction while keeping the surface area stable for serialization and future extension.
|
||||
|
||||
## Remarks
|
||||
By modeling the payload as its own type, this symbol helps keep key handling explicit and self-describing across boundaries. It pairs with other server DTOs to form a consistent contract for encryption-related data, and it can evolve to carry extra metadata (expiry, algorithm) without breaking existing clients.
|
||||
Using a one-property `record` as a DTO provides a stable, strongly-typed surface for returning the key, while enabling easy evolution (e.g., adding metadata like algorithm, expiration, or salt) without breaking client contracts. It also leverages `record` semantics to support value-based equality and clean deconstruction when used in responses.
|
||||
|
||||
## Notes
|
||||
- Treat the `Key` as sensitive data; avoid logging it or exposing it in traces. Ensure it is transmitted only over secure channels and managed according to your security policy.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,8 +42,7 @@ public record ServerStatusDto(
|
||||
string? Description,
|
||||
int OnlineUsers,
|
||||
int TotalChannels,
|
||||
string RegistrationMode = "open",
|
||||
string Version = "0.0.0")
|
||||
string RegistrationMode = "open")
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
@@ -57,22 +54,15 @@ public record ServerStatusDto(
|
||||
| `OnlineUsers` | `int` | — |
|
||||
| `TotalChannels` | `int` | — |
|
||||
| `RegistrationMode` | `string` | `"open"` |
|
||||
| `Version` | `string` | `"0.0.0"` |
|
||||
|
||||
|
||||
Represents a compact, immutable data transfer object that conveys a server's identity and current activity. It exposes the server's `Name`, optional `Description`, the `OnlineUsers` count, the `TotalChannels`, and optional `RegistrationMode` and `Version` (defaulting to `"open"` and `"0.0.0"` when omitted). Use this DTO in API responses or status endpoints to deliver a stable snapshot of server state.
|
||||
Represents a lightweight, immutable snapshot of a server's status for transport between layers or to clients. It exposes the server's `Name`, optional `Description`, current `OnlineUsers`, total `TotalChannels`, and the `RegistrationMode` (defaulting to `open` when not provided).
|
||||
|
||||
## Remarks
|
||||
Because it is a `record`, `ServerStatusDto` benefits from value-based equality and deconstruction semantics, making it convenient to compare status payloads in tests or across clients. The trailing `RegistrationMode` and `Version` parameters are optional in construction, allowing callers to supply just the core metrics while still producing a complete payload. This DTO isolates status representation from internal domain entities and keeps the shape stable for clients and tooling.
|
||||
|
||||
## Example
|
||||
```csharp
|
||||
// Minimal construction: Description omitted (use null)
|
||||
var status = new ServerStatusDto("EchoHub", null, 12, 3);
|
||||
|
||||
// Full construction with explicit values
|
||||
var statusFull = new ServerStatusDto("EchoHub", "Main gateway", 12, 3, "open", "1.2.0");
|
||||
```
|
||||
Because this is a `record`, it uses value-based equality and immutable properties, making it ideal as a DTO boundary between internal domain models and external consumers. Construct this type from your server state when returning status information to clients, rather than leaking domain entities.
|
||||
|
||||
## Notes
|
||||
- `Description` is nullable (`string?`). Guard against null or provide a fallback when presenting it to callers.
|
||||
- To derive a modified copy (e.g., update `OnlineUsers`), use the `with` expression since `ServerStatusDto` is immutable.
|
||||
|
||||
---
|
||||
@@ -8,10 +8,4 @@ public static class IrcMessageFormatter
|
||||
```
|
||||
|
||||
|
||||
IrcMessageFormatter is a static helper that formats a `MessageDto` into one or more IRC PRIVMSG lines for posting to an IRC channel. It orchestrates the translation of message content, reply references, attachments, and embeds into IRC-compatible payloads, applying line-length constraints and CTCP ACTION handling where appropriate. Attachments are rendered as distinct link lines with an appropriate tag (image, audio, or file) and converted to absolute URLs using an optional `publicBaseUrl`. When embeds are present, they are appended via the embed formatting pipeline. The formatting rules are centralized in this class to ensure consistent IRC output across messages and channels.
|
||||
|
||||
## Remarks
|
||||
By centralizing IRC-specific formatting in `IrcMessageFormatter`, the server ensures consistent transport behavior for all messages moved from the domain model to IRC clients. It isolates concerns about line-length, reply quoting, action formatting, and attachment rendering from higher-level message construction, making it straightforward to adjust how content appears in IRC without changing business logic. The implementation supports plain content as well as CTCP ACTIONs and gracefully handles reply contexts, including a placeholder for encrypted room content when applicable.
|
||||
|
||||
## Notes
|
||||
- If `publicBaseUrl` is not provided and attachments use relative URLs, the resulting links may be non-functional in IRC clients. Ensure a base URL is supplied when needed.
|
||||
IrcMessageFormatter formats a [`MessageDto`](../EchoHub.Core/DTOs/ChatDtos.cs.md) into IRC `PRIVMSG` lines for an EchoHub channel. It splits content into IRC-friendly chunks (up to 400 bytes per line), handles CTCP ACTION content, and prefixes replies with the compact ``> nick: snippet | `` prefix when a reply exists. Attachments are emitted as separate lines with absolute URLs (constructed from the optional `publicBaseUrl`) and labeled by kind (Image, Audio, or File). If present, embeds are appended via the embed formatter. When a reply references encrypted room content, the snippet is shown as `[encrypted]` and non-encrypted snippets are truncated to 80 characters to fit IRC constraints.
|
||||
@@ -10,12 +10,13 @@ public class ServerController : ControllerBase
|
||||
```
|
||||
|
||||
|
||||
ServerController is an ASP.NET Core API controller that exposes server-related admin endpoints under `/api/server`. It assembles live server state by querying the database context for `Users` and `Channels`, reading server metadata from `IConfiguration` (name, description, and registration mode), and deriving the server version from the executing assembly. It returns a `ServerStatusDto` via the `GetInfo` endpoint. The protected endpoints `GetEncryptionKey` and `GetDirectoryStatus` require authentication (and admin privileges for directory status) and return either the configured encryption key or directory-registration state, respectively, without exposing the claim token. A small helper, `GetCallerAsync`, enforces the required role before performing admin-only operations.
|
||||
ServerController is an ASP.NET Core API controller that exposes server-wide information and administrative operations under the `/api/server` route. It wires together runtime configuration, persistence, and directory-state to provide a concise snapshot of the server and a small admin surface for privileged tasks. The public `GetInfo` endpoint returns a [`ServerStatusDto`](../../EchoHub.Core/DTOs/ServerDtos.cs.md) containing the server name, description, user and channel counts, and the current registration mode derived from config. The `GetEncryptionKey` endpoint is protected by `[Authorize]` and returns an [`EncryptionKeyResponse`](../../EchoHub.Core/DTOs/ServerDtos.cs.md) containing the configured key, or a 503 if encryption is not configured. The `GetDirectoryStatus` endpoint is admin-only and surfaces directory registration state, including the server identifier and whether a claim token exists, while never exposing the token itself. A private helper `GetCallerAsync` centralizes authentication and authorization checks for admin actions.
|
||||
|
||||
## Remarks
|
||||
ServerController acts as a focused orchestration boundary that surfaces operator-facing server state by weaving together data from the data layer, configuration, and directory claim store. It centralizes admin concerns (health, configuration, and directory registration) behind clear HTTP endpoints, enabling simple client UIs and tooling. The design emphasizes guarded access for sensitive data (encryption keys and directory status) and relies on role-based checks to restrict those capabilities to admins.
|
||||
|
||||
By centralizing server-wide information and admin operations in a single controller, the architecture cleanly separates concerns: data access ([`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md)), configuration (`IConfiguration`), and directory registration state ([`DirectoryClaimStore`](../Services/DirectoryClaimStore.cs.md)) are coordinated behind stable, contract-driven DTOs ([`ServerStatusDto`](../../EchoHub.Core/DTOs/ServerDtos.cs.md), [`EncryptionKeyResponse`](../../EchoHub.Core/DTOs/ServerDtos.cs.md)). Authorization boundaries are explicit: open information through `GetInfo`, authenticated access for the encryption key, and admin-only access for directory status. The internal `GetCallerAsync` encapsulates common identity/role validation, reducing duplication and potential security gaps across admin endpoints.
|
||||
|
||||
## Notes
|
||||
- Access to `/api/server/encryption-key` and `/api/server/directory` is protected by authentication; admins only for the latter.
|
||||
- The code path for `GetCallerAsync` relies on the `NameIdentifier` claim being a valid GUID; malformed claims could cause an exception at runtime.
|
||||
- If encryption is not configured, `/api/server/encryption-key` responds with HTTP 503 to indicate the service is not ready.
|
||||
|
||||
- The admin surface is guarded: `GetDirectoryStatus` relies on `GetCallerAsync` to enforce that the caller has at least `ServerRole.Admin`; non-admins will receive an appropriate 403/Unauthorized response.
|
||||
- If encryption is not configured on the server, the `GetEncryptionKey` endpoint returns a 503 Service Unavailable, signaling to clients that encryption is not currently available despite the endpoint being accessible.
|
||||
@@ -1,16 +1,16 @@
|
||||
# Drift Report — HueByte/EchoHub
|
||||
|
||||
> 25 stale, 8 ambiguous, 113 ok since commit `40aea9a`.
|
||||
> no docs evaluated since commit `2d5f8ee`.
|
||||
|
||||
**Drift score:** 
|
||||
**Drift score:** n/a (no docs evaluated)
|
||||
|
||||
| Status | Count |
|
||||
| --- | ---: |
|
||||
| 🔴 Broken | 0 |
|
||||
| 🟡 Stale | 25 |
|
||||
| 🟠 Ambiguous | 8 |
|
||||
| 🟢 OK | 113 |
|
||||
| **Total** | **146** |
|
||||
| 🟡 Stale | 0 |
|
||||
| 🟠 Ambiguous | 0 |
|
||||
| 🟢 OK | 0 |
|
||||
| **Total** | **0** |
|
||||
|
||||
## Broken (0)
|
||||
|
||||
@@ -18,173 +18,25 @@ Docs that reference symbols the merge deleted. These must be regenerated or remo
|
||||
|
||||
*None.*
|
||||
|
||||
## Stale (25)
|
||||
## Stale (0)
|
||||
|
||||
Docs whose underlying symbols changed in this merge. Targeted regeneration is in flight.
|
||||
|
||||
- [`README.md`](../Code/README.md.md) — References 1 modified symbol(s)
|
||||
- [`ui-theming.md`](../Code/ui-theming.md.md) — References 1 modified symbol(s)
|
||||
- [`Architecture.md`](../Code/Architecture.md.md) — References 4 modified symbol(s)
|
||||
- [`Agent/repo-map.md`](../Code/Agent/repo-map.md.md) — References 9 modified symbol(s)
|
||||
- [`command-handling.md`](../Code/command-handling.md.md) — References 2 modified symbol(s)
|
||||
- [`update-management.md`](../Code/update-management.md.md) — References 1 modified symbol(s)
|
||||
- [`Workflows/controller.md`](../Code/Workflows/controller.md.md) — References 1 modified symbol(s)
|
||||
- [`attachments-transfer.md`](../Code/attachments-transfer.md.md) — References 1 modified symbol(s)
|
||||
- [`real-time-connection.md`](../Code/real-time-connection.md.md) — References 3 modified symbol(s)
|
||||
- [`api-client-authentication.md`](../Code/api-client-authentication.md.md) — References 1 modified symbol(s)
|
||||
- [`src/EchoHub.Client/Program.cs`](../Code/src/EchoHub.Client/Program.cs.md) — References 1 modified symbol(s)
|
||||
- [`src/EchoHub.Client/UI/MainWindow.cs`](../Code/src/EchoHub.Client/UI/MainWindow.cs.md) — References 1 modified symbol(s)
|
||||
- [`src/EchoHub.Core/DTOs/ServerDtos.cs`](../Code/src/EchoHub.Core/DTOs/ServerDtos.cs.md) — References 1 modified symbol(s)
|
||||
- [`src/EchoHub.Client/AppOrchestrator.cs`](../Code/src/EchoHub.Client/AppOrchestrator.cs.md) — References 3 modified symbol(s)
|
||||
- [`src/EchoHub.Client/Services/ApiClient.cs`](../Code/src/EchoHub.Client/Services/ApiClient.cs.md) — References 4 modified symbol(s)
|
||||
- [`src/EchoHub.Server.Irc/IrcBroadcaster.cs`](../Code/src/EchoHub.Server.Irc/IrcBroadcaster.cs.md) — References 2 modified symbol(s)
|
||||
- [`src/EchoHub.Client/Services/AvatarHelper.cs`](../Code/src/EchoHub.Client/Services/AvatarHelper.cs.md) — References 1 modified symbol(s)
|
||||
- [`src/EchoHub.Server.Irc/IrcCommandHandler.cs`](../Code/src/EchoHub.Server.Irc/IrcCommandHandler.cs.md) — References 1 modified symbol(s)
|
||||
- [`src/EchoHub.Server.Irc/IrcGatewayService.cs`](../Code/src/EchoHub.Server.Irc/IrcGatewayService.cs.md) — References 2 modified symbol(s)
|
||||
- [`src/EchoHub.Server.Irc/IrcMessageFormatter.cs`](../Code/src/EchoHub.Server.Irc/IrcMessageFormatter.cs.md) — References 1 modified symbol(s)
|
||||
- [`src/EchoHub.Server.Irc/IrcServiceExtensions.cs`](../Code/src/EchoHub.Server.Irc/IrcServiceExtensions.cs.md) — References 3 modified symbol(s)
|
||||
- [`src/EchoHub.Client/Services/ConnectionManager.cs`](../Code/src/EchoHub.Client/Services/ConnectionManager.cs.md) — References 5 modified symbol(s)
|
||||
- [`src/EchoHub.Client/Services/EchoHubConnection.cs`](../Code/src/EchoHub.Client/Services/EchoHubConnection.cs.md) — References 2 modified symbol(s)
|
||||
- [`src/EchoHub.Server/Controllers/ServerController.cs`](../Code/src/EchoHub.Server/Controllers/ServerController.cs.md) — References 3 modified symbol(s)
|
||||
- [`docs/changelog/v0.2.17.md`](../Code/docs/changelog/v0.2.17.md.md) — New file — needs initial documentation
|
||||
*None.*
|
||||
|
||||
## Ambiguous (8)
|
||||
## Ambiguous (0)
|
||||
|
||||
Docs that reference symbols the baseline cache doesn't know about — likely a stale cache rather than real drift.
|
||||
|
||||
- [`src/EchoHub.Core/DTOs/AuthDtos.cs`](../Code/src/EchoHub.Core/DTOs/AuthDtos.cs.md) — References an unknown symbol — cache may be stale
|
||||
- [`src/EchoHub.Core/DTOs/ChatDtos.cs`](../Code/src/EchoHub.Core/DTOs/ChatDtos.cs.md) — References an unknown symbol — cache may be stale
|
||||
- [`src/EchoHub.Core/DTOs/CommonDtos.cs`](../Code/src/EchoHub.Core/DTOs/CommonDtos.cs.md) — References an unknown symbol — cache may be stale
|
||||
- [`src/EchoHub.Core/DTOs/InviteDtos.cs`](../Code/src/EchoHub.Core/DTOs/InviteDtos.cs.md) — References an unknown symbol — cache may be stale
|
||||
- [`src/EchoHub.Core/DTOs/AccountDtos.cs`](../Code/src/EchoHub.Core/DTOs/AccountDtos.cs.md) — References an unknown symbol — cache may be stale
|
||||
- [`src/EchoHub.Core/DTOs/ProfileDtos.cs`](../Code/src/EchoHub.Core/DTOs/ProfileDtos.cs.md) — References an unknown symbol — cache may be stale
|
||||
- [`src/EchoHub.Tests/Irc/TestHelpers.cs`](../Code/src/EchoHub.Tests/Irc/TestHelpers.cs.md) — References an unknown symbol — cache may be stale
|
||||
- [`src/EchoHub.Core/DTOs/ModerationDtos.cs`](../Code/src/EchoHub.Core/DTOs/ModerationDtos.cs.md) — References an unknown symbol — cache may be stale
|
||||
*None.*
|
||||
|
||||
## OK (113)
|
||||
## OK (0)
|
||||
|
||||
Docs the merge did not impact. No action required.
|
||||
|
||||
<details>
|
||||
<summary>Show 113 unaffected doc(s)</summary>
|
||||
|
||||
- [`index.md`](../Code/index.md.md)
|
||||
- [`Onboarding.md`](../Code/Onboarding.md.md)
|
||||
- [`Agent/README.md`](../Code/Agent/README.md.md)
|
||||
- [`clipboard-tools.md`](../Code/clipboard-tools.md.md)
|
||||
- [`Workflows/service.md`](../Code/Workflows/service.md.md)
|
||||
- [`encryption-roomkeys.md`](../Code/encryption-roomkeys.md.md)
|
||||
- [`Agent/symbol-graph.json`](../Code/Agent/symbol-graph.json.md)
|
||||
- [`src/EchoHub.Server/Program.cs`](../Code/src/EchoHub.Server/Program.cs.md)
|
||||
- [`src/EchoHub.Core/Models/User.cs`](../Code/src/EchoHub.Core/Models/User.cs.md)
|
||||
- [`src/EchoHub.Client/Themes/Theme.cs`](../Code/src/EchoHub.Client/Themes/Theme.cs.md)
|
||||
- [`src/EchoHub.Core/Models/Channel.cs`](../Code/src/EchoHub.Core/Models/Channel.cs.md)
|
||||
- [`src/EchoHub.Core/Models/Message.cs`](../Code/src/EchoHub.Core/Models/Message.cs.md)
|
||||
- [`src/EchoHub.Server/Hubs/ChatHub.cs`](../Code/src/EchoHub.Server/Hubs/ChatHub.cs.md)
|
||||
- [`src/EchoHub.Server.Irc/IrcMessage.cs`](../Code/src/EchoHub.Server.Irc/IrcMessage.cs.md)
|
||||
- [`src/EchoHub.Server.Irc/IrcOptions.cs`](../Code/src/EchoHub.Server.Irc/IrcOptions.cs.md)
|
||||
- [`src/EchoHub.Core/Models/Attachment.cs`](../Code/src/EchoHub.Core/Models/Attachment.cs.md)
|
||||
- [`src/EchoHub.Core/Models/InviteCode.cs`](../Code/src/EchoHub.Core/Models/InviteCode.cs.md)
|
||||
- [`src/EchoHub.Core/Models/ServerRole.cs`](../Code/src/EchoHub.Core/Models/ServerRole.cs.md)
|
||||
- [`src/EchoHub.Core/Models/UserStatus.cs`](../Code/src/EchoHub.Core/Models/UserStatus.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Chat/ChatLine.cs`](../Code/src/EchoHub.Client/UI/Chat/ChatLine.cs.md)
|
||||
- [`src/EchoHub.Core/Models/MessageType.cs`](../Code/src/EchoHub.Core/Models/MessageType.cs.md)
|
||||
- [`src/EchoHub.Core/Models/RefreshToken.cs`](../Code/src/EchoHub.Core/Models/RefreshToken.cs.md)
|
||||
- [`src/EchoHub.Core/Security/RoomCrypto.cs`](../Code/src/EchoHub.Core/Security/RoomCrypto.cs.md)
|
||||
- [`src/EchoHub.Client/Services/PathSetup.cs`](../Code/src/EchoHub.Client/Services/PathSetup.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Chat/ChatColors.cs`](../Code/src/EchoHub.Client/UI/Chat/ChatColors.cs.md)
|
||||
- [`src/EchoHub.Server/Config/SpamOptions.cs`](../Code/src/EchoHub.Server/Config/SpamOptions.cs.md)
|
||||
- [`src/EchoHub.Server/Services/SpamGuard.cs`](../Code/src/EchoHub.Server/Services/SpamGuard.cs.md)
|
||||
- [`src/EchoHub.Client/Config/ClientConfig.cs`](../Code/src/EchoHub.Client/Config/ClientConfig.cs.md)
|
||||
- [`src/EchoHub.Client/Themes/ThemeManager.cs`](../Code/src/EchoHub.Client/Themes/ThemeManager.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Chat/ChatSegment.cs`](../Code/src/EchoHub.Client/UI/Chat/ChatSegment.cs.md)
|
||||
- [`src/EchoHub.Core/Models/AttachmentKind.cs`](../Code/src/EchoHub.Core/Models/AttachmentKind.cs.md)
|
||||
- [`src/EchoHub.Server.Irc/IrcNumericReply.cs`](../Code/src/EchoHub.Server.Irc/IrcNumericReply.cs.md)
|
||||
- [`src/EchoHub.Server/Config/StatsOptions.cs`](../Code/src/EchoHub.Server/Config/StatsOptions.cs.md)
|
||||
- [`src/EchoHub.Server/Config/UploadLimits.cs`](../Code/src/EchoHub.Server/Config/UploadLimits.cs.md)
|
||||
- [`src/EchoHub.Server/Setup/DatabaseSetup.cs`](../Code/src/EchoHub.Server/Setup/DatabaseSetup.cs.md)
|
||||
- [`src/EchoHub.Server/Setup/FirstRunSetup.cs`](../Code/src/EchoHub.Server/Setup/FirstRunSetup.cs.md)
|
||||
- [`src/EchoHub.Client/Config/ConfigManager.cs`](../Code/src/EchoHub.Client/Config/ConfigManager.cs.md)
|
||||
- [`src/EchoHub.Client/Services/AsyncRunner.cs`](../Code/src/EchoHub.Client/Services/AsyncRunner.cs.md)
|
||||
- [`src/EchoHub.Client/Services/UserSession.cs`](../Code/src/EchoHub.Client/Services/UserSession.cs.md)
|
||||
- [`src/EchoHub.Core/Constants/HubConstants.cs`](../Code/src/EchoHub.Core/Constants/HubConstants.cs.md)
|
||||
- [`src/EchoHub.Core/Contracts/IChatService.cs`](../Code/src/EchoHub.Core/Contracts/IChatService.cs.md)
|
||||
- [`src/EchoHub.Core/Contracts/IUserService.cs`](../Code/src/EchoHub.Core/Contracts/IUserService.cs.md)
|
||||
- [`src/EchoHub.Server/Auth/JwtTokenService.cs`](../Code/src/EchoHub.Server/Auth/JwtTokenService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/ChatService.cs`](../Code/src/EchoHub.Server/Services/ChatService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/UserService.cs`](../Code/src/EchoHub.Server/Services/UserService.cs.md)
|
||||
- [`src/EchoHub.Client/Services/RoomKeyStore.cs`](../Code/src/EchoHub.Client/Services/RoomKeyStore.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Chat/RenderHelpers.cs`](../Code/src/EchoHub.Client/UI/Chat/RenderHelpers.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Chat/WelcomeBanner.cs`](../Code/src/EchoHub.Client/UI/Chat/WelcomeBanner.cs.md)
|
||||
- [`src/EchoHub.Server/Data/EchoHubDbContext.cs`](../Code/src/EchoHub.Server/Data/EchoHubDbContext.cs.md)
|
||||
- [`src/EchoHub.Client/Services/UpdateChecker.cs`](../Code/src/EchoHub.Client/Services/UpdateChecker.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Chat/ChatListSource.cs`](../Code/src/EchoHub.Client/UI/Chat/ChatListSource.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Helpers/EmojiHelper.cs`](../Code/src/EchoHub.Client/UI/Helpers/EmojiHelper.cs.md)
|
||||
- [`src/EchoHub.Core/Contracts/IEchoHubClient.cs`](../Code/src/EchoHub.Core/Contracts/IEchoHubClient.cs.md)
|
||||
- [`src/EchoHub.Core/Models/ChannelMembership.cs`](../Code/src/EchoHub.Core/Models/ChannelMembership.cs.md)
|
||||
- [`src/EchoHub.Core/Models/ServerStatsReport.cs`](../Code/src/EchoHub.Core/Models/ServerStatsReport.cs.md)
|
||||
- [`src/EchoHub.Client/Commands/CommandHandler.cs`](../Code/src/EchoHub.Client/Commands/CommandHandler.cs.md)
|
||||
- [`src/EchoHub.Client/Services/ClipboardFiles.cs`](../Code/src/EchoHub.Client/Services/ClipboardFiles.cs.md)
|
||||
- [`src/EchoHub.Client/Services/ClipboardImage.cs`](../Code/src/EchoHub.Client/Services/ClipboardImage.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/SearchDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/StatusDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/StatusDialog.cs.md)
|
||||
- [`src/EchoHub.Core/Contracts/IChannelService.cs`](../Code/src/EchoHub.Core/Contracts/IChannelService.cs.md)
|
||||
- [`src/EchoHub.Server.Irc/IrcClientConnection.cs`](../Code/src/EchoHub.Server.Irc/IrcClientConnection.cs.md)
|
||||
- [`src/EchoHub.Server/Services/ChannelService.cs`](../Code/src/EchoHub.Server/Services/ChannelService.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/ConnectDialog.cs.md)
|
||||
- [`src/EchoHub.Core/Contracts/IChatBroadcaster.cs`](../Code/src/EchoHub.Core/Contracts/IChatBroadcaster.cs.md)
|
||||
- [`src/EchoHub.Server/Config/ServerLogsOptions.cs`](../Code/src/EchoHub.Server/Config/ServerLogsOptions.cs.md)
|
||||
- [`src/EchoHub.Server/Services/PresenceTracker.cs`](../Code/src/EchoHub.Server/Services/PresenceTracker.cs.md)
|
||||
- [`src/EchoHub.Client/Services/RoomKeyProtector.cs`](../Code/src/EchoHub.Client/Services/RoomKeyProtector.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Helpers/HexColorHelper.cs`](../Code/src/EchoHub.Client/UI/Helpers/HexColorHelper.cs.md)
|
||||
- [`src/EchoHub.Core/Services/AsciiBannerService.cs`](../Code/src/EchoHub.Core/Services/AsciiBannerService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/LinkEmbedService.cs`](../Code/src/EchoHub.Server/Services/LinkEmbedService.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Chat/ChatMessageManager.cs`](../Code/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Helpers/NickColorHelper.cs`](../Code/src/EchoHub.Client/UI/Helpers/NickColorHelper.cs.md)
|
||||
- [`src/EchoHub.Core/Constants/MessageConventions.cs`](../Code/src/EchoHub.Core/Constants/MessageConventions.cs.md)
|
||||
- [`src/EchoHub.Core/Services/ImageToAsciiService.cs`](../Code/src/EchoHub.Core/Services/ImageToAsciiService.cs.md)
|
||||
- [`src/EchoHub.Server/Controllers/AuthController.cs`](../Code/src/EchoHub.Server/Controllers/AuthController.cs.md)
|
||||
- [`src/EchoHub.Server/Setup/DataMigrationService.cs`](../Code/src/EchoHub.Server/Setup/DataMigrationService.cs.md)
|
||||
- [`src/EchoHub.Client/Services/NativeFolderPicker.cs`](../Code/src/EchoHub.Client/Services/NativeFolderPicker.cs.md)
|
||||
- [`src/EchoHub.Client/Services/OutgoingAttachment.cs`](../Code/src/EchoHub.Client/Services/OutgoingAttachment.cs.md)
|
||||
- [`src/EchoHub.Core/Constants/ValidationConstants.cs`](../Code/src/EchoHub.Core/Constants/ValidationConstants.cs.md)
|
||||
- [`src/EchoHub.Core/Services/FileValidationHelper.cs`](../Code/src/EchoHub.Core/Services/FileValidationHelper.cs.md)
|
||||
- [`src/EchoHub.Server/Controllers/FilesController.cs`](../Code/src/EchoHub.Server/Controllers/FilesController.cs.md)
|
||||
- [`src/EchoHub.Server/Controllers/UsersController.cs`](../Code/src/EchoHub.Server/Controllers/UsersController.cs.md)
|
||||
- [`src/EchoHub.Server/Services/FileCleanupService.cs`](../Code/src/EchoHub.Server/Services/FileCleanupService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/FileStorageService.cs`](../Code/src/EchoHub.Server/Services/FileStorageService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/SignalRBroadcaster.cs`](../Code/src/EchoHub.Server/Services/SignalRBroadcaster.cs.md)
|
||||
- [`src/EchoHub.Client/Services/UpdateBackupService.cs`](../Code/src/EchoHub.Client/Services/UpdateBackupService.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/AudioPlayerDialog.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/ProfileEditDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/ProfileEditDialog.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/ProfileViewDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/ProfileViewDialog.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Helpers/DroppedFileParser.cs`](../Code/src/EchoHub.Client/UI/Helpers/DroppedFileParser.cs.md)
|
||||
- [`src/EchoHub.Server/Services/DirectoryClaimStore.cs`](../Code/src/EchoHub.Server/Services/DirectoryClaimStore.cs.md)
|
||||
- [`src/EchoHub.Client/Services/AudioPlaybackService.cs`](../Code/src/EchoHub.Client/Services/AudioPlaybackService.cs.md)
|
||||
- [`src/EchoHub.Client/UI/ListSources/UserListSource.cs`](../Code/src/EchoHub.Client/UI/ListSources/UserListSource.cs.md)
|
||||
- [`src/EchoHub.Server/Controllers/InvitesController.cs`](../Code/src/EchoHub.Server/Controllers/InvitesController.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/CreateChannelDialog.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/UpdateConfirmDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/UpdateConfirmDialog.cs.md)
|
||||
- [`src/EchoHub.Server/Controllers/ChannelsController.cs`](../Code/src/EchoHub.Server/Controllers/ChannelsController.cs.md)
|
||||
- [`src/EchoHub.Server/Services/MuteExpirationService.cs`](../Code/src/EchoHub.Server/Services/MuteExpirationService.cs.md)
|
||||
- [`src/EchoHub.Client/UI/ListSources/SearchListSource.cs`](../Code/src/EchoHub.Client/UI/ListSources/SearchListSource.cs.md)
|
||||
- [`src/EchoHub.Server/Services/ServerDirectoryService.cs`](../Code/src/EchoHub.Server/Services/ServerDirectoryService.cs.md)
|
||||
- [`src/EchoHub.Client/Services/ClientEncryptionService.cs`](../Code/src/EchoHub.Client/Services/ClientEncryptionService.cs.md)
|
||||
- [`src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs`](../Code/src/EchoHub.Client/UI/Dialogs/ChannelPasswordDialog.cs.md)
|
||||
- [`src/EchoHub.Client/UI/ListSources/ChannelListSource.cs`](../Code/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs.md)
|
||||
- [`src/EchoHub.Server/Controllers/ModerationController.cs`](../Code/src/EchoHub.Server/Controllers/ModerationController.cs.md)
|
||||
- [`src/EchoHub.Client/Services/NotificationSoundService.cs`](../Code/src/EchoHub.Client/Services/NotificationSoundService.cs.md)
|
||||
- [`src/EchoHub.Core/Contracts/IMessageEncryptionService.cs`](../Code/src/EchoHub.Core/Contracts/IMessageEncryptionService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/MessageEncryptionService.cs`](../Code/src/EchoHub.Server/Services/MessageEncryptionService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs`](../Code/src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs.md)
|
||||
- [`src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs`](../Code/src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs.md)
|
||||
- [`src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs`](../Code/src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs`](../Code/src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs.md)
|
||||
- [`src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs`](../Code/src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs.md)
|
||||
|
||||
</details>
|
||||
*None.*
|
||||
|
||||
---
|
||||
|
||||
*Generated by AurionDocs on 2026-07-23 21:13:35 UTC*
|
||||
*Commit range: `40aea9a` → `2d5f8ee`*
|
||||
*Generated by AurionDocs on 2026-07-25 19:24:11 UTC*
|
||||
*Commit range: `2d5f8ee` → `65766ea`*
|
||||
|
||||
@@ -4,6 +4,7 @@ Release history for EchoHub.
|
||||
|
||||
## Releases
|
||||
|
||||
- [v0.2.18](v0.2.18.md) - IRCv3 Capabilities & Native OS Notifications
|
||||
- [v0.2.17](v0.2.17.md) - Server Version Reporting & IRC Multi-Line Fix
|
||||
- [v0.2.16](v0.2.16.md) - Periodic Server-Stats Report, Upload & Moderation Logging & Quieter Connection Logs
|
||||
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
- name: Overview
|
||||
href: index.md
|
||||
- name: v0.2.18
|
||||
href: v0.2.18.md
|
||||
- name: v0.2.17
|
||||
href: v0.2.17.md
|
||||
- name: v0.2.16
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# v0.2.18
|
||||
|
||||
## New Features
|
||||
|
||||
- **IRCv3 capabilities** — `server-time`, `message-tags`, `echo-message`, `batch` and `draft/multiline` are now advertised and fully supported in the IRC gateway. CAP LS 302 requests receive capability values. Clients can enable caps via CAP REQ and disable them with CAP REQ -cap.
|
||||
- **Native OS notifications** — EchoHub now supports native notifications on Windows, macOS and Linux. Notifications are sent for mentions or replies.
|
||||
@@ -23,3 +23,4 @@
|
||||
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
|
||||
- [x] space between mod|admin "icon" and username
|
||||
- [ ] Embeds still incorrectly display colors
|
||||
- [ ] Find a better solution for Native OS notifications, currently uses the [OsNotifications](https://github.com/DemonExposer/OsNotifications) library, maintained by a single person.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.17</Version>
|
||||
<Version>0.2.18</Version>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -27,6 +27,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
private readonly ChatMessageManager _messageManager;
|
||||
private readonly CommandHandler _commandHandler;
|
||||
private readonly NotificationSoundService _notificationSound;
|
||||
private readonly OsNotificationService _osNotification;
|
||||
private readonly AudioPlaybackService _audioPlayback = new();
|
||||
private readonly UpdateChecker _updateService;
|
||||
private readonly ConnectionManager _conn = new();
|
||||
@@ -65,6 +66,7 @@ public sealed class AppOrchestrator : IDisposable
|
||||
_mainWindow = new MainWindow(app, _messageManager);
|
||||
_commandHandler = new CommandHandler();
|
||||
_notificationSound = new NotificationSoundService(config.Notifications);
|
||||
_osNotification = new OsNotificationService();
|
||||
_updateService = new UpdateChecker(app);
|
||||
|
||||
WireMainWindowEvents();
|
||||
@@ -1105,6 +1107,9 @@ public sealed class AppOrchestrator : IDisposable
|
||||
&& message.Content.Contains($"@{_session.Username}", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_ = _notificationSound.PlayAsync();
|
||||
string title = $"Mentioned by {message.SenderDisplayName ?? message.SenderUsername} in #{message.ChannelName}";
|
||||
string body = message.Content.Length > 200 ? message.Content[..200] + "..." : message.Content;
|
||||
_osNotification.Show(title, body);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
|
||||
<PackageReference Include="NetCoreAudio" Version="2.0.1" />
|
||||
<PackageReference Include="OsNotifications" Version="1.1.5" />
|
||||
<PackageReference Include="Serilog" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using OsNotifications;
|
||||
|
||||
using Serilog;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
public sealed class OsNotificationService
|
||||
{
|
||||
static OsNotificationService()
|
||||
{
|
||||
Notifications.SetGuiApplication(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows an OS notification with the given title and optional body.
|
||||
/// </summary>
|
||||
/// <param name="title"></param>
|
||||
/// <param name="body"></param>
|
||||
public void Show(string title, string? body = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Notifications.ShowNotification(title, body ?? string.Empty);
|
||||
}
|
||||
catch (PlatformNotSupportedException ex)
|
||||
{
|
||||
Log.Warning(ex, "OS notifications not supported on this platform");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to show OS notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,20 +23,108 @@ public class IrcBroadcaster : IChatBroadcaster
|
||||
Content = _encryption.Decrypt(message.Content),
|
||||
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
||||
};
|
||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
||||
|
||||
// Clients that support message-tags receive the +reply tag instead of
|
||||
// the text reply prefix, so format without it. The sender's echo-message
|
||||
// also needs the raw content to correlate with what they sent.
|
||||
var hasReply = message.ReplyTo is not null;
|
||||
var modernMessage = hasReply ? decryptedMessage with { ReplyTo = null } : decryptedMessage;
|
||||
var legacyLines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
||||
var modernLines = hasReply
|
||||
? IrcMessageFormatter.FormatMessage(modernMessage, _gateway.Options.PublicBaseUrl)
|
||||
: legacyLines;
|
||||
|
||||
// Compute shared tag components (same for all connections in this channel)
|
||||
var serverTimeTag = message.SentAt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
|
||||
var msgid = message.Id.ToString("D");
|
||||
var replyMsgid = message.ReplyTo?.MessageId.ToString("D");
|
||||
|
||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||
{
|
||||
// IRC convention: don't echo a message back to the connection that sent it
|
||||
// (its client already displayed it locally). Match by connection id, not
|
||||
// nickname — the same account may also be online via the TUI or a second
|
||||
// IRC client, and those sessions must still receive the message.
|
||||
if (conn.ConnectionId == excludeConnectionId)
|
||||
// Don't echo back to traditional IRC clients (they display locally).
|
||||
// Echo-message clients need their own messages back for msgid/+reply tracking.
|
||||
if (conn.ConnectionId == excludeConnectionId && !conn.HasCap("echo-message"))
|
||||
continue;
|
||||
|
||||
foreach (var line in lines)
|
||||
await conn.SendAsync(line);
|
||||
// Use modern lines (without reply prefix) for echo-message senders and
|
||||
// for any client that gets the +reply tag via message-tags capability.
|
||||
var useModern = conn.ConnectionId == excludeConnectionId || conn.HasCap("message-tags");
|
||||
var lines = useModern ? modernLines : legacyLines;
|
||||
|
||||
// Build per-connection tags
|
||||
var tags = new List<(string Key, string? Value)>();
|
||||
|
||||
if (conn.HasCap("server-time"))
|
||||
tags.Add(("time", serverTimeTag));
|
||||
|
||||
if (conn.HasCap("message-tags"))
|
||||
{
|
||||
tags.Add(("msgid", msgid));
|
||||
if (replyMsgid is not null)
|
||||
tags.Add(("+reply", replyMsgid));
|
||||
}
|
||||
|
||||
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||
|
||||
if (conn.HasCap("draft/multiline") && conn.HasCap("batch") && lines.Count > 1)
|
||||
{
|
||||
await SendMultilineBatchAsync(conn, channelName, tagPrefix, lines);
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var line in lines)
|
||||
await conn.SendAsync(tagPrefix + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendMultilineBatchAsync(
|
||||
IrcClientConnection conn, string channelName, string tagPrefix, List<string> lines)
|
||||
{
|
||||
if (lines.Count == 0) return;
|
||||
|
||||
var batchRef = $"ml{Guid.NewGuid().ToString("N")[..8]}";
|
||||
var ircChannel = $"#{channelName}";
|
||||
|
||||
// Per draft/multiline spec: msgid and +reply go on the BATCH start line
|
||||
// only; per-message tags (time, batch) go on individual lines.
|
||||
// Parse the pre-built tagPrefix to split batch-level from line-level tags.
|
||||
string batchTags, lineTags;
|
||||
if (tagPrefix.Length > 0 && tagPrefix.StartsWith('@'))
|
||||
{
|
||||
var tagBody = tagPrefix.AsSpan(1).TrimEnd(' ');
|
||||
var parts = tagBody.ToString().Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
var batchParts = new List<string>();
|
||||
var lineParts = new List<string>();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part.StartsWith("msgid=") || part.StartsWith("+reply="))
|
||||
batchParts.Add(part);
|
||||
else
|
||||
lineParts.Add(part);
|
||||
}
|
||||
batchTags = batchParts.Count > 0 ? "@" + string.Join(";", batchParts) + " " : "";
|
||||
lineParts.Add("batch=" + batchRef);
|
||||
lineTags = "@" + string.Join(";", lineParts) + " ";
|
||||
}
|
||||
else
|
||||
{
|
||||
batchTags = "";
|
||||
lineTags = "@batch=" + batchRef + " ";
|
||||
}
|
||||
|
||||
// Extract the sender prefix from the first line
|
||||
var firstLine = lines[0];
|
||||
var senderPrefix = firstLine.StartsWith(':')
|
||||
? firstLine[1..firstLine.IndexOf(' ')]
|
||||
: _gateway.Options.ServerName;
|
||||
|
||||
await conn.SendAsync($"{batchTags}:{senderPrefix} BATCH +{batchRef} draft/multiline {ircChannel}");
|
||||
|
||||
foreach (var line in lines)
|
||||
await conn.SendAsync($"{lineTags}{line}");
|
||||
|
||||
await conn.SendAsync($"BATCH -{batchRef}");
|
||||
}
|
||||
|
||||
public async Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||
@@ -44,7 +132,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||
{
|
||||
if (conn.ConnectionId == excludeConnectionId) continue;
|
||||
await conn.SendAsync($":{username}!{username}@echohub JOIN #{channelName}");
|
||||
var line = $":{username}!{username}@echohub JOIN #{channelName}";
|
||||
|
||||
var tags = new List<(string Key, string? Value)>();
|
||||
if (conn.HasCap("server-time"))
|
||||
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||
|
||||
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||
await conn.SendAsync(tagPrefix + line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +148,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||
{
|
||||
if (conn.Nickname == username) continue;
|
||||
await conn.SendAsync($":{username}!{username}@echohub PART #{channelName}");
|
||||
var line = $":{username}!{username}@echohub PART #{channelName}";
|
||||
|
||||
var tags = new List<(string Key, string? Value)>();
|
||||
if (conn.HasCap("server-time"))
|
||||
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||
|
||||
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||
await conn.SendAsync(tagPrefix + line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +166,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
||||
|
||||
foreach (var conn in _gateway.GetConnectionsInChannel(target))
|
||||
{
|
||||
await conn.SendAsync($":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}");
|
||||
var line = $":{_gateway.Options.ServerName} TOPIC #{channel.Name} :{channel.Topic}";
|
||||
|
||||
var tags = new List<(string Key, string? Value)>();
|
||||
if (conn.HasCap("server-time"))
|
||||
tags.Add(("time", DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||
|
||||
var tagPrefix = tags.Count > 0 ? IrcMessage.BuildTagPrefix([.. tags]) : "";
|
||||
await conn.SendAsync(tagPrefix + line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,22 @@ public sealed class IrcClientConnection : IAsyncDisposable
|
||||
public bool IsSasl { get; set; }
|
||||
public bool CapNegotiating { get; set; }
|
||||
|
||||
// Highest CAP LS version received from client (0 = no version)
|
||||
public int CapVersion { get; set; }
|
||||
|
||||
public HashSet<string> EnabledCaps { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool HasCap(string cap) => EnabledCaps.Contains(cap);
|
||||
public void EnableCap(string cap) => EnabledCaps.Add(cap);
|
||||
public void DisableCap(string cap) => EnabledCaps.Remove(cap);
|
||||
|
||||
// Channel state — thread-safe: written by command handler, read by broadcaster threads
|
||||
private readonly HashSet<string> _joinedChannels = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _channelLock = new();
|
||||
|
||||
// Non-null while BATCH lines are being collected
|
||||
public MultilineBatchContext? PendingMultilineBatch { get; set; }
|
||||
|
||||
// Away state
|
||||
public string? AwayMessage { get; set; }
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ public sealed class IrcCommandHandler
|
||||
|
||||
private string ServerName => _options.ServerName;
|
||||
|
||||
private static readonly Dictionary<string, string?> ServerCaps = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["sasl"] = null,
|
||||
["server-time"] = null,
|
||||
["message-tags"] = null,
|
||||
["echo-message"] = null,
|
||||
["batch"] = null,
|
||||
["draft/multiline"] = "max-bytes=40000,max-lines=10",
|
||||
};
|
||||
|
||||
public IrcCommandHandler(
|
||||
IrcClientConnection conn,
|
||||
IrcOptions options,
|
||||
@@ -82,6 +92,7 @@ public sealed class IrcCommandHandler
|
||||
"JOIN" => HandleJoinAsync(msg),
|
||||
"PART" => HandlePartAsync(msg),
|
||||
"PRIVMSG" => HandlePrivmsgAsync(msg),
|
||||
"NOTICE" => Task.CompletedTask,
|
||||
"QUIT" => HandleQuitAsync(msg),
|
||||
"NAMES" => HandleNamesAsync(msg),
|
||||
"TOPIC" => HandleTopicAsync(msg),
|
||||
@@ -93,36 +104,46 @@ public sealed class IrcCommandHandler
|
||||
"MOTD" => SendMotdAsync(),
|
||||
"USERHOST" or "LUSERS" => Task.CompletedTask,
|
||||
|
||||
// IRCv3 multiline batch
|
||||
"BATCH" => HandleBatchAsync(msg),
|
||||
|
||||
_ => _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_UNKNOWNCOMMAND,
|
||||
$"{command} :Unknown command"),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Authentication ──────────────────────────────────────────────────────
|
||||
// ── IRCv3 CAP Negotiation ─────────────────────────────────────────────
|
||||
|
||||
private static readonly string[] CapList302 = [.. ServerCaps.Select(kvp =>
|
||||
kvp.Value is not null ? $"{kvp.Key}={kvp.Value}" : kvp.Key)];
|
||||
|
||||
private static readonly string[] CapListLegacy = [.. ServerCaps.Keys];
|
||||
|
||||
private async Task HandleCapAsync(IrcMessage msg)
|
||||
{
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
if (msg.Parameters.Count < 1)
|
||||
{
|
||||
await SendInvalidCapCmdAsync("CAP requires a subcommand");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (msg.Parameters[0].ToUpperInvariant())
|
||||
var subcommand = msg.Parameters[0].ToUpperInvariant();
|
||||
|
||||
// Build the nick placeholder for server responses (use * while unregistered)
|
||||
var nick = _conn.Nickname ?? "*";
|
||||
|
||||
switch (subcommand)
|
||||
{
|
||||
case "LS":
|
||||
await _conn.SendAsync($":{ServerName} CAP * LS :sasl");
|
||||
_conn.CapNegotiating = true;
|
||||
await HandleCapLsAsync(msg, nick);
|
||||
break;
|
||||
|
||||
case "LIST":
|
||||
await HandleCapListAsync(nick);
|
||||
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}");
|
||||
}
|
||||
await HandleCapReqAsync(msg, nick);
|
||||
break;
|
||||
|
||||
case "END":
|
||||
@@ -130,9 +151,247 @@ public sealed class IrcCommandHandler
|
||||
if (_conn.Nickname is not null && _conn.Username is not null && !_conn.IsRegistered)
|
||||
await TryCompleteRegistrationAsync();
|
||||
break;
|
||||
|
||||
default:
|
||||
await SendInvalidCapCmdAsync($"Unknown subcommand {subcommand}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCapLsAsync(IrcMessage msg, string nick)
|
||||
{
|
||||
// Parse optional version argument
|
||||
var version = 0;
|
||||
if (msg.Parameters.Count >= 2 && int.TryParse(msg.Parameters[1], out var v))
|
||||
version = v;
|
||||
|
||||
// Store the highest version seen (clients cannot downgrade)
|
||||
if (version > _conn.CapVersion)
|
||||
_conn.CapVersion = version;
|
||||
|
||||
// Decide capability list format based on negotiated version
|
||||
var caps = version >= 302 ? CapList302 : CapListLegacy;
|
||||
|
||||
// Suspend registration during CAP negotiation
|
||||
_conn.CapNegotiating = true;
|
||||
|
||||
// Multiline CAP LS 302 response
|
||||
if (version >= 302 && caps.Length > 0)
|
||||
{
|
||||
// If the total fits in one line, send it as a single reply
|
||||
var singleLine = string.Join(" ", caps);
|
||||
if (singleLine.Length < 400)
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} LS :{singleLine}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Split across multiple lines; all but the last get '*' as a marker
|
||||
var lines = SplitCapList(caps, 400);
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var marker = i < lines.Count - 1 ? "*" : "";
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} LS {marker}:{lines[i]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (caps.Length > 0)
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} LS :{string.Join(" ", caps)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} LS :");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCapListAsync(string nick)
|
||||
{
|
||||
var enabled = _conn.EnabledCaps.ToArray();
|
||||
if (enabled.Length > 0)
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} LIST :{string.Join(" ", enabled)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} LIST :");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCapReqAsync(IrcMessage msg, string nick)
|
||||
{
|
||||
if (msg.Parameters.Count < 2 || string.IsNullOrWhiteSpace(msg.Parameters[1]))
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} NAK :");
|
||||
return;
|
||||
}
|
||||
|
||||
var requested = msg.Parameters[1].Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Validate all caps are known before modifying anything (all-or-nothing)
|
||||
var ackList = new List<string>();
|
||||
var valid = true;
|
||||
|
||||
foreach (var item in requested)
|
||||
{
|
||||
var cap = item;
|
||||
if (cap.StartsWith('-'))
|
||||
cap = cap[1..];
|
||||
|
||||
// cap-notify is implicitly enabled for CAP LS 302; accept it silently
|
||||
if (cap.Equals("cap-notify", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (!ServerCaps.ContainsKey(cap))
|
||||
{
|
||||
valid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valid)
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} NAK :{msg.Parameters[1]}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in requested)
|
||||
{
|
||||
if (item.StartsWith('-'))
|
||||
{
|
||||
_conn.DisableCap(item[1..]);
|
||||
ackList.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
_conn.EnableCap(item);
|
||||
ackList.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (ackList.Count > 0)
|
||||
{
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} ACK :{string.Join(" ", ackList)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
// No actual caps to ack (e.g. cap-notify only) — send empty ACK
|
||||
await _conn.SendAsync($":{ServerName} CAP {nick} ACK :");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendInvalidCapCmdAsync(string message)
|
||||
{
|
||||
var nick = _conn.Nickname ?? "*";
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_INVALIDCAPCMD, nick, $"{message}");
|
||||
}
|
||||
|
||||
private static List<string> SplitCapList(string[] caps, int maxLen)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var current = new List<string>();
|
||||
var currentLen = 0;
|
||||
|
||||
foreach (var cap in caps)
|
||||
{
|
||||
var capLen = cap.Length + (current.Count > 0 ? 1 : 0); // +1 for leading space
|
||||
if (currentLen + capLen > maxLen && current.Count > 0)
|
||||
{
|
||||
lines.Add(string.Join(" ", current));
|
||||
current.Clear();
|
||||
currentLen = 0;
|
||||
capLen = cap.Length;
|
||||
}
|
||||
current.Add(cap);
|
||||
currentLen += capLen;
|
||||
}
|
||||
|
||||
if (current.Count > 0)
|
||||
lines.Add(string.Join(" ", current));
|
||||
|
||||
return lines.Count > 0 ? lines : [""];
|
||||
}
|
||||
|
||||
// ── IRCv3 Multiline Batch ─────────────────────────────────────────────
|
||||
|
||||
private async Task HandleBatchAsync(IrcMessage msg)
|
||||
{
|
||||
if (!await RequireRegisteredAsync()) return;
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
|
||||
var reference = msg.Parameters[0];
|
||||
|
||||
if (reference.StartsWith('-'))
|
||||
{
|
||||
// BATCH -ref → end of batch
|
||||
var batch = _conn.PendingMultilineBatch;
|
||||
if (batch is null || batch.ReferenceTag != reference[1..])
|
||||
return;
|
||||
|
||||
_conn.PendingMultilineBatch = null;
|
||||
await FlushMultilineBatchAsync(batch);
|
||||
}
|
||||
else
|
||||
{
|
||||
// BATCH +ref type [target]
|
||||
if (msg.Parameters.Count < 2) return;
|
||||
var type = msg.Parameters[1];
|
||||
|
||||
if (!type.Equals("draft/multiline", StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
if (msg.Parameters.Count < 3) return;
|
||||
var target = msg.Parameters[2];
|
||||
|
||||
var channelName = IrcToEchoHubChannel(target);
|
||||
if (channelName is null) return;
|
||||
|
||||
var batchCtx = new MultilineBatchContext(reference[1..], channelName);
|
||||
|
||||
// Capture +reply tag from the BATCH start line for reply handling
|
||||
if (msg.Tags.TryGetValue("+reply", out var replyStr) &&
|
||||
Guid.TryParse(replyStr, out var replyId))
|
||||
{
|
||||
batchCtx.ReplyToMessageId = replyId;
|
||||
}
|
||||
|
||||
_conn.PendingMultilineBatch = batchCtx;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FlushMultilineBatchAsync(MultilineBatchContext batch)
|
||||
{
|
||||
if (batch.Lines.Count == 0) return;
|
||||
|
||||
// Validate: no blank lines with concat tag, no entirely blank messages
|
||||
var allBlank = true;
|
||||
for (var i = 0; i < batch.Lines.Count; i++)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(batch.Lines[i]))
|
||||
{
|
||||
allBlank = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allBlank) return;
|
||||
|
||||
// Per spec: lines joined by \n by default; draft/multiline-concat lines
|
||||
// are directly concatenated (already handled during collection).
|
||||
var content = string.Join("\n", batch.Lines);
|
||||
|
||||
var error = await _chatService.SendMessageAsync(
|
||||
_conn.UserId!.Value, _conn.Nickname!, batch.Target, content, _conn.ConnectionId, batch.ReplyToMessageId);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_CANNOTSENDTOCHAN,
|
||||
$"#{batch.Target} :{error}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authentication ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task HandleAuthenticateAsync(IrcMessage msg)
|
||||
{
|
||||
if (msg.Parameters.Count < 1) return;
|
||||
@@ -328,8 +587,26 @@ public sealed class IrcCommandHandler
|
||||
$":This server was created {DateTimeOffset.UtcNow:yyyy-MM-dd}");
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_MYINFO,
|
||||
$"{ServerName} EchoHub-IRC o o");
|
||||
|
||||
var isupportTokens = new List<string>
|
||||
{
|
||||
"CHANTYPES=#",
|
||||
"CHANMODES=b,k,,,",
|
||||
"NICKLEN=50",
|
||||
"CHANNELLEN=100",
|
||||
"CLIENTTAGDENY=*,-reply",
|
||||
};
|
||||
|
||||
// If the client has message-tags, advertise CLIENTTAGDENY
|
||||
if (_conn.HasCap("message-tags"))
|
||||
{
|
||||
isupportTokens.Add("CLIENTTAGDENY=*,-reply");
|
||||
}
|
||||
|
||||
isupportTokens.Add(":are supported by this server");
|
||||
|
||||
await _conn.SendNumericAsync(ServerName, IrcNumericReply.RPL_ISUPPORT,
|
||||
"CHANTYPES=# CHANMODES=b,k,, NICKLEN=50 CHANNELLEN=100 :are supported by this server");
|
||||
string.Join(" ", isupportTokens));
|
||||
|
||||
await SendMotdAsync();
|
||||
}
|
||||
@@ -449,7 +726,7 @@ public sealed class IrcCommandHandler
|
||||
};
|
||||
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
|
||||
foreach (var line in lines)
|
||||
await _conn.SendAsync(line);
|
||||
await _conn.SendAsync(DecorateLineForConnection(line, m));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -499,8 +776,44 @@ public sealed class IrcCommandHandler
|
||||
var channelName = IrcToEchoHubChannel(target);
|
||||
if (channelName is null) return;
|
||||
|
||||
// Check if we're inside a multiline batch
|
||||
var batch = _conn.PendingMultilineBatch;
|
||||
if (batch is not null)
|
||||
{
|
||||
if (batch.Target != channelName)
|
||||
return;
|
||||
|
||||
// Collect this line. If the message has the draft/multiline-concat tag,
|
||||
// it appends directly without a newline separator.
|
||||
var isConcat = msg.Tags.ContainsKey("draft/multiline-concat");
|
||||
if (isConcat)
|
||||
{
|
||||
if (string.IsNullOrEmpty(content))
|
||||
return; // blank concat lines not allowed
|
||||
batch.UsesConcat = true;
|
||||
// Append to the last line (or start a new one)
|
||||
if (batch.Lines.Count > 0)
|
||||
batch.Lines[^1] += content;
|
||||
else
|
||||
batch.Lines.Add(content);
|
||||
}
|
||||
else
|
||||
{
|
||||
batch.Lines.Add(content);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse +reply tag for reply-to support from IRC clients
|
||||
Guid? replyTo = null;
|
||||
if (msg.Tags.TryGetValue("+reply", out var replyStr) &&
|
||||
Guid.TryParse(replyStr, out var replyId))
|
||||
{
|
||||
replyTo = replyId;
|
||||
}
|
||||
|
||||
var error = await _chatService.SendMessageAsync(
|
||||
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId);
|
||||
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId, replyTo);
|
||||
|
||||
if (error is not null)
|
||||
{
|
||||
@@ -816,4 +1129,24 @@ public sealed class IrcCommandHandler
|
||||
var name = ircChannel[1..].ToLowerInvariant().Trim();
|
||||
return ValidationConstants.ChannelNameRegex().IsMatch(name) ? name : null;
|
||||
}
|
||||
|
||||
private string DecorateLineForConnection(string line, MessageDto message)
|
||||
{
|
||||
var tags = new List<(string Key, string? Value)>();
|
||||
|
||||
if (_conn.HasCap("server-time"))
|
||||
tags.Add(("time", message.SentAt.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||
|
||||
if (_conn.HasCap("message-tags"))
|
||||
{
|
||||
tags.Add(("msgid", message.Id.ToString("D")));
|
||||
if (message.ReplyTo is not null)
|
||||
tags.Add(("+reply", message.ReplyTo.MessageId.ToString("D")));
|
||||
}
|
||||
|
||||
if (tags.Count == 0)
|
||||
return line;
|
||||
|
||||
return IrcMessage.BuildTagPrefix([.. tags]) + line;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Text;
|
||||
|
||||
namespace EchoHub.Server.Irc;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed representation of an IRC protocol line.
|
||||
/// Format: [:prefix] COMMAND [params...] [:trailing]
|
||||
/// IRCv3 format: ['@' tags ' '] [':' prefix ' '] COMMAND [params...] [':' trailing]
|
||||
/// </summary>
|
||||
public sealed class IrcMessage
|
||||
{
|
||||
public Dictionary<string, string?> Tags { get; init; } = new();
|
||||
public string? Prefix { get; init; }
|
||||
public string Command { get; init; } = "";
|
||||
public List<string> Parameters { get; init; } = [];
|
||||
@@ -13,29 +16,64 @@ public sealed class IrcMessage
|
||||
public string? Trailing => Parameters.Count > 0 ? Parameters[^1] : null;
|
||||
|
||||
/// <summary>
|
||||
/// Parse a raw IRC line: [:prefix SPACE] command [SPACE params] CRLF
|
||||
/// Parse a raw IRC line with optional IRCv3 message tags.
|
||||
/// Format: ['@' tags ' '] [':' prefix ' '] COMMAND 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] == ':')
|
||||
if (span.Length == 0)
|
||||
return new IrcMessage { Command = "" };
|
||||
|
||||
var tags = new Dictionary<string, string?>();
|
||||
|
||||
// Parse optional IRCv3 message tags: @tag1;tag2=val2;...
|
||||
if (span[pos] == '@')
|
||||
{
|
||||
var spaceIdx = span.IndexOf(' ');
|
||||
if (spaceIdx == -1)
|
||||
return new IrcMessage { Prefix = span[1..].ToString() };
|
||||
|
||||
prefix = span[1..spaceIdx].ToString();
|
||||
if (spaceIdx > 1)
|
||||
{
|
||||
var tagSection = span[1..spaceIdx].ToString();
|
||||
foreach (var rawTag in tagSection.Split(';'))
|
||||
{
|
||||
var eqIdx = rawTag.IndexOf('=');
|
||||
if (eqIdx == -1)
|
||||
{
|
||||
tags[rawTag] = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var key = rawTag[..eqIdx];
|
||||
var val = TagUnescape(rawTag[(eqIdx + 1)..]);
|
||||
tags[key] = val;
|
||||
}
|
||||
}
|
||||
pos = spaceIdx + 1;
|
||||
}
|
||||
}
|
||||
|
||||
string? prefix = null;
|
||||
|
||||
// Parse optional prefix
|
||||
if (pos < span.Length && span[pos] == ':')
|
||||
{
|
||||
var spaceIdx = span[pos..].IndexOf(' ');
|
||||
if (spaceIdx == -1)
|
||||
return new IrcMessage { Tags = tags, Prefix = span[(pos + 1)..].ToString(), Command = "" };
|
||||
|
||||
prefix = span.Slice(pos + 1, spaceIdx - 1).ToString();
|
||||
pos = pos + spaceIdx + 1;
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
while (pos < span.Length && span[pos] == ' ') pos++;
|
||||
|
||||
// Parse command
|
||||
if (pos >= span.Length)
|
||||
return new IrcMessage { Tags = tags, Prefix = prefix, Command = "" };
|
||||
|
||||
var cmdStart = pos;
|
||||
while (pos < span.Length && span[pos] != ' ') pos++;
|
||||
var command = span[cmdStart..pos].ToString();
|
||||
@@ -61,9 +99,78 @@ public sealed class IrcMessage
|
||||
|
||||
return new IrcMessage
|
||||
{
|
||||
Tags = tags,
|
||||
Prefix = prefix,
|
||||
Command = command,
|
||||
Parameters = parameters,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the message tags prefix for outgoing lines.
|
||||
/// Returns "@key1=val1;key2=val2 " or empty string if no tags.
|
||||
/// Values are tag-escaped. Client-only tags (+prefix) pass through.
|
||||
/// </summary>
|
||||
public static string BuildTagPrefix(params (string Key, string? Value)[] tags)
|
||||
{
|
||||
if (tags.Length == 0) return "";
|
||||
|
||||
var sb = new StringBuilder("@");
|
||||
var first = true;
|
||||
foreach (var (key, value) in tags)
|
||||
{
|
||||
if (!first) sb.Append(';');
|
||||
first = false;
|
||||
sb.Append(key);
|
||||
if (value is not null)
|
||||
{
|
||||
sb.Append('=');
|
||||
sb.Append(TagEscape(value));
|
||||
}
|
||||
}
|
||||
sb.Append(' ');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escape a tag value per IRCv3 message-tags spec.
|
||||
/// ; → \:, SPACE → \s, \ → \\, CR → \r, LF → \n
|
||||
/// </summary>
|
||||
public static string TagEscape(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("\\", "\\\\")
|
||||
.Replace(";", "\\:")
|
||||
.Replace(" ", "\\s")
|
||||
.Replace("\r", "\\r")
|
||||
.Replace("\n", "\\n");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unescape a tag value per IRCv3 message-tags spec.
|
||||
/// </summary>
|
||||
public static string TagUnescape(string value)
|
||||
{
|
||||
var sb = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] == '\\' && i + 1 < value.Length)
|
||||
{
|
||||
switch (value[i + 1])
|
||||
{
|
||||
case ':': sb.Append(';'); i++; break;
|
||||
case 's': sb.Append(' '); i++; break;
|
||||
case '\\': sb.Append('\\'); i++; break;
|
||||
case 'r': sb.Append('\r'); i++; break;
|
||||
case 'n': sb.Append('\n'); i++; break;
|
||||
default: sb.Append(value[i]); break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(value[i]);
|
||||
}
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ public static class IrcNumericReply
|
||||
public const string RPL_ENDOFBANLIST = "368";
|
||||
|
||||
// Errors
|
||||
public const string ERR_INVALIDCAPCMD = "410";
|
||||
public const string ERR_NOSUCHNICK = "401";
|
||||
public const string ERR_NOSUCHCHANNEL = "403";
|
||||
public const string ERR_CANNOTSENDTOCHAN = "404";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace EchoHub.Server.Irc;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks an in-progress multiline batch (draft/multiline) being collected
|
||||
/// from an IRC client before dispatching as a single EchoHub message.
|
||||
/// </summary>
|
||||
public sealed class MultilineBatchContext(string referenceTag, string target)
|
||||
{
|
||||
public string ReferenceTag { get; } = referenceTag;
|
||||
public string Target { get; } = target;
|
||||
public List<string> Lines { get; } = [];
|
||||
public bool UsesConcat { get; set; }
|
||||
public Guid? ReplyToMessageId { get; set; }
|
||||
}
|
||||
@@ -335,6 +335,151 @@ public class IrcBroadcasterTests
|
||||
|
||||
// ── SendUserStatusChangedAsync ───────────────────────────────────────
|
||||
|
||||
// ── IRCv3 Tags (server-time, msgid, reply) ───────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithServerTimeCap_IncludesTimeTag()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("server-time");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general",
|
||||
new DateTimeOffset(2024, 1, 15, 10, 30, 0, TimeSpan.Zero));
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.StartsWith("@time=2024-01-15T10:30:00.000Z"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithoutServerTimeCap_NoTimeTag()
|
||||
{
|
||||
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.All(output, l => Assert.DoesNotContain("@time=", l));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithMessageTagsCap_IncludesMsgid()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("message-tags");
|
||||
|
||||
var msgId = Guid.NewGuid();
|
||||
var message = new MessageDto(
|
||||
msgId, _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.Contains($"msgid={msgId:D}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithMessageTagsAndReply_IncludesReplyTag()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("message-tags");
|
||||
|
||||
var replyToId = Guid.NewGuid();
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Hello!"), "alice", null, "general", DateTimeOffset.UtcNow,
|
||||
ReplyTo: new ReplyRefDto(replyToId, "bob", _encryption.Encrypt("Original")));
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.Contains($"+reply={replyToId:D}"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithAllTags_FormatsCorrectly()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("server-time");
|
||||
conn.EnableCap("message-tags");
|
||||
|
||||
var msgId = Guid.NewGuid();
|
||||
var sentAt = new DateTimeOffset(2024, 6, 15, 14, 30, 0, TimeSpan.Zero);
|
||||
var message = new MessageDto(
|
||||
msgId, _encryption.Encrypt("Hey"), "alice", null, "general", sentAt);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.StartsWith("@time=2024-06-15T14:30:00.000Z;msgid="));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendUserJoined_WithServerTime_IncludesTimeTag()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("server-time");
|
||||
|
||||
await _broadcaster.SendUserJoinedAsync("general", "alice", null);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.Contains(output, l => l.StartsWith("@time=") && l.Contains("JOIN"));
|
||||
}
|
||||
|
||||
// ── Multiline Batch ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithMultilineCap_WrapsInBatch()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("draft/multiline");
|
||||
conn.EnableCap("batch");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
// BATCH start should use the user's prefix, not the server name
|
||||
Assert.Contains(output, l => l.Contains(":alice!alice@echohub BATCH +") && l.Contains("draft/multiline"));
|
||||
Assert.Contains(output, l => l.Contains("BATCH -"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_WithoutMultilineCap_SendsLinesDirectly()
|
||||
{
|
||||
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Line1\nLine2"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendMessage_SingleLineWithMultilineCap_NoBatch()
|
||||
{
|
||||
var (conn, stream) = AddConnectionWithCapture("bob", "general");
|
||||
conn.EnableCap("draft/multiline");
|
||||
conn.EnableCap("batch");
|
||||
|
||||
var message = new MessageDto(
|
||||
Guid.NewGuid(), _encryption.Encrypt("Just one line"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||
|
||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
Assert.All(output, l => Assert.DoesNotContain("BATCH", l));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendUserStatusChanged_IsNoOp()
|
||||
{
|
||||
|
||||
@@ -164,11 +164,59 @@ public class IrcCommandHandlerTests
|
||||
// ── CAP / SASL ──────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CapLs_AdvertisesSasl()
|
||||
public async Task CapLs_AdvertisesCapabilities()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP LS"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("server-time"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("message-tags"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("batch"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapLs302_AdvertisesCapabilitiesWithValues()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP LS 302"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("draft/multiline=max-bytes=40000"));
|
||||
Assert.Contains(lines, l => l.Contains("CAP") && l.Contains("sasl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapLs_SuspendsRegistration()
|
||||
{
|
||||
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
|
||||
|
||||
var lines = await RunAndCapture([
|
||||
"CAP LS",
|
||||
"PASS secret123",
|
||||
"NICK alice",
|
||||
"USER alice 0 * :Alice"
|
||||
]);
|
||||
|
||||
// Should NOT get welcome (001) since CAP END wasn't sent
|
||||
Assert.DoesNotContain(lines, l => l.Contains("001") && l.Contains("Welcome"));
|
||||
|
||||
// Registration should still be pending
|
||||
Assert.True(lines.All(l => !l.Contains("001")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapEnd_ResumesRegistration()
|
||||
{
|
||||
_userService.AuthResult = FakeUserService.SuccessResult(Guid.NewGuid(), "alice");
|
||||
|
||||
var lines = await RunAndCapture([
|
||||
"CAP LS",
|
||||
"PASS secret123",
|
||||
"NICK alice",
|
||||
"USER alice 0 * :Alice",
|
||||
"CAP END"
|
||||
]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("001") && l.Contains("Welcome"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -179,12 +227,61 @@ public class IrcCommandHandlerTests
|
||||
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("sasl"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapReqMultiple_Acknowledged()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP REQ :server-time message-tags"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("ACK") && l.Contains("server-time") && l.Contains("message-tags"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapReqUnknown_GetsNak()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP REQ :multi-prefix"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("NAK"));
|
||||
Assert.Contains(lines, l => l.Contains("NAK") && l.Contains("multi-prefix"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapReqDisableCap_DisablesAndAcks()
|
||||
{
|
||||
var (conn, stream) = TestIrcConnectionFactory.Create(
|
||||
"CAP REQ :message-tags\r\nCAP REQ :-message-tags\r\nCAP END\r\nNICK alice\r\nUSER alice 0 * :Alice\r\n".Split("\r\n", StringSplitOptions.RemoveEmptyEntries));
|
||||
|
||||
var handler = new IrcCommandHandler(conn, new IrcOptions { ServerName = "testserver" },
|
||||
_chatService, _userService, _channelService, _encryption, NullLogger.Instance);
|
||||
await handler.RunAsync(CancellationToken.None);
|
||||
|
||||
var output = stream.GetOutputLines();
|
||||
|
||||
// First REQ should ACK
|
||||
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("message-tags"));
|
||||
|
||||
// Second REQ with - should ACK with - prefix
|
||||
Assert.Contains(output, l => l.Contains("ACK") && l.Contains("-message-tags"));
|
||||
|
||||
// Cap should be disabled
|
||||
Assert.False(conn.HasCap("message-tags"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapList_AfterEnable_ShowsEnabledCaps()
|
||||
{
|
||||
var lines = await RunAndCapture([
|
||||
"CAP REQ :server-time",
|
||||
"CAP LIST"
|
||||
]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("LIST") && l.Contains("server-time"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CapUnknownSubcommand_Gets410()
|
||||
{
|
||||
var lines = await RunAndCapture(["CAP FOO"]);
|
||||
|
||||
Assert.Contains(lines, l => l.Contains("410") && l.Contains("FOO"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -693,6 +790,49 @@ public class IrcCommandHandlerTests
|
||||
Assert.Contains(lines, l => l.Contains("376")); // ENDOFMOTD
|
||||
}
|
||||
|
||||
// ── BATCH (Multiline) ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_Multiline_CollectsAndFlushes()
|
||||
{
|
||||
var lines = await RunAuthenticated([
|
||||
"BATCH +abc123 draft/multiline #general",
|
||||
"@batch=abc123 PRIVMSG #general :Hello",
|
||||
"@batch=abc123 PRIVMSG #general :world",
|
||||
"BATCH -abc123"
|
||||
]);
|
||||
|
||||
// Should send a single message with lines joined by newlines
|
||||
Assert.Single(_chatService.SentMessages);
|
||||
Assert.Equal("general", _chatService.SentMessages[0].Channel);
|
||||
Assert.Equal("Hello\nworld", _chatService.SentMessages[0].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_MultilineWithConcat_JoinsWithoutNewline()
|
||||
{
|
||||
var lines = await RunAuthenticated([
|
||||
"BATCH +abc123 draft/multiline #general",
|
||||
"@batch=abc123 PRIVMSG #general :hello ",
|
||||
"@batch=abc123;draft/multiline-concat PRIVMSG #general :world",
|
||||
"BATCH -abc123"
|
||||
]);
|
||||
|
||||
Assert.Single(_chatService.SentMessages);
|
||||
Assert.Equal("hello world", _chatService.SentMessages[0].Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_MultilineEmptyBatch_DoesNothing()
|
||||
{
|
||||
var lines = await RunAuthenticated([
|
||||
"BATCH +abc123 draft/multiline #general",
|
||||
"BATCH -abc123"
|
||||
]);
|
||||
|
||||
Assert.Empty(_chatService.SentMessages);
|
||||
}
|
||||
|
||||
// ── Unknown command ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -180,4 +180,128 @@ public class IrcMessageTests
|
||||
var msg = IrcMessage.Parse("MODE #channel +o alice");
|
||||
Assert.Equal("alice", msg.Trailing);
|
||||
}
|
||||
|
||||
// ── IRCv3 Message Tags ──────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithTags_ExtractsTags()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z;msgid=abc PRIVMSG #channel :Hello");
|
||||
|
||||
Assert.Equal(2, msg.Tags.Count);
|
||||
Assert.Equal("2024-01-01T12:00:00.000Z", msg.Tags["time"]);
|
||||
Assert.Equal("abc", msg.Tags["msgid"]);
|
||||
Assert.Equal("PRIVMSG", msg.Command);
|
||||
Assert.Equal("Hello", msg.Parameters[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithClientOnlyTags_ExtractsPlusPrefix()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@+reply=abc123;+example.com/tag=val PRIVMSG #channel :Hello");
|
||||
|
||||
Assert.Equal(2, msg.Tags.Count);
|
||||
Assert.Equal("abc123", msg.Tags["+reply"]);
|
||||
Assert.Equal("val", msg.Tags["+example.com/tag"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WithTagsAndPrefix_ExtractsBoth()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@time=2024-01-01T12:00:00.000Z :alice!user@host PRIVMSG #channel :Hello");
|
||||
|
||||
Assert.Single(msg.Tags);
|
||||
Assert.Equal("alice!user@host", msg.Prefix);
|
||||
Assert.Equal("PRIVMSG", msg.Command);
|
||||
Assert.Equal("Hello", msg.Parameters[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TagWithEscapedValue_Unescapes()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@key=hello\\sworld\\:! PRIVMSG #channel :Hi");
|
||||
|
||||
Assert.Single(msg.Tags);
|
||||
Assert.Equal("hello world;!", msg.Tags["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TagWithNoValue_StoresNull()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@empty;key=val PRIVMSG #channel :Hi");
|
||||
|
||||
Assert.Equal(2, msg.Tags.Count);
|
||||
Assert.Null(msg.Tags["empty"]);
|
||||
Assert.Equal("val", msg.Tags["key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BatchTag_ParsesMultilineContext()
|
||||
{
|
||||
var msg = IrcMessage.Parse("@batch=abc123 PRIVMSG #channel :line content");
|
||||
|
||||
Assert.Single(msg.Tags);
|
||||
Assert.Equal("abc123", msg.Tags["batch"]);
|
||||
Assert.Equal("PRIVMSG", msg.Command);
|
||||
Assert.Equal("line content", msg.Parameters[1]);
|
||||
}
|
||||
|
||||
// ── Tag Escaping ─────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TagEscape_HandlesSpecialChars()
|
||||
{
|
||||
Assert.Equal("hello\\sworld", IrcMessage.TagEscape("hello world"));
|
||||
Assert.Equal("a\\:b", IrcMessage.TagEscape("a;b"));
|
||||
Assert.Equal("a\\\\b", IrcMessage.TagEscape("a\\b"));
|
||||
Assert.Equal("a\\rb\\n", IrcMessage.TagEscape("a\rb\n"));
|
||||
|
||||
// Regular chars pass through unchanged
|
||||
Assert.Equal("plain-text_123", IrcMessage.TagEscape("plain-text_123"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TagUnescape_HandlesEscapeSequences()
|
||||
{
|
||||
Assert.Equal("hello world", IrcMessage.TagUnescape("hello\\sworld"));
|
||||
Assert.Equal("a;b", IrcMessage.TagUnescape("a\\:b"));
|
||||
Assert.Equal("a\\b", IrcMessage.TagUnescape("a\\\\b"));
|
||||
Assert.Equal("a\rb\n", IrcMessage.TagUnescape("a\\rb\\n"));
|
||||
}
|
||||
|
||||
// ── BuildTagPrefix ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_NoTags_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal("", IrcMessage.BuildTagPrefix());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_SingleTag_FormatsCorrectly()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"));
|
||||
Assert.Equal("@time=2024-01-01T12:00:00.000Z ", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_MultipleTags_SemicolonSeparated()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("time", "2024-01-01T12:00:00.000Z"), ("msgid", "abc123"));
|
||||
Assert.Equal("@time=2024-01-01T12:00:00.000Z;msgid=abc123 ", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_TagWithNullValue_OmitsEquals()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("tag-only", null));
|
||||
Assert.Equal("@tag-only ", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildTagPrefix_ClientOnlyTag_IncludesPlusPrefix()
|
||||
{
|
||||
var result = IrcMessage.BuildTagPrefix(("+reply", "msg-123"));
|
||||
Assert.Equal("@+reply=msg-123 ", result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user