Compare commits

...

92 commits

Author SHA1 Message Date
flash 42a0160cde Changed pretty much every context mutation into an event.
Don't run this in prod yet lol.
2024-05-29 20:51:41 +00:00
flash 6bda1ee09d Only a single clear mode is ever used, removed the rest. 2024-05-28 21:51:54 +00:00
flash 86effa0452 Split status elements out of UserInfo and made user update event based. 2024-05-24 19:17:12 +00:00
flash 2eae48325a Issue user disconnected and kick/ban as events and restructure table. 2024-05-24 16:01:11 +00:00
flash 09d5bfef82 Fixed excessive sending of user update packets.
the funny inverted if condition
2024-05-24 13:19:04 +00:00
flash d426df91f0 Made the channel event log code similar to the normal event handling code. 2024-05-24 13:11:21 +00:00
flash 5daad52aba Events system overhaul. 2024-05-24 03:44:20 +00:00
flash 454a460441 Removed event flags attribute. 2024-05-24 00:23:31 +00:00
flash 651c3f127d Use interface instead of abstract classes as base for Sock Chat S2C packets. 2024-05-23 23:13:57 +00:00
flash 4ace355374 Removed AddEvent aliases. 2024-05-23 22:31:43 +00:00
flash 968df2b161 Split connection classes. 2024-05-21 20:08:23 +00:00
flash 12e7bd2768 Turns out neither of those two repos were public! 2024-05-21 14:52:15 +00:00
flash cfbe98d34a Updated README.md. 2024-05-20 23:45:29 +00:00
flash e0f83ca259 Split various components into sublibraries to avoid things depending on things they should not depend on. 2024-05-20 23:40:34 +00:00
flash 980ec5b855 Apply S2C and C2S naming scheme for easy packet direction identification. 2024-05-20 23:00:47 +00:00
flash a0e6fbbeea Packet packing micro optimisation. 2024-05-20 16:24:14 +00:00
flash 610f9ab142 Connection handling rewrite. 2024-05-20 16:16:32 +00:00
flash fa8c416b77 Use server start timestamp for welcome MOTD message and MOTD file last write for the other one. 2024-05-20 02:27:46 +00:00
flash 1d781bd72c Added base class for packets with timestamp. 2024-05-20 02:16:38 +00:00
flash 042b6ddbd6 Removed IServerPacket interface. 2024-05-20 01:35:39 +00:00
flash c490dcf128 Extracted all log packets into their own ones. 2024-05-20 01:35:33 +00:00
flash 549c80740d Rewrote user and channel collections. 2024-05-19 21:02:17 +00:00
flash 1a8c44a4ba Cleaned up the names of some of the base classes. 2024-05-19 02:17:51 +00:00
flash bd23d3aa15 Some cleanups (snapshot, don't run this). 2024-05-19 01:53:32 +00:00
flash 68a523f76a Use HasFlag instead of custom Can method. 2024-05-17 23:50:22 +00:00
flash 322500739e Moved some things out of the MessagePopulatePacket class. 2024-05-14 22:56:56 +00:00
flash a6a7e56bd1 Drew the rest of the fucking owl. 2024-05-14 22:17:25 +00:00
flash 7bcf5acb7e Created more discrete error/response packets. 2024-05-14 19:11:09 +00:00
flash 38f17c325a Apparently markdown doesn't have underlining. 2024-05-14 17:54:59 +00:00
flash 907711e753 Split some packets out of LegacyCommandResponse. 2024-05-13 20:55:54 +00:00
flash 8cc00fe1a8 Updated protocol information document. 2024-05-10 23:50:40 +00:00
flash 3c58e5201a Updated LICENSE year. 2024-05-10 19:29:03 +00:00
flash 795a87fe56 Added migration to update event types of older messages. 2024-05-10 19:23:19 +00:00
flash a6569815af Enabled explicit nullable. 2024-05-10 19:18:55 +00:00
flash b95cd06cb1 Reduce usage of working objects in packet objects as much as possible. 2024-05-10 18:29:48 +00:00
flash 356409eb16 Simplified packet building. 2024-05-10 17:28:52 +00:00
flash 1ba94a526c Removed unused method from ChatMessageAddPacket. 2024-05-10 15:25:50 +00:00
flash 0b0de00cc4 Updated MySQL connector library. 2024-05-10 15:24:56 +00:00
flash b1fae4bdeb Simplified Pack method return type. 2024-05-10 15:24:43 +00:00
flash fc7d428f76 Split name change notification out of UserUpdatePacket. 2024-05-10 15:07:56 +00:00
flash 54af837c82 Fixed various inconsistencies with Sock Chat. 2024-05-09 21:31:19 +00:00
flash 0d0f2e68b9 client -> server 2024-04-17 20:44:23 +00:00
flash e291a17705 Updated Pong packet documentation, removed column used for version info and reverted 'Sequence ID' back to 'Message ID'. 2024-04-17 20:41:15 +00:00
flash cd32995367 Removed mention of versioning. 2024-04-17 20:31:25 +00:00
flash 5985f63744 Allow super users to kick anyone regardless of ranking. 2023-11-07 14:50:55 +00:00
flash a9ca3705ad Keep track of super user status. 2023-11-07 14:49:12 +00:00
flash 294471dcfd Fixed inverted permission for /create.
For the love of god remember to update the permissions table and recalculate before starting back up.
2023-11-07 14:33:07 +00:00
flash c46d117d15 Merge branch 'new-master' of git.flash.moe:flashii/sharp-chat into new-master 2023-11-04 23:37:13 +00:00
flash 05fcbcb0f8 Fixed issues relating to event_sender_name being nullable for some reason. 2023-11-04 23:37:02 +00:00
flash 03b3b6b0a3 Fixed issue when starting without any configuration data present. 2023-10-01 04:54:30 +02:00
flash a7a05f04bd Don't remove AFK status when opening a new connection. 2023-08-09 14:59:29 +00:00
flash dc4989a3cf Fixed connection error when issuing a permanent ban. 2023-07-23 21:45:10 +00:00
flash 903e39ab76 Removed any remaining references to silencing. 2023-07-23 21:36:22 +00:00
flash 8c19c22736 Reworking event dispatching... I think?
I did this make in february but left it uncommitted. Hopefully it's stable!
2023-07-23 21:31:13 +00:00
flash 4e0def980f Revised event storage to use less magic classes. 2023-02-23 22:46:49 +01:00
flash 82973f7a33 Improved user updating but also other things that were already local. 2023-02-22 01:28:53 +01:00
flash 8de3ba8dbb Even slightly lesser aggressive question mark 2023-02-19 23:47:53 +01:00
flash 86a46539f2 Less haphazard locking (perhaps too?) 2023-02-19 23:27:08 +01:00
flash 70df99fe9b Significantly less stupid connection resolving. 2023-02-17 23:17:24 +01:00
flash 546e8a2c83 Simplify rate limiter, disabled silencing and merged BasicUser and ChatUser. 2023-02-17 22:47:44 +01:00
flash d268a419dc Cleaned up channel/user association logic. 2023-02-17 20:02:35 +01:00
flash 1466562c54 Use virtual channel name for DMs. 2023-02-16 23:56:50 +01:00
flash a5089f14b8 Undid the IPacketTarget system. 2023-02-16 23:47:30 +01:00
flash 13ae843c8d No longer keep track of connections within the ChatUser class. 2023-02-16 23:33:48 +01:00
flash 06af94e94f Renamed 'Sessions' to 'Connections' 2023-02-16 22:25:41 +01:00
flash c8a589c1c1 Un-switch packet handlers. 2023-02-16 22:16:06 +01:00
flash ea56af0210 Turned commands into classes instead of a shitty switch. 2023-02-16 21:34:59 +01:00
flash d1f78a7e8b Actually send the message deletion packet. 2023-02-16 17:10:30 +01:00
flash dbdaaeec9e Better session ID generation code. 2023-02-10 08:06:07 +01:00
flash 8050a295c1 Updated protocol documentation to indicate that IDs should not be treated as numbers. 2023-02-10 07:28:36 +01:00
flash c291ef178d Fixed the backlog being sent in reverse order. 2023-02-10 07:18:38 +01:00
flash c21605cf3b Marginal improvements to cross thread access. 2023-02-10 07:07:59 +01:00
flash e1e3def62c Ported the config system from old master. 2023-02-09 00:53:42 +01:00
flash 56a818254e Backported SharpId class 2023-02-08 04:32:12 +01:00
flash 40c7ba4ded Removed a bunch of @ prefixes that aren't needed. 2023-02-08 04:17:07 +01:00
flash 27c28aafcd Don't use a stinky Timer for user bumps and exclude AFK. 2023-02-08 01:01:55 +01:00
flash 36f3ff6385 Removed internal ban handling and integrate with Misuzu. 2023-02-07 23:28:06 +01:00
flash 5e3eecda8c Convert Colour class to a struct. 2023-02-07 16:13:38 +01:00
flash 4104e40843 Code style updates. 2023-02-07 16:01:56 +01:00
flash c9cc5ff23a Removed protocol enums. 2023-02-07 15:34:31 +01:00
flash 513539319f Better HttpClient handling. 2023-02-06 21:14:50 +01:00
flash d2fef02e08 Added Protocol doc to new master branch. 2023-02-06 19:38:16 +00:00
flash 1051a26494 Fixed dumb shit in SharpChat. 2022-11-04 20:02:16 +00:00
flash 6f50ec66a9 Added /shutdown and /restart commands for server maintenance. 2022-08-30 18:29:11 +00:00
flash e6dffe06e6 Removed documentation files from the stable branch. 2022-08-30 16:05:07 +00:00
flash 9790f77a16 Removed Sock Chat v2 crust. 2022-08-30 16:04:38 +00:00
flash 08f9a2c5a1 Fixed /who <channel> result being incorrectly formatted. 2022-08-30 15:52:03 +00:00
flash ea83c8cca0 Don't print auth url to console... 2022-08-30 15:51:10 +00:00
flash bfd1819798 OOPS!!!!!!!!!!!!!!!!!!!!!!!!! 2022-08-30 17:44:33 +02:00
flash 2de19035ff Allow changing the Misuzu base URL. 2022-08-30 17:42:03 +02:00
flash 3f8c2781ee Cleaned things up again. 2022-08-30 17:28:46 +02:00
flash 23f0bd478f Apparently this is what's actually running on the server. 2022-08-30 15:21:00 +00:00
226 changed files with 7292 additions and 8053 deletions

4
.editorconfig Normal file
View file

@ -0,0 +1,4 @@
[*.{cs,vb}]
# IDE0046: Convert to conditional expression
dotnet_style_prefer_conditional_expression_over_return = false

9
.gitignore vendored
View file

@ -1,6 +1,15 @@
## Ignore Visual Studio temporary files, build results, and ## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons. ## files generated by popular Visual Studio add-ons.
welcome.txt
mariadb.txt
login_key.txt
http-motd.txt
_webdb.txt
msz_url.txt
sharpchat.cfg
SharpChat/version.txt
# User-specific files # User-specific files
*.suo *.suo
*.user *.user

View file

@ -1,7 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
</Project>

View file

@ -1,26 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Hamakaze.Headers {
public class HttpAcceptEncodingHeader : HttpHeader {
public const string NAME = @"Accept-Encoding";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public HttpEncoding[] Encodings { get; }
public HttpAcceptEncodingHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) { }
public HttpAcceptEncodingHeader(string[] encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse)
) {}
public HttpAcceptEncodingHeader(IEnumerable<HttpEncoding> encodings) {
Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray();
}
}
}

View file

@ -1,17 +0,0 @@
using System;
namespace Hamakaze.Headers {
public class HttpConnectionHeader : HttpHeader {
public const string NAME = @"Connection";
public override string Name => NAME;
public override object Value { get; }
public const string CLOSE = @"close";
public const string KEEP_ALIVE = @"keep-alive";
public HttpConnectionHeader(string mode) {
Value = mode ?? throw new ArgumentNullException(nameof(mode));
}
}
}

View file

@ -1,20 +0,0 @@
using System;
namespace Hamakaze.Headers {
public class HttpContentEncodingHeader : HttpHeader {
public const string NAME = @"Content-Encoding";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public string[] Encodings { get; }
public HttpContentEncodingHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) { }
public HttpContentEncodingHeader(string[] encodings) {
Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings));
}
}
}

View file

@ -1,30 +0,0 @@
using System;
using System.IO;
namespace Hamakaze.Headers {
public class HttpContentLengthHeader : HttpHeader {
public const string NAME = @"Content-Length";
public override string Name => NAME;
public override object Value => Stream?.Length ?? Length;
private Stream Stream { get; }
private long Length { get; }
public HttpContentLengthHeader(Stream stream) {
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
if(!stream.CanRead || !stream.CanSeek)
throw new ArgumentException(@"Body must readable and seekable.", nameof(stream));
}
public HttpContentLengthHeader(long length) {
Length = length;
}
public HttpContentLengthHeader(string length) {
if(!long.TryParse(length, out long ll))
throw new ArgumentException(@"Invalid length value.", nameof(length));
Length = ll;
}
}
}

View file

@ -1,20 +0,0 @@
using System;
namespace Hamakaze.Headers {
public class HttpContentTypeHeader : HttpHeader {
public const string NAME = @"Content-Type";
public override string Name => NAME;
public override object Value => MediaType.ToString();
public HttpMediaType MediaType { get; }
public HttpContentTypeHeader(string mediaType) {
MediaType = HttpMediaType.Parse(mediaType ?? throw new ArgumentNullException(nameof(mediaType)));
}
public HttpContentTypeHeader(HttpMediaType mediaType) {
MediaType = mediaType;
}
}
}

View file

@ -1,13 +0,0 @@
using System;
namespace Hamakaze.Headers {
public class HttpCustomHeader : HttpHeader {
public override string Name { get; }
public override object Value { get; }
public HttpCustomHeader(string name, object value) {
Name = NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
Value = value;
}
}
}

View file

@ -1,18 +0,0 @@
using System;
using System.Globalization;
namespace Hamakaze.Headers {
public class HttpDateHeader : HttpHeader {
public const string NAME = @"Date";
public override string Name => NAME;
public override object Value { get; }
public DateTimeOffset DateTime { get; }
public HttpDateHeader(string dateString) {
Value = dateString ?? throw new ArgumentNullException(nameof(dateString));
DateTime = DateTimeOffset.ParseExact(dateString, @"r", CultureInfo.InvariantCulture);
}
}
}

View file

