Compare commits

...
Sign in to create a new pull request.

40 commits

Author SHA1 Message Date
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
6bda1ee09d Only a single clear mode is ever used, removed the rest. 2024-05-28 21:51:54 +00:00
86effa0452 Split status elements out of UserInfo and made user update event based. 2024-05-24 19:17:12 +00:00
2eae48325a Issue user disconnected and kick/ban as events and restructure table. 2024-05-24 16:01:11 +00:00
09d5bfef82 Fixed excessive sending of user update packets.
the funny inverted if condition
2024-05-24 13:19:04 +00:00
d426df91f0 Made the channel event log code similar to the normal event handling code. 2024-05-24 13:11:21 +00:00
5daad52aba Events system overhaul. 2024-05-24 03:44:20 +00:00
454a460441 Removed event flags attribute. 2024-05-24 00:23:31 +00:00
651c3f127d Use interface instead of abstract classes as base for Sock Chat S2C packets. 2024-05-23 23:13:57 +00:00
4ace355374 Removed AddEvent aliases. 2024-05-23 22:31:43 +00:00
968df2b161 Split connection classes. 2024-05-21 20:08:23 +00:00
12e7bd2768 Turns out neither of those two repos were public! 2024-05-21 14:52:15 +00:00
cfbe98d34a Updated README.md. 2024-05-20 23:45:29 +00:00
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
980ec5b855 Apply S2C and C2S naming scheme for easy packet direction identification. 2024-05-20 23:00:47 +00:00
a0e6fbbeea Packet packing micro optimisation. 2024-05-20 16:24:14 +00:00
610f9ab142 Connection handling rewrite. 2024-05-20 16:16:32 +00:00
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
1d781bd72c Added base class for packets with timestamp. 2024-05-20 02:16:38 +00:00
042b6ddbd6 Removed IServerPacket interface. 2024-05-20 01:35:39 +00:00
c490dcf128 Extracted all log packets into their own ones. 2024-05-20 01:35:33 +00:00
549c80740d Rewrote user and channel collections. 2024-05-19 21:02:17 +00:00
1a8c44a4ba Cleaned up the names of some of the base classes. 2024-05-19 02:17:51 +00:00
bd23d3aa15 Some cleanups (snapshot, don't run this). 2024-05-19 01:53:32 +00:00
68a523f76a Use HasFlag instead of custom Can method. 2024-05-17 23:50:22 +00:00
322500739e Moved some things out of the MessagePopulatePacket class. 2024-05-14 22:56:56 +00:00
a6a7e56bd1 Drew the rest of the fucking owl. 2024-05-14 22:17:25 +00:00
7bcf5acb7e Created more discrete error/response packets. 2024-05-14 19:11:09 +00:00
38f17c325a Apparently markdown doesn't have underlining. 2024-05-14 17:54:59 +00:00
907711e753 Split some packets out of LegacyCommandResponse. 2024-05-13 20:55:54 +00:00
8cc00fe1a8 Updated protocol information document. 2024-05-10 23:50:40 +00:00
3c58e5201a Updated LICENSE year. 2024-05-10 19:29:03 +00:00
795a87fe56 Added migration to update event types of older messages. 2024-05-10 19:23:19 +00:00
a6569815af Enabled explicit nullable. 2024-05-10 19:18:55 +00:00
b95cd06cb1 Reduce usage of working objects in packet objects as much as possible. 2024-05-10 18:29:48 +00:00
356409eb16 Simplified packet building. 2024-05-10 17:28:52 +00:00
1ba94a526c Removed unused method from ChatMessageAddPacket. 2024-05-10 15:25:50 +00:00
0b0de00cc4 Updated MySQL connector library. 2024-05-10 15:24:56 +00:00
b1fae4bdeb Simplified Pack method return type. 2024-05-10 15:24:43 +00:00
fc7d428f76 Split name change notification out of UserUpdatePacket. 2024-05-10 15:07:56 +00:00
202 changed files with 5728 additions and 4209 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

View file

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

View file

@ -12,68 +12,55 @@ Further documentation on their behaviour will be added at a later date.
### `bool`
A value that indicates a true or a false state. `0` represents false and anything non-`0` represents true, please stick to `1` for representing true though.
### `int`
Any number ranging from `-9007199254740991` to `9007199254740991`, `Number.MAX_SAFE_INTEGER` and `Number.MIN_SAFE_INTEGER` in JavaScript.
### `string`
Any printable unicode character, except `\t` which is used to separate packets.
### `timestamp`
Extends `int`, contains a second based UNIX timestamp.
### `user name`
A `string` containing a user's name.
If the user currently has an AFK tag set through the `/afk`, it is prefixed by `<AFK>_`.
The `AFK` text can be replaced by any text supplied to the `/afk` command as an argument, the original PHP Sock Chat and SharpChat force this text to uppercase and limit it to 5 characters.
If prefixed by a `~`, it is not the user's actual name but a nick name.
### `channel name`
A `string` containing only alphanumeric characters (any case), `-` or `_`.
### `color`
Any valid value for the CSS `color` property.
### `colour`
Any valid value for the CSS `colour` property.
Further documentation can be found [on MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
### `message flags`
Message flags alter how a message should be displayed to the client, these are all `bool` values.
The parts are as follows:
- Username should appear using a **bold** font.
- Username should appear using a *cursive* font.
- Username should appear __underlined__.
- Username should appear <u>underlined</u>.
- A colon `:` should be displayed between the username and the message.
- The message was sent privately, directly to the current user.
As an example, the most common message flagset is `10010`. This indicates a bold username with a colon separator.
### `user permissions`
User permissions are a set of flags separated by either the form feed character (`\f` / `0x0C`) or a space (<code> </code> / `0x20`).
### `user perms`
User permissions are a set of flags separated by either the form feed character (`\f` / `0x0C`) or a space (' ' / `0x20`).
The reason there are two options is due to a past mixup that we now have to live with.
Which of the methods is used remains consistent per server however, so the result of a test can be cached.
<table>
<tr>
<td><code>int</code></td>
<td>Rank of the user. Used to determine what channels a user can access or what other users the user can moderate.</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the user the ability kick/ban/unban others.</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the user can access the logs. This should always be <code>0</code>, unless the client has a dedicated log view that can be accessed without connecting to the chat server.</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the user can set an alternate display name.</td>
</tr>
<tr>
<td><code>int</code></td>
<td>Indicates whether the user can create channel. If <code>0</code> the user cannot create channels, if <code>1</code> the user can create channels but they are to disappear when all users have left it and if <code>2</code> the user can create channels that permanently stay in the channel assortment.</td>
</tr>
</table>
| Type | Description |
|:------:| ----------- |
| `int` | Rank of the user. Used to determine what channels a user can access or what other users the user can moderate. |
| `bool` | Indicates whether the user the ability kick/ban/unban others. |
| `bool` | Indicates whether the user can access the logs. This should always be `0`, unless the client has a dedicated log view that can be accessed without connecting to the chat server. |
| `bool` | Indicates whether the user can set an alternate display name. |
| `int` | Indicates whether the user can create channel. If `0` the user cannot create channels, if `1` the user can create channels but they are to disappear when all users have left it and if `2` the user can create channels that permanently stay in the channel assortment. |
## Client Packets
@ -82,23 +69,35 @@ These are the packets sent from the client to the server.
### Packet `0`: Ping
Used to prevent the client from closing the session due to inactivity.
<table>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
</table>
| Type | Description |
|:--------:| ----------- |
| `string` | User ID of the current user. |
### Packet `1`: Authentication
Takes a variable number of parameters that are fed into the authentication script associated with the chat.
<table>
<tr>
<td><code>...string</code></td>
<td>Any amount of data required to complete authentication</td>
</tr>
</table>
| Type | Description |
|:-----------:| ----------- |
| `...string` | Any amount of data required to complete authentication. |
#### Original Sock Chat phpBB format
Although the specification was never strict on the format, the original client and server combo included only a single authentication extension for phpBB.
Flashii Hajime, Sakura and early Misuzu also used this same format.
| Type | Description |
|:--------:| ----------- |
| `string` | User ID of the authenticating user. |
| `string` | Session token for the authenticating user. |
#### Flashii Chat format
In order to support multiple authentication methods more easily, Flashii Chat currently uses a format similar to the HTTP Authorization header, where the first field is the authentication method and the second field is the authentication token.
Previously, this was achieved using prefixes to the session token part of phpBB format.
| Type | Description |
|:--------:| ----------- |
| `string` | Authentication method, for example `Bearer` for an OAuth2 bearer token, or `Misuzu` for using the `msz_auth` cookie value. |
| `string` | Authentication token, for example the OAuth2 bearer token value, or the Misuzu authentication cookie. |
### Packet `2`: Message
@ -106,16 +105,10 @@ Informs the server that the user has sent a message.
Commands are described lower in the document.
<table>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message text, may not contain <code>\t</code></td>
</tr>
</table>
| Type | Description |
|:--------:| ----------- |
| `string` | User ID of the current user. |
| `string` | Message text, cannot contain a tab character `\t`. |
## Server Packets
@ -124,12 +117,9 @@ These are the packets sent from the server to the client.
### Packet `0`: Pong
Response to client packet `0`: Ping.
<table>
<tr>
<td><code>string</code></td>
<td>Any arbitrary string, do not rely on the data sent here. Original Sock Chat server sent <code>pong</code>, SharpChat currently sends the current Unix epoch timestamp in seconds.</td>
</tr>
</table>
| Type | Description |
|:--------:| ----------- |
| `string` | A string containing `pong`. Previously SharpChat sent the current Unix timestamp here, but has since been updated to conform to the Original Sock Chat server formatting. |
### Packet `1`: Join/Auth
@ -138,532 +128,255 @@ While authenticated this packet indicates that a new user has joined the server/
#### Successful authentication response
Informs the client that authentication has succeeded.
<table>
<tr>
<td><code>string</code></td>
<td><code>y</code></td>
</tr>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Username</td>
</tr>
<tr>
<td><code>color</code></td>
<td>Username color</td>
</tr>
<tr>
<td><code>user permissions</code></td>
<td>User permissions</td>
</tr>
<tr>
<td><code>channel name</code></td>
<td>Default channel the user will join following this packet</td>
</tr>
<tr>
<td><code>int</code></td>
<td>Maximum length for messages sent by the client</td>
</tr>
</table>
| Type | Description |
|:--------------:| ----------- |
| `string` | `y` to indicate a successful authentication attempt. |
| `string` | User ID of the authenticated user. |
| `user name` | User name of the authenticated user. |
| `colour` | User colour of the authenticated user. |
| `user perms` | Permissions for the authenticated user. |
| `channel name` | Default channel the user will join following this packet. |
| `int` | Maximum length for messages sent by the client. NOTE: While the original PHP server did not send this data, the bundled client did look for it. |
#### Failed authentication response
Informs the client that authentication has failed.
<table>
<tr>
<td><code>string</code></td>
<td><code>n</code></td>
</tr>
<tr>
<td><code>string</code></td>
<td>
Reason for failure.
<ul>
<li><code>authfail</code>: Authentication data is invalid.</li>
<li><code>userfail</code>: Username in use.</li>
<li><code>sockfail</code>: A connection has already been started (used to cap maximum connections to 5 in SharpChat).</li>
<li><code>joinfail</code>: User attempting to authenticate is banned.</li>
</ul>
</td>
</tr>
<tr>
<td><code>timestamp</code></td>
<td>If <code>joinfail</code> this contains expiration time of the ban, otherwise it is empty.</td>
</tr>
</table>
| Type | Description |
|:-----------:| ----------- |
| `string` | `n` to indicate a failed authentication attempt. |
| `string` | A reason string indicating the reason why the authentication attempt failed, options are listed below. |
| `timestamp` | If included, indicates a ban expiration timestamp. Usually paired with the `joinfail` reason string. |
##### Reason strings
| Reason | Explanation |
| ---------- | ----------- |
| `authfail` | Provided authentication data cannot be verified with an existing user or session. |
| `joinfail` | Authentication data was correct, but the user is banned. The third field will contain an expiration timestamp. |
| `sockfail` | Current socket connection is already authenticated. PHP Chat did not allow the same user account to be in chat more than once. SharpChat allows simultaneous connections, so this is used to put a cap on the maximum amount of connections a single user may have. As of writing this, the maximum number is `5`, however this may change in the future. |
| `userfail` | A user with the same user name is already in chat. PHP Chat did not allow the same user account to be in chat more than once. SharpChat allows simultaneous connections, so this is used as a generic fallback reason in that implementation. If it occurs, a critical error happened on the backend. |
#### User joining
Informs the client that a user has joined.
<table>
<tr>
<td><code>timestamp</code></td>
<td>Time the user joined</td>
</tr>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Username</td>
</tr>
<tr>
<td><code>colour</code></td>
<td>Username color</td>
</tr>
<tr>
<td><code>user permissions</code></td>
<td>User permissions</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message ID</td>
</tr>
</table>
| Type | Description |
|:------------:| ----------- |
| `timestamp` | Timestamp at which the user joined. |
| `string` | User ID of the joining user. |
| `user name` | User name of the joining user. |
| `colour` | User colour for the joining user. |
| `user perms` | Permissions for the joining user. |
| `string` | Message ID associated with this event. |
### Packet `2`: Chat message
Informs the client that a chat message has been received.
<table>
<tr>
<td><code>timestamp</code></td>
<td>Time when the message was received by the server</td>
</tr>
<tr>
<td><code>string</code></td>
<td>
User ID.
If <code>-1</code> this message is an informational message from the server and the next field takes on a special form.
</td>
</tr>
<tr>
<td><code>string</code></td>
<td>
<p>Message, sanitised by the server</p>
<p>
If this is an informational message this field is formatted as follows and concatenated by the form feed character <code>\f</code>, respresented in ASCII by <code>0x0C</code>. Bot message formats are described lower in the document.
<table>
<tr>
<td><code>int</code></td>
<td>
Message type.
<ul>
<li><code>0</code> for a normal informational message.</li>
<li><code>1</code> for an error.</li>
</ul>
</td>
</tr>
<tr>
<td><code>string</code></td>
<td>An id of a string in the legacy language files.</td>
</tr>
<tr>
<td><code>...string</code></td>
<td>Any number of parameters used to format the language string.</td>
</tr>
</table>
</p>
</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message ID</td>
</tr>
<tr>
<td><code>message flags</code></td>
<td>Message flags</td>
</tr>
</table>
| Type | Description |
|:---------------:| ----------- |
| `timestamp` | Timestamp at which the message was received by the server. |
| `string` | User ID of the author. If `-1` the message body will contain a formatted informational message documented below. |
| `string` | Message body. If this isn't an informational message, it is sanitised by the server: `<`, `>` and `\n` are replaced by `&lt;`, `&gt;` and ` <br/> `. SharpChat also replaces `\t` with four sequential space characters. |
| `string` | Message ID. |
| `message flags` | Message flags. |
#### Informational message formatting
If this is an informational message this field is formatted as follows and concatenated by the form feed character `\f`, respresented in ASCII by `0x0C`.
Bot message formats are described lower in the document.
| Type | Description |
|:-----------:| ----------- |
| `bool` | If `1`, this message should be displayed as an error message. If `0`, it should be displayed as a normal informational message. |
| `string` | Informational message format name. List of formats is described below. |
| `...string` | Any number of parameters required by the format string. |
### Packet `3`: User disconnect
Informs the client that a user has disconnected.
<table>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Username</td>
</tr>
<tr>
<td><code>string</code></td>
<td>
One of four disconnect reasons.
<ul>
<li><code>leave</code>: The user gracefully left, e.g. "x logged out".</li>
<li><code>timeout</code>: The user lost connection unexpectedly, e.g. "x timed out".</li>
<li><code>kick</code>: The user has been kicked, e.g. "x has been kicked".</li>
<li><code>flood</code>: The user has been kicked for exceeding the flood protection limit, e.g. "x has been kicked for spam".</li>
</ul>
</td>
</tr>
<tr>
<td><code>timestamp</code></td>
<td>Time when the user disconnected</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message ID</td>
</tr>
</table>
| Type | Description |
|:-----------:| ----------- |
| `string` | User ID of the disconnecting user. |
| `user name` | User name of the disconnecting user. |
| `string` | Reason for disconnecting, described below. |
| `timestamp` | Timestamp when the user disconnected. |
| `string` | Message ID associated with this event. |
#### Disconnect reasons
| Reason | Explanation |
| --------- | ----------- |
| `leave` | User gracefully left, e.g. "xyz logged out". |
| `kick` | User got banned, kicked or otherwise unvoluntarily had their session terminated, e.g. "xyz has been kicked". |
| `flood` | User got kicked for exceeding the flood protection limit, e.g. "xyz has been kicked for spam". |
| `timeout` | User lost connection unexpectedly after not sending pings in a timely fashion, e.g. "xyz timed out". The original PHP Sock Chat implementation did not support this and was added later by SharpChat. Support could be added by modifying the language file, so we're letting this slide. |
### Packet `4`: Channel event
This packet informs the user about channel related updates. The only consistent parameter across sub-packets is the first one described as follows.
<table>
<tr>
<td><code>int</code></td>
<td>
Channel information update event ID.
<ul>
<li><code>0</code>: A channel has been created.</li>
<li><code>1</code>: A channel has been updated.</li>
<li><code>2</code>: A channel has been deleted.</li>
</ul>
</td>
</tr>
</table>
This packet informs the user about channel related updates.
The only consistent parameter across sub-packets is the first one described as follows.
| Type | Description |
|:-----:| -------------- |
| `int` | Sub-packet ID. |
#### Sub-packet `0`: Channel creation
Informs the client that a channel has been created.
<table>
<tr>
<td><code>channel name</code></td>
<td>The name of the new channel</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the channel is password protected</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the channel is temporary, meaning the channel will be deleted after the last person departs</td>
</tr>
</table>
| Type | Description |
|:--------------:| ----------- |
| `channel name` | Name of the new channel. |
| `bool` | Indicates whether the channel is password protected (non-`0`) or not (`0`). |
| `bool` | Indicates whether the channel is temporary (non-`0`) or not (`0`). In the original PHP Chat implementation this would mean a channel gets deleted after the last person departs from it. |
#### Sub-packet `1`: Channel update
Informs the client that details of a channel has changed.
<table>
<tr>
<td><code>channel name</code></td>
<td>Old/current name of the channel</td>
</tr>
<tr>
<td><code>channel name</code></td>
<td>New name of the channel</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the channel is password protected</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the channel is temporary, meaning the channel will be deleted after the last person departs</td>
</tr>
</table>
| Type | Description |
|:--------------:| ----------- |
| `channel name` | Previous name of the channel. |
| `channel name` | New name of the channel. |
| `bool` | Indicates whether the channel is password protected (non-`0`) or not (`0`). |
| `bool` | Indicates whether the channel is temporary (non-`0`) or not (`0`). In the original PHP Chat implementation this would mean a channel gets deleted after the last person departs from it. |
#### Sub-packet `2`: Channel deletion
Informs the client that a channel has been deleted
<table>
<tr>
<td><code>channel name</code></td>
<td>Name of the channel that has been deleted</td>
</tr>
</table>
| Type | Description |
|:--------------:| ----------- |
| `channel name` | Name of the channel to be deleted. |
### Packet `5`: Channel switching
This packet informs the client about channel switching.
<table>
<tr>
<td><code>int</code></td>
<td>
Channel information update event ID.
<ul>
<li><code>0</code>: A user joined the channel. Sent to all users in said channel.</li>
<li><code>1</code>: A user left the channel. Sent to all users in said channel.</li>
<li><code>2</code>: The client is to forcibly join a channel.</li>
</ul>
</td>
</tr>
</table>
| Type | Description |
|:-----:| -------------- |
| `int` | Sub-packet ID. |
#### Sub-packet `0`: Channel join
Informs the client that a user has joined the channel.
<table>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Username</td>
</tr>
<tr>
<td><code>color</code></td>
<td>Username color</td>
</tr>
<tr>
<td><code>user permissions</code></td>
<td>User permissions</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message ID</td>
</tr>
</table>
| Type | Description |
|:------------:| ----------- |
| `string` | User ID of the user joining the channel. |
| `user name` | User name of the user joining the channel. |
| `colour` | User colour of the user joining the channel. |
| `user perms` | Permissions of the user joining the channel. |
| `string` | Message ID associated with this event. |
#### Sub-packet `1`: Channel departure
Informs the client that a user has left the channel.
<table>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message ID</td>
</tr>
</table>
| Type | Description |
|:------------:| ----------- |
| `string` | User ID of the user leaving the channel. |
| `string` | Message ID associated with this event. |
#### Sub-packet `2`: Forced channel switch
Informs the client that it has been forcibly switched to a different channel.
<table>
<tr>
<td><code>channel name</code></td>
<td>The name of the channel that the user has been switched to</td>
</tr>
</table>
| Type | Description |
|:--------------:| ----------- |
| `channel name` | Name of the channel the current user is now present in. |
### Packet `6`: Message deletion
Informs the client that a message has been deleted.
<table>
<tr>
<td><code>string</code></td>
<td>Message ID of the deleted message</td>
</tr>
</table>
| Type | Description |
|:--------:| ----------- |
| `string` | Message ID of the message to be deleted. |
### Packet `7`: Context information
Informs the client about the context of a channel before the client was present.
<table>
<tr>
<td><code>int</code></td>
<td>
Context event ID.
<ul>
<li><code>0</code>: Users present in the current channel.</li>
<li><code>1</code>: A message already in the channel, occurs more than once per channel.</li>
<li><code>2</code>: Channels on the server.</li>
</ul>
</td>
</tr>
</table>
| Type | Description |
|:-----:| -------------- |
| `int` | Sub-packet ID. |
#### Sub-packet `0`: Existing users
Informs the client about users already present in the channel.
<table>
<tr>
<td><code>int</code></td>
<td>Amount of users present in the channel</td>
</tr>
<tr>
<td><code>Context User</code></td>
<td>An amount of repetitions of this object based on the number in the previous param, the object is described below</td>
</tr>
</table>
##### Context User object
<table>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Username</td>
</tr>
<tr>
<td><code>color</code></td>
<td>Username color</td>
</tr>
<tr>
<td><code>user permissions</code></td>
<td>User permissions</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Whether the user should be visible in the users list</td>
</tr>
</table>
| Type | Description |
|:-----:| ----------- |
| `int` | Amount of users present in the current channel. |
| | A repetition of a number of fields based on the number above, described below. |
##### Context user information
| Type | Description |
|:------------:| ----------- |
| `string` | User ID of this already present user. |
| `user name` | User name of this already present user. |
| `colour` | User colour of this already present user. |
| `user perms` | Permissions of this already present user. |
| `bool` | If non-`0` this user should be displayed in a user list, if `0` this user should be treated as invisible. Used by bot mods in the original PHP implementation. |
#### Sub-packet `1`: Existing message
Informs the client about an existing message in a channel.
<table>
<tr>
<td><code>timestamp</code></td>
<td>Time when the message was received by the server</td>
</tr>
<tr>
<td><code>string</code></td>
<td>User ID</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Username</td>
</tr>
<tr>
<td><code>color</code></td>
<td>Username color</td>
</tr>
<tr>
<td><code>user permissions</code></td>
<td>User permissions</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message text, functions the same as described in Packet <code>2</code>: Chat message</td>
</tr>
<tr>
<td><code>string</code></td>
<td>Message ID</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Whether the client should notify the user about this message</td>
</tr>
<tr>
<td><code>message flags</code></td>
<td>Message flags</td>
</tr>
</table>
| Type | Description |
|:---------------:| ----------- |
| `timestamp` | Timestamp at which the message was received by the server. |
| `string` | User ID of the author. If `-1` the message body will contain a formatted informational message documented below. |
| `user name` | User name of the author of this message. |
| `colour` | User colour of the author of this message. |
| `user perms` | Permissions of the author of this message. |
| `string` | Message body. If this isn't an informational message, it is sanitised by the server: `<`, `>` and `\n` are replaced by `&lt;`, `&gt;` and ` <br/> `. SharpChat also replaces `\t` with four sequential space characters. |
| `string` | Message ID. |
| `bool` | If non-`0` this message should trigger notifications (sounds, banners, etc) on the client. |
| `message flags` | Message flags. |
#### Sub-packet `2`: Channels
Informs the client about the channels on the server.
<table>
<tr>
<td><code>int</code></td>
<td>Amount of channels on the channel</td>
</tr>
<tr>
<td><code>Context Channel</code></td>
<td>An amount of repetitions of this object based on the number in the previous param, the object is described below</td>
</tr>
</table>
##### Context Channel object
<table>
<tr>
<td><code>channel name</code></td>
<td>Name of the channel</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the channel is password protected</td>
</tr>
<tr>
<td><code>bool</code></td>
<td>Indicates whether the channel is temporary, meaning the channel will be deleted after the last person departs</td>
</tr>
</table>
| Type | Description |
|:-----:| ----------- |
| `int` | Amount of channels on the server. |
| | A repetition of a number of fields based on the number above, described below. |
##### Context channel information
| Type | Description |
|:--------------:| ----------- |
| `channel name` | Name of the new channel. |
| `bool` | Indicates whether the channel is password protected (non-`0`) or not (`0`). |
| `bool` | Indicates whether the channel is temporary (non-`0`) or not (`0`). In the original PHP Chat implementation this would mean a channel gets deleted after the last person departs from it. |
### Packet `8`: Context clearing
Informs the client that the context has been cleared.
This packet can be safely ignored, its behaviour can be implicitly handled when required according to the table below.
<table>
<tr>
<td><code>int</code></td>
<td>
Number indicating what has been cleared.
<ul>
<li><code>0</code>: The message list has been cleared.</li>
<li><code>1</code>: The user list has been cleared.</li>
<li><code>2</code>: The channel list has been cleared.</li>
<li><code>3</code>: Both the message and user listing have been cleared.</li>
<li><code>4</code>: The message, user and channel listing have all been cleared.</li>
</ul>
</td>
</tr>
</table>
| Type | Description |
|:-----:| ----------- |
| `int` | Number indicating what data should be cleared. Modes are described below. |
#### Context clear modes
| Mode | Explanation |
|:----:| ----------- |
| `0` | Only the message history should be cleared. Does not normally occur. |
| `1` | Only the user list should be cleared. Does not normally occur. |
| `2` | Only the channel list should be cleared. Does not normally occur. |
| `3` | Clear the message history and user list. Occurs upon switching (or being switched) to another channel. |
| `4` | Clear the message history, user list and channel list. Does not normally occur. |
### Packet `9`: Forced disconnect
Informs the client that they have either been banned or kicked from the server.
<table>
<tr>
<td><code>bool</code></td>
<td>
<ul>
<li><code>0</code>: The client has been kicked, can reconnect immediately.</li>
<li><code>1</code>: The client has been banned, can reconnect after the timestamp (documented below) in the next param has expired.</li>
</ul>
</td>
</tr>
<tr>
<td><code>timestamp</code></td>
<td>Ban expiration time, not present when the first argument is <code>0</code>.</td>
</tr>
</table>
| Type | Description |
|:-----------:| ----------- |
| `bool` | If non-`0`, the next field will be defined and contain an expiration time. If `0`, the user can rejoin immediately. |
| `timestamp` | Ban expiration timestamp, present when the first field is non-`0`. |
### Packet `10`: User update
Informs that another user's details have been updated.
<table>
<tr>
<td><code>string</code></td>
<td>User ID of the affected user</td>
</tr>
<tr>
<td><code>string</code></td>
<td>New username</td>
</tr>
<tr>
<td><code>color</code></td>
<td>New username color</td>
</tr>
<tr>
<td><code>user permissions</code></td>
<td>User permissions</td>
</tr>
</table>
| Type | Description |
|:------------:| ----------- |
| `string` | User ID of the updated user. |
| `user name` | New user name of the updated user. |
| `colour` | New user colour for the updated user. |
| `user perms` | New permissions for the updated user. |
## Bot Messages

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

@ -1,5 +1,4 @@
using System;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace SharpChat.Misuzu {
public class MisuzuAuthInfo {
@ -13,18 +12,18 @@ namespace SharpChat.Misuzu {
public long UserId { get; set; }
[JsonPropertyName("username")]
public string UserName { get; set; }
public string? UserName { get; set; }
[JsonPropertyName("colour_raw")]
public int ColourRaw { get; set; }
public ChatColour Colour => ChatColour.FromMisuzu(ColourRaw);
public Colour Colour => Colour.FromMisuzu(ColourRaw);
[JsonPropertyName("hierarchy")]
public int Rank { get; set; }
[JsonPropertyName("perms")]
public ChatUserPermissions Permissions { get; set; }
public UserPermissions Permissions { get; set; }
[JsonPropertyName("super")]
public bool IsSuper { get; set; }

View file

@ -7,10 +7,10 @@ namespace SharpChat.Misuzu {
public bool IsBanned { get; set; }
[JsonPropertyName("user_id")]
public string UserId { get; set; }
public string? UserId { get; set; }
[JsonPropertyName("ip_addr")]
public string RemoteAddress { get; set; }
public string? RemoteAddress { get; set; }
[JsonPropertyName("is_perma")]
public bool IsPermanent { get; set; }
@ -20,13 +20,13 @@ namespace SharpChat.Misuzu {
// only populated in list request
[JsonPropertyName("user_name")]
public string UserName { get; set; }
public string? UserName { get; set; }
[JsonPropertyName("user_colour")]
public int UserColourRaw { get; set; }
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
public ChatColour UserColour => ChatColour.FromMisuzu(UserColourRaw);
public Colour UserColour => Colour.FromMisuzu(UserColourRaw);
}
}

View file

@ -34,9 +34,7 @@ namespace SharpChat.Misuzu {
private CachedValue<string> SecretKey { get; }
public MisuzuClient(HttpClient httpClient, IConfig config) {
if(config == null)
throw new ArgumentNullException(nameof(config));
HttpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
HttpClient = httpClient;
BaseURL = config.ReadCached("url", DEFAULT_BASE_URL);
SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY);
@ -47,11 +45,11 @@ namespace SharpChat.Misuzu {
}
public string CreateBufferSignature(byte[] bytes) {
using HMACSHA256 algo = new(Encoding.UTF8.GetBytes(SecretKey));
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) {
public async Task<MisuzuAuthInfo?> AuthVerifyAsync(string method, string token, string ipAddr) {
method ??= string.Empty;
token ??= string.Empty;
ipAddr ??= string.Empty;
@ -77,8 +75,6 @@ namespace SharpChat.Misuzu {
}
public async Task BumpUsersOnlineAsync(IEnumerable<(string userId, string ipAddr)> list) {
if(list == null)
throw new ArgumentNullException(nameof(list));
if(!list.Any())
return;
@ -114,7 +110,11 @@ namespace SharpChat.Misuzu {
}
}
public async Task<MisuzuBanInfo> CheckBanAsync(string userId = null, string ipAddr = null, bool userIdIsName = false) {
public async Task<MisuzuBanInfo?> CheckBanAsync(
string? userId = null,
string? ipAddr = null,
bool userIdIsName = false
) {
userId ??= string.Empty;
ipAddr ??= string.Empty;
@ -136,7 +136,7 @@ namespace SharpChat.Misuzu {
);
}
public async Task<MisuzuBanInfo[]> GetBanListAsync() {
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);
@ -160,9 +160,6 @@ namespace SharpChat.Misuzu {
}
public async Task<bool> RevokeBanAsync(MisuzuBanInfo banInfo, BanRevokeKind kind) {
if(banInfo == null)
throw new ArgumentNullException(nameof(banInfo));
string type = kind switch {
BanRevokeKind.UserId => "user",
BanRevokeKind.RemoteAddress => "addr",
@ -170,8 +167,8 @@ namespace SharpChat.Misuzu {
};
string target = kind switch {
BanRevokeKind.UserId => banInfo.UserId,
BanRevokeKind.RemoteAddress => banInfo.RemoteAddress,
BanRevokeKind.UserId => banInfo?.UserId ?? string.Empty,
BanRevokeKind.RemoteAddress => banInfo?.RemoteAddress ?? string.Empty,
_ => string.Empty,
};
@ -204,9 +201,9 @@ namespace SharpChat.Misuzu {
string reason
) {
if(string.IsNullOrWhiteSpace(targetAddr))
throw new ArgumentNullException(nameof(targetAddr));
throw new ArgumentException("targetAddr may not be empty", nameof(targetAddr));
if(string.IsNullOrWhiteSpace(modAddr))
throw new ArgumentNullException(nameof(modAddr));
throw new ArgumentException("modAddr may not be empty", nameof(modAddr));
if(duration <= TimeSpan.Zero)
return;

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

@ -1,17 +1,17 @@
using SharpChat.Packet;
using SharpChat.Events;
using System.Linq;
namespace SharpChat.Commands {
public class AFKCommand : IChatCommand {
namespace SharpChat.SockChat.Commands {
public class UserAFKCommand : ISockChatClientCommand {
private const string DEFAULT = "AFK";
private const int MAX_LENGTH = 5;
public bool IsMatch(ChatCommandContext ctx) {
public bool IsMatch(SockChatClientCommandContext ctx) {
return ctx.NameEquals("afk");
}
public void Dispatch(ChatCommandContext ctx) {
string statusText = ctx.Args.FirstOrDefault();
public void Dispatch(SockChatClientCommandContext ctx) {
string? statusText = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(statusText))
statusText = DEFAULT;
else {
@ -20,10 +20,10 @@ namespace SharpChat.Commands {
statusText = statusText[..MAX_LENGTH].Trim();
}
ctx.Chat.UpdateUser(
ctx.Chat.Events.Dispatch(
"user:status",
ctx.User,
status: ChatUserStatus.Away,
statusText: statusText
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
);
}
}
}

View file

@ -0,0 +1,28 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserConnectLogS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string UserName;
public UserConnectLogS2CPacket(
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\fjoin\f{1}\t{2}\t0\t10010",
TimeStamp.ToUnixTimeSeconds(),
UserName,
MessageId
);
}
}
}

View file

@ -0,0 +1,49 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserConnectS2CPacket : 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;
public UserConnectS2CPacket(
long messageId,
DateTimeOffset timeStamp,
long userId,
string userName,
Colour userColour,
int userRank,
UserPermissions userPerms
) {
MessageId = messageId;
TimeStamp = timeStamp;
UserId = userId;
UserName = userName;
UserColour = userColour;
UserRank = userRank;
UserPerms = userPerms;
}
public string Pack() {
return string.Format(
"1\t{0}\t{1}\t{2}\t{3}\t{4} {5} {6} {7} {8}\t{9}",
TimeStamp.ToUnixTimeSeconds(),
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,38 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserDisconnectLogS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string UserName;
private readonly UserDisconnectReason Reason;
public UserDisconnectLogS2CPacket(
long messageId,
DateTimeOffset timeStamp,
string userName,
UserDisconnectReason reason
) {
MessageId = messageId;
TimeStamp = timeStamp;
UserName = userName;
Reason = reason;
}
public string Pack() {
return string.Format(
"7\t1\t{0}\t-1\tChatBot\tinherit\t\t0\f{1}\f{2}\t{3}\t0\t10010",
TimeStamp.ToUnixTimeSeconds(),
Reason switch {
UserDisconnectReason.Leave => "leave",
UserDisconnectReason.TimeOut => "timeout",
UserDisconnectReason.Kicked => "kick",
UserDisconnectReason.Flood => "flood",
_ => "leave",
},
UserName,
MessageId
);
}
}
}

View file

@ -0,0 +1,42 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserDisconnectS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly long UserId;
private readonly string UserName;
private readonly UserDisconnectReason Reason;
public UserDisconnectS2CPacket(
long messageId,
DateTimeOffset timeStamp,
long userId,
string userName,
UserDisconnectReason reason
) {
MessageId = messageId;
TimeStamp = timeStamp;
UserId = userId;
UserName = userName;
Reason = reason;
}
public string Pack() {
return string.Format(
"3\t{0}\t{1}\t{2}\t{3}\t{4}",
UserId,
UserName,
Reason switch {
UserDisconnectReason.Leave => "leave",
UserDisconnectReason.TimeOut => "timeout",
UserDisconnectReason.Kicked => "kick",
UserDisconnectReason.Flood => "flood",
_ => "leave",
},
TimeStamp.ToUnixTimeSeconds(),
MessageId
);
}
}
}

View file

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

View file

@ -0,0 +1,32 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserNickChangeLogS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string PrevName;
private readonly string NewName;
public UserNickChangeLogS2CPacket(
long messageId,
DateTimeOffset timeStamp,
string prevName,
string newName
) {
MessageId = messageId;
TimeStamp = timeStamp;
PrevName = prevName;
NewName = newName;
}
public string Pack() {
return string.Format(
"7\t1\t{0}\t-1\tChatBot\tinherit\t\t0\fnick\f{1}\f{2}\t{3}\t0\t10010",
TimeStamp.ToUnixTimeSeconds(),
PrevName,
NewName,
MessageId
);
}
}
}

View file

@ -0,0 +1,32 @@
using System;
namespace SharpChat.SockChat.PacketsS2C {
public class UserNickChangeS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string PrevName;
private readonly string NewName;
public UserNickChangeS2CPacket(
long messageId,
DateTimeOffset timeStamp,
string prevName,
string newName
) {
MessageId = messageId;
TimeStamp = timeStamp;
PrevName = prevName;
NewName = newName;
}
public string Pack() {
return string.Format(
"2\t{0}\t-1\t0\fnick\f{1}\f{2}\t{3}\t10010",
TimeStamp.ToUnixTimeSeconds(),
PrevName,
NewName,
MessageId
);
}
}
}

View file

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

View file

@ -0,0 +1,39 @@
namespace SharpChat.SockChat.PacketsS2C {
public class UserUpdateS2CPacket : ISockChatS2CPacket {
private readonly long UserId;
private readonly string UserName;
private readonly Colour UserColour;
private readonly int UserRank;
private readonly UserPermissions UserPerms;
public UserUpdateS2CPacket(
long userId,
string userName,
Colour userColour,
int userRank,
UserPermissions userPerms
) {
UserId = userId;
UserName = userName;
UserColour = userColour;
UserRank = userRank;
UserPerms = userPerms;
}
public string Pack() {
return string.Format(
"10\t{0}\t{1}\t{2}\t{3} {4} {5} {6} {7}",
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
);
}
}
}

View file

@ -0,0 +1,37 @@
using System.Text;
namespace SharpChat.SockChat.PacketsS2C {
public class UsersPopulateS2CPacket : ISockChatS2CPacket {
public record ListEntry(long Id, string Name, Colour Colour, int Rank, UserPermissions Perms, bool Visible);
private readonly ListEntry[] Entries;
public UsersPopulateS2CPacket(ListEntry[] entries) {
Entries = entries;
}
public string Pack() {
StringBuilder sb = new();
sb.AppendFormat("7\t0\t{0}", Entries.Length);
foreach(ListEntry entry in Entries)
sb.AppendFormat(
"\t{0}\t{1}\t{2}\t{3} {4} {5} {6} {7}\t{8}",
entry.Id,
entry.Name,
entry.Colour,
entry.Rank,
entry.Perms.HasFlag(UserPermissions.KickUser) ? 1 : 0,
entry.Perms.HasFlag(UserPermissions.ViewLogs) ? 1 : 0,
entry.Perms.HasFlag(UserPermissions.SetOwnNickname) ? 1 : 0,
entry.Perms.HasFlag(UserPermissions.CreateChannel) ? (
entry.Perms.HasFlag(UserPermissions.SetChannelPermanent) ? 2 : 1
) : 0,
entry.Visible ? 1 : 0
);
return sb.ToString();
}
}
}

View file

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

View file

@ -0,0 +1,46 @@
using System;
using System.Text;
namespace SharpChat.SockChat.PacketsS2C {
public class WhoChannelResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string ChannelName;
private readonly string[] Users;
private readonly string SelfName;
public WhoChannelResponseS2CPacket(string channelName, string[] users, string selfName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
ChannelName = channelName;
Users = users;
SelfName = selfName;
}
public string Pack() {
StringBuilder sb = new();
sb.AppendFormat(
"2\t{0}\t-1\t0\fwhochan\f{1}\f",
TimeStamp.ToUnixTimeSeconds(),
SockChatUtility.SanitiseChannelName(ChannelName)
);
foreach(string userName in Users) {
sb.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(userName.Equals(SelfName, StringComparison.InvariantCultureIgnoreCase))
sb.Append(@" style=""font-weight: bold;""");
sb.AppendFormat(@">{0}</a>, ", userName);
}
if(Users.Length > 0)
sb.Length -= 2;
sb.AppendFormat("\t{0}\t10010", MessageId);
return sb.ToString();
}
}
}

View file

@ -0,0 +1,40 @@
using System;
using System.Text;
namespace SharpChat.SockChat.PacketsS2C {
public class WhoServerResponseS2CPacket : ISockChatS2CPacket {
private readonly long MessageId;
private readonly DateTimeOffset TimeStamp;
private readonly string[] Users;
private readonly string SelfName;
public WhoServerResponseS2CPacket(string[] users, string selfName) {
MessageId = SharpId.Next();
TimeStamp = DateTimeOffset.UtcNow;
Users = users;
SelfName = selfName;
}
public string Pack() {
StringBuilder sb = new();
sb.AppendFormat("2\t{0}\t-1\t0\fwho\f", TimeStamp.ToUnixTimeSeconds());
foreach(string userName in Users) {
sb.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(userName.Equals(SelfName, StringComparison.InvariantCultureIgnoreCase))
sb.Append(@" style=""font-weight: bold;""");
sb.AppendFormat(@">{0}</a>, ", userName);
}
if(Users.Length > 0)
sb.Length -= 2;
sb.AppendFormat("\t{0}\t10010", MessageId);
return sb.ToString();
}
}
}

View file

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

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Fleck" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SharpChat.Misuzu\SharpChat.Misuzu.csproj" />
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,43 @@
using Fleck;
using SharpChat.SockChat.PacketsS2C;
using System.Net;
namespace SharpChat.SockChat {
public class SockChatConnectionInfo : ConnectionInfo {
public IWebSocketConnection Socket { get; }
public SockChatConnectionInfo(
IWebSocketConnection socket,
IPAddress remoteAddr,
ushort remotePort
) : base(remoteAddr, remotePort) {
Socket = socket;
}
public static SockChatConnectionInfo Create(IWebSocketConnection socket) {
IPAddress remoteAddr = IPAddress.Parse(socket.ConnectionInfo.ClientIpAddress);
if(IPAddress.IsLoopback(remoteAddr)
&& socket.ConnectionInfo.Headers.ContainsKey("X-Real-IP")
&& IPAddress.TryParse(socket.ConnectionInfo.Headers["X-Real-IP"], out IPAddress? realAddr))
remoteAddr = realAddr;
return new SockChatConnectionInfo(socket, remoteAddr, (ushort)socket.ConnectionInfo.ClientPort);
}
public void Send(string packet) {
if(Socket.IsAvailable)
Socket.Send(packet).Wait();
}
public void Send(ISockChatS2CPacket packet) {
string data = packet.Pack();
if(!string.IsNullOrWhiteSpace(data))
Send(data);
}
public void Close(int code) {
Socket.Close(code);
}
}
}

View file

@ -0,0 +1,565 @@
using SharpChat.Events;
using SharpChat.EventStorage;
using SharpChat.SockChat;
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
namespace SharpChat {
public class SockChatContext : IChatEventHandler {
public readonly SemaphoreSlim ContextAccess = new(1, 1);
public IEventStorage EventStorage { get; }
public ChatEventDispatcher Events { get; } = new();
public ConnectionsContext Connections { get; } = new();
public ChannelsContext Channels { get; } = new();
public UsersContext Users { get; } = new();
public UserStatusContext UserStatuses { get; } = new();
public ChannelsUsersContext ChannelsUsers { get; } = new();
public Dictionary<long, RateLimiter> UserRateLimiters { get; } = new();
public SockChatContext(IEventStorage evtStore) {
EventStorage = evtStore;
Events.Subscribe(evtStore);
Events.Subscribe(this);
}
public void HandleEvent(ChatEventInfo info) {
UserStatusInfo userStatusInfo = UserStatuses.Get(info.SenderId);
if(!string.IsNullOrWhiteSpace(info.ChannelName))
ChannelsUsers.SetUserLastChannel(info.SenderId, info.ChannelName);
// TODO: should user:connect and user:disconnect be channel agnostic?
switch(info.Type) {
case "user:add":
Users.Add(new UserInfo(
info.SenderId,
info.SenderName,
info.SenderColour,
info.SenderRank,
info.SenderPerms,
info.SenderNickName,
info.Data is UserAddEventData uaData && uaData.IsSuper
));
break;
case "user:delete":
UserStatuses.Clear(info.SenderId);
Users.Remove(info.SenderId);
break;
case "user:connect":
if(info.Data is not UserConnectEventData ucData || !ucData.Notify)
break;
SendTo(info.ChannelName, new UserConnectS2CPacket(
info.Id,
info.Created,
info.SenderId,
SockChatUtility.GetUserName(info, userStatusInfo),
info.SenderColour,
info.SenderRank,
info.SenderPerms
));
ChannelsUsers.Join(info.ChannelName, info.SenderId);
break;
case "user:disconnect":
ChannelInfo[] channels = Channels.GetMany(ChannelsUsers.GetUserChannelNames(info.SenderId));
ChannelsUsers.DeleteUser(info.SenderId);
if(channels.Length > 0) {
UserDisconnectS2CPacket udPacket = new(
info.Id,
info.Created,
info.SenderId,
SockChatUtility.GetUserName(info, userStatusInfo),
info.Data is UserDisconnectEventData userDisconnect ? userDisconnect.Reason : UserDisconnectReason.Leave
);
foreach(ChannelInfo chan in channels) {
if(chan.IsTemporary && chan.OwnerId == info.SenderId)
Events.Dispatch("chan:delete", chan.Name, info);
else
SendTo(chan, udPacket);
}
}
break;
case "user:status":
if(info.Data is not UserStatusUpdateEventData userStatusUpdate)
break;
if(userStatusInfo.Status == userStatusUpdate.Status
&& userStatusInfo.Text.Equals(userStatusUpdate.Text))
break;
userStatusInfo = UserStatuses.Set(
info.SenderId,
userStatusUpdate.Status,
userStatusUpdate.Text ?? string.Empty
);
SendToUserChannels(info.SenderId, new UserUpdateS2CPacket(
info.SenderId,
SockChatUtility.GetUserName(info, userStatusInfo),
info.SenderColour,
info.SenderRank,
info.SenderPerms
));
break;
case "user:update":
if(info.Data is not UserUpdateEventData userUpdate)
break;
UserInfo? uuUserInfo = Users.Get(info.SenderId);
if(uuUserInfo is null)
break;
bool uuHasChanged = false;
string? uuPrevName = null;
if(userUpdate.Name != null && !userUpdate.Name.Equals(uuUserInfo.UserName)) {
uuUserInfo.UserName = userUpdate.Name;
uuHasChanged = true;
}
if(userUpdate.NickName != null && !userUpdate.NickName.Equals(uuUserInfo.NickName)) {
if(userUpdate.Notify)
uuPrevName = string.IsNullOrWhiteSpace(uuUserInfo.NickName) ? uuUserInfo.UserName : uuUserInfo.NickName;
uuUserInfo.NickName = userUpdate.NickName;
uuHasChanged = true;
}
if(userUpdate.Colour.HasValue && userUpdate.Colour != uuUserInfo.Colour.ToMisuzu()) {
uuUserInfo.Colour = Colour.FromMisuzu(userUpdate.Colour.Value);
uuHasChanged = true;
}
if(userUpdate.Rank != null && userUpdate.Rank != uuUserInfo.Rank) {
uuUserInfo.Rank = userUpdate.Rank.Value;
uuHasChanged = true;
}
if(userUpdate.Perms.HasValue && userUpdate.Perms != uuUserInfo.Permissions) {
uuUserInfo.Permissions = userUpdate.Perms.Value;
uuHasChanged = true;
}
if(userUpdate.IsSuper.HasValue && userUpdate.IsSuper != uuUserInfo.IsSuper)
uuUserInfo.IsSuper = userUpdate.IsSuper.Value;
if(uuHasChanged) {
if(uuPrevName != null)
SendToUserChannels(info.SenderId, new UserNickChangeS2CPacket(
info.Id,
info.Created,
string.IsNullOrWhiteSpace(info.SenderNickName) ? uuPrevName : $"~{info.SenderNickName}",
SockChatUtility.GetUserName(uuUserInfo, userStatusInfo)
));
SendToUserChannels(info.SenderId, new UserUpdateS2CPacket(
uuUserInfo.UserId,
SockChatUtility.GetUserName(uuUserInfo, userStatusInfo),
uuUserInfo.Colour,
uuUserInfo.Rank,
uuUserInfo.Permissions
));
}
break;
case "user:kickban":
if(info.Data is not UserKickBanEventData userBaka)
break;
SendTo(info.SenderId, new ForceDisconnectS2CPacket(userBaka.Expires));
ConnectionInfo[] conns = Connections.GetUser(info.SenderId);
foreach(ConnectionInfo conn in conns) {
Connections.Remove(conn);
if(conn is SockChatConnectionInfo scConn)
scConn.Close(1000);
Logger.Write($"<{conn.RemoteEndPoint}> Nuked connection from banned user #{conn.UserId}.");
}
string bakaChannelName = ChannelsUsers.GetUserLastChannel(info.SenderId);
if(!string.IsNullOrWhiteSpace(bakaChannelName))
Events.Dispatch(new ChatEventInfo(
SharpId.Next(),
"user:disconnect",
info.Created,
bakaChannelName,
info.SenderId,
info.SenderName,
info.SenderColour,
info.SenderRank,
info.SenderNickName,
info.SenderPerms,
new UserDisconnectEventData(userBaka.Reason)
));
break;
case "chan:join":
HandleUserChannelJoin(
info.ChannelName,
new UserInfo(info), // kinda stinky
userStatusInfo
);
break;
case "chan:leave":
ChannelsUsers.Leave(info.ChannelName, info.SenderId);
SendTo(info.ChannelName, new UserChannelLeaveS2CPacket(info.SenderId));
ChannelInfo? clChannelInfo = Channels.Get(info.ChannelName);
if(clChannelInfo?.IsTemporary == true && clChannelInfo.OwnerId == info.SenderId)
Events.Dispatch("chan:delete", clChannelInfo.Name, info);
break;
case "chan:add":
if(info.Data is not ChannelAddEventData caData)
break;
ChannelInfo caChannelInfo = new(
info.ChannelName,
caData.Password,
caData.IsTemporary,
caData.MinRank,
info.SenderId
);
Channels.Add(caChannelInfo);
foreach(UserInfo ccu in Users.GetMany(minRank: caChannelInfo.Rank))
SendTo(ccu, new ChannelCreateS2CPacket(
caChannelInfo.Name,
caChannelInfo.HasPassword,
caChannelInfo.IsTemporary
));
break;
case "chan:update":
if(info.Data is not ChannelUpdateEventData cuData)
break;
ChannelInfo? cuChannelInfo = Channels.Get(info.ChannelName);
if(cuChannelInfo is null)
break;
string cuChannelName = cuChannelInfo.Name;
if(!string.IsNullOrEmpty(cuData.Name))
cuChannelInfo.Name = cuData.Name;
if(cuData.MinRank.HasValue)
cuChannelInfo.Rank = cuData.MinRank.Value;
if(cuData.Password != null) // this should probably be hashed
cuChannelInfo.Password = cuData.Password;
if(cuData.IsTemporary.HasValue)
cuChannelInfo.IsTemporary = cuData.IsTemporary.Value;
bool nameChanged = !cuChannelName.Equals(cuChannelInfo.Name, StringComparison.InvariantCultureIgnoreCase);
if(nameChanged)
ChannelsUsers.RenameChannel(cuChannelName, cuChannelInfo.Name);
// TODO: Users that no longer have access to the channel/gained access to the channel by the rank change should receive delete and create packets respectively
// the server currently doesn't keep track of what channels a user is already aware of so can't really simulate this yet.
foreach(UserInfo user in Users.GetMany(minRank: cuChannelInfo.Rank))
SendTo(user, new ChannelUpdateS2CPacket(
cuChannelName,
cuChannelInfo.Name,
cuChannelInfo.HasPassword,
cuChannelInfo.IsTemporary
));
if(nameChanged)
SendTo(cuChannelInfo, new UserChannelForceJoinS2CPacket(cuChannelInfo.Name));
break;
case "chan:delete":
ChannelInfo? cdTargetChannelInfo = Channels.Get(info.ChannelName);
ChannelInfo? cdMainChannelInfo = Channels.MainChannel;
if(cdTargetChannelInfo == null || cdMainChannelInfo == null || cdTargetChannelInfo == Channels.MainChannel)
break;
// Remove channel from the listing
Channels.Remove(info.ChannelName);
// Move all users back to the main channel
UserInfo[] cdUserInfos = Users.GetMany(ChannelsUsers.GetChannelUserIds(info.ChannelName));
ChannelsUsers.DeleteChannel(cdMainChannelInfo);
foreach(UserInfo userInfo in cdUserInfos)
HandleUserChannelJoin(
info.ChannelName,
userInfo,
UserStatuses.Get(userInfo)
);
// Broadcast deletion of channel
foreach(UserInfo user in Users.GetMany(minRank: cdTargetChannelInfo.Rank))
SendTo(user, new ChannelDeleteS2CPacket(cdTargetChannelInfo.Name));
break;
case "msg:delete":
if(info.Data is not MessageDeleteEventData msgDelete)
break;
MessageDeleteS2CPacket msgDelPacket = new(msgDelete.MessageId);
if(info.IsBroadcast) {
Send(msgDelPacket);
} else if(info.ChannelName.StartsWith('@')) {
long[] targetIds = info.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1).ToArray();
if(targetIds.Length != 2)
return;
UserInfo[] users = Users.GetMany(targetIds);
UserInfo? target = users.FirstOrDefault(u => u.UserId != info.SenderId);
if(target == null)
return;
foreach(UserInfo user in users)
SendTo(user, msgDelPacket);
} else {
SendTo(info.ChannelName, msgDelPacket);
}
break;
case "msg:add":
if(info.Data is not MessageAddEventData msgAdd)
break;
if(info.IsBroadcast) {
Send(new MessageBroadcastS2CPacket(info.Id, info.Created, msgAdd.Text));
} else if(info.ChannelName.StartsWith('@')) {
// The channel name returned by GetDMChannelName should not be exposed to the user, instead @<Target User> should be displayed
// e.g. nook sees @Arysil and Arysil sees @nook
long[] targetIds = info.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1).ToArray();
if(targetIds.Length != 2)
return;
UserInfo[] users = Users.GetMany(targetIds);
UserInfo? target = users.FirstOrDefault(u => u.UserId != info.SenderId);
if(target == null)
return;
foreach(UserInfo user in users)
SendTo(user, new MessageAddS2CPacket(
info.Id,
DateTimeOffset.Now,
info.SenderId,
info.SenderId == user.UserId ? $"{SockChatUtility.GetUserName(target)} {msgAdd.Text}" : msgAdd.Text,
msgAdd.IsAction,
true
));
} else {
ChannelInfo? channel = Channels.Get(info.ChannelName, SockChatUtility.SanitiseChannelName);
if(channel != null)
SendTo(channel, new MessageAddS2CPacket(
info.Id,
DateTimeOffset.Now,
info.SenderId,
msgAdd.Text,
msgAdd.IsAction,
false
));
}
break;
}
}
private void HandleUserChannelJoin(string channelName, UserInfo userInfo, UserStatusInfo statusInfo) {
SendTo(userInfo.UserId, new ClearMessagesAndUsersS2CPacket());
UserInfo[] userInfos = Users.GetMany(ChannelsUsers.GetChannelUserIds(channelName));
List<UsersPopulateS2CPacket.ListEntry> userEntries = new();
foreach(UserInfo memberInfo in userInfos)
if(memberInfo.UserId != userInfo.UserId)
userEntries.Add(new(
memberInfo.UserId,
SockChatUtility.GetUserName(memberInfo, UserStatuses.Get(memberInfo)),
memberInfo.Colour,
memberInfo.Rank,
memberInfo.Permissions,
true
));
SendTo(userInfo.UserId, new UsersPopulateS2CPacket(userEntries.ToArray()));
SendTo(channelName, new UserChannelJoinS2CPacket(
userInfo.UserId,
SockChatUtility.GetUserName(userInfo, statusInfo),
userInfo.Colour,
userInfo.Rank,
userInfo.Permissions
));
HandleChannelEventLog(channelName, p => SendTo(userInfo.UserId, p));
ChannelsUsers.Join(channelName, userInfo.UserId);
SendTo(userInfo.UserId, new UserChannelForceJoinS2CPacket(channelName));
}
public void Update() {
ConnectionInfo[] timedOut = Connections.GetTimedOut();
foreach(ConnectionInfo conn in timedOut) {
Connections.Remove(conn);
if(conn is SockChatConnectionInfo scConn)
scConn.Close(1002);
Logger.Write($"<{conn.RemoteEndPoint}> Nuked timed out connection from user #{conn.UserId}.");
}
foreach(UserInfo user in Users.All)
if(!Connections.HasUser(user)) {
Events.Dispatch("user:delete", user);
Events.Dispatch(
"user:disconnect",
ChannelsUsers.GetUserLastChannel(user),
user,
new UserDisconnectEventData(UserDisconnectReason.TimeOut)
);
Logger.Write($"Timed out {user} (no more connections).");
}
}
public void SafeUpdate() {
ContextAccess.Wait();
try {
Update();
} finally {
ContextAccess.Release();
}
}
public void HandleChannelEventLog(string channelName, Action<ISockChatS2CPacket> handler) {
foreach(ChatEventInfo info in EventStorage.GetChannelEventLog(channelName)) {
switch(info.Type) {
case "msg:add":
if(info.Data is not MessageAddEventData msgAdd)
break;
handler(new MessageAddLogS2CPacket(
info.Id,
info.Created,
info.SenderId,
info.SenderName,
info.SenderColour,
info.SenderRank,
info.SenderPerms,
msgAdd.Text,
msgAdd.IsAction,
info.ChannelName.StartsWith('@'),
info.IsBroadcast,
false
));
break;
case "user:connect":
if(info.Data is UserConnectEventData ucData && ucData.Notify)
handler(new UserConnectLogS2CPacket(
info.Id,
info.Created,
SockChatUtility.GetUserName(info)
));
break;
case "user:disconnect":
handler(new UserDisconnectLogS2CPacket(
info.Id,
info.Created,
SockChatUtility.GetUserName(info),
info.Data is UserDisconnectEventData userDisconnect ? userDisconnect.Reason : UserDisconnectReason.Leave
));
break;
case "user:update":
if(info.Data is UserUpdateEventData userUpdate && userUpdate.Notify)
handler(new UserNickChangeLogS2CPacket(
info.Id,
info.Created,
info.SenderNickName == null ? info.SenderName : $"~{info.SenderNickName}",
userUpdate.NickName == null ? info.SenderName : $"~{userUpdate.NickName}"
));
break;
case "chan:join":
handler(new UserChannelJoinLogS2CPacket(
info.Id,
info.Created,
SockChatUtility.GetUserName(info)
));
break;
case "chan:leave":
handler(new UserChannelLeaveLogS2CPacket(
info.Id,
info.Created,
SockChatUtility.GetUserName(info)
));
break;
}
}
}
public void Send(ISockChatS2CPacket packet) {
string data = packet.Pack();
Connections.WithAuthed(conn => {
if(conn is SockChatConnectionInfo scConn)
scConn.Send(data);
});
}
public void SendTo(UserInfo user, ISockChatS2CPacket packet) {
SendTo(user.UserId, packet.Pack());
}
public void SendTo(long userId, ISockChatS2CPacket packet) {
SendTo(userId, packet.Pack());
}
public void SendTo(long userId, string packet) {
Connections.WithUser(userId, conn => {
if(conn is SockChatConnectionInfo scConn)
scConn.Send(packet);
});
}
public void SendTo(ChannelInfo channel, ISockChatS2CPacket packet) {
SendTo(channel.Name, packet.Pack());
}
public void SendTo(ChannelInfo channel, string packet) {
SendTo(channel.Name, packet);
}
public void SendTo(string channelName, ISockChatS2CPacket packet) {
SendTo(channelName, packet.Pack());
}
public void SendTo(string channelName, string packet) {
long[] userIds = ChannelsUsers.GetChannelUserIds(channelName);
foreach(long userId in userIds)
Connections.WithUser(userId, conn => {
if(conn is SockChatConnectionInfo scConn)
scConn.Send(packet);
});
}
public void SendToUserChannels(long userId, ISockChatS2CPacket packet) {
ChannelInfo[] chans = Channels.GetMany(ChannelsUsers.GetUserChannelNames(userId));
string data = packet.Pack();
foreach(ChannelInfo chan in chans)
SendTo(chan, data);
}
}
}

View file

@ -0,0 +1,265 @@
using Fleck;
using SharpChat.Config;
using SharpChat.Events;
using SharpChat.EventStorage;
using SharpChat.Misuzu;
using SharpChat.SockChat.Commands;
using SharpChat.SockChat.PacketsC2S;
using SharpChat.SockChat.PacketsS2C;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
namespace SharpChat.SockChat {
public class SockChatServer : IDisposable {
public const ushort DEFAULT_PORT = 6770;
public const int DEFAULT_MSG_LENGTH_MAX = 5000;
public const int DEFAULT_MAX_CONNECTIONS = 5;
public const int DEFAULT_FLOOD_KICK_LENGTH = 30;
public IWebSocketServer Server { get; }
public SockChatContext Context { get; }
private readonly HttpClient HttpClient;
private readonly MisuzuClient Misuzu;
private readonly CachedValue<int> MaxMessageLength;
private readonly CachedValue<int> MaxConnections;
private readonly CachedValue<int> FloodKickLength;
private readonly List<IC2SPacketHandler> GuestHandlers = new();
private readonly List<IC2SPacketHandler> AuthedHandlers = new();
private readonly SendMessageC2SPacketHandler SendMessageHandler;
private bool IsShuttingDown = false;
private bool IsRestarting = false;
public SockChatServer(HttpClient httpClient, MisuzuClient msz, IEventStorage evtStore, IConfig config) {
Logger.Write("Initialising Sock Chat server...");
DateTimeOffset started = DateTimeOffset.UtcNow;
HttpClient = httpClient;
Misuzu = msz;
MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX);
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH);
Context = new SockChatContext(evtStore);
string[]? channelNames = config.ReadValue("channels", new[] { "lounge" });
if(channelNames != null)
foreach(string channelName in channelNames) {
IConfig channelCfg = config.ScopeTo($"channels:{channelName}");
string? name = channelCfg.SafeReadValue("name", string.Empty);
if(string.IsNullOrWhiteSpace(name))
name = channelName;
ChannelInfo channelInfo = new(
name,
channelCfg.SafeReadValue("password", string.Empty),
rank: channelCfg.SafeReadValue("minRank", 0)
);
Context.Channels.Add(channelInfo);
}
if(Context.Channels.PublicCount < 1)
Context.Channels.Add(new ChannelInfo("Default"));
CachedValue<string> motdHeaderFormat = config.ReadCached("motd", @"Welcome to Flashii Chat, {0}!");
GuestHandlers.Add(new AuthC2SPacketHandler(
started,
Misuzu,
Context.Channels.MainChannel,
motdHeaderFormat,
MaxMessageLength,
MaxConnections
));
AuthedHandlers.AddRange(new IC2SPacketHandler[] {
new PingC2SPacketHandler(Misuzu),
SendMessageHandler = new SendMessageC2SPacketHandler(MaxMessageLength),
});
SendMessageHandler.AddCommands(new ISockChatClientCommand[] {
new UserAFKCommand(),
new UserNickCommand(),
new MessageWhisperCommand(),
new MessageActionCommand(),
new WhoCommand(),
new ChannelJoinCommand(),
new ChannelCreateCommand(),
new ChannelDeleteCommand(),
new ChannelPasswordCommand(),
new ChannelRankCommand(),
new MessageBroadcastCommand(),
new MessageDeleteCommand(),
new KickBanCommand(msz),
new PardonUserCommand(msz),
new PardonAddressCommand(msz),
new BanListCommand(msz),
new WhoisCommand(),
});
ushort port = config.SafeReadValue("port", DEFAULT_PORT);
Server = new SockChatWebSocketServer($"ws://0.0.0.0:{port}");
}
public void Listen(ManualResetEvent waitHandle) {
if(waitHandle != null)
SendMessageHandler.AddCommand(new ShutdownRestartCommand(
waitHandle,
() => IsShuttingDown,
restarting => {
IsShuttingDown = true;
IsRestarting = restarting;
}
));
Server.Start(sock => {
if(IsShuttingDown) {
sock.Close(1013);
return;
}
SockChatConnectionInfo conn = SockChatConnectionInfo.Create(sock);
Context.Connections.Add(conn);
sock.OnOpen = () => OnOpen(conn);
sock.OnClose = () => OnClose(conn);
sock.OnError = err => OnError(conn, err);
sock.OnMessage = msg => OnMessage(conn, msg);
});
Logger.Write("Listening...");
}
private void OnOpen(ConnectionInfo conn) {
Logger.Write($"Connection opened from {conn.RemoteEndPoint}");
Context.SafeUpdate();
}
private void OnError(ConnectionInfo conn, Exception ex) {
Logger.Write($"<{conn.RemoteEndPoint}> {ex}");
Context.SafeUpdate();
}
private void OnClose(ConnectionInfo conn) {
Logger.Write($"Connection closed from {conn.RemoteEndPoint}");
Context.ContextAccess.Wait();
try {
Context.Connections.Remove(conn);
if(!Context.Connections.HasUser(conn.UserId)) {
UserInfo? userInfo = Context.Users.Get(conn.UserId);
if(userInfo != null) {
Context.Events.Dispatch("user:delete", userInfo);
Context.Events.Dispatch(
"user:disconnect",
Context.ChannelsUsers.GetUserLastChannel(conn.UserId),
userInfo,
new UserDisconnectEventData(UserDisconnectReason.Leave)
);
}
}
Context.Update();
} finally {
Context.ContextAccess.Release();
}
}
private void OnMessage(SockChatConnectionInfo conn, string msg) {
Context.SafeUpdate();
// this doesn't affect non-authed connections?????
if(conn.UserId > 0) {
long banUserId = 0;
string banAddr = string.Empty;
TimeSpan banDuration = TimeSpan.MinValue;
Context.ContextAccess.Wait();
try {
if(!Context.UserRateLimiters.TryGetValue(conn.UserId, out RateLimiter? rateLimiter))
Context.UserRateLimiters.Add(conn.UserId, rateLimiter = new RateLimiter(
UserInfo.DEFAULT_SIZE,
UserInfo.DEFAULT_MINIMUM_DELAY,
UserInfo.DEFAULT_RISKY_OFFSET
));
rateLimiter.Update();
if(rateLimiter.IsExceeded) {
banDuration = TimeSpan.FromSeconds(FloodKickLength);
banUserId = conn.UserId;
banAddr = conn.RemoteAddress;
} else if(rateLimiter.IsRisky) {
banUserId = conn.UserId;
}
if(banUserId > 0) {
UserInfo? userInfo = Context.Users.Get(banUserId);
if(userInfo != null) {
if(banDuration == TimeSpan.MinValue) {
Context.SendTo(userInfo, new FloodWarningS2CPacket());
} else {
Context.Events.Dispatch("user:kickban", userInfo, UserKickBanEventData.OfDuration(UserDisconnectReason.Flood, banDuration));
if(banDuration > TimeSpan.Zero)
Misuzu.CreateBanAsync(
banUserId.ToString(), conn.RemoteAddress,
string.Empty, "::1",
banDuration,
"Kicked from chat for flood protection."
).Wait();
return;
}
}
}
} finally {
Context.ContextAccess.Release();
}
}
C2SPacketHandlerContext context = new(msg, Context, conn);
IC2SPacketHandler? handler = conn.UserId > 0
? AuthedHandlers.FirstOrDefault(h => h.IsMatch(context))
: GuestHandlers.FirstOrDefault(h => h.IsMatch(context));
handler?.Handle(context);
}
private bool IsDisposed;
~SockChatServer() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
IsShuttingDown = true;
Context.Connections.WithAll(conn => {
if(conn is SockChatConnectionInfo scConn)
scConn.Close(IsRestarting ? 1012 : 1001);
});
Server?.Dispose();
HttpClient?.Dispose();
}
}
}

View file

@ -0,0 +1,75 @@
using SharpChat.Events;
using System;
using System.Text.RegularExpressions;
namespace SharpChat.SockChat {
public static class SockChatUtility {
private static readonly Regex ChannelName = new(@"[^A-Za-z0-9\-_]", RegexOptions.CultureInvariant | RegexOptions.Compiled);
public static string SanitiseMessageBody(string? body) {
if(string.IsNullOrEmpty(body))
return string.Empty;
return body.Replace("<", "&lt;").Replace(">", "&gt;").Replace("\n", " <br/> ").Replace("\t", " ");
}
public static string SanitiseChannelName(string name) {
return ChannelName.Replace(name.Replace(" ", "_"), "-");
}
public static bool CheckChannelName(string name) {
return name.Length < 1 || ChannelName.IsMatch(name);
}
public static string GetUserName(UserInfo info, UserStatusInfo? statusInfo = null) {
string name = string.IsNullOrWhiteSpace(info.NickName) ? info.UserName : $"~{info.NickName}";
if(statusInfo?.Status == UserStatus.Away)
name = string.Format(
"&lt;{0}&gt;_{1}",
statusInfo.Text[..Math.Min(statusInfo.Text.Length, 5)].ToUpperInvariant(),
name
);
return name;
}
public static string GetUserName(ChatEventInfo info, UserStatusInfo? statusInfo = null) {
string name = string.IsNullOrWhiteSpace(info.SenderNickName) ? info.SenderName : $"~{info.SenderNickName}";
if(statusInfo?.Status == UserStatus.Away)
name = string.Format(
"&lt;{0}&gt;_{1}",
statusInfo.Text[..Math.Min(statusInfo.Text.Length, 5)].ToUpperInvariant(),
name
);
return name;
}
public static (string, UsersContext.NameTarget) ExplodeUserName(string name) {
UsersContext.NameTarget target = UsersContext.NameTarget.UserName;
if(name.StartsWith("<")) {
int gt = name.IndexOf(">_");
if(gt > 0) {
gt += 2;
name = name[gt..];
}
} else if(name.StartsWith("&lt;")) {
int gt = name.IndexOf("&gt;_");
if(gt > 0) {
gt += 5;
name = name[gt..];
}
}
if(name.StartsWith("~")) {
target = UsersContext.NameTarget.NickName;
name = name[1..];
}
return (name, target);
}
}
}

View file

@ -1,4 +1,6 @@
using Fleck;
#nullable disable
using Fleck;
using System;
using System.Collections.Generic;
using System.IO;
@ -14,13 +16,13 @@ using System.Text;
// https://github.com/statianzo/Fleck/blob/1.1.0/src/Fleck/WebSocketServer.cs
namespace SharpChat {
public class SharpChatWebSocketServer : IWebSocketServer {
public class SockChatWebSocketServer : IWebSocketServer {
private readonly string _scheme;
private readonly IPAddress _locationIP;
private Action<IWebSocketConnection> _config;
public SharpChatWebSocketServer(string location, bool supportDualStack = true) {
public SockChatWebSocketServer(string location, bool supportDualStack = true) {
Uri uri = new(location);
Port = uri.Port;
@ -115,7 +117,8 @@ namespace SharpChat {
}
private void OnClientConnect(ISocket clientSocket) {
if(clientSocket == null) return; // socket closed
if(clientSocket == null)
return; // socket closed
FleckLog.Debug(string.Format("Client connected from {0}:{1}", clientSocket.RemoteIpAddress, clientSocket.RemotePort.ToString()));
ListenForClients();

View file

@ -7,6 +7,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat", "SharpChat\Shar
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DF7A7073-A67A-4D93-92C6-F9D0F95E2359}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.gitattributes = .gitattributes
.gitignore = .gitignore
LICENSE = LICENSE
@ -15,6 +16,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
start.sh = start.sh
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChatCommon", "SharpChatCommon\SharpChatCommon.csproj", "{B2228E3C-E0DB-4AAF-A603-2A822B531F76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.SockChat", "SharpChat.SockChat\SharpChat.SockChat.csproj", "{4D48CCFB-5D3B-4AB6-AF94-04377474078C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.Misuzu", "SharpChat.Misuzu\SharpChat.Misuzu.csproj", "{08FD8B99-011A-43F9-A6C9-A3C1979604CF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -25,6 +32,18 @@ Global
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.Build.0 = Release|Any CPU
{B2228E3C-E0DB-4AAF-A603-2A822B531F76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2228E3C-E0DB-4AAF-A603-2A822B531F76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2228E3C-E0DB-4AAF-A603-2A822B531F76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2228E3C-E0DB-4AAF-A603-2A822B531F76}.Release|Any CPU.Build.0 = Release|Any CPU
{4D48CCFB-5D3B-4AB6-AF94-04377474078C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4D48CCFB-5D3B-4AB6-AF94-04377474078C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4D48CCFB-5D3B-4AB6-AF94-04377474078C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4D48CCFB-5D3B-4AB6-AF94-04377474078C}.Release|Any CPU.Build.0 = Release|Any CPU
{08FD8B99-011A-43F9-A6C9-A3C1979604CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{08FD8B99-011A-43F9-A6C9-A3C1979604CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{08FD8B99-011A-43F9-A6C9-A3C1979604CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{08FD8B99-011A-43F9-A6C9-A3C1979604CF}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View file

@ -1,72 +0,0 @@
using System;
using System.Linq;
using System.Text;
namespace SharpChat {
public class ChatChannel {
public string Name { get; }
public string Password { get; set; }
public bool IsTemporary { get; set; }
public int Rank { get; set; }
public long OwnerId { get; set; }
public bool HasPassword
=> !string.IsNullOrWhiteSpace(Password);
public ChatChannel(
ChatUser owner,
string name,
string password = null,
bool isTemporary = false,
int rank = 0
) : this(name, password, isTemporary, rank, owner?.UserId ?? 0) {}
public ChatChannel(
string name,
string password = null,
bool isTemporary = false,
int rank = 0,
long ownerId = 0
) {
Name = name;
Password = password ?? string.Empty;
IsTemporary = isTemporary;
Rank = rank;
OwnerId = ownerId;
}
public string Pack() {
StringBuilder sb = new();
sb.Append(Name);
sb.Append('\t');
sb.Append(string.IsNullOrEmpty(Password) ? '0' : '1');
sb.Append('\t');
sb.Append(IsTemporary ? '1' : '0');
return sb.ToString();
}
public bool NameEquals(string name) {
return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase);
}
public bool IsOwner(ChatUser user) {
return OwnerId > 0
&& user != null
&& OwnerId == user.UserId;
}
public override int GetHashCode() {
return Name.GetHashCode();
}
public static bool CheckName(string name) {
return !string.IsNullOrWhiteSpace(name) && name.All(CheckNameChar);
}
public static bool CheckNameChar(char c) {
return char.IsLetter(c) || char.IsNumber(c) || c == '-' || c == '_';
}
}
}

View file

@ -1,79 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace SharpChat {
public struct ChatColour {
public byte Red { get; }
public byte Green { get; }
public byte Blue { get; }
public bool Inherits { get; }
public static ChatColour None { get; } = new();
public ChatColour() {
Red = 0;
Green = 0;
Blue = 0;
Inherits = true;
}
public ChatColour(byte red, byte green, byte blue) {
Red = red;
Green = green;
Blue = blue;
Inherits = false;
}
public override bool Equals([NotNullWhen(true)] object obj) {
return obj is ChatColour colour && Equals(colour);
}
public bool Equals(ChatColour other) {
return Red == other.Red
&& Green == other.Green
&& Blue == other.Blue
&& Inherits == other.Inherits;
}
public override int GetHashCode() {
return ToMisuzu();
}
public override string ToString() {
return Inherits
? "inherit"
: string.Format("#{0:x2}{1:x2}{2:x2}", Red, Green, Blue);
}
public int ToRawRGB() {
return (Red << 16) | (Green << 8) | Blue;
}
public static ChatColour FromRawRGB(int rgb) {
return new(
(byte)((rgb >> 16) & 0xFF),
(byte)((rgb >> 8) & 0xFF),
(byte)(rgb & 0xFF)
);
}
private const int MSZ_INHERIT = 0x40000000;
public int ToMisuzu() {
return Inherits ? MSZ_INHERIT : ToRawRGB();
}
public static ChatColour FromMisuzu(int raw) {
return (raw & MSZ_INHERIT) > 0
? None
: FromRawRGB(raw);
}
public static bool operator ==(ChatColour left, ChatColour right) {
return left.Equals(right);
}
public static bool operator !=(ChatColour left, ChatColour right) {
return !(left == right);
}
}
}

View file

@ -1,53 +0,0 @@
using System;
using System.Linq;
namespace SharpChat {
public class ChatCommandContext {
public string Name { get; }
public string[] Args { get; }
public ChatContext Chat { get; }
public ChatUser User { get; }
public ChatConnection Connection { get; }
public ChatChannel Channel { get; }
public ChatCommandContext(
string text,
ChatContext chat,
ChatUser user,
ChatConnection connection,
ChatChannel channel
) {
if(text == null)
throw new ArgumentNullException(nameof(text));
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
User = user ?? throw new ArgumentNullException(nameof(user));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
string[] parts = text[1..].Split(' ');
Name = parts.First().Replace(".", string.Empty);
Args = parts.Skip(1).ToArray();
}
public ChatCommandContext(
string name,
string[] args,
ChatContext chat,
ChatUser user,
ChatConnection connection,
ChatChannel channel
) {
Name = name ?? throw new ArgumentNullException(nameof(name));
Args = args ?? throw new ArgumentNullException(nameof(args));
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
User = user ?? throw new ArgumentNullException(nameof(user));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
public bool NameEquals(string name) {
return Name.Equals(name, StringComparison.InvariantCultureIgnoreCase);
}
}
}

View file

@ -1,96 +0,0 @@
using Fleck;
using System;
using System.Collections.Generic;
using System.Net;
namespace SharpChat {
public class ChatConnection : IDisposable {
public const int ID_LENGTH = 20;
#if DEBUG
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1);
#else
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5);
#endif
public IWebSocketConnection Socket { get; }
public string Id { get; }
public bool IsDisposed { get; private set; }
public DateTimeOffset LastPing { get; set; } = DateTimeOffset.Now;
public ChatUser User { get; set; }
private int CloseCode { get; set; } = 1000;
public IPAddress RemoteAddress { get; }
public ushort RemotePort { get; }
public bool IsAlive => !IsDisposed && !HasTimedOut;
public bool IsAuthed => IsAlive && User is not null;
public ChatConnection(IWebSocketConnection sock) {
Socket = sock;
Id = RNG.SecureRandomString(ID_LENGTH);
if(!IPAddress.TryParse(sock.ConnectionInfo.ClientIpAddress, out IPAddress addr))
throw new Exception("Unable to parse remote address?????");
if(IPAddress.IsLoopback(addr)
&& sock.ConnectionInfo.Headers.ContainsKey("X-Real-IP")
&& IPAddress.TryParse(sock.ConnectionInfo.Headers["X-Real-IP"], out IPAddress realAddr))
addr = realAddr;
RemoteAddress = addr;
RemotePort = (ushort)sock.ConnectionInfo.ClientPort;
}
public void Send(IServerPacket packet) {
if(!Socket.IsAvailable)
return;
IEnumerable<string> data = packet.Pack();
if(data != null)
foreach(string line in data)
if(!string.IsNullOrWhiteSpace(line))
Socket.Send(line);
}
public void BumpPing() {
LastPing = DateTimeOffset.Now;
}
public bool HasTimedOut
=> DateTimeOffset.Now - LastPing > SessionTimeOut;
public void PrepareForRestart() {
CloseCode = 1012;
}
~ChatConnection() {
DoDispose();
}
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
Socket.Close(CloseCode);
}
public override string ToString() {
return Id;
}
public override int GetHashCode() {
return Id.GetHashCode();
}
}
}

View file

@ -1,398 +0,0 @@
using SharpChat.Events;
using SharpChat.EventStorage;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading;
namespace SharpChat {
public class ChatContext {
public record ChannelUserAssoc(long UserId, string ChannelName);
public readonly SemaphoreSlim ContextAccess = new(1, 1);
public HashSet<ChatChannel> Channels { get; } = new();
public HashSet<ChatConnection> Connections { get; } = new();
public HashSet<ChatUser> Users { get; } = new();
public IEventStorage Events { get; }
public HashSet<ChannelUserAssoc> ChannelUsers { get; } = new();
public Dictionary<long, RateLimiter> UserRateLimiters { get; } = new();
public Dictionary<long, ChatChannel> UserLastChannel { get; } = new();
public ChatContext(IEventStorage evtStore) {
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
}
public void DispatchEvent(IChatEvent eventInfo) {
if(eventInfo is MessageCreateEvent mce) {
if(mce.IsBroadcast) {
Send(new LegacyCommandResponse(LCR.BROADCAST, false, mce.MessageText));
} else if(mce.IsPrivate) {
// The channel name returned by GetDMChannelName should not be exposed to the user, instead @<Target User> should be displayed
// e.g. nook sees @Arysil and Arysil sees @nook
// this entire routine is garbage, channels should probably in the db
if(!mce.ChannelName.StartsWith("@"))
return;
IEnumerable<long> uids = mce.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1);
if(uids.Count() != 2)
return;
IEnumerable<ChatUser> users = Users.Where(u => uids.Any(uid => uid == u.UserId));
ChatUser target = users.FirstOrDefault(u => u.UserId != mce.SenderId);
if(target == null)
return;
foreach(ChatUser user in users)
SendTo(user, new ChatMessageAddPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.SenderId == user.UserId ? $"{target.LegacyName} {mce.MessageText}" : mce.MessageText,
mce.IsAction,
true
));
} else {
ChatChannel channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName));
SendTo(channel, new ChatMessageAddPacket(
mce.MessageId,
DateTimeOffset.Now,
mce.SenderId,
mce.MessageText,
mce.IsAction,
false
));
}
Events.AddEvent(
mce.MessageId, "msg:add",
mce.ChannelName,
mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms,
new { text = mce.MessageText },
(mce.IsBroadcast ? StoredEventFlags.Broadcast : 0)
| (mce.IsAction ? StoredEventFlags.Action : 0)
| (mce.IsPrivate ? StoredEventFlags.Private : 0)
);
return;
}
}
public void Update() {
foreach(ChatConnection conn in Connections)
if(!conn.IsDisposed && conn.HasTimedOut) {
conn.Dispose();
Logger.Write($"Nuked connection {conn.Id} associated with {conn.User}.");
}
Connections.RemoveWhere(conn => conn.IsDisposed);
foreach(ChatUser user in Users)
if(!Connections.Any(conn => conn.User == user)) {
HandleDisconnect(user, UserDisconnectReason.TimeOut);
Logger.Write($"Timed out {user} (no more connections).");
}
}
public void SafeUpdate() {
ContextAccess.Wait();
try {
Update();
} finally {
ContextAccess.Release();
}
}
public bool IsInChannel(ChatUser user, ChatChannel channel) {
return ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name));
}
public string[] GetUserChannelNames(ChatUser user) {
return ChannelUsers.Where(cu => cu.UserId == user.UserId).Select(cu => cu.ChannelName).ToArray();
}
public ChatChannel[] GetUserChannels(ChatUser user) {
string[] names = GetUserChannelNames(user);
return Channels.Where(c => names.Any(n => c.NameEquals(n))).ToArray();
}
public long[] GetChannelUserIds(ChatChannel channel) {
return ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId).ToArray();
}
public ChatUser[] GetChannelUsers(ChatChannel channel) {
long[] ids = GetChannelUserIds(channel);
return Users.Where(u => ids.Contains(u.UserId)).ToArray();
}
public void UpdateUser(
ChatUser user,
string userName = null,
string nickName = null,
ChatColour? colour = null,
ChatUserStatus? status = null,
string statusText = null,
int? rank = null,
ChatUserPermissions? perms = null,
bool? isSuper = null,
bool silent = false
) {
if(user == null)
throw new ArgumentNullException(nameof(user));
bool hasChanged = false;
string previousName = null;
if(userName != null && !user.UserName.Equals(userName)) {
user.UserName = userName;
hasChanged = true;
}
if(nickName != null && !user.NickName.Equals(nickName)) {
if(!silent)
previousName = string.IsNullOrWhiteSpace(user.NickName) ? user.UserName : user.NickName;
user.NickName = nickName;
hasChanged = true;
}
if(colour.HasValue && user.Colour != colour.Value) {
user.Colour = colour.Value;
hasChanged = true;
}
if(status.HasValue && user.Status != status.Value) {
user.Status = status.Value;
hasChanged = true;
}
if(statusText != null && !user.StatusText.Equals(statusText)) {
user.StatusText = statusText;
hasChanged = true;
}
if(rank != null && user.Rank != rank) {
user.Rank = (int)rank;
hasChanged = true;
}
if(perms.HasValue && user.Permissions != perms) {
user.Permissions = perms.Value;
hasChanged = true;
}
if(isSuper.HasValue) {
user.IsSuper = isSuper.Value;
hasChanged = true;
}
if(hasChanged)
SendToUserChannels(user, new UserUpdatePacket(user, previousName));
}
public void BanUser(ChatUser user, TimeSpan duration, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
if(duration > TimeSpan.Zero) {
DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration;
SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Banned, expires));
} else
SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Kicked));
foreach(ChatConnection conn in Connections)
if(conn.User == user)
conn.Dispose();
Connections.RemoveWhere(conn => conn.IsDisposed);
HandleDisconnect(user, reason);
}
public void HandleJoin(ChatUser user, ChatChannel chan, ChatConnection conn, int maxMsgLength) {
if(!IsInChannel(user, chan)) {
SendTo(chan, new UserConnectPacket(DateTimeOffset.Now, user));
Events.AddEvent("user:connect", user, chan, flags: StoredEventFlags.Log);
}
conn.Send(new AuthSuccessPacket(user, chan, conn, maxMsgLength));
conn.Send(new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
conn.Send(new ContextMessagePacket(msg));
conn.Send(new ContextChannelsPacket(Channels.Where(c => c.Rank <= user.Rank)));
Users.Add(user);
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
}
public void HandleDisconnect(ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
UpdateUser(user, status: ChatUserStatus.Offline);
Users.Remove(user);
UserLastChannel.Remove(user.UserId);
ChatChannel[] channels = GetUserChannels(user);
foreach(ChatChannel chan in channels) {
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, chan.Name));
SendTo(chan, new UserDisconnectPacket(DateTimeOffset.Now, user, reason));
Events.AddEvent("user:disconnect", user, chan, new { reason = (int)reason }, StoredEventFlags.Log);
if(chan.IsTemporary && chan.IsOwner(user))
RemoveChannel(chan);
}
}
public void SwitchChannel(ChatUser user, ChatChannel chan, string password) {
if(UserLastChannel.TryGetValue(user.UserId, out ChatChannel ulc) && chan == ulc) {
ForceChannel(user);
return;
}
if(!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.IsOwner(user)) {
if(chan.Rank > user.Rank) {
SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
ForceChannel(user);
return;
}
if(!string.IsNullOrEmpty(chan.Password) && chan.Password != password) {
SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
ForceChannel(user);
return;
}
}
ForceChannelSwitch(user, chan);
}
public void ForceChannelSwitch(ChatUser user, ChatChannel chan) {
if(!Channels.Contains(chan))
return;
ChatChannel oldChan = UserLastChannel[user.UserId];
SendTo(oldChan, new UserChannelLeavePacket(user));
Events.AddEvent("chan:leave", user, oldChan, flags: StoredEventFlags.Log);
SendTo(chan, new UserChannelJoinPacket(user));
Events.AddEvent("chan:join", user, oldChan, flags: StoredEventFlags.Log);
SendTo(user, new ContextClearPacket(chan, ContextClearMode.MessagesUsers));
SendTo(user, new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
SendTo(user, new ContextMessagePacket(msg));
ForceChannel(user, chan);
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
if(oldChan.IsTemporary && oldChan.IsOwner(user))
RemoveChannel(oldChan);
}
public void Send(IServerPacket packet) {
if(packet == null)
throw new ArgumentNullException(nameof(packet));
foreach(ChatConnection conn in Connections)
if(conn.IsAuthed)
conn.Send(packet);
}
public void SendTo(ChatUser user, IServerPacket packet) {
if(user == null)
throw new ArgumentNullException(nameof(user));
if(packet == null)
throw new ArgumentNullException(nameof(packet));
foreach(ChatConnection conn in Connections)
if(conn.IsAlive && conn.User == user)
conn.Send(packet);
}
public void SendTo(ChatChannel channel, IServerPacket packet) {
if(channel == null)
throw new ArgumentNullException(nameof(channel));
if(packet == null)
throw new ArgumentNullException(nameof(packet));
// might be faster to grab the users first and then cascade into that SendTo
IEnumerable<ChatConnection> conns = Connections.Where(c => c.IsAuthed && IsInChannel(c.User, channel));
foreach(ChatConnection conn in conns)
conn.Send(packet);
}
public void SendToUserChannels(ChatUser user, IServerPacket packet) {
if(user == null)
throw new ArgumentNullException(nameof(user));
if(packet == null)
throw new ArgumentNullException(nameof(packet));
IEnumerable<ChatChannel> chans = Channels.Where(c => IsInChannel(user, c));
IEnumerable<ChatConnection> conns = Connections.Where(conn => conn.IsAuthed && ChannelUsers.Any(cu => cu.UserId == conn.User.UserId && chans.Any(chan => chan.NameEquals(cu.ChannelName))));
foreach(ChatConnection conn in conns)
conn.Send(packet);
}
public IPAddress[] GetRemoteAddresses(ChatUser user) {
return Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct().ToArray();
}
public void ForceChannel(ChatUser user, ChatChannel chan = null) {
if(user == null)
throw new ArgumentNullException(nameof(user));
if(chan == null && !UserLastChannel.TryGetValue(user.UserId, out chan))
throw new ArgumentException("no channel???");
SendTo(user, new UserChannelForceJoinPacket(chan));
}
public void UpdateChannel(ChatChannel channel, bool? temporary = null, int? hierarchy = null, string password = null) {
if(channel == null)
throw new ArgumentNullException(nameof(channel));
if(!Channels.Contains(channel))
throw new ArgumentException("Provided channel is not registered with this manager.", nameof(channel));
if(temporary.HasValue)
channel.IsTemporary = temporary.Value;
if(hierarchy.HasValue)
channel.Rank = hierarchy.Value;
if(password != null)
channel.Password = password;
// TODO: Users that no longer have access to the channel/gained access to the channel by the hierarchy change should receive delete and create packets respectively
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank)) {
SendTo(user, new ChannelUpdatePacket(channel.Name, channel));
}
}
public void RemoveChannel(ChatChannel channel) {
if(channel == null || !Channels.Any())
return;
ChatChannel defaultChannel = Channels.FirstOrDefault();
if(defaultChannel == null)
return;
// Remove channel from the listing
Channels.Remove(channel);
// Move all users back to the main channel
// TODO: Replace this with a kick. SCv2 supports being in 0 channels, SCv1 should force the user back to DefaultChannel.
foreach(ChatUser user in GetChannelUsers(channel))
SwitchChannel(user, defaultChannel, string.Empty);
// Broadcast deletion of channel
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank))
SendTo(user, new ChannelDeletePacket(channel));
}
}
}

