Compare commits

..

48 commits

Author SHA1 Message Date
fafec2175c Merge pull request 'Mention that user perms may be empty sometimes.' (#2) from rust-moment into mistress
Reviewed-on: #2
2024-10-23 22:24:21 +00:00
2f8024f87f Mention that user perms may be empty sometimes. 2024-10-23 22:24:05 +00:00
7d3cf5de62 Merge pull request 'documentation-updates' (#1) from documentation-updates into mistress
Reviewed-on: https://patchii.net///flashii/sharp-chat/pulls/1
2024-08-25 23:30:59 +00:00
0b90079fd5 Update README.md 2024-08-25 23:30:00 +00:00
a578f8ed9d Updated copyright year (2024) 2024-08-25 23:28:53 +00:00
06f2d11c7c Copied protocol doc over from old new master branch. 2024-08-25 23:28:22 +00:00
54af837c82 Fixed various inconsistencies with Sock Chat. 2024-05-09 21:31:19 +00:00
0d0f2e68b9 client -> server 2024-04-17 20:44:23 +00:00
e291a17705 Updated Pong packet documentation, removed column used for version info and reverted 'Sequence ID' back to 'Message ID'. 2024-04-17 20:41:15 +00:00
cd32995367 Removed mention of versioning. 2024-04-17 20:31:25 +00:00
5985f63744 Allow super users to kick anyone regardless of ranking. 2023-11-07 14:50:55 +00:00
a9ca3705ad Keep track of super user status. 2023-11-07 14:49:12 +00:00
294471dcfd Fixed inverted permission for /create.
For the love of god remember to update the permissions table and recalculate before starting back up.
2023-11-07 14:33:07 +00:00
c46d117d15 Merge branch 'new-master' of git.flash.moe:flashii/sharp-chat into new-master 2023-11-04 23:37:13 +00:00
05fcbcb0f8 Fixed issues relating to event_sender_name being nullable for some reason. 2023-11-04 23:37:02 +00:00
03b3b6b0a3 Fixed issue when starting without any configuration data present. 2023-10-01 04:54:30 +02:00
a7a05f04bd Don't remove AFK status when opening a new connection. 2023-08-09 14:59:29 +00:00
dc4989a3cf Fixed connection error when issuing a permanent ban. 2023-07-23 21:45:10 +00:00
903e39ab76 Removed any remaining references to silencing. 2023-07-23 21:36:22 +00:00
8c19c22736 Reworking event dispatching... I think?
I did this make in february but left it uncommitted. Hopefully it's stable!
2023-07-23 21:31:13 +00:00
4e0def980f Revised event storage to use less magic classes. 2023-02-23 22:46:49 +01:00
82973f7a33 Improved user updating but also other things that were already local. 2023-02-22 01:28:53 +01:00
8de3ba8dbb Even slightly lesser aggressive question mark 2023-02-19 23:47:53 +01:00
86a46539f2 Less haphazard locking (perhaps too?) 2023-02-19 23:27:08 +01:00
70df99fe9b Significantly less stupid connection resolving. 2023-02-17 23:17:24 +01:00
546e8a2c83 Simplify rate limiter, disabled silencing and merged BasicUser and ChatUser. 2023-02-17 22:47:44 +01:00
d268a419dc Cleaned up channel/user association logic. 2023-02-17 20:02:35 +01:00
1466562c54 Use virtual channel name for DMs. 2023-02-16 23:56:50 +01:00
a5089f14b8 Undid the IPacketTarget system. 2023-02-16 23:47:30 +01:00
13ae843c8d No longer keep track of connections within the ChatUser class. 2023-02-16 23:33:48 +01:00
06af94e94f Renamed 'Sessions' to 'Connections' 2023-02-16 22:25:41 +01:00
c8a589c1c1 Un-switch packet handlers. 2023-02-16 22:16:06 +01:00
ea56af0210 Turned commands into classes instead of a shitty switch. 2023-02-16 21:34:59 +01:00
d1f78a7e8b Actually send the message deletion packet. 2023-02-16 17:10:30 +01:00
dbdaaeec9e Better session ID generation code. 2023-02-10 08:06:07 +01:00
8050a295c1 Updated protocol documentation to indicate that IDs should not be treated as numbers. 2023-02-10 07:28:36 +01:00
c291ef178d Fixed the backlog being sent in reverse order. 2023-02-10 07:18:38 +01:00
c21605cf3b Marginal improvements to cross thread access. 2023-02-10 07:07:59 +01:00
e1e3def62c Ported the config system from old master. 2023-02-09 00:53:42 +01:00
56a818254e Backported SharpId class 2023-02-08 04:32:12 +01:00
40c7ba4ded Removed a bunch of @ prefixes that aren't needed. 2023-02-08 04:17:07 +01:00
27c28aafcd Don't use a stinky Timer for user bumps and exclude AFK. 2023-02-08 01:01:55 +01:00
36f3ff6385 Removed internal ban handling and integrate with Misuzu. 2023-02-07 23:28:06 +01:00
5e3eecda8c Convert Colour class to a struct. 2023-02-07 16:13:38 +01:00
4104e40843 Code style updates. 2023-02-07 16:01:56 +01:00
c9cc5ff23a Removed protocol enums. 2023-02-07 15:34:31 +01:00
513539319f Better HttpClient handling. 2023-02-06 21:14:50 +01:00
d2fef02e08 Added Protocol doc to new master branch. 2023-02-06 19:38:16 +00:00
109 changed files with 4599 additions and 3027 deletions

2
.gitignore vendored
View file

@ -7,6 +7,8 @@ login_key.txt
http-motd.txt
_webdb.txt
msz_url.txt
sharpchat.cfg
SharpChat/version.txt
# User-specific files
*.suo

View file

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

936
Protocol.md Normal file
View file

@ -0,0 +1,936 @@
# Sock Chat Protocol Information
The Sock Chat protocol operates on a websocket in text mode.
Messages sent between the client and server are a series of concatenated strings delimited by the vertical tab character, represented in most languages by the escape sequence `\t` and defined in ASCII as `0x09`.
The first string in this concatenation must be the packet identifier.
Updated behaviour is defined through capabilities.
Further documentation on their behaviour will be added at a later date.
## Types
### `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 `_`.
### `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 <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 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.
Note that this string MAY be empty if sent by the bot user (`-1`).
| 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
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.
| 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.
| 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
Informs the server that the user has sent a message.
Commands are described lower in the document.
| Type | Description |
|:--------:| ----------- |
| `string` | User ID of the current user. |
| `string` | Message text, cannot contain a tab character `\t`. |
## Server Packets
These are the packets sent from the server to the client.
### Packet `0`: Pong
Response to client packet `0`: Ping.
| 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
While authenticated this packet indicates that a new user has joined the server/channel. Before authentication this packet serves as a response to client packet `1`: Authentication.
#### Successful authentication response
Informs the client that authentication has succeeded.
| 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.
| 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.
| 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.
| 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.
| 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.
| Type | Description |
|:-----:| -------------- |
| `int` | Sub-packet ID. |
#### Sub-packet `0`: Channel creation
Informs the client that a channel has been created.
| 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.
| 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
| Type | Description |
|:--------------:| ----------- |
| `channel name` | Name of the channel to be deleted. |
### Packet `5`: Channel switching
This packet informs the client about channel switching.
| Type | Description |
|:-----:| -------------- |
| `int` | Sub-packet ID. |
#### Sub-packet `0`: Channel join
Informs the client that a user has joined the channel.
| 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.
| 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.
| 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.
| 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.
| Type | Description |
|:-----:| -------------- |
| `int` | Sub-packet ID. |
#### Sub-packet `0`: Existing users
Informs the client about users already present in the channel.
| 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.
| 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.
| 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.
| 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.
| 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.
| 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
Formatting IDs sent by user -1.
### Informational
#### `say`: Broadcast
Just echo whatever is specified in the first argument.
##### Arguments
- `string`: Message to be broadcast.
#### `flwarn`: Flood protection warning
Informs the client that they are risking getting kicked for flood protection (spam) if they continue sending messages at the same rate.
#### `unban`: Ban revocation confirmation
Informs the client that they have successfully revoked a ban on a user or an IP address.
#### `banlist`: List of banned entities
Provides the client with a list of all banned users and IP addresses.
##### Arguments
- `string`: HTML with the information on the users with the following format: "<code>&lt;a href="javascript:void(0);" onclick="Chat.SendMessageWrapper('/unban '+ this.innerHTML);"&gt;{0}&lt;/a&gt;</code>" where {0} is the username of the banned user or the banned IP address. The set is separated by "<code>, </code>".
#### `who`: List of online users
Provides the client with a list of users currently online on the server.
##### Arguments
- `string`: HTML with the information on the users with the following format: "<code>&lt;a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);"&gt;{0}&lt;/a&gt;</code>" where {0} is the username of a user. The current online user is highlighted with "<code> style="font-weight: bold;"</code>" before the closing &gt; of the opening &lt;a&gt; tag. The set is separated by "<code>, </code>".
#### `whochan`: List of users in a channel.
Provides the client with a list of users currently online in a channel.
##### Arguments
- `string`: HTML with the information on the users with the following format: "<code>&lt;a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);"&gt;{0}&lt;/a&gt;</code>" where {0} is the username of a user. The current online user is highlighted with "<code> style="font-weight: bold;"</code>" before the closing &gt; of the opening &lt;a&gt; tag. The set is separated by "<code>, </code>"
#### `join`: User connected
Informs the client that a user just connected to the server.
##### Arguments
- `string`: Username of the user.
#### `jchan`: User joined channel
Informs the client that a user just joined a channel they're in.
##### Arguments
- `string`: Username of the user.
#### `leave`: User disconnected
Informs the client that a user just disconnected from the server.
##### Arguments
- `string`: Username of the user.
#### `lchan`: User left channel
Informs the client that a user just left a channel they're in.
#### `kick`: User has been kicked
Informs the client that another user has just been kicked.
##### Arguments
- `string`: Username of the user.
#### `flood`: User exceeded flood limit
Informs the client that another user has just been kicked for exceeding the flood protection limit.
##### Arguments
- `string`: Username of the user.
#### `timeout`: User has timed out
Informs the client that another user has been disconnected from the server automatically.
##### Arguments
- `string`: Username of the user.
#### `nick`: User has changed their nickname
Informs the client that a user has changed their nickname.
##### Arguments
- `string`: Previous username of the user.
- `string`: New username of the user.
#### `crchan`: Channel creation confirmation
Informs the client that the channel they attempted to create has been successfully created.
##### Arguments
- `string`: Name of the target channel.
#### `delchan`: Channel deletion confirmation
Informs the client that the channel they attempted to delete has been successfully deleted.
##### Arguments
- `string`: Name of the target channel.
#### `cpwdchan`: Channel password update confirmation
Informs the client that they've successfully changed the password of a channel.
#### `cprivchan`: Channel rank update confirmation
Informs the client that they've successfully changed the minimum required rank to join a channel.
#### `ipaddr`: IP address
Shows the IP address of another user to a user with moderation privileges.
##### Arguments
- `string`: Name of the target user.
- `string`: IP address.
### Errors
#### `generr`: Generic Error
Informs the client that Something went Wrong.
#### `nocmd`: Command not found
Informs the client that the command they tried to run does not exist.
##### Arguments
- `string`: Name of the command.
#### `cmdna`: Command not allowed
Informs the client that they are not allowed to use a command.
##### Arguments
- `string`: Name of the command.
#### `cmderr`: Command format error
Informs the client that the command they tried to run was incorrectly formatted.
##### Arguments
- `string`: Name of the command.
#### `usernf`: User not found
Informs the client that the user argument of a command contains a user that is not known by the server.
##### Arguments
- `string`: Name of the target user.
#### `rankerr`: Rank error
Informs the client that they are not allowed to do something because their ranking is too low.
#### `nameinuse`: Name in use
Informs the the client that the name they attempted to choose is already in use by another user.
##### Arguments
- `string`: Name that is in use.
#### `whoerr`: User listing error
Informs the client that they do not have access to the channel they tried to query.
##### Arguments
- `string`: Name of the channel.
#### `kickna`: Kick or ban not allowed
Informs the client that they are not allowed to kick a user.
##### Arguments
- `string`: Username of the user in question.
#### `notban`: Not banned
Informs the client that the ban they tried to revoke was not in place.
##### Arguments
- `string`: Username or IP address in question.
#### `nochan`: Channel not found
Informs the client that the channel they tried to join does not exist.
##### Arguments
- `string`: Name of the channel.
#### `samechan`: Already in channel
Informs the client that they attempted to join a channel they are already in.
##### Arguments
- `string`: Name of the channel.
#### `ipchan`: Channel join not allowed
Informs the client that they do not have sufficient rank or permissions to join a channel.
##### Arguments
- `string`: Name of the channel.
#### `nopwchan`: No password provided
Informs the client that they must specify a password to join a channel.
##### Arguments
- `string`: Name of the channel.
#### `ipwchan`: No password provided
Informs the client that the password they provided to join a channel was invalid.
##### Arguments
- `string`: Name of the channel.
#### `inchan`: Invalid channel name
Informs the client that the name they tried to give to a channel contains invalid characters.
#### `nischan`: Channel name in use
Informs the client that the name they tried to give to a channel is already used by another channel.
##### Arguments
- `string`: Name of the channel.
#### `ndchan`: Channel deletion error
Informs the client that they are not allowed to delete a channel.
##### Arguments
- `string`: Name of the channel.
#### `namchan`: Channel edit error
Informs the client that they are not allowed to edit a channel.
##### Arguments
- `string`: Name of the channel.
#### `delerr`: Message deletion error
Informs the client that they are not allowed to delete a message.
## Commands
Actions sent through messages prefixed with `/`. Arguments are described as `[name]`, optional arguments as `[name?]`. The `.` character is ignored in command names (replaced by nothing).
### `/afk`: Setting status to away
Marks the current user as afk, the first 5 characters from the user string are prefixed uppercase to the current username prefixed by `&amp;lt;` and suffixed by `&amp;gt;_` resulting in a username that looks like `&lt;AWAY&gt;_flash` if `/afk away` is ran by the user `flash`. If no reason is specified "`AFK`" is used.
#### Format
```
/afk [reason?]
```
### `/nick`: Change nickname
Temporarily changes the user's nickname, generally with a prefix such as `~` to avoid name clashing with real users. If the user's original name or no argument at all is specified, the command returns the user's name to its original state without the prefix.
#### Format
```
/nick [name?]
```
#### Responses
- `cmdna`: User is not allowed to change their own nickname.
- `nameinuse`: The specified nickname is already in use by another user.
- `nick`: Username has changed.
### `/msg`: Sending a Private Message
Sends a private message to another user.
#### Format
```
/msg [username] [message]
```
#### Aliases
- `/whisper`
#### Responses
- `cmderr`: Missing username and/or message arguments.
- `usernf`: Target user could not be found by the server.
### `/me`: Describing an action
Sends a message but with flags `11000` instead of the regular `10010`, used to describe an action.
#### Format
```
/me [message]
```
#### Aliases
- `/action`
### `/who`: Requesting a user list
Requests a list of users either currently online on the server in general or in a channel. If no argument is specified it'll return all users on the server, if a channel is specified it'll return all users in that channel.
#### Format
```
/who [channel?]
```
#### Responses
- `nochan`: The given channel does not exist.
- `whoerr`: The user does not have access to the channel.
- `whochan`: Listing of users in the channel.
- `who`: Listing of users in the server.
### `/delete`: Deleting a message or channel
Due to an oversight in the original implementation, this command was specified to be both the command for deleting messages and for channels. Fortunately messages always have numeric IDs and channels must start with an alphabetic character. Thus if the argument is entirely numeric this function acts as an alias for `/delmsg`, otherwise `/delchan`.
#### Format
```
/delete [channel name or message id]
```
#### Responses
Inherits the responses of whichever command is forwarded to.
### `/join`: Joining a channel
Switches or joins the current user to a different channel.
#### Format
```
/join [channel] [password?]
```
#### Responses
- `nochan`: The given channel does not exist.
- `ipchan`: The client does not have the required rank to enter the given channel.
- `nopwchan`: A password is required to enter the given channel.
- `ipwchan`: The password provided was invalid.
### `/leave`: Leaving a channel
Leave a specified channel.
#### Format
```
/leave [channel]
```
#### Responses
- `nocmd`: The client tried to run this command without specifying the `MCHAN` capability.
### `/create`: Creating a channel
Creates a new channel.
#### Format
```
/create [rank?] [name...]
```
If the first argument is numeric, it is taken as the minimum required rank to join the channel. All further arguments are glued with underscores to create the channel name.
#### Responses
- `cmdna`: The client is not allowed to create channels.
- `cmderr`: The command is formatted incorrectly.
- `rankerr`: The specified rank is higher than the client's own rank.
- `inchan`: The given channel name contains invalid characters.
- `nischan`: A channel with this name already exists.
- `crchan`: The channel has been created successfully.
### `/delchan`: Deleting a channel
Deletes an existing channel.
#### Format
```
/delchan [name]
```
#### Responses
- `cmderr`: The command is formatted incorrectly.
- `nochan`: No channel exists with this name.
- `ndchan`: The client is not allowed to delete this channel.
- `delchan`: The target channel has been deleted.
### `/password`: Update channel password
Changes the password for a channel. Removes the password if no argument is given.
#### Format
```
/password [password?]
```
#### Aliases
- `/pwd`
#### Responses
- `cmdna`: The client is not allowed to change the password for this channel.
- `cpwdchan`: The password of the channel has been successfully updated.
### `/rank`: Update channel minimum rank
Changes what user rank is required to enter a channel.
#### Format
```
/rank [rank]
```
#### Aliases
- `/privilege`
- `/priv`
#### Responses
- `cmdna`: The client is not allowed to change the rank of the target channel.
- `rankerr`: Missing rank argument or the given rank is higher than the client's own rank.
- `cprivchan`: The minimum rank of the channel has been successfully updated.
### `/say`: Broadcast a message
Broadcasts a message as the server/chatbot to all users in all channels.
#### Format
```
/say [message]
```
#### Responses
- `cmdna`: The client is not allowed to broadcast messages.
- `say`: The broadcasted message.
### `/delmsg`: Deleting a message
Deletes a given message.
#### Format
```
/delmsg [message id]
```
#### Responses
- `cmdna`: The client is not allowed to delete messages.
- `cmderr`: The given message ID was invalid.
- `delerr`: The target message does not exist or the client is not allowed to delete this message.
### `/kick`: Kick a user
Kicks a user from the serer. If not time is specified, then kick expires immediately.
#### Format
```
/kick [username] [time?]
```
#### Responses
- `cmdna`: The client is not allowed to kick others.
- `usernf`: The target user could not be found on the server.
- `kickna`: The client is trying to kick someone who they are not allowed to kick, or someone that is currently banned.
- `cmderr`: The provided time is invalid.
### `/ban`: Bans a user
Bans a user and their IP addresses from the server. If no time is specified the ban will never expire.
#### Format
```
/ban [user] [time?]
```
#### Responses
- `cmdna`: The client is not allowed to kick others.
- `usernf`: The target user could not be found on the server.
- `kickna`: The client is trying to kick someone who they are not allowed to kick, or someone that is currently banned.
- `cmderr`: The provided time is invalid.
### `/pardon`: Revokes a user ban
Revokes a ban currently placed on a user.
#### Format
```
/pardon [user]
```
### Aliases
- `/unban`
#### Responses
- `cmdna`: The client is not allowed to revoke user bans.
- `notban`: The target user is not banned.
- `unban`: The ban on the target user has been successfully revoked.
### `/pardonip`: Revokes an IP address ban
Revokes a ban currently placed on an IP address.
#### Format
```
/pardonip [address]
```
#### Aliases
- `/unbanip`
#### Responses
- `cmdna`: The client is not allowed to revoke IP bans.
- `notban`: The target address is not banned.
- `unban`: The ban on the target address has been successfully revoked.
### `/bans`: List of bans
Retrieves a list of banned users and IP addresses.
#### Format
```
/bans
```
#### Aliases
- `/banned`
#### Responses
- `cmdna`: Not allowed to view banned users and IP addresses.
- `banlist`: The list of banned users and IP addresses.
### `/ip`: Retrieve IP addresses
Retrieves a user's IP addresses. If the user has multiple connections, multiple `ipaddr` responses may be sent.
#### Format
```
/ip [username]
```
#### Aliases
- `whois`
#### Responses
- `cmdna`: The client is not allowed to view IP addresses.
- `usernf`: The target user is not connected to the server.
- `ipaddr`: (One of) The target user's IP address(es).

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

@ -5,6 +5,16 @@ VisualStudioVersion = 17.2.32630.192
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat", "SharpChat\SharpChat.csproj", "{DDB24C19-B802-4C96-AC15-0449C6FC77F2}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DF7A7073-A67A-4D93-92C6-F9D0F95E2359}"
ProjectSection(SolutionItems) = preProject
.gitattributes = .gitattributes
.gitignore = .gitignore
LICENSE = LICENSE
Protocol.md = Protocol.md
README.md = README.md
start.sh = start.sh
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU

View file

@ -1,189 +0,0 @@
using SharpChat.Flashii;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
namespace SharpChat {
public interface IBan {
DateTimeOffset Expires { get; }
string ToString();
}
public class BannedUser : IBan {
public long UserId { get; set; }
public DateTimeOffset Expires { get; set; }
public string Username { get; set; }
public BannedUser() {
}
public BannedUser(FlashiiBan fb) {
UserId = fb.UserId;
Expires = fb.Expires;
Username = fb.Username;
}
public override string ToString() => Username;
}
public class BannedIPAddress : IBan {
public IPAddress Address { get; set; }
public DateTimeOffset Expires { get; set; }
public BannedIPAddress() {
}
public BannedIPAddress(FlashiiBan fb) {
Address = IPAddress.Parse(fb.UserIP);
Expires = fb.Expires;
}
public override string ToString() => Address.ToString();
}
public class BanManager : IDisposable {
private readonly List<IBan> BanList = new List<IBan>();
public readonly ChatContext Context;
public bool IsDisposed { get; private set; }
public BanManager(ChatContext context) {
Context = context;
RefreshFlashiiBans();
}
public void Add(ChatUser user, DateTimeOffset expires) {
if (expires <= DateTimeOffset.Now)
return;
lock (BanList) {
BannedUser ban = BanList.OfType<BannedUser>().FirstOrDefault(x => x.UserId == user.UserId);
if (ban == null)
Add(new BannedUser { UserId = user.UserId, Expires = expires, Username = user.Username });
else
ban.Expires = expires;
}
}
public void Add(IPAddress addr, DateTimeOffset expires) {
if (expires <= DateTimeOffset.Now)
return;
lock (BanList) {
BannedIPAddress ban = BanList.OfType<BannedIPAddress>().FirstOrDefault(x => x.Address.Equals(addr));
if (ban == null)
Add(new BannedIPAddress { Address = addr, Expires = expires });
else
ban.Expires = expires;
}
}
private void Add(IBan ban) {
if (ban == null)
return;
lock (BanList)
if (!BanList.Contains(ban))
BanList.Add(ban);
}
public void Remove(ChatUser user) {
lock(BanList)
BanList.RemoveAll(x => x is BannedUser ub && ub.UserId == user.UserId);
}
public void Remove(IPAddress addr) {
lock(BanList)
BanList.RemoveAll(x => x is BannedIPAddress ib && ib.Address.Equals(addr));
}
public void Remove(IBan ban) {
lock (BanList)
BanList.Remove(ban);
}
public DateTimeOffset Check(ChatUser user) {
if (user == null)
return DateTimeOffset.MinValue;
lock(BanList)
return BanList.OfType<BannedUser>().Where(x => x.UserId == user.UserId).FirstOrDefault()?.Expires ?? DateTimeOffset.MinValue;
}
public DateTimeOffset Check(IPAddress addr) {
if (addr == null)
return DateTimeOffset.MinValue;
lock (BanList)
return BanList.OfType<BannedIPAddress>().Where(x => x.Address.Equals(addr)).FirstOrDefault()?.Expires ?? DateTimeOffset.MinValue;
}
public BannedUser GetUser(string username) {
if (username == null)
return null;
if (!long.TryParse(username, out long userId))
userId = 0;
lock (BanList)
return BanList.OfType<BannedUser>().FirstOrDefault(x => x.Username.ToLowerInvariant() == username.ToLowerInvariant() || (userId > 0 && x.UserId == userId));
}
public BannedIPAddress GetIPAddress(IPAddress addr) {
lock (BanList)
return BanList.OfType<BannedIPAddress>().FirstOrDefault(x => x.Address.Equals(addr));
}
public void RemoveExpired() {
lock(BanList)
BanList.RemoveAll(x => x.Expires <= DateTimeOffset.Now);
}
public void RefreshFlashiiBans() {
FlashiiBan.GetList(SockChatServer.HttpClient).ContinueWith(x => {
if(x.IsFaulted) {
Logger.Write($@"Ban Refresh: {x.Exception}");
return;
}
if(!x.Result.Any())
return;
lock(BanList) {
foreach(FlashiiBan fb in x.Result) {
if(!BanList.OfType<BannedUser>().Any(x => x.UserId == fb.UserId))
Add(new BannedUser(fb));
if(!BanList.OfType<BannedIPAddress>().Any(x => x.Address.ToString() == fb.UserIP))
Add(new BannedIPAddress(fb));
}
}
});
}
public IEnumerable<IBan> All() {
lock (BanList)
return BanList.ToList();
}
~BanManager()
=> Dispose(false);
public void Dispose()
=> Dispose(true);
private void Dispose(bool disposing) {
if (IsDisposed)
return;
IsDisposed = true;
BanList.Clear();
if (disposing)
GC.SuppressFinalize(this);
}
}
}

View file

@ -1,160 +0,0 @@
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public class ChannelException : Exception { }
public class ChannelExistException : ChannelException { }
public class ChannelInvalidNameException : ChannelException { }
public class ChannelManager : IDisposable {
private readonly List<ChatChannel> Channels = new List<ChatChannel>();
public readonly ChatContext Context;
public bool IsDisposed { get; private set; }
public ChannelManager(ChatContext context) {
Context = context;
}
private ChatChannel _DefaultChannel;
public ChatChannel DefaultChannel {
get {
if (_DefaultChannel == null)
_DefaultChannel = Channels.FirstOrDefault();
return _DefaultChannel;
}
set {
if (value == null)
return;
if (Channels.Contains(value))
_DefaultChannel = value;
}
}
public void Add(ChatChannel channel) {
if (channel == null)
throw new ArgumentNullException(nameof(channel));
if (!channel.Name.All(c => char.IsLetter(c) || char.IsNumber(c) || c == '-'))
throw new ChannelInvalidNameException();
if (Get(channel.Name) != null)
throw new ChannelExistException();
// Add channel to the listing
Channels.Add(channel);
// Set as default if there's none yet
if (_DefaultChannel == null)
_DefaultChannel = channel;
// Broadcast creation of channel
foreach (ChatUser user in Context.Users.OfHierarchy(channel.Rank))
user.Send(new ChannelCreatePacket(channel));
}
public void Remove(ChatChannel channel) {
if (channel == null || channel == DefaultChannel)
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 channel.GetUsers()) {
Context.SwitchChannel(user, DefaultChannel, string.Empty);
}
// Broadcast deletion of channel
foreach (ChatUser user in Context.Users.OfHierarchy(channel.Rank))
user.Send(new ChannelDeletePacket(channel));
}
public bool Contains(ChatChannel chan) {
if (chan == null)
return false;
lock (Channels)
return Channels.Contains(chan) || Channels.Any(c => c.Name.ToLowerInvariant() == chan.Name.ToLowerInvariant());
}
public void Update(ChatChannel channel, string name = null, 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));
string prevName = channel.Name;
int prevHierarchy = channel.Rank;
bool nameUpdated = !string.IsNullOrWhiteSpace(name) && name != prevName;
if (nameUpdated) {
if (!name.All(c => char.IsLetter(c) || char.IsNumber(c) || c == '-'))
throw new ChannelInvalidNameException();
if (Get(name) != null)
throw new ChannelExistException();
channel.Name = name;
}
if (temporary.HasValue)
channel.IsTemporary = temporary.Value;
if (hierarchy.HasValue)
channel.Rank = hierarchy.Value;
if (password != null)
channel.Password = password;
// 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 Context.Users.OfHierarchy(channel.Rank)) {
user.Send(new ChannelUpdatePacket(prevName, channel));
if (nameUpdated)
user.ForceChannel();
}
}
public ChatChannel Get(string name) {
if (string.IsNullOrWhiteSpace(name))
return null;
return Channels.FirstOrDefault(x => x.Name.ToLowerInvariant() == name.ToLowerInvariant());
}
public IEnumerable<ChatChannel> GetUser(ChatUser user) {
if (user == null)
return null;
return Channels.Where(x => x.HasUser(user));
}
public IEnumerable<ChatChannel> OfHierarchy(int hierarchy) {
lock (Channels)
return Channels.Where(c => c.Rank <= hierarchy).ToList();
}
~ChannelManager()
=> Dispose(false);
public void Dispose()
=> Dispose(true);
private void Dispose(bool disposing) {
if (IsDisposed)
return;
IsDisposed = true;
Channels.Clear();
if (disposing)
GC.SuppressFinalize(this);
}
}
}