@ -1,41 +0,0 @@
using System;
using System.Globalization;
namespace Hamakaze.Headers {
public abstract class HttpHeader {
public abstract string Name { get; }
public abstract object Value { get; }
public override string ToString() {
return string.Format(@"{0}: {1}", Name, Value);
}
public static string NormaliseName(string name) {
if(string.IsNullOrWhiteSpace(name))
return string.Empty;
string[] parts = name.ToLowerInvariant().Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
for(int i = 0; i < parts.Length; ++i)
parts[i] = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(parts[i]);
return string.Join('-', parts);
}
public static HttpHeader Create(string name, object value) {
return name switch {
HttpTeHeader.NAME => new HttpTeHeader(value.ToString()),
HttpDateHeader.NAME => new HttpDateHeader(value.ToString()),
HttpHostHeader.NAME => new HttpHostHeader(value.ToString()),
HttpServerHeader.NAME => new HttpServerHeader(value.ToString()),
HttpUserAgentHeader.NAME => new HttpUserAgentHeader(value.ToString()),
HttpKeepAliveHeader.NAME => new HttpKeepAliveHeader(value.ToString()),
HttpConnectionHeader.NAME => new HttpConnectionHeader(value.ToString()),
HttpContentTypeHeader.NAME => new HttpContentTypeHeader(value.ToString()),
HttpContentLengthHeader.NAME => new HttpContentLengthHeader(value.ToString()),
HttpAcceptEncodingHeader.NAME => new HttpAcceptEncodingHeader(value.ToString()),
HttpContentEncodingHeader.NAME => new HttpContentEncodingHeader(value.ToString()),
HttpTransferEncodingHeader.NAME => new HttpTransferEncodingHeader(value.ToString()),
_ => new HttpCustomHeader(name, value),
};
}
}
}

View file

@ -1,37 +0,0 @@
using System;
using System.Linq;
using System.Text;
namespace Hamakaze.Headers {
public class HttpHostHeader : HttpHeader {
public const string NAME = @"Host";
public override string Name => NAME;
public override object Value {
get {
StringBuilder sb = new();
sb.Append(Host);
if(Port != -1)
sb.AppendFormat(@":{0}", Port);
return sb.ToString();
}
}
public string Host { get; }
public int Port { get; }
public bool IsSecure { get; }
public HttpHostHeader(string host, int port) {
Host = host;
Port = port;
}
public HttpHostHeader(string hostAndPort) {
string[] parts = hostAndPort.Split(':', 2, StringSplitOptions.TrimEntries);
Host = parts.ElementAtOrDefault(0) ?? throw new ArgumentNullException(nameof(hostAndPort));
if(!ushort.TryParse(parts.ElementAtOrDefault(1), out ushort port))
throw new FormatException(@"Host is not in valid format.");
Port = port;
}
}
}

View file

@ -1,35 +0,0 @@
using System;
using System.Collections.Generic;
namespace Hamakaze.Headers {
public class HttpKeepAliveHeader : HttpHeader {
public const string NAME = @"Keep-Alive";
public override string Name => NAME;
public override object Value {
get {
List<string> parts = new();
if(MaxIdle != TimeSpan.MaxValue)
parts.Add(string.Format(@"timeout={0}", MaxIdle.TotalSeconds));
if(MaxRequests >= 0)
parts.Add(string.Format(@"max={0}", MaxRequests));
return string.Join(@", ", parts);
}
}
public TimeSpan MaxIdle { get; } = TimeSpan.MaxValue;
public int MaxRequests { get; } = -1;
public HttpKeepAliveHeader(string value) {
IEnumerable<string> kvps = (value ?? throw new ArgumentNullException(nameof(value))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach(string kvp in kvps) {
string[] parts = kvp.Split('=', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if(parts[0] == @"timeout" && int.TryParse(parts[1], out int timeout))
MaxIdle = TimeSpan.FromSeconds(timeout);
else if(parts[0] == @"max" && int.TryParse(parts[1], out int max))
MaxRequests = max;
}
}
}
}

View file

@ -1,14 +0,0 @@
using System;
namespace Hamakaze.Headers {
public class HttpServerHeader : HttpHeader {
public const string NAME = @"Server";
public override string Name => NAME;
public override object Value { get; }
public HttpServerHeader(string server) {
Value = server ?? throw new ArgumentNullException(nameof(server));
}
}
}

View file

@ -1,26 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Hamakaze.Headers {
public class HttpTeHeader : HttpHeader {
public const string NAME = @"TE";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public HttpEncoding[] Encodings { get; }
public HttpTeHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) { }
public HttpTeHeader(string[] encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Select(HttpEncoding.Parse)
) { }
public HttpTeHeader(IEnumerable<HttpEncoding> encodings) {
Encodings = (encodings ?? throw new ArgumentNullException(nameof(encodings))).ToArray();
}
}
}

View file

@ -1,20 +0,0 @@
using System;
namespace Hamakaze.Headers {
public class HttpTransferEncodingHeader : HttpHeader {
public const string NAME = @"Transfer-Encoding";
public override string Name => NAME;
public override object Value => string.Join(@", ", Encodings);
public string[] Encodings { get; }
public HttpTransferEncodingHeader(string encodings) : this(
(encodings ?? throw new ArgumentNullException(nameof(encodings))).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
) {}
public HttpTransferEncodingHeader(string[] encodings) {
Encodings = encodings ?? throw new ArgumentNullException(nameof(encodings));
}
}
}

View file

@ -1,20 +0,0 @@
using System;
namespace Hamakaze.Headers {
public class HttpUserAgentHeader : HttpHeader {
public const string NAME = @"User-Agent";
public override string Name => NAME;
public override object Value { get; }
public HttpUserAgentHeader(string userAgent) {
if(userAgent == null)
throw new ArgumentNullException(nameof(userAgent));
if(string.IsNullOrWhiteSpace(userAgent) || userAgent.Equals(HttpClient.USER_AGENT))
Value = HttpClient.USER_AGENT;
else
Value = string.Format(@"{0} {1}", userAgent, HttpClient.USER_AGENT);
}
}
}

View file

@ -1,118 +0,0 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
namespace Hamakaze {
public class HttpClient : IDisposable {
public const string PRODUCT_STRING = @"HMKZ";
public const string VERSION_MAJOR = @"1";
public const string VERSION_MINOR = @"0";
public const string USER_AGENT = PRODUCT_STRING + @"/" + VERSION_MAJOR + @"." + VERSION_MINOR;
private static HttpClient InstanceValue { get; set; }
public static HttpClient Instance {
get {
if(InstanceValue == null)
InstanceValue = new HttpClient();
return InstanceValue;
}
}
private HttpConnectionManager Connections { get; }
private HttpTaskManager Tasks { get; }
public string DefaultUserAgent { get; set; } = USER_AGENT;
public bool ReuseConnections { get; set; } = true;
public IEnumerable<HttpEncoding> AcceptedEncodings { get; set; } = new[] { HttpEncoding.GZip, HttpEncoding.Deflate, HttpEncoding.Brotli };
public HttpClient() {
Connections = new HttpConnectionManager();
Tasks = new HttpTaskManager();
}
public HttpTask CreateTask(
HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> onStateChange = null,
bool disposeRequest = true,
bool disposeResponse = true
) {
if(request == null)
throw new ArgumentNullException(nameof(request));
if(string.IsNullOrWhiteSpace(request.UserAgent))
request.UserAgent = DefaultUserAgent;
if(!request.HasHeader(HttpAcceptEncodingHeader.NAME))
request.AcceptedEncodings = AcceptedEncodings;
request.Connection = ReuseConnections ? HttpConnectionHeader.KEEP_ALIVE : HttpConnectionHeader.CLOSE;
HttpTask task = new(Connections, request, disposeRequest, disposeResponse);
if(onComplete != null)
task.OnComplete += onComplete;
if(onError != null)
task.OnError += onError;
if(onCancel != null)
task.OnCancel += onCancel;
if(onDownloadProgress != null)
task.OnDownloadProgress += onDownloadProgress;
if(onUploadProgress != null)
task.OnUploadProgress += onUploadProgress;
if(onStateChange != null)
task.OnStateChange += onStateChange;
return task;
}
public void RunTask(HttpTask task) {
Tasks.RunTask(task);
}
public void SendRequest(
HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> onStateChange = null,
bool disposeRequest = true,
bool disposeResponse = true
) {
RunTask(CreateTask(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse));
}
public static void Send(
HttpRequestMessage request,
Action<HttpTask, HttpResponseMessage> onComplete = null,
Action<HttpTask, Exception> onError = null,
Action<HttpTask> onCancel = null,
Action<HttpTask, long, long> onDownloadProgress = null,
Action<HttpTask, long, long> onUploadProgress = null,
Action<HttpTask, HttpTask.TaskState> onStateChange = null,
bool disposeRequest = true,
bool disposeResponse = true
) {
Instance.SendRequest(request, onComplete, onError, onCancel, onDownloadProgress, onUploadProgress, onStateChange, disposeRequest, disposeResponse);
}
private bool IsDisposed;
~HttpClient()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Tasks.Dispose();
Connections.Dispose();
}
}
}

View file

@ -1,81 +0,0 @@
using System;
using System.IO;
using System.Net;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using System.Threading;
namespace Hamakaze {
public class HttpConnection : IDisposable {
public IPEndPoint EndPoint { get; }
public Stream Stream { get; }
public Socket Socket { get; }
public NetworkStream NetworkStream { get; }
public SslStream SslStream { get; }
public string Host { get; }
public bool IsSecure { get; }
public bool HasTimedOut => MaxRequests == 0 || (DateTimeOffset.Now - LastOperation) > MaxIdle;
public int MaxRequests { get; set; } = -1;
public TimeSpan MaxIdle { get; set; } = TimeSpan.MaxValue;
public DateTimeOffset LastOperation { get; private set; } = DateTimeOffset.Now;
public bool InUse { get; private set; }
public HttpConnection(string host, IPEndPoint endPoint, bool secure) {
Host = host ?? throw new ArgumentNullException(nameof(host));
EndPoint = endPoint ?? throw new ArgumentNullException(nameof(endPoint));
IsSecure = secure;
if(endPoint.AddressFamily is not AddressFamily.InterNetwork and not AddressFamily.InterNetworkV6)
throw new ArgumentException(@"Address must be an IPv4 or IPv6 address.", nameof(endPoint));
Socket = new Socket(endPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp) {
NoDelay = true,
Blocking = true,
};
Socket.Connect(endPoint);
NetworkStream = new NetworkStream(Socket, true);
if(IsSecure) {
SslStream = new SslStream(NetworkStream, false, (s, ce, ch, e) => e == SslPolicyErrors.None, null);
Stream = SslStream;
SslStream.AuthenticateAsClient(Host, null, SslProtocols.Tls11 | SslProtocols.Tls12 | SslProtocols.Tls13, true);
} else
Stream = NetworkStream;
}
public void MarkUsed() {
LastOperation = DateTimeOffset.Now;
if(MaxRequests > 0)
--MaxRequests;
}
public bool Acquire() {
return !InUse && (InUse = true);
}
public void Release() {
InUse = false;
}
private bool IsDisposed;
~HttpConnection()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Stream.Dispose();
}
}
}

View file

@ -1,122 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
namespace Hamakaze {
public class HttpConnectionManager : IDisposable {
private List<HttpConnection> Connections { get; } = new();
private Mutex Lock { get; } = new();
public HttpConnectionManager() {
}
private void AcquireLock() {
if(!Lock.WaitOne(10000))
throw new HttpConnectionManagerLockException();
}
private void ReleaseLock() {
Lock.ReleaseMutex();
}
public HttpConnection CreateConnection(string host, IPEndPoint endPoint, bool secure) {
if(host == null)
throw new ArgumentNullException(nameof(host));
if(endPoint == null)
throw new ArgumentNullException(nameof(endPoint));
HttpConnection conn = null;
AcquireLock();
try {
conn = CreateConnectionInternal(host, endPoint, secure);
} finally {
ReleaseLock();
}
return conn;
}
private HttpConnection CreateConnectionInternal(string host, IPEndPoint endPoint, bool secure) {
HttpConnection conn = new(host, endPoint, secure);
Connections.Add(conn);
return conn;
}
public HttpConnection GetConnection(string host, IPEndPoint endPoint, bool secure) {
if(host == null)
throw new ArgumentNullException(nameof(host));
if(endPoint == null)
throw new ArgumentNullException(nameof(endPoint));
HttpConnection conn = null;
AcquireLock();
try {
conn = GetConnectionInternal(host, endPoint, secure);
} finally {
ReleaseLock();
}
return conn;
}
private HttpConnection GetConnectionInternal(string host, IPEndPoint endPoint, bool secure) {
CleanConnectionsInternal();
HttpConnection conn = Connections.FirstOrDefault(c => host.Equals(c.Host) && endPoint.Equals(c.EndPoint) && c.IsSecure == secure && c.Acquire());
if(conn == null) {
conn = CreateConnectionInternal(host, endPoint, secure);
conn.Acquire();
}
return conn;
}
public void EndConnection(HttpConnection conn) {
if(conn == null)
throw new ArgumentNullException(nameof(conn));
AcquireLock();
try {
EndConnectionInternal(conn);
} finally {
ReleaseLock();
}
}
private void EndConnectionInternal(HttpConnection conn) {
Connections.Remove(conn);
conn.Dispose();
}
public void CleanConnection() {
AcquireLock();
try {
CleanConnectionsInternal();
} finally {
ReleaseLock();
}
}
private void CleanConnectionsInternal() {
IEnumerable<HttpConnection> conns = Connections.Where(x => x.HasTimedOut).ToArray();
foreach(HttpConnection conn in conns) {
Connections.Remove(conn);
conn.Dispose();
}
}
private bool IsDisposed;
~HttpConnectionManager()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Lock.Dispose();
foreach(HttpConnection conn in Connections)
conn.Dispose();
Connections.Clear();
}
}
}

View file

@ -1,69 +0,0 @@
using System;
using System.Globalization;
using System.Text;
namespace Hamakaze {
public readonly struct HttpEncoding : IComparable<HttpEncoding?>, IEquatable<HttpEncoding?> {
public const string DEFLATE = @"deflate";
public const string GZIP = @"gzip";
public const string XGZIP = @"x-gzip";
public const string BROTLI = @"br";
public const string IDENTITY = @"identity";
public const string CHUNKED = @"chunked";
public const string ANY = @"*";
public static readonly HttpEncoding Any = new(ANY);
public static readonly HttpEncoding None = new(ANY, 0f);
public static readonly HttpEncoding Deflate = new(DEFLATE);
public static readonly HttpEncoding GZip = new(GZIP);
public static readonly HttpEncoding Brotli = new(BROTLI);
public static readonly HttpEncoding Identity = new(IDENTITY);
public string Name { get; }
public float Quality { get; }
public HttpEncoding(string name, float quality = 1f) {
Name = name ?? throw new ArgumentNullException(nameof(name));
Quality = quality;
}
public HttpEncoding WithQuality(float quality) {
return new HttpEncoding(Name, quality);
}
public static HttpEncoding Parse(string encoding) {
string[] parts = encoding.Split(';', StringSplitOptions.TrimEntries);
float quality = 1f;
encoding = parts[0];
for(int i = 1; i < parts.Length; ++i)
if(parts[i].StartsWith(@"q=")) {
if(!float.TryParse(parts[i], out quality))
quality = 1f;
break;
}
return new HttpEncoding(encoding, quality);
}
public override string ToString() {
StringBuilder sb = new();
sb.Append(Name);
if(Quality is >= 0f and < 1f)
sb.AppendFormat(CultureInfo.InvariantCulture, @";q={0:0.0}", Quality);
return sb.ToString();
}
public int CompareTo(HttpEncoding? other) {
if(!other.HasValue || other.Value.Quality < Quality)
return -1;
if(other.Value.Quality > Quality)
return 1;
return 0;
}
public bool Equals(HttpEncoding? other) {
return other.HasValue && Name.Equals(other.Value.Name) && Quality.Equals(other.Value.Quality);
}
}
}