View file

@ -1,27 +0,0 @@
using System;
namespace SharpChat {
public class ChatPacketHandlerContext {
public string Text { get; }
public ChatContext Chat { get; }
public ChatConnection Connection { get; }
public ChatPacketHandlerContext(
string text,
ChatContext chat,
ChatConnection connection
) {
Text = text ?? throw new ArgumentNullException(nameof(text));
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
Connection = connection ?? throw new ArgumentNullException(nameof(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

@ -1,110 +0,0 @@
using System;
using System.Text;
namespace SharpChat {
public class ChatUser : IEquatable<ChatUser> {
public const int DEFAULT_SIZE = 30;
public const int DEFAULT_MINIMUM_DELAY = 10000;
public const int DEFAULT_RISKY_OFFSET = 5;
public long UserId { get; }
public string UserName { get; set; }
public ChatColour Colour { get; set; }
public int Rank { get; set; }
public ChatUserPermissions Permissions { get; set; }
public bool IsSuper { get; set; }
public string NickName { get; set; }
public ChatUserStatus Status { get; set; }
public string StatusText { get; set; }
public string LegacyName => string.IsNullOrWhiteSpace(NickName) ? UserName : $"~{NickName}";
public string LegacyNameWithStatus {
get {
StringBuilder sb = new();
if(Status == ChatUserStatus.Away)
sb.AppendFormat("&lt;{0}&gt;_", StatusText[..Math.Min(StatusText.Length, 5)].ToUpperInvariant());
sb.Append(LegacyName);
return sb.ToString();
}
}
public ChatUser(
long userId,
string userName,
ChatColour colour,
int rank,
ChatUserPermissions perms,
string nickName = null,
ChatUserStatus status = ChatUserStatus.Online,
string statusText = null,
bool isSuper = false
) {
UserId = userId;
UserName = userName ?? throw new ArgumentNullException(nameof(userName));
Colour = colour;
Rank = rank;
Permissions = perms;
NickName = nickName ?? string.Empty;
Status = status;
StatusText = statusText ?? string.Empty;
}
public bool Can(ChatUserPermissions perm, bool strict = false) {
ChatUserPermissions perms = Permissions & perm;
return strict ? perms == perm : perms > 0;
}
public string Pack() {
StringBuilder sb = new();
sb.Append(UserId);
sb.Append('\t');
sb.Append(LegacyNameWithStatus);
sb.Append('\t');
sb.Append(Colour);
sb.Append('\t');
sb.Append(Rank);
sb.Append(' ');
sb.Append(Can(ChatUserPermissions.KickUser) ? '1' : '0');
sb.Append(' ');
sb.Append(Can(ChatUserPermissions.ViewLogs) ? '1' : '0');
sb.Append(' ');
sb.Append(Can(ChatUserPermissions.SetOwnNickname) ? '1' : '0');
sb.Append(' ');
sb.Append(Can(ChatUserPermissions.CreateChannel | ChatUserPermissions.SetChannelPermanent, true) ? '2' : (
Can(ChatUserPermissions.CreateChannel) ? '1' : '0'
));
return sb.ToString();
}
public bool NameEquals(string name) {
return string.Equals(name, UserName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, NickName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, LegacyName, StringComparison.InvariantCultureIgnoreCase)
|| string.Equals(name, LegacyNameWithStatus, StringComparison.InvariantCultureIgnoreCase);
}
public override int GetHashCode() {
return UserId.GetHashCode();
}
public override bool Equals(object obj) {
return Equals(obj as ChatUser);
}
public bool Equals(ChatUser other) {
return UserId == other?.UserId;
}
public static string GetDMChannelName(ChatUser user1, ChatUser user2) {
return user1.UserId < user2.UserId
? $"@{user1.UserId}-{user2.UserId}"
: $"@{user2.UserId}-{user1.UserId}";
}
}
}

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