View file

@ -1,98 +1,42 @@
using System.Collections.Generic;
using System;
using System.Linq;
using System.Text;
namespace SharpChat {
public class ChatChannel : IPacketTarget {
public string Name { get; set; }
public string Password { get; set; } = string.Empty;
public bool IsTemporary { get; set; } = false;
public int Rank { get; set; } = 0;
public ChatUser Owner { get; set; } = null;
private List<ChatUser> Users { get; } = new List<ChatUser>();
private List<ChatChannelTyping> Typing { get; } = new List<ChatChannelTyping>();
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 string TargetName => Name;
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() {
}
public ChatChannel(string name) {
public ChatChannel(
string name,
string password = null,
bool isTemporary = false,
int rank = 0,
long ownerId = 0
) {
Name = name;
}
public bool HasUser(ChatUser user) {
lock (Users)
return Users.Contains(user);
}
public void UserJoin(ChatUser user) {
if (!user.InChannel(this)) {
// Remove this, a different means for this should be established for V1 compat.
user.Channel?.UserLeave(user);
user.JoinChannel(this);
}
lock (Users) {
if (!HasUser(user))
Users.Add(user);
}
}
public void UserLeave(ChatUser user) {
lock (Users)
Users.Remove(user);
if (user.InChannel(this))
user.LeaveChannel(this);
}
public void Send(IServerPacket packet) {
lock (Users) {
foreach (ChatUser user in Users)
user.Send(packet);
}
}
public IEnumerable<ChatUser> GetUsers(IEnumerable<ChatUser> exclude = null) {
lock (Users) {
IEnumerable<ChatUser> users = Users.OrderByDescending(x => x.Rank);
if (exclude != null)
users = users.Except(exclude);
return users.ToList();
}
}
public bool IsTyping(ChatUser user) {
if(user == null)
return false;
lock(Typing)
return Typing.Any(x => x.User == user && !x.HasExpired);
}
public bool CanType(ChatUser user) {
if(user == null || !HasUser(user))
return false;
return !IsTyping(user);
}
public ChatChannelTyping RegisterTyping(ChatUser user) {
if(user == null || !HasUser(user))
return null;
ChatChannelTyping typing = new ChatChannelTyping(user);
lock(Typing) {
Typing.RemoveAll(x => x.HasExpired);
Typing.Add(typing);
}
return typing;
Password = password ?? string.Empty;
IsTemporary = isTemporary;
Rank = rank;
OwnerId = ownerId;
}
public string Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append(Name);
sb.Append('\t');
@ -102,5 +46,27 @@ namespace SharpChat {
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,18 +0,0 @@
using System;
namespace SharpChat {
public class ChatChannelTyping {
public static TimeSpan Lifetime { get; } = TimeSpan.FromSeconds(5);
public ChatUser User { get; }
public DateTimeOffset Started { get; }
public bool HasExpired
=> DateTimeOffset.Now - Started > Lifetime;
public ChatChannelTyping(ChatUser user) {
User = user ?? throw new ArgumentNullException(nameof(user));
Started = DateTimeOffset.Now;
}
}
}

View file

@ -1,55 +1,79 @@
namespace SharpChat {
public class ChatColour {
public const int INHERIT = 0x40000000;
using System.Diagnostics.CodeAnalysis;
public int Raw { get; set; }
namespace SharpChat {
public struct ChatColour {
public byte Red { get; }
public byte Green { get; }
public byte Blue { get; }
public bool Inherits { get; }
public ChatColour(bool inherit = true) {
Inherit = inherit;
public static ChatColour None { get; } = new();
public ChatColour() {
Red = 0;
Green = 0;
Blue = 0;
Inherits = true;
}
public ChatColour(int colour) {
Raw = colour;
public ChatColour(byte red, byte green, byte blue) {
Red = red;
Green = green;
Blue = blue;
Inherits = false;
}
public bool Inherit {
get => (Raw & INHERIT) > 0;
set {
if (value)
Raw |= INHERIT;
else
Raw &= ~INHERIT;
}
public override bool Equals([NotNullWhen(true)] object obj) {
return obj is ChatColour colour && Equals(colour);
}
public int Red {
get => (Raw >> 16) & 0xFF;
set {
Raw &= ~0xFF0000;
Raw |= (value & 0xFF) << 16;
}
public bool Equals(ChatColour other) {
return Red == other.Red
&& Green == other.Green
&& Blue == other.Blue
&& Inherits == other.Inherits;
}
public int Green {
get => (Raw >> 8) & 0xFF;
set {
Raw &= ~0xFF00;
Raw |= (value & 0xFF) << 8;
}
}
public int Blue {
get => Raw & 0xFF;
set {
Raw &= ~0xFF;
Raw |= value & 0xFF;
}
public override int GetHashCode() {
return ToMisuzu();
}
public override string ToString() {
if (Inherit)
return @"inherit";
return string.Format(@"#{0:X6}", Raw);
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

@ -0,0 +1,53 @@
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

@ -0,0 +1,96 @@
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,116 +1,266 @@
using SharpChat.Events;
using SharpChat.Flashii;
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 : IDisposable, IPacketTarget {
public bool IsDisposed { get; private set; }
public class ChatContext {
public record ChannelUserAssoc(long UserId, string ChannelName);
public SockChatServer Server { get; }
public Timer BumpTimer { get; }
public BanManager Bans { get; }
public ChannelManager Channels { get; }
public UserManager Users { get; }
public ChatEventManager Events { get; }
public readonly SemaphoreSlim ContextAccess = new(1, 1);
public string TargetName => @"@broadcast";
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(SockChatServer server) {
Server = server;
Bans = new BanManager(this);
Users = new UserManager(this);
Channels = new ChannelManager(this);
Events = new ChatEventManager(this);
public ChatContext(IEventStorage evtStore) {
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
}
BumpTimer = new Timer(e => FlashiiBump.Submit(SockChatServer.HttpClient, Users.WithActiveConnections()), null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
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() {
Bans.RemoveExpired();
CheckPings();
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 BanUser(ChatUser user, DateTimeOffset? until = null, bool banIPs = false, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
if (until.HasValue && until.Value <= DateTimeOffset.UtcNow)
until = null;
public void SafeUpdate() {
ContextAccess.Wait();
try {
Update();
} finally {
ContextAccess.Release();
}
}
if (until.HasValue) {
user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Banned, until.Value));
Bans.Add(user, until.Value);
public bool IsInChannel(ChatUser user, ChatChannel channel) {
return ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name));
}
if (banIPs) {
foreach (IPAddress ip in user.RemoteAddresses)
Bans.Add(ip, until.Value);
}
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
user.Send(new ForceDisconnectPacket(ForceDisconnectReason.Kicked));
SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Kicked));
user.Close();
UserLeave(user.Channel, user, reason);
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, ChatUserSession sess) {
if (!chan.HasUser(user)) {
chan.Send(new UserConnectPacket(DateTimeOffset.Now, user));
Events.Add(new UserConnectEvent(DateTimeOffset.Now, user, chan));
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);
}
sess.Send(new AuthSuccessPacket(user, chan, sess));
sess.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
conn.Send(new AuthSuccessPacket(user, chan, conn, maxMsgLength));
conn.Send(new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
IEnumerable<IChatEvent> msgs = Events.GetTargetLog(chan);
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
conn.Send(new ContextMessagePacket(msg));
foreach(IChatEvent msg in msgs)
sess.Send(new ContextMessagePacket(msg));
conn.Send(new ContextChannelsPacket(Channels.Where(c => c.Rank <= user.Rank)));
sess.Send(new ContextChannelsPacket(Channels.OfHierarchy(user.Rank)));
Users.Add(user);
if (!chan.HasUser(user))
chan.UserJoin(user);
if (!Users.Contains(user))
Users.Add(user);
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
}
public void UserLeave(ChatChannel chan, ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
user.Status = ChatUserStatus.Offline;
public void HandleDisconnect(ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
UpdateUser(user, status: ChatUserStatus.Offline);
Users.Remove(user);
UserLastChannel.Remove(user.UserId);
if (chan == null) {
foreach(ChatChannel channel in user.GetChannels()) {
UserLeave(channel, user, reason);
}
return;
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);
}
if (chan.IsTemporary && chan.Owner == user)
Channels.Remove(chan);
chan.UserLeave(user);
chan.Send(new UserDisconnectPacket(DateTimeOffset.Now, user, reason));
Events.Add(new UserDisconnectEvent(DateTimeOffset.Now, user, chan, reason));
}
public void SwitchChannel(ChatUser user, ChatChannel chan, string password) {
if (user.CurrentChannel == chan) {
//user.Send(true, @"samechan", chan.Name);
user.ForceChannel();
if(UserLastChannel.TryGetValue(user.UserId, out ChatChannel ulc) && chan == ulc) {
ForceChannel(user);
return;
}
if (!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.Owner != user) {
if (chan.Rank > user.Rank) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
user.ForceChannel();
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 (chan.Password != password) {
user.Send(new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
user.ForceChannel();
if(!string.IsNullOrEmpty(chan.Password) && chan.Password != password) {
SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
ForceChannel(user);
return;
}
}
@ -119,72 +269,130 @@ namespace SharpChat {
}
public void ForceChannelSwitch(ChatUser user, ChatChannel chan) {
if (!Channels.Contains(chan))
if(!Channels.Contains(chan))
return;
ChatChannel oldChan = user.CurrentChannel;
ChatChannel oldChan = UserLastChannel[user.UserId];
oldChan.Send(new UserChannelLeavePacket(user));
Events.Add(new UserChannelLeaveEvent(DateTimeOffset.Now, user, oldChan));
chan.Send(new UserChannelJoinPacket(user));
Events.Add(new UserChannelJoinEvent(DateTimeOffset.Now, user, chan));
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);
user.Send(new ContextClearPacket(chan, ContextClearMode.MessagesUsers));
user.Send(new ContextUsersPacket(chan.GetUsers(new[] { user })));
SendTo(user, new ContextClearPacket(chan, ContextClearMode.MessagesUsers));
SendTo(user, new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
IEnumerable<IChatEvent> msgs = Events.GetTargetLog(chan);
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
SendTo(user, new ContextMessagePacket(msg));
foreach (IChatEvent msg in msgs)
user.Send(new ContextMessagePacket(msg));
ForceChannel(user, chan);
user.ForceChannel(chan);
oldChan.UserLeave(user);
chan.UserJoin(user);
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
UserLastChannel[user.UserId] = chan;
if (oldChan.IsTemporary && oldChan.Owner == user)
Channels.Remove(oldChan);
}
public void CheckPings() {
lock(Users)
foreach (ChatUser user in Users.All()) {
IEnumerable<ChatUserSession> timedOut = user.GetDeadSessions();
foreach(ChatUserSession sess in timedOut) {
user.RemoveSession(sess);
sess.Dispose();
Logger.Write($@"Nuked session {sess.Id} from {user.Username} (timeout)");
}
if(!user.HasSessions)
UserLeave(null, user, UserDisconnectReason.TimeOut);
}
if(oldChan.IsTemporary && oldChan.IsOwner(user))
RemoveChannel(oldChan);
}
public void Send(IServerPacket packet) {
foreach (ChatUser user in Users.All())
user.Send(packet);
if(packet == null)
throw new ArgumentNullException(nameof(packet));
foreach(ChatConnection conn in Connections)
if(conn.IsAuthed)
conn.Send(packet);
}
~ChatContext()
=> Dispose(false);
public void SendTo(ChatUser user, IServerPacket packet) {
if(user == null)
throw new ArgumentNullException(nameof(user));
if(packet == null)
throw new ArgumentNullException(nameof(packet));
public void Dispose()
=> Dispose(true);
foreach(ChatConnection conn in Connections)
if(conn.IsAlive && conn.User == user)
conn.Send(packet);
}
private void Dispose(bool disposing) {
if (IsDisposed)
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;
IsDisposed = true;
BumpTimer?.Dispose();
Events?.Dispose();
Channels?.Dispose();
Users?.Dispose();
Bans?.Dispose();
ChatChannel defaultChannel = Channels.FirstOrDefault();
if(defaultChannel == null)
return;
if (disposing)
GC.SuppressFinalize(this);
// 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,100 +0,0 @@
using SharpChat.Events;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public class ChatEventManager : IDisposable {
private readonly List<IChatEvent> Events = null;
public readonly ChatContext Context;
public bool IsDisposed { get; private set; }
public ChatEventManager(ChatContext context) {
Context = context;
if (!Database.HasDatabase)
Events = new List<IChatEvent>();
}
public void Add(IChatEvent evt) {
if (evt == null)
throw new ArgumentNullException(nameof(evt));
if(Events != null)
lock(Events)
Events.Add(evt);
if(Database.HasDatabase)
Database.LogEvent(evt);
}
public void Remove(IChatEvent evt) {
if (evt == null)
return;
if (Events != null)
lock (Events)
Events.Remove(evt);
if (Database.HasDatabase)
Database.DeleteEvent(evt);
Context.Send(new ChatMessageDeletePacket(evt.SequenceId));
}
public IChatEvent Get(long seqId) {
if (seqId < 1)
return null;
if (Database.HasDatabase)
return Database.GetEvent(seqId);
if (Events != null)
lock (Events)
return Events.FirstOrDefault(e => e.SequenceId == seqId);
return null;
}
public IEnumerable<IChatEvent> GetTargetLog(IPacketTarget target, int amount = 20, int offset = 0) {
if (Database.HasDatabase)
return Database.GetEvents(target, amount, offset).Reverse();
if (Events != null)
lock (Events) {
IEnumerable<IChatEvent> subset = Events.Where(e => e.Target == target || e.Target == null);
int start = subset.Count() - offset - amount;
if(start < 0) {
amount += start;
start = 0;
}
return subset.Skip(start).Take(amount).ToList();
}
return Enumerable.Empty<IChatEvent>();
}
~ChatEventManager()
=> Dispose(false);
public void Dispose()
=> Dispose(true);
private void Dispose(bool disposing) {
if (IsDisposed)
return;
IsDisposed = true;
Events?.Clear();
if (disposing)
GC.SuppressFinalize(this);
}
}
}

View file

@ -0,0 +1,27 @@
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,46 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat {
public enum ChatRateLimitState {
None,
Warning,
Kick,
}
public class ChatRateLimiter {
private const int FLOOD_PROTECTION_AMOUNT = 30;
private const int FLOOD_PROTECTION_THRESHOLD = 10;
private readonly Queue<DateTimeOffset> TimePoints = new Queue<DateTimeOffset>();
public ChatRateLimitState State {
get {
lock (TimePoints) {
if (TimePoints.Count == FLOOD_PROTECTION_AMOUNT) {
if ((TimePoints.Last() - TimePoints.First()).TotalSeconds <= FLOOD_PROTECTION_THRESHOLD)
return ChatRateLimitState.Kick;
if ((TimePoints.Last() - TimePoints.Skip(5).First()).TotalSeconds <= FLOOD_PROTECTION_THRESHOLD)
return ChatRateLimitState.Warning;
}
return ChatRateLimitState.None;
}
}
}
public void AddTimePoint(DateTimeOffset? dto = null) {
if (!dto.HasValue)
dto = DateTimeOffset.Now;
lock (TimePoints) {
if (TimePoints.Count >= FLOOD_PROTECTION_AMOUNT)
TimePoints.Dequeue();
TimePoints.Enqueue(dto.Value);
}
}
}
}

View file

@ -1,215 +1,110 @@
using SharpChat.Flashii;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net;
using System;
using System.Text;
namespace SharpChat {
public class BasicUser : IEquatable<BasicUser> {
private const int RANK_NO_FLOOD = 9;
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; set; }
public string Username { get; set; }
public long UserId { get; }
public string UserName { get; set; }
public ChatColour Colour { get; set; }
public int Rank { get; set; }
public string Nickname { get; set; }
public ChatUserPermissions Permissions { get; set; }
public ChatUserStatus Status { get; set; } = ChatUserStatus.Online;
public string StatusMessage { get; set; }
public bool IsSuper { get; set; }
public string NickName { get; set; }
public ChatUserStatus Status { get; set; }
public string StatusText { get; set; }
public bool HasFloodProtection
=> Rank < RANK_NO_FLOOD;
public string LegacyName => string.IsNullOrWhiteSpace(NickName) ? UserName : $"~{NickName}";
public bool Equals([AllowNull] BasicUser other)
=> UserId == other.UserId;
public string DisplayName {
public string LegacyNameWithStatus {
get {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
if(Status == ChatUserStatus.Away)
sb.AppendFormat(@"&lt;{0}&gt;_", StatusMessage.Substring(0, Math.Min(StatusMessage.Length, 5)).ToUpperInvariant());
sb.AppendFormat("&lt;{0}&gt;_", StatusText[..Math.Min(StatusText.Length, 5)].ToUpperInvariant());
if(string.IsNullOrWhiteSpace(Nickname))
sb.Append(Username);
else {
sb.Append('~');
sb.Append(Nickname);
}
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 StringBuilder();
StringBuilder sb = new();
sb.Append(UserId);
sb.Append('\t');
sb.Append(DisplayName);
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(@" 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
sb.Append(Can(ChatUserPermissions.CreateChannel | ChatUserPermissions.SetChannelPermanent, true) ? '2' : (
Can(ChatUserPermissions.CreateChannel) ? '1' : '0'
));
return sb.ToString();
}
}
public class ChatUser : BasicUser, IPacketTarget {
public DateTimeOffset SilencedUntil { get; set; }
private readonly List<ChatUserSession> Sessions = new List<ChatUserSession>();
private readonly List<ChatChannel> Channels = new List<ChatChannel>();
public readonly ChatRateLimiter RateLimiter = new ChatRateLimiter();
public string TargetName => @"@log";
public ChatChannel Channel {
get {
lock(Channels)
return Channels.FirstOrDefault();
}
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);
}
// This needs to be a session thing
public ChatChannel CurrentChannel { get; private set; }
public bool IsSilenced
=> DateTimeOffset.UtcNow - SilencedUntil <= TimeSpan.Zero;
public bool HasSessions {
get {
lock(Sessions)
return Sessions.Where(c => !c.HasTimedOut && !c.IsDisposed).Any();
}
public override int GetHashCode() {
return UserId.GetHashCode();
}
public int SessionCount {
get {
lock (Sessions)
return Sessions.Where(c => !c.HasTimedOut && !c.IsDisposed).Count();
}
public override bool Equals(object obj) {
return Equals(obj as ChatUser);
}
public IEnumerable<IPAddress> RemoteAddresses {
get {
lock(Sessions)
return Sessions.Select(c => c.RemoteAddress);
}
public bool Equals(ChatUser other) {
return UserId == other?.UserId;
}
public ChatUser() {
}
public ChatUser(FlashiiAuth auth) {
UserId = auth.UserId;
ApplyAuth(auth, true);
}
public void ApplyAuth(FlashiiAuth auth, bool invalidateRestrictions = false) {
Username = auth.Username;
if (Status == ChatUserStatus.Offline)
Status = ChatUserStatus.Online;
Colour = new ChatColour(auth.ColourRaw);
Rank = auth.Rank;
Permissions = auth.Permissions;
if (invalidateRestrictions || !IsSilenced)
SilencedUntil = auth.SilencedUntil;
}
public void Send(IServerPacket packet) {
lock(Sessions)
foreach (ChatUserSession conn in Sessions)
conn.Send(packet);
}
public void Close() {
lock (Sessions) {
foreach (ChatUserSession conn in Sessions)
conn.Dispose();
Sessions.Clear();
}
}
public void ForceChannel(ChatChannel chan = null)
=> Send(new UserChannelForceJoinPacket(chan ?? CurrentChannel));
public void FocusChannel(ChatChannel chan) {
lock(Channels) {
if(InChannel(chan))
CurrentChannel = chan;
}
}
public bool InChannel(ChatChannel chan) {
lock (Channels)
return Channels.Contains(chan);
}
public void JoinChannel(ChatChannel chan) {
lock (Channels) {
if(!InChannel(chan)) {
Channels.Add(chan);
CurrentChannel = chan;
}
}
}
public void LeaveChannel(ChatChannel chan) {
lock(Channels) {
Channels.Remove(chan);
CurrentChannel = Channels.FirstOrDefault();
}
}
public IEnumerable<ChatChannel> GetChannels() {
lock (Channels)
return Channels.ToList();
}
public void AddSession(ChatUserSession sess) {
if (sess == null)
return;
sess.User = this;
lock (Sessions)
Sessions.Add(sess);
}
public void RemoveSession(ChatUserSession sess) {
if (sess == null)
return;
if(!sess.IsDisposed) // this could be possible
sess.User = null;
lock(Sessions)
Sessions.Remove(sess);
}
public IEnumerable<ChatUserSession> GetDeadSessions() {
lock (Sessions)
return Sessions.Where(x => x.HasTimedOut || x.IsDisposed).ToList();
public static string GetDMChannelName(ChatUser user1, ChatUser user2) {
return user1.UserId < user2.UserId
? $"@{user1.UserId}-{user2.UserId}"
: $"@{user2.UserId}-{user1.UserId}";
}
}
}

View file

@ -5,7 +5,7 @@ namespace SharpChat {
public enum ChatUserPermissions : int {
KickUser = 0x00000001,
BanUser = 0x00000002,
SilenceUser = 0x00000004,
//SilenceUser = 0x00000004,
Broadcast = 0x00000008,
SetOwnNickname = 0x00000010,
SetOthersNickname = 0x00000020,
@ -21,5 +21,6 @@ namespace SharpChat {
EditOwnMessage = 0x00002000,
EditAnyMessage = 0x00004000,
SeeIPAddress = 0x00008000,
ViewLogs = 0x00040000,
}
}

View file

@ -1,94 +0,0 @@
using Fleck;
using System;
using System.Collections.Generic;
using System.Net;
namespace SharpChat {
public class ChatUserSession : IDisposable, IPacketTarget {
public const int ID_LENGTH = 32;
#if DEBUG
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1);
#else
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5);
#endif
public IWebSocketConnection Connection { get; }
public string Id { get; private set; }
public bool IsDisposed { get; private set; }
public DateTimeOffset LastPing { get; set; } = DateTimeOffset.MinValue;
public ChatUser User { get; set; }
private static int CloseCode { get; set; } = 1000;
public string TargetName => @"@log";
private IPAddress _RemoteAddress = null;
public IPAddress RemoteAddress {
get {
if(_RemoteAddress == null) {
if((Connection.ConnectionInfo.ClientIpAddress == @"127.0.0.1" || Connection.ConnectionInfo.ClientIpAddress == @"::1")
&& Connection.ConnectionInfo.Headers.ContainsKey(@"X-Real-IP"))
_RemoteAddress = IPAddress.Parse(Connection.ConnectionInfo.Headers[@"X-Real-IP"]);
else
_RemoteAddress = IPAddress.Parse(Connection.ConnectionInfo.ClientIpAddress);
}
return _RemoteAddress;
}
}
public ChatUserSession(IWebSocketConnection ws) {
Connection = ws;
Id = GenerateId();
}
private static string GenerateId() {
byte[] buffer = new byte[ID_LENGTH];
RNG.NextBytes(buffer);
return buffer.GetIdString();
}
public void Send(IServerPacket packet) {
if(!Connection.IsAvailable)
return;
IEnumerable<string> data = packet.Pack();
if(data != null)
foreach(string line in data)
if(!string.IsNullOrWhiteSpace(line))
Connection.Send(line);
}
public void BumpPing()
=> LastPing = DateTimeOffset.Now;
public bool HasTimedOut
=> DateTimeOffset.Now - LastPing > SessionTimeOut;
public void PrepareForRestart()
=> CloseCode = 1012;
public void Dispose()
=> Dispose(true);
~ChatUserSession()
=> Dispose(false);
private void Dispose(bool disposing) {
if(IsDisposed)
return;
IsDisposed = true;
Connection.Close(CloseCode);
if(disposing)
GC.SuppressFinalize(this);
}
}
}

View file

@ -1,31 +1,30 @@
using SharpChat.Events;
using SharpChat.Packet;
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class AFKCommand : IChatCommand {
private const string DEFAULT = @"AFK";
private const string DEFAULT = "AFK";
private const int MAX_LENGTH = 5;
public bool IsMatch(string name) {
return name == @"afk";
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("afk");
}
public IChatMessage Dispatch(IChatCommandContext context) {
string statusText = context.Args.ElementAtOrDefault(1);
public void Dispatch(ChatCommandContext ctx) {
string statusText = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(statusText))
statusText = DEFAULT;
else {
statusText = statusText.Trim();
if(statusText.Length > MAX_LENGTH)
statusText = statusText.Substring(0, MAX_LENGTH).Trim();
statusText = statusText[..MAX_LENGTH].Trim();
}
context.User.Status = ChatUserStatus.Away;
context.User.StatusMessage = statusText;
context.Channel.Send(new UserUpdatePacket(context.User));
return null;
ctx.Chat.UpdateUser(
ctx.User,
status: ChatUserStatus.Away,
statusText: statusText
);
}
}
}

View file

@ -0,0 +1,30 @@
using SharpChat.Events;
using System;
using System.Linq;
namespace SharpChat.Commands {
public class ActionCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("action")
|| ctx.NameEquals("me");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.Args.Any())
return;
string actionStr = string.Join(' ', ctx.Args);
if(string.IsNullOrWhiteSpace(actionStr))
return;
ctx.Chat.DispatchEvent(new MessageCreateEvent(
SharpId.Next(),
ctx.Channel,
ctx.User,
DateTimeOffset.Now,
actionStr,
false, true, false
));
}
}
}

View file

@ -0,0 +1,32 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class BanListCommand : IChatCommand {
private readonly MisuzuClient Misuzu;
public BanListCommand(MisuzuClient msz) {
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
}
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("bans")
|| ctx.NameEquals("banned");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
Task.Run(async () => {
ctx.Chat.SendTo(ctx.User, new BanListPacket(
await Misuzu.GetBanListAsync()
));
}).Wait();
}
}
}

View file

@ -0,0 +1,28 @@
using SharpChat.Events;
using SharpChat.Packet;
using System;
namespace SharpChat.Commands {
public class BroadcastCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("say")
|| ctx.NameEquals("broadcast");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.Broadcast)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
ctx.Chat.DispatchEvent(new MessageCreateEvent(
SharpId.Next(),
string.Empty,
ctx.User,
DateTimeOffset.Now,
string.Join(' ', ctx.Args),
false, false, true
));
}
}
}

View file

@ -0,0 +1,60 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class CreateChannelCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("create");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.CreateChannel)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{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 LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
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 LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY));
return;
}
string createChanName = string.Join('_', ctx.Args.Skip(createChanHasHierarchy ? 1 : 0));
if(!ChatChannel.CheckName(createChanName)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_NAME_INVALID));
return;
}
if(ctx.Chat.Channels.Any(c => c.NameEquals(createChanName))) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_ALREADY_EXISTS, true, createChanName));
return;
}
ChatChannel createChan = new(
ctx.User, createChanName,
isTemporary: !ctx.User.Can(ChatUserPermissions.SetChannelPermanent),
rank: createChanHierarchy
);
ctx.Chat.Channels.Add(createChan);
foreach(ChatUser ccu in ctx.Chat.Users.Where(u => u.Rank >= ctx.Channel.Rank))
ctx.Chat.SendTo(ccu, new ChannelCreatePacket(ctx.Channel));
ctx.Chat.SwitchChannel(ctx.User, createChan, createChan.Password);
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_CREATED, false, createChan.Name));
}
}
}