View file

@ -1,40 +0,0 @@
using System;
namespace Hamakaze {
public class HttpException : Exception {
public HttpException(string message) : base(message) { }
}
public class HttpConnectionManagerException : HttpException {
public HttpConnectionManagerException(string message) : base(message) { }
}
public class HttpConnectionManagerLockException : HttpConnectionManagerException {
public HttpConnectionManagerLockException() : base(@"Failed to lock the connection manager in time.") { }
}
public class HttpTaskException : HttpException {
public HttpTaskException(string message) : base(message) { }
}
public class HttpTaskAlreadyStartedException : HttpTaskException {
public HttpTaskAlreadyStartedException() : base(@"Task has already started.") { }
}
public class HttpTaskInvalidStateException : HttpTaskException {
public HttpTaskInvalidStateException() : base(@"Task has ended up in an invalid state.") { }
}
public class HttpTaskNoAddressesException : HttpTaskException {
public HttpTaskNoAddressesException() : base(@"Could not find any addresses for this host.") { }
}
public class HttpTaskNoConnectionException : HttpTaskException {
public HttpTaskNoConnectionException() : base(@"Was unable to create a connection with this host.") { }
}
public class HttpTaskRequestFailedException : HttpTaskException {
public HttpTaskRequestFailedException() : base(@"Request failed for unknown reasons.") { }
}
public class HttpTaskManagerException : HttpException {
public HttpTaskManagerException(string message) : base(message) { }
}
public class HttpTaskManagerLockException : HttpTaskManagerException {
public HttpTaskManagerLockException() : base(@"Failed to reserve a thread.") { }
}
}

View file

@ -1,159 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Hamakaze {
public readonly struct HttpMediaType : IComparable<HttpMediaType?>, IEquatable<HttpMediaType?> {
public const string TYPE_APPLICATION = @"application";
public const string TYPE_AUDIO = @"audio";
public const string TYPE_IMAGE = @"image";
public const string TYPE_MESSAGE = @"message";
public const string TYPE_MULTIPART = @"multipart";
public const string TYPE_TEXT = @"text";
public const string TYPE_VIDEO = @"video";
public static readonly HttpMediaType OctetStream = new(TYPE_APPLICATION, @"octet-stream");
public static readonly HttpMediaType FWIF = new(TYPE_APPLICATION, @"x.fwif");
public static readonly HttpMediaType JSON = new(TYPE_APPLICATION, @"json");
public static readonly HttpMediaType HTML = new(TYPE_TEXT, @"html", args: new[] { Param.UTF8 });
public string Type { get; }
public string Subtype { get; }
public string Suffix { get; }
public IEnumerable<Param> Params { get; }
public HttpMediaType(string type, string subtype, string suffix = null, IEnumerable<Param> args = null) {
Type = type ?? throw new ArgumentNullException(nameof(type));
Subtype = subtype ?? throw new ArgumentNullException(nameof(subtype));
Suffix = suffix ?? string.Empty;
Params = args ?? Enumerable.Empty<Param>();
}
public string GetParamValue(string name) {
foreach(Param param in Params)
if(param.Name.ToLowerInvariant() == name)
return param.Value;
return null;
}
public static explicit operator HttpMediaType(string mediaTypeString) => Parse(mediaTypeString);
public static HttpMediaType Parse(string mediaTypeString) {
if(mediaTypeString == null)
throw new ArgumentNullException(nameof(mediaTypeString));
int slashIndex = mediaTypeString.IndexOf('/');
if(slashIndex == -1)
return OctetStream;
string type = mediaTypeString[..slashIndex];
string subtype = mediaTypeString[(slashIndex + 1)..];
string suffix = null;
IEnumerable<Param> args = null;
int paramIndex = subtype.IndexOf(';');
if(paramIndex != -1) {
args = subtype[(paramIndex + 1)..]
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(Param.Parse);
subtype = subtype[..paramIndex];
}
int suffixIndex = subtype.IndexOf('+');
if(suffixIndex != -1) {
suffix = subtype[(suffixIndex + 1)..];
subtype = subtype[..suffixIndex];
}
return new HttpMediaType(type, subtype, suffix, args);
}
public override string ToString() {
StringBuilder sb = new();
sb.AppendFormat(@"{0}/{1}", Type, Subtype);
if(!string.IsNullOrWhiteSpace(Suffix))
sb.AppendFormat(@"+{0}", Suffix);
if(Params.Any())
sb.AppendFormat(@";{0}", string.Join(';', Params));
return sb.ToString();
}
public int CompareTo(HttpMediaType? other) {
if(!other.HasValue)
return -1;
int type = Type.CompareTo(other.Value.Type);
if(type != 0)
return type;
int subtype = Subtype.CompareTo(other.Value.Subtype);
if(subtype != 0)
return subtype;
int suffix = Suffix.CompareTo(other.Value.Suffix);
if(suffix != 0)
return suffix;
int paramCount = Params.Count();
int args = paramCount - other.Value.Params.Count();
if(args != 0)
return args;
for(int i = 0; i < paramCount; ++i) {
args = Params.ElementAt(i).CompareTo(other.Value.Params.ElementAt(i));
if(args != 0)
return args;
}
return 0;
}
public bool Equals(HttpMediaType? other) {
if(!other.HasValue)
return false;
if(!Type.Equals(other.Value.Type) || !Subtype.Equals(other.Value.Subtype) || !Suffix.Equals(other.Value.Suffix))
return false;
int paramCount = Params.Count();
if(paramCount != other.Value.Params.Count())
return false;
for(int i = 0; i < paramCount; ++i)
if(!Params.ElementAt(i).Equals(other.Value.Params.ElementAt(i)))
return false;
return true;
}
public readonly struct Param : IComparable<Param?>, IEquatable<Param?> {
public const string CHARSET = @"charset";
public static readonly Param ASCII = new(CHARSET, @"us-ascii");
public static readonly Param UTF8 = new(CHARSET, @"utf-8");
public string Name { get; }
public string Value { get; }
public Param(string name, string value) {
Name = name ?? throw new ArgumentNullException(nameof(name));
Value = value ?? throw new ArgumentNullException(nameof(name));
}
public override string ToString() {
return string.Format(@"{0}={1}", Name, Value);
}
public static explicit operator Param(string paramStr) => Parse(paramStr);
public static Param Parse(string paramStr) {
string[] parts = (paramStr ?? throw new ArgumentNullException(nameof(paramStr))).Split('=', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return new Param(parts[0], parts[1]);
}
public int CompareTo(Param? other) {
if(!other.HasValue)
return -1;
int name = Name.CompareTo(other.Value.Name);
return name != 0
? name
: Value.CompareTo(other.Value.Value);
}
public bool Equals(Param? other) {
return other.HasValue && Name.Equals(other.Value.Name) && Value.Equals(other.Value.Value);
}
}
}
}

View file

@ -1,46 +0,0 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Hamakaze {
public abstract class HttpMessage : IDisposable {
public abstract string ProtocolVersion { get; }
public abstract IEnumerable<HttpHeader> Headers { get; }
public abstract Stream Body { get; }
public virtual bool HasBody => Body != null;
protected bool OwnsBodyStream { get; set; }
public virtual IEnumerable<HttpHeader> GetHeader(string header) {
header = HttpHeader.NormaliseName(header);
return Headers.Where(h => h.Name == header);
}
public virtual bool HasHeader(string header) {
header = HttpHeader.NormaliseName(header);
return Headers.Any(h => h.Name == header);
}
public virtual string GetHeaderLine(string header) {
return string.Join(@", ", GetHeader(header).Select(h => h.Value));
}
private bool IsDisposed;
~HttpMessage()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
protected void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
if(OwnsBodyStream && Body != null)
Body.Dispose();
}
}
}

View file

@ -1,190 +0,0 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace Hamakaze {
public class HttpRequestMessage : HttpMessage {
public const string GET = @"GET";
public const string PUT = @"PUT";
public const string HEAD = @"HEAD";
public const string POST = @"POST";
public const string DELETE = @"DELETE";
public override string ProtocolVersion => @"1.1";
public string Method { get; }
public string RequestTarget { get; }
public bool IsSecure { get; }
public string Host { get; }
public ushort Port { get; }
public bool IsDefaultPort { get; }
public override IEnumerable<HttpHeader> Headers => HeaderList;
private List<HttpHeader> HeaderList { get; } = new();
private Stream BodyStream { get; set; }
public override Stream Body {
get {
if(BodyStream == null) {
OwnsBodyStream = true;
SetBody(new MemoryStream());
}
return BodyStream;
}
}
private static readonly string[] HEADERS_READONLY = new[] {
HttpHostHeader.NAME, HttpContentLengthHeader.NAME,
};
private static readonly string[] HEADERS_SINGLE = new[] {
HttpUserAgentHeader.NAME, HttpConnectionHeader.NAME, HttpAcceptEncodingHeader.NAME,
};
public IEnumerable<HttpEncoding> AcceptedEncodings {
get => HeaderList.Where(x => x.Name == HttpAcceptEncodingHeader.NAME).Cast<HttpAcceptEncodingHeader>().FirstOrDefault()?.Encodings
?? Enumerable.Empty<HttpEncoding>();
set {
HeaderList.RemoveAll(x => x.Name == HttpAcceptEncodingHeader.NAME);
HeaderList.Add(new HttpAcceptEncodingHeader(value));
}
}
public string UserAgent {
get => HeaderList.FirstOrDefault(x => x.Name == HttpUserAgentHeader.NAME)?.Value.ToString()
?? string.Empty;
set {
HeaderList.RemoveAll(x => x.Name == HttpUserAgentHeader.NAME);
HeaderList.Add(new HttpUserAgentHeader(value));
}
}
public string Connection {
get => HeaderList.FirstOrDefault(x => x.Name == HttpConnectionHeader.NAME)?.Value.ToString()
?? string.Empty;
set {
HeaderList.RemoveAll(x => x.Name == HttpConnectionHeader.NAME);
HeaderList.Add(new HttpConnectionHeader(value));
}
}
public HttpMediaType ContentType {
get => HeaderList.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast<HttpContentTypeHeader>().FirstOrDefault()?.MediaType
?? HttpMediaType.OctetStream;
set {
HeaderList.RemoveAll(x => x.Name == HttpContentTypeHeader.NAME);
HeaderList.Add(new HttpContentTypeHeader(value));
}
}
public HttpRequestMessage(string method, string uri) : this(
method, new Uri(uri)
) {}
public const ushort HTTP = 80;
public const ushort HTTPS = 443;
public HttpRequestMessage(string method, Uri uri) {
Method = method ?? throw new ArgumentNullException(nameof(method));
RequestTarget = uri.PathAndQuery;
IsSecure = uri.Scheme.Equals(@"https", StringComparison.InvariantCultureIgnoreCase);
Host = uri.Host;
ushort defaultPort = (IsSecure ? HTTPS : HTTP);
Port = uri.Port == -1 ? defaultPort : (ushort)uri.Port;
IsDefaultPort = Port == defaultPort;
HeaderList.Add(new HttpHostHeader(Host, IsDefaultPort ? -1 : Port));
}
public static bool IsHeaderReadOnly(string name)
=> HEADERS_READONLY.Contains(name ?? throw new ArgumentNullException(nameof(name)));
public static bool IsHeaderSingleInstance(string name)
=> HEADERS_SINGLE.Contains(name ?? throw new ArgumentNullException(nameof(name)));
public void SetHeader(string name, object value) {
name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
if(IsHeaderReadOnly(name))
throw new ArgumentException(@"This header is read-only.", nameof(name));
HeaderList.RemoveAll(x => x.Name == name);
HeaderList.Add(HttpHeader.Create(name, value));
}
public void AddHeader(string name, object value) {
name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
if(IsHeaderReadOnly(name))
throw new ArgumentException(@"This header is read-only.", nameof(name));
if(IsHeaderSingleInstance(name))
HeaderList.RemoveAll(x => x.Name == name);
HeaderList.Add(HttpHeader.Create(name, value));
}
public void RemoveHeader(string name) {
name = HttpHeader.NormaliseName(name ?? throw new ArgumentNullException(nameof(name)));
if(IsHeaderReadOnly(name))
throw new ArgumentException(@"This header is read-only.", nameof(name));
HeaderList.RemoveAll(x => x.Name == name);
}
public void SetBody(Stream stream) {
if(stream == null) {
if(OwnsBodyStream)
BodyStream?.Dispose();
OwnsBodyStream = false;
BodyStream = null;
HeaderList.RemoveAll(x => x.Name == HttpContentLengthHeader.NAME);
} else {
if(!stream.CanRead || !stream.CanSeek)
throw new ArgumentException(@"Body must readable and seekable.", nameof(stream));
if(OwnsBodyStream)
BodyStream?.Dispose();
OwnsBodyStream = false;
BodyStream = stream;
HeaderList.Add(new HttpContentLengthHeader(BodyStream));
}
}
public void SetBody(byte[] buffer) {
SetBody(new MemoryStream(buffer));
OwnsBodyStream = true;
}
public void SetBody(string str, Encoding encoding = null) {
SetBody((encoding ?? Encoding.UTF8).GetBytes(str));
}
public void WriteTo(Stream stream, Action<long, long> onProgress = null) {
using(StreamWriter sw = new(stream, new ASCIIEncoding(), leaveOpen: true)) {
sw.NewLine = "\r\n";
sw.Write(Method);
sw.Write(' ');
sw.Write(RequestTarget);
sw.Write(@" HTTP/");
sw.WriteLine(ProtocolVersion);
foreach(HttpHeader header in Headers)
sw.WriteLine(header);
sw.WriteLine();
sw.Flush();
}
if(BodyStream != null) {
const int bufferSize = 8192;
byte[] buffer = new byte[bufferSize];
int read;
long totalRead = 0;
onProgress?.Invoke(totalRead, BodyStream.Length);
BodyStream.Seek(0, SeekOrigin.Begin);
while((read = BodyStream.Read(buffer, 0, bufferSize)) > 0) {
stream.Write(buffer, 0, read);
totalRead += read;
onProgress?.Invoke(totalRead, BodyStream.Length);
}
}
}
}
}

View file

