+Enum AttachmentAction +
+ + + +An action a click on an attachment line can trigger.
+public enum AttachmentAction
+ Fields +
+-
+
OpenImage = 0
+
+
+ SaveImage = 1
+
+
+
diff --git a/api/client/EchoHub.Client.Commands.CommandHandler.html b/api/client/EchoHub.Client.Commands.CommandHandler.html index 0c1c5b7..55e77bb 100644 --- a/api/client/EchoHub.Client.Commands.CommandHandler.html +++ b/api/client/EchoHub.Client.Commands.CommandHandler.html @@ -379,6 +379,96 @@ Class CommandHandler + OnCreateInvite + + + +
+ + +public event Func<int?, int?, Task>? OnCreateInvite
+ public event Func<Task>? OnDeleteAccount
+ public event Func<Task>? OnExportData
+ public event Func<Task>? OnListInvites
+ public event Func<string, Task>? OnRevokeInvite
+ public event Func<string, Task>? OnSendAction
+ public event Func<string, Task>? OnSendBanner
+ Status update. A null status means "keep the current status"; a null message means +"keep the current message" and an empty message means "clear it". The orchestrator +resolves both against the session state.
+public event Func<UserStatus, string?, Task>? OnSetStatus
+ public event Func<UserStatus?, string?, Task>? OnSetStatus
public Task<InviteDto?> CreateInviteAsync(int? maxUses = null, int? expiresInHours = null)
+ maxUses int?expiresInHours int?Deletes the caller's account. The password re-confirms intent.
+public Task DeleteMyAccountAsync(string password)
+ password stringDownloads the caller's full data export as raw JSON text.
+public Task<string> ExportMyDataAsync()
+ /meta command. Returns null if
+
+
+ public Task<List<InviteDto>> GetInvitesAsync()
+ public Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
+ public Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null, string? inviteCode = null)
displayName stringinviteCode stringpublic Task RevokeInviteAsync(string code)
+ code stringpublic Task SendMessageAsync(string channelName, string content)
+ public Task SendMessageAsync(string channelName, string content, Guid? replyToMessageId = null)
replyToMessageId Guid?public event Action<string>? OnChannelDeleted
+ An action a click on an attachment line can trigger.
+public enum AttachmentAction
+ OpenImage = 0SaveImage = 1Inclusive column range on a chat line that triggers an attachment action when clicked.
+public readonly struct AttachmentActionSpan : IEquatable<AttachmentActionSpan>
+ Inclusive column range on a chat line that triggers an attachment action when clicked.
+public AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action)
+ StartCol intEndCol intAction AttachmentActionpublic AttachmentAction Action { get; init; }
+ public int EndCol { get; init; }
+ public int StartCol { get; init; }
+ public void Deconstruct(out int StartCol, out int EndCol, out AttachmentAction Action)
+ StartCol intEndCol intAction AttachmentActionpublic bool Equals(AttachmentActionSpan other)
+ other AttachmentActionSpanpublic override bool Equals(object obj)
+ obj objectpublic override int GetHashCode()
+ public override string ToString()
+ public static bool operator ==(AttachmentActionSpan left, AttachmentActionSpan right)
+ left AttachmentActionSpanright AttachmentActionSpanpublic static bool operator !=(AttachmentActionSpan left, AttachmentActionSpan right)
+ left AttachmentActionSpanright AttachmentActionSpanClickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an +image). Columns are relative to the unwrapped line, so only the first wrapped line +keeps them. Null means the whole line uses the kind's default action.
+public List<AttachmentActionSpan>? ActionSpans { get; set; }
+ Set on a reply's quote line: activating the line jumps to this message +if it is in the loaded history.
+public Guid? JumpToMessageId { get; set; }
+ A colored text segment within a chat line.
Inclusive column range on a chat line that triggers an attachment action when clicked.
+An action a click on an attachment line can trigger.
+public ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister, bool RememberMe, string? SavedRefreshToken)
+ public ConnectDialogResult(string ServerUrl, string Username, string Password, bool IsRegister, bool RememberMe, string? SavedRefreshToken, string? DisplayName = null, string? InviteCode = null)
DisplayName stringInviteCode stringpublic string? DisplayName { get; init; }
+ public string? InviteCode { get; init; }
+ public void Deconstruct(out string ServerUrl, out string Username, out string Password, out bool IsRegister, out bool RememberMe, out string? SavedRefreshToken)
+ public void Deconstruct(out string ServerUrl, out string Username, out string Password, out bool IsRegister, out bool RememberMe, out string? SavedRefreshToken, out string? DisplayName, out string? InviteCode)
DisplayName stringInviteCode stringpublic void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel, IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null, IReadOnlySet<string>? privateChannels = null)
+ public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel, IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null, IReadOnlySet<string>? privateChannels = null, IReadOnlySet<string>? systemChannels = null)
privateChannels IReadOnlySet<string>systemChannels IReadOnlySet<string>public bool HasPendingReplyIndicator { get; }
+ public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null)
+ public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null, bool? isSystem = null)
isProtected bool?isSystem bool?Shows/clears the "replying to" strip on the input frame's title. Pass null to clear.
+public void SetReplyingTo(string? label)
+ label stringFired when the user activates an image's "[open]" action to view it without saving. +Parameters: attachmentUrl, fileName.
+public event Action<string, string>? OnImageOpenRequested
+ Fired when the user cancels a pending reply (Esc in the input field).
+public event Action? OnReplyCancelRequested
+ Fired when the user picks "Reply" on a message. Parameters: message id, sender username, +a short plain-text snippet for the reply strip.
+public event Action<Guid, string, string>? OnReplyRequested
+ Cross-protocol message conventions. Action messages (/me) use the IRC CTCP ACTION
+wire format 0x01 + "ACTION " + text + 0x01 as the stored content, so IRC clients
+interoperate natively (irssi's /me arrives in exactly this shape) and the TUI renders
+"* nick text". In end-to-end encrypted rooms the marker encrypts along with the text.
public static class MessageConventions
+ public const string ActionPrefix = "\u0001ACTION "
+ public const string ActionSuffix = "\u0001"
+ public static string FormatAction(string text)
+ text stringpublic static bool TryParseAction(string content, out string? actionText)
+ Cross-protocol message conventions. Action messages (/me) use the IRC CTCP ACTION
+wire format 0x01 + "ACTION " + text + 0x01 as the stored content, so IRC clients
+interoperate natively (irssi's /me arrives in exactly this shape) and the TUI renders
+"* nick text". In end-to-end encrypted rooms the marker encrypts along with the text.
Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null)
+ channelName stringtopic stringTask SendChannelDeletedAsync(string channelName)
+ channelName stringBroadcast a chat message to a channel. excludeConnectionId is the
+connection the message originated from (IRC convention: never echo a message back to
+the connection that sent it — its client already displayed it locally). Other
+connections of the same user (e.g. an IRC session alongside a TUI session) still
+receive the message.
Task SendMessageToChannelAsync(string channelName, MessageDto message)
+ Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null)
excludeConnectionId stringTask BroadcastChannelDeletedAsync(string channelName)
+ channelName stringTask<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
+ Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
originConnectionId stringreplyToMessageId Guid?Task ChannelDeleted(string channelName)
+ channelName stringTask<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
+ Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null)
inviteCode stringpublic ChannelDto(Guid Id, string Name, string? Topic, bool IsPublic, int MessageCount, DateTimeOffset CreatedAt, bool IsProtected = false, bool IsEncrypted = false)
+ public ChannelDto(Guid Id, string Name, string? Topic, bool IsPublic, int MessageCount, DateTimeOffset CreatedAt, bool IsProtected = false, bool IsEncrypted = false, bool IsSystem = false)
IsEncrypted boolIsSystem boolpublic bool IsSystem { get; init; }
+ public void Deconstruct(out Guid Id, out string Name, out string? Topic, out bool IsPublic, out int MessageCount, out DateTimeOffset CreatedAt, out bool IsProtected, out bool IsEncrypted)
+ public void Deconstruct(out Guid Id, out string Name, out string? Topic, out bool IsPublic, out int MessageCount, out DateTimeOffset CreatedAt, out bool IsProtected, out bool IsEncrypted, out bool IsSystem)
IsEncrypted boolIsSystem boolpublic record CreateInviteRequest : IEquatable<CreateInviteRequest>
+ protected CreateInviteRequest(CreateInviteRequest original)
+ original CreateInviteRequestpublic CreateInviteRequest(int? MaxUses = null, int? ExpiresInHours = null)
+ protected virtual Type EqualityContract { get; }
+ public int? ExpiresInHours { get; init; }
+ public int? MaxUses { get; init; }
+ public void Deconstruct(out int? MaxUses, out int? ExpiresInHours)
+ public virtual bool Equals(CreateInviteRequest? other)
+ other CreateInviteRequestpublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(CreateInviteRequest? left, CreateInviteRequest? right)
+ left CreateInviteRequestright CreateInviteRequestpublic static bool operator !=(CreateInviteRequest? left, CreateInviteRequest? right)
+ left CreateInviteRequestright CreateInviteRequestPassword re-confirmation for destructive self-service account actions.
+public record DeleteAccountRequest : IEquatable<DeleteAccountRequest>
+ protected DeleteAccountRequest(DeleteAccountRequest original)
+ original DeleteAccountRequestPassword re-confirmation for destructive self-service account actions.
+public DeleteAccountRequest(string Password)
+ Password stringprotected virtual Type EqualityContract { get; }
+ public string Password { get; init; }
+ public void Deconstruct(out string Password)
+ Password stringpublic virtual bool Equals(DeleteAccountRequest? other)
+ other DeleteAccountRequestpublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(DeleteAccountRequest? left, DeleteAccountRequest? right)
+ left DeleteAccountRequestright DeleteAccountRequestpublic static bool operator !=(DeleteAccountRequest? left, DeleteAccountRequest? right)
+ left DeleteAccountRequestright DeleteAccountRequestpublic record ExportedAttachmentDto : IEquatable<ExportedAttachmentDto>
+ protected ExportedAttachmentDto(ExportedAttachmentDto original)
+ original ExportedAttachmentDtopublic ExportedAttachmentDto(string FileName, string Url, long FileSize, string Kind, string ChannelName, DateTimeOffset SentAt)
+ FileName stringUrl stringFileSize longKind stringChannelName stringSentAt DateTimeOffsetpublic string ChannelName { get; init; }
+ protected virtual Type EqualityContract { get; }
+ public string FileName { get; init; }
+ public long FileSize { get; init; }
+ public string Kind { get; init; }
+ public DateTimeOffset SentAt { get; init; }
+ public string Url { get; init; }
+ public void Deconstruct(out string FileName, out string Url, out long FileSize, out string Kind, out string ChannelName, out DateTimeOffset SentAt)
+ FileName stringUrl stringFileSize longKind stringChannelName stringSentAt DateTimeOffsetpublic virtual bool Equals(ExportedAttachmentDto? other)
+ other ExportedAttachmentDtopublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(ExportedAttachmentDto? left, ExportedAttachmentDto? right)
+ left ExportedAttachmentDtoright ExportedAttachmentDtopublic static bool operator !=(ExportedAttachmentDto? left, ExportedAttachmentDto? right)
+ left ExportedAttachmentDtoright ExportedAttachmentDtopublic record ExportedMessageDto : IEquatable<ExportedMessageDto>
+ protected ExportedMessageDto(ExportedMessageDto original)
+ original ExportedMessageDtopublic ExportedMessageDto(Guid Id, string ChannelName, DateTimeOffset SentAt, string Content, Guid? ReplyToMessageId)
+ Id GuidChannelName stringSentAt DateTimeOffsetContent stringReplyToMessageId Guid?public string ChannelName { get; init; }
+ public string Content { get; init; }
+ protected virtual Type EqualityContract { get; }
+ public Guid Id { get; init; }
+ public Guid? ReplyToMessageId { get; init; }
+ public DateTimeOffset SentAt { get; init; }
+ public void Deconstruct(out Guid Id, out string ChannelName, out DateTimeOffset SentAt, out string Content, out Guid? ReplyToMessageId)
+ Id GuidChannelName stringSentAt DateTimeOffsetContent stringReplyToMessageId Guid?public virtual bool Equals(ExportedMessageDto? other)
+ other ExportedMessageDtopublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(ExportedMessageDto? left, ExportedMessageDto? right)
+ left ExportedMessageDtoright ExportedMessageDtopublic static bool operator !=(ExportedMessageDto? left, ExportedMessageDto? right)
+ left ExportedMessageDtoright ExportedMessageDtopublic record InviteDto : IEquatable<InviteDto>
+ protected InviteDto(InviteDto original)
+ original InviteDtopublic InviteDto(string Code, string CreatedByUsername, DateTimeOffset CreatedAt, DateTimeOffset? ExpiresAt, int MaxUses, int UseCount)
+ Code stringCreatedByUsername stringCreatedAt DateTimeOffsetExpiresAt DateTimeOffset?MaxUses intUseCount intpublic string Code { get; init; }
+ public DateTimeOffset CreatedAt { get; init; }
+ public string CreatedByUsername { get; init; }
+ protected virtual Type EqualityContract { get; }
+ public DateTimeOffset? ExpiresAt { get; init; }
+ public int MaxUses { get; init; }
+ public int UseCount { get; init; }
+ public void Deconstruct(out string Code, out string CreatedByUsername, out DateTimeOffset CreatedAt, out DateTimeOffset? ExpiresAt, out int MaxUses, out int UseCount)
+ Code stringCreatedByUsername stringCreatedAt DateTimeOffsetExpiresAt DateTimeOffset?MaxUses intUseCount intpublic virtual bool Equals(InviteDto? other)
+ other InviteDtopublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(InviteDto? left, InviteDto? right)
+ public static bool operator !=(InviteDto? left, InviteDto? right)
+ public MessageDto(Guid Id, string Content, string SenderUsername, string? SenderNicknameColor, string ChannelName, DateTimeOffset SentAt, List<AttachmentDto>? Attachments = null, List<EmbedDto>? Embeds = null, string? SenderDisplayName = null)
+ public MessageDto(Guid Id, string Content, string SenderUsername, string? SenderNicknameColor, string ChannelName, DateTimeOffset SentAt, List<AttachmentDto>? Attachments = null, List<EmbedDto>? Embeds = null, string? SenderDisplayName = null, ReplyRefDto? ReplyTo = null)
SenderDisplayName stringReplyTo ReplyRefDtopublic ReplyRefDto? ReplyTo { get; init; }
+ public void Deconstruct(out Guid Id, out string Content, out string SenderUsername, out string? SenderNicknameColor, out string ChannelName, out DateTimeOffset SentAt, out List<AttachmentDto>? Attachments, out List<EmbedDto>? Embeds, out string? SenderDisplayName)
+ public void Deconstruct(out Guid Id, out string Content, out string SenderUsername, out string? SenderNicknameColor, out string ChannelName, out DateTimeOffset SentAt, out List<AttachmentDto>? Attachments, out List<EmbedDto>? Embeds, out string? SenderDisplayName, out ReplyRefDto? ReplyTo)
SenderDisplayName stringReplyTo ReplyRefDtopublic RegisterRequest(string Username, string Password, string? DisplayName = null)
+ public RegisterRequest(string Username, string Password, string? DisplayName = null, string? InviteCode = null)
InviteCode stringpublic string? InviteCode { get; init; }
+ public void Deconstruct(out string Username, out string Password, out string? DisplayName)
+ public void Deconstruct(out string Username, out string Password, out string? DisplayName, out string? InviteCode)
InviteCode stringReference to the message a reply targets. Content is treated exactly like +message content on the wire: transport-encrypted, and for end-to-end encrypted rooms it is +room ciphertext the client must decrypt (the server truncates only plaintext snippets). +Null on a MessageDto when the original message no longer exists.
+public record ReplyRefDto : IEquatable<ReplyRefDto>
+ protected ReplyRefDto(ReplyRefDto original)
+ original ReplyRefDtoReference to the message a reply targets. Content is treated exactly like +message content on the wire: transport-encrypted, and for end-to-end encrypted rooms it is +room ciphertext the client must decrypt (the server truncates only plaintext snippets). +Null on a MessageDto when the original message no longer exists.
+public ReplyRefDto(Guid MessageId, string SenderUsername, string Content)
+ public string Content { get; init; }
+ protected virtual Type EqualityContract { get; }
+ public Guid MessageId { get; init; }
+ public string SenderUsername { get; init; }
+ public void Deconstruct(out Guid MessageId, out string SenderUsername, out string Content)
+ public virtual bool Equals(ReplyRefDto? other)
+ other ReplyRefDtopublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(ReplyRefDto? left, ReplyRefDto? right)
+ left ReplyRefDtoright ReplyRefDtopublic static bool operator !=(ReplyRefDto? left, ReplyRefDto? right)
+ left ReplyRefDtoright ReplyRefDtopublic ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels)
+ public ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels, string RegistrationMode = "open")
RegistrationMode stringpublic string RegistrationMode { get; init; }
+ public void Deconstruct(out string Name, out string? Description, out int OnlineUsers, out int TotalChannels)
+ public void Deconstruct(out string Name, out string? Description, out int OnlineUsers, out int TotalChannels, out string RegistrationMode)
RegistrationMode stringEverything the server holds about a user, as stored. For end-to-end encrypted rooms the +message content in here is room ciphertext — the server cannot include plaintext it never had.
+public record UserDataExportDto : IEquatable<UserDataExportDto>
+ protected UserDataExportDto(UserDataExportDto original)
+ original UserDataExportDtoEverything the server holds about a user, as stored. For end-to-end encrypted rooms the +message content in here is room ciphertext — the server cannot include plaintext it never had.
+public UserDataExportDto(DateTimeOffset ExportedAt, string ServerName, UserProfileDto Profile, List<ExportedMessageDto> Messages, List<ExportedAttachmentDto> Attachments)
+ ExportedAt DateTimeOffsetServerName stringProfile UserProfileDtoMessages List<ExportedMessageDto>Attachments List<ExportedAttachmentDto>public List<ExportedAttachmentDto> Attachments { get; init; }
+ protected virtual Type EqualityContract { get; }
+ public DateTimeOffset ExportedAt { get; init; }
+ public List<ExportedMessageDto> Messages { get; init; }
+ public UserProfileDto Profile { get; init; }
+ public string ServerName { get; init; }
+ public void Deconstruct(out DateTimeOffset ExportedAt, out string ServerName, out UserProfileDto Profile, out List<ExportedMessageDto> Messages, out List<ExportedAttachmentDto> Attachments)
+ ExportedAt DateTimeOffsetServerName stringProfile UserProfileDtoMessages List<ExportedMessageDto>Attachments List<ExportedAttachmentDto>public virtual bool Equals(UserDataExportDto? other)
+ other UserDataExportDtopublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(UserDataExportDto? left, UserDataExportDto? right)
+ left UserDataExportDtoright UserDataExportDtopublic static bool operator !=(UserDataExportDto? left, UserDataExportDto? right)
+ left UserDataExportDtoright UserDataExportDtoPassword re-confirmation for destructive self-service account actions.
+Passphrase change for an encrypted channel: the client proves knowledge of the old passphrase (old auth key), then supplies the re-wrapped room key under the new one.
+Reference to the message a reply targets. Content is treated exactly like +message content on the wire: transport-encrypted, and for end-to-end encrypted rooms it is +room ciphertext the client must decrypt (the server truncates only plaintext snippets). +Null on a MessageDto when the original message no longer exists.
Everything the server holds about a user, as stored. For end-to-end encrypted rooms the +message content in here is room ciphertext — the server cannot include plaintext it never had.
+public bool IsSystem { get; set; }
+ A registration invite code. When the server runs with Server:Registration = "invite",
+new accounts (REST and IRC alike) require a valid, unexpired, not-fully-used code.
public class InviteCode
+ public InviteCode()
+ public required string Code { get; set; }
+ public DateTimeOffset CreatedAt { get; set; }
+ public Guid CreatedByUserId { get; set; }
+ public string CreatedByUsername { get; set; }
+ Null means the code never expires.
+public DateTimeOffset? ExpiresAt { get; set; }
+ public Guid Id { get; set; }
+ public int MaxUses { get; set; }
+ public int UseCount { get; set; }
+ Message this one replies to, if any. The target may since have been deleted.
+public Guid? ReplyToMessageId { get; set; }
+ A registration invite code. When the server runs with Server:Registration = "invite",
+new accounts (REST and IRC alike) require a valid, unexpired, not-fully-used code.
Renders short text as a 5-row block-character banner (the /banner command).
+Entirely local — a hand-rolled figlet-style font, no dependencies, no network.
+Output is plain message content, so it travels (and encrypts) like any other text.
public static class AsciiBannerService
+ public const int MaxInputLength = 20
+ Renders text as banner lines joined with '\n'.
+Returns null when the input is empty or contains no renderable characters.
+Characters outside the font (letters, digits, and basic punctuation) are skipped.
public static string? Render(string text)
+ text stringRenders short text as a 5-row block-character banner (the /banner command).
+Entirely local — a hand-rolled figlet-style font, no dependencies, no network.
+Output is plain message content, so it travels (and encrypts) like any other text.
public Task SendChannelDeletedAsync(string channelName)
+ channelName stringBroadcast a chat message to a channel. excludeConnectionId is the
+connection the message originated from (IRC convention: never echo a message back to
+the connection that sent it — its client already displayed it locally). Other
+connections of the same user (e.g. an IRC session alongside a TUI session) still
+receive the message.
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
+ public Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null)
excludeConnectionId stringConvert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients. -Also passes through content that already uses ANSI codes unchanged.
-public static string ColorTagsToAnsi(string text)
- text stringFormat a MessageDto as one or more IRC PRIVMSG lines.
+Format a MessageDto as one or more IRC PRIVMSG lines.
+Attachments are rendered as single link lines \u2014 the widely-supported IRC convention
+(clients auto-preview or open plain http(s) URLs) \u2014 never as terminal color art.
+publicBaseUrl makes the links absolute so any IRC client can open them.
public static List<string> FormatMessage(MessageDto message)
+ public static List<string> FormatMessage(MessageDto message, string? publicBaseUrl = null)
message MessageDtopublicBaseUrl stringJoins a relative attachment path onto the configured public base URL. +Already-absolute URLs and unset base URLs pass through unchanged.
+public static string ToAbsoluteUrl(string url, string? publicBaseUrl)
+ Public HTTP(S) base of this EchoHub server (e.g. "https://chat.example.com"), +used to turn relative attachment URLs into absolute links IRC clients can open. +When unset, attachment lines fall back to the relative path.
+public string? PublicBaseUrl { get; set; }
+ Live server-log room settings, bound from the "ServerLogs" config section (env override:
+ServerLogs__Enabled etc.). When enabled, a read-only system channel is auto-created
+and log events are streamed to it live — log lines are never stored as messages in the
+database; the rolling Serilog log files remain the only persistence.
public sealed class ServerLogsOptions
+ public ServerLogsOptions()
+ How many recent log entries are replayed from the log file when someone opens the room.
+public int BacklogLines { get; set; }
+ Master switch for the live log room.
+public bool Enabled { get; set; }
+ Directory holding the rolling log files. Must match the Serilog file sink's path.
+public string LogDirectory { get; set; }
+ Filename glob for the rolling log files inside LogDirectory.
+public string LogFilePattern { get; set; }
+ Minimum level of log events streamed to the room (Verbose/Debug/Information/Warning/ +Error/Fatal). Only affects the room — file and console sinks keep their own levels.
+public LogEventLevel MinLevel { get; set; }
+ Minimum server role allowed to see and join the log room (Member/Mod/Admin/Owner).
+public ServerRole MinRole { get; set; }
+ Channel names are stored lowercased; compare against this form.
+public string NormalizedRoomName { get; }
+ Name of the auto-created system channel. Must satisfy the normal channel-name rules; +the name is reserved — users cannot create a channel with it.
+public string RoomName { get; set; }
+ Anti-spam thresholds, bound from the "Spam" config section. Defaults are lenient enough +that a fast typist never trips them; Mods and above are always exempt.
+public sealed class SpamOptions
+ public SpamOptions()
+ Auto-mute duration once a user accumulates ViolationThreshold rejected
+sends within ViolationWindowMinutes. 0 disables auto-mute (rejections
+still apply). The mute uses the normal timed-mute machinery, so moderators see it
+and MuteExpirationService lifts it.
public int AutoMuteMinutes { get; set; }
+ public int ChannelCreateWindowMinutes { get; set; }
+ Master switch for all spam protection.
+public bool Enabled { get; set; }
+ public int JoinWindowSeconds { get; set; }
+ Max channel creations per ChannelCreateWindowMinutes window.
+public int MaxChannelCreatesPerWindow { get; set; }
+ How many identical messages in a row are tolerated; the next one is rejected. +(End-to-end encrypted rooms are naturally exempt — identical plaintext produces +different ciphertext per message, so repeats never look identical to the server.)
+public int MaxDuplicateMessages { get; set; }
+ Max first-time channel joins per JoinWindowSeconds window. Joins of +channels the user already belongs to (reconnect/auto-join) never count, but a brand-new +user's first connect joins every public channel at once — keep this above your public +channel count.
+public int MaxJoinsPerWindow { get; set; }
+ Max messages a user may send per WindowSeconds window.
+public int MaxMessagesPerWindow { get; set; }
+ public int ViolationThreshold { get; set; }
+ public int ViolationWindowMinutes { get; set; }
+ public int WindowSeconds { get; set; }
+ Live server-log room settings, bound from the "ServerLogs" config section (env override:
+ServerLogs__Enabled etc.). When enabled, a read-only system channel is auto-created
+and log events are streamed to it live — log lines are never stored as messages in the
+database; the rolling Serilog log files remain the only persistence.
Anti-spam thresholds, bound from the "Spam" config section. Defaults are lenient enough +that a fast typist never trips them; Mods and above are always exempt.
+Admin-configurable upload limits, bound from the Uploads configuration section.
diff --git a/api/server/EchoHub.Server.Controllers.FilesController.html b/api/server/EchoHub.Server.Controllers.FilesController.html
index 9632f2f..7981eef 100644
--- a/api/server/EchoHub.Server.Controllers.FilesController.html
+++ b/api/server/EchoHub.Server.Controllers.FilesController.html
@@ -199,11 +199,16 @@ public class FilesController : ControllerBase
Serves an uploaded file. Anonymous by design: the unguessable GUID in the URL is the +access token (Discord-CDN-style capability URL), so attachment links can be opened +directly in a browser and shared to IRC clients. E2E-encrypted room blobs are +ciphertext at rest, so anonymous access reveals nothing for those channels.
+[HttpGet("{fileId}")]
+[AllowAnonymous]
public IActionResult GetFile(string fileId)
Invite-code management for invite-gated registration (Admin+ only). +Codes live in this server's own database — there is no central service.
+[ApiController]
+[Route("api/invites")]
+[Authorize]
+[EnableRateLimiting("general")]
+public class InvitesController : ControllerBase
+ public InvitesController(EchoHubDbContext db, ILogger<InvitesController> logger)
+ db EchoHubDbContextlogger ILogger<InvitesController>[HttpPost]
+public Task<IActionResult> Create(CreateInviteRequest request)
+ request CreateInviteRequest[HttpGet]
+public Task<IActionResult> List()
+ [HttpDelete("{code}")]
+public Task<IActionResult> Revoke(string code)
+ code stringpublic UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits)
+ public UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits, EchoHubDbContext db, IMessageEncryptionService encryption, FileStorageService fileStorage, PresenceTracker presenceTracker, IEnumerable<IChatBroadcaster> broadcasters, IConfiguration config, ILogger<UsersController> logger)
uploadLimits UploadLimitsdb EchoHubDbContextencryption IMessageEncryptionServicefileStorage FileStorageServicepresenceTracker PresenceTrackerbroadcasters IEnumerable<IChatBroadcaster>config IConfigurationlogger ILogger<UsersController>Username tombstoned messages are re-attributed to after account deletion. +Reserved — UserService refuses to register it.
+public const string DeletedUserName = "deleted-user"
+ Self-service account deletion (password re-confirmed). Removes the account, its refresh +tokens and memberships (FK cascade), and every attachment blob the user uploaded. +Their messages are kept but tombstoned to DeletedUserName — deleting them +outright would silently gut other people's conversations.
+[HttpDelete("me")]
+public Task<IActionResult> DeleteMyAccount(DeleteAccountRequest request)
+ request DeleteAccountRequestEverything the server stores about the caller, as stored: profile, their messages +(end-to-end encrypted room content stays ciphertext — the server never had plaintext), +and metadata of their uploaded attachments. "You own the data" made demonstrable.
+[HttpGet("me/export")]
+public Task<IActionResult> ExportMyData()
+ Invite-code management for invite-gated registration (Admin+ only). +Codes live in this server's own database — there is no central service.
+public DbSet<InviteCode> InviteCodes { get; }
+ [DbContext(typeof(EchoHubDbContext))]
+[Migration("20260717182450_AddChannelIsSystem")]
+public class AddChannelIsSystem : Migration
+ public AddChannelIsSystem()
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ modelBuilder ModelBuilderprotected override void Down(MigrationBuilder migrationBuilder)
+ migrationBuilder MigrationBuilderprotected override void Up(MigrationBuilder migrationBuilder)
+ migrationBuilder MigrationBuilder[DbContext(typeof(EchoHubDbContext))]
+[Migration("20260717165218_AddInvitesAndReplies")]
+public class AddInvitesAndReplies : Migration
+ public AddInvitesAndReplies()
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ modelBuilder ModelBuilderprotected override void Down(MigrationBuilder migrationBuilder)
+ migrationBuilder MigrationBuilderprotected override void Up(MigrationBuilder migrationBuilder)
+ migrationBuilder MigrationBuilderpublic Task SendMessage(string channelName, string content)
+ public Task SendMessage(string channelName, string content, Guid? replyToMessageId = null)
content stringreplyToMessageId Guid?public ChannelService(IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, ILogger<ChannelService> logger)
+ public ChannelService(IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, SpamGuard spamGuard, ServerLogsService serverLogs, ILogger<ChannelService> logger)
spamGuard SpamGuardserverLogs ServerLogsServicelogger ILogger<ChannelService>public Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null)
+ channelName stringtopic stringpublic ChatService(IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, IEnumerable<IChatBroadcaster> broadcasters, LinkEmbedService embedService, IMessageEncryptionService encryption, IChannelService channelService, FileStorageService fileStorage, ILogger<ChatService> logger)
+ public ChatService(IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, IEnumerable<IChatBroadcaster> broadcasters, LinkEmbedService embedService, IMessageEncryptionService encryption, IChannelService channelService, FileStorageService fileStorage, SpamGuard spamGuard, ServerLogsService serverLogs, ILogger<ChatService> logger)
fileStorage FileStorageServicespamGuard SpamGuardserverLogs ServerLogsServicelogger ILogger<ChatService>public Task BroadcastChannelDeletedAsync(string channelName)
+ channelName stringpublic Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
+ public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
content stringoriginConnectionId stringreplyToMessageId Guid?A backlog entry read from the log file: one timestamped log line plus any continuation +lines (exception stack traces) that followed it.
+public record LogBacklogEntry : IEquatable<LogBacklogEntry>
+ protected LogBacklogEntry(LogBacklogEntry original)
+ original LogBacklogEntryA backlog entry read from the log file: one timestamped log line plus any continuation +lines (exception stack traces) that followed it.
+public LogBacklogEntry(DateTimeOffset Timestamp, string Content)
+ Timestamp DateTimeOffsetContent stringpublic string Content { get; init; }
+ protected virtual Type EqualityContract { get; }
+ public DateTimeOffset Timestamp { get; init; }
+ public void Deconstruct(out DateTimeOffset Timestamp, out string Content)
+ Timestamp DateTimeOffsetContent stringpublic virtual bool Equals(LogBacklogEntry? other)
+ other LogBacklogEntrypublic override bool Equals(object? obj)
+ obj objectpublic override int GetHashCode()
+ protected virtual bool PrintMembers(StringBuilder builder)
+ builder StringBuilderpublic override string ToString()
+ public static bool operator ==(LogBacklogEntry? left, LogBacklogEntry? right)
+ left LogBacklogEntryright LogBacklogEntrypublic static bool operator !=(LogBacklogEntry? left, LogBacklogEntry? right)
+ left LogBacklogEntryright LogBacklogEntryShared logic for the live log room: room identity, the role gate, and reading the backlog +tail from the current rolling log file. The file stays the only persistence — log lines +are never stored as messages.
+public sealed class ServerLogsService
+ public ServerLogsService(ServerLogsOptions options)
+ options ServerLogsOptionspublic const string RoomTopic = "Live server logs — read-only"
+ Username shown as the sender of streamed log messages.
+public const string SenderName = "server"
+ public ServerLogsOptions Options { get; }
+ Whether a user with this role may see and join the log room.
+public bool CanView(ServerRole role)
+ role ServerRoleGroups raw file lines into entries by their timestamp prefix. Public for tests.
+public static IReadOnlyList<LogBacklogEntry> GroupIntoEntries(IReadOnlyList<string> lines, bool skipLeadingContinuations, int maxEntries)
+ Whether the given channel is the (enabled) live log room.
+public bool IsLogsChannel(string channelName)
+ channelName stringReads the last BacklogLines entries from the newest +log file. A line starting with a timestamp begins a new entry; continuation lines +(stack traces) stay attached to the entry above them. Best-effort: any I/O problem +yields an empty backlog rather than failing the join.
+public IReadOnlyList<LogBacklogEntry> ReadBacklog()
+ Serilog sink feeding the live log room. Events are queued in a bounded drop-oldest buffer +and consumed by ServerLogsStreamService; nothing is written to the database. +Events emitted by the streaming pipeline itself (the stream service and SignalR transport +internals) are dropped here so a broadcast that logs — e.g. a transport warning on a dead +connection — can never enter a log → broadcast → log feedback loop.
+public sealed class ServerLogsSink : ILogEventSink
+ public ServerLogsSink(ServerLogsOptions options)
+ options ServerLogsOptionspublic ChannelReader<LogEvent> Reader { get; }
+ public void Emit(LogEvent logEvent)
+ logEvent LogEventStreams queued log events to the live log room as ephemeral messages: SignalR only (the +IRC gateway never sees them) and no database rows. Ensures the room exists before +streaming, recreating it on the fly if it was deleted.
+This class must never log from its streaming path — its namespace is excluded by +ServerLogsSink as a second line of defense, but the primary rule is simply +not to log per event, otherwise every streamed line would spawn another.
+public sealed class ServerLogsStreamService : BackgroundService, IDisposable
+ public ServerLogsStreamService(ServerLogsSink sink, ServerLogsOptions options, IChannelService channelService, IMessageEncryptionService encryption, IServiceProvider serviceProvider)
+ sink ServerLogsSinkoptions ServerLogsOptionschannelService IChannelServiceencryption IMessageEncryptionServiceserviceProvider IServiceProviderprotected override Task ExecuteAsync(CancellationToken stoppingToken)
+ stoppingToken CancellationTokenFormats an event like the file sink's template, minus the timestamp (clients render their own). Public for tests.
+public static string Format(LogEvent logEvent)
+ logEvent LogEventA backlog entry read from the log file: one timestamped log line plus any continuation +lines (exception stack traces) that followed it.
+Shared logic for the live log room: room identity, the role gate, and reading the backlog +tail from the current rolling log file. The file stays the only persistence — log lines +are never stored as messages.
+Serilog sink feeding the live log room. Events are queued in a bounded drop-oldest buffer +and consumed by ServerLogsStreamService; nothing is written to the database. +Events emitted by the streaming pipeline itself (the stream service and SignalR transport +internals) are dropped here so a broadcast that logs — e.g. a transport warning on a dead +connection — can never enter a log → broadcast → log feedback loop.
+Streams queued log events to the live log room as ephemeral messages: SignalR only (the +IRC gateway never sees them) and no database rows. Ensures the room exists before +streaming, recreating it on the fly if it was deleted.
+This class must never log from its streaming path — its namespace is excluded by +ServerLogsSink as a second line of defense, but the primary rule is simply +not to log per event, otherwise every streamed line would spawn another.
+public Task SendChannelDeletedAsync(string channelName)
+ channelName stringBroadcast a chat message to a channel. excludeConnectionId is the
+connection the message originated from (IRC convention: never echo a message back to
+the connection that sent it — its client already displayed it locally). Other
+connections of the same user (e.g. an IRC session alongside a TUI session) still
+receive the message.
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
+ public Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null)
excludeConnectionId stringIn-memory, per-user spam protection consulted from ChatService (messages, +joins) and ChannelService (channel creation), so every ingress protocol — +SignalR and IRC alike — shares the same limits. State is per-process and never persisted; +auto-mutes land in the normal mute store. Operates only on content the server already +stores (for end-to-end encrypted rooms that is ciphertext) — nothing is decrypted.
+public sealed class SpamGuard
+ public SpamGuard(SpamOptions options)
+ options SpamOptionspublic bool Enabled { get; }
+ Checks a channel creation. Rejections count as violations but never auto-mute.
+public SpamVerdict CheckChannelCreate(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
+ userId Guidrole ServerRolenowOverride DateTimeOffset?Checks a channel join. Rejections count as violations but never auto-mute.
+public SpamVerdict CheckJoin(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
+ userId Guidrole ServerRolenowOverride DateTimeOffset?Checks a message send. Rejections count as violations; enough violations inside the +violation window escalate to AutoMute (evaluated only on +rejected messages, so a clean message never mutes anyone).
+public SpamVerdict CheckMessage(Guid userId, ServerRole role, string content, DateTimeOffset? nowOverride = null)
+ userId Guidrole ServerRolecontent stringnowOverride DateTimeOffset?public readonly struct SpamVerdict : IEquatable<SpamVerdict>
+ public SpamVerdict(SpamVerdictKind Kind, string? Reason = null, TimeSpan MuteDuration = default)
+ Kind SpamVerdictKindReason stringMuteDuration TimeSpanpublic static readonly SpamVerdict Allowed
+ public SpamVerdictKind Kind { get; init; }
+ public TimeSpan MuteDuration { get; init; }
+ public string? Reason { get; init; }
+ public void Deconstruct(out SpamVerdictKind Kind, out string? Reason, out TimeSpan MuteDuration)
+ Kind SpamVerdictKindReason stringMuteDuration TimeSpanpublic bool Equals(SpamVerdict other)
+ other SpamVerdictpublic override bool Equals(object obj)
+ obj objectpublic override int GetHashCode()
+ public override string ToString()
+ public static bool operator ==(SpamVerdict left, SpamVerdict right)
+ left SpamVerdictright SpamVerdictpublic static bool operator !=(SpamVerdict left, SpamVerdict right)
+ left SpamVerdictright SpamVerdictpublic enum SpamVerdictKind
+ Allowed = 0AutoMute = 2The user crossed the violation threshold — the caller should apply a timed mute.
+Rejected = 1public UserService(IServiceScopeFactory scopeFactory)
+ public UserService(IServiceScopeFactory scopeFactory, IConfiguration configuration)
scopeFactory IServiceScopeFactoryconfiguration IConfigurationRegistration mode from config: "open" (default), "invite", or "closed".
+public string RegistrationMode { get; }
+ public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
+ public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null)
displayName stringinviteCode stringIn-memory, per-user spam protection consulted from ChatService (messages, +joins) and ChannelService (channel creation), so every ingress protocol — +SignalR and IRC alike — shares the same limits. State is per-process and never persisted; +auto-mutes land in the normal mute store. Operates only on content the server already +stores (for end-to-end encrypted rooms that is ciphertext) — nothing is decrypted.
+