View file

@ -0,0 +1,36 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class DeleteChannelCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("delchan") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false
);
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.Args.Any() || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
return;
}
string delChanName = string.Join('_', ctx.Args);
ChatChannel delChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(delChanName));
if(delChan == null) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, delChanName));
return;
}
if(!ctx.User.Can(ChatUserPermissions.DeleteChannel) && delChan.IsOwner(ctx.User)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_DELETE_FAILED, true, delChan.Name));
return;
}
ctx.Chat.RemoveChannel(delChan);
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_DELETED, false, delChan.Name));
}
}
}

View file

@ -0,0 +1,41 @@
using SharpChat.EventStorage;
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands
{
public class DeleteMessageCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("delmsg") || (
ctx.NameEquals("delete")
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true
);
}
public void Dispatch(ChatCommandContext ctx) {
bool deleteAnyMessage = ctx.User.Can(ChatUserPermissions.DeleteAnyMessage);
if(!deleteAnyMessage && !ctx.User.Can(ChatUserPermissions.DeleteOwnMessage)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string firstArg = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(firstArg) || !firstArg.All(char.IsDigit) || !long.TryParse(firstArg, out long delSeqId)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
return;
}
StoredEventInfo delMsg = ctx.Chat.Events.GetEvent(delSeqId);
if(delMsg == null || delMsg.Sender.Rank > ctx.User.Rank || (!deleteAnyMessage && delMsg.Sender.UserId != ctx.User.UserId)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.MESSAGE_DELETE_ERROR));
return;
}
ctx.Chat.Events.RemoveEvent(delMsg);
ctx.Chat.Send(new ChatMessageDeletePacket(delMsg.Id));
}
}
}