@ -1,265 +0,0 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
namespace Hamakaze {
public class HttpResponseMessage : HttpMessage {
public override string ProtocolVersion { get; }
public int StatusCode { get; }
public string StatusMessage { get; }
public override IEnumerable<HttpHeader> Headers { get; }
public override Stream Body { get; }
public string Connection
=> Headers.FirstOrDefault(x => x.Name == HttpConnectionHeader.NAME)?.Value.ToString() ?? string.Empty;
public string Server
=> Headers.FirstOrDefault(x => x.Name == HttpServerHeader.NAME)?.Value.ToString() ?? string.Empty;
public DateTimeOffset Date
=> Headers.Where(x => x.Name == HttpDateHeader.NAME).Cast<HttpDateHeader>().FirstOrDefault()?.DateTime ?? DateTimeOffset.MinValue;
public HttpMediaType ContentType
=> Headers.Where(x => x.Name == HttpContentTypeHeader.NAME).Cast<HttpContentTypeHeader>().FirstOrDefault()?.MediaType
?? HttpMediaType.OctetStream;
public Encoding ResponseEncoding
=> Encoding.GetEncoding(ContentType.GetParamValue(@"charset") ?? @"iso8859-1");
public IEnumerable<string> ContentEncodings
=> Headers.Where(x => x.Name == HttpContentEncodingHeader.NAME).Cast<HttpContentEncodingHeader>().FirstOrDefault()?.Encodings
?? Enumerable.Empty<string>();
public IEnumerable<string> TransferEncodings
=> Headers.Where(x => x.Name == HttpTransferEncodingHeader.NAME).Cast<HttpTransferEncodingHeader>().FirstOrDefault()?.Encodings
?? Enumerable.Empty<string>();
public HttpResponseMessage(
int statusCode, string statusMessage, string protocolVersion,
IEnumerable<HttpHeader> headers, Stream body
) {
ProtocolVersion = protocolVersion ?? throw new ArgumentNullException(nameof(protocolVersion));
StatusCode = statusCode;
StatusMessage = statusMessage ?? string.Empty;
Headers = (headers ?? throw new ArgumentNullException(nameof(headers))).ToArray();
OwnsBodyStream = true;
Body = body;
}
public byte[] GetBodyBytes() {
if(Body == null)
return null;
if(Body is MemoryStream msBody)
return msBody.ToArray();
using MemoryStream ms = new();
if(Body.CanSeek)
Body.Seek(0, SeekOrigin.Begin);
Body.CopyTo(ms);
return ms.ToArray();
}
public string GetBodyString() {
byte[] bytes = GetBodyBytes();
return bytes == null || bytes.Length < 1
? string.Empty
: ResponseEncoding.GetString(bytes);
}
// there's probably a less stupid way to do this, be my guest and call me an idiot
private static void ProcessEncoding(Stack<string> encodings, Stream stream, bool transfer) {
using MemoryStream temp = new();
bool inTemp = false;
while(encodings.TryPop(out string encoding)) {
Stream target = (inTemp = !inTemp) ? temp : stream,
source = inTemp ? stream : temp;
target.SetLength(0);
source.Seek(0, SeekOrigin.Begin);
switch(encoding) {
case HttpEncoding.GZIP:
case HttpEncoding.XGZIP:
using(GZipStream gzs = new(source, CompressionMode.Decompress, true))
gzs.CopyTo(target);
break;
case HttpEncoding.DEFLATE:
using(DeflateStream def = new(source, CompressionMode.Decompress, true))
def.CopyTo(target);
break;
case HttpEncoding.BROTLI:
if(transfer)
goto default;
using(BrotliStream br = new(source, CompressionMode.Decompress, true))
br.CopyTo(target);
break;
case HttpEncoding.IDENTITY:
break;
case HttpEncoding.CHUNKED:
if(!transfer)
goto default;
throw new IOException(@"Invalid use of chunked encoding type in Transfer-Encoding header.");
default:
throw new IOException(@"Unsupported encoding supplied.");
}
}
if(inTemp) {
stream.SetLength(0);
temp.Seek(0, SeekOrigin.Begin);
temp.CopyTo(stream);
}
}
public static HttpResponseMessage ReadFrom(Stream stream, Action<long, long> onProgress = null) {
// ignore this function, it doesn't exist
string readLine() {
const ushort crlf = 0x0D0A;
using MemoryStream ms = new();
int byt; ushort lastTwo = 0;
for(; ; ) {
byt = stream.ReadByte();
if(byt == -1 && ms.Length == 0)
return null;
ms.WriteByte((byte)byt);
lastTwo <<= 8;
lastTwo |= (byte)byt;
if(lastTwo == crlf) {
ms.SetLength(ms.Length - 2);
break;
}
}
return Encoding.ASCII.GetString(ms.ToArray());
}
long contentLength = -1;
Stack<string> transferEncodings = null;
Stack<string> contentEncodings = null;
// Read initial header
string line = readLine();
if(line == null)
throw new IOException(@"Failed to read initial HTTP header.");
if(!line.StartsWith(@"HTTP/"))
throw new IOException(@"Response is not a valid HTTP message.");
string[] parts = line[5..].Split(' ', 3);
if(!int.TryParse(parts.ElementAtOrDefault(1), out int statusCode))
throw new IOException(@"Invalid HTTP status code format.");
string protocolVersion = parts.ElementAtOrDefault(0);
string statusMessage = parts.ElementAtOrDefault(2);
// Read header key-value pairs
List<HttpHeader> headers = new();
while((line = readLine()) != null) {
if(string.IsNullOrWhiteSpace(line))
break;
parts = line.Split(':', 2, StringSplitOptions.TrimEntries);
if(parts.Length < 2)
throw new IOException(@"Invalid HTTP header in response.");
string hName = HttpHeader.NormaliseName(parts.ElementAtOrDefault(0) ?? string.Empty),
hValue = parts.ElementAtOrDefault(1);
if(string.IsNullOrEmpty(hName))
throw new IOException(@"Invalid HTTP header name.");
HttpHeader header = HttpHeader.Create(hName, hValue);
if(header is HttpContentLengthHeader hclh)
contentLength = (long)hclh.Value;
else if(header is HttpTransferEncodingHeader hteh)
transferEncodings = new Stack<string>(hteh.Encodings);
else if(header is HttpContentEncodingHeader hceh)
contentEncodings = new Stack<string>(hceh.Encodings);
headers.Add(header);
}
if(statusCode is < 200 or 201 or 204 or 205)
contentLength = 0;
Stream body = null;
long totalRead = 0;
const int buffer_size = 8192;
byte[] buffer = new byte[buffer_size];
int read;
void readBuffer(long length = -1) {
if(length == 0)
return;
long remaining = length;
int bufferRead = buffer_size;
if(bufferRead > length)
bufferRead = (int)length;
if(totalRead < 1)
onProgress?.Invoke(0, contentLength);
while((read = stream.Read(buffer, 0, bufferRead)) > 0) {
body.Write(buffer, 0, read);
totalRead += read;
onProgress?.Invoke(totalRead, contentLength);
if(length >= 0) {
remaining -= read;
if(remaining < 1)
break;
if(bufferRead > remaining)
bufferRead = (int)remaining;
}
}
}
// Read body
if(transferEncodings != null && transferEncodings.Any() && transferEncodings.Peek() == HttpEncoding.CHUNKED) {
// oh no the poop is chunky
transferEncodings.Pop();
body = new MemoryStream();
while((line = readLine()) != null) {
if(string.IsNullOrWhiteSpace(line))
break;
if(!int.TryParse(line, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int chunkLength))
throw new IOException(@"Failed to decode chunk length.");
if(chunkLength == 0) // final chunk
break;
readBuffer(chunkLength);
readLine();
}
readLine();
} else if(contentLength != 0) {
body = new MemoryStream();
readBuffer(contentLength);
readLine();
}
if(body != null)
// Check if body is empty and null it again if so
if(body.Length == 0) {
body.Dispose();
body = null;
} else {
if(transferEncodings != null)
ProcessEncoding(transferEncodings, body, true);
if(contentEncodings != null)
ProcessEncoding(contentEncodings, body, false);
body.Seek(0, SeekOrigin.Begin);
}
return new HttpResponseMessage(statusCode, statusMessage, protocolVersion, headers, body);
}
}
}

View file

@ -1,189 +0,0 @@
using Hamakaze.Headers;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
namespace Hamakaze {
public class HttpTask {
public TaskState State { get; private set; } = TaskState.Initial;
public bool IsStarted
=> State != TaskState.Initial;
public bool IsFinished
=> State == TaskState.Finished;
public bool IsCancelled
=> State == TaskState.Cancelled;
public bool IsErrored
=> Exception != null;
public Exception Exception { get; private set; }
public HttpRequestMessage Request { get; }
public HttpResponseMessage Response { get; private set; }
private HttpConnectionManager Connections { get; }
private IEnumerable<IPAddress> Addresses { get; set; }
private HttpConnection Connection { get; set; }
public bool DisposeRequest { get; set; }
public bool DisposeResponse { get; set; }
public event Action<HttpTask, HttpResponseMessage> OnComplete;
public event Action<HttpTask, Exception> OnError;
public event Action<HttpTask> OnCancel;
public event Action<HttpTask, long, long> OnUploadProgress;
public event Action<HttpTask, long, long> OnDownloadProgress;
public event Action<HttpTask, TaskState> OnStateChange;
public HttpTask(HttpConnectionManager conns, HttpRequestMessage request, bool disposeRequest, bool disposeResponse) {
Connections = conns ?? throw new ArgumentNullException(nameof(conns));
Request = request ?? throw new ArgumentNullException(nameof(request));
DisposeRequest = disposeRequest;
DisposeResponse = disposeResponse;
}
public void Run() {
if(IsStarted)
throw new HttpTaskAlreadyStartedException();
while(NextStep());
}
public void Cancel() {
State = TaskState.Cancelled;
OnStateChange?.Invoke(this, State);
OnCancel?.Invoke(this);
if(DisposeResponse)
Response?.Dispose();
if(DisposeRequest)
Request?.Dispose();
}
private void Error(Exception ex) {
Exception = ex;
OnError?.Invoke(this, ex);
Cancel();
}
public bool NextStep() {
if(IsCancelled)
return false;
switch(State) {
case TaskState.Initial:
State = TaskState.Lookup;
OnStateChange?.Invoke(this, State);
DoLookup();
break;
case TaskState.Lookup:
State = TaskState.Request;
OnStateChange?.Invoke(this, State);
DoRequest();
break;
case TaskState.Request:
State = TaskState.Response;
OnStateChange?.Invoke(this, State);
DoResponse();
break;
case TaskState.Response:
State = TaskState.Finished;
OnStateChange?.Invoke(this, State);
OnComplete?.Invoke(this, Response);
if(DisposeResponse)
Response?.Dispose();
if(DisposeRequest)
Request?.Dispose();
return false;
default:
Error(new HttpTaskInvalidStateException());
return false;
}
return true;
}
private void DoLookup() {
try {
Addresses = Dns.GetHostAddresses(Request.Host);
} catch(Exception ex) {
Error(ex);
return;
}
if(!Addresses.Any())
Error(new HttpTaskNoAddressesException());
}
private void DoRequest() {
Exception exception = null;
try {
foreach(IPAddress addr in Addresses) {
int tries = 0;
IPEndPoint endPoint = new(addr, Request.Port);
exception = null;
Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure);
retry:
++tries;
try {
Request.WriteTo(Connection.Stream, (p, t) => OnUploadProgress?.Invoke(this, p, t));
break;
} catch(IOException ex) {
Connection.Dispose();
Connection = Connections.GetConnection(Request.Host, endPoint, Request.IsSecure);
if(tries < 2)
goto retry;
exception = ex;
continue;
} finally {
Connection.MarkUsed();
}
}
} catch(Exception ex) {
Error(ex);
}
if(exception != null)
Error(exception);
else if(Connection == null)
Error(new HttpTaskNoConnectionException());
}
private void DoResponse() {
try {
Response = HttpResponseMessage.ReadFrom(Connection.Stream, (p, t) => OnDownloadProgress?.Invoke(this, p, t));
} catch(Exception ex) {
Error(ex);
return;
}
if(Response.Connection == HttpConnectionHeader.CLOSE)
Connection.Dispose();
if(Response == null)
Error(new HttpTaskRequestFailedException());
HttpKeepAliveHeader hkah = Response.Headers.Where(x => x.Name == HttpKeepAliveHeader.NAME).Cast<HttpKeepAliveHeader>().FirstOrDefault();
if(hkah != null) {
Connection.MaxIdle = hkah.MaxIdle;
Connection.MaxRequests = hkah.MaxRequests;
}
Connection.Release();
}
public enum TaskState {
Initial = 0,
Lookup = 10,
Request = 20,
Response = 30,
Finished = 40,
Cancelled = -1,
}
}
}

View file