View file

@ -0,0 +1,23 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class JoinChannelCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("join");
}
public void Dispatch(ChatCommandContext ctx) {
string joinChanStr = ctx.Args.FirstOrDefault();
ChatChannel joinChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(joinChanStr));
if(joinChan == null) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, joinChanStr));
ctx.Chat.ForceChannel(ctx.User);
return;
}
ctx.Chat.SwitchChannel(ctx.User, joinChan, string.Join(' ', ctx.Args.Skip(1)));
}
}
}

View file

@ -0,0 +1,83 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class KickBanCommand : IChatCommand {
private readonly MisuzuClient Misuzu;
public KickBanCommand(MisuzuClient msz) {
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
}
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("kick")
|| ctx.NameEquals("ban");
}
public void Dispatch(ChatCommandContext ctx) {
bool isBanning = ctx.NameEquals("ban");
if(!ctx.User.Can(isBanning ? ChatUserPermissions.BanUser : ChatUserPermissions.KickUser)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string banUserTarget = ctx.Args.ElementAtOrDefault(0);
string banDurationStr = ctx.Args.ElementAtOrDefault(1);
int banReasonIndex = 1;
ChatUser banUser = null;
if(banUserTarget == null || (banUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(banUserTarget))) == null) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, banUser == null ? "User" : banUserTarget));
return;
}
if(!ctx.User.IsSuper && banUser.Rank >= ctx.User.Rank && banUser != ctx.User) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName));
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 LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
return;
}
duration = TimeSpan.FromSeconds(durationSeconds);
++banReasonIndex;
}
if(duration <= TimeSpan.Zero) {
ctx.Chat.BanUser(banUser, duration);
return;
}
string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex));
Task.Run(async () => {
string userId = banUser.UserId.ToString();
string userIp = ctx.Chat.GetRemoteAddresses(banUser).FirstOrDefault()?.ToString() ?? string.Empty;
// obviously it makes no sense to only check for one ip address but that's current misuzu limitations
MisuzuBanInfo fbi = await Misuzu.CheckBanAsync(userId, userIp);
if(fbi.IsBanned && !fbi.HasExpired) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.KICK_NOT_ALLOWED, true, banUser.LegacyName));
return;
}
await Misuzu.CreateBanAsync(
userId, userIp,
ctx.User.UserId.ToString(), ctx.Connection.RemoteAddress.ToString(),
duration, banReason
);
ctx.Chat.BanUser(banUser, duration);
}).Wait();
}
}
}

View file

@ -0,0 +1,53 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class NickCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("nick");
}
public void Dispatch(ChatCommandContext ctx) {
bool setOthersNick = ctx.User.Can(ChatUserPermissions.SetOthersNickname);
if(!setOthersNick && !ctx.User.Can(ChatUserPermissions.SetOwnNickname)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
ChatUser targetUser = null;
int offset = 0;
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
targetUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == targetUserId);
++offset;
}
targetUser ??= ctx.User;
if(ctx.Args.Length < offset) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
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];
else if(string.IsNullOrEmpty(nickStr))
nickStr = string.Empty;
if(!string.IsNullOrWhiteSpace(nickStr) && ctx.Chat.Users.Any(u => u.NameEquals(nickStr))) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.NAME_IN_USE, true, nickStr));
return;
}
string previousName = targetUser == ctx.User ? (targetUser.NickName ?? targetUser.UserName) : null;
ctx.Chat.UpdateUser(targetUser, nickName: nickStr, silent: previousName == null);
}
}
}

View file

@ -0,0 +1,51 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class PardonAddressCommand : IChatCommand {
private readonly MisuzuClient Misuzu;
public PardonAddressCommand(MisuzuClient msz) {
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
}
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("pardonip")
|| ctx.NameEquals("unbanip");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string unbanAddrTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress unbanAddr)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
return;
}
unbanAddrTarget = unbanAddr.ToString();
Task.Run(async () => {
MisuzuBanInfo banInfo = await Misuzu.CheckBanAsync(ipAddr: unbanAddrTarget);
if(!banInfo.IsBanned || banInfo.HasExpired) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanAddrTarget));
return;
}
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.RemoteAddress);
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanAddrTarget));
else
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanAddrTarget));
}).Wait();
}
}
}

View file

@ -0,0 +1,58 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.Commands {
public class PardonUserCommand : IChatCommand {
private readonly MisuzuClient Misuzu;
public PardonUserCommand(MisuzuClient msz) {
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
}
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("pardon")
|| ctx.NameEquals("unban");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.BanUser | ChatUserPermissions.KickUser)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
bool unbanUserTargetIsName = true;
string unbanUserTarget = ctx.Args.FirstOrDefault();
if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
return;
}
ChatUser unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(unbanUserTarget));
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId)) {
unbanUserTargetIsName = false;
unbanUser = ctx.Chat.Users.FirstOrDefault(u => u.UserId == unbanUserId);
}
if(unbanUser != null)
unbanUserTarget = unbanUser.UserId.ToString();
Task.Run(async () => {
MisuzuBanInfo banInfo = await Misuzu.CheckBanAsync(unbanUserTarget, userIdIsName: unbanUserTargetIsName);
if(!banInfo.IsBanned || banInfo.HasExpired) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanUserTarget));
return;
}
bool wasBanned = await Misuzu.RevokeBanAsync(banInfo, MisuzuClient.BanRevokeKind.UserId);
if(wasBanned)
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_UNBANNED, false, unbanUserTarget));
else
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_NOT_BANNED, true, unbanUserTarget));
}).Wait();
}
}
}

View file

@ -0,0 +1,25 @@
using SharpChat.Packet;
namespace SharpChat.Commands {
public class PasswordChannelCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("pwd")
|| ctx.NameEquals("password");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.SetChannelPassword) || ctx.Channel.IsOwner(ctx.User)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
string chanPass = string.Join(' ', ctx.Args).Trim();
if(string.IsNullOrWhiteSpace(chanPass))
chanPass = string.Empty;
ctx.Chat.UpdateChannel(ctx.Channel, password: chanPass);
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_PASSWORD_CHANGED, false));
}
}
}

View file

@ -0,0 +1,27 @@
using SharpChat.Packet;
using System.Linq;
namespace SharpChat.Commands {
public class RankChannelCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("rank")
|| ctx.NameEquals("privilege")
|| ctx.NameEquals("priv");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.SetChannelHierarchy) || ctx.Channel.IsOwner(ctx.User)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
if(!ctx.Args.Any() || !int.TryParse(ctx.Args.First(), out int chanHierarchy) || chanHierarchy > ctx.User.Rank) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.INSUFFICIENT_HIERARCHY));
return;
}
ctx.Chat.UpdateChannel(ctx.Channel, hierarchy: chanHierarchy);
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_HIERARCHY_CHANGED, false));
}
}
}

View file

@ -0,0 +1,30 @@
using SharpChat.Packet;
using System.Linq;
using System.Net;
namespace SharpChat.Commands {
public class RemoteAddressCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("ip")
|| ctx.NameEquals("whois");
}
public void Dispatch(ChatCommandContext ctx) {
if(!ctx.User.Can(ChatUserPermissions.SeeIPAddress)) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, "/ip"));
return;
}
string ipUserStr = ctx.Args.FirstOrDefault();
ChatUser ipUser;
if(string.IsNullOrWhiteSpace(ipUserStr) || (ipUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(ipUserStr))) == null) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, ipUserStr ?? "User"));
return;
}
foreach(IPAddress ip in ctx.Chat.GetRemoteAddresses(ipUser))
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.IP_ADDRESS, false, ipUser.UserName, ip));
}
}
}

View file

@ -0,0 +1,37 @@
using SharpChat.Packet;
using System;
using System.Threading;
namespace SharpChat.Commands {
public class ShutdownRestartCommand : IChatCommand {
private readonly ManualResetEvent WaitHandle;
private readonly Func<bool> ShutdownCheck;
public ShutdownRestartCommand(ManualResetEvent waitHandle, Func<bool> shutdownCheck) {
WaitHandle = waitHandle ?? throw new ArgumentNullException(nameof(waitHandle));
ShutdownCheck = shutdownCheck ?? throw new ArgumentNullException(nameof(shutdownCheck));
}
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("shutdown")
|| ctx.NameEquals("restart");
}
public void Dispatch(ChatCommandContext ctx) {
if(ctx.User.UserId != 1) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
return;
}
if(!ShutdownCheck())
return;
if(ctx.NameEquals("restart"))
foreach(ChatConnection conn in ctx.Chat.Connections)
conn.PrepareForRestart();
ctx.Chat.Update();
WaitHandle?.Set();
}
}
}

View file

@ -0,0 +1,40 @@
using SharpChat.Events;
using SharpChat.Packet;
using System;
using System.Linq;
namespace SharpChat.Commands {
public class WhisperCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("whisper")
|| ctx.NameEquals("msg");
}
public void Dispatch(ChatCommandContext ctx) {
if(ctx.Args.Length < 2) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.COMMAND_FORMAT_ERROR));
return;
}
string whisperUserStr = ctx.Args.FirstOrDefault();
ChatUser whisperUser = ctx.Chat.Users.FirstOrDefault(u => u.NameEquals(whisperUserStr));
if(whisperUser == null) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USER_NOT_FOUND, true, whisperUserStr));
return;
}
if(whisperUser == ctx.User)
return;
ctx.Chat.DispatchEvent(new MessageCreateEvent(
SharpId.Next(),
ChatUser.GetDMChannelName(ctx.User, whisperUser),
ctx.User,
DateTimeOffset.Now,
string.Join(' ', ctx.Args.Skip(1)),
true, false, false
));
}
}
}

View file

@ -0,0 +1,62 @@
using SharpChat.Packet;
using System.Linq;
using System.Text;
namespace SharpChat.Commands {
public class WhoCommand : IChatCommand {
public bool IsMatch(ChatCommandContext ctx) {
return ctx.NameEquals("who");
}
public void Dispatch(ChatCommandContext ctx) {
StringBuilder whoChanSB = new();
string whoChanStr = ctx.Args.FirstOrDefault();
if(string.IsNullOrEmpty(whoChanStr)) {
foreach(ChatUser whoUser in ctx.Chat.Users) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(whoUser == ctx.User)
whoChanSB.Append(@" style=""font-weight: bold;""");
whoChanSB.Append('>');
whoChanSB.Append(whoUser.LegacyName);
whoChanSB.Append("</a>, ");
}
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USERS_LISTING_SERVER, false, whoChanSB));
} else {
ChatChannel whoChan = ctx.Chat.Channels.FirstOrDefault(c => c.NameEquals(whoChanStr));
if(whoChan == null) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.CHANNEL_NOT_FOUND, true, whoChanStr));
return;
}
if(whoChan.Rank > ctx.User.Rank || (whoChan.HasPassword && !ctx.User.Can(ChatUserPermissions.JoinAnyChannel))) {
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USERS_LISTING_ERROR, true, whoChanStr));
return;
}
foreach(ChatUser whoUser in ctx.Chat.GetChannelUsers(whoChan)) {
whoChanSB.Append(@"<a href=""javascript:void(0);"" onclick=""UI.InsertChatText(this.innerHTML);""");
if(whoUser == ctx.User)
whoChanSB.Append(@" style=""font-weight: bold;""");
whoChanSB.Append('>');
whoChanSB.Append(whoUser.LegacyName);
whoChanSB.Append("</a>, ");
}
if(whoChanSB.Length > 2)
whoChanSB.Length -= 2;
ctx.Chat.SendTo(ctx.User, new LegacyCommandResponse(LCR.USERS_LISTING_CHANNEL, false, whoChan.Name, whoChanSB));
}
}
}
}

View file

@ -0,0 +1,47 @@
using System;
namespace SharpChat.Config {
public class CachedValue<T> {
private IConfig Config { get; }
private string Name { get; }
private TimeSpan Lifetime { get; }
private T Fallback { get; }
private object ConfigAccess { get; } = new();
private object CurrentValue { get; set; }
private DateTimeOffset LastRead { get; set; }
public T Value {
get {
lock(ConfigAccess) { // this lock doesn't really make sense since it doesn't affect other config calls
DateTimeOffset now = DateTimeOffset.Now;
if((now - LastRead) >= Lifetime) {
LastRead = now;
CurrentValue = Config.ReadValue(Name, Fallback);
Logger.Debug($"Read {Name} ({CurrentValue})");
}
}
return (T)CurrentValue;
}
}
public static implicit operator T(CachedValue<T> val) => val.Value;
public CachedValue(IConfig config, string name, TimeSpan lifetime, T fallback) {
Config = config ?? throw new ArgumentNullException(nameof(config));
Name = name ?? throw new ArgumentNullException(nameof(name));
Lifetime = lifetime;
Fallback = fallback;
if(string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name cannot be empty.", nameof(name));
}
public void Refresh() {
LastRead = DateTimeOffset.MinValue;
}
public override string ToString() {
return Value.ToString();
}
}
}

View file

@ -0,0 +1,16 @@
using System;
namespace SharpChat.Config {
public abstract class ConfigException : Exception {
public ConfigException(string message) : base(message) { }
public ConfigException(string message, Exception ex) : base(message, ex) { }
}
public class ConfigLockException : ConfigException {
public ConfigLockException() : base("Unable to acquire lock for reading configuration.") { }
}
public class ConfigTypeException : ConfigException {
public ConfigTypeException(Exception ex) : base("Given type does not match the value in the configuration.", ex) { }
}
}

View file

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SharpChat.Config {
public interface IConfig : IDisposable {
/// <summary>
/// Creates a proxy object that forces all names to start with the given prefix.
/// </summary>
IConfig ScopeTo(string prefix);
/// <summary>
/// Reads a raw (string) value from the config.
/// </summary>
string ReadValue(string name, string fallback = null);
/// <summary>
/// Reads and casts value from the config.
/// </summary>
/// <exception cref="ConfigTypeException">Type conversion failed.</exception>
T ReadValue<T>(string name, T fallback = default);
/// <summary>
/// Reads and casts a value from the config. Returns fallback when type conversion fails.
/// </summary>
T SafeReadValue<T>(string name, T fallback);
/// <summary>
/// Creates an object that caches the read value for a certain amount of time, avoiding disk reads for frequently used non-static values.
/// </summary>
CachedValue<T> ReadCached<T>(string name, T fallback = default, TimeSpan? lifetime = null);
}
}

View file

@ -0,0 +1,45 @@
using System;
namespace SharpChat.Config {
public class ScopedConfig : IConfig {
private IConfig Config { get; }
private string Prefix { get; }
public ScopedConfig(IConfig config, string prefix) {
Config = config ?? throw new ArgumentNullException(nameof(config));
Prefix = prefix ?? throw new ArgumentNullException(nameof(prefix));
if(string.IsNullOrWhiteSpace(prefix))
throw new ArgumentException("Prefix must exist.", nameof(prefix));
if(Prefix[^1] != ':')
Prefix += ':';
}
private string GetName(string name) {
return Prefix + name;
}
public string ReadValue(string name, string fallback = null) {
return Config.ReadValue(GetName(name), fallback);
}
public T ReadValue<T>(string name, T fallback = default) {
return Config.ReadValue(GetName(name), fallback);
}
public T SafeReadValue<T>(string name, T fallback) {
return Config.SafeReadValue(GetName(name), fallback);
}
public IConfig ScopeTo(string prefix) {
return Config.ScopeTo(GetName(prefix));
}
public CachedValue<T> ReadCached<T>(string name, T fallback = default, TimeSpan? lifetime = null) {
return Config.ReadCached(GetName(name), fallback, lifetime);
}
public void Dispose() {
GC.SuppressFinalize(this);
}
}
}

View file

@ -0,0 +1,112 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
namespace SharpChat.Config {
public class StreamConfig : IConfig {
private Stream Stream { get; }
private StreamReader StreamReader { get; }
private Mutex Lock { get; }
private const int LOCK_TIMEOUT = 10000;
private static readonly TimeSpan CACHE_LIFETIME = TimeSpan.FromMinutes(15);
public StreamConfig(string fileName)
: this(new FileStream(fileName, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite)) { }
public StreamConfig(Stream stream) {
Stream = stream ?? throw new ArgumentNullException(nameof(stream));
if(!Stream.CanRead)
throw new ArgumentException("Provided stream must be readable.", nameof(stream));
if(!Stream.CanSeek)
throw new ArgumentException("Provided stream must be seekable.", nameof(stream));
StreamReader = new StreamReader(stream, new UTF8Encoding(false), false);
Lock = new Mutex();
}
public string ReadValue(string name, string fallback = null) {
if(!Lock.WaitOne(LOCK_TIMEOUT)) // don't catch this, if this happens something is Very Wrong
throw new ConfigLockException();
try {
Stream.Seek(0, SeekOrigin.Begin);
string line;
while((line = StreamReader.ReadLine()) != null) {
if(string.IsNullOrWhiteSpace(line))
continue;
line = line.TrimStart();
if(line.StartsWith(";") || line.StartsWith("#"))
continue;
string[] parts = line.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
if(parts.Length < 2 || !string.Equals(parts[0], name))
continue;
return parts[1];
}
} finally {
Lock.ReleaseMutex();
}
return fallback;
}
public T ReadValue<T>(string name, T fallback = default) {
object value = ReadValue(name);
if(value == null)
return fallback;
Type type = typeof(T);
if(value is string strVal) {
if(type == typeof(bool))
value = !string.Equals(strVal, "0", StringComparison.InvariantCultureIgnoreCase)
&& !string.Equals(strVal, "false", StringComparison.InvariantCultureIgnoreCase);
else if(type == typeof(string[]))
value = strVal.Split(' ');
}
try {
return (T)Convert.ChangeType(value, type);
} catch(InvalidCastException ex) {
throw new ConfigTypeException(ex);
}
}
public T SafeReadValue<T>(string name, T fallback) {
try {
return ReadValue(name, fallback);
} catch(ConfigTypeException) {
return fallback;
}
}
public IConfig ScopeTo(string prefix) {
return new ScopedConfig(this, prefix);
}
public CachedValue<T> ReadCached<T>(string name, T fallback = default, TimeSpan? lifetime = null) {
return new CachedValue<T>(this, name, lifetime ?? CACHE_LIFETIME, fallback);
}
private bool IsDisposed;
~StreamConfig()
=> DoDispose();
public void Dispose() {
DoDispose();
GC.SuppressFinalize(this);
}
private void DoDispose() {
if(IsDisposed)
return;
IsDisposed = true;
StreamReader.Dispose();
Stream.Dispose();
Lock.Dispose();
}
}
}

View file

@ -1,230 +0,0 @@
using MySqlConnector;
using SharpChat.Events;
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Json;
namespace SharpChat {
public static partial class Database {
private static string ConnectionString = null;
public static bool HasDatabase
=> !string.IsNullOrWhiteSpace(ConnectionString);
public static void ReadConfig() {
if(!File.Exists(@"mariadb.txt"))
return;
string[] config = File.ReadAllLines(@"mariadb.txt");
if (config.Length < 4)
return;
Init(config[0], config[1], config[2], config[3]);
}
public static void Init(string host, string username, string password, string database) {
ConnectionString = new MySqlConnectionStringBuilder {
Server = host,
UserID = username,
Password = password,
Database = database,
OldGuids = false,
TreatTinyAsBoolean = false,
CharacterSet = @"utf8mb4",
SslMode = MySqlSslMode.None,
ForceSynchronous = true,
ConnectionTimeout = 5,
}.ToString();
RunMigrations();
}
public static void Deinit() {
ConnectionString = null;
}
private static MySqlConnection GetConnection() {
if (!HasDatabase)
return null;
MySqlConnection conn = new MySqlConnection(ConnectionString);
conn.Open();
return conn;
}
private static int RunCommand(string command, params MySqlParameter[] parameters) {
if (!HasDatabase)
return 0;
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if (parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteNonQuery();
} catch (MySqlException ex) {
Logger.Write(ex);
}
return 0;
}
private static MySqlDataReader RunQuery(string command, params MySqlParameter[] parameters) {
if (!HasDatabase)
return null;
try {
MySqlConnection conn = GetConnection();
MySqlCommand cmd = conn.CreateCommand();
if (parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection);
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private static object RunQueryValue(string command, params MySqlParameter[] parameters) {
if (!HasDatabase)
return null;
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if (parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
cmd.Prepare();
return cmd.ExecuteScalar();
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private const long ID_EPOCH = 1588377600000;
private static int IdCounter = 0;
public static long GenerateId() {
if (IdCounter > 200)
IdCounter = 0;
long id = 0;
id |= (DateTimeOffset.Now.ToUnixTimeMilliseconds() - ID_EPOCH) << 8;
id |= (ushort)(++IdCounter);
return id;
}
public static void LogEvent(IChatEvent evt) {
if(evt.SequenceId < 1)
evt.SequenceId = GenerateId();
RunCommand(
@"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`"
+ @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)"
+ @" VALUES (@id, FROM_UNIXTIME(@created), @type, @target, @flags, @data"
+ @", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
new MySqlParameter(@"id", evt.SequenceId),
new MySqlParameter(@"created", evt.DateTime.ToUnixTimeSeconds()),
new MySqlParameter(@"type", evt.GetType().FullName),
new MySqlParameter(@"target", evt.Target.TargetName),
new MySqlParameter(@"flags", (byte)evt.Flags),
new MySqlParameter(@"data", JsonSerializer.SerializeToUtf8Bytes(evt, evt.GetType())),
new MySqlParameter(@"sender", evt.Sender?.UserId < 1 ? null : (long?)evt.Sender.UserId),
new MySqlParameter(@"sender_name", evt.Sender?.Username),
new MySqlParameter(@"sender_colour", evt.Sender?.Colour.Raw),
new MySqlParameter(@"sender_rank", evt.Sender?.Rank),
new MySqlParameter(@"sender_nick", evt.Sender?.Nickname),
new MySqlParameter(@"sender_perms", evt.Sender?.Permissions)
);
}
public static void DeleteEvent(IChatEvent evt) {
RunCommand(
@"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL",
new MySqlParameter(@"id", evt.SequenceId)
);
}
private static IChatEvent ReadEvent(MySqlDataReader reader, IPacketTarget target = null) {
Type evtType = Type.GetType(Encoding.ASCII.GetString((byte[])reader[@"event_type"]));
IChatEvent evt = JsonSerializer.Deserialize(Encoding.ASCII.GetString((byte[])reader[@"event_data"]), evtType) as IChatEvent;
evt.SequenceId = reader.GetInt64(@"event_id");
evt.Target = target;
evt.TargetName = target?.TargetName ?? Encoding.ASCII.GetString((byte[])reader[@"event_target"]);
evt.Flags = (ChatMessageFlags)reader.GetByte(@"event_flags");
evt.DateTime = DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32(@"event_created"));
if (!reader.IsDBNull(reader.GetOrdinal(@"event_sender"))) {
evt.Sender = new BasicUser {
UserId = reader.GetInt64(@"event_sender"),
Username = reader.GetString(@"event_sender_name"),
Colour = new ChatColour(reader.GetInt32(@"event_sender_colour")),
Rank = reader.GetInt32(@"event_sender_rank"),
Nickname = reader.IsDBNull(reader.GetOrdinal(@"event_sender_nick")) ? null : reader.GetString(@"event_sender_nick"),
Permissions = (ChatUserPermissions)reader.GetInt32(@"event_sender_perms")
};
}
return evt;
}
public static IEnumerable<IChatEvent> GetEvents(IPacketTarget target, int amount, int offset) {
List<IChatEvent> events = new List<IChatEvent>();
try {
using MySqlDataReader reader = RunQuery(
@"SELECT `event_id`, `event_type`, `event_flags`, `event_data`"
+ @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ @", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ @" FROM `sqc_events`"
+ @" WHERE `event_deleted` IS NULL AND `event_target` = @target"
+ @" AND `event_id` > @offset"
+ @" ORDER BY `event_id` DESC"
+ @" LIMIT @amount",
new MySqlParameter(@"target", target.TargetName),
new MySqlParameter(@"amount", amount),
new MySqlParameter(@"offset", offset)
);
while (reader.Read()) {
IChatEvent evt = ReadEvent(reader, target);
if (evt != null)
events.Add(evt);
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
return events;
}
public static IChatEvent GetEvent(long seqId) {
try {
using MySqlDataReader reader = RunQuery(
@"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ @", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ @", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ @" FROM `sqc_events`"
+ @" WHERE `event_id` = @id",
new MySqlParameter(@"id", seqId)
);
while (reader.Read()) {
IChatEvent evt = ReadEvent(reader);
if (evt != null)
return evt;
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
}
}

View file

@ -1,60 +0,0 @@
using MySqlConnector;
using System;
namespace SharpChat {
public static partial class Database {
private static void DoMigration(string name, Action action) {
bool done = (long)RunQueryValue(
@"SELECT COUNT(*) FROM `sqc_migrations` WHERE `migration_name` = @name",
new MySqlParameter(@"name", name)
) > 0;
if (!done) {
Logger.Write($@"Running migration '{name}'...");
action();
RunCommand(
@"INSERT INTO `sqc_migrations` (`migration_name`) VALUES (@name)",
new MySqlParameter(@"name", name)
);
}
}
private static void RunMigrations() {
RunCommand(
@"CREATE TABLE IF NOT EXISTS `sqc_migrations` ("
+ @"`migration_name` VARCHAR(255) NOT NULL,"
+ @"`migration_completed` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ @"UNIQUE INDEX `migration_name` (`migration_name`),"
+ @"INDEX `migration_completed` (`migration_completed`)"
+ @") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
DoMigration(@"create_events_table", CreateEventsTable);
}
private static void CreateEventsTable() {
RunCommand(
@"CREATE TABLE `sqc_events` ("
+ @"`event_id` BIGINT(20) NOT NULL,"
+ @"`event_sender` BIGINT(20) UNSIGNED NULL DEFAULT NULL,"
+ @"`event_sender_name` VARCHAR(255) NULL DEFAULT NULL,"
+ @"`event_sender_colour` INT(11) NULL DEFAULT NULL,"
+ @"`event_sender_rank` INT(11) NULL DEFAULT NULL,"
+ @"`event_sender_nick` VARCHAR(255) NULL DEFAULT NULL,"
+ @"`event_sender_perms` INT(11) NULL DEFAULT NULL,"
+ @"`event_created` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ @"`event_deleted` TIMESTAMP NULL DEFAULT NULL,"
+ @"`event_type` VARBINARY(255) NOT NULL,"
+ @"`event_target` VARBINARY(255) NOT NULL,"
+ @"`event_flags` TINYINT(3) UNSIGNED NOT NULL,"
+ @"`event_data` BLOB NULL DEFAULT NULL,"
+ @"PRIMARY KEY (`event_id`),"
+ @"INDEX `event_target` (`event_target`),"
+ @"INDEX `event_type` (`event_type`),"
+ @"INDEX `event_sender` (`event_sender`),"
+ @"INDEX `event_datetime` (`event_created`),"
+ @"INDEX `event_deleted` (`event_deleted`)"
+ @") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
}
}
}

View file

@ -0,0 +1,36 @@
using System.Collections.Generic;
namespace SharpChat.EventStorage
{
public interface IEventStorage {
void AddEvent(
long id, string type,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
);
void AddEvent(
long id, string type,
string channelName,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
);
void AddEvent(
long id, string type,
long senderId, string senderName, ChatColour senderColour, int senderRank, string senderNick, ChatUserPermissions senderPerms,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
);
void AddEvent(
long id, string type,
string channelName,
long senderId, string senderName, ChatColour senderColour, int senderRank, string senderNick, ChatUserPermissions senderPerms,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
);
long AddEvent(string type, ChatUser user, ChatChannel channel, object data = null, StoredEventFlags flags = StoredEventFlags.None);
void RemoveEvent(StoredEventInfo evt);
StoredEventInfo GetEvent(long seqId);
IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0);
}
}

View file

@ -0,0 +1,181 @@
using MySqlConnector;
using System;
using System.Collections.Generic;
using System.Dynamic;
using System.Text;
using System.Text.Json;
using System.Threading.Channels;
namespace SharpChat.EventStorage
{
public partial class MariaDBEventStorage : IEventStorage {
private string ConnectionString { get; }
public MariaDBEventStorage(string connString) {
ConnectionString = connString ?? throw new ArgumentNullException(nameof(connString));
}
public void AddEvent(
long id, string type,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, 0, null, ChatColour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
string channelName,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, channelName, 0, null, ChatColour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
long senderId, string senderName, ChatColour senderColour, int senderRank, string senderNick, ChatUserPermissions senderPerms,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, senderId, senderName, senderColour, senderRank, senderNick, senderPerms, data, flags);
}
public void AddEvent(
long id, string type,
string channelName,
long senderId, string senderName, ChatColour senderColour, int senderRank, string senderNick, ChatUserPermissions senderPerms,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
if(type == null)
throw new ArgumentNullException(nameof(type));
RunCommand(
"INSERT INTO `sqc_events` (`event_id`, `event_created`, `event_type`, `event_target`, `event_flags`, `event_data`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`)"
+ " VALUES (@id, NOW(), @type, @target, @flags, @data"
+ ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
new MySqlParameter("id", id),
new MySqlParameter("type", type),
new MySqlParameter("target", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
new MySqlParameter("flags", (byte)flags),
new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
new MySqlParameter("sender", senderId < 1 ? null : senderId),
new MySqlParameter("sender_name", senderName),
new MySqlParameter("sender_colour", senderColour.ToMisuzu()),
new MySqlParameter("sender_rank", senderRank),
new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
new MySqlParameter("sender_perms", senderPerms)
);
}
public long AddEvent(string type, ChatUser user, ChatChannel channel, object data = null, StoredEventFlags flags = StoredEventFlags.None) {
if(type == null)
throw new ArgumentNullException(nameof(type));
long id = SharpId.Next();
AddEvent(
id, type,
channel?.Name,
user?.UserId ?? 0,
user?.UserName ?? string.Empty,
user?.Colour ?? ChatColour.None,
user?.Rank ?? 0,
user?.NickName,
user?.Permissions ?? 0,
data,
flags
);
return id;
}
public StoredEventInfo GetEvent(long seqId) {
try {
using MySqlDataReader reader = RunQuery(
"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ ", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_id` = @id",
new MySqlParameter("id", seqId)
);
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
return evt;
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private static StoredEventInfo ReadEvent(MySqlDataReader reader) {
return new StoredEventInfo(
reader.GetInt64("event_id"),
Encoding.ASCII.GetString((byte[])reader["event_type"]),
reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : new ChatUser(
reader.GetInt64("event_sender"),
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString("event_sender_name"),
ChatColour.FromMisuzu(reader.GetInt32("event_sender_colour")),
reader.GetInt32("event_sender_rank"),
(ChatUserPermissions)reader.GetInt32("event_sender_perms"),
reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? null : reader.GetString("event_sender_nick")
),
DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_created")),
reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32("event_deleted")),
reader.IsDBNull(reader.GetOrdinal("event_target")) ? null : Encoding.ASCII.GetString((byte[])reader["event_target"]),
JsonDocument.Parse(Encoding.ASCII.GetString((byte[])reader["event_data"])),
(StoredEventFlags)reader.GetByte("event_flags")
);
}
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
List<StoredEventInfo> events = new();
try {
using MySqlDataReader reader = RunQuery(
"SELECT `event_id`, `event_type`, `event_flags`, `event_data`, `event_target`"
+ ", `event_sender`, `event_sender_name`, `event_sender_colour`, `event_sender_rank`, `event_sender_nick`, `event_sender_perms`"
+ ", UNIX_TIMESTAMP(`event_created`) AS `event_created`"
+ ", UNIX_TIMESTAMP(`event_deleted`) AS `event_deleted`"
+ " FROM `sqc_events`"
+ " WHERE `event_deleted` IS NULL AND (`event_target` = @target OR `event_target` IS NULL)"
+ " AND `event_id` > @offset"
+ " ORDER BY `event_id` DESC"
+ " LIMIT @amount",
new MySqlParameter("target", channelName),
new MySqlParameter("amount", amount),
new MySqlParameter("offset", offset)
);
while(reader.Read()) {
StoredEventInfo evt = ReadEvent(reader);
if(evt != null)
events.Add(evt);
}
} catch(MySqlException ex) {
Logger.Write(ex);
}
events.Reverse();
return events;
}
public void RemoveEvent(StoredEventInfo evt) {
if(evt == null)
throw new ArgumentNullException(nameof(evt));
RunCommand(
"UPDATE IGNORE `sqc_events` SET `event_deleted` = NOW() WHERE `event_id` = @id AND `event_deleted` IS NULL",
new MySqlParameter("id", evt.Id)
);
}
}
}

View file

@ -0,0 +1,82 @@
using MySqlConnector;
using SharpChat.Config;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage {
public static string BuildConnString(IConfig config) {
return BuildConnString(
config.ReadValue("host", "localhost"),
config.ReadValue("user", string.Empty),
config.ReadValue("pass", string.Empty),
config.ReadValue("db", "sharpchat")
);
}
public static string BuildConnString(string host, string username, string password, string database) {
return new MySqlConnectionStringBuilder {
Server = host,
UserID = username,
Password = password,
Database = database,
OldGuids = false,
TreatTinyAsBoolean = false,
CharacterSet = "utf8mb4",
SslMode = MySqlSslMode.None,
ForceSynchronous = true,
ConnectionTimeout = 5,
}.ToString();
}
private MySqlConnection GetConnection() {
MySqlConnection conn = new(ConnectionString);
conn.Open();
return conn;
}
private int RunCommand(string command, params MySqlParameter[] parameters) {
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteNonQuery();
} catch(MySqlException ex) {
Logger.Write(ex);
}
return 0;
}
private MySqlDataReader RunQuery(string command, params MySqlParameter[] parameters) {
try {
MySqlConnection conn = GetConnection();
MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
return cmd.ExecuteReader(System.Data.CommandBehavior.CloseConnection);
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
private object RunQueryValue(string command, params MySqlParameter[] parameters) {
try {
using MySqlConnection conn = GetConnection();
using MySqlCommand cmd = conn.CreateCommand();
if(parameters?.Length > 0)
cmd.Parameters.AddRange(parameters);
cmd.CommandText = command;
cmd.Prepare();
return cmd.ExecuteScalar();
} catch(MySqlException ex) {
Logger.Write(ex);
}
return null;
}
}
}

View file

@ -0,0 +1,68 @@
using MySqlConnector;
using System;
namespace SharpChat.EventStorage {
public partial class MariaDBEventStorage {
private void DoMigration(string name, Action action) {
bool done = (long)RunQueryValue(
"SELECT COUNT(*) FROM `sqc_migrations` WHERE `migration_name` = @name",
new MySqlParameter("name", name)
) > 0;
if(!done) {
Logger.Write($"Running migration '{name}'...");
action();
RunCommand(
"INSERT INTO `sqc_migrations` (`migration_name`) VALUES (@name)",
new MySqlParameter("name", name)
);
}
}
public void RunMigrations() {
RunCommand(
"CREATE TABLE IF NOT EXISTS `sqc_migrations` ("
+ "`migration_name` VARCHAR(255) NOT NULL,"
+ "`migration_completed` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ "UNIQUE INDEX `migration_name` (`migration_name`),"
+ "INDEX `migration_completed` (`migration_completed`)"
+ ") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
DoMigration("create_events_table", CreateEventsTable);
DoMigration("allow_null_target", AllowNullTarget);
}
private void AllowNullTarget() {
RunCommand(
"ALTER TABLE `sqc_events`"
+ " CHANGE COLUMN `event_target` `event_target` VARBINARY(255) NULL AFTER `event_type`;"
);
}
private void CreateEventsTable() {
RunCommand(
"CREATE TABLE `sqc_events` ("
+ "`event_id` BIGINT(20) NOT NULL,"
+ "`event_sender` BIGINT(20) UNSIGNED NULL DEFAULT NULL,"
+ "`event_sender_name` VARCHAR(255) NULL DEFAULT NULL,"
+ "`event_sender_colour` INT(11) NULL DEFAULT NULL,"
+ "`event_sender_rank` INT(11) NULL DEFAULT NULL,"
+ "`event_sender_nick` VARCHAR(255) NULL DEFAULT NULL,"
+ "`event_sender_perms` INT(11) NULL DEFAULT NULL,"
+ "`event_created` TIMESTAMP NOT NULL DEFAULT current_timestamp(),"
+ "`event_deleted` TIMESTAMP NULL DEFAULT NULL,"
+ "`event_type` VARBINARY(255) NOT NULL,"
+ "`event_target` VARBINARY(255) NOT NULL,"
+ "`event_flags` TINYINT(3) UNSIGNED NOT NULL,"
+ "`event_data` BLOB NULL DEFAULT NULL,"
+ "PRIMARY KEY (`event_id`),"
+ "INDEX `event_target` (`event_target`),"
+ "INDEX `event_type` (`event_type`),"
+ "INDEX `event_sender` (`event_sender`),"
+ "INDEX `event_datetime` (`event_created`),"
+ "INDEX `event_deleted` (`event_deleted`)"
+ ") COLLATE='utf8mb4_unicode_ci' ENGINE=InnoDB;"
);
}
}
}

View file

@ -0,0 +1,14 @@
using System;
namespace SharpChat.EventStorage
{
[Flags]
public enum StoredEventFlags
{
None = 0,
Action = 1,
Broadcast = 1 << 1,
Log = 1 << 2,
Private = 1 << 3,
}
}

View file

@ -0,0 +1,35 @@
using System;
using System.Text.Json;
namespace SharpChat.EventStorage {
public class StoredEventInfo {
public long Id { get; set; }
public string Type { get; set; }
public ChatUser Sender { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset? Deleted { get; set; }
public string ChannelName { get; set; }
public StoredEventFlags Flags { get; set; }
public JsonDocument Data { get; set; }
public StoredEventInfo(
long id,
string type,
ChatUser sender,
DateTimeOffset created,
DateTimeOffset? deleted,
string channelName,
JsonDocument data,
StoredEventFlags flags
) {
Id = id;
Type = type;
Sender = sender;
Created = created;
Deleted = deleted;
ChannelName = channelName;
Data = data;
Flags = flags;
}
}
}

View file

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace SharpChat.EventStorage {
public class VirtualEventStorage : IEventStorage {
private readonly Dictionary<long, StoredEventInfo> Events = new();
public void AddEvent(
long id, string type,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, 0, null, ChatColour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
string channelName,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, channelName, 0, null, ChatColour.None, 0, null, 0, data, flags);
}
public void AddEvent(
long id, string type,
long senderId, string senderName, ChatColour senderColour, int senderRank, string senderNick, ChatUserPermissions senderPerms,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
AddEvent(id, type, null, senderId, senderName, senderColour, senderRank, senderNick, senderPerms, data, flags);
}
public void AddEvent(
long id, string type,
string channelName,
long senderId, string senderName, ChatColour senderColour, int senderRank, string senderNick, ChatUserPermissions senderPerms,
object data = null,
StoredEventFlags flags = StoredEventFlags.None
) {
if(type == null)
throw new ArgumentNullException(nameof(type));
// VES is meant as an emergency fallback but this is something else
JsonDocument hack = JsonDocument.Parse(data == null ? "{}" : JsonSerializer.Serialize(data));
Events.Add(id, new(id, type, senderId < 1 ? null : new ChatUser(
senderId,
senderName,
senderColour,
senderRank,
senderPerms,
senderNick
), DateTimeOffset.Now, null, channelName, hack, flags));
}
public long AddEvent(string type, ChatUser user, ChatChannel channel, object data = null, StoredEventFlags flags = StoredEventFlags.None) {
if(type == null)
throw new ArgumentNullException(nameof(type));
long id = SharpId.Next();
AddEvent(
id, type,
channel?.Name,
user?.UserId ?? 0,
user?.UserName,
user?.Colour ?? ChatColour.None,
user?.Rank ?? 0,
user?.NickName,
user?.Permissions ?? 0,
data,
flags
);
return id;
}
public StoredEventInfo GetEvent(long seqId) {
return Events.TryGetValue(seqId, out StoredEventInfo evt) ? evt : null;
}
public void RemoveEvent(StoredEventInfo evt) {
if(evt == null)
throw new ArgumentNullException(nameof(evt));
Events.Remove(evt.Id);
}
public IEnumerable<StoredEventInfo> GetChannelEventLog(string channelName, int amount = 20, int offset = 0) {
IEnumerable<StoredEventInfo> subset = Events.Values.Where(ev => ev.ChannelName == channelName);
int start = subset.Count() - offset - amount;
if(start < 0) {
amount += start;
start = 0;
}
return subset.Skip(start).Take(amount).ToArray();
}
}
}

View file

@ -1,31 +0,0 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class ChatMessage : IChatMessage {
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.None;
[JsonIgnore]
public long SequenceId { get; set; }
[JsonPropertyName(@"text")]
public string Text { get; set; }
public static string PackBotMessage(int type, string id, params string[] parts) {
return type.ToString() + '\f' + id + '\f' + string.Join('\f', parts);
}
}
}

View file

@ -1,25 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace SharpChat.Events {
[Flags]
public enum ChatMessageFlags {
None = 0,
Action = 1,
Broadcast = 1 << 1,
Log = 1 << 2,
Private = 1 << 3,
}
public interface IChatEvent {
DateTimeOffset DateTime { get; set; }
BasicUser Sender { get; set; }
IPacketTarget Target { get; set; }
string TargetName { get; set; }
ChatMessageFlags Flags { get; set; }
long SequenceId { get; set; }
}
public interface IChatMessage : IChatEvent {
string Text { get; }
}
}

View file

@ -0,0 +1,95 @@
using System;
namespace SharpChat.Events {
public class MessageCreateEvent : IChatEvent {
public long MessageId { get; }
public string ChannelName { get; }
public long SenderId { get; }
public string SenderName { get; }
public ChatColour SenderColour { get; }
public int SenderRank { get; }
public string SenderNickName { get; }
public ChatUserPermissions SenderPerms { get; }
public DateTimeOffset MessageCreated { get; }
public string MessageChannel { get; }
public string MessageText { get; }
public bool IsPrivate { get; }
public bool IsAction { get; }
public bool IsBroadcast { get; }
public MessageCreateEvent(
long msgId,
string channelName,
long senderId,
string senderName,
ChatColour senderColour,
int senderRank,
string senderNickName,
ChatUserPermissions senderPerms,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) {
MessageId = msgId;
ChannelName = channelName;
SenderId = senderId;
SenderName = senderName;
SenderColour = senderColour;
SenderRank = senderRank;
SenderNickName = senderNickName;
SenderPerms = senderPerms;
MessageCreated = msgCreated;
MessageText = msgText;
IsPrivate = isPrivate;
IsAction = isAction;
IsBroadcast = isBroadcast;
}
public MessageCreateEvent(
long msgId,
string channelName,
ChatUser sender,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) : this(
msgId,
channelName,
sender?.UserId ?? -1,
sender?.UserName ?? null,
sender?.Colour ?? ChatColour.None,
sender?.Rank ?? 0,
sender?.NickName ?? null,
sender?.Permissions ?? 0,
msgCreated,
msgText,
isPrivate,
isAction,
isBroadcast
) { }
public MessageCreateEvent(
long msgId,
ChatChannel channel,
ChatUser sender,
DateTimeOffset msgCreated,
string msgText,
bool isPrivate,
bool isAction,
bool isBroadcast
) : this(
msgId,
channel?.Name ?? null,
sender,
msgCreated,
msgText,
isPrivate,
isAction,
isBroadcast
) { }
}
}

View file

@ -1,32 +0,0 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserChannelJoinEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
public UserChannelJoinEvent() { }
public UserChannelJoinEvent(DateTimeOffset joined, BasicUser user, IPacketTarget target) {
DateTime = joined;
Sender = user;
Target = target;
TargetName = target?.TargetName;
}
}
}

View file

@ -1,32 +0,0 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserChannelLeaveEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
public UserChannelLeaveEvent() { }
public UserChannelLeaveEvent(DateTimeOffset parted, BasicUser user, IPacketTarget target) {
DateTime = parted;
Sender = user;
Target = target;
TargetName = target?.TargetName;
}
}
}

View file

@ -1,32 +0,0 @@
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserConnectEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
public UserConnectEvent() { }
public UserConnectEvent(DateTimeOffset joined, BasicUser user, IPacketTarget target) {
DateTime = joined;
Sender = user;
Target = target;
TargetName = target?.TargetName;
}
}
}

View file

@ -1,38 +0,0 @@
using SharpChat.Packet;
using System;
using System.Text.Json.Serialization;
namespace SharpChat.Events {
public class UserDisconnectEvent : IChatEvent {
[JsonIgnore]
public DateTimeOffset DateTime { get; set; }
[JsonIgnore]
public BasicUser Sender { get; set; }
[JsonIgnore]
public IPacketTarget Target { get; set; }
[JsonIgnore]
public string TargetName { get; set; }
[JsonIgnore]
public ChatMessageFlags Flags { get; set; } = ChatMessageFlags.Log;
[JsonIgnore]
public long SequenceId { get; set; }
[JsonPropertyName(@"reason")]
public UserDisconnectReason Reason { get; set; }
public UserDisconnectEvent() { }
public UserDisconnectEvent(DateTimeOffset parted, BasicUser user, IPacketTarget target, UserDisconnectReason reason) {
DateTime = parted;
Sender = user;
Target = target;
TargetName = target?.TargetName;
Reason = reason;
}
}
}

View file

@ -1,35 +0,0 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
namespace SharpChat {
public static class Extensions {
public static string GetSignedHash(this string str, string key = null)
=> Encoding.UTF8.GetBytes(str).GetSignedHash(key);
public static string GetSignedHash(this byte[] bytes, string key = null) {
if (key == null)
key = File.Exists(@"login_key.txt") ? File.ReadAllText(@"login_key.txt") : @"woomy";
StringBuilder sb = new StringBuilder();
using (HMACSHA256 algo = new HMACSHA256(Encoding.UTF8.GetBytes(key))) {
byte[] hash = algo.ComputeHash(bytes);
foreach (byte b in hash)
sb.AppendFormat(@"{0:x2}", b);
}
return sb.ToString();
}
public static string GetIdString(this byte[] buffer) {
const string id_chars = @"abcdefghijklmnopqrstuvwxyz0123456789-_ABCDEFGHIJKLMNOPQRSTUVWXYZ";
StringBuilder sb = new StringBuilder();
foreach(byte b in buffer)
sb.Append(id_chars[b % id_chars.Length]);
return sb.ToString();
}
}
}

View file

@ -1,85 +0,0 @@
using Microsoft.Win32.SafeHandles;
using System;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace SharpChat.Flashii {
public class FlashiiAuthRequest {
[JsonPropertyName(@"user_id")]
public long UserId { get; set; }
[JsonPropertyName(@"token")]
public string Token { get; set; }
[JsonPropertyName(@"ip")]
public string IPAddress { get; set; }
[JsonIgnore]
public string Hash
=> string.Join(@"#", UserId, Token, IPAddress).GetSignedHash();
public byte[] GetJSON()
=> JsonSerializer.SerializeToUtf8Bytes(this);
}
public class FlashiiAuth {
[JsonPropertyName(@"success")]
public bool Success { get; set; }
[JsonPropertyName(@"reason")]
public string Reason { get; set; } = @"none";
[JsonPropertyName(@"user_id")]
public long UserId { get; set; }
[JsonPropertyName(@"username")]
public string Username { get; set; }
[JsonPropertyName(@"colour_raw")]
public int ColourRaw { get; set; }
[JsonPropertyName(@"hierarchy")]
public int Rank { get; set; }
[JsonPropertyName(@"is_silenced")]
public DateTimeOffset SilencedUntil { get; set; }
[JsonPropertyName(@"perms")]
public ChatUserPermissions Permissions { get; set; }
public static async Task<FlashiiAuth> Attempt(HttpClient httpClient, FlashiiAuthRequest authRequest) {
if(httpClient == null)
throw new ArgumentNullException(nameof(httpClient));
if(authRequest == null)
throw new ArgumentNullException(nameof(authRequest));
#if DEBUG
if (authRequest.UserId >= 10000)
return new FlashiiAuth {
Success = true,
UserId = authRequest.UserId,
Username = @"Misaka-" + (authRequest.UserId - 10000),
ColourRaw = (RNG.Next(0, 255) << 16) | (RNG.Next(0, 255) << 8) | RNG.Next(0, 255),
Rank = 0,
SilencedUntil = DateTimeOffset.MinValue,
Permissions = ChatUserPermissions.SendMessage | ChatUserPermissions.EditOwnMessage | ChatUserPermissions.DeleteOwnMessage,
};
#endif
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, FlashiiUrls.AuthURL) {
Content = new ByteArrayContent(authRequest.GetJSON()),
Headers = {
{ @"X-SharpChat-Signature", authRequest.Hash },
},
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
return JsonSerializer.Deserialize<FlashiiAuth>(
await response.Content.ReadAsByteArrayAsync()
);
}
}
}

View file

@ -1,39 +0,0 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace SharpChat.Flashii {
public class FlashiiBan {
private const string STRING = @"givemethebeans";
[JsonPropertyName(@"id")]
public int UserId { get; set; }
[JsonPropertyName(@"ip")]
public string UserIP { get; set; }
[JsonPropertyName(@"expires")]
public DateTimeOffset Expires { get; set; }
[JsonPropertyName(@"username")]
public string Username { get; set; }
public static async Task<IEnumerable<FlashiiBan>> GetList(HttpClient httpClient) {
if(httpClient == null)
throw new ArgumentNullException(nameof(httpClient));
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, FlashiiUrls.BansURL) {
Headers = {
{ @"X-SharpChat-Signature", STRING.GetSignedHash() },
},
};
using HttpResponseMessage response = await httpClient.SendAsync(request);
return JsonSerializer.Deserialize<IEnumerable<FlashiiBan>>(await response.Content.ReadAsByteArrayAsync());
}
}
}

View file

@ -1,47 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace SharpChat.Flashii {
public class FlashiiBump {
[JsonPropertyName(@"id")]
public long UserId { get; set; }
[JsonPropertyName(@"ip")]
public string UserIP { get; set; }
public static void Submit(HttpClient httpClient, IEnumerable<ChatUser> users) {
List<FlashiiBump> bups = users.Where(u => u.HasSessions).Select(x => new FlashiiBump { UserId = x.UserId, UserIP = x.RemoteAddresses.First().ToString() }).ToList();
if (bups.Any())
Submit(httpClient, bups);
}
public static void Submit(HttpClient httpClient, IEnumerable<FlashiiBump> users) {
if(httpClient == null)
throw new ArgumentNullException(nameof(httpClient));
if(users == null)
throw new ArgumentNullException(nameof(users));
if(!users.Any())
return;
byte[] data = JsonSerializer.SerializeToUtf8Bytes(users);
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, FlashiiUrls.BumpURL) {
Content = new ByteArrayContent(data),
Headers = {
{ @"X-SharpChat-Signature", data.GetSignedHash() },
}
};
httpClient.SendAsync(request).ContinueWith(x => {
if(x.IsFaulted)
Logger.Write($@"Flashii Bump Error: {x.Exception}");
});
}
}
}

View file

@ -1,35 +0,0 @@
using System.IO;
namespace SharpChat.Flashii {
public static class FlashiiUrls {
private const string BASE_URL_FILE = @"msz_url.txt";
private const string BASE_URL_FALLBACK = @"https://flashii.net";
private const string AUTH = @"/_sockchat/verify";
private const string BANS = @"/_sockchat/bans";
private const string BUMP = @"/_sockchat/bump";
public static string AuthURL { get; }
public static string BansURL { get; }
public static string BumpURL { get; }
static FlashiiUrls() {
AuthURL = GetURL(AUTH);
BansURL = GetURL(BANS);
BumpURL = GetURL(BUMP);
}
public static string GetBaseURL() {
if(!File.Exists(BASE_URL_FILE))
return BASE_URL_FALLBACK;
string url = File.ReadAllText(BASE_URL_FILE).Trim().Trim('/');
if(string.IsNullOrEmpty(url))
return BASE_URL_FALLBACK;
return url;
}
public static string GetURL(string path) {
return GetBaseURL() + path;
}
}
}

View file

@ -1,8 +1,6 @@
using SharpChat.Events;
namespace SharpChat {
namespace SharpChat {
public interface IChatCommand {
bool IsMatch(string name);
IChatMessage Dispatch(IChatCommandContext context);
bool IsMatch(ChatCommandContext ctx);
void Dispatch(ChatCommandContext ctx);
}
}

View file

@ -1,22 +0,0 @@
using System;
using System.Collections.Generic;
namespace SharpChat {
public interface IChatCommandContext {
IEnumerable<string> Args { get; }
ChatUser User { get; }
ChatChannel Channel { get; }
}
public class ChatCommandContext : IChatCommandContext {
public IEnumerable<string> Args { get; }
public ChatUser User { get; }
public ChatChannel Channel { get; }
public ChatCommandContext(IEnumerable<string> args, ChatUser user, ChatChannel channel) {
Args = args ?? throw new ArgumentNullException(nameof(args));
User = user ?? throw new ArgumentNullException(nameof(user));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
}
}
}

View file

@ -0,0 +1,6 @@
namespace SharpChat {
public interface IChatPacketHandler {
bool IsMatch(ChatPacketHandlerContext ctx);
void Handle(ChatPacketHandlerContext ctx);
}
}

View file

@ -1,6 +0,0 @@
namespace SharpChat {
public interface IPacketTarget {
string TargetName { get; }
void Send(IServerPacket packet);
}
}

View file

@ -1,5 +1,4 @@
using System.Collections.Generic;
using System.Threading;
namespace SharpChat {
public interface IServerPacket {
@ -8,13 +7,11 @@ namespace SharpChat {
}
public abstract class ServerPacket : IServerPacket {
private static long SequenceIdCounter = 0;
public long SequenceId { get; }
public ServerPacket(long sequenceId = 0) {
// Allow sequence id to be manually set for potential message repeats
SequenceId = sequenceId > 0 ? sequenceId : Interlocked.Increment(ref SequenceIdCounter);
SequenceId = sequenceId > 0 ? sequenceId : SharpId.Next();
}
public abstract IEnumerable<string> Pack();

View file

@ -4,25 +4,31 @@ using System.Text;
namespace SharpChat {
public static class Logger {
public static void Write(string str)
=> Console.WriteLine(string.Format(@"[{1}] {0}", str, DateTime.Now));
public static void Write(string str) {
Console.WriteLine(string.Format("[{1}] {0}", str, DateTime.Now));
}
public static void Write(byte[] bytes)
=> Write(Encoding.UTF8.GetString(bytes));
public static void Write(byte[] bytes) {
Write(Encoding.UTF8.GetString(bytes));
}
public static void Write(object obj)
=> Write(obj?.ToString() ?? string.Empty);
public static void Write(object obj) {
Write(obj?.ToString() ?? string.Empty);
}
[Conditional(@"DEBUG")]
public static void Debug(string str)
=> Write(str);
[Conditional("DEBUG")]
public static void Debug(string str) {
Write(str);
}
[Conditional(@"DEBUG")]
public static void Debug(byte[] bytes)
=> Write(bytes);
[Conditional("DEBUG")]
public static void Debug(byte[] bytes) {
Write(bytes);
}
[Conditional(@"DEBUG")]
public static void Debug(object obj)
=> Write(obj);
[Conditional("DEBUG")]
public static void Debug(object obj) {
Write(obj);
}
}
}

View file

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

View file

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

View file

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

View file

@ -1,4 +1,5 @@
using System;
using SharpChat.Misuzu;
using System;
using System.Collections.Generic;
using System.Text;
@ -11,44 +12,41 @@ namespace SharpChat.Packet {
public class AuthFailPacket : ServerPacket {
public AuthFailReason Reason { get; private set; }
public DateTimeOffset Expires { get; private set; }
public MisuzuBanInfo BanInfo { get; private set; }
public AuthFailPacket(AuthFailReason reason, DateTimeOffset? expires = null) {
public AuthFailPacket(AuthFailReason reason, MisuzuBanInfo fbi = null) {
Reason = reason;
if (reason == AuthFailReason.Banned) {
if (!expires.HasValue)
throw new ArgumentNullException(nameof(expires));
Expires = expires.Value;
}
if(reason == AuthFailReason.Banned)
BanInfo = fbi ?? throw new ArgumentNullException(nameof(fbi));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.UserConnect);
sb.Append('1');
sb.Append("\tn\t");
switch (Reason) {
switch(Reason) {
case AuthFailReason.AuthInvalid:
default:
sb.Append(@"authfail");
sb.Append("authfail");
break;
case AuthFailReason.MaxSessions:
sb.Append(@"sockfail");
sb.Append("sockfail");
break;
case AuthFailReason.Banned:
sb.Append(@"joinfail");
sb.Append("joinfail");
break;
}
if (Reason == AuthFailReason.Banned) {
if(Reason == AuthFailReason.Banned) {
sb.Append('\t');
if (Expires == DateTimeOffset.MaxValue)
sb.Append(@"-1");
if(BanInfo.IsPermanent)
sb.Append("-1");
else
sb.Append(Expires.ToUnixTimeSeconds());
sb.Append(BanInfo.ExpiresAt.ToUnixTimeSeconds());
}
yield return sb.ToString();

View file

@ -6,24 +6,31 @@ namespace SharpChat.Packet {
public class AuthSuccessPacket : ServerPacket {
public ChatUser User { get; private set; }
public ChatChannel Channel { get; private set; }
public ChatUserSession Session { get; private set; }
public ChatConnection Connection { get; private set; }
public int MaxMessageLength { get; private set; }
public AuthSuccessPacket(ChatUser user, ChatChannel channel, ChatUserSession sess) {
public AuthSuccessPacket(
ChatUser user,
ChatChannel channel,
ChatConnection connection,
int maxMsgLength
) {
User = user ?? throw new ArgumentNullException(nameof(user));
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
Session = sess ?? throw new ArgumentNullException(nameof(channel));
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
MaxMessageLength = maxMsgLength;
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.UserConnect);
sb.Append('1');
sb.Append("\ty\t");
sb.Append(User.Pack());
sb.Append('\t');
sb.Append(Channel.Name);
sb.Append('\t');
sb.Append(SockChatServer.MSG_LENGTH_MAX);
sb.Append(MaxMessageLength);
return new[] { sb.ToString() };
}

View file

@ -1,28 +1,31 @@
using System;
using SharpChat.Misuzu;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace SharpChat.Packet {
public class BanListPacket : ServerPacket {
public IEnumerable<IBan> Bans { get; private set; }
public IEnumerable<MisuzuBanInfo> Bans { get; private set; }
public BanListPacket(IEnumerable<IBan> bans) {
public BanListPacket(IEnumerable<MisuzuBanInfo> bans) {
Bans = bans ?? throw new ArgumentNullException(nameof(bans));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('2');
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t0\fbanlist\f");
foreach (IBan ban in Bans)
sb.AppendFormat(@"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('/unban '+ this.innerHTML);"">{0}</a>, ", ban);
foreach(MisuzuBanInfo ban in Bans) {
string banStr = string.IsNullOrEmpty(ban.UserName) ? ban.RemoteAddress : ban.UserName;
sb.AppendFormat(@"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('/unban '+ this.innerHTML);"">{0}</a>, ", banStr);
}
if (Bans.Any())
if(Bans.Any())
sb.Length -= 2;
sb.Append('\t');

View file

@ -10,11 +10,11 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.ChannelEvent);
sb.Append('4');
sb.Append('\t');
sb.Append((int)SockChatServerChannelPacket.Create);
sb.Append('0');
sb.Append('\t');
sb.Append(Channel.Pack());

View file

@ -11,11 +11,11 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.ChannelEvent);
sb.Append('4');
sb.Append('\t');
sb.Append((int)SockChatServerChannelPacket.Delete);
sb.Append('2');
sb.Append('\t');
sb.Append(Channel.Name);

View file

@ -12,11 +12,11 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.ChannelEvent);
sb.Append('4');
sb.Append('\t');
sb.Append((int)SockChatServerChannelPacket.Update);
sb.Append('1');
sb.Append('\t');
sb.Append(PreviousName);
sb.Append('\t');

View file

@ -1,52 +1,79 @@
using SharpChat.Events;
using SharpChat.EventStorage;
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class ChatMessageAddPacket : ServerPacket {
public IChatMessage Message { get; private set; }
public DateTimeOffset Created { get; }
public long UserId { get; }
public string Text { get; }
public bool IsAction { get; }
public bool IsPrivate { get; }
public ChatMessageAddPacket(IChatMessage message) : base(message?.SequenceId ?? 0) {
Message = message ?? throw new ArgumentNullException(nameof(message));
public ChatMessageAddPacket(
long msgId,
DateTimeOffset created,
long userId,
string text,
bool isAction,
bool isPrivate
) : base(msgId) {
Created = created;
UserId = userId < 0 ? -1 : userId;
Text = text;
IsAction = isAction;
IsPrivate = isPrivate;
}
if (Message.SequenceId < 1)
Message.SequenceId = SequenceId;
public static ChatMessageAddPacket FromStoredEvent(StoredEventInfo sei) {
if(sei == null)
throw new ArgumentNullException(nameof(sei));
if(sei.Type is not "msg:add" and not "SharpChat.Events.ChatMessage")
throw new ArgumentException("Wrong event type.", nameof(sei));
return new ChatMessageAddPacket(
sei.Id,
sei.Created,
sei.Sender?.UserId ?? -1,
string.Empty, // todo: this
(sei.Flags & StoredEventFlags.Action) > 0,
(sei.Flags & StoredEventFlags.Private) > 0
);
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('2');
sb.Append('\t');
sb.Append(Message.DateTime.ToUnixTimeSeconds());
sb.Append(Created.ToUnixTimeSeconds());
sb.Append('\t');
sb.Append(Message.Sender?.UserId ?? -1);
sb.Append(UserId);
sb.Append('\t');
if (Message.Flags.HasFlag(ChatMessageFlags.Action))
sb.Append(@"<i>");
if(IsAction)
sb.Append("<i>");
sb.Append(
Message.Text
.Replace(@"<", @"&lt;")
.Replace(@">", @"&gt;")
.Replace("\n", @" <br/> ")
.Replace("\t", @" ")
Text.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")
);
if (Message.Flags.HasFlag(ChatMessageFlags.Action))
sb.Append(@"</i>");
if(IsAction)
sb.Append("</i>");
sb.Append('\t');
sb.Append(SequenceId);
sb.AppendFormat(
"\t1{0}0{1}{2}",
Message.Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0',
Message.Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1',
Message.Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0'
IsAction ? '1' : '0',
IsAction ? '0' : '1',
IsPrivate ? '1' : '0'
);
yield return sb.ToString();

View file

@ -10,9 +10,9 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.MessageDelete);
sb.Append('6');
sb.Append('\t');
sb.Append(EventId);

View file

@ -12,15 +12,15 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.ContextPopulate);
sb.Append('7');
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Channels);
sb.Append('2');
sb.Append('\t');
sb.Append(Channels.Count());
foreach (ChatChannel channel in Channels) {
foreach(ChatChannel channel in Channels) {
sb.Append('\t');
sb.Append(channel.Pack());
}

View file

@ -23,9 +23,9 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.ContextClear);
sb.Append('8');
sb.Append('\t');
sb.Append((int)Mode);

View file

@ -1,14 +1,15 @@
using SharpChat.Events;
using SharpChat.EventStorage;
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
namespace SharpChat.Packet
{
public class ContextMessagePacket : ServerPacket {
public IChatEvent Event { get; private set; }
public StoredEventInfo Event { get; private set; }
public bool Notify { get; private set; }
public ContextMessagePacket(IChatEvent evt, bool notify = false) {
public ContextMessagePacket(StoredEventInfo evt, bool notify = false) {
Event = evt ?? throw new ArgumentNullException(nameof(evt));
Notify = notify;
}
@ -16,81 +17,101 @@ namespace SharpChat.Packet {
private const string V1_CHATBOT = "-1\tChatBot\tinherit\t\t";
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
bool isAction = Event.Flags.HasFlag(StoredEventFlags.Action);
bool isBroadcast = Event.Flags.HasFlag(StoredEventFlags.Broadcast);
bool isPrivate = Event.Flags.HasFlag(StoredEventFlags.Private);
sb.Append((int)SockChatServerPacket.ContextPopulate);
StringBuilder sb = new();
sb.Append('7');
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Message);
sb.Append('1');
sb.Append('\t');
sb.Append(Event.DateTime.ToUnixTimeSeconds());
sb.Append(Event.Created.ToUnixTimeSeconds());
sb.Append('\t');
switch (Event) {
case IChatMessage msg:
sb.Append(Event.Sender.Pack());
sb.Append('\t');
switch(Event.Type) {
case "msg:add":
case "SharpChat.Events.ChatMessage":
if(isBroadcast) {
sb.Append(V1_CHATBOT);
sb.Append("0\fsay\f");
} else {
sb.Append(Event.Sender.Pack());
sb.Append('\t');
}
if(isAction)
sb.Append("<i>");
sb.Append(
msg.Text
.Replace(@"<", @"&lt;")
.Replace(@">", @"&gt;")
.Replace("\n", @" <br/> ")
.Replace("\t", @" ")
Event.Data.RootElement.GetProperty("text").GetString()
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\n", " <br/> ")
.Replace("\t", " ")
);
if(isAction)
sb.Append("</i>");
break;
case UserConnectEvent _:
case "user:connect":
case "SharpChat.Events.UserConnectEvent":
sb.Append(V1_CHATBOT);
sb.Append("0\fjoin\f");
sb.Append(Event.Sender.Username);
sb.Append(Event.Sender.LegacyName);
break;
case UserChannelJoinEvent _:
case "chan:join":
case "SharpChat.Events.UserChannelJoinEvent":
sb.Append(V1_CHATBOT);
sb.Append("0\fjchan\f");
sb.Append(Event.Sender.Username);
sb.Append(Event.Sender.LegacyName);
break;
case UserChannelLeaveEvent _:
case "chan:leave":
case "SharpChat.Events.UserChannelLeaveEvent":
sb.Append(V1_CHATBOT);
sb.Append("0\flchan\f");
sb.Append(Event.Sender.Username);
sb.Append(Event.Sender.LegacyName);
break;
case UserDisconnectEvent ude:
case "user:disconnect":
case "SharpChat.Events.UserDisconnectEvent":
sb.Append(V1_CHATBOT);
sb.Append("0\f");
switch (ude.Reason) {
switch((UserDisconnectReason)Event.Data.RootElement.GetProperty("reason").GetByte()) {
case UserDisconnectReason.Flood:
sb.Append(@"flood");
sb.Append("flood");
break;
case UserDisconnectReason.Kicked:
sb.Append(@"kick");
sb.Append("kick");
break;
case UserDisconnectReason.TimeOut:
sb.Append(@"timeout");
sb.Append("timeout");
break;
case UserDisconnectReason.Leave:
default:
sb.Append(@"leave");
sb.Append("leave");
break;
}
sb.Append('\f');
sb.Append(Event.Sender.Username);
sb.Append(Event.Sender.LegacyName);
break;
}
sb.Append('\t');
sb.Append(Event.SequenceId < 1 ? SequenceId : Event.SequenceId);
sb.Append(Event.Id < 1 ? SequenceId : Event.Id);
sb.Append('\t');
sb.Append(Notify ? '1' : '0');
sb.AppendFormat(
"\t1{0}0{1}{2}",
Event.Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0',
Event.Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1',
Event.Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0'
isAction ? '1' : '0',
isAction ? '0' : '1',
isPrivate ? '1' : '0'
);
yield return sb.ToString();

View file

@ -12,15 +12,15 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.ContextPopulate);
sb.Append('7');
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Users);
sb.Append('0');
sb.Append('\t');
sb.Append(Users.Count());
foreach (ChatUser user in Users) {
foreach(ChatUser user in Users) {
sb.Append('\t');
sb.Append(user.Pack());
sb.Append('\t');

View file

@ -1,20 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class FloodWarningPacket : ServerPacket {
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t0\fflwarn\t");
sb.Append(SequenceId);
sb.Append("\t10010");
yield return sb.ToString();
}
}
}

View file

@ -15,23 +15,26 @@ namespace SharpChat.Packet {
public ForceDisconnectPacket(ForceDisconnectReason reason, DateTimeOffset? expires = null) {
Reason = reason;
if (reason == ForceDisconnectReason.Banned) {
if (!expires.HasValue)
if(reason == ForceDisconnectReason.Banned) {
if(!expires.HasValue)
throw new ArgumentNullException(nameof(expires));
Expires = expires.Value;
}
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.BAKA);
sb.Append('9');
sb.Append('\t');
sb.Append((int)Reason);
if (Reason == ForceDisconnectReason.Banned) {
if(Reason == ForceDisconnectReason.Banned) {
sb.Append('\t');
sb.Append(Expires.ToUnixTimeSeconds());
if(Expires.Year >= 2100)
sb.Append("-1");
else
sb.Append(Expires.ToUnixTimeSeconds());
}
yield return sb.ToString();

View file

@ -20,17 +20,17 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
if (StringId == LCR.WELCOME) {
sb.Append((int)SockChatServerPacket.ContextPopulate);
if(StringId == LCR.WELCOME) {
sb.Append('7');
sb.Append('\t');
sb.Append((int)SockChatServerContextPacket.Message);
sb.Append('1');
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\tChatBot\tinherit\t\t");
} else {
sb.Append((int)SockChatServerPacket.MessageAdd);
sb.Append('2');
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t");
@ -40,15 +40,15 @@ namespace SharpChat.Packet {
sb.Append('\f');
sb.Append(StringId == LCR.WELCOME ? LCR.BROADCAST : StringId);
if (Arguments?.Any() == true)
foreach (object arg in Arguments) {
if(Arguments?.Any() == true)
foreach(object arg in Arguments) {
sb.Append('\f');
sb.Append(arg);
}
sb.Append('\t');
if (StringId == LCR.WELCOME) {
if(StringId == LCR.WELCOME) {
sb.Append(StringId);
sb.Append("\t0");
} else
@ -68,40 +68,33 @@ namespace SharpChat.Packet {
// Abbreviated class name because otherwise shit gets wide
public static class LCR {
public const string COMMAND_NOT_FOUND = @"nocmd";
public const string COMMAND_NOT_ALLOWED = @"cmdna";
public const string COMMAND_FORMAT_ERROR = @"cmderr";
public const string WELCOME = @"welcome";
public const string BROADCAST = @"say";
public const string IP_ADDRESS = @"ipaddr";
public const string USER_NOT_FOUND = @"usernf";
public const string SILENCE_SELF = @"silself";
public const string SILENCE_HIERARCHY = @"silperr";
public const string SILENCE_ALREADY = @"silerr";
public const string TARGET_SILENCED = @"silok";
public const string SILENCED = @"silence";
public const string UNSILENCED = @"unsil";
public const string TARGET_UNSILENCED = @"usilok";
public const string NOT_SILENCED = @"usilerr";
public const string UNSILENCE_HIERARCHY = @"usilperr";
public const string NAME_IN_USE = @"nameinuse";
public const string CHANNEL_INSUFFICIENT_HIERARCHY = @"ipchan";
public const string CHANNEL_INVALID_PASSWORD = @"ipwchan";
public const string CHANNEL_NOT_FOUND = @"nochan";
public const string CHANNEL_ALREADY_EXISTS = @"nischan";
public const string GENERIC_ERROR = "generr";
public const string COMMAND_NOT_FOUND = "nocmd";
public const string COMMAND_NOT_ALLOWED = "cmdna";
public const string COMMAND_FORMAT_ERROR = "cmderr";
public const string WELCOME = "welcome";
public const string BROADCAST = "say";
public const string IP_ADDRESS = "ipaddr";
public const string USER_NOT_FOUND = "usernf";
public const string NAME_IN_USE = "nameinuse";
public const string CHANNEL_INSUFFICIENT_HIERARCHY = "ipchan";
public const string CHANNEL_INVALID_PASSWORD = "ipwchan";
public const string CHANNEL_NOT_FOUND = "nochan";
public const string CHANNEL_ALREADY_EXISTS = "nischan";
public const string CHANNEL_NAME_INVALID = "inchan";
public const string CHANNEL_CREATED = @"crchan";
public const string CHANNEL_DELETE_FAILED = @"ndchan";
public const string CHANNEL_DELETED = @"delchan";
public const string CHANNEL_PASSWORD_CHANGED = @"cpwdchan";
public const string CHANNEL_HIERARCHY_CHANGED = @"cprivchan";
public const string USERS_LISTING_ERROR = @"whoerr";
public const string USERS_LISTING_CHANNEL = @"whochan";
public const string USERS_LISTING_SERVER = @"who";
public const string INSUFFICIENT_HIERARCHY = @"rankerr";
public const string MESSAGE_DELETE_ERROR = @"delerr";
public const string KICK_NOT_ALLOWED = @"kickna";
public const string USER_NOT_BANNED = @"notban";
public const string USER_UNBANNED = @"unban";
public const string CHANNEL_CREATED = "crchan";
public const string CHANNEL_DELETE_FAILED = "ndchan";
public const string CHANNEL_DELETED = "delchan";
public const string CHANNEL_PASSWORD_CHANGED = "cpwdchan";
public const string CHANNEL_HIERARCHY_CHANGED = "cprivchan";
public const string USERS_LISTING_ERROR = "whoerr";
public const string USERS_LISTING_CHANNEL = "whochan";
public const string USERS_LISTING_SERVER = "who";
public const string INSUFFICIENT_HIERARCHY = "rankerr";
public const string MESSAGE_DELETE_ERROR = "delerr";
public const string KICK_NOT_ALLOWED = "kickna";
public const string USER_NOT_BANNED = "notban";
public const string USER_UNBANNED = "unban";
public const string FLOOD_WARN = "flwarn";
}
}

View file

@ -11,9 +11,9 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.Pong);
sb.Append('0');
sb.Append('\t');
sb.Append(PongTime.ToUnixTimeSeconds());

View file

@ -1,29 +0,0 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace SharpChat.Packet {
public class TypingPacket : ServerPacket {
public ChatChannel Channel { get; }
public ChatChannelTyping TypingInfo { get; }
public TypingPacket(ChatChannel channel, ChatChannelTyping typingInfo) {
Channel = channel;
TypingInfo = typingInfo ?? throw new ArgumentNullException(nameof(typingInfo));
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
sb.Append((int)SockChatServerPacket.Typing);
sb.Append('\t');
sb.Append(Channel?.TargetName ?? string.Empty);
sb.Append('\t');
sb.Append(TypingInfo.User.UserId);
sb.Append('\t');
sb.Append(TypingInfo.Started.ToUnixTimeSeconds());
yield return sb.ToString();
}
}
}

View file

@ -11,11 +11,11 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.UserSwitch);
sb.Append('5');
sb.Append('\t');
sb.Append((int)SockChatServerMovePacket.ForcedMove);
sb.Append('2');
sb.Append('\t');
sb.Append(Channel.Name);

View file

@ -11,11 +11,11 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.UserSwitch);
sb.Append('5');
sb.Append('\t');
sb.Append((int)SockChatServerMovePacket.UserJoined);
sb.Append('0');
sb.Append('\t');
sb.Append(User.Pack());
sb.Append('\t');

View file

@ -11,11 +11,11 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.UserSwitch);
sb.Append('5');
sb.Append('\t');
sb.Append((int)SockChatServerMovePacket.UserLeft);
sb.Append('1');
sb.Append('\t');
sb.Append(User.UserId);
sb.Append('\t');

View file

@ -13,9 +13,9 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.UserConnect);
sb.Append('1');
sb.Append('\t');
sb.Append(Joined.ToUnixTimeSeconds());
sb.Append('\t');

View file

@ -22,28 +22,28 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
sb.Append((int)SockChatServerPacket.UserDisconnect);
sb.Append('3');
sb.Append('\t');
sb.Append(User.UserId);
sb.Append('\t');
sb.Append(User.DisplayName);
sb.Append(User.LegacyNameWithStatus);
sb.Append('\t');
switch (Reason) {
switch(Reason) {
case UserDisconnectReason.Leave:
default:
sb.Append(@"leave");
sb.Append("leave");
break;
case UserDisconnectReason.TimeOut:
sb.Append(@"timeout");
sb.Append("timeout");
break;
case UserDisconnectReason.Kicked:
sb.Append(@"kick");
sb.Append("kick");
break;
case UserDisconnectReason.Flood:
sb.Append(@"flood");
sb.Append("flood");
break;
}

View file

@ -13,18 +13,18 @@ namespace SharpChat.Packet {
}
public override IEnumerable<string> Pack() {
StringBuilder sb = new StringBuilder();
StringBuilder sb = new();
bool isSilent = string.IsNullOrEmpty(PreviousName);
if (!isSilent) {
sb.Append((int)SockChatServerPacket.MessageAdd);
if(!isSilent) {
sb.Append('2');
sb.Append('\t');
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
sb.Append("\t-1\t0\fnick\f");
sb.Append(PreviousName);
sb.Append('\f');
sb.Append(User.DisplayName);
sb.Append(User.LegacyNameWithStatus);
sb.Append('\t');
sb.Append(SequenceId);
sb.Append("\t10010");
@ -32,7 +32,7 @@ namespace SharpChat.Packet {
sb.Clear();
}
sb.Append((int)SockChatServerPacket.UserUpdate);
sb.Append("10");
sb.Append('\t');
sb.Append(User.Pack());

View file

@ -0,0 +1,150 @@
using SharpChat.Config;
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.PacketHandlers {
public class AuthHandler : IChatPacketHandler {
private readonly MisuzuClient Misuzu;
private readonly ChatChannel DefaultChannel;
private readonly CachedValue<int> MaxMessageLength;
private readonly CachedValue<int> MaxConnections;
public AuthHandler(
MisuzuClient msz,
ChatChannel defaultChannel,
CachedValue<int> maxMsgLength,
CachedValue<int> maxConns
) {
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
DefaultChannel = defaultChannel ?? throw new ArgumentNullException(nameof(defaultChannel));
MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength));
MaxConnections = maxConns ?? throw new ArgumentNullException(nameof(maxConns));
}
public bool IsMatch(ChatPacketHandlerContext ctx) {
return ctx.CheckPacketId("1");
}
public void Handle(ChatPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
string authMethod = args.ElementAtOrDefault(1);
if(string.IsNullOrWhiteSpace(authMethod)) {
ctx.Connection.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
ctx.Connection.Dispose();
return;
}
string authToken = args.ElementAtOrDefault(2);
if(string.IsNullOrWhiteSpace(authToken)) {
ctx.Connection.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
ctx.Connection.Dispose();
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.ToString();
try {
fai = await Misuzu.AuthVerifyAsync(authMethod, authToken, ipAddr);
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed to authenticate: {ex}");
ctx.Connection.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
ctx.Connection.Dispose();
#if DEBUG
throw;
#else
return;
#endif
}
if(!fai.Success) {
Logger.Debug($"<{ctx.Connection.Id}> Auth fail: {fai.Reason}");
ctx.Connection.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
ctx.Connection.Dispose();
return;
}
MisuzuBanInfo fbi;
try {
fbi = await Misuzu.CheckBanAsync(fai.UserId.ToString(), ipAddr);
} catch(Exception ex) {
Logger.Write($"<{ctx.Connection.Id}> Failed auth ban check: {ex}");
ctx.Connection.Send(new AuthFailPacket(AuthFailReason.AuthInvalid));
ctx.Connection.Dispose();
#if DEBUG
throw;
#else
return;
#endif
}
if(fbi.IsBanned && !fbi.HasExpired) {
Logger.Write($"<{ctx.Connection.Id}> User is banned.");
ctx.Connection.Send(new AuthFailPacket(AuthFailReason.Banned, fbi));
ctx.Connection.Dispose();
return;
}
await ctx.Chat.ContextAccess.WaitAsync();
try {
ChatUser user = ctx.Chat.Users.FirstOrDefault(u => u.UserId == fai.UserId);
if(user == null)
user = new ChatUser(
fai.UserId,
fai.UserName,
fai.Colour,
fai.Rank,
fai.Permissions,
isSuper: fai.IsSuper
);
else
ctx.Chat.UpdateUser(
user,
userName: fai.UserName,
colour: fai.Colour,
rank: fai.Rank,
perms: fai.Permissions,
isSuper: fai.IsSuper
);
// Enforce a maximum amount of connections per user
if(ctx.Chat.Connections.Count(conn => conn.User == user) >= MaxConnections) {
ctx.Connection.Send(new AuthFailPacket(AuthFailReason.MaxSessions));
ctx.Connection.Dispose();
return;
}
ctx.Connection.BumpPing();
ctx.Connection.User = user;
ctx.Connection.Send(new LegacyCommandResponse(LCR.WELCOME, false, $"Welcome to Flashii Chat, {user.UserName}!"));
if(File.Exists("welcome.txt")) {
IEnumerable<string> lines = File.ReadAllLines("welcome.txt").Where(x => !string.IsNullOrWhiteSpace(x));
string line = lines.ElementAtOrDefault(RNG.Next(lines.Count()));
if(!string.IsNullOrWhiteSpace(line))
ctx.Connection.Send(new LegacyCommandResponse(LCR.WELCOME, false, line));
}
ctx.Chat.HandleJoin(user, DefaultChannel, ctx.Connection, MaxMessageLength);
} finally {
ctx.Chat.ContextAccess.Release();
}
}).Wait();
}
}
}

View file

@ -0,0 +1,51 @@
using SharpChat.Misuzu;
using SharpChat.Packet;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace SharpChat.PacketHandlers {
public class PingHandler : IChatPacketHandler {
private readonly MisuzuClient Misuzu;
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
private DateTimeOffset LastBump = DateTimeOffset.MinValue;
public PingHandler(MisuzuClient msz) {
Misuzu = msz ?? throw new ArgumentNullException(nameof(msz));
}
public bool IsMatch(ChatPacketHandlerContext ctx) {
return ctx.CheckPacketId("0");
}
public void Handle(ChatPacketHandlerContext ctx) {
string[] parts = ctx.SplitText(2);
if(!int.TryParse(parts.FirstOrDefault(), out int pTime))
return;
ctx.Connection.BumpPing();
ctx.Connection.Send(new PongPacket(ctx.Connection.LastPing));
ctx.Chat.ContextAccess.Wait();
try {
if(LastBump < DateTimeOffset.UtcNow - BumpInterval) {
(string, string)[] bumpList = ctx.Chat.Users
.Where(u => u.Status == ChatUserStatus.Online && ctx.Chat.Connections.Any(c => c.User == u))
.Select(u => (u.UserId.ToString(), ctx.Chat.GetRemoteAddresses(u).FirstOrDefault()?.ToString() ?? string.Empty))
.ToArray();
if(bumpList.Any())
Task.Run(async () => {
await Misuzu.BumpUsersOnlineAsync(bumpList);
}).Wait();
LastBump = DateTimeOffset.UtcNow;
}
} finally {
ctx.Chat.ContextAccess.Release();
}
}
}
}

View file

@ -0,0 +1,97 @@
using SharpChat.Commands;
using SharpChat.Config;
using SharpChat.Events;
using SharpChat.EventStorage;
using SharpChat.Packet;
using System;
using System.Collections.Generic;
using System.Linq;
namespace SharpChat.PacketHandlers
{
public class SendMessageHandler : IChatPacketHandler {
private readonly CachedValue<int> MaxMessageLength;
private List<IChatCommand> Commands { get; } = new();
public SendMessageHandler(CachedValue<int> maxMsgLength) {
MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength));
}
public void AddCommand(IChatCommand command) {
Commands.Add(command ?? throw new ArgumentNullException(nameof(command)));
}
public void AddCommands(IEnumerable<IChatCommand> commands) {
Commands.AddRange(commands ?? throw new ArgumentNullException(nameof(commands)));
}
public bool IsMatch(ChatPacketHandlerContext ctx) {
return ctx.CheckPacketId("2");
}
public void Handle(ChatPacketHandlerContext ctx) {
string[] args = ctx.SplitText(3);
ChatUser user = ctx.Connection.User;
// No longer concats everything after index 1 with \t, no previous implementation did that either
string messageText = args.ElementAtOrDefault(2);
if(user == null || !user.Can(ChatUserPermissions.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 {
if(!ctx.Chat.UserLastChannel.TryGetValue(user.UserId, out ChatChannel channel)
&& !ctx.Chat.IsInChannel(user, channel))
return;
if(user.Status != ChatUserStatus.Online)
ctx.Chat.UpdateUser(user, status: ChatUserStatus.Online);
int maxMsgLength = MaxMessageLength;
if(messageText.Length > maxMsgLength)
messageText = messageText[..maxMsgLength];
messageText = messageText.Trim();
#if DEBUG
Logger.Write($"<{ctx.Connection.Id} {user.UserName}> {messageText}");
#endif
if(messageText.StartsWith("/")) {
ChatCommandContext context = new(messageText, ctx.Chat, user, ctx.Connection, channel);
IChatCommand command = null;
foreach(IChatCommand cmd in Commands)
if(cmd.IsMatch(context)) {
command = cmd;
break;
}
if(command != null) {
command.Dispatch(context);
return;
}
}
ctx.Chat.DispatchEvent(new MessageCreateEvent(
SharpId.Next(),
channel,
user,
DateTimeOffset.Now,
messageText,
false, false, false
));
} finally {
ctx.Chat.ContextAccess.Release();
}
}
}
}

View file

@ -1,12 +1,15 @@
using SharpChat.Flashii;
using SharpChat.Config;
using SharpChat.EventStorage;
using SharpChat.Misuzu;
using System;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Threading;
namespace SharpChat {
public class Program {
public const ushort PORT = 6770;
public const string CONFIG = "sharpchat.cfg";
public static void Main(string[] args) {
Console.WriteLine(@" _____ __ ________ __ ");
@ -14,17 +17,135 @@ namespace SharpChat {
Console.WriteLine(@" \__ \/ __ \/ __ `/ ___/ __ \/ / / __ \/ __ `/ __/");
Console.WriteLine(@" ___/ / / / / /_/ / / / /_/ / /___/ / / / /_/ / /_ ");
Console.WriteLine(@"/____/_/ /_/\__,_/_/ / .___/\____/_/ /_/\__,_/\__/ ");
Console.WriteLine(@" / _/ Sock Chat Server");
#if DEBUG
Console.WriteLine(@"============================================ DEBUG ==");
#endif
/**/Console.Write(@" /__/");
if(SharpInfo.IsDebugBuild) {
Console.WriteLine();
Console.Write(@"== ");
Console.Write(SharpInfo.VersionString);
Console.WriteLine(@" == DBG ==");
} else
Console.WriteLine(SharpInfo.VersionStringShort.PadLeft(28, ' '));
Database.ReadConfig();
using ManualResetEvent mre = new(false);
bool hasCancelled = false;
void cancelKeyPressHandler(object sender, ConsoleCancelEventArgs ev) {
Console.CancelKeyPress -= cancelKeyPressHandler;
hasCancelled = true;
ev.Cancel = true;
mre.Set();
};
Console.CancelKeyPress += cancelKeyPressHandler;
if(hasCancelled) return;
string configFile = CONFIG;
// If the config file doesn't exist and we're using the default path, run the converter
if(!File.Exists(configFile) && configFile == CONFIG)
ConvertConfiguration();
using IConfig config = new StreamConfig(configFile);
if(hasCancelled) return;
using HttpClient httpClient = new(new HttpClientHandler() {
UseProxy = false,
});
httpClient.DefaultRequestHeaders.Add("User-Agent", SharpInfo.ProgramName);
if(hasCancelled) return;
MisuzuClient msz = new(httpClient, config.ScopeTo("msz"));
if(hasCancelled) return;
IEventStorage evtStore;
if(string.IsNullOrWhiteSpace(config.SafeReadValue("mariadb:host", string.Empty))) {
evtStore = new VirtualEventStorage();
} else {
MariaDBEventStorage mdbes = new(MariaDBEventStorage.BuildConnString(config.ScopeTo("mariadb")));
evtStore = mdbes;
mdbes.RunMigrations();
}
if(hasCancelled) return;
using SockChatServer scs = new(httpClient, msz, evtStore, config.ScopeTo("chat"));
scs.Listen(mre);
using ManualResetEvent mre = new ManualResetEvent(false);
using SockChatServer scs = new SockChatServer(mre, PORT);
Console.CancelKeyPress += (s, e) => { e.Cancel = true; mre.Set(); };
mre.WaitOne();
}
private static void ConvertConfiguration() {
using Stream s = new FileStream(CONFIG, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
s.SetLength(0);
s.Flush();
using StreamWriter sw = new(s, new UTF8Encoding(false));
sw.WriteLine("# and ; can be used at the start of a line for comments.");
sw.WriteLine();
sw.WriteLine("# General Configuration");
sw.WriteLine($"#chat:port {SockChatServer.DEFAULT_PORT}");
sw.WriteLine($"#chat:msgMaxLength {SockChatServer.DEFAULT_MSG_LENGTH_MAX}");
sw.WriteLine($"#chat:connMaxCount {SockChatServer.DEFAULT_MAX_CONNECTIONS}");
sw.WriteLine($"#chat:floodKickLength {SockChatServer.DEFAULT_FLOOD_KICK_LENGTH}");
sw.WriteLine();
sw.WriteLine("# Channels");
sw.WriteLine("chat:channels lounge staff");
sw.WriteLine();
sw.WriteLine("# Lounge channel settings");
sw.WriteLine("chat:channels:lounge:name Lounge");
sw.WriteLine("chat:channels:lounge:autoJoin true");
sw.WriteLine();
sw.WriteLine("# Staff channel settings");
sw.WriteLine("chat:channels:staff:name Staff");
sw.WriteLine("chat:channels:staff:minRank 5");
const string msz_secret = "login_key.txt";
const string msz_url = "msz_url.txt";
sw.WriteLine();
sw.WriteLine("# Misuzu integration settings");
if(File.Exists(msz_secret))
sw.WriteLine(string.Format("msz:secret {0}", File.ReadAllText(msz_secret).Trim()));
else
sw.WriteLine("#msz:secret woomy");
if(File.Exists(msz_url))
sw.WriteLine(string.Format("msz:url {0}/_sockchat", File.ReadAllText(msz_url).Trim()));
else
sw.WriteLine("#msz:url https://flashii.net/_sockchat");
const string mdb_config = @"mariadb.txt";
string[] mdbCfg = File.Exists(mdb_config) ? File.ReadAllLines(mdb_config) : Array.Empty<string>();
sw.WriteLine();
sw.WriteLine("# MariaDB configuration");
if(mdbCfg.Length > 0)
sw.WriteLine($"mariadb:host {mdbCfg[0]}");
else
sw.WriteLine($"#mariadb:host <username>");
if(mdbCfg.Length > 1)
sw.WriteLine($"mariadb:user {mdbCfg[1]}");
else
sw.WriteLine($"#mariadb:user <username>");
if(mdbCfg.Length > 2)
sw.WriteLine($"mariadb:pass {mdbCfg[2]}");
else
sw.WriteLine($"#mariadb:pass <password>");
if(mdbCfg.Length > 3)
sw.WriteLine($"mariadb:db {mdbCfg[3]}");
else
sw.WriteLine($"#mariadb:db <database>");
sw.Flush();
}
}
}

View file

@ -1,30 +1,83 @@
using System;
using System.Buffers;
using System.Security.Cryptography;
namespace SharpChat {
public static class RNG {
private static object Lock { get; } = new object();
private static Random NormalRandom { get; } = new Random();
public const string CHARS = "AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789";
private static Random NormalRandom { get; } = new();
private static RandomNumberGenerator SecureRandom { get; } = RandomNumberGenerator.Create();
public static int Next() {
lock (Lock)
return NormalRandom.Next();
return NormalRandom.Next();
}
public static int Next(int max) {
lock (Lock)
return NormalRandom.Next(max);
return NormalRandom.Next(max);
}
public static int Next(int min, int max) {
lock (Lock)
return NormalRandom.Next(min, max);
return NormalRandom.Next(min, max);
}
public static void NextBytes(byte[] buffer) {
lock(Lock)
SecureRandom.GetBytes(buffer);
}
public static int SecureNext() {
return SecureNext(int.MaxValue);
}
public static int SecureNext(int max) {
return SecureNext(0, max);
}
public static int SecureNext(int min, int max) {
--max;
if(min == max)
return min;
uint umax = (uint)max - (uint)min;
uint num;
byte[] buffer = ArrayPool<byte>.Shared.Rent(4);
try {
SecureRandom.GetBytes(buffer);
num = BitConverter.ToUInt32(buffer);
if(umax != uint.MaxValue) {
++umax;
if((umax & (umax - 1)) != 0) {
uint limit = uint.MaxValue - (uint.MaxValue & umax) - 1;
while(num > limit) {
SecureRandom.GetBytes(buffer);
num = BitConverter.ToUInt32(buffer);
}
}
}
} finally {
ArrayPool<byte>.Shared.Return(buffer);
}
return (int)((num % umax) + min);
}
private static string RandomStringInternal(Func<int, int> next, int length) {
char[] str = new char[length];
for(int i = 0; i < length; ++i)
str[i] = CHARS[next(CHARS.Length)];
return new string(str);
}
public static string RandomString(int length) {
return RandomStringInternal(Next, length);
}
public static string SecureRandomString(int length) {
return RandomStringInternal(SecureNext, length);
}
}
}

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