@ -1,41 +0,0 @@
using System;
using System.Threading;
namespace Hamakaze {
public class HttpTaskManager : IDisposable {
private Semaphore Lock { get; set; }
public HttpTaskManager(int maxThreads = 5) {
Lock = new Semaphore(maxThreads, maxThreads);
}
public void RunTask(HttpTask task) {
if(task == null)
throw new ArgumentNullException(nameof(task));
if(!Lock.WaitOne())
throw new HttpTaskManagerLockException();
new Thread(() => {
try {
task.Run();
} finally {
Lock?.Release();
}
}).Start();
}
private bool IsDisposed;
~HttpTaskManager()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Lock.Dispose();
Lock = null;
}
}
}

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2019-2022 flashwave Copyright (c) 2019-2024 flashwave
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,4 +7,7 @@
/_/ /_/
``` ```
Welcome to the repository of the temporary Flashii chat server. This is a reimplementation of the old [PHP based Sock Chat server](https://github.com/flashwave/mahou-chat/) in C#. Welcome to the repository of the Flashii Chat server!
The protocol used is based on the protocol found in the original [PHP Sock Chat](https://patchii.net/sockchat/sockchat).
A rendered version of the Protocol.md document can be found on [railgun.sh/sockchat](https://railgun.sh/sockchat).

View file

@ -0,0 +1,31 @@
using System.Text.Json.Serialization;
namespace SharpChat.Misuzu {
public class MisuzuAuthInfo {
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = "none";
[JsonPropertyName("user_id")]
public long UserId { get; set; }
[JsonPropertyName("username")]
public string? UserName { get; set; }
[JsonPropertyName("colour_raw")]
public int ColourRaw { get; set; }
public Colour Colour => Colour.FromMisuzu(ColourRaw);
[JsonPropertyName("hierarchy")]
public int Rank { get; set; }
[JsonPropertyName("perms")]
public UserPermissions Permissions { get; set; }
[JsonPropertyName("super")]
public bool IsSuper { get; set; }
}
}

View file

@ -0,0 +1,32 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Misuzu {
public class MisuzuBanInfo {
[JsonPropertyName("is_ban")]
public bool IsBanned { get; set; }
[JsonPropertyName("user_id")]
public string? UserId { get; set; }
[JsonPropertyName("ip_addr")]
public string? RemoteAddress { get; set; }
[JsonPropertyName("is_perma")]
public bool IsPermanent { get; set; }
[JsonPropertyName("expires")]
public DateTimeOffset ExpiresAt { get; set; }
// only populated in list request
[JsonPropertyName("user_name")]
public string? UserName { get; set; }
[JsonPropertyName("user_colour")]
public int UserColourRaw { get; set; }
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
public Colour UserColour => Colour.FromMisuzu(UserColourRaw);
}
}

View file

@ -0,0 +1,246 @@
using SharpChat.Config;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace SharpChat.Misuzu {
public class MisuzuClient {
private const string DEFAULT_BASE_URL = "https://flashii.net/_sockchat";
private const string DEFAULT_SECRET_KEY = "woomy";
private const string BUMP_ONLINE_URL = "{0}/bump";
private const string AUTH_VERIFY_URL = "{0}/verify";
private const string BANS_CHECK_URL = "{0}/bans/check?u={1}&a={2}&x={3}&n={4}";
private const string BANS_CREATE_URL = "{0}/bans/create";
private const string BANS_REVOKE_URL = "{0}/bans/revoke?t={1}&s={2}&x={3}";
private const string BANS_LIST_URL = "{0}/bans/list?x={1}";
private const string VERIFY_SIG = "verify#{0}#{1}#{2}";
private const string BANS_CHECK_SIG = "check#{0}#{1}#{2}#{3}";
private const string BANS_REVOKE_SIG = "revoke#{0}#{1}#{2}";
private const string BANS_CREATE_SIG = "create#{0}#{1}#{2}#{3}#{4}#{5}#{6}#{7}";
private const string BANS_LIST_SIG = "list#{0}";
private readonly HttpClient HttpClient;
private CachedValue<string> BaseURL { get; }
private CachedValue<string> SecretKey { get; }
public MisuzuClient(HttpClient httpClient, IConfig config) {
HttpClient = httpClient;
BaseURL = config.ReadCached("url", DEFAULT_BASE_URL);
SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY);
}
public string CreateStringSignature(string str) {
return CreateBufferSignature(Encoding.UTF8.GetBytes(str));
}
public string CreateBufferSignature(byte[] bytes) {
using HMACSHA256 algo = new(Encoding.UTF8.GetBytes(SecretKey.Value ?? string.Empty));
return string.Concat(algo.ComputeHash(bytes).Select(c => c.ToString("x2")));
}
public async Task<MisuzuAuthInfo?> AuthVerifyAsync(string method, string token, string ipAddr) {
method ??= string.Empty;
token ??= string.Empty;
ipAddr ??= string.Empty;
string sig = string.Format(VERIFY_SIG, method, token, ipAddr);
HttpRequestMessage req = new(HttpMethod.Post, string.Format(AUTH_VERIFY_URL, BaseURL)) {
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "method", method },
{ "token", token },
{ "ipaddr", ipAddr },
}),
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
return JsonSerializer.Deserialize<MisuzuAuthInfo>(
await res.Content.ReadAsByteArrayAsync()
);
}
public async Task BumpUsersOnlineAsync(IEnumerable<(string userId, string ipAddr)> list) {
if(!list.Any())
return;
string now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
StringBuilder sb = new();
sb.AppendFormat("bump#{0}", now);
Dictionary<string, string> formData = new() {
{ "t", now }
};
foreach(var (userId, ipAddr) in list) {
sb.AppendFormat("#{0}:{1}", userId, ipAddr);
formData.Add(string.Format("u[{0}]", userId), ipAddr);
}
HttpRequestMessage req = new(HttpMethod.Post, string.Format(BUMP_ONLINE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sb.ToString()) }
},
Content = new FormUrlEncodedContent(formData),
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
try {
res.EnsureSuccessStatusCode();
} catch(HttpRequestException) {
Logger.Debug(await res.Content.ReadAsStringAsync());
#if DEBUG
throw;
#endif
}
}
public async Task<MisuzuBanInfo?> CheckBanAsync(
string? userId = null,
string? ipAddr = null,
bool userIdIsName = false
) {
userId ??= string.Empty;
ipAddr ??= string.Empty;
string userIdIsNameStr = userIdIsName ? "1" : "0";
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_CHECK_URL, BaseURL, Uri.EscapeDataString(userId), Uri.EscapeDataString(ipAddr), Uri.EscapeDataString(now), Uri.EscapeDataString(userIdIsNameStr));
string sig = string.Format(BANS_CHECK_SIG, now, userId, ipAddr, userIdIsNameStr);
HttpRequestMessage req = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
return JsonSerializer.Deserialize<MisuzuBanInfo>(
await res.Content.ReadAsByteArrayAsync()
);
}
public async Task<MisuzuBanInfo[]?> GetBanListAsync() {
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_LIST_URL, BaseURL, Uri.EscapeDataString(now));
string sig = string.Format(BANS_LIST_SIG, now);
HttpRequestMessage req = new(HttpMethod.Get, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
return JsonSerializer.Deserialize<MisuzuBanInfo[]>(
await res.Content.ReadAsByteArrayAsync()
);
}
public enum BanRevokeKind {
UserId,
RemoteAddress,
}
public async Task<bool> RevokeBanAsync(MisuzuBanInfo banInfo, BanRevokeKind kind) {
string type = kind switch {
BanRevokeKind.UserId => "user",
BanRevokeKind.RemoteAddress => "addr",
_ => throw new ArgumentException("Invalid kind specified.", nameof(kind)),
};
string target = kind switch {
BanRevokeKind.UserId => banInfo?.UserId ?? string.Empty,
BanRevokeKind.RemoteAddress => banInfo?.RemoteAddress ?? string.Empty,
_ => string.Empty,
};
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string url = string.Format(BANS_REVOKE_URL, BaseURL, Uri.EscapeDataString(type), Uri.EscapeDataString(target), Uri.EscapeDataString(now));
string sig = string.Format(BANS_REVOKE_SIG, now, type, target);
HttpRequestMessage req = new(HttpMethod.Delete, url) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
if(res.StatusCode == HttpStatusCode.NotFound)
return false;
res.EnsureSuccessStatusCode();
return res.StatusCode == HttpStatusCode.NoContent;
}
public async Task CreateBanAsync(
string targetId,
string targetAddr,
string modId,
string modAddr,
TimeSpan duration,
string reason
) {
if(string.IsNullOrWhiteSpace(targetAddr))
throw new ArgumentException("targetAddr may not be empty", nameof(targetAddr));
if(string.IsNullOrWhiteSpace(modAddr))
throw new ArgumentException("modAddr may not be empty", nameof(modAddr));
if(duration <= TimeSpan.Zero)
return;
modId ??= string.Empty;
targetId ??= string.Empty;
reason ??= string.Empty;
string isPerma = duration == TimeSpan.MaxValue ? "1" : "0";
string durationStr = duration == TimeSpan.MaxValue ? "-1" : duration.TotalSeconds.ToString();
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
string sig = string.Format(
BANS_CREATE_SIG,
now, targetId, targetAddr,
modId, modAddr,
durationStr, isPerma, reason
);
HttpRequestMessage req = new(HttpMethod.Post, string.Format(BANS_CREATE_URL, BaseURL)) {
Headers = {
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
},
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
{ "t", now },
{ "ui", targetId },
{ "ua", targetAddr },
{ "mi", modId },
{ "ma", modAddr },
{ "d", durationStr },
{ "p", isPerma },
{ "r", reason },
}),
};
using HttpResponseMessage res = await HttpClient.SendAsync(req);
res.EnsureSuccessStatusCode();
}
}
}

View file

@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,36 @@
using SharpChat.Misuzu;
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.SockChat.Commands {
public class BanListCommand : ISockChatClientCommand {
private readonly MisuzuClient Misuzu;
public BanListCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("bans")
|| ctx.NameEquals("banned");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.KickUser)
&& !ctx.User.Permissions.HasFlag(UserPermissions.BanUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
Task.Run(async () => {
ctx.Chat.SendTo(ctx.User, new BanListResponseS2CPacket(
(await Misuzu.GetBanListAsync() ?? Array.Empty<MisuzuBanInfo>()).Select(
ban => string.IsNullOrEmpty(ban.UserName) ? (string.IsNullOrEmpty(ban.RemoteAddress) ? string.Empty : ban.RemoteAddress) : ban.UserName
).ToArray()
));
}).Wait();
}
}
}

View file

@ -0,0 +1,66 @@
using SharpChat.Events;
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class ChannelCreateCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("create");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.CreateChannel)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
string firstArg = ctx.Args.First();
bool createChanHasHierarchy;
if(!ctx.Args.Any() || (createChanHasHierarchy = firstArg.All(char.IsDigit) && ctx.Args.Length < 2)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
int createChanHierarchy = 0;
if(createChanHasHierarchy)
if(!int.TryParse(firstArg, out createChanHierarchy))
createChanHierarchy = 0;
if(createChanHierarchy > ctx.User.Rank) {
ctx.Chat.SendTo(ctx.User, new ChannelRankTooHighErrorS2CPacket());
return;
}
string channelName = string.Join('_', ctx.Args.Skip(createChanHasHierarchy ? 1 : 0));
if(!SockChatUtility.CheckChannelName(channelName)) {
ctx.Chat.SendTo(ctx.User, new ChannelNameFormatErrorS2CPacket());
return;
}
if(ctx.Chat.Channels.Get(channelName, SockChatUtility.SanitiseChannelName) != null) {
ctx.Chat.SendTo(ctx.User, new ChannelNameInUseErrorS2CPacket(channelName));
return;
}
ctx.Chat.Events.Dispatch(
"chan:add",
channelName,
ctx.User,
new ChannelAddEventData(
!ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPermanent),
createChanHierarchy,
string.Empty
)
);
DateTimeOffset now = DateTimeOffset.UtcNow;
ctx.Chat.Events.Dispatch("chan:leave", now, ctx.Channel, ctx.User);
ctx.Chat.Events.Dispatch("chan:join", now, channelName, ctx.User);
ctx.Chat.SendTo(ctx.User, new ChannelCreateResponseS2CPacket(channelName));
}
}
}

View file

@ -0,0 +1,36 @@
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class ChannelDeleteCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("delchan") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false
);
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.Args.Any() || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
string delChanName = string.Join('_', ctx.Args);
ChannelInfo? delChan = ctx.Chat.Channels.Get(delChanName, SockChatUtility.SanitiseChannelName);
if(delChan == null) {
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorS2CPacket(delChanName));
return;
}
if(!ctx.User.Permissions.HasFlag(UserPermissions.DeleteChannel) || delChan.OwnerId != ctx.User.UserId) {
ctx.Chat.SendTo(ctx.User, new ChannelDeleteNotAllowedErrorS2CPacket(delChan.Name));
return;
}
ctx.Chat.Events.Dispatch("chan:delete", delChan, ctx.User);
ctx.Chat.SendTo(ctx.User, new ChannelDeleteResponseS2CPacket(delChan.Name));
}
}
}

View file

@ -0,0 +1,48 @@
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class ChannelJoinCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("join");
}
public void Dispatch(SockChatClientCommandContext ctx) {
string channelName = ctx.Args.FirstOrDefault() ?? string.Empty;
string password = string.Join(' ', ctx.Args.Skip(1));
ChannelInfo? channelInfo = ctx.Chat.Channels.Get(channelName, SockChatUtility.SanitiseChannelName);
if(channelInfo == null) {
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorS2CPacket(channelName));
ctx.Chat.SendTo(ctx.User, new UserChannelForceJoinS2CPacket(ctx.Channel.Name));
return;
}
if(channelInfo.Name.Equals(ctx.Channel.Name, StringComparison.InvariantCultureIgnoreCase)) {
// this is where the elusive commented out "samechan" error would go!
// https://patchii.net/sockchat/sockchat/src/commit/6c2111fb4b0241f9ef31060b0f86e7176664f572/server/lib/context.php#L61
ctx.Chat.SendTo(ctx.User, new UserChannelForceJoinS2CPacket(ctx.Channel.Name));
return;
}
if(!ctx.User.Permissions.HasFlag(UserPermissions.JoinAnyChannel) && channelInfo.OwnerId != ctx.User.UserId) {
if(channelInfo.Rank > ctx.User.Rank) {
ctx.Chat.SendTo(ctx.User, new ChannelRankTooLowErrorS2CPacket(channelInfo.Name));
ctx.Chat.SendTo(ctx.User, new UserChannelForceJoinS2CPacket(ctx.Channel.Name));
return;
}
if(!string.IsNullOrEmpty(channelInfo.Password) && channelInfo.Password.Equals(password)) {
ctx.Chat.SendTo(ctx.User, new ChannelPasswordWrongErrorS2CPacket(channelInfo.Name));
ctx.Chat.SendTo(ctx.User, new UserChannelForceJoinS2CPacket(ctx.Channel.Name));
return;
}
}
DateTimeOffset now = DateTimeOffset.UtcNow;
ctx.Chat.Events.Dispatch("chan:leave", now, ctx.Channel, ctx.User);
ctx.Chat.Events.Dispatch("chan:join", now, channelInfo, ctx.User);
}
}
}

View file

@ -0,0 +1,26 @@
using SharpChat.Events;
using SharpChat.SockChat.PacketsS2C;
namespace SharpChat.SockChat.Commands {
public class ChannelPasswordCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("pwd")
|| ctx.NameEquals("password");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPassword) || ctx.Channel.OwnerId != ctx.User.UserId) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
string chanPass = string.Join(' ', ctx.Args).Trim();
if(string.IsNullOrWhiteSpace(chanPass))
chanPass = string.Empty;
ctx.Chat.Events.Dispatch("chan:update", ctx.Channel.Name, ctx.User, new ChannelUpdateEventData(password: chanPass));
ctx.Chat.SendTo(ctx.User, new ChannelPasswordChangedResponseS2CPacket());
}
}
}

View file

@ -0,0 +1,28 @@
using SharpChat.Events;
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class ChannelRankCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("rank")
|| ctx.NameEquals("privilege")
|| ctx.NameEquals("priv");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.SetChannelHierarchy) || ctx.Channel.OwnerId != ctx.User.UserId) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
if(!ctx.Args.Any() || !int.TryParse(ctx.Args.First(), out int chanMinRank) || chanMinRank > ctx.User.Rank) {
ctx.Chat.SendTo(ctx.User, new ChannelRankTooHighErrorS2CPacket());
return;
}
ctx.Chat.Events.Dispatch("chan:update", ctx.Channel.Name, ctx.User, new ChannelUpdateEventData(minRank: chanMinRank));
ctx.Chat.SendTo(ctx.User, new ChannelRankChangedResponseS2CPacket());
}
}
}

View file

@ -0,0 +1,6 @@
namespace SharpChat.SockChat.Commands {
public interface ISockChatClientCommand {
bool IsMatch(SockChatClientCommandContext ctx);
void Dispatch(SockChatClientCommandContext ctx);
}
}

View file

@ -0,0 +1,86 @@
using SharpChat.Events;
using SharpChat.Misuzu;
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.SockChat.Commands {
public class KickBanCommand : ISockChatClientCommand {
private readonly MisuzuClient Misuzu;
public KickBanCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("kick")
|| ctx.NameEquals("ban");
}
public void Dispatch(SockChatClientCommandContext ctx) {
bool isBanning = ctx.NameEquals("ban");
if(!ctx.User.Permissions.HasFlag(isBanning ? UserPermissions.BanUser : UserPermissions.KickUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
string banUserTarget = ctx.Args.ElementAtOrDefault(0) ?? string.Empty;
string? banDurationStr = ctx.Args.ElementAtOrDefault(1);
int banReasonIndex = 1;
UserInfo? banUser = null;
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(banUserTarget);
if(string.IsNullOrEmpty(name) || (banUser = ctx.Chat.Users.Get(name: name, nameTarget: target)) == null) {
ctx.Chat.SendTo(ctx.User, new UserNotFoundErrorS2CPacket(banUserTarget));
return;
}
if(!ctx.User.IsSuper && banUser.Rank >= ctx.User.Rank && banUser != ctx.User) {
ctx.Chat.SendTo(ctx.User, new KickBanNotAllowedErrorS2CPacket(SockChatUtility.GetUserName(banUser)));
return;
}
TimeSpan duration = isBanning ? TimeSpan.MaxValue : TimeSpan.Zero;
if(!string.IsNullOrWhiteSpace(banDurationStr) && double.TryParse(banDurationStr, out double durationSeconds)) {
if(durationSeconds < 0) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
duration = TimeSpan.FromSeconds(durationSeconds);
++banReasonIndex;
}
if(duration <= TimeSpan.Zero) {
ctx.Chat.Events.Dispatch("user:kickban", banUser, UserKickBanEventData.OfDuration(UserDisconnectReason.Kicked, TimeSpan.Zero));
return;
}
string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex));
Task.Run(async () => {
string userId = banUser.UserId.ToString();
MisuzuBanInfo? fbi = await Misuzu.CheckBanAsync(userId);
if(fbi != null && fbi.IsBanned && !fbi.HasExpired) {
ctx.Chat.SendTo(ctx.User, new KickBanNotAllowedErrorS2CPacket(SockChatUtility.GetUserName(banUser)));
return;
}
string[] userRemoteAddrs = ctx.Chat.Connections.GetUserRemoteAddresses(banUser);
string userRemoteAddr = string.Format(", ", userRemoteAddrs);
// Misuzu only stores the IP address in private comment and doesn't do any checking, so this is fine.
await Misuzu.CreateBanAsync(
userId, userRemoteAddr,
ctx.User.UserId.ToString(), ctx.Connection.RemoteAddress,
duration, banReason
);
ctx.Chat.Events.Dispatch("user:kickban", banUser, UserKickBanEventData.OfDuration(UserDisconnectReason.Kicked, duration));
}).Wait();
}
}
}

View file

@ -0,0 +1,22 @@
using SharpChat.Events;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class MessageActionCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("action")
|| ctx.NameEquals("me");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.Args.Any())
return;
string actionStr = string.Join(' ', ctx.Args);
if(string.IsNullOrWhiteSpace(actionStr))
return;
ctx.Chat.Events.Dispatch("msg:add", ctx.Channel, ctx.User, new MessageAddEventData(actionStr, true));
}
}
}

View file

@ -0,0 +1,24 @@
using SharpChat.Events;
using SharpChat.SockChat.PacketsS2C;
namespace SharpChat.SockChat.Commands {
public class MessageBroadcastCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("say")
|| ctx.NameEquals("broadcast");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.Broadcast)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
ctx.Chat.Events.Dispatch(
"msg:add",
ctx.User,
new MessageAddEventData(string.Join(' ', ctx.Args))
);
}
}
}

View file

@ -0,0 +1,42 @@
using SharpChat.Events;
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class MessageDeleteCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("delmsg") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true
);
}
public void Dispatch(SockChatClientCommandContext ctx) {
bool deleteAnyMessage = ctx.User.Permissions.HasFlag(UserPermissions.DeleteAnyMessage);
if(!deleteAnyMessage && !ctx.User.Permissions.HasFlag(UserPermissions.DeleteOwnMessage)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
string? firstArg = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(firstArg) || !firstArg.All(char.IsDigit) || !long.TryParse(firstArg, out long eventId)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
ChatEventInfo? eventInfo = ctx.Chat.EventStorage.GetEvent(eventId);
if(eventInfo == null
|| !eventInfo.Type.Equals("msg:add")
|| eventInfo.SenderRank > ctx.User.Rank
|| (!deleteAnyMessage && eventInfo.SenderId != ctx.User.UserId)) {
ctx.Chat.SendTo(ctx.User, new MessageDeleteNotAllowedErrorS2CPacket());
return;
}
ctx.Chat.Events.Dispatch("msg:delete", eventInfo.ChannelName, ctx.User, new MessageDeleteEventData(eventInfo.Id.ToString()));
}
}
}

View file

@ -0,0 +1,38 @@
using SharpChat.Events;
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class MessageWhisperCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("whisper")
|| ctx.NameEquals("msg");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(ctx.Args.Length < 2) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
string whisperUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(whisperUserStr);
UserInfo? whisperUser = ctx.Chat.Users.Get(name: name, nameTarget: target);
if(whisperUser == null) {
ctx.Chat.SendTo(ctx.User, new UserNotFoundErrorS2CPacket(whisperUserStr));
return;
}
if(whisperUser == ctx.User)
return;
ctx.Chat.Events.Dispatch(
"msg:add",
UserInfo.GetDMChannelName(ctx.User, whisperUser),
ctx.User,
new MessageAddEventData(string.Join(' ', ctx.Args.Skip(1)))
);
}
}
}

View file

@ -0,0 +1,51 @@
using SharpChat.Misuzu;
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
namespace SharpChat.SockChat.Commands {
public class PardonAddressCommand : ISockChatClientCommand {
private readonly MisuzuClient Misuzu;
public PardonAddressCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("pardonip")
|| ctx.NameEquals("unbanip");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.KickUser)
&& !ctx.User.Permissions.HasFlag(UserPermissions.BanUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
string? unbanAddrTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress? unbanAddr)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
unbanAddrTarget = unbanAddr.ToString();
Task.Run(async () => {
MisuzuBanInfo? banInfo = await Misuzu.CheckBanAsync(ipAddr: unbanAddrTarget);
if(banInfo?.IsBanned != true || banInfo.HasExpired) {
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorS2CPacket(unbanAddrTarget));
return;
}
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.RemoteAddress);
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new PardonResponseS2CPacket(unbanAddrTarget));
else
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorS2CPacket(unbanAddrTarget));
}).Wait();
}
}
}

View file

@ -0,0 +1,59 @@
using SharpChat.Misuzu;
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.SockChat.Commands {
public class PardonUserCommand : ISockChatClientCommand {
private readonly MisuzuClient Misuzu;
public PardonUserCommand(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("pardon")
|| ctx.NameEquals("unban");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.KickUser)
&& !ctx.User.Permissions.HasFlag(UserPermissions.BanUser)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
bool unbanUserTargetIsName = true;
string? unbanUserTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(unbanUserTarget);
UserInfo? unbanUser = ctx.Chat.Users.Get(name: name, nameTarget: target);
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId)) {
unbanUserTargetIsName = false;
unbanUser = ctx.Chat.Users.Get(unbanUserId);
}
if(unbanUser != null)
unbanUserTarget = unbanUser.UserId.ToString();
Task.Run(async () => {
MisuzuBanInfo? banInfo = await Misuzu.CheckBanAsync(unbanUserTarget, userIdIsName: unbanUserTargetIsName);
if(banInfo?.IsBanned != true || banInfo.HasExpired) {
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorS2CPacket(unbanUserTarget));
return;
}
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.UserId);
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new PardonResponseS2CPacket(unbanUserTarget));
else
ctx.Chat.SendTo(ctx.User, new KickBanNoRecordErrorS2CPacket(unbanUserTarget));
}).Wait();
}
}
}

View file

@ -0,0 +1,40 @@
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Threading;
namespace SharpChat.SockChat.Commands {
public class ShutdownRestartCommand : ISockChatClientCommand {
private readonly ManualResetEvent WaitHandle;
private readonly Func<bool> ShuttingDown;
private readonly Action<bool> SetShutdown;
public ShutdownRestartCommand(
ManualResetEvent waitHandle,
Func<bool> shuttingDown,
Action<bool> setShutdown
) {
WaitHandle = waitHandle;
ShuttingDown = shuttingDown;
SetShutdown = setShutdown;
}
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("shutdown")
|| ctx.NameEquals("restart");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(ctx.User.UserId != 1) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
if(ShuttingDown())
return;
SetShutdown(ctx.NameEquals("restart"));
ctx.Chat.Update();
WaitHandle?.Set();
}
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class SockChatClientCommandContext {
public string Name { get; }
public string[] Args { get; }
public SockChatContext Chat { get; }
public UserInfo User { get; }
public ConnectionInfo Connection { get; }
public ChannelInfo Channel { get; }
public SockChatClientCommandContext(
string text,
SockChatContext chat,
UserInfo user,
ConnectionInfo connection,
ChannelInfo channel
) {
Chat = chat;
User = user;
Connection = connection;
Channel = channel;
string[] parts = text[1..].Split(' ');
Name = parts.First().Replace(".", string.Empty);
Args = parts.Skip(1).ToArray();
}
public bool NameEquals(string name) {
return Name.Equals(name, StringComparison.InvariantCultureIgnoreCase);
}
}
}

View file

@ -0,0 +1,30 @@
using SharpChat.Events;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class UserAFKCommand : ISockChatClientCommand {
private const string DEFAULT = "AFK";
private const int MAX_LENGTH = 5;
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("afk");
}
public void Dispatch(SockChatClientCommandContext ctx) {
string? statusText = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(statusText))
statusText = DEFAULT;
else {
statusText = statusText.Trim();
if(statusText.Length > MAX_LENGTH)
statusText = statusText[..MAX_LENGTH].Trim();
}
ctx.Chat.Events.Dispatch(
"user:status",
ctx.User,
new UserStatusUpdateEventData(UserStatus.Away, statusText)
);
}
}
}

View file

@ -0,0 +1,61 @@
using SharpChat.Events;
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class UserNickCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("nick");
}
public void Dispatch(SockChatClientCommandContext ctx) {
bool setOthersNick = ctx.User.Permissions.HasFlag(UserPermissions.SetOthersNickname);
if(!setOthersNick && !ctx.User.Permissions.HasFlag(UserPermissions.SetOwnNickname)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
UserInfo? targetUser = null;
int offset = 0;
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
targetUser = ctx.Chat.Users.Get(targetUserId);
++offset;
}
targetUser ??= ctx.User;
if(ctx.Args.Length < offset) {
ctx.Chat.SendTo(ctx.User, new CommandFormatErrorS2CPacket());
return;
}
string nickStr = string.Join('_', ctx.Args.Skip(offset))
.Replace("\n", string.Empty).Replace("\r", string.Empty)
.Replace("\f", string.Empty).Replace("\t", string.Empty)
.Replace(' ', '_').Trim();
if(nickStr == targetUser.UserName)
nickStr = string.Empty;
else if(nickStr.Length > 15)
nickStr = nickStr[..15];
if(string.IsNullOrWhiteSpace(nickStr))
nickStr = string.Empty;
else if(ctx.Chat.Users.Get(name: nickStr, nameTarget: UsersContext.NameTarget.UserAndNickName) != null) {
ctx.Chat.SendTo(ctx.User, new UserNameInUseErrorS2CPacket(nickStr));
return;
}
ctx.Chat.Events.Dispatch(
"user:update",
targetUser,
new UserUpdateEventData(
nickName: nickStr,
notify: targetUser.UserId == ctx.User.UserId
)
);
}
}
}

View file

@ -0,0 +1,44 @@
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class WhoCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("who");
}
public void Dispatch(SockChatClientCommandContext ctx) {
string? channelName = ctx.Args.FirstOrDefault();
if(string.IsNullOrEmpty(channelName)) {
ctx.Chat.SendTo(ctx.User, new WhoServerResponseS2CPacket(
ctx.Chat.Users.All.Select(u => SockChatUtility.GetUserName(u, ctx.Chat.UserStatuses.Get(u))).ToArray(),
SockChatUtility.GetUserName(ctx.User)
));
return;
}
ChannelInfo? channel = ctx.Chat.Channels.Get(channelName, SockChatUtility.SanitiseChannelName);
if(channel == null) {
ctx.Chat.SendTo(ctx.User, new ChannelNotFoundErrorS2CPacket(channelName));
return;
}
if(channel.Rank > ctx.User.Rank || (channel.HasPassword && !ctx.User.Permissions.HasFlag(UserPermissions.JoinAnyChannel))) {
ctx.Chat.SendTo(ctx.User, new WhoChannelNotFoundErrorS2CPacket(channelName));
return;
}
UserInfo[] userInfos = ctx.Chat.Users.GetMany(
ctx.Chat.ChannelsUsers.GetChannelUserIds(channel)
);
ctx.Chat.SendTo(ctx.User, new WhoChannelResponseS2CPacket(
channel.Name,
userInfos.Select(user => SockChatUtility.GetUserName(user, ctx.Chat.UserStatuses.Get(user))).ToArray(),
SockChatUtility.GetUserName(ctx.User, ctx.Chat.UserStatuses.Get(ctx.User))
));
}
}
}

View file

@ -0,0 +1,30 @@
using SharpChat.SockChat.PacketsS2C;
using System.Linq;
namespace SharpChat.SockChat.Commands {
public class WhoisCommand : ISockChatClientCommand {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("ip")
|| ctx.NameEquals("whois");
}
public void Dispatch(SockChatClientCommandContext ctx) {
if(!ctx.User.Permissions.HasFlag(UserPermissions.SeeIPAddress)) {
ctx.Chat.SendTo(ctx.User, new CommandNotAllowedErrorS2CPacket(ctx.Name));
return;
}
string ipUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
UserInfo? ipUser;
(string name, UsersContext.NameTarget target) = SockChatUtility.ExplodeUserName(ipUserStr);
if(string.IsNullOrWhiteSpace(name) || (ipUser = ctx.Chat.Users.Get(name: name, nameTarget: target)) == null) {
ctx.Chat.SendTo(ctx.User, new UserNotFoundErrorS2CPacket(ipUserStr));
return;
}
foreach(string remoteAddr in ctx.Chat.Connections.GetUserRemoteAddresses(ipUser))
ctx.Chat.SendTo(ctx.User, new WhoisResponseS2CPacket(ipUser.UserName, remoteAddr));
}
}
}

View file

@ -0,0 +1,237 @@
using SharpChat.Config;
using SharpChat.Events;
using SharpChat.Misuzu;
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.SockChat.PacketsC2S {
public class AuthC2SPacketHandler : IC2SPacketHandler {
public const string MOTD_FILE = @"welcome.txt";
private readonly DateTimeOffset Started;
private readonly MisuzuClient Misuzu;
private readonly ChannelInfo DefaultChannel;
private readonly CachedValue<string> MOTDHeaderFormat;
private readonly CachedValue<int> MaxMessageLength;
private readonly CachedValue<int> MaxConnections;
public AuthC2SPacketHandler(
DateTimeOffset started,
MisuzuClient msz,
ChannelInfo? defaultChannel,
CachedValue<string> motdHeaderFormat,
CachedValue<int> maxMsgLength,
CachedValue<int> maxConns
) {
Started = started;
Misuzu = msz;
DefaultChannel = defaultChannel ?? throw new ArgumentNullException(nameof(defaultChannel));
MOTDHeaderFormat = motdHeaderFormat;
MaxMessageLength = maxMsgLength;
MaxConnections = maxConns;
}
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("1");
}
public void Handle(C2SPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
string? authMethod = args.ElementAtOrDefault(1);
if(string.IsNullOrWhiteSpace(authMethod)) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.AuthInvalid));
ctx.Connection.Close(1000);
return;
}
string? authToken = args.ElementAtOrDefault(2);
if(string.IsNullOrWhiteSpace(authToken)) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.AuthInvalid));
ctx.Connection.Close(1000);
return;
}
if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) {
string[] tokenParts = authToken.Split(':', 2);
authMethod = tokenParts[0];
authToken = tokenParts[1];
}
Task.Run(async () => {
MisuzuAuthInfo? fai;
string ipAddr = ctx.Connection.RemoteAddress;
try {
fai = await Misuzu.AuthVerifyAsync(authMethod, authToken, ipAddr);
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.RemoteEndPoint}> Failed to authenticate: {ex}");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.AuthInvalid));
ctx.Connection.Close(1000);
#if DEBUG
throw;
#else
return;
#endif
}
if(fai == null) {
Logger.Debug($"<{ctx.Connection.RemoteEndPoint}> Auth fail: <null>");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.Null));
ctx.Connection.Close(1000);
return;
}
if(!fai.Success) {
Logger.Debug($"<{ctx.Connection.RemoteEndPoint}> Auth fail: {fai.Reason}");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.AuthInvalid));
ctx.Connection.Close(1000);
return;
}
MisuzuBanInfo? fbi;
try {
fbi = await Misuzu.CheckBanAsync(fai.UserId.ToString(), ipAddr);
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.RemoteEndPoint}> Failed auth ban check: {ex}");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.AuthInvalid));
ctx.Connection.Close(1000);
#if DEBUG
throw;
#else
return;
#endif
}
if(fbi == null) {
Logger.Debug($"<{ctx.Connection.RemoteEndPoint}> Ban check fail: <null>");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.Null));
ctx.Connection.Close(1000);
return;
}
if(fbi.IsBanned && !fbi.HasExpired) {
Logger.Write($"<{ctx.Connection.RemoteEndPoint}> User is banned.");
ctx.Connection.Send(new AuthFailS2CPacket(fbi.ExpiresAt));
ctx.Connection.Close(1000);
return;
}
if(ctx.Chat.Connections.GetCountForUser(fai.UserId) >= MaxConnections) {
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.MaxSessions));
ctx.Connection.Close(1000);
return;
}
await ctx.Chat.ContextAccess.WaitAsync();
try {
UserInfo? user = ctx.Chat.Users.Get(fai.UserId);
if(user == null) {
ctx.Chat.Events.Dispatch(
"user:add",
fai.UserId,
fai.UserName ?? string.Empty,
fai.Colour,
fai.Rank,
string.Empty,
fai.Permissions,
new UserAddEventData(fai.IsSuper)
);
user = ctx.Chat.Users.Get(fai.UserId);
if(user == null) {
Logger.Write($"<{ctx.Connection.RemoteEndPoint}> User didn't get added.");
ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.FailReason.Null));
ctx.Connection.Close(1000);
return;
}
} else {
string? updName = !user.UserName.Equals(fai.UserName) ? fai.UserName : null;
int? updColour = (updColour = fai.Colour.ToMisuzu()) != user.Colour.ToMisuzu() ? updColour : null;
int? updRank = user.Rank != fai.Rank ? fai.Rank : null;
UserPermissions? updPerms = user.Permissions != fai.Permissions ? fai.Permissions : null;
bool? updSuper = user.IsSuper != fai.IsSuper ? fai.IsSuper : null;
if(updName != null || updColour != null || updRank != null || updPerms != null || updSuper != null)
ctx.Chat.Events.Dispatch(
"user:update",
user,
new UserUpdateEventData(
name: updName,
colour: updColour,
rank: updRank,
perms: updPerms,
isSuper: updSuper
)
);
}
ctx.Connection.BumpPing();
ctx.Chat.Connections.SetUser(ctx.Connection, user);
if(!string.IsNullOrWhiteSpace(MOTDHeaderFormat.Value))
ctx.Connection.Send(new MOTDS2CPacket(Started, string.Format(MOTDHeaderFormat.Value, user.UserName)));
if(File.Exists(MOTD_FILE)) {
IEnumerable<string> lines = File.ReadAllLines(MOTD_FILE).Where(x => !string.IsNullOrWhiteSpace(x));
string? line = lines.ElementAtOrDefault(RNG.Next(lines.Count()));
if(!string.IsNullOrWhiteSpace(line))
ctx.Connection.Send(new MOTDS2CPacket(File.GetLastWriteTimeUtc(MOTD_FILE), line));
}
ctx.Connection.Send(new AuthSuccessS2CPacket(
user.UserId,
SockChatUtility.GetUserName(user, ctx.Chat.UserStatuses.Get(user)),
user.Colour,
user.Rank,
user.Permissions,
DefaultChannel.Name,
MaxMessageLength
));
UserInfo[] chanUsers = ctx.Chat.Users.GetMany(
ctx.Chat.ChannelsUsers.GetChannelUserIds(DefaultChannel)
);
List<UsersPopulateS2CPacket.ListEntry> chanUserEntries = new();
foreach(UserInfo chanUserInfo in chanUsers)
if(chanUserInfo.UserId != user.UserId)
chanUserEntries.Add(new(
chanUserInfo.UserId,
SockChatUtility.GetUserName(chanUserInfo, ctx.Chat.UserStatuses.Get(chanUserInfo)),
chanUserInfo.Colour,
chanUserInfo.Rank,
chanUserInfo.Permissions,
true
));
ctx.Connection.Send(new UsersPopulateS2CPacket(chanUserEntries.ToArray()));
ctx.Chat.Events.Dispatch(
"user:connect",
DefaultChannel,
user,
new UserConnectEventData(!ctx.Chat.ChannelsUsers.Has(DefaultChannel, user))
);
ctx.Chat.HandleChannelEventLog(DefaultChannel.Name, p => ctx.Connection.Send(p));
ChannelInfo[] chans = ctx.Chat.Channels.GetMany(isPublic: true, minRank: user.Rank);
List<ChannelsPopulateS2CPacket.ListEntry> chanEntries = new();
foreach(ChannelInfo chanInfo in chans)
chanEntries.Add(new(
chanInfo.Name,
chanInfo.HasPassword,
chanInfo.IsTemporary
));
ctx.Connection.Send(new ChannelsPopulateS2CPacket(chanEntries.ToArray()));
} finally {
ctx.Chat.ContextAccess.Release();
}
}).Wait();
}
}
}

View file

@ -0,0 +1,21 @@
namespace SharpChat.SockChat.PacketsC2S {
public class C2SPacketHandlerContext {
public string Text { get; }
public SockChatContext Chat { get; }
public SockChatConnectionInfo Connection { get; }
public C2SPacketHandlerContext(string text, SockChatContext chat, SockChatConnectionInfo connection) {
Text = text;
Chat = chat;
Connection = connection;
}
public bool CheckPacketId(string packetId) {
return Text == packetId || Text.StartsWith(packetId + '\t');
}
public string[] SplitText(int expect) {
return Text.Split('\t', expect + 1);
}
}
}

View file

@ -0,0 +1,6 @@
namespace SharpChat.SockChat.PacketsC2S {
public interface IC2SPacketHandler {
bool IsMatch(C2SPacketHandlerContext ctx);
void Handle(C2SPacketHandlerContext ctx);
}
}

View file

@ -0,0 +1,60 @@
using SharpChat.Misuzu;
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.SockChat.PacketsC2S {
public class PingC2SPacketHandler : IC2SPacketHandler {
private readonly MisuzuClient Misuzu;
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
private DateTimeOffset LastBump = DateTimeOffset.MinValue;
public PingC2SPacketHandler(MisuzuClient msz) {
Misuzu = msz;
}
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("0");
}
public void Handle(C2SPacketHandlerContext ctx) {
string[] parts = ctx.SplitText(2);
if(!int.TryParse(parts.FirstOrDefault(), out int pTime))
return;
ctx.Connection.BumpPing();
ctx.Connection.Send(new PongS2CPacket());
ctx.Chat.ContextAccess.Wait();
try {
if(LastBump < DateTimeOffset.UtcNow - BumpInterval) {
List<(string, string)> bumpList = new();
foreach(UserInfo userInfo in ctx.Chat.Users.All) {
if(ctx.Chat.UserStatuses.GetStatus(userInfo) != UserStatus.Online)
continue;
string[] remoteAddrs = ctx.Chat.Connections.GetUserRemoteAddresses(userInfo);
if(remoteAddrs.Length < 1)
continue;
bumpList.Add((userInfo.UserId.ToString(), remoteAddrs[0]));
}
if(bumpList.Count > 0)
Task.Run(async () => {
await Misuzu.BumpUsersOnlineAsync(bumpList);
}).Wait();
LastBump = DateTimeOffset.UtcNow;
}
} finally {
ctx.Chat.ContextAccess.Release();
}
}
}
}

View file

@ -0,0 +1,92 @@
using SharpChat.Config;
using SharpChat.Events;
using SharpChat.SockChat.Commands;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat.SockChat.PacketsC2S {
public class SendMessageC2SPacketHandler : IC2SPacketHandler {
private readonly CachedValue<int> MaxMessageLength;
private List<ISockChatClientCommand> Commands { get; } = new();
public SendMessageC2SPacketHandler(CachedValue<int> maxMsgLength) {
MaxMessageLength = maxMsgLength;
}
public void AddCommand(ISockChatClientCommand command) {
Commands.Add(command);
}
public void AddCommands(IEnumerable<ISockChatClientCommand> commands) {
Commands.AddRange(commands);
}
public bool IsMatch(C2SPacketHandlerContext ctx) {
return ctx.CheckPacketId("2");
}
public void Handle(C2SPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
UserInfo? user = ctx.Chat.Users.Get(ctx.Connection.UserId);
// No longer concats everything after index 1 with \t, no previous implementation did that either
string? messageText = args.ElementAtOrDefault(2);
if(user == null || !user.Permissions.HasFlag(UserPermissions.SendMessage) || string.IsNullOrWhiteSpace(messageText))
return;
// Extra validation step, not necessary at all but enforces proper formatting in SCv1.
if(!long.TryParse(args[1], out long mUserId) || user.UserId != mUserId)
return;
ctx.Chat.ContextAccess.Wait();
try {
ChannelInfo? channelInfo = ctx.Chat.Channels.Get(
ctx.Chat.ChannelsUsers.GetUserLastChannel(user)
);
if(channelInfo == null)
return;
if(ctx.Chat.UserStatuses.GetStatus(user) != UserStatus.Online)
ctx.Chat.Events.Dispatch(
"user:status",
user,
new UserStatusUpdateEventData(UserStatus.Online)
);
int maxMsgLength = MaxMessageLength;
if(messageText.Length > maxMsgLength)
messageText = messageText[..maxMsgLength];
messageText = messageText.Trim();
#if DEBUG
Logger.Write($"<{user.UserId} {user.UserName}> {messageText}");
#endif
if(messageText.StartsWith("/")) {
SockChatClientCommandContext context = new(messageText, ctx.Chat, user, ctx.Connection, channelInfo);
ISockChatClientCommand? command = null;
foreach(ISockChatClientCommand cmd in Commands)
if(cmd.IsMatch(context)) {
command = cmd;
break;
}
if(command != null) {
command.Dispatch(context);
return;
}
}
ctx.Chat.Events.Dispatch("msg:add", channelInfo, user, new MessageAddEventData(messageText));
} finally {
ctx.Chat.ContextAccess.Release();
}
}
}
}

View file

@ -0,0 +1,38 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class AuthFailS2CPacket : ISockChatS2CPacket {
public enum FailReason {
AuthInvalid,
MaxSessions,
Banned,
Null,
}
private readonly FailReason Reason;
private readonly long Expires;
public AuthFailS2CPacket(FailReason reason) {
Reason = reason;
}
public AuthFailS2CPacket(DateTimeOffset expires) {
Reason = FailReason.Banned;
Expires = expires.Year >= 2100 ? -1 : expires.ToUnixTimeSeconds();
}
public string Pack() {
string packet = string.Format("1\tn\t{0}fail", Reason switch {
FailReason.AuthInvalid => "auth",
FailReason.MaxSessions => "sock",
FailReason.Banned => "join",
_ => "user",
});
if(Reason == FailReason.Banned)
packet += string.Format("\t{0}", Expires);
return packet;
}
}
}

View file

@ -0,0 +1,47 @@
namespace SharpChat.SockChat.PacketsS2C {
public class AuthSuccessS2CPacket : ISockChatS2CPacket {
private readonly long UserId;
private readonly string UserName;
private readonly Colour UserColour;
private readonly int UserRank;
private readonly UserPermissions UserPerms;
private readonly string ChannelName;
private readonly int MaxMessageLength;
public AuthSuccessS2CPacket(
long userId,
string userName,
Colour userColour,
int userRank,
UserPermissions userPerms,
string channelName,
int maxMsgLength
) {
UserId = userId;
UserName = userName;
UserColour = userColour;
UserRank = userRank;
UserPerms = userPerms;
ChannelName = channelName;
MaxMessageLength = maxMsgLength;
}
public string Pack() {
return string.Format(
"1\ty\t{0}\t{1}\t{2}\t{3} {4} {5} {6} {7}\t{8}\t{9}",
UserId,
UserName,
UserColour,
UserRank,
UserPerms.HasFlag(UserPermissions.KickUser) ? 1 : 0,
UserPerms.HasFlag(UserPermissions.ViewLogs) ? 1 : 0,
UserPerms.HasFlag(UserPermissions.SetOwnNickname) ? 1 : 0,
UserPerms.HasFlag(UserPermissions.CreateChannel) ? (
UserPerms.HasFlag(UserPermissions.SetChannelPermanent) ? 2 : 1
) : 0,
SockChatUtility.SanitiseChannelName(ChannelName),
MaxMessageLength
);
}
}
}

View file

@ -0,0 +1,32 @@
using System;
using System.Text;
namespace SharpChat.SockChat.PacketsS2C {
public class BanListResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string[] Bans;
public BanListResponseS2CPacket(string[] bans) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
Bans = bans;
}
public string Pack() {
StringBuilder sb = new();
sb.AppendFormat("2\t{0}\t-1\t0\fbanlist\f", TimeStamp.ToUnixTimeSeconds());
foreach(string ban in Bans)
sb.AppendFormat(@"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('/unban '+ this.innerHTML);"">{0}</a>, ", ban);
if(Bans.Length > 0)
sb.Length -= 2;
sb.AppendFormat("\t{0}\t10010", MessageId);
return sb.ToString();
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelCreateResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
public ChannelCreateResponseS2CPacket(string channelName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\fcrchan\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName),
MessageId
);
}
}
}

View file

@ -0,0 +1,26 @@
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelCreateS2CPacket : ISockChatS2CPacket {
private readonly string ChannelName;
private readonly bool ChannelHasPassword;
private readonly bool ChannelIsTemporary;
public ChannelCreateS2CPacket(
string channelName,
bool channelHasPassword,
bool channelIsTemporary
) {
ChannelName = channelName;
ChannelHasPassword = channelHasPassword;
ChannelIsTemporary = channelIsTemporary;
}
public string Pack() {
return string.Format(
"4\t0\t{0}\t{1}\t{2}",
SockChatUtility.SanitiseChannelName(ChannelName),
ChannelHasPassword ? 1 : 0,
ChannelIsTemporary ? 1 : 0
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelDeleteNotAllowedErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
public ChannelDeleteNotAllowedErrorS2CPacket(string channelName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fndchan\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName),
MessageId
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelDeleteResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
public ChannelDeleteResponseS2CPacket(string channelName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\fdelchan\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName),
MessageId
);
}
}
}

View file

@ -0,0 +1,16 @@
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelDeleteS2CPacket : ISockChatS2CPacket {
private readonly string ChannelName;
public ChannelDeleteS2CPacket(string channelName) {
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"4\t2\t{0}",
SockChatUtility.SanitiseChannelName(ChannelName)
);
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelNameFormatErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
public ChannelNameFormatErrorS2CPacket() {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\finchan\t{1}\t10010",
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelNameInUseErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
public ChannelNameInUseErrorS2CPacket(string channelName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fnischan\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName),
MessageId
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelNotFoundErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
public ChannelNotFoundErrorS2CPacket(string channelName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fnochan\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName),
MessageId
);
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelPasswordChangedResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
public ChannelPasswordChangedResponseS2CPacket() {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\fcpwdchan\t{1}\t10010",
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelPasswordWrongErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
public ChannelPasswordWrongErrorS2CPacket(string channelName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fipwchan\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName),
MessageId
);
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelRankChangedResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
public ChannelRankChangedResponseS2CPacket() {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\fcprivchan\t{1}\t10010",
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelRankTooHighErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
public ChannelRankTooHighErrorS2CPacket() {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\frankerr\t{1}\t10010",
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelRankTooLowErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
public ChannelRankTooLowErrorS2CPacket(string channelName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fipchan\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName),
MessageId
);
}
}
}

View file

@ -0,0 +1,30 @@
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelUpdateS2CPacket : ISockChatS2CPacket {
private readonly string ChannelNamePrevious;
private readonly string ChannelNameNew;
private readonly bool ChannelHasPassword;
private readonly bool ChannelIsTemporary;
public ChannelUpdateS2CPacket(
string channelNamePrevious,
string channelNameNew,
bool channelHasPassword,
bool channelIsTemporary
) {
ChannelNamePrevious = channelNamePrevious;
ChannelNameNew = channelNameNew;
ChannelHasPassword = channelHasPassword;
ChannelIsTemporary = channelIsTemporary;
}
public string Pack() {
return string.Format(
"4\t1\t{0}\t{1}\t{2}\t{3}",
SockChatUtility.SanitiseChannelName(ChannelNamePrevious),
SockChatUtility.SanitiseChannelName(ChannelNameNew),
ChannelHasPassword ? 1 : 0,
ChannelIsTemporary ? 1 : 0
);
}
}
}

View file

@ -0,0 +1,29 @@
using System.Text;
namespace SharpChat.SockChat.PacketsS2C {
public class ChannelsPopulateS2CPacket : ISockChatS2CPacket {
public record ListEntry(string Name, bool HasPassword, bool IsTemporary);
private readonly ListEntry[] Entries;
public ChannelsPopulateS2CPacket(ListEntry[] entries) {
Entries = entries;
}
public string Pack() {
StringBuilder sb = new();
sb.AppendFormat("7\t2\t{0}", Entries.Length);
foreach(ListEntry entry in Entries)
sb.AppendFormat(
"\t{0}\t{1}\t{2}",
SockChatUtility.SanitiseChannelName(entry.Name),
entry.HasPassword ? 1 : 0,
entry.IsTemporary ? 1 : 0
);
return sb.ToString();
}
}
}

View file

@ -0,0 +1,7 @@
namespace SharpChat.SockChat.PacketsS2C {
public class ClearMessagesAndUsersS2CPacket : ISockChatS2CPacket {
public string Pack() {
return "8\t3";
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class CommandFormatErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
public CommandFormatErrorS2CPacket() {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fcmdna\t{1}\t10010",
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class CommandNotAllowedErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string CommandName;
public CommandNotAllowedErrorS2CPacket(string commandName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
CommandName = commandName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fcmdna\f/{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
CommandName,
MessageId
);
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class FloodWarningS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
public FloodWarningS2CPacket() {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\fflwarn\t{1}\t10010",
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

@ -0,0 +1,19 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class ForceDisconnectS2CPacket : ISockChatS2CPacket {
private readonly long Expires;
public ForceDisconnectS2CPacket(DateTimeOffset expires) {
Expires = expires <= DateTimeOffset.UtcNow
? 0 : (expires.Year >= 2100 ? -1 : expires.ToUnixTimeSeconds());
}
public string Pack() {
if(Expires != 0)
return string.Format("9\t1\t{0}", Expires);
return "9\t0";
}
}
}

View file

@ -0,0 +1,5 @@
namespace SharpChat.SockChat.PacketsS2C {
public interface ISockChatS2CPacket {
string Pack();
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class KickBanNoRecordErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string TargetName;
public KickBanNoRecordErrorS2CPacket(string targetName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
TargetName = targetName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fnotban\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
TargetName,
MessageId
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class KickBanNotAllowedErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string UserName;
public KickBanNotAllowedErrorS2CPacket(string userName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
UserName = userName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fkickna\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
UserName,
MessageId
);
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class MOTDS2CPacket : ISockChatS2CPacket {
private readonly DateTimeOffset TimeStamp;
private readonly string Body;
public MOTDS2CPacket(DateTimeOffset timeStamp, string body) {
TimeStamp = timeStamp;
Body = body;
}
public string Pack() {
return string.Format(
"7\t1\t{0}\t-1\tChatBot\tinherit\t\t0\fsay\f{1}\twelcome\t0\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseMessageBody(Body)
);
}
}
}

View file

@ -0,0 +1,83 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class MessageAddLogS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly long UserId;
private readonly string UserName;
private readonly Colour UserColour;
private readonly int UserRank;
private readonly UserPermissions UserPerms;
private readonly string Body;
private readonly bool IsAction;
private readonly bool IsPrivate;
private readonly bool IsBroadcast; // this should be MessageBroadcastLogPacket
private readonly bool Notify;
public MessageAddLogS2CPacket(
long messageId,
DateTimeOffset timeStamp,
long userId,
string userName,
Colour userColour,
int userRank,
UserPermissions userPerms,
string body,
bool isAction,
bool isPrivate,
bool isBroadcast,
bool notify
) {
MessageId = messageId;
TimeStamp = timeStamp;
UserId = userId < 0 ? -1 : userId;
UserName = userName;
UserColour = userColour;
UserRank = userRank;
UserPerms = userPerms;
Body = body;
IsAction = isAction;
IsPrivate = isPrivate;
IsBroadcast = isBroadcast;
Notify = notify;
}
public string Pack() {
string body = SockChatUtility.SanitiseMessageBody(Body);
if(IsAction)
body = string.Format("<i>{0}</i>", body);
if(IsBroadcast)
body = "0\fsay\f" + body;
string userPerms = UserId < 0 ? string.Empty : string.Format(
"{0} {1} {2} {3} {4}",
UserRank,
UserPerms.HasFlag(UserPermissions.KickUser) == true ? 1 : 0,
UserPerms.HasFlag(UserPermissions.ViewLogs) == true ? 1 : 0,
UserPerms.HasFlag(UserPermissions.SetOwnNickname) == true ? 1 : 0,
UserPerms.HasFlag(UserPermissions.CreateChannel) == true ? (
UserPerms.HasFlag(UserPermissions.SetChannelPermanent) == true ? 2 : 1
) : 0
);
return string.Format(
"7\t1\t{0}\t{1}\t{2}\t{3}\t{4}\t{5}\t{6}\t{7}\t{8}{9}{10}{11}{12}",
TimeStamp.ToUnixTimeSeconds(),
UserId,
UserName,
UserColour,
userPerms,
body,
MessageId,
Notify ? 1 : 0,
1,
IsAction ? 1 : 0,
0,
IsAction ? 0 : 1,
IsPrivate ? 1 : 0
);
}
}
}

View file

@ -0,0 +1,47 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class MessageAddS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly long UserId;
private readonly string Body;
private readonly bool IsAction;
private readonly bool IsPrivate;
public MessageAddS2CPacket(
long messageId,
DateTimeOffset timeStamp,
long userId,
string body,
bool isAction,
bool isPrivate
) {
MessageId = messageId;
TimeStamp = timeStamp;
UserId = userId < 0 ? -1 : userId;
Body = body;
IsAction = isAction;
IsPrivate = isPrivate;
}
public string Pack() {
string body = SockChatUtility.SanitiseMessageBody(Body);
if(IsAction)
body = string.Format("<i>{0}</i>", body);
return string.Format(
"2\t{0}\t{1}\t{2}\t{3}\t{4}{5}{6}{7}{8}",
TimeStamp.ToUnixTimeSeconds(),
UserId,
body,
MessageId,
1,
IsAction ? 1 : 0,
0,
IsAction ? 0 : 1,
IsPrivate ? 1 : 0
);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class MessageBroadcastS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string Body;
public MessageBroadcastS2CPacket(long messageId, DateTimeOffset timeStamp, string body) {
MessageId = messageId;
TimeStamp = timeStamp;
Body = body;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\fsay\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseMessageBody(Body),
MessageId
);
}
}
}

View file

@ -0,0 +1,21 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class MessageDeleteNotAllowedErrorS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
public MessageDeleteNotAllowedErrorS2CPacket() {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t1\fdelerr\t{1}\t10010",
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

@ -0,0 +1,13 @@
namespace SharpChat.SockChat.PacketsS2C {
public class MessageDeleteS2CPacket : ISockChatS2CPacket {
private readonly string DeletedMessageId;
public MessageDeleteS2CPacket(string deletedMessageId) {
DeletedMessageId = deletedMessageId;
}
public string Pack() {
return string.Format("6\t{0}", DeletedMessageId);
}
}
}

View file

@ -0,0 +1,24 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class PardonResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string Subject;
public PardonResponseS2CPacket(string subject) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
Subject = subject;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\funban\f{1}\t{2}\t10010",
TimeStamp.ToUnixTimeSeconds(),
Subject,
MessageId
);
}
}
}

View file

@ -0,0 +1,7 @@
namespace SharpChat.SockChat.PacketsS2C {
public class PongS2CPacket : ISockChatS2CPacket {
public string Pack() {
return "0\tpong";
}
}
}

View file

@ -0,0 +1,16 @@
namespace SharpChat.SockChat.PacketsS2C {
public class UserChannelForceJoinS2CPacket : ISockChatS2CPacket {
private readonly string ChannelName;
public UserChannelForceJoinS2CPacket(string channelName) {
ChannelName = channelName;
}
public string Pack() {
return string.Format(
"5\t2\t{0}",
SockChatUtility.SanitiseChannelName(ChannelName)
);
}
}
}

View file

@ -0,0 +1,28 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserChannelJoinLogS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string UserName;
public UserChannelJoinLogS2CPacket(
long messageId,
DateTimeOffset timeStamp,
string userName
) {
MessageId = messageId;
TimeStamp = timeStamp;
UserName = userName;
}
public string Pack() {
return string.Format(
"7\t1\t{0}\t-1\tChatBot\tinherit\t\t0\fjchan\f{1}\t{2}\t0\t10010",
TimeStamp.ToUnixTimeSeconds(),
UserName,
MessageId
);
}
}
}

View file

@ -0,0 +1,42 @@
namespace SharpChat.SockChat.PacketsS2C {
public class UserChannelJoinS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly long UserId;
private readonly string UserName;
private readonly Colour UserColour;
private readonly int UserRank;
private readonly UserPermissions UserPerms;
public UserChannelJoinS2CPacket(
long userId,
string userName,
Colour userColour,
int userRank,
UserPermissions userPerms
) {
MessageId = SharpId.Next();
UserId = userId;
UserName = userName;
UserColour = userColour;
UserRank = userRank;
UserPerms = userPerms;
}
public string Pack() {
return string.Format(
"5\t0\t{0}\t{1}\t{2}\t{3} {4} {5} {6} {7}\t{8}",
UserId,
UserName,
UserColour,
UserRank,
UserPerms.HasFlag(UserPermissions.KickUser) ? 1 : 0,
UserPerms.HasFlag(UserPermissions.ViewLogs) ? 1 : 0,
UserPerms.HasFlag(UserPermissions.SetOwnNickname) ? 1 : 0,
UserPerms.HasFlag(UserPermissions.CreateChannel) ? (
UserPerms.HasFlag(UserPermissions.SetChannelPermanent) ? 2 : 1
) : 0,
MessageId
);
}
}
}

View file

@ -0,0 +1,28 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserChannelLeaveLogS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string UserName;
public UserChannelLeaveLogS2CPacket(
long messageId,
DateTimeOffset timeStamp,
string userName
) {
MessageId = messageId;
TimeStamp = timeStamp;
UserName = userName;
}
public string Pack() {
return string.Format(
"7\t1\t{0}\t-1\tChatBot\tinherit\t\t0\flchan\f{1}\t{2}\t0\t10010",
TimeStamp.ToUnixTimeSeconds(),
UserName,
MessageId
);
}
}
}

View file

@ -0,0 +1,19 @@
namespace SharpChat.SockChat.PacketsS2C {
public class UserChannelLeaveS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly long UserId;
public UserChannelLeaveS2CPacket(long userId) {
MessageId = SharpId.Next();
UserId = userId;
}
public string Pack() {
return string.Format(
"5\t1\t{0}\t{1}",
UserId,
MessageId
);
}
}
}

Some files were not shown because too many files have changed in this diff Show more