Compare commits
52 commits
flash-patc
...
mistress
Author | SHA1 | Date | |
---|---|---|---|
26e756525e |
|||
5a7756894b |
|||
f41ca7fb7f |
|||
67202d27f7 |
|||
a487a8dadf |
|||
0bc025e5f8 |
|||
eae379e933 |
|||
c537df792e |
|||
e0050a51bd |
|||
98d13ebbbb |
|||
d94b1cb813 |
|||
3f6007922c |
|||
999ce86a27 |
|||
dde349601d |
|||
224036adbd |
|||
bd86c610a3 |
|||
80475a9180 |
|||
f1d4051fb5 |
|||
8eff4127b5 |
|||
b4aa5873c3 |
|||
0a7e01f154 |
|||
dd377358e2 |
|||
bef41b2718 |
|||
9381bdfe51 |
|||
b9a7a43db8 |
|||
78a683620f |
|||
34e4e9b1a9 |
|||
6593929827 |
|||
158a0d3cea |
|||
7bdf41a047 |
|||
2eba089a21 |
|||
51f5c4c948 |
|||
f5eab926de |
|||
0cc5d46ea9 |
|||
b8ec381f3b |
|||
9f283e48fe |
|||
3fc94c425e |
|||
626951ad10 |
|||
a8f5c00e37 |
|||
e17aed7c25 |
|||
f5c8f2ae1d |
|||
40f8fc2e86 |
|||
1c23ffbbe8 |
|||
b026bad176 |
|||
3c8bb88d53 |
|||
83cf6ae438 |
|||
62ab8c0c93 |
|||
e7b38dc8e1 |
|||
306f8a29d1 |
|||
3ad3721979 |
|||
4f2d207761 | |||
783d5162da |
217 changed files with 6660 additions and 5657 deletions
.editorconfig.gitignoreLICENSEProtocol.md
SharpChat.Flashii
FlashiiAuthResult.csFlashiiBanInfo.csFlashiiClient.csFlashiiIPAddressBanInfo.csFlashiiRawBanInfo.csFlashiiUserBanInfo.csFlashiiUserPermissions.csSharpChat.Flashii.csproj
SharpChat.MariaDB
MariaDBConnection.csMariaDBMessageStorage.csMariaDBMigrations.csMariaDBStorage.csMariaDBUserPermissions.csMariaDBUserPermissionsConverter.csSharpChat.MariaDB.csproj
SharpChat.SQLite
SQLiteConnection.csSQLiteMessageStorage.csSQLiteMigrations.csSQLiteStorage.csSQLiteUserPermissions.csSQLiteUserPermissionsConverter.csSharpChat.SQLite.csproj
SharpChat.SockChat
IWebSocketConnectionExtensions.csS2CPacket.cs
SharpChat.slnS2CPackets
AuthFailS2CPacket.csAuthSuccessS2CPacket.csBanListS2CPacket.csChannelCreateS2CPacket.csChannelDeleteS2CPacket.csChannelUpdateS2CPacket.csChatMessageAddS2CPacket.csChatMessageDeleteS2CPacket.csCommandResponseS2CPacket.csContextChannelsS2CPacket.csContextClearS2CPacket.csContextMessageS2CPacket.csContextUsersS2CPacket.csForceDisconnectS2CPacket.csPongS2CPacket.csUserChannelForceJoinS2CPacket.csUserChannelJoinS2CPacket.csUserChannelLeaveS2CPacket.csUserConnectS2CPacket.csUserDisconnectS2CPacket.csUserUpdateS2CPacket.cs
SharpChat.SockChat.csprojSharpChatWebSocketServer.csSharpChat
C2SPacketHandler.csC2SPacketHandlerContext.cs
C2SPacketHandlers
ChatChannel.csChatColour.csChatCommandContext.csChatConnection.csChatContext.csChatPacketHandlerContext.csChatUser.csChatUserPermissions.csChatUserStatus.csClientCommand.csClientCommandContext.csClientCommands
AFKClientCommand.csActionClientCommand.csBanListClientCommand.csBroadcastClientCommand.csCreateChannelClientCommand.csDeleteChannelClientCommand.csDeleteMessageClientCommand.csJoinChannelClientCommand.csKickBanClientCommand.csNickClientCommand.csPardonAddressClientCommand.csPardonUserClientCommand.csPasswordChannelClientCommand.csRankChannelClientCommand.csRemoteAddressClientCommand.csShutdownRestartClientCommand.csWhisperClientCommand.csWhoClientCommand.cs
Commands
144
.editorconfig
Normal file
144
.editorconfig
Normal file
|
@ -0,0 +1,144 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.cs]
|
||||
|
||||
# IDE1006: Naming Styles
|
||||
dotnet_diagnostic.IDE1006.severity = none
|
||||
csharp_indent_labels = one_less_than_current
|
||||
csharp_using_directive_placement = outside_namespace:silent
|
||||
csharp_prefer_simple_using_statement = true:suggestion
|
||||
csharp_prefer_braces = true:silent
|
||||
csharp_style_namespace_declarations = file_scoped:silent
|
||||
csharp_style_prefer_method_group_conversion = true:silent
|
||||
csharp_style_prefer_top_level_statements = true:silent
|
||||
csharp_style_prefer_primary_constructors = true:suggestion
|
||||
csharp_prefer_system_threading_lock = true:suggestion
|
||||
csharp_style_expression_bodied_methods = false:silent
|
||||
csharp_style_expression_bodied_constructors = false:silent
|
||||
csharp_style_expression_bodied_operators = false:silent
|
||||
csharp_style_expression_bodied_properties = true:silent
|
||||
csharp_style_expression_bodied_indexers = true:silent
|
||||
csharp_style_expression_bodied_accessors = true:silent
|
||||
csharp_style_expression_bodied_lambdas = true:silent
|
||||
csharp_style_expression_bodied_local_functions = false:silent
|
||||
csharp_style_prefer_null_check_over_type_check = true:suggestion
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
csharp_style_prefer_local_over_anonymous_function = true:suggestion
|
||||
csharp_prefer_simple_default_expression = true:suggestion
|
||||
csharp_style_prefer_range_operator = true:suggestion
|
||||
csharp_style_prefer_index_operator = true:suggestion
|
||||
csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion
|
||||
csharp_style_prefer_tuple_swap = true:suggestion
|
||||
csharp_style_prefer_utf8_string_literals = true:suggestion
|
||||
csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion
|
||||
csharp_style_deconstructed_variable_declaration = true:suggestion
|
||||
csharp_style_inlined_variable_declaration = true:suggestion
|
||||
csharp_style_unused_value_expression_statement_preference = discard_variable:silent
|
||||
csharp_style_unused_value_assignment_preference = discard_variable:suggestion
|
||||
csharp_prefer_static_local_function = true:suggestion
|
||||
csharp_style_prefer_readonly_struct = true:suggestion
|
||||
csharp_prefer_static_anonymous_function = true:suggestion
|
||||
csharp_style_prefer_readonly_struct_member = true:suggestion
|
||||
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent
|
||||
csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
|
||||
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
|
||||
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
|
||||
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
csharp_style_prefer_switch_expression = true:suggestion
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_prefer_pattern_matching = true:suggestion
|
||||
csharp_style_prefer_not_pattern = true:suggestion
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
csharp_style_prefer_extended_property_pattern = true:suggestion
|
||||
csharp_style_var_for_built_in_types = false:silent
|
||||
csharp_style_var_when_type_is_apparent = false:silent
|
||||
csharp_style_var_elsewhere = false:silent
|
||||
|
||||
[*.{cs,vb}]
|
||||
#### Naming styles ####
|
||||
|
||||
# Naming rules
|
||||
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
|
||||
dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
|
||||
|
||||
dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.types_should_be_pascal_case.symbols = types
|
||||
dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
|
||||
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
|
||||
dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
|
||||
|
||||
# Symbol specifications
|
||||
|
||||
dotnet_naming_symbols.interface.applicable_kinds = interface
|
||||
dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.interface.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
|
||||
dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.types.required_modifiers =
|
||||
|
||||
dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
|
||||
dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_field_members.required_modifiers =
|
||||
|
||||
# Naming styles
|
||||
|
||||
dotnet_naming_style.begins_with_i.required_prefix = I
|
||||
dotnet_naming_style.begins_with_i.required_suffix =
|
||||
dotnet_naming_style.begins_with_i.word_separator =
|
||||
dotnet_naming_style.begins_with_i.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
|
||||
dotnet_naming_style.pascal_case.required_prefix =
|
||||
dotnet_naming_style.pascal_case.required_suffix =
|
||||
dotnet_naming_style.pascal_case.word_separator =
|
||||
dotnet_naming_style.pascal_case.capitalization = pascal_case
|
||||
dotnet_style_operator_placement_when_wrapping = beginning_of_line
|
||||
tab_width = 4
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion
|
||||
dotnet_style_prefer_auto_properties = true:suggestion
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
|
||||
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
|
||||
dotnet_style_explicit_tuple_names = true:suggestion
|
||||
dotnet_style_prefer_inferred_tuple_names = true:suggestion
|
||||
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
|
||||
dotnet_style_prefer_compound_assignment = true:suggestion
|
||||
dotnet_style_prefer_simplified_interpolation = true:suggestion
|
||||
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||
dotnet_style_namespace_match_folder = true:suggestion
|
||||
dotnet_style_readonly_field = true:suggestion
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:silent
|
||||
dotnet_style_predefined_type_for_member_access = true:silent
|
||||
dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent
|
||||
dotnet_style_allow_statement_immediately_after_block_experimental = true:silent
|
||||
dotnet_style_allow_multiple_blank_lines_experimental = true:silent
|
||||
dotnet_code_quality_unused_parameters = all:suggestion
|
||||
dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
|
||||
dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent
|
||||
dotnet_style_qualification_for_property = false:silent
|
||||
dotnet_style_qualification_for_field = false:silent
|
||||
dotnet_style_qualification_for_event = false:silent
|
||||
dotnet_style_qualification_for_method = false:silent
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -8,7 +8,11 @@ http-motd.txt
|
|||
_webdb.txt
|
||||
msz_url.txt
|
||||
sharpchat.cfg
|
||||
sharpchat.db
|
||||
sharpchat.db-wal
|
||||
sharpchat.db-shm
|
||||
SharpChat/version.txt
|
||||
logs/
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019-2024 flashwave
|
||||
Copyright (c) 2019-2025 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
936
Protocol.md
|
@ -1,936 +0,0 @@
|
|||
# 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 `<`, `>` 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 `<`, `>` 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><a href="javascript:void(0);" onclick="Chat.SendMessageWrapper('/unban '+ this.innerHTML);">{0}</a></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><a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);">{0}</a></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 > of the opening <a> 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><a href="javascript:void(0);" onclick="UI.InsertChatText(this.innerHTML);">{0}</a></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 > of the opening <a> 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 `&lt;` and suffixed by `&gt;_` resulting in a username that looks like `<AWAY>_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).
|
81
SharpChat.Flashii/FlashiiAuthResult.cs
Normal file
81
SharpChat.Flashii/FlashiiAuthResult.cs
Normal file
|
@ -0,0 +1,81 @@
|
|||
using SharpChat.Auth;
|
||||
using SharpChat.Users;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SharpChat.Flashii;
|
||||
|
||||
public class FlashiiAuthResult : AuthResult {
|
||||
public string UserId => UserIdRaw.ToString();
|
||||
public string UserName => UserNameRaw ?? string.Empty;
|
||||
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
|
||||
|
||||
public UserPermissions UserPermissions {
|
||||
get {
|
||||
UserPermissions perms = 0;
|
||||
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_MESSAGE_SEND))
|
||||
perms |= UserPermissions.SendMessage;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_MESSAGE_DELETE_OWN))
|
||||
perms |= UserPermissions.DeleteOwnMessage;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_MESSAGE_DELETE_ANY))
|
||||
perms |= UserPermissions.DeleteAnyMessage;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_MESSAGE_EDIT_OWN))
|
||||
perms |= UserPermissions.EditOwnMessage;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_MESSAGE_EDIT_ANY))
|
||||
perms |= UserPermissions.EditAnyMessage;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_MESSAGE_BROADCAST))
|
||||
perms |= UserPermissions.SendBroadcast;
|
||||
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_USER_KICK))
|
||||
perms |= UserPermissions.KickUser;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_USER_BAN))
|
||||
perms |= UserPermissions.BanUser
|
||||
| UserPermissions.ViewBanList
|
||||
| UserPermissions.PardonUser
|
||||
| UserPermissions.PardonIPAddress;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_USER_VIEW_ADDR))
|
||||
perms |= UserPermissions.ViewIPAddress;
|
||||
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_CHANNEL_CREATE))
|
||||
perms |= UserPermissions.CreateChannel;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_CHANNEL_SET_PERSIST))
|
||||
perms |= UserPermissions.SetChannelPermanent;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_CHANNEL_SET_PASSWORD))
|
||||
perms |= UserPermissions.SetChannelPassword;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_CHANNEL_SET_MIN_RANK))
|
||||
perms |= UserPermissions.SetChannelMinimumRank;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_CHANNEL_DELETE))
|
||||
perms |= UserPermissions.DeleteChannel;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_CHANNEL_JOIN_ANY))
|
||||
perms |= UserPermissions.JoinAnyChannel;
|
||||
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_NICK_SET_OWN))
|
||||
perms |= UserPermissions.SetOwnNickname;
|
||||
if(UserPermissionsRaw.HasFlag(FlashiiUserPermissions.C_NICK_SET_ANY))
|
||||
perms |= UserPermissions.SetOthersNickname;
|
||||
|
||||
return perms;
|
||||
}
|
||||
}
|
||||
|
||||
[JsonPropertyName("success")]
|
||||
public bool Success { get; init; }
|
||||
|
||||
[JsonPropertyName("reason")]
|
||||
public string? Reason { get; init; }
|
||||
|
||||
[JsonPropertyName("user_id")]
|
||||
public long UserIdRaw { get; init; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string? UserNameRaw { get; init; }
|
||||
|
||||
[JsonPropertyName("colour_raw")]
|
||||
public int UserColourRaw { get; init; }
|
||||
|
||||
[JsonPropertyName("hierarchy")]
|
||||
public int UserRank { get; init; }
|
||||
|
||||
[JsonPropertyName("perms")]
|
||||
public FlashiiUserPermissions UserPermissionsRaw { get; init; }
|
||||
}
|
13
SharpChat.Flashii/FlashiiBanInfo.cs
Normal file
13
SharpChat.Flashii/FlashiiBanInfo.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using SharpChat.Bans;
|
||||
|
||||
namespace SharpChat.Flashii;
|
||||
|
||||
public abstract class FlashiiBanInfo(
|
||||
BanKind kind,
|
||||
FlashiiRawBanInfo rawBanInfo
|
||||
) : BanInfo {
|
||||
public BanKind Kind { get; } = kind;
|
||||
public bool IsPermanent { get; } = rawBanInfo.IsPermanent;
|
||||
public DateTimeOffset ExpiresAt { get; } = rawBanInfo.ExpiresAt;
|
||||
public abstract override string ToString();
|
||||
}
|
259
SharpChat.Flashii/FlashiiClient.cs
Normal file
259
SharpChat.Flashii/FlashiiClient.cs
Normal file
|
@ -0,0 +1,259 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using SharpChat.Auth;
|
||||
using SharpChat.Bans;
|
||||
using SharpChat.Configuration;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.Flashii;
|
||||
|
||||
public class FlashiiClient(ILogger logger, HttpClient httpClient, Config config) : AuthClient, BansClient {
|
||||
private const string DEFAULT_BASE_URL = "https://flashii.net/_sockchat";
|
||||
private readonly CachedValue<string> BaseURL = config.ReadCached("url", DEFAULT_BASE_URL);
|
||||
|
||||
private const string DEFAULT_SECRET_KEY = "woomy";
|
||||
private readonly CachedValue<string> SecretKey = config.ReadCached("secret", DEFAULT_SECRET_KEY);
|
||||
|
||||
private string CreateStringSignature(string str) {
|
||||
return CreateBufferSignature(Encoding.UTF8.GetBytes(str));
|
||||
}
|
||||
|
||||
private string CreateBufferSignature(byte[] bytes) {
|
||||
using HMACSHA256 algo = new(Encoding.UTF8.GetBytes(SecretKey!));
|
||||
return string.Concat(algo.ComputeHash(bytes).Select(c => c.ToString("x2")));
|
||||
}
|
||||
|
||||
private const string AUTH_VERIFY_URL = "{0}/verify";
|
||||
private const string AUTH_VERIFY_SIG = "verify#{0}#{1}#{2}";
|
||||
|
||||
public async Task<AuthResult> AuthVerify(IPAddress remoteAddr, string scheme, string token) {
|
||||
logger.ZLogInformation($"Verifying authentication data for {remoteAddr}...");
|
||||
logger.ZLogTrace($"AuthVerify({remoteAddr}, {scheme}, {token})");
|
||||
|
||||
string remoteAddrStr = remoteAddr.ToString();
|
||||
|
||||
HttpRequestMessage request = new(HttpMethod.Post, string.Format(AUTH_VERIFY_URL, BaseURL)) {
|
||||
Content = new FormUrlEncodedContent(new Dictionary<string, string> {
|
||||
{ "method", scheme },
|
||||
{ "token", token },
|
||||
{ "ipaddr", remoteAddrStr },
|
||||
}),
|
||||
Headers = {
|
||||
{ "X-SharpChat-Signature", CreateStringSignature(string.Format(AUTH_VERIFY_SIG, scheme, token, remoteAddrStr)) },
|
||||
},
|
||||
};
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request);
|
||||
logger.ZLogTrace($"AuthVerify() -> HTTP {response.StatusCode}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
FlashiiAuthResult? authResult = await JsonSerializer.DeserializeAsync<FlashiiAuthResult>(stream);
|
||||
if(authResult?.Success != true)
|
||||
throw new AuthFailedException(authResult?.Reason ?? "none");
|
||||
|
||||
return authResult;
|
||||
}
|
||||
|
||||
private const string AUTH_BUMP_USERS_ONLINE_URL = "{0}/bump";
|
||||
|
||||
public async Task AuthBumpUsersOnline(IEnumerable<(IPAddress remoteAddr, string userId)> entries) {
|
||||
if(!entries.Any())
|
||||
return;
|
||||
|
||||
logger.ZLogInformation($"Bumping online users list...");
|
||||
|
||||
string now = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
|
||||
StringBuilder sb = new();
|
||||
sb.AppendFormat("bump#{0}", now);
|
||||
|
||||
Dictionary<string, string> formData = new() {
|
||||
{ "t", now },
|
||||
};
|
||||
|
||||
foreach(var (remoteAddr, userId) in entries) {
|
||||
string remoteAddrStr = remoteAddr.ToString();
|
||||
sb.AppendFormat("#{0}:{1}", userId, remoteAddrStr);
|
||||
formData.Add(string.Format("u[{0}]", userId), remoteAddrStr);
|
||||
}
|
||||
|
||||
HttpRequestMessage request = new(HttpMethod.Post, string.Format(AUTH_BUMP_USERS_ONLINE_URL, BaseURL)) {
|
||||
Headers = {
|
||||
{ "X-SharpChat-Signature", CreateStringSignature(sb.ToString()) }
|
||||
},
|
||||
Content = new FormUrlEncodedContent(formData),
|
||||
};
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request);
|
||||
logger.ZLogTrace($"AuthBumpUsersOnline() -> HTTP {response.StatusCode}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private const string BANS_CREATE_URL = "{0}/bans/create";
|
||||
private const string BANS_CREATE_SIG = "create#{0}#{1}#{2}#{3}#{4}#{5}#{6}#{7}";
|
||||
|
||||
public async Task BanCreate(
|
||||
BanKind kind,
|
||||
TimeSpan duration,
|
||||
IPAddress remoteAddr,
|
||||
string? userId = null,
|
||||
string? reason = null,
|
||||
IPAddress? issuerRemoteAddr = null,
|
||||
string? issuerUserId = null
|
||||
) {
|
||||
logger.ZLogInformation($"Creating ban of kind {kind} with duration {duration} for {remoteAddr}/{userId} issued by {issuerRemoteAddr}/{issuerUserId}...");
|
||||
if(duration <= TimeSpan.Zero || kind != BanKind.User)
|
||||
return;
|
||||
|
||||
issuerUserId ??= string.Empty;
|
||||
userId ??= string.Empty;
|
||||
reason ??= string.Empty;
|
||||
issuerRemoteAddr ??= IPAddress.IPv6None;
|
||||
|
||||
string isPerma = duration == TimeSpan.MaxValue ? "1" : "0";
|
||||
string durationStr = duration == TimeSpan.MaxValue ? "-1" : duration.TotalSeconds.ToString();
|
||||
string remoteAddrStr = remoteAddr.ToString();
|
||||
string issuerRemoteAddrStr = issuerRemoteAddr.ToString();
|
||||
|
||||
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
|
||||
string sig = string.Format(
|
||||
BANS_CREATE_SIG,
|
||||
now, userId, remoteAddrStr,
|
||||
issuerUserId, issuerRemoteAddrStr,
|
||||
durationStr, isPerma, reason
|
||||
);
|
||||
|
||||
HttpRequestMessage request = 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", userId },
|
||||
{ "ua", remoteAddrStr },
|
||||
{ "mi", issuerUserId },
|
||||
{ "ma", issuerRemoteAddrStr },
|
||||
{ "d", durationStr },
|
||||
{ "p", isPerma },
|
||||
{ "r", reason },
|
||||
}),
|
||||
};
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request);
|
||||
logger.ZLogTrace($"BanCreate() -> HTTP {response.StatusCode}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private const string BANS_REVOKE_URL = "{0}/bans/revoke?t={1}&s={2}&x={3}";
|
||||
private const string BANS_REVOKE_SIG = "revoke#{0}#{1}#{2}";
|
||||
|
||||
public async Task<bool> BanRevoke(BanInfo info) {
|
||||
string type;
|
||||
string target;
|
||||
|
||||
if(info is UserBanInfo ubi) {
|
||||
if(info.Kind != BanKind.User)
|
||||
throw new ArgumentException("info argument is an instance of UserBanInfo but Kind was not set to BanKind.User", nameof(info));
|
||||
|
||||
type = "user";
|
||||
target = ubi.UserId;
|
||||
} else if(info is IPAddressBanInfo iabi) {
|
||||
if(info.Kind != BanKind.IPAddress)
|
||||
throw new ArgumentException("info argument is an instance of IPAddressBanInfo but Kind was not set to BanKind.IPAddress", nameof(info));
|
||||
|
||||
type = "addr";
|
||||
target = iabi.Address.ToString();
|
||||
} else throw new ArgumentException("info argument is set to unsupported implementation", nameof(info));
|
||||
|
||||
logger.ZLogInformation($"Revoking ban of kind {info.Kind} issued on {target}...");
|
||||
|
||||
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 request = new(HttpMethod.Delete, url) {
|
||||
Headers = {
|
||||
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
|
||||
},
|
||||
};
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request);
|
||||
if(response.StatusCode == HttpStatusCode.NotFound)
|
||||
return false;
|
||||
|
||||
logger.ZLogTrace($"BanRevoke() -> HTTP {response.StatusCode}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
return response.StatusCode == HttpStatusCode.NoContent;
|
||||
}
|
||||
|
||||
private const string BANS_CHECK_URL = "{0}/bans/check?u={1}&a={2}&x={3}&n={4}";
|
||||
private const string BANS_CHECK_SIG = "check#{0}#{1}#{2}#{3}";
|
||||
|
||||
public async Task<BanInfo?> BanGet(string? userIdOrName = null, IPAddress? remoteAddr = null) {
|
||||
userIdOrName ??= "0";
|
||||
remoteAddr ??= IPAddress.None;
|
||||
|
||||
logger.ZLogInformation($"Requesting ban info for {remoteAddr}/{userIdOrName}...");
|
||||
|
||||
string now = DateTimeOffset.Now.ToUnixTimeSeconds().ToString();
|
||||
bool usingUserName = string.IsNullOrEmpty(userIdOrName) || userIdOrName.Any(c => c is < '0' or > '9');
|
||||
string remoteAddrStr = remoteAddr.ToString();
|
||||
string usingUserNameStr = usingUserName ? "1" : "0";
|
||||
string url = string.Format(BANS_CHECK_URL, BaseURL, Uri.EscapeDataString(userIdOrName), Uri.EscapeDataString(remoteAddrStr), Uri.EscapeDataString(now), Uri.EscapeDataString(usingUserNameStr));
|
||||
string sig = string.Format(BANS_CHECK_SIG, now, userIdOrName, remoteAddrStr, usingUserNameStr);
|
||||
|
||||
HttpRequestMessage request = new(HttpMethod.Get, url) {
|
||||
Headers = {
|
||||
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
|
||||
},
|
||||
};
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request);
|
||||
logger.ZLogTrace($"BanGet() -> HTTP {response.StatusCode}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
FlashiiRawBanInfo? rawBanInfo = await JsonSerializer.DeserializeAsync<FlashiiRawBanInfo>(stream);
|
||||
if(rawBanInfo?.IsBanned != true || rawBanInfo.HasExpired)
|
||||
return null;
|
||||
|
||||
return rawBanInfo.RemoteAddress is null or "::"
|
||||
? new FlashiiUserBanInfo(rawBanInfo)
|
||||
: new FlashiiIPAddressBanInfo(rawBanInfo);
|
||||
}
|
||||
|
||||
private const string BANS_LIST_URL = "{0}/bans/list?x={1}";
|
||||
private const string BANS_LIST_SIG = "list#{0}";
|
||||
|
||||
public async Task<BanInfo[]> BanGetList() {
|
||||
logger.ZLogInformation($"Requesting ban list...");
|
||||
|
||||
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 request = new(HttpMethod.Get, url) {
|
||||
Headers = {
|
||||
{ "X-SharpChat-Signature", CreateStringSignature(sig) },
|
||||
},
|
||||
};
|
||||
|
||||
using HttpResponseMessage response = await httpClient.SendAsync(request);
|
||||
logger.ZLogTrace($"BanGetList() -> HTTP {response.StatusCode}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using Stream stream = await response.Content.ReadAsStreamAsync();
|
||||
FlashiiRawBanInfo[]? list = await JsonSerializer.DeserializeAsync<FlashiiRawBanInfo[]>(stream);
|
||||
if(list is null || list.Length < 1)
|
||||
return [];
|
||||
|
||||
return [.. list.Where(b => b?.IsBanned == true && !b.HasExpired).Select(b => {
|
||||
return (BanInfo)(b.RemoteAddress is null or "::"
|
||||
? new FlashiiUserBanInfo(b) : new FlashiiIPAddressBanInfo(b));
|
||||
})];
|
||||
}
|
||||
}
|
9
SharpChat.Flashii/FlashiiIPAddressBanInfo.cs
Normal file
9
SharpChat.Flashii/FlashiiIPAddressBanInfo.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using SharpChat.Bans;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat.Flashii;
|
||||
|
||||
public class FlashiiIPAddressBanInfo(FlashiiRawBanInfo rawBanInfo) : FlashiiBanInfo(BanKind.IPAddress, rawBanInfo), IPAddressBanInfo {
|
||||
public IPAddress Address { get; } = IPAddress.TryParse(rawBanInfo.RemoteAddress, out IPAddress? addr) && addr is not null ? addr : IPAddress.IPv6None;
|
||||
public override string ToString() => Address.ToString();
|
||||
}
|
29
SharpChat.Flashii/FlashiiRawBanInfo.cs
Normal file
29
SharpChat.Flashii/FlashiiRawBanInfo.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SharpChat.Flashii;
|
||||
|
||||
public class FlashiiRawBanInfo {
|
||||
[JsonPropertyName("is_ban")]
|
||||
public bool IsBanned { get; set; }
|
||||
|
||||
[JsonPropertyName("user_id")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("user_name")]
|
||||
public string? UserName { get; set; }
|
||||
|
||||
[JsonPropertyName("user_colour")]
|
||||
public int UserColourRaw { get; set; }
|
||||
public ColourInheritable UserColour => ColourInheritable.FromMisuzu(UserColourRaw);
|
||||
|
||||
[JsonPropertyName("ip_addr")]
|
||||
public string? RemoteAddress { get; set; }
|
||||
|
||||
[JsonPropertyName("is_perma")]
|
||||
public bool IsPermanent { get; set; }
|
||||
|
||||
[JsonPropertyName("expires")]
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
|
||||
public bool HasExpired => !IsPermanent && DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
}
|
10
SharpChat.Flashii/FlashiiUserBanInfo.cs
Normal file
10
SharpChat.Flashii/FlashiiUserBanInfo.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using SharpChat.Bans;
|
||||
|
||||
namespace SharpChat.Flashii;
|
||||
|
||||
public class FlashiiUserBanInfo(FlashiiRawBanInfo rawBanInfo) : FlashiiBanInfo(BanKind.User, rawBanInfo), UserBanInfo {
|
||||
public string UserId { get; } = rawBanInfo.UserId ?? string.Empty;
|
||||
public string UserName { get; } = rawBanInfo.UserName ?? $"({rawBanInfo.UserId ?? string.Empty})";
|
||||
public ColourInheritable UserColour { get; } = ColourInheritable.FromMisuzu(rawBanInfo.UserColourRaw);
|
||||
public override string ToString() => UserName;
|
||||
}
|
27
SharpChat.Flashii/FlashiiUserPermissions.cs
Normal file
27
SharpChat.Flashii/FlashiiUserPermissions.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
namespace SharpChat.Flashii;
|
||||
|
||||
/// <summary>
|
||||
/// Flashii Chat Permissions.
|
||||
/// Has strange naming because its yoinked from https://patchii.net/flashii/misuzu/src/commit/dd8ec7c8ddb8aa1343b993eac5d23d152ac71940/src/Perm.php#L98.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum FlashiiUserPermissions : int {
|
||||
C_USER_KICK = 0b00000_00000000_00000000_00000000_00000000_00000000_00000001,
|
||||
C_USER_BAN = 0b00000_00000000_00000000_00000000_00000000_00000000_00000010,
|
||||
//C_USER_SILENCE = 0b00000_00000000_00000000_00000000_00000000_00000000_00000100,
|
||||
C_MESSAGE_BROADCAST = 0b00000_00000000_00000000_00000000_00000000_00000000_00001000,
|
||||
C_NICK_SET_OWN = 0b00000_00000000_00000000_00000000_00000000_00000000_00010000,
|
||||
C_NICK_SET_ANY = 0b00000_00000000_00000000_00000000_00000000_00000000_00100000,
|
||||
C_CHANNEL_CREATE = 0b00000_00000000_00000000_00000000_00000000_00000000_01000000,
|
||||
C_CHANNEL_SET_PERSIST = 0b00000_00000000_00000000_00000000_00000000_00000000_10000000,
|
||||
C_CHANNEL_SET_PASSWORD = 0b00000_00000000_00000000_00000000_00000000_00000001_00000000,
|
||||
C_CHANNEL_SET_MIN_RANK = 0b00000_00000000_00000000_00000000_00000000_00000010_00000000,
|
||||
C_MESSAGE_SEND = 0b00000_00000000_00000000_00000000_00000000_00000100_00000000,
|
||||
C_MESSAGE_DELETE_OWN = 0b00000_00000000_00000000_00000000_00000000_00001000_00000000,
|
||||
C_MESSAGE_DELETE_ANY = 0b00000_00000000_00000000_00000000_00000000_00010000_00000000,
|
||||
C_MESSAGE_EDIT_OWN = 0b00000_00000000_00000000_00000000_00000000_00100000_00000000,
|
||||
C_MESSAGE_EDIT_ANY = 0b00000_00000000_00000000_00000000_00000000_01000000_00000000,
|
||||
C_USER_VIEW_ADDR = 0b00000_00000000_00000000_00000000_00000000_10000000_00000000,
|
||||
C_CHANNEL_DELETE = 0b00000_00000000_00000000_00000000_00000001_00000000_00000000,
|
||||
C_CHANNEL_JOIN_ANY = 0b00000_00000000_00000000_00000000_00000010_00000000_00000000,
|
||||
}
|
17
SharpChat.Flashii/SharpChat.Flashii.csproj
Normal file
17
SharpChat.Flashii/SharpChat.Flashii.csproj
Normal file
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZLogger" Version="2.5.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
57
SharpChat.MariaDB/MariaDBConnection.cs
Normal file
57
SharpChat.MariaDB/MariaDBConnection.cs
Normal file
|
@ -0,0 +1,57 @@
|
|||
using MySqlConnector;
|
||||
using System.Data;
|
||||
|
||||
namespace SharpChat.MariaDB;
|
||||
|
||||
public class MariaDBConnection(MySqlConnection conn) : IDisposable {
|
||||
public MySqlConnection Connection { get; } = conn;
|
||||
|
||||
public async Task<int> RunCommand(string command, params MySqlParameter[] parameters) {
|
||||
using MySqlCommand cmd = Connection.CreateCommand();
|
||||
cmd.Parameters.Clear();
|
||||
if(parameters?.Length > 0)
|
||||
cmd.Parameters.AddRange(parameters);
|
||||
cmd.CommandText = command;
|
||||
return await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public async Task<MySqlDataReader?> RunQuery(string command, params MySqlParameter[] parameters) {
|
||||
using MySqlCommand cmd = Connection.CreateCommand();
|
||||
cmd.Parameters.Clear();
|
||||
if(parameters?.Length > 0)
|
||||
cmd.Parameters.AddRange(parameters);
|
||||
cmd.CommandText = command;
|
||||
return await cmd.ExecuteReaderAsync(CommandBehavior.CloseConnection);
|
||||
}
|
||||
|
||||
public async Task<T> RunQueryValue<T>(string command, params MySqlParameter[] parameters)
|
||||
where T : struct {
|
||||
using MySqlCommand cmd = Connection.CreateCommand();
|
||||
cmd.Parameters.Clear();
|
||||
if(parameters?.Length > 0)
|
||||
cmd.Parameters.AddRange(parameters);
|
||||
cmd.CommandText = command;
|
||||
await cmd.PrepareAsync();
|
||||
|
||||
object? raw = await cmd.ExecuteScalarAsync();
|
||||
return raw is T value ? value : default;
|
||||
}
|
||||
|
||||
private bool disposed = false;
|
||||
|
||||
~MariaDBConnection() {
|
||||
DoDispose();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
DoDispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void DoDispose() {
|
||||
if(disposed)
|
||||
return;
|
||||
disposed = true;
|
||||
Connection.Dispose();
|
||||
}
|
||||
}
|
218
SharpChat.MariaDB/MariaDBMessageStorage.cs
Normal file
218
SharpChat.MariaDB/MariaDBMessageStorage.cs
Normal file
|
@ -0,0 +1,218 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using MySqlConnector;
|
||||
using SharpChat.Data;
|
||||
using SharpChat.Messages;
|
||||
using SharpChat.Users;
|
||||
using System.Data.Common;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.MariaDB;
|
||||
|
||||
public class MariaDBMessageStorage(MariaDBStorage storage, ILogger logger) : MessageStorage {
|
||||
public async Task LogMessage(Message msg) {
|
||||
try {
|
||||
using MariaDBConnection conn = await storage.CreateConnection();
|
||||
await conn.RunCommand(
|
||||
"INSERT IGNORE INTO sqc_events (event_id, event_type, event_channel, event_data"
|
||||
+ ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms"
|
||||
+ ", event_created, event_deleted)"
|
||||
+ " VALUES (@id, @type, @channel, @data"
|
||||
+ ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms"
|
||||
+ ", FROM_UNIXTIME(@created), FROM_UNIXTIME(@deleted))",
|
||||
new MySqlParameter("id", msg.Id),
|
||||
new MySqlParameter("type", msg.Type),
|
||||
new MySqlParameter("channel", string.IsNullOrWhiteSpace(msg.ChannelName) ? null : msg.ChannelName),
|
||||
new MySqlParameter("data", JsonSerializer.SerializeToUtf8Bytes(msg.Data)),
|
||||
new MySqlParameter("sender", long.TryParse(msg.SenderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
|
||||
new MySqlParameter("sender_name", msg.SenderName),
|
||||
new MySqlParameter("sender_colour", msg.SenderColour.ToMisuzu()),
|
||||
new MySqlParameter("sender_rank", msg.SenderRank),
|
||||
new MySqlParameter("sender_nick", string.IsNullOrWhiteSpace(msg.SenderNickName) ? null : msg.SenderNickName),
|
||||
new MySqlParameter("sender_perms", MariaDBUserPermissionsConverter.To(msg.SenderPermissions)),
|
||||
new MySqlParameter("created", msg.Created.ToUnixTimeSeconds()),
|
||||
new MySqlParameter("deleted", msg.Deleted?.ToUnixTimeSeconds())
|
||||
);
|
||||
} catch(MySqlException ex) {
|
||||
logger.ZLogError($"Error in LogMessage(Message): {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogMessage(
|
||||
long id,
|
||||
string type,
|
||||
string channelName,
|
||||
string senderId,
|
||||
string senderName,
|
||||
ColourInheritable senderColour,
|
||||
int senderRank,
|
||||
string senderNick,
|
||||
UserPermissions senderPerms,
|
||||
object? data = null
|
||||
) {
|
||||
try {
|
||||
using MariaDBConnection conn = await storage.CreateConnection();
|
||||
await conn.RunCommand(
|
||||
"INSERT INTO sqc_events (event_id, event_created, event_type, event_channel, event_data"
|
||||
+ ", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms)"
|
||||
+ " VALUES (@id, NOW(), @type, @channel, @data"
|
||||
+ ", @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms)",
|
||||
new MySqlParameter("id", id),
|
||||
new MySqlParameter("type", type),
|
||||
new MySqlParameter("channel", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
|
||||
new MySqlParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data)),
|
||||
new MySqlParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
|
||||
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", MariaDBUserPermissionsConverter.To(senderPerms))
|
||||
);
|
||||
} catch(MySqlException ex) {
|
||||
logger.ZLogError($"Error in LogMessage(long, ...): {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteMessage(Message msg) {
|
||||
try {
|
||||
using MariaDBConnection conn = await storage.CreateConnection();
|
||||
await conn.RunCommand(
|
||||
"UPDATE IGNORE sqc_events SET event_deleted = NOW() WHERE event_id = @id AND event_deleted IS NULL",
|
||||
new MySqlParameter("id", msg.Id)
|
||||
);
|
||||
} catch(MySqlException ex) {
|
||||
logger.ZLogError($"Error in DeleteMessage(): {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Message ReadMessage(DbDataReader reader) {
|
||||
using Stream data = reader.GetStream(reader.GetOrdinal("event_data"));
|
||||
return new Message(
|
||||
reader.GetInt64(reader.GetOrdinal("event_id")),
|
||||
reader.GetString(reader.GetOrdinal("event_type")),
|
||||
reader.IsDBNull(reader.GetOrdinal("event_sender")) ? null : reader.GetString(reader.GetOrdinal("event_sender")),
|
||||
reader.IsDBNull(reader.GetOrdinal("event_sender_name")) ? string.Empty : reader.GetString(reader.GetOrdinal("event_sender_name")),
|
||||
ColourInheritable.FromMisuzu(reader.GetInt32(reader.GetOrdinal("event_sender_colour"))),
|
||||
reader.GetInt32(reader.GetOrdinal("event_sender_rank")),
|
||||
MariaDBUserPermissionsConverter.From((MariaDBUserPermissions)reader.GetInt32(reader.GetOrdinal("event_sender_perms"))),
|
||||
reader.IsDBNull(reader.GetOrdinal("event_sender_nick")) ? string.Empty : reader.GetString(reader.GetOrdinal("event_sender_nick")),
|
||||
DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32(reader.GetOrdinal("event_created"))),
|
||||
reader.IsDBNull(reader.GetOrdinal("event_deleted")) ? null : DateTimeOffset.FromUnixTimeSeconds(reader.GetInt32(reader.GetOrdinal("event_deleted"))),
|
||||
reader.IsDBNull(reader.GetOrdinal("event_channel")) ? null : reader.GetString(reader.GetOrdinal("event_channel")),
|
||||
JsonDocument.Parse(data)
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Message?> GetMessage(long id) {
|
||||
try {
|
||||
using MariaDBConnection conn = await storage.CreateConnection();
|
||||
using MySqlDataReader? reader = await conn.RunQuery(
|
||||
"SELECT event_id, event_type, event_data, event_channel"
|
||||
+ ", 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", id)
|
||||
);
|
||||
|
||||
if(reader is null)
|
||||
return null;
|
||||
|
||||
while(reader.Read()) {
|
||||
Message evt = ReadMessage(reader);
|
||||
if(evt != null)
|
||||
return evt;
|
||||
}
|
||||
} catch(MySqlException ex) {
|
||||
logger.ZLogError($"Error in GetMessage(): {ex}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<long> CountMessages(
|
||||
string? channelName = null,
|
||||
bool includeDeleted = false
|
||||
) {
|
||||
List<MySqlParameter> parameters = [];
|
||||
bool firstParam = true;
|
||||
StringBuilder qb = new();
|
||||
qb.Append("SELECT COUNT(*) FROM sqc_events");
|
||||
|
||||
if(!includeDeleted) {
|
||||
firstParam = false;
|
||||
qb.Append(" WHERE event_deleted IS NULL");
|
||||
}
|
||||
|
||||
if(!string.IsNullOrEmpty(channelName)) {
|
||||
qb.AppendFormat(" {0} (event_channel = @channel OR event_channel IS NULL)", firstParam ? "WHERE" : "AND");
|
||||
parameters.Add(new MySqlParameter("channel", channelName));
|
||||
}
|
||||
|
||||
try {
|
||||
using MariaDBConnection conn = await storage.CreateConnection();
|
||||
return await conn.RunQueryValue<long>(qb.ToString(), [.. parameters]);
|
||||
} catch(MySqlException ex) {
|
||||
logger.ZLogError($"Error in CountMessages({channelName}, {includeDeleted}): {ex}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Message>> GetMessages(
|
||||
string? channelName = null,
|
||||
int? take = 20,
|
||||
long? beforeId = null,
|
||||
bool includeDeleted = false
|
||||
) {
|
||||
List<MySqlParameter> parameters = [];
|
||||
bool firstParam = true;
|
||||
StringBuilder qb = new();
|
||||
qb.Append("SELECT event_id, event_type, event_data, event_channel");
|
||||
qb.Append(", event_sender, event_sender_name, event_sender_colour, event_sender_rank, event_sender_nick, event_sender_perms");
|
||||
qb.Append(", UNIX_TIMESTAMP(event_created) AS event_created");
|
||||
qb.Append(", UNIX_TIMESTAMP(event_deleted) AS event_deleted");
|
||||
qb.Append(" FROM sqc_events");
|
||||
|
||||
if(!includeDeleted) {
|
||||
firstParam = false;
|
||||
qb.Append(" WHERE event_deleted IS NULL");
|
||||
}
|
||||
|
||||
if(!string.IsNullOrEmpty(channelName)) {
|
||||
qb.AppendFormat(" {0} (event_channel = @channel OR event_channel IS NULL)", firstParam ? "WHERE" : "AND");
|
||||
parameters.Add(new MySqlParameter("channel", channelName));
|
||||
firstParam = false;
|
||||
}
|
||||
|
||||
if(beforeId.HasValue) {
|
||||
qb.AppendFormat(" {0} event_id < @before", firstParam ? "WHERE" : "AND");
|
||||
parameters.Add(new MySqlParameter("before", beforeId.Value));
|
||||
}
|
||||
|
||||
qb.Append(" ORDER BY event_id DESC");
|
||||
|
||||
if(take.HasValue) {
|
||||
qb.Append(" LIMIT @take");
|
||||
parameters.Add(new MySqlParameter("take", take.Value));
|
||||
}
|
||||
|
||||
string query = string.Format("SELECT * FROM ({0}) AS _ ORDER BY event_id ASC", qb);
|
||||
|
||||
try {
|
||||
MariaDBConnection conn = await storage.CreateConnection();
|
||||
DbDataReader? reader = await conn.RunQuery(query, [.. parameters]);
|
||||
|
||||
if(reader is null) {
|
||||
conn.Dispose();
|
||||
return [];
|
||||
}
|
||||
|
||||
return new DbObjectEnumerable<Message>(reader, ReadMessage, () => conn.Dispose());
|
||||
} catch(MySqlException ex) {
|
||||
logger.ZLogError($"Error in GetMessages({channelName}, {take}, {beforeId}, {includeDeleted}): {ex}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
126
SharpChat.MariaDB/MariaDBMigrations.cs
Normal file
126
SharpChat.MariaDB/MariaDBMigrations.cs
Normal file
|
@ -0,0 +1,126 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using MySqlConnector;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.MariaDB;
|
||||
|
||||
public class MariaDBMigrations(ILogger logger, MariaDBConnection conn) {
|
||||
private async Task DoMigration(string name, Func<Task> action) {
|
||||
bool done = await conn.RunQueryValue<long>(
|
||||
"SELECT COUNT(*) FROM sqc_migrations WHERE migration_name = @name",
|
||||
new MySqlParameter("name", name)
|
||||
) > 0;
|
||||
if(!done) {
|
||||
logger.ZLogInformation($@"Running migration ""{name}""...");
|
||||
await action();
|
||||
await conn.RunCommand(
|
||||
"INSERT INTO sqc_migrations (migration_name) VALUES (@name)",
|
||||
new MySqlParameter("name", name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RunMigrations() {
|
||||
await conn.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;"
|
||||
);
|
||||
|
||||
await DoMigration("create_events_table", CreateEventsTable);
|
||||
await DoMigration("allow_null_target", AllowNullTarget);
|
||||
await DoMigration("event_data_as_medium_blob", EventDataAsMediumBlob);
|
||||
await DoMigration("event_user_and_nick_name_to_1000", EventUserAndNickNameTo1000);
|
||||
await DoMigration("no_more_flags_field", NoMoreFlagsField);
|
||||
await DoMigration("update_event_type_names", UpdateEventTypeNames);
|
||||
await DoMigration("update_collations_and_use_json_type", UpdateCollationsAndUseJsonType);
|
||||
}
|
||||
|
||||
private async Task UpdateCollationsAndUseJsonType() {
|
||||
await conn.RunCommand("UPDATE sqc_events SET event_target = LOWER(CONVERT(event_target USING ascii))");
|
||||
await conn.RunCommand("UPDATE sqc_events SET event_sender_nick = NULL WHERE event_sender_nick = ''");
|
||||
await conn.RunCommand(
|
||||
"ALTER TABLE sqc_events COLLATE='utf8mb4_unicode_520_ci',"
|
||||
+ " CHANGE COLUMN event_type event_type VARCHAR(255) NOT NULL COLLATE 'ascii_general_ci' AFTER event_id,"
|
||||
+ " CHANGE COLUMN event_created event_created TIMESTAMP NOT NULL DEFAULT current_timestamp() AFTER event_type,"
|
||||
+ " CHANGE COLUMN event_deleted event_deleted TIMESTAMP NULL DEFAULT NULL AFTER event_created,"
|
||||
+ " CHANGE COLUMN event_target event_channel VARCHAR(255) NULL DEFAULT NULL COLLATE 'ascii_general_ci' AFTER event_deleted,"
|
||||
+ " CHANGE COLUMN event_sender event_sender VARCHAR(255) NULL DEFAULT NULL COLLATE 'ascii_bin' AFTER event_channel,"
|
||||
+ " CHANGE COLUMN event_sender_name event_sender_name VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER event_sender,"
|
||||
+ " CHANGE COLUMN event_sender_nick event_sender_nick VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER event_sender_rank,"
|
||||
+ " CHANGE COLUMN event_data event_data JSON NOT NULL DEFAULT '{}' AFTER event_sender_perms,"
|
||||
+ " DROP INDEX event_target, ADD INDEX event_channel (event_channel)"
|
||||
);
|
||||
}
|
||||
|
||||
private async Task UpdateEventTypeNames() {
|
||||
await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""msg:add"" WHERE event_type = ""SharpChat.Events.ChatMessage""");
|
||||
await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""user:connect"" WHERE event_type = ""SharpChat.Events.UserConnectEvent""");
|
||||
await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""user:disconnect"" WHERE event_type = ""SharpChat.Events.UserDisconnectEvent""");
|
||||
await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""chan:join"" WHERE event_type = ""SharpChat.Events.UserChannelJoinEvent""");
|
||||
await conn.RunCommand(@"UPDATE sqc_events SET event_type = ""chan:leave"" WHERE event_type = ""SharpChat.Events.UserChannelLeaveEvent""");
|
||||
}
|
||||
|
||||
private async Task NoMoreFlagsField() {
|
||||
// MessageFlags.Action is just a field in the data object
|
||||
await conn.RunCommand("UPDATE sqc_events SET event_data = JSON_MERGE_PATCH(event_data, JSON_OBJECT('act', true)) WHERE event_flags & 1");
|
||||
|
||||
// MessageFlags.Broadcast can be implied by just having a NULL as the channel name
|
||||
await conn.RunCommand("UPDATE sqc_events SET event_target = NULL WHERE event_flags & 2");
|
||||
|
||||
// MessageFlags.Log was never meaningfully used by anything and basically just meant "not-msg:add"
|
||||
// MessageFlags.Private was also never meaningfully used, can be determined by checking if the channel name starts with @
|
||||
await conn.RunCommand("ALTER TABLE sqc_events DROP COLUMN event_flags");
|
||||
}
|
||||
|
||||
private async Task EventUserAndNickNameTo1000() {
|
||||
await conn.RunCommand(
|
||||
"ALTER TABLE sqc_events"
|
||||
+ " CHANGE COLUMN event_sender_name event_sender_name VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER event_sender,"
|
||||
+ " CHANGE COLUMN event_sender_nick event_sender_nick VARCHAR(1000) NULL DEFAULT NULL COLLATE 'utf8mb4_unicode_520_ci' AFTER event_sender_rank;"
|
||||
);
|
||||
}
|
||||
|
||||
private async Task EventDataAsMediumBlob() {
|
||||
await conn.RunCommand(
|
||||
"ALTER TABLE sqc_events"
|
||||
+ " CHANGE COLUMN event_data event_data MEDIUMBLOB NULL DEFAULT NULL AFTER event_flags;"
|
||||
);
|
||||
}
|
||||
|
||||
private async Task AllowNullTarget() {
|
||||
await conn.RunCommand(
|
||||
"ALTER TABLE sqc_events"
|
||||
+ " CHANGE COLUMN event_target event_target VARBINARY(255) NULL AFTER event_type;"
|
||||
);
|
||||
}
|
||||
|
||||
private async Task CreateEventsTable() {
|
||||
await conn.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;"
|
||||
);
|
||||
}
|
||||
}
|
51
SharpChat.MariaDB/MariaDBStorage.cs
Normal file
51
SharpChat.MariaDB/MariaDBStorage.cs
Normal file
|
@ -0,0 +1,51 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using MySqlConnector;
|
||||
using SharpChat.Configuration;
|
||||
using SharpChat.Messages;
|
||||
using SharpChat.Storage;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.MariaDB;
|
||||
|
||||
public class MariaDBStorage(ILogger logger, string connString) : StorageBackend {
|
||||
public async Task<MariaDBConnection> CreateConnection() {
|
||||
MySqlConnection conn = new(connString);
|
||||
await conn.OpenAsync();
|
||||
return new MariaDBConnection(conn);
|
||||
}
|
||||
|
||||
public MessageStorage CreateMessageStorage() {
|
||||
return new MariaDBMessageStorage(this, logger);
|
||||
}
|
||||
|
||||
public async Task UpgradeStorage() {
|
||||
logger.ZLogInformation($"Upgrading storage...");
|
||||
using MariaDBConnection conn = await CreateConnection();
|
||||
await new MariaDBMigrations(logger, conn).RunMigrations();
|
||||
}
|
||||
|
||||
public static string BuildConnectionString(Config config) {
|
||||
return BuildConnectionString(
|
||||
config.ReadValue("host", "localhost"),
|
||||
config.ReadValue("user", string.Empty),
|
||||
config.ReadValue("pass", string.Empty),
|
||||
config.ReadValue("db", "sharpchat")
|
||||
);
|
||||
}
|
||||
|
||||
public static string BuildConnectionString(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 = false,
|
||||
ConnectionTimeout = 5,
|
||||
DefaultCommandTimeout = 900, // fuck it, 15 minutes
|
||||
}.ToString();
|
||||
}
|
||||
}
|
27
SharpChat.MariaDB/MariaDBUserPermissions.cs
Normal file
27
SharpChat.MariaDB/MariaDBUserPermissions.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
namespace SharpChat.MariaDB;
|
||||
|
||||
[Flags]
|
||||
public enum MariaDBUserPermissions : int {
|
||||
KickUser = 0x1,
|
||||
BanUser = 0x2,
|
||||
//SilenceUser = 0x4,
|
||||
Broadcast = 0x8,
|
||||
SetOwnNickname = 0x10,
|
||||
SetOthersNickname = 0x20,
|
||||
CreateChannel = 0x40,
|
||||
SetChannelPermanent = 0x80,
|
||||
SetChannelPassword = 0x100,
|
||||
SetChannelHierarchy = 0x200,
|
||||
SendMessage = 0x400,
|
||||
DeleteOwnMessage = 0x800,
|
||||
DeleteAnyMessage = 0x1000,
|
||||
EditOwnMessage = 0x2000,
|
||||
EditAnyMessage = 0x4000,
|
||||
SeeIPAddress = 0x8000,
|
||||
DeleteChannel = 0x10000,
|
||||
JoinAnyChannel = 0x20000,
|
||||
ViewLogs = 0x40000,
|
||||
ViewBanList = 0x80000,
|
||||
PardonUser = 0x100000,
|
||||
PardonIPAddress = 0x200000,
|
||||
}
|
103
SharpChat.MariaDB/MariaDBUserPermissionsConverter.cs
Normal file
103
SharpChat.MariaDB/MariaDBUserPermissionsConverter.cs
Normal file
|
@ -0,0 +1,103 @@
|
|||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.MariaDB;
|
||||
|
||||
public static class MariaDBUserPermissionsConverter {
|
||||
public static UserPermissions From(MariaDBUserPermissions mup) {
|
||||
UserPermissions perms = 0;
|
||||
|
||||
if(mup.HasFlag(MariaDBUserPermissions.KickUser))
|
||||
perms |= UserPermissions.KickUser;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.BanUser))
|
||||
perms |= UserPermissions.BanUser;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.Broadcast))
|
||||
perms |= UserPermissions.SendBroadcast;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.SetOwnNickname))
|
||||
perms |= UserPermissions.SetOwnNickname;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.SetOthersNickname))
|
||||
perms |= UserPermissions.SetOthersNickname;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.CreateChannel))
|
||||
perms |= UserPermissions.CreateChannel;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.DeleteChannel))
|
||||
perms |= UserPermissions.DeleteChannel;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.SetChannelPermanent))
|
||||
perms |= UserPermissions.SetChannelPermanent;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.SetChannelPassword))
|
||||
perms |= UserPermissions.SetChannelPassword;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.SetChannelHierarchy))
|
||||
perms |= UserPermissions.SetChannelMinimumRank;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.JoinAnyChannel))
|
||||
perms |= UserPermissions.JoinAnyChannel;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.SendMessage))
|
||||
perms |= UserPermissions.SendMessage;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.DeleteOwnMessage))
|
||||
perms |= UserPermissions.DeleteOwnMessage;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.DeleteAnyMessage))
|
||||
perms |= UserPermissions.DeleteAnyMessage;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.EditOwnMessage))
|
||||
perms |= UserPermissions.EditOwnMessage;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.EditAnyMessage))
|
||||
perms |= UserPermissions.EditAnyMessage;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.SeeIPAddress))
|
||||
perms |= UserPermissions.ViewIPAddress;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.ViewLogs))
|
||||
perms |= UserPermissions.ViewLogs;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.ViewBanList))
|
||||
perms |= UserPermissions.ViewBanList;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.PardonUser))
|
||||
perms |= UserPermissions.PardonUser;
|
||||
if(mup.HasFlag(MariaDBUserPermissions.PardonIPAddress))
|
||||
perms |= UserPermissions.PardonIPAddress;
|
||||
|
||||
return perms;
|
||||
}
|
||||
|
||||
public static MariaDBUserPermissions To(UserPermissions up) {
|
||||
MariaDBUserPermissions perms = 0;
|
||||
|
||||
if(up.HasFlag(UserPermissions.KickUser))
|
||||
perms |= MariaDBUserPermissions.KickUser;
|
||||
if(up.HasFlag(UserPermissions.BanUser))
|
||||
perms |= MariaDBUserPermissions.BanUser;
|
||||
if(up.HasFlag(UserPermissions.SendBroadcast))
|
||||
perms |= MariaDBUserPermissions.Broadcast;
|
||||
if(up.HasFlag(UserPermissions.SetOwnNickname))
|
||||
perms |= MariaDBUserPermissions.SetOwnNickname;
|
||||
if(up.HasFlag(UserPermissions.SetOthersNickname))
|
||||
perms |= MariaDBUserPermissions.SetOthersNickname;
|
||||
if(up.HasFlag(UserPermissions.CreateChannel))
|
||||
perms |= MariaDBUserPermissions.CreateChannel;
|
||||
if(up.HasFlag(UserPermissions.DeleteChannel))
|
||||
perms |= MariaDBUserPermissions.DeleteChannel;
|
||||
if(up.HasFlag(UserPermissions.SetChannelPermanent))
|
||||
perms |= MariaDBUserPermissions.SetChannelPermanent;
|
||||
if(up.HasFlag(UserPermissions.SetChannelPassword))
|
||||
perms |= MariaDBUserPermissions.SetChannelPassword;
|
||||
if(up.HasFlag(UserPermissions.SetChannelMinimumRank))
|
||||
perms |= MariaDBUserPermissions.SetChannelHierarchy;
|
||||
if(up.HasFlag(UserPermissions.JoinAnyChannel))
|
||||
perms |= MariaDBUserPermissions.JoinAnyChannel;
|
||||
if(up.HasFlag(UserPermissions.SendMessage))
|
||||
perms |= MariaDBUserPermissions.SendMessage;
|
||||
if(up.HasFlag(UserPermissions.DeleteOwnMessage))
|
||||
perms |= MariaDBUserPermissions.DeleteOwnMessage;
|
||||
if(up.HasFlag(UserPermissions.DeleteAnyMessage))
|
||||
perms |= MariaDBUserPermissions.DeleteAnyMessage;
|
||||
if(up.HasFlag(UserPermissions.EditOwnMessage))
|
||||
perms |= MariaDBUserPermissions.EditOwnMessage;
|
||||
if(up.HasFlag(UserPermissions.EditAnyMessage))
|
||||
perms |= MariaDBUserPermissions.EditAnyMessage;
|
||||
if(up.HasFlag(UserPermissions.ViewIPAddress))
|
||||
perms |= MariaDBUserPermissions.SeeIPAddress;
|
||||
if(up.HasFlag(UserPermissions.ViewLogs))
|
||||
perms |= MariaDBUserPermissions.ViewLogs;
|
||||
if(up.HasFlag(UserPermissions.ViewBanList))
|
||||
perms |= MariaDBUserPermissions.ViewBanList;
|
||||
if(up.HasFlag(UserPermissions.PardonUser))
|
||||
perms |= MariaDBUserPermissions.PardonUser;
|
||||
if(up.HasFlag(UserPermissions.PardonIPAddress))
|
||||
perms |= MariaDBUserPermissions.PardonIPAddress;
|
||||
|
||||
return perms;
|
||||
}
|
||||
}
|
18
SharpChat.MariaDB/SharpChat.MariaDB.csproj
Normal file
18
SharpChat.MariaDB/SharpChat.MariaDB.csproj
Normal file
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MySqlConnector" Version="2.4.0" />
|
||||
<PackageReference Include="ZLogger" Version="2.5.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
60
SharpChat.SQLite/SQLiteConnection.cs
Normal file
60
SharpChat.SQLite/SQLiteConnection.cs
Normal file
|
@ -0,0 +1,60 @@
|
|||
using System.Data.Common;
|
||||
using System.Data.SQLite;
|
||||
using NativeSQLiteConnection = System.Data.SQLite.SQLiteConnection;
|
||||
|
||||
namespace SharpChat.SQLite;
|
||||
|
||||
public class SQLiteConnection(NativeSQLiteConnection conn) : IDisposable {
|
||||
public NativeSQLiteConnection Connection { get; } = conn;
|
||||
|
||||
public async Task<int> RunCommand(string command, params SQLiteParameter[] parameters) {
|
||||
using SQLiteCommand cmd = Connection.CreateCommand();
|
||||
cmd.Parameters.Clear();
|
||||
if(parameters?.Length > 0)
|
||||
cmd.Parameters.AddRange(parameters);
|
||||
cmd.CommandText = command;
|
||||
return await cmd.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
public async Task<DbDataReader?> RunQuery(string command, params SQLiteParameter[] parameters) {
|
||||
using SQLiteCommand cmd = Connection.CreateCommand();
|
||||
cmd.Parameters.Clear();
|
||||
if(parameters?.Length > 0)
|
||||
cmd.Parameters.AddRange(parameters);
|
||||
cmd.CommandText = command;
|
||||
return await cmd.ExecuteReaderAsync();
|
||||
}
|
||||
|
||||
public async Task<T> RunQueryValue<T>(string command, params SQLiteParameter[] parameters)
|
||||
where T : struct {
|
||||
using SQLiteCommand cmd = Connection.CreateCommand();
|
||||
cmd.Parameters.Clear();
|
||||
if(parameters?.Length > 0)
|
||||
cmd.Parameters.AddRange(parameters);
|
||||
cmd.CommandText = command;
|
||||
await cmd.PrepareAsync();
|
||||
|
||||
object? raw = await cmd.ExecuteScalarAsync();
|
||||
return raw is T value ? value : default;
|
||||
}
|
||||
|
||||
private bool disposed = false;
|
||||
|
||||
~SQLiteConnection() {
|
||||
DoDispose();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
DoDispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void DoDispose() {
|
||||
if(disposed)
|
||||
return;
|
||||
disposed = true;
|
||||
|
||||
RunCommand("VACUUM").Wait();
|
||||
Connection.Dispose();
|
||||
}
|
||||
}
|
187
SharpChat.SQLite/SQLiteMessageStorage.cs
Normal file
187
SharpChat.SQLite/SQLiteMessageStorage.cs
Normal file
|
@ -0,0 +1,187 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using SharpChat.Data;
|
||||
using SharpChat.Messages;
|
||||
using SharpChat.Users;
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Data.SQLite;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.SQLite;
|
||||
|
||||
public class SQLiteMessageStorage(ILogger logger, SQLiteConnection conn) : MessageStorage {
|
||||
public async Task LogMessage(Message msg) {
|
||||
try {
|
||||
await conn.RunCommand(
|
||||
"INSERT OR IGNORE INTO messages (msg_id, msg_type, msg_created, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data)"
|
||||
+ " VALUES (@id, @type, @created, @channel, @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms, @data)",
|
||||
new SQLiteParameter("id", msg.Id),
|
||||
new SQLiteParameter("type", msg.Type),
|
||||
new SQLiteParameter("channel", string.IsNullOrWhiteSpace(msg.ChannelName) ? null : msg.ChannelName),
|
||||
new SQLiteParameter("data", JsonSerializer.SerializeToUtf8Bytes(msg.Data)),
|
||||
new SQLiteParameter("sender", long.TryParse(msg.SenderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
|
||||
new SQLiteParameter("sender_name", msg.SenderName),
|
||||
new SQLiteParameter("sender_colour", msg.SenderColour.Rgb.HasValue ? msg.SenderColour.Rgb.Value.Raw : null),
|
||||
new SQLiteParameter("sender_rank", msg.SenderRank),
|
||||
new SQLiteParameter("sender_nick", string.IsNullOrWhiteSpace(msg.SenderNickName) ? null : msg.SenderNickName),
|
||||
new SQLiteParameter("sender_perms", SQLiteUserPermissionsConverter.To(msg.SenderPermissions)),
|
||||
new SQLiteParameter("created", $"{msg.Created:s}Z"),
|
||||
new SQLiteParameter("deleted", msg.Deleted is null ? null : $"{msg.Deleted:s}Z")
|
||||
);
|
||||
} catch(SQLiteException ex) {
|
||||
logger.ZLogError($"Error in LogMessage(Message): {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogMessage(
|
||||
long id,
|
||||
string type,
|
||||
string channelName,
|
||||
string senderId,
|
||||
string senderName,
|
||||
ColourInheritable senderColour,
|
||||
int senderRank,
|
||||
string senderNick,
|
||||
UserPermissions senderPerms,
|
||||
object? data = null
|
||||
) {
|
||||
try {
|
||||
await conn.RunCommand(
|
||||
"INSERT INTO messages (msg_id, msg_type, msg_created, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data)"
|
||||
+ " VALUES (@id, @type, @created, @channel, @sender, @sender_name, @sender_colour, @sender_rank, @sender_nick, @sender_perms, @data)",
|
||||
new SQLiteParameter("id", id),
|
||||
new SQLiteParameter("type", type),
|
||||
new SQLiteParameter("created", $"{DateTimeOffset.UtcNow:s}Z"),
|
||||
new SQLiteParameter("channel", string.IsNullOrWhiteSpace(channelName) ? null : channelName),
|
||||
new SQLiteParameter("sender", long.TryParse(senderId, out long senderId64) && senderId64 > 0 ? senderId64 : null),
|
||||
new SQLiteParameter("sender_name", senderName),
|
||||
new SQLiteParameter("sender_colour", senderColour.Rgb.HasValue ? senderColour.Rgb.Value.Raw : null),
|
||||
new SQLiteParameter("sender_rank", senderRank),
|
||||
new SQLiteParameter("sender_nick", string.IsNullOrWhiteSpace(senderNick) ? null : senderNick),
|
||||
new SQLiteParameter("sender_perms", SQLiteUserPermissionsConverter.To(senderPerms)),
|
||||
new SQLiteParameter("data", data == null ? "{}" : JsonSerializer.SerializeToUtf8Bytes(data))
|
||||
);
|
||||
} catch(SQLiteException ex) {
|
||||
logger.ZLogError($"Error in LogMessage(long, ...): {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteMessage(Message msg) {
|
||||
try {
|
||||
await conn.RunCommand(
|
||||
"UPDATE IGNORE messages SET msg_deleted = NOW() WHERE msg_id = @id AND msg_deleted IS NULL",
|
||||
new SQLiteParameter("id", msg.Id)
|
||||
);
|
||||
} catch(SQLiteException ex) {
|
||||
logger.ZLogError($"Error in DeleteMessage(): {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static Message ReadMessage(DbDataReader reader) {
|
||||
return new Message(
|
||||
reader.GetInt64("msg_id"),
|
||||
reader.GetString("msg_type"),
|
||||
reader.IsDBNull(reader.GetOrdinal("msg_sender")) ? null : reader.GetString("msg_sender"),
|
||||
reader.IsDBNull(reader.GetOrdinal("msg_sender_name")) ? string.Empty : reader.GetString("msg_sender_name"),
|
||||
reader.IsDBNull(reader.GetOrdinal("msg_sender_colour")) ? ColourInheritable.None : ColourInheritable.FromRaw((int)reader.GetInt64("msg_sender_colour")),
|
||||
(int)reader.GetInt64("msg_sender_rank"),
|
||||
SQLiteUserPermissionsConverter.From((SQLiteUserPermissions)reader.GetInt64("msg_sender_perms")),
|
||||
reader.IsDBNull(reader.GetOrdinal("msg_sender_nick")) ? string.Empty : reader.GetString("msg_sender_nick"),
|
||||
DateTimeOffset.Parse(reader.GetString("msg_created")),
|
||||
reader.IsDBNull(reader.GetOrdinal("msg_deleted")) ? null : DateTimeOffset.Parse(reader.GetString("msg_deleted")),
|
||||
reader.IsDBNull(reader.GetOrdinal("msg_channel")) ? null : reader.GetString("msg_channel"),
|
||||
JsonDocument.Parse(reader.GetString("msg_data"))
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<Message?> GetMessage(long id) {
|
||||
try {
|
||||
using DbDataReader? reader = await conn.RunQuery(
|
||||
"SELECT msg_id, msg_type, msg_created, msg_deleted, msg_channel, msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms, msg_data"
|
||||
+ " FROM messages WHERE msg_id = @id",
|
||||
new SQLiteParameter("id", id)
|
||||
);
|
||||
|
||||
return reader?.Read() == true ? ReadMessage(reader) : null;
|
||||
} catch(SQLiteException ex) {
|
||||
logger.ZLogError($"Error in GetMessage(): {ex}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<long> CountMessages(
|
||||
string? channelName = null,
|
||||
bool includeDeleted = false
|
||||
) {
|
||||
List<SQLiteParameter> parameters = [];
|
||||
bool firstParam = true;
|
||||
StringBuilder qb = new();
|
||||
qb.Append("SELECT COUNT(*) FROM messages");
|
||||
|
||||
if(!includeDeleted) {
|
||||
firstParam = false;
|
||||
qb.Append(" WHERE msg_deleted IS NULL");
|
||||
}
|
||||
|
||||
if(!string.IsNullOrEmpty(channelName)) {
|
||||
qb.AppendFormat(" {0} (msg_channel = @channel OR msg_channel IS NULL)", firstParam ? "WHERE" : "AND");
|
||||
parameters.Add(new SQLiteParameter("channel", channelName));
|
||||
}
|
||||
|
||||
try {
|
||||
return await conn.RunQueryValue<long>(qb.ToString(), [.. parameters]);
|
||||
} catch(SQLiteException ex) {
|
||||
logger.ZLogError($"Error in CountMessages({channelName}, {includeDeleted}): {ex}");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Message>> GetMessages(
|
||||
string? channelName = null,
|
||||
int? take = 20,
|
||||
long? beforeId = null,
|
||||
bool includeDeleted = false
|
||||
) {
|
||||
List<SQLiteParameter> parameters = [];
|
||||
bool firstParam = true;
|
||||
StringBuilder qb = new();
|
||||
qb.Append("SELECT msg_id, msg_type, msg_created, msg_deleted, msg_channel, msg_data");
|
||||
qb.Append(", msg_sender, msg_sender_name, msg_sender_colour, msg_sender_rank, msg_sender_nick, msg_sender_perms");
|
||||
qb.Append(" FROM messages");
|
||||
|
||||
if(!includeDeleted) {
|
||||
firstParam = false;
|
||||
qb.Append(" WHERE msg_deleted IS NULL");
|
||||
}
|
||||
|
||||
if(!string.IsNullOrEmpty(channelName)) {
|
||||
qb.AppendFormat(" {0} (msg_channel = @channel OR msg_channel IS NULL)", firstParam ? "WHERE" : "AND");
|
||||
parameters.Add(new SQLiteParameter("channel", channelName));
|
||||
firstParam = false;
|
||||
}
|
||||
|
||||
if(beforeId.HasValue) {
|
||||
qb.AppendFormat(" {0} msg_id < @before", firstParam ? "WHERE" : "AND");
|
||||
parameters.Add(new SQLiteParameter("before", beforeId.Value));
|
||||
}
|
||||
|
||||
qb.Append(" ORDER BY msg_id DESC");
|
||||
|
||||
if(take.HasValue) {
|
||||
qb.Append(" LIMIT @take");
|
||||
parameters.Add(new SQLiteParameter("take", take.Value));
|
||||
}
|
||||
|
||||
string query = string.Format("SELECT * FROM ({0}) AS _ ORDER BY msg_id ASC", qb);
|
||||
|
||||
try {
|
||||
DbDataReader? reader = await conn.RunQuery(query, [.. parameters]);
|
||||
return reader is null ? [] : new DbObjectEnumerable<Message>(reader, ReadMessage);
|
||||
} catch(SQLiteException ex) {
|
||||
logger.ZLogError($"Error in GetMessages({channelName}, {take}, {beforeId}, {includeDeleted}): {ex}");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
49
SharpChat.SQLite/SQLiteMigrations.cs
Normal file
49
SharpChat.SQLite/SQLiteMigrations.cs
Normal file
|
@ -0,0 +1,49 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.SQLite;
|
||||
|
||||
public class SQLiteMigrations(ILogger logger, SQLiteConnection conn) {
|
||||
public async Task RunMigrations() {
|
||||
long currentVersion = await conn.RunQueryValue<long>("PRAGMA user_version");
|
||||
long version = currentVersion;
|
||||
|
||||
async Task doMigration(int expect, Func<Task> action) {
|
||||
if(version < expect) {
|
||||
logger.ZLogInformation($"Upgrading to version {version + 1}...");
|
||||
await action();
|
||||
++version;
|
||||
}
|
||||
};
|
||||
|
||||
await doMigration(1, CreateMessagesTable);
|
||||
|
||||
if(currentVersion != version)
|
||||
await conn.RunCommand($"PRAGMA user_version = {version}");
|
||||
}
|
||||
|
||||
private async Task CreateMessagesTable() {
|
||||
await conn.RunCommand(
|
||||
@"CREATE TABLE ""messages"" ("
|
||||
+ @"""msg_id"" INTEGER NOT NULL,"
|
||||
+ @"""msg_type"" TEXT NOT NULL COLLATE NOCASE,"
|
||||
+ @"""msg_created"" TEXT NOT NULL COLLATE NOCASE,"
|
||||
+ @"""msg_deleted"" TEXT DEFAULT NULL COLLATE NOCASE,"
|
||||
+ @"""msg_channel"" TEXT DEFAULT NULL COLLATE NOCASE,"
|
||||
+ @"""msg_sender"" TEXT DEFAULT NULL COLLATE BINARY,"
|
||||
+ @"""msg_sender_name"" TEXT DEFAULT NULL COLLATE NOCASE,"
|
||||
+ @"""msg_sender_colour"" INTEGER DEFAULT NULL,"
|
||||
+ @"""msg_sender_rank"" INTEGER DEFAULT NULL,"
|
||||
+ @"""msg_sender_nick"" TEXT DEFAULT NULL COLLATE NOCASE,"
|
||||
+ @"""msg_sender_perms"" INTEGER DEFAULT NULL,"
|
||||
+ @"""msg_data"" BLOB DEFAULT NULL CHECK(JSON_VALID(""msg_data"") AND JSON_TYPE(""msg_data"") = ""object"") COLLATE BINARY,"
|
||||
+ @"PRIMARY KEY(""msg_id"")"
|
||||
+ @");"
|
||||
);
|
||||
await conn.RunCommand(@"CREATE INDEX ""messages_channel_index"" ON ""messages"" (""msg_channel"");");
|
||||
await conn.RunCommand(@"CREATE INDEX ""messages_created_index"" ON ""messages"" (""msg_created"");");
|
||||
await conn.RunCommand(@"CREATE INDEX ""messages_deleted_index"" ON ""messages"" (""msg_deleted"");");
|
||||
await conn.RunCommand(@"CREATE INDEX ""messages_event_type"" ON ""messages"" (""msg_type"");");
|
||||
await conn.RunCommand(@"CREATE INDEX ""messages_sender_index"" ON ""messages"" (""msg_sender"");");
|
||||
}
|
||||
}
|
66
SharpChat.SQLite/SQLiteStorage.cs
Normal file
66
SharpChat.SQLite/SQLiteStorage.cs
Normal file
|
@ -0,0 +1,66 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using SharpChat.Configuration;
|
||||
using SharpChat.Messages;
|
||||
using SharpChat.Storage;
|
||||
using System.Data.SQLite;
|
||||
using ZLogger;
|
||||
using NativeSQLiteConnection = System.Data.SQLite.SQLiteConnection;
|
||||
|
||||
namespace SharpChat.SQLite;
|
||||
|
||||
public class SQLiteStorage(ILogger logger, string connString) : StorageBackend, IDisposable {
|
||||
public const string MEMORY = "file::memory:?cache=shared";
|
||||
public const string DEFAULT = "sharpchat.db";
|
||||
|
||||
public SQLiteConnection Connection { get; } = new SQLiteConnection(new NativeSQLiteConnection(connString).OpenAndReturn());
|
||||
|
||||
public MessageStorage CreateMessageStorage() {
|
||||
return new SQLiteMessageStorage(logger, Connection);
|
||||
}
|
||||
|
||||
public async Task UpgradeStorage() {
|
||||
logger.ZLogInformation($"Upgrading storage...");
|
||||
await new SQLiteMigrations(logger, Connection).RunMigrations();
|
||||
}
|
||||
|
||||
public static string BuildConnectionString(Config config, bool journalling = true) {
|
||||
return BuildConnectionString(
|
||||
config.ReadValue("path", DEFAULT)!,
|
||||
config.ReadValue("pass"),
|
||||
config.ReadValue("journal", journalling)
|
||||
);
|
||||
}
|
||||
|
||||
public static string BuildConnectionString(string path, string? password, bool journalling = true) {
|
||||
return new SQLiteConnectionStringBuilder {
|
||||
DataSource = string.IsNullOrWhiteSpace(path) ? MEMORY : path,
|
||||
DateTimeFormat = SQLiteDateFormats.ISO8601,
|
||||
DateTimeKind = DateTimeKind.Utc,
|
||||
FailIfMissing = false,
|
||||
ForeignKeys = true,
|
||||
JournalMode = journalling ? SQLiteJournalModeEnum.Wal : SQLiteJournalModeEnum.Off,
|
||||
LegacyFormat = false,
|
||||
Password = string.IsNullOrWhiteSpace(password) ? null : password,
|
||||
ReadOnly = false,
|
||||
UseUTF16Encoding = false,
|
||||
}.ToString();
|
||||
}
|
||||
|
||||
private bool disposed = false;
|
||||
|
||||
~SQLiteStorage() {
|
||||
DoDispose();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
DoDispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void DoDispose() {
|
||||
if(disposed)
|
||||
return;
|
||||
disposed = true;
|
||||
Connection.Dispose();
|
||||
}
|
||||
}
|
26
SharpChat.SQLite/SQLiteUserPermissions.cs
Normal file
26
SharpChat.SQLite/SQLiteUserPermissions.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
namespace SharpChat.SQLite;
|
||||
|
||||
[Flags]
|
||||
public enum SQLiteUserPermissions : long {
|
||||
SendMessage = 0x1L,
|
||||
DeleteOwnMessage = 0x2L,
|
||||
DeleteAnyMessage = 0x4L,
|
||||
EditOwnMessage = 0x8L,
|
||||
EditAnyMessage = 0x10L,
|
||||
SendBroadcast = 0x20L,
|
||||
ViewLogs = 0x40L,
|
||||
KickUser = 0x80L,
|
||||
BanUser = 0x100L,
|
||||
PardonUser = 0x200L,
|
||||
PardonIPAddress = 0x400L,
|
||||
ViewIPAddress = 0x800L,
|
||||
ViewBanList = 0x1000L,
|
||||
CreateChannel = 0x2000L,
|
||||
SetChannelPermanent = 0x4000L,
|
||||
SetChannelPassword = 0x8000L,
|
||||
SetChannelMinimumRank = 0x10000L,
|
||||
DeleteChannel = 0x20000L,
|
||||
JoinAnyChannel = 0x40000L,
|
||||
SetOwnNickname = 0x80000L,
|
||||
SetOthersNickname = 0x100000L,
|
||||
}
|
103
SharpChat.SQLite/SQLiteUserPermissionsConverter.cs
Normal file
103
SharpChat.SQLite/SQLiteUserPermissionsConverter.cs
Normal file
|
@ -0,0 +1,103 @@
|
|||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.SQLite;
|
||||
|
||||
public static class SQLiteUserPermissionsConverter {
|
||||
public static UserPermissions From(SQLiteUserPermissions sup) {
|
||||
UserPermissions up = 0;
|
||||
|
||||
if(sup.HasFlag(SQLiteUserPermissions.SendMessage))
|
||||
up |= UserPermissions.SendMessage;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.DeleteOwnMessage))
|
||||
up |= UserPermissions.DeleteOwnMessage;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.DeleteAnyMessage))
|
||||
up |= UserPermissions.DeleteAnyMessage;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.EditOwnMessage))
|
||||
up |= UserPermissions.EditOwnMessage;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.EditAnyMessage))
|
||||
up |= UserPermissions.EditAnyMessage;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.SendBroadcast))
|
||||
up |= UserPermissions.SendBroadcast;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.ViewLogs))
|
||||
up |= UserPermissions.ViewLogs;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.KickUser))
|
||||
up |= UserPermissions.KickUser;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.BanUser))
|
||||
up |= UserPermissions.BanUser;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.PardonUser))
|
||||
up |= UserPermissions.PardonUser;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.PardonIPAddress))
|
||||
up |= UserPermissions.PardonIPAddress;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.ViewIPAddress))
|
||||
up |= UserPermissions.ViewIPAddress;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.ViewBanList))
|
||||
up |= UserPermissions.ViewBanList;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.CreateChannel))
|
||||
up |= UserPermissions.CreateChannel;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.SetChannelPermanent))
|
||||
up |= UserPermissions.SetChannelPermanent;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.SetChannelPassword))
|
||||
up |= UserPermissions.SetChannelPassword;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.SetChannelMinimumRank))
|
||||
up |= UserPermissions.SetChannelMinimumRank;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.DeleteChannel))
|
||||
up |= UserPermissions.DeleteChannel;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.JoinAnyChannel))
|
||||
up |= UserPermissions.JoinAnyChannel;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.SetOwnNickname))
|
||||
up |= UserPermissions.SetOwnNickname;
|
||||
if(sup.HasFlag(SQLiteUserPermissions.SetOthersNickname))
|
||||
up |= UserPermissions.SetOthersNickname;
|
||||
|
||||
return up;
|
||||
}
|
||||
|
||||
public static SQLiteUserPermissions To(UserPermissions up) {
|
||||
SQLiteUserPermissions sup = 0;
|
||||
|
||||
if(up.HasFlag(UserPermissions.SendMessage))
|
||||
sup |= SQLiteUserPermissions.SendMessage;
|
||||
if(up.HasFlag(UserPermissions.DeleteOwnMessage))
|
||||
sup |= SQLiteUserPermissions.DeleteOwnMessage;
|
||||
if(up.HasFlag(UserPermissions.DeleteAnyMessage))
|
||||
sup |= SQLiteUserPermissions.DeleteAnyMessage;
|
||||
if(up.HasFlag(UserPermissions.EditOwnMessage))
|
||||
sup |= SQLiteUserPermissions.EditOwnMessage;
|
||||
if(up.HasFlag(UserPermissions.EditAnyMessage))
|
||||
sup |= SQLiteUserPermissions.EditAnyMessage;
|
||||
if(up.HasFlag(UserPermissions.SendBroadcast))
|
||||
sup |= SQLiteUserPermissions.SendBroadcast;
|
||||
if(up.HasFlag(UserPermissions.ViewLogs))
|
||||
sup |= SQLiteUserPermissions.ViewLogs;
|
||||
if(up.HasFlag(UserPermissions.KickUser))
|
||||
sup |= SQLiteUserPermissions.KickUser;
|
||||
if(up.HasFlag(UserPermissions.BanUser))
|
||||
sup |= SQLiteUserPermissions.BanUser;
|
||||
if(up.HasFlag(UserPermissions.PardonUser))
|
||||
sup |= SQLiteUserPermissions.PardonUser;
|
||||
if(up.HasFlag(UserPermissions.PardonIPAddress))
|
||||
sup |= SQLiteUserPermissions.PardonIPAddress;
|
||||
if(up.HasFlag(UserPermissions.ViewIPAddress))
|
||||
sup |= SQLiteUserPermissions.ViewIPAddress;
|
||||
if(up.HasFlag(UserPermissions.ViewBanList))
|
||||
sup |= SQLiteUserPermissions.ViewBanList;
|
||||
if(up.HasFlag(UserPermissions.CreateChannel))
|
||||
sup |= SQLiteUserPermissions.CreateChannel;
|
||||
if(up.HasFlag(UserPermissions.SetChannelPermanent))
|
||||
sup |= SQLiteUserPermissions.SetChannelPermanent;
|
||||
if(up.HasFlag(UserPermissions.SetChannelPassword))
|
||||
sup |= SQLiteUserPermissions.SetChannelPassword;
|
||||
if(up.HasFlag(UserPermissions.SetChannelMinimumRank))
|
||||
sup |= SQLiteUserPermissions.SetChannelMinimumRank;
|
||||
if(up.HasFlag(UserPermissions.DeleteChannel))
|
||||
sup |= SQLiteUserPermissions.DeleteChannel;
|
||||
if(up.HasFlag(UserPermissions.JoinAnyChannel))
|
||||
sup |= SQLiteUserPermissions.JoinAnyChannel;
|
||||
if(up.HasFlag(UserPermissions.SetOwnNickname))
|
||||
sup |= SQLiteUserPermissions.SetOwnNickname;
|
||||
if(up.HasFlag(UserPermissions.SetOthersNickname))
|
||||
sup |= SQLiteUserPermissions.SetOthersNickname;
|
||||
|
||||
return sup;
|
||||
}
|
||||
}
|
18
SharpChat.SQLite/SharpChat.SQLite.csproj
Normal file
18
SharpChat.SQLite/SharpChat.SQLite.csproj
Normal file
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.119" />
|
||||
<PackageReference Include="ZLogger" Version="2.5.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
9
SharpChat.SockChat/IWebSocketConnectionExtensions.cs
Normal file
9
SharpChat.SockChat/IWebSocketConnectionExtensions.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
using Fleck;
|
||||
|
||||
namespace SharpChat.SockChat;
|
||||
|
||||
public static class IWebSocketConnectionExtensions {
|
||||
public static void Close(this IWebSocketConnection conn, WebSocketCloseCode closeCode) {
|
||||
conn.Close((int)closeCode);
|
||||
}
|
||||
}
|
5
SharpChat.SockChat/S2CPacket.cs
Normal file
5
SharpChat.SockChat/S2CPacket.cs
Normal file
|
@ -0,0 +1,5 @@
|
|||
namespace SharpChat.SockChat;
|
||||
|
||||
public interface S2CPacket {
|
||||
string Pack();
|
||||
}
|
43
SharpChat.SockChat/S2CPackets/AuthFailS2CPacket.cs
Normal file
43
SharpChat.SockChat/S2CPackets/AuthFailS2CPacket.cs
Normal file
|
@ -0,0 +1,43 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class AuthFailS2CPacket(
|
||||
AuthFailS2CPacket.Reason reason,
|
||||
DateTimeOffset? expiresAt = null
|
||||
) : S2CPacket {
|
||||
public enum Reason {
|
||||
AuthInvalid,
|
||||
MaxSessions,
|
||||
Banned,
|
||||
Exception,
|
||||
}
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("1\tn\t");
|
||||
|
||||
switch(reason) {
|
||||
case Reason.AuthInvalid:
|
||||
default:
|
||||
sb.Append("authfail");
|
||||
break;
|
||||
case Reason.Exception:
|
||||
sb.Append("userfail");
|
||||
break;
|
||||
case Reason.MaxSessions:
|
||||
sb.Append("sockfail");
|
||||
break;
|
||||
case Reason.Banned:
|
||||
sb.Append("joinfail\t");
|
||||
if(expiresAt is null || expiresAt == DateTimeOffset.MaxValue)
|
||||
sb.Append("-1");
|
||||
else
|
||||
sb.Append(expiresAt.Value.ToUnixTimeSeconds());
|
||||
break;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
41
SharpChat.SockChat/S2CPackets/AuthSuccessS2CPacket.cs
Normal file
41
SharpChat.SockChat/S2CPackets/AuthSuccessS2CPacket.cs
Normal file
|
@ -0,0 +1,41 @@
|
|||
using SharpChat.Users;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class AuthSuccessS2CPacket(
|
||||
string userId,
|
||||
string userName,
|
||||
ColourInheritable userColour,
|
||||
int userRank,
|
||||
UserPermissions userPerms,
|
||||
string channelName,
|
||||
int maxMsgLength
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("1\ty\t");
|
||||
sb.Append(userId);
|
||||
sb.Append('\t');
|
||||
sb.Append(userName);
|
||||
sb.Append('\t');
|
||||
sb.Append(userColour);
|
||||
sb.Append('\t');
|
||||
sb.Append(userRank);
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
|
||||
sb.Append('\t');
|
||||
sb.Append(channelName);
|
||||
sb.Append('\t');
|
||||
sb.Append(maxMsgLength);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
29
SharpChat.SockChat/S2CPackets/BanListS2CPacket.cs
Normal file
29
SharpChat.SockChat/S2CPackets/BanListS2CPacket.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using SharpChat.Bans;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class BanListS2CPacket(
|
||||
long msgId,
|
||||
IEnumerable<BanListS2CPacket.Entry> entries
|
||||
) : S2CPacket {
|
||||
public record Entry(BanKind type, string value);
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("2\t");
|
||||
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
|
||||
sb.Append("\t-1\t0\fbanlist\f");
|
||||
sb.Append(string.Join(", ", entries.Select(entry => string.Format(
|
||||
@"<a href=""javascript:void(0);"" onclick=""Chat.SendMessageWrapper('{0} '+ this.innerHTML);"">{1}</a>",
|
||||
entry.type == BanKind.IPAddress ? "/unbanip" : "/unban",
|
||||
entry.value
|
||||
))));
|
||||
sb.Append('\t');
|
||||
sb.Append(msgId);
|
||||
sb.Append("\t10010");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
22
SharpChat.SockChat/S2CPackets/ChannelCreateS2CPacket.cs
Normal file
22
SharpChat.SockChat/S2CPackets/ChannelCreateS2CPacket.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ChannelCreateS2CPacket(
|
||||
string name,
|
||||
bool hasPassword,
|
||||
bool isTemporary
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("4\t0\t");
|
||||
sb.Append(name);
|
||||
sb.Append('\t');
|
||||
sb.Append(hasPassword ? '1' : '0');
|
||||
sb.Append('\t');
|
||||
sb.Append(isTemporary ? '1' : '0');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
16
SharpChat.SockChat/S2CPackets/ChannelDeleteS2CPacket.cs
Normal file
16
SharpChat.SockChat/S2CPackets/ChannelDeleteS2CPacket.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ChannelDeleteS2CPacket(
|
||||
string channelName
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("4\t2\t");
|
||||
sb.Append(channelName);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
25
SharpChat.SockChat/S2CPackets/ChannelUpdateS2CPacket.cs
Normal file
25
SharpChat.SockChat/S2CPackets/ChannelUpdateS2CPacket.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ChannelUpdateS2CPacket(
|
||||
string previousName,
|
||||
string newName,
|
||||
bool hasPassword,
|
||||
bool isTemporary
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("4\t1\t");
|
||||
sb.Append(previousName);
|
||||
sb.Append('\t');
|
||||
sb.Append(newName);
|
||||
sb.Append('\t');
|
||||
sb.Append(hasPassword ? '1' : '0');
|
||||
sb.Append('\t');
|
||||
sb.Append(isTemporary ? '1' : '0');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
48
SharpChat.SockChat/S2CPackets/ChatMessageAddS2CPacket.cs
Normal file
48
SharpChat.SockChat/S2CPackets/ChatMessageAddS2CPacket.cs
Normal file
|
@ -0,0 +1,48 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ChatMessageAddS2CPacket(
|
||||
long msgId,
|
||||
DateTimeOffset created,
|
||||
string userId,
|
||||
string text,
|
||||
bool isAction,
|
||||
bool isPrivate
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("2\t");
|
||||
|
||||
sb.Append(created.ToUnixTimeSeconds());
|
||||
sb.Append('\t');
|
||||
|
||||
sb.Append(userId);
|
||||
sb.Append('\t');
|
||||
|
||||
if(isAction)
|
||||
sb.Append("<i>");
|
||||
|
||||
sb.Append(
|
||||
text.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\n", " <br/> ")
|
||||
.Replace("\t", " ")
|
||||
);
|
||||
|
||||
if(isAction)
|
||||
sb.Append("</i>");
|
||||
|
||||
sb.Append('\t');
|
||||
sb.Append(msgId);
|
||||
sb.AppendFormat(
|
||||
"\t1{0}0{1}{2}",
|
||||
isAction ? '1' : '0',
|
||||
isAction ? '0' : '1',
|
||||
isPrivate ? '1' : '0'
|
||||
);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
14
SharpChat.SockChat/S2CPackets/ChatMessageDeleteS2CPacket.cs
Normal file
14
SharpChat.SockChat/S2CPackets/ChatMessageDeleteS2CPacket.cs
Normal file
|
@ -0,0 +1,14 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ChatMessageDeleteS2CPacket(long eventId) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("6\t");
|
||||
sb.Append(eventId);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
85
SharpChat.SockChat/S2CPackets/CommandResponseS2CPacket.cs
Normal file
85
SharpChat.SockChat/S2CPackets/CommandResponseS2CPacket.cs
Normal file
|
@ -0,0 +1,85 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class CommandResponseS2CPacket(
|
||||
long msgId,
|
||||
string stringId,
|
||||
bool isError = true,
|
||||
params object[] args
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
if(stringId == LCR.WELCOME) {
|
||||
sb.Append("7\t1\t");
|
||||
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
|
||||
sb.Append("\t-1\tChatBot\tinherit\t\t");
|
||||
} else {
|
||||
sb.Append("2\t");
|
||||
sb.Append(DateTimeOffset.Now.ToUnixTimeSeconds());
|
||||
sb.Append("\t-1\t");
|
||||
}
|
||||
|
||||
sb.Append(isError ? '1' : '0');
|
||||
sb.Append('\f');
|
||||
sb.Append(stringId == LCR.WELCOME ? LCR.BROADCAST : stringId);
|
||||
|
||||
if(args.Length > 0)
|
||||
foreach(object arg in args) {
|
||||
sb.Append('\f');
|
||||
sb.Append(arg);
|
||||
}
|
||||
|
||||
sb.Append('\t');
|
||||
|
||||
if(stringId == LCR.WELCOME) {
|
||||
sb.Append(stringId);
|
||||
sb.Append("\t0");
|
||||
} else
|
||||
sb.Append(msgId);
|
||||
|
||||
sb.Append("\t10010");
|
||||
/*sb.AppendFormat(
|
||||
"\t1{0}0{1}{2}",
|
||||
Flags.HasFlag(ChatMessageFlags.Action) ? '1' : '0',
|
||||
Flags.HasFlag(ChatMessageFlags.Action) ? '0' : '1',
|
||||
Flags.HasFlag(ChatMessageFlags.Private) ? '1' : '0'
|
||||
);*/
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Abbreviated class name because otherwise shit gets wide
|
||||
public static class LCR {
|
||||
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 FLOOD_WARN = "flwarn";
|
||||
public const string NICKNAME_CHANGE = "nick";
|
||||
}
|
25
SharpChat.SockChat/S2CPackets/ContextChannelsS2CPacket.cs
Normal file
25
SharpChat.SockChat/S2CPackets/ContextChannelsS2CPacket.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ContextChannelsS2CPacket(IEnumerable<ContextChannelsS2CPacket.Entry> entries) : S2CPacket {
|
||||
public record Entry(string name, bool hasPassword, bool isTemporary);
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("7\t2\t");
|
||||
sb.Append(entries.Count());
|
||||
|
||||
foreach(Entry entry in entries) {
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.name);
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.hasPassword ? '1' : '0');
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.isTemporary ? '1' : '0');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
22
SharpChat.SockChat/S2CPackets/ContextClearS2CPacket.cs
Normal file
22
SharpChat.SockChat/S2CPackets/ContextClearS2CPacket.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ContextClearS2CPacket(ContextClearS2CPacket.Mode mode) : S2CPacket {
|
||||
public enum Mode {
|
||||
Messages = 0,
|
||||
Users = 1,
|
||||
Channels = 2,
|
||||
MessagesUsers = 3,
|
||||
MessagesUsersChannels = 4,
|
||||
}
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("8\t");
|
||||
sb.Append((int)mode);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
114
SharpChat.SockChat/S2CPackets/ContextMessageS2CPacket.cs
Normal file
114
SharpChat.SockChat/S2CPackets/ContextMessageS2CPacket.cs
Normal file
|
@ -0,0 +1,114 @@
|
|||
using SharpChat.Messages;
|
||||
using SharpChat.Users;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ContextMessageS2CPacket(Message msg, bool notify = false) : S2CPacket {
|
||||
public string Pack() {
|
||||
bool isAction = false;
|
||||
bool isBroadcast = msg.IsBroadcast;
|
||||
bool isPrivate = msg.IsPrivate;
|
||||
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("7\t1\t");
|
||||
sb.Append(msg.Created.ToUnixTimeSeconds());
|
||||
sb.Append('\t');
|
||||
|
||||
switch(msg.Type) {
|
||||
case "msg:add":
|
||||
if(msg.Data.RootElement.TryGetProperty("act", out JsonElement act))
|
||||
isAction = act.GetBoolean();
|
||||
|
||||
if(isBroadcast || msg.SenderId is null) {
|
||||
sb.Append("-1\tChatBot\tinherit\t\t0\fsay\f");
|
||||
} else {
|
||||
sb.Append(msg.SenderId);
|
||||
sb.Append('\t');
|
||||
sb.Append(msg.SenderLegacyName);
|
||||
sb.Append('\t');
|
||||
sb.Append(msg.SenderColour);
|
||||
sb.Append('\t');
|
||||
sb.Append(msg.SenderRank);
|
||||
sb.Append(' ');
|
||||
sb.Append(msg.SenderPermissions.HasFlag(UserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(msg.SenderPermissions.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(msg.SenderPermissions.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(msg.SenderPermissions.HasFlag(UserPermissions.CreateChannel) ? (msg.SenderPermissions.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
|
||||
sb.Append('\t');
|
||||
}
|
||||
|
||||
if(isAction)
|
||||
sb.Append("<i>");
|
||||
|
||||
if(msg.Data.RootElement.TryGetProperty("text", out JsonElement text))
|
||||
sb.Append(
|
||||
text.GetString()!
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">")
|
||||
.Replace("\n", " <br/> ")
|
||||
.Replace("\t", " ")
|
||||
);
|
||||
|
||||
if(isAction)
|
||||
sb.Append("</i>");
|
||||
break;
|
||||
|
||||
case "user:connect":
|
||||
sb.Append("-1\tChatBot\tinherit\t\t0\fjoin\f");
|
||||
sb.Append(msg.SenderLegacyName);
|
||||
break;
|
||||
|
||||
case "chan:join":
|
||||
sb.Append("-1\tChatBot\tinherit\t\t0\fjchan\f");
|
||||
sb.Append(msg.SenderLegacyName);
|
||||
break;
|
||||
|
||||
case "chan:leave":
|
||||
sb.Append("-1\tChatBot\tinherit\t\t0\flchan\f");
|
||||
sb.Append(msg.SenderLegacyName);
|
||||
break;
|
||||
|
||||
case "user:disconnect":
|
||||
sb.Append("-1\tChatBot\tinherit\t\t0\f");
|
||||
|
||||
switch((UserDisconnectS2CPacket.Reason)msg.Data.RootElement.GetProperty("reason").GetByte()) {
|
||||
case UserDisconnectS2CPacket.Reason.Flood:
|
||||
sb.Append("flood");
|
||||
break;
|
||||
case UserDisconnectS2CPacket.Reason.Kicked:
|
||||
sb.Append("kick");
|
||||
break;
|
||||
case UserDisconnectS2CPacket.Reason.TimeOut:
|
||||
sb.Append("timeout");
|
||||
break;
|
||||
case UserDisconnectS2CPacket.Reason.Leave:
|
||||
default:
|
||||
sb.Append("leave");
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append('\f');
|
||||
sb.Append(msg.SenderLegacyName);
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append('\t');
|
||||
sb.Append(msg.Id);
|
||||
sb.Append('\t');
|
||||
sb.Append(notify ? '1' : '0');
|
||||
sb.AppendFormat(
|
||||
"\t1{0}0{1}{2}",
|
||||
isAction ? '1' : '0',
|
||||
isAction ? '0' : '1',
|
||||
isPrivate ? '1' : '0'
|
||||
);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
38
SharpChat.SockChat/S2CPackets/ContextUsersS2CPacket.cs
Normal file
38
SharpChat.SockChat/S2CPackets/ContextUsersS2CPacket.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
using SharpChat.Users;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ContextUsersS2CPacket(IEnumerable<ContextUsersS2CPacket.Entry> entries) : S2CPacket {
|
||||
public record Entry(string id, string name, ColourInheritable colour, int rank, UserPermissions perms, bool visible);
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("7\t0\t");
|
||||
sb.Append(entries.Count());
|
||||
|
||||
foreach(Entry entry in entries) {
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.id);
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.name);
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.colour);
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.rank);
|
||||
sb.Append(' ');
|
||||
sb.Append(entry.perms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(entry.perms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(entry.perms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(entry.perms.HasFlag(UserPermissions.CreateChannel) ? (entry.perms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
|
||||
sb.Append('\t');
|
||||
sb.Append(entry.visible ? '1' : '0');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
22
SharpChat.SockChat/S2CPackets/ForceDisconnectS2CPacket.cs
Normal file
22
SharpChat.SockChat/S2CPackets/ForceDisconnectS2CPacket.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class ForceDisconnectS2CPacket(DateTimeOffset? expires = null) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("9\t");
|
||||
|
||||
if(expires.HasValue && expires.Value > DateTimeOffset.UtcNow) {
|
||||
sb.Append("1\t");
|
||||
if(expires.Value < DateTimeOffset.MaxValue)
|
||||
sb.Append(expires.Value.ToUnixTimeSeconds());
|
||||
else
|
||||
sb.Append("-1");
|
||||
} else
|
||||
sb.Append('0');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
7
SharpChat.SockChat/S2CPackets/PongS2CPacket.cs
Normal file
7
SharpChat.SockChat/S2CPackets/PongS2CPacket.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class PongS2CPacket : S2CPacket {
|
||||
public string Pack() {
|
||||
return "0\tpong";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class UserChannelForceJoinS2CPacket(string channelName) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("5\t2\t");
|
||||
sb.Append(channelName);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
38
SharpChat.SockChat/S2CPackets/UserChannelJoinS2CPacket.cs
Normal file
38
SharpChat.SockChat/S2CPackets/UserChannelJoinS2CPacket.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
using SharpChat.Users;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class UserChannelJoinS2CPacket(
|
||||
long msgId,
|
||||
string userId,
|
||||
string userName,
|
||||
ColourInheritable userColour,
|
||||
int userRank,
|
||||
UserPermissions userPerms
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("5\t0\t");
|
||||
sb.Append(userId);
|
||||
sb.Append('\t');
|
||||
sb.Append(userName);
|
||||
sb.Append('\t');
|
||||
sb.Append(userColour);
|
||||
sb.Append('\t');
|
||||
sb.Append(userRank);
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
|
||||
sb.Append('\t');
|
||||
sb.Append(msgId);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
16
SharpChat.SockChat/S2CPackets/UserChannelLeaveS2CPacket.cs
Normal file
16
SharpChat.SockChat/S2CPackets/UserChannelLeaveS2CPacket.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class UserChannelLeaveS2CPacket(long msgId, string userId) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("5\t1\t");
|
||||
sb.Append(userId);
|
||||
sb.Append('\t');
|
||||
sb.Append(msgId);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
41
SharpChat.SockChat/S2CPackets/UserConnectS2CPacket.cs
Normal file
41
SharpChat.SockChat/S2CPackets/UserConnectS2CPacket.cs
Normal file
|
@ -0,0 +1,41 @@
|
|||
using SharpChat.Users;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class UserConnectS2CPacket(
|
||||
long msgId,
|
||||
DateTimeOffset joined,
|
||||
string userId,
|
||||
string userName,
|
||||
ColourInheritable userColour,
|
||||
int userRank,
|
||||
UserPermissions userPerms
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("1\t");
|
||||
sb.Append(joined.ToUnixTimeSeconds());
|
||||
sb.Append('\t');
|
||||
sb.Append(userId);
|
||||
sb.Append('\t');
|
||||
sb.Append(userName);
|
||||
sb.Append('\t');
|
||||
sb.Append(userColour);
|
||||
sb.Append('\t');
|
||||
sb.Append(userRank);
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
|
||||
sb.Append('\t');
|
||||
sb.Append(msgId);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
51
SharpChat.SockChat/S2CPackets/UserDisconnectS2CPacket.cs
Normal file
51
SharpChat.SockChat/S2CPackets/UserDisconnectS2CPacket.cs
Normal file
|
@ -0,0 +1,51 @@
|
|||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class UserDisconnectS2CPacket(
|
||||
long msgId,
|
||||
DateTimeOffset disconnected,
|
||||
string userId,
|
||||
string userName,
|
||||
UserDisconnectS2CPacket.Reason reason
|
||||
) : S2CPacket {
|
||||
public enum Reason {
|
||||
Leave,
|
||||
TimeOut,
|
||||
Kicked,
|
||||
Flood,
|
||||
}
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("3\t");
|
||||
sb.Append(userId);
|
||||
sb.Append('\t');
|
||||
sb.Append(userName);
|
||||
sb.Append('\t');
|
||||
|
||||
switch(reason) {
|
||||
case Reason.Leave:
|
||||
default:
|
||||
sb.Append("leave");
|
||||
break;
|
||||
case Reason.TimeOut:
|
||||
sb.Append("timeout");
|
||||
break;
|
||||
case Reason.Kicked:
|
||||
sb.Append("kick");
|
||||
break;
|
||||
case Reason.Flood:
|
||||
sb.Append("flood");
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append('\t');
|
||||
sb.Append(disconnected.ToUnixTimeSeconds());
|
||||
sb.Append('\t');
|
||||
sb.Append(msgId);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
35
SharpChat.SockChat/S2CPackets/UserUpdateS2CPacket.cs
Normal file
35
SharpChat.SockChat/S2CPackets/UserUpdateS2CPacket.cs
Normal file
|
@ -0,0 +1,35 @@
|
|||
using SharpChat.Users;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.SockChat.S2CPackets;
|
||||
|
||||
public class UserUpdateS2CPacket(
|
||||
string userId,
|
||||
string userName,
|
||||
ColourInheritable userColour,
|
||||
int userRank,
|
||||
UserPermissions userPerms
|
||||
) : S2CPacket {
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append("10\t");
|
||||
sb.Append(userId);
|
||||
sb.Append('\t');
|
||||
sb.Append(userName);
|
||||
sb.Append('\t');
|
||||
sb.Append(userColour);
|
||||
sb.Append('\t');
|
||||
sb.Append(userRank);
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.ViewLogs) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(userPerms.HasFlag(UserPermissions.CreateChannel) ? (userPerms.HasFlag(UserPermissions.SetChannelPermanent) ? '2' : '1') : '0');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
17
SharpChat.SockChat/SharpChat.SockChat.csproj
Normal file
17
SharpChat.SockChat/SharpChat.SockChat.csproj
Normal file
|
@ -0,0 +1,17 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Fleck" Version="1.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SharpChatCommon\SharpChatCommon.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
171
SharpChat.SockChat/SharpChatWebSocketServer.cs
Normal file
171
SharpChat.SockChat/SharpChatWebSocketServer.cs
Normal file
|
@ -0,0 +1,171 @@
|
|||
#nullable disable
|
||||
|
||||
using Fleck;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using ZLogger;
|
||||
|
||||
// Near direct reimplementation of Fleck's WebSocketServer with address reusing
|
||||
// Fleck's Socket wrapper doesn't provide any way to do this with the normally provided APIs
|
||||
// https://github.com/statianzo/Fleck/blob/1.1.0/src/Fleck/WebSocketServer.cs
|
||||
|
||||
namespace SharpChat.SockChat;
|
||||
|
||||
public class SharpChatWebSocketServer : IWebSocketServer {
|
||||
private readonly ILogger Logger;
|
||||
private readonly string _scheme;
|
||||
private readonly IPAddress _locationIP;
|
||||
private Action<IWebSocketConnection> _config;
|
||||
|
||||
public SharpChatWebSocketServer(ILogger logger, string location, bool supportDualStack = true) {
|
||||
Logger = logger;
|
||||
|
||||
Uri uri = new(location);
|
||||
|
||||
Port = uri.Port;
|
||||
Location = location;
|
||||
SupportDualStack = supportDualStack;
|
||||
|
||||
_locationIP = ParseIPAddress(uri);
|
||||
_scheme = uri.Scheme;
|
||||
Socket socket = new(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
|
||||
|
||||
if(SupportDualStack && Type.GetType("Mono.Runtime") == null && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) {
|
||||
socket.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, false);
|
||||
}
|
||||
|
||||
ListenerSocket = new SocketWrapper(socket);
|
||||
SupportedSubProtocols = [];
|
||||
}
|
||||
|
||||
public ISocket ListenerSocket { get; set; }
|
||||
public string Location { get; private set; }
|
||||
public bool SupportDualStack { get; }
|
||||
public int Port { get; private set; }
|
||||
public X509Certificate2 Certificate { get; set; }
|
||||
public SslProtocols EnabledSslProtocols { get; set; }
|
||||
public IEnumerable<string> SupportedSubProtocols { get; set; }
|
||||
public bool RestartAfterListenError { get; set; }
|
||||
|
||||
public bool IsSecure {
|
||||
get { return _scheme == "wss" && Certificate != null; }
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
ListenerSocket.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private static IPAddress ParseIPAddress(Uri uri) {
|
||||
string ipStr = uri.Host;
|
||||
|
||||
if(ipStr == "0.0.0.0") {
|
||||
return IPAddress.Any;
|
||||
} else if(ipStr == "[0000:0000:0000:0000:0000:0000:0000:0000]") {
|
||||
return IPAddress.IPv6Any;
|
||||
} else {
|
||||
try {
|
||||
return IPAddress.Parse(ipStr);
|
||||
} catch(Exception ex) {
|
||||
throw new FormatException("Failed to parse the IP address part of the location. Please make sure you specify a valid IP address. Use 0.0.0.0 or [::] to listen on all interfaces.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(Action<IWebSocketConnection> config) {
|
||||
IPEndPoint ipLocal = new(_locationIP, Port);
|
||||
ListenerSocket.Bind(ipLocal);
|
||||
ListenerSocket.Listen(100);
|
||||
Port = ((IPEndPoint)ListenerSocket.LocalEndPoint).Port;
|
||||
Logger.ZLogInformation($"Server started at {Location} (actual port {Port})");
|
||||
if(_scheme == "wss") {
|
||||
if(Certificate == null) {
|
||||
Logger.ZLogError($"Scheme cannot be 'wss' without a Certificate");
|
||||
return;
|
||||
}
|
||||
|
||||
// makes dotnet shut up, TLS is handled by NGINX anyway
|
||||
// if(EnabledSslProtocols == SslProtocols.None) {
|
||||
// EnabledSslProtocols = SslProtocols.Tls;
|
||||
// Logger.ZLogDebug($"Using default TLS 1.0 security protocol.");
|
||||
// }
|
||||
}
|
||||
ListenForClients();
|
||||
_config = config;
|
||||
}
|
||||
|
||||
private void ListenForClients() {
|
||||
ListenerSocket.Accept(OnClientConnect, e => {
|
||||
Logger.ZLogError($"Listener socket is closed: {e}");
|
||||
if(RestartAfterListenError) {
|
||||
Logger.ZLogInformation($"Listener socket restarting");
|
||||
try {
|
||||
ListenerSocket.Dispose();
|
||||
Socket socket = new(_locationIP.AddressFamily, SocketType.Stream, ProtocolType.IP);
|
||||
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, 1);
|
||||
ListenerSocket = new SocketWrapper(socket);
|
||||
Start(_config);
|
||||
Logger.ZLogInformation($"Listener socket restarted");
|
||||
} catch(Exception ex) {
|
||||
Logger.ZLogError($"Listener could not be restarted: {ex}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void OnClientConnect(ISocket clientSocket) {
|
||||
if(clientSocket == null) return; // socket closed
|
||||
|
||||
Logger.ZLogDebug($"Client connected from {clientSocket.RemoteIpAddress}:{clientSocket.RemotePort}");
|
||||
ListenForClients();
|
||||
|
||||
WebSocketConnection connection = null;
|
||||
|
||||
connection = new WebSocketConnection(
|
||||
clientSocket,
|
||||
_config,
|
||||
bytes => RequestParser.Parse(bytes, _scheme),
|
||||
r => {
|
||||
try {
|
||||
return HandlerFactory.BuildHandler(
|
||||
r, s => connection.OnMessage(s), connection.Close, b => connection.OnBinary(b),
|
||||
b => connection.OnPing(b), b => connection.OnPong(b)
|
||||
);
|
||||
} catch(WebSocketException) {
|
||||
const string responseMsg = "HTTP/1.1 200 OK\r\n"
|
||||
+ "Date: {0}\r\n"
|
||||
+ "Server: SharpChat\r\n"
|
||||
+ "Content-Length: {1}\r\n"
|
||||
+ "Content-Type: text/html; charset=utf-8\r\n"
|
||||
+ "Connection: close\r\n"
|
||||
+ "\r\n"
|
||||
+ "{2}";
|
||||
string responseBody = File.Exists("http-motd.txt") ? File.ReadAllText("http-motd.txt") : "SharpChat";
|
||||
|
||||
clientSocket.Stream.Write(Encoding.UTF8.GetBytes(string.Format(
|
||||
responseMsg, DateTimeOffset.Now.ToString("r"), responseBody.CountUtf8Bytes(), responseBody
|
||||
)));
|
||||
clientSocket.Close();
|
||||
return null;
|
||||
}
|
||||
},
|
||||
s => SubProtocolNegotiator.Negotiate(SupportedSubProtocols, s));
|
||||
|
||||
if(IsSecure) {
|
||||
Logger.ZLogDebug($"Authenticating Secure Connection");
|
||||
clientSocket
|
||||
.Authenticate(Certificate,
|
||||
EnabledSslProtocols,
|
||||
connection.StartReceiving,
|
||||
e => Logger.ZLogWarning($"Failed to Authenticate: {e}"));
|
||||
} else {
|
||||
connection.StartReceiving();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,14 +7,30 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpChat", "SharpChat\Shar
|
|||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{DF7A7073-A67A-4D93-92C6-F9D0F95E2359}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
.editorconfig = .editorconfig
|
||||
.gitattributes = .gitattributes
|
||||
.gitignore = .gitignore
|
||||
LICENSE = LICENSE
|
||||
Protocol.md = Protocol.md
|
||||
README.md = README.md
|
||||
start.sh = start.sh
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChatCommon", "SharpChatCommon\SharpChatCommon.csproj", "{C8B619A7-7815-426D-B459-20EE26F7460E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.Flashii", "SharpChat.Flashii\SharpChat.Flashii.csproj", "{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.SockChat", "SharpChat.SockChat\SharpChat.SockChat.csproj", "{FEDDC565-B784-4D6F-BEF5-121C383D7AB2}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Protos", "Protos", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Providers", "Providers", "{5BB7CDAA-06BB-4746-BA07-7EF9090774D8}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Storage", "Storage", "{D5EB4BD7-7C69-41F5-9D94-AA5E25B26BDA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.MariaDB", "SharpChat.MariaDB\SharpChat.MariaDB.csproj", "{5B760B2D-F0AD-46E5-B701-8C53D25E2355}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpChat.SQLite", "SharpChat.SQLite\SharpChat.SQLite.csproj", "{6D74CAE7-200D-44C8-B950-9F45B843E133}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
@ -25,10 +41,36 @@ Global
|
|||
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DDB24C19-B802-4C96-AC15-0449C6FC77F2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C8B619A7-7815-426D-B459-20EE26F7460E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C8B619A7-7815-426D-B459-20EE26F7460E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C8B619A7-7815-426D-B459-20EE26F7460E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C8B619A7-7815-426D-B459-20EE26F7460E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{FEDDC565-B784-4D6F-BEF5-121C383D7AB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{FEDDC565-B784-4D6F-BEF5-121C383D7AB2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{FEDDC565-B784-4D6F-BEF5-121C383D7AB2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{FEDDC565-B784-4D6F-BEF5-121C383D7AB2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5B760B2D-F0AD-46E5-B701-8C53D25E2355}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5B760B2D-F0AD-46E5-B701-8C53D25E2355}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5B760B2D-F0AD-46E5-B701-8C53D25E2355}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5B760B2D-F0AD-46E5-B701-8C53D25E2355}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6D74CAE7-200D-44C8-B950-9F45B843E133}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6D74CAE7-200D-44C8-B950-9F45B843E133}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6D74CAE7-200D-44C8-B950-9F45B843E133}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6D74CAE7-200D-44C8-B950-9F45B843E133}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{A9B0B652-C20F-4C62-A96A-EF7ACD2079E9} = {5BB7CDAA-06BB-4746-BA07-7EF9090774D8}
|
||||
{FEDDC565-B784-4D6F-BEF5-121C383D7AB2} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||
{5B760B2D-F0AD-46E5-B701-8C53D25E2355} = {D5EB4BD7-7C69-41F5-9D94-AA5E25B26BDA}
|
||||
{6D74CAE7-200D-44C8-B950-9F45B843E133} = {D5EB4BD7-7C69-41F5-9D94-AA5E25B26BDA}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {42279FE1-5980-440A-87F8-25338DFE54CF}
|
||||
EndGlobalSection
|
||||
|
|
6
SharpChat/C2SPacketHandler.cs
Normal file
6
SharpChat/C2SPacketHandler.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace SharpChat;
|
||||
|
||||
public interface C2SPacketHandler {
|
||||
bool IsMatch(C2SPacketHandlerContext ctx);
|
||||
Task Handle(C2SPacketHandlerContext ctx);
|
||||
}
|
20
SharpChat/C2SPacketHandlerContext.cs
Normal file
20
SharpChat/C2SPacketHandlerContext.cs
Normal file
|
@ -0,0 +1,20 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using SharpChat.Sessions;
|
||||
|
||||
namespace SharpChat;
|
||||
|
||||
public record class C2SPacketHandlerContext(
|
||||
string Text,
|
||||
Context Chat,
|
||||
SockChatConnection Connection,
|
||||
Session? Session,
|
||||
ILogger Logger
|
||||
) {
|
||||
public bool CheckPacketId(string packetId) {
|
||||
return Text == packetId || Text.StartsWith(packetId + '\t');
|
||||
}
|
||||
|
||||
public string[] SplitText(int expect) {
|
||||
return Text.Split('\t', expect + 1);
|
||||
}
|
||||
}
|
143
SharpChat/C2SPacketHandlers/AuthC2SPacketHandler.cs
Normal file
143
SharpChat/C2SPacketHandlers/AuthC2SPacketHandler.cs
Normal file
|
@ -0,0 +1,143 @@
|
|||
using SharpChat.Auth;
|
||||
using SharpChat.Bans;
|
||||
using SharpChat.Channels;
|
||||
using SharpChat.Configuration;
|
||||
using SharpChat.Connections;
|
||||
using SharpChat.Messages;
|
||||
using SharpChat.Snowflake;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.C2SPacketHandlers;
|
||||
|
||||
public class AuthC2SPacketHandler(
|
||||
AuthClient authClient,
|
||||
BansClient bansClient,
|
||||
ChannelsContext channelsCtx,
|
||||
RandomSnowflake snowflake,
|
||||
CachedValue<int> maxMsgLength,
|
||||
CachedValue<int> maxConns
|
||||
) : C2SPacketHandler {
|
||||
public bool IsMatch(C2SPacketHandlerContext ctx) {
|
||||
return ctx.CheckPacketId("1");
|
||||
}
|
||||
|
||||
public async Task Handle(C2SPacketHandlerContext ctx) {
|
||||
if(ctx.Session is not null)
|
||||
return;
|
||||
|
||||
string[] args = ctx.SplitText(3);
|
||||
|
||||
string? authMethod = args.ElementAtOrDefault(1);
|
||||
string? authToken = args.ElementAtOrDefault(2);
|
||||
|
||||
if(string.IsNullOrWhiteSpace(authMethod) || string.IsNullOrWhiteSpace(authToken)) {
|
||||
ctx.Logger.ZLogInformation($"Received empty authentication information.");
|
||||
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
|
||||
ctx.Connection.Close(ConnectionCloseReason.Unauthorized);
|
||||
return;
|
||||
}
|
||||
|
||||
if(authMethod.All(c => c is >= '0' and <= '9') && authToken.Contains(':')) {
|
||||
string[] tokenParts = authToken.Split(':', 2);
|
||||
authMethod = tokenParts[0];
|
||||
authToken = tokenParts[1];
|
||||
}
|
||||
|
||||
try {
|
||||
AuthResult authResult = await authClient.AuthVerify(
|
||||
ctx.Connection.RemoteEndPoint.Address,
|
||||
authMethod,
|
||||
authToken
|
||||
);
|
||||
|
||||
BanInfo? banInfo = await bansClient.BanGet(authResult.UserId, ctx.Connection.RemoteEndPoint.Address);
|
||||
if(banInfo is not null) {
|
||||
ctx.Logger.ZLogInformation($"User {authResult.UserId} is banned.");
|
||||
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Banned, banInfo.IsPermanent ? DateTimeOffset.MaxValue : banInfo.ExpiresAt));
|
||||
ctx.Connection.Close(ConnectionCloseReason.AccessDenied);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Chat.ContextAccess.WaitAsync();
|
||||
try {
|
||||
User user = ctx.Chat.Users.CreateOrUpdateUser(authResult);
|
||||
|
||||
// Enforce a maximum amount of connections per user
|
||||
if(ctx.Chat.Sessions.CountNonSuspendedActiveSessions(user) >= maxConns) {
|
||||
ctx.Logger.ZLogInformation($"Too many active connections.");
|
||||
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.MaxSessions));
|
||||
ctx.Connection.Close(ConnectionCloseReason.TooManyConnections);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.Chat.Sessions.CreateSession(user, ctx.Connection);
|
||||
|
||||
await ctx.Connection.Send(new CommandResponseS2CPacket(0, 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))
|
||||
await ctx.Connection.Send(new CommandResponseS2CPacket(0, LCR.WELCOME, false, line));
|
||||
}
|
||||
|
||||
Channel channel = channelsCtx.GetDefaultChannel();
|
||||
|
||||
if(!ctx.Chat.ChannelsUsers.HasChannelUser(channel, user)) {
|
||||
long msgId = snowflake.Next();
|
||||
await ctx.Chat.SendTo(channel, new UserConnectS2CPacket(msgId, DateTimeOffset.Now, user.UserId, user.GetLegacyNameWithStatus(), user.Colour, user.Rank, user.Permissions));
|
||||
await ctx.Chat.Messages.LogMessage(msgId, "user:connect", channel.Name, user.UserId, user.UserName, user.Colour, user.Rank, user.NickName, user.Permissions);
|
||||
}
|
||||
|
||||
await ctx.Connection.Send(new AuthSuccessS2CPacket(
|
||||
user.UserId,
|
||||
user.GetLegacyNameWithStatus(),
|
||||
user.Colour,
|
||||
user.Rank,
|
||||
user.Permissions,
|
||||
channel.Name,
|
||||
maxMsgLength
|
||||
));
|
||||
await ctx.Connection.Send(new ContextUsersS2CPacket(
|
||||
ctx.Chat.ChannelsUsers.GetChannelUsers(channel).Except([user]).OrderByDescending(u => u.Rank)
|
||||
.Select(u => new ContextUsersS2CPacket.Entry(
|
||||
u.UserId,
|
||||
u.GetLegacyNameWithStatus(),
|
||||
u.Colour,
|
||||
u.Rank,
|
||||
u.Permissions,
|
||||
true
|
||||
))
|
||||
));
|
||||
|
||||
IEnumerable<Message> msgs = await ctx.Chat.Messages.GetMessages(channel.Name);
|
||||
foreach(Message msg in msgs)
|
||||
await ctx.Connection.Send(new ContextMessageS2CPacket(msg));
|
||||
|
||||
await ctx.Connection.Send(new ContextChannelsS2CPacket(
|
||||
ctx.Chat.Channels.GetChannels(user.Rank)
|
||||
.Select(c => new ContextChannelsS2CPacket.Entry(c.Name, c.HasPassword, c.IsTemporary))
|
||||
));
|
||||
|
||||
ctx.Chat.ChannelsUsers.AddChannelUser(channel, user);
|
||||
} finally {
|
||||
ctx.Chat.ContextAccess.Release();
|
||||
}
|
||||
} catch(AuthFailedException ex) {
|
||||
ctx.Chat.Sessions.DestroySession(ctx.Connection);
|
||||
ctx.Logger.ZLogWarning($"Failed to authenticate (expected): {ex}");
|
||||
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.AuthInvalid));
|
||||
ctx.Connection.Close(ConnectionCloseReason.Unauthorized);
|
||||
throw;
|
||||
} catch(Exception ex) {
|
||||
ctx.Chat.Sessions.DestroySession(ctx.Connection);
|
||||
ctx.Logger.ZLogError($"Failed to authenticate (unexpected): {ex}");
|
||||
await ctx.Connection.Send(new AuthFailS2CPacket(AuthFailS2CPacket.Reason.Exception));
|
||||
ctx.Connection.Close(ConnectionCloseReason.Error);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
43
SharpChat/C2SPacketHandlers/PingC2SPacketHandler.cs
Normal file
43
SharpChat/C2SPacketHandlers/PingC2SPacketHandler.cs
Normal file
|
@ -0,0 +1,43 @@
|
|||
using SharpChat.Auth;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat.C2SPacketHandlers;
|
||||
|
||||
public class PingC2SPacketHandler(AuthClient authClient) : C2SPacketHandler {
|
||||
private readonly TimeSpan BumpInterval = TimeSpan.FromMinutes(1);
|
||||
private DateTimeOffset LastBump = DateTimeOffset.MinValue;
|
||||
|
||||
public bool IsMatch(C2SPacketHandlerContext ctx) {
|
||||
return ctx.CheckPacketId("0");
|
||||
}
|
||||
|
||||
public async Task Handle(C2SPacketHandlerContext ctx) {
|
||||
if(ctx.Session is null)
|
||||
return;
|
||||
|
||||
string[] parts = ctx.SplitText(2);
|
||||
|
||||
if(!int.TryParse(parts.FirstOrDefault(), out int pTime))
|
||||
return;
|
||||
|
||||
ctx.Session.Heartbeat();
|
||||
await ctx.Connection.Send(new PongS2CPacket());
|
||||
|
||||
ctx.Chat.ContextAccess.Wait();
|
||||
try {
|
||||
if(LastBump < DateTimeOffset.UtcNow - BumpInterval) {
|
||||
(IPAddress, string)[] bumpList = [.. ctx.Chat.Users.GetUsersWithStatus(UserStatus.Online)
|
||||
.Select(u => (ctx.Chat.Sessions.GetRemoteEndPoints(u).Select(e => e.Address).FirstOrDefault() ?? IPAddress.None, u.UserId))];
|
||||
|
||||
if(bumpList.Length > 0)
|
||||
await authClient.AuthBumpUsersOnline(bumpList);
|
||||
|
||||
LastBump = DateTimeOffset.UtcNow;
|
||||
}
|
||||
} finally {
|
||||
ctx.Chat.ContextAccess.Release();
|
||||
}
|
||||
}
|
||||
}
|
86
SharpChat/C2SPacketHandlers/SendMessageC2SPacketHandler.cs
Normal file
86
SharpChat/C2SPacketHandlers/SendMessageC2SPacketHandler.cs
Normal file
|
@ -0,0 +1,86 @@
|
|||
using SharpChat.Channels;
|
||||
using SharpChat.Configuration;
|
||||
using SharpChat.Events;
|
||||
using SharpChat.Snowflake;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.C2SPacketHandlers;
|
||||
|
||||
public class SendMessageC2SPacketHandler(
|
||||
RandomSnowflake randomSnowflake,
|
||||
CachedValue<int> maxMsgLength
|
||||
) : C2SPacketHandler {
|
||||
private readonly CachedValue<int> MaxMessageLength = maxMsgLength ?? throw new ArgumentNullException(nameof(maxMsgLength));
|
||||
|
||||
private List<ClientCommand> Commands { get; } = [];
|
||||
|
||||
public void AddCommand(ClientCommand command) {
|
||||
Commands.Add(command ?? throw new ArgumentNullException(nameof(command)));
|
||||
}
|
||||
|
||||
public void AddCommands(IEnumerable<ClientCommand> commands) {
|
||||
Commands.AddRange(commands ?? throw new ArgumentNullException(nameof(commands)));
|
||||
}
|
||||
|
||||
public bool IsMatch(C2SPacketHandlerContext ctx) {
|
||||
return ctx.CheckPacketId("2");
|
||||
}
|
||||
|
||||
public async Task Handle(C2SPacketHandlerContext ctx) {
|
||||
if(ctx.Session is null)
|
||||
return;
|
||||
|
||||
string[] args = ctx.SplitText(3);
|
||||
|
||||
User? user = ctx.Chat.Users.GetUser(ctx.Session.UserId);
|
||||
string? messageText = args.ElementAtOrDefault(2);
|
||||
|
||||
if(user?.Permissions.HasFlag(UserPermissions.SendMessage) != true
|
||||
|| 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.ToString())
|
||||
return;
|
||||
|
||||
ctx.Chat.ContextAccess.Wait();
|
||||
try {
|
||||
Channel? channel = ctx.Chat.ChannelsUsers.GetUserLastChannel(user);
|
||||
if(channel is null)
|
||||
return;
|
||||
|
||||
ctx.Chat.ChannelsUsers.RecordChannelUserActivity(channel, user);
|
||||
|
||||
if(user.Status != UserStatus.Online)
|
||||
await ctx.Chat.UpdateUser(user, status: UserStatus.Online);
|
||||
|
||||
int maxMsgLength = MaxMessageLength;
|
||||
messageText = messageText.TruncateIfTooLong(maxMsgLength, maxMsgLength * 10).Trim();
|
||||
|
||||
if(messageText.StartsWith('/')) {
|
||||
ClientCommandContext context = new(messageText, ctx.Chat, user, ctx.Session, ctx.Connection, channel);
|
||||
foreach(ClientCommand cmd in Commands)
|
||||
if(cmd.IsMatch(context)) {
|
||||
await cmd.Dispatch(context);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
|
||||
randomSnowflake.Next(),
|
||||
channel.Name,
|
||||
user.UserId,
|
||||
user.UserName,
|
||||
user.Colour,
|
||||
user.Rank,
|
||||
user.NickName,
|
||||
user.Permissions,
|
||||
DateTimeOffset.Now,
|
||||
messageText,
|
||||
false, false, false
|
||||
));
|
||||
} finally {
|
||||
ctx.Chat.ContextAccess.Release();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,72 +0,0 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatChannel {
|
||||
public string Name { get; }
|
||||
public string Password { get; set; }
|
||||
public bool IsTemporary { get; set; }
|
||||
public int Rank { get; set; }
|
||||
public long OwnerId { get; set; }
|
||||
|
||||
public bool HasPassword
|
||||
=> !string.IsNullOrWhiteSpace(Password);
|
||||
|
||||
public ChatChannel(
|
||||
ChatUser owner,
|
||||
string name,
|
||||
string password = null,
|
||||
bool isTemporary = false,
|
||||
int rank = 0
|
||||
) : this(name, password, isTemporary, rank, owner?.UserId ?? 0) {}
|
||||
|
||||
public ChatChannel(
|
||||
string name,
|
||||
string password = null,
|
||||
bool isTemporary = false,
|
||||
int rank = 0,
|
||||
long ownerId = 0
|
||||
) {
|
||||
Name = name;
|
||||
Password = password ?? string.Empty;
|
||||
IsTemporary = isTemporary;
|
||||
Rank = rank;
|
||||
OwnerId = ownerId;
|
||||
}
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append(Name);
|
||||
sb.Append('\t');
|
||||
sb.Append(string.IsNullOrEmpty(Password) ? '0' : '1');
|
||||
sb.Append('\t');
|
||||
sb.Append(IsTemporary ? '1' : '0');
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public bool NameEquals(string name) {
|
||||
return string.Equals(name, Name, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
public bool IsOwner(ChatUser user) {
|
||||
return OwnerId > 0
|
||||
&& user != null
|
||||
&& OwnerId == user.UserId;
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return Name.GetHashCode();
|
||||
}
|
||||
|
||||
public static bool CheckName(string name) {
|
||||
return !string.IsNullOrWhiteSpace(name) && name.All(CheckNameChar);
|
||||
}
|
||||
|
||||
public static bool CheckNameChar(char c) {
|
||||
return char.IsLetter(c) || char.IsNumber(c) || c == '-' || c == '_';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace SharpChat {
|
||||
public struct ChatColour {
|
||||
public byte Red { get; }
|
||||
public byte Green { get; }
|
||||
public byte Blue { get; }
|
||||
public bool Inherits { get; }
|
||||
|
||||
public static ChatColour None { get; } = new();
|
||||
|
||||
public ChatColour() {
|
||||
Red = 0;
|
||||
Green = 0;
|
||||
Blue = 0;
|
||||
Inherits = true;
|
||||
}
|
||||
|
||||
public ChatColour(byte red, byte green, byte blue) {
|
||||
Red = red;
|
||||
Green = green;
|
||||
Blue = blue;
|
||||
Inherits = false;
|
||||
}
|
||||
|
||||
public override bool Equals([NotNullWhen(true)] object obj) {
|
||||
return obj is ChatColour colour && Equals(colour);
|
||||
}
|
||||
|
||||
public bool Equals(ChatColour other) {
|
||||
return Red == other.Red
|
||||
&& Green == other.Green
|
||||
&& Blue == other.Blue
|
||||
&& Inherits == other.Inherits;
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return ToMisuzu();
|
||||
}
|
||||
|
||||
public override string ToString() {
|
||||
return Inherits
|
||||
? "inherit"
|
||||
: string.Format("#{0:x2}{1:x2}{2:x2}", Red, Green, Blue);
|
||||
}
|
||||
|
||||
public int ToRawRGB() {
|
||||
return (Red << 16) | (Green << 8) | Blue;
|
||||
}
|
||||
|
||||
public static ChatColour FromRawRGB(int rgb) {
|
||||
return new(
|
||||
(byte)((rgb >> 16) & 0xFF),
|
||||
(byte)((rgb >> 8) & 0xFF),
|
||||
(byte)(rgb & 0xFF)
|
||||
);
|
||||
}
|
||||
|
||||
private const int MSZ_INHERIT = 0x40000000;
|
||||
|
||||
public int ToMisuzu() {
|
||||
return Inherits ? MSZ_INHERIT : ToRawRGB();
|
||||
}
|
||||
|
||||
public static ChatColour FromMisuzu(int raw) {
|
||||
return (raw & MSZ_INHERIT) > 0
|
||||
? None
|
||||
: FromRawRGB(raw);
|
||||
}
|
||||
|
||||
public static bool operator ==(ChatColour left, ChatColour right) {
|
||||
return left.Equals(right);
|
||||
}
|
||||
|
||||
public static bool operator !=(ChatColour left, ChatColour right) {
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatCommandContext {
|
||||
public string Name { get; }
|
||||
public string[] Args { get; }
|
||||
public ChatContext Chat { get; }
|
||||
public ChatUser User { get; }
|
||||
public ChatConnection Connection { get; }
|
||||
public ChatChannel Channel { get; }
|
||||
|
||||
public ChatCommandContext(
|
||||
string text,
|
||||
ChatContext chat,
|
||||
ChatUser user,
|
||||
ChatConnection connection,
|
||||
ChatChannel channel
|
||||
) {
|
||||
if(text == null)
|
||||
throw new ArgumentNullException(nameof(text));
|
||||
|
||||
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
User = user ?? throw new ArgumentNullException(nameof(user));
|
||||
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
|
||||
string[] parts = text[1..].Split(' ');
|
||||
Name = parts.First().Replace(".", string.Empty);
|
||||
Args = parts.Skip(1).ToArray();
|
||||
}
|
||||
|
||||
public ChatCommandContext(
|
||||
string name,
|
||||
string[] args,
|
||||
ChatContext chat,
|
||||
ChatUser user,
|
||||
ChatConnection connection,
|
||||
ChatChannel channel
|
||||
) {
|
||||
Name = name ?? throw new ArgumentNullException(nameof(name));
|
||||
Args = args ?? throw new ArgumentNullException(nameof(args));
|
||||
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
User = user ?? throw new ArgumentNullException(nameof(user));
|
||||
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
Channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
}
|
||||
|
||||
public bool NameEquals(string name) {
|
||||
return Name.Equals(name, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
using Fleck;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatConnection : IDisposable {
|
||||
public const int ID_LENGTH = 20;
|
||||
|
||||
#if DEBUG
|
||||
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(1);
|
||||
#else
|
||||
public static TimeSpan SessionTimeOut { get; } = TimeSpan.FromMinutes(5);
|
||||
#endif
|
||||
|
||||
public IWebSocketConnection Socket { get; }
|
||||
|
||||
public string Id { get; }
|
||||
public bool IsDisposed { get; private set; }
|
||||
public DateTimeOffset LastPing { get; set; } = DateTimeOffset.Now;
|
||||
public ChatUser User { get; set; }
|
||||
|
||||
private int CloseCode { get; set; } = 1000;
|
||||
|
||||
public IPAddress RemoteAddress { get; }
|
||||
public ushort RemotePort { get; }
|
||||
|
||||
public bool IsAlive => !IsDisposed && !HasTimedOut;
|
||||
|
||||
public bool IsAuthed => IsAlive && User is not null;
|
||||
|
||||
public ChatConnection(IWebSocketConnection sock) {
|
||||
Socket = sock;
|
||||
Id = RNG.SecureRandomString(ID_LENGTH);
|
||||
|
||||
if(!IPAddress.TryParse(sock.ConnectionInfo.ClientIpAddress, out IPAddress addr))
|
||||
throw new Exception("Unable to parse remote address?????");
|
||||
|
||||
if(IPAddress.IsLoopback(addr)
|
||||
&& sock.ConnectionInfo.Headers.ContainsKey("X-Real-IP")
|
||||
&& IPAddress.TryParse(sock.ConnectionInfo.Headers["X-Real-IP"], out IPAddress realAddr))
|
||||
addr = realAddr;
|
||||
|
||||
RemoteAddress = addr;
|
||||
RemotePort = (ushort)sock.ConnectionInfo.ClientPort;
|
||||
}
|
||||
|
||||
public void Send(IServerPacket packet) {
|
||||
if(!Socket.IsAvailable)
|
||||
return;
|
||||
|
||||
IEnumerable<string> data = packet.Pack();
|
||||
|
||||
if(data != null)
|
||||
foreach(string line in data)
|
||||
if(!string.IsNullOrWhiteSpace(line))
|
||||
Socket.Send(line);
|
||||
}
|
||||
|
||||
public void BumpPing() {
|
||||
LastPing = DateTimeOffset.Now;
|
||||
}
|
||||
|
||||
public bool HasTimedOut
|
||||
=> DateTimeOffset.Now - LastPing > SessionTimeOut;
|
||||
|
||||
public void PrepareForRestart() {
|
||||
CloseCode = 1012;
|
||||
}
|
||||
|
||||
~ChatConnection() {
|
||||
DoDispose();
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
DoDispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
private void DoDispose() {
|
||||
if(IsDisposed)
|
||||
return;
|
||||
|
||||
IsDisposed = true;
|
||||
Socket.Close(CloseCode);
|
||||
}
|
||||
|
||||
public override string ToString() {
|
||||
return Id;
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return Id.GetHashCode();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,398 +0,0 @@
|
|||
using SharpChat.Events;
|
||||
using SharpChat.EventStorage;
|
||||
using SharpChat.Packet;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatContext {
|
||||
public record ChannelUserAssoc(long UserId, string ChannelName);
|
||||
|
||||
public readonly SemaphoreSlim ContextAccess = new(1, 1);
|
||||
|
||||
public HashSet<ChatChannel> Channels { get; } = new();
|
||||
public HashSet<ChatConnection> Connections { get; } = new();
|
||||
public HashSet<ChatUser> Users { get; } = new();
|
||||
public IEventStorage Events { get; }
|
||||
public HashSet<ChannelUserAssoc> ChannelUsers { get; } = new();
|
||||
public Dictionary<long, RateLimiter> UserRateLimiters { get; } = new();
|
||||
public Dictionary<long, ChatChannel> UserLastChannel { get; } = new();
|
||||
|
||||
public ChatContext(IEventStorage evtStore) {
|
||||
Events = evtStore ?? throw new ArgumentNullException(nameof(evtStore));
|
||||
}
|
||||
|
||||
public void DispatchEvent(IChatEvent eventInfo) {
|
||||
if(eventInfo is MessageCreateEvent mce) {
|
||||
if(mce.IsBroadcast) {
|
||||
Send(new LegacyCommandResponse(LCR.BROADCAST, false, mce.MessageText));
|
||||
} else if(mce.IsPrivate) {
|
||||
// The channel name returned by GetDMChannelName should not be exposed to the user, instead @<Target User> should be displayed
|
||||
// e.g. nook sees @Arysil and Arysil sees @nook
|
||||
|
||||
// this entire routine is garbage, channels should probably in the db
|
||||
if(!mce.ChannelName.StartsWith("@"))
|
||||
return;
|
||||
|
||||
IEnumerable<long> uids = mce.ChannelName[1..].Split('-', 3).Select(u => long.TryParse(u, out long up) ? up : -1);
|
||||
if(uids.Count() != 2)
|
||||
return;
|
||||
|
||||
IEnumerable<ChatUser> users = Users.Where(u => uids.Any(uid => uid == u.UserId));
|
||||
ChatUser target = users.FirstOrDefault(u => u.UserId != mce.SenderId);
|
||||
if(target == null)
|
||||
return;
|
||||
|
||||
foreach(ChatUser user in users)
|
||||
SendTo(user, new ChatMessageAddPacket(
|
||||
mce.MessageId,
|
||||
DateTimeOffset.Now,
|
||||
mce.SenderId,
|
||||
mce.SenderId == user.UserId ? $"{target.LegacyName} {mce.MessageText}" : mce.MessageText,
|
||||
mce.IsAction,
|
||||
true
|
||||
));
|
||||
} else {
|
||||
ChatChannel channel = Channels.FirstOrDefault(c => c.NameEquals(mce.ChannelName));
|
||||
SendTo(channel, new ChatMessageAddPacket(
|
||||
mce.MessageId,
|
||||
DateTimeOffset.Now,
|
||||
mce.SenderId,
|
||||
mce.MessageText,
|
||||
mce.IsAction,
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
Events.AddEvent(
|
||||
mce.MessageId, "msg:add",
|
||||
mce.ChannelName,
|
||||
mce.SenderId, mce.SenderName, mce.SenderColour, mce.SenderRank, mce.SenderNickName, mce.SenderPerms,
|
||||
new { text = mce.MessageText },
|
||||
(mce.IsBroadcast ? StoredEventFlags.Broadcast : 0)
|
||||
| (mce.IsAction ? StoredEventFlags.Action : 0)
|
||||
| (mce.IsPrivate ? StoredEventFlags.Private : 0)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update() {
|
||||
foreach(ChatConnection conn in Connections)
|
||||
if(!conn.IsDisposed && conn.HasTimedOut) {
|
||||
conn.Dispose();
|
||||
Logger.Write($"Nuked connection {conn.Id} associated with {conn.User}.");
|
||||
}
|
||||
|
||||
Connections.RemoveWhere(conn => conn.IsDisposed);
|
||||
|
||||
foreach(ChatUser user in Users)
|
||||
if(!Connections.Any(conn => conn.User == user)) {
|
||||
HandleDisconnect(user, UserDisconnectReason.TimeOut);
|
||||
Logger.Write($"Timed out {user} (no more connections).");
|
||||
}
|
||||
}
|
||||
|
||||
public void SafeUpdate() {
|
||||
ContextAccess.Wait();
|
||||
try {
|
||||
Update();
|
||||
} finally {
|
||||
ContextAccess.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsInChannel(ChatUser user, ChatChannel channel) {
|
||||
return ChannelUsers.Contains(new ChannelUserAssoc(user.UserId, channel.Name));
|
||||
}
|
||||
|
||||
public string[] GetUserChannelNames(ChatUser user) {
|
||||
return ChannelUsers.Where(cu => cu.UserId == user.UserId).Select(cu => cu.ChannelName).ToArray();
|
||||
}
|
||||
|
||||
public ChatChannel[] GetUserChannels(ChatUser user) {
|
||||
string[] names = GetUserChannelNames(user);
|
||||
return Channels.Where(c => names.Any(n => c.NameEquals(n))).ToArray();
|
||||
}
|
||||
|
||||
public long[] GetChannelUserIds(ChatChannel channel) {
|
||||
return ChannelUsers.Where(cu => channel.NameEquals(cu.ChannelName)).Select(cu => cu.UserId).ToArray();
|
||||
}
|
||||
|
||||
public ChatUser[] GetChannelUsers(ChatChannel channel) {
|
||||
long[] ids = GetChannelUserIds(channel);
|
||||
return Users.Where(u => ids.Contains(u.UserId)).ToArray();
|
||||
}
|
||||
|
||||
public void UpdateUser(
|
||||
ChatUser user,
|
||||
string userName = null,
|
||||
string nickName = null,
|
||||
ChatColour? colour = null,
|
||||
ChatUserStatus? status = null,
|
||||
string statusText = null,
|
||||
int? rank = null,
|
||||
ChatUserPermissions? perms = null,
|
||||
bool? isSuper = null,
|
||||
bool silent = false
|
||||
) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
|
||||
bool hasChanged = false;
|
||||
string previousName = null;
|
||||
|
||||
if(userName != null && !user.UserName.Equals(userName)) {
|
||||
user.UserName = userName;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(nickName != null && !user.NickName.Equals(nickName)) {
|
||||
if(!silent)
|
||||
previousName = string.IsNullOrWhiteSpace(user.NickName) ? user.UserName : user.NickName;
|
||||
|
||||
user.NickName = nickName;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(colour.HasValue && user.Colour != colour.Value) {
|
||||
user.Colour = colour.Value;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(status.HasValue && user.Status != status.Value) {
|
||||
user.Status = status.Value;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(statusText != null && !user.StatusText.Equals(statusText)) {
|
||||
user.StatusText = statusText;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(rank != null && user.Rank != rank) {
|
||||
user.Rank = (int)rank;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(perms.HasValue && user.Permissions != perms) {
|
||||
user.Permissions = perms.Value;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(isSuper.HasValue) {
|
||||
user.IsSuper = isSuper.Value;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if(hasChanged)
|
||||
SendToUserChannels(user, new UserUpdatePacket(user, previousName));
|
||||
}
|
||||
|
||||
public void BanUser(ChatUser user, TimeSpan duration, UserDisconnectReason reason = UserDisconnectReason.Kicked) {
|
||||
if(duration > TimeSpan.Zero) {
|
||||
DateTimeOffset expires = duration >= TimeSpan.MaxValue ? DateTimeOffset.MaxValue : DateTimeOffset.Now + duration;
|
||||
SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Banned, expires));
|
||||
} else
|
||||
SendTo(user, new ForceDisconnectPacket(ForceDisconnectReason.Kicked));
|
||||
|
||||
foreach(ChatConnection conn in Connections)
|
||||
if(conn.User == user)
|
||||
conn.Dispose();
|
||||
Connections.RemoveWhere(conn => conn.IsDisposed);
|
||||
|
||||
HandleDisconnect(user, reason);
|
||||
}
|
||||
|
||||
public void HandleJoin(ChatUser user, ChatChannel chan, ChatConnection conn, int maxMsgLength) {
|
||||
if(!IsInChannel(user, chan)) {
|
||||
SendTo(chan, new UserConnectPacket(DateTimeOffset.Now, user));
|
||||
Events.AddEvent("user:connect", user, chan, flags: StoredEventFlags.Log);
|
||||
}
|
||||
|
||||
conn.Send(new AuthSuccessPacket(user, chan, conn, maxMsgLength));
|
||||
conn.Send(new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
|
||||
|
||||
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
|
||||
conn.Send(new ContextMessagePacket(msg));
|
||||
|
||||
conn.Send(new ContextChannelsPacket(Channels.Where(c => c.Rank <= user.Rank)));
|
||||
|
||||
Users.Add(user);
|
||||
|
||||
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
|
||||
UserLastChannel[user.UserId] = chan;
|
||||
}
|
||||
|
||||
public void HandleDisconnect(ChatUser user, UserDisconnectReason reason = UserDisconnectReason.Leave) {
|
||||
UpdateUser(user, status: ChatUserStatus.Offline);
|
||||
Users.Remove(user);
|
||||
UserLastChannel.Remove(user.UserId);
|
||||
|
||||
ChatChannel[] channels = GetUserChannels(user);
|
||||
|
||||
foreach(ChatChannel chan in channels) {
|
||||
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, chan.Name));
|
||||
|
||||
SendTo(chan, new UserDisconnectPacket(DateTimeOffset.Now, user, reason));
|
||||
Events.AddEvent("user:disconnect", user, chan, new { reason = (int)reason }, StoredEventFlags.Log);
|
||||
|
||||
if(chan.IsTemporary && chan.IsOwner(user))
|
||||
RemoveChannel(chan);
|
||||
}
|
||||
}
|
||||
|
||||
public void SwitchChannel(ChatUser user, ChatChannel chan, string password) {
|
||||
if(UserLastChannel.TryGetValue(user.UserId, out ChatChannel ulc) && chan == ulc) {
|
||||
ForceChannel(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!user.Can(ChatUserPermissions.JoinAnyChannel) && chan.IsOwner(user)) {
|
||||
if(chan.Rank > user.Rank) {
|
||||
SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INSUFFICIENT_HIERARCHY, true, chan.Name));
|
||||
ForceChannel(user);
|
||||
return;
|
||||
}
|
||||
|
||||
if(!string.IsNullOrEmpty(chan.Password) && chan.Password != password) {
|
||||
SendTo(user, new LegacyCommandResponse(LCR.CHANNEL_INVALID_PASSWORD, true, chan.Name));
|
||||
ForceChannel(user);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ForceChannelSwitch(user, chan);
|
||||
}
|
||||
|
||||
public void ForceChannelSwitch(ChatUser user, ChatChannel chan) {
|
||||
if(!Channels.Contains(chan))
|
||||
return;
|
||||
|
||||
ChatChannel oldChan = UserLastChannel[user.UserId];
|
||||
|
||||
SendTo(oldChan, new UserChannelLeavePacket(user));
|
||||
Events.AddEvent("chan:leave", user, oldChan, flags: StoredEventFlags.Log);
|
||||
SendTo(chan, new UserChannelJoinPacket(user));
|
||||
Events.AddEvent("chan:join", user, oldChan, flags: StoredEventFlags.Log);
|
||||
|
||||
SendTo(user, new ContextClearPacket(chan, ContextClearMode.MessagesUsers));
|
||||
SendTo(user, new ContextUsersPacket(GetChannelUsers(chan).Except(new[] { user }).OrderByDescending(u => u.Rank)));
|
||||
|
||||
foreach(StoredEventInfo msg in Events.GetChannelEventLog(chan.Name))
|
||||
SendTo(user, new ContextMessagePacket(msg));
|
||||
|
||||
ForceChannel(user, chan);
|
||||
|
||||
ChannelUsers.Remove(new ChannelUserAssoc(user.UserId, oldChan.Name));
|
||||
ChannelUsers.Add(new ChannelUserAssoc(user.UserId, chan.Name));
|
||||
UserLastChannel[user.UserId] = chan;
|
||||
|
||||
if(oldChan.IsTemporary && oldChan.IsOwner(user))
|
||||
RemoveChannel(oldChan);
|
||||
}
|
||||
|
||||
public void Send(IServerPacket packet) {
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
foreach(ChatConnection conn in Connections)
|
||||
if(conn.IsAuthed)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
public void SendTo(ChatUser user, IServerPacket packet) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
foreach(ChatConnection conn in Connections)
|
||||
if(conn.IsAlive && conn.User == user)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
public void SendTo(ChatChannel channel, IServerPacket packet) {
|
||||
if(channel == null)
|
||||
throw new ArgumentNullException(nameof(channel));
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
// might be faster to grab the users first and then cascade into that SendTo
|
||||
IEnumerable<ChatConnection> conns = Connections.Where(c => c.IsAuthed && IsInChannel(c.User, channel));
|
||||
foreach(ChatConnection conn in conns)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
public void SendToUserChannels(ChatUser user, IServerPacket packet) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
if(packet == null)
|
||||
throw new ArgumentNullException(nameof(packet));
|
||||
|
||||
IEnumerable<ChatChannel> chans = Channels.Where(c => IsInChannel(user, c));
|
||||
IEnumerable<ChatConnection> conns = Connections.Where(conn => conn.IsAuthed && ChannelUsers.Any(cu => cu.UserId == conn.User.UserId && chans.Any(chan => chan.NameEquals(cu.ChannelName))));
|
||||
foreach(ChatConnection conn in conns)
|
||||
conn.Send(packet);
|
||||
}
|
||||
|
||||
public IPAddress[] GetRemoteAddresses(ChatUser user) {
|
||||
return Connections.Where(c => c.IsAlive && c.User == user).Select(c => c.RemoteAddress).Distinct().ToArray();
|
||||
}
|
||||
|
||||
public void ForceChannel(ChatUser user, ChatChannel chan = null) {
|
||||
if(user == null)
|
||||
throw new ArgumentNullException(nameof(user));
|
||||
|
||||
if(chan == null && !UserLastChannel.TryGetValue(user.UserId, out chan))
|
||||
throw new ArgumentException("no channel???");
|
||||
|
||||
SendTo(user, new UserChannelForceJoinPacket(chan));
|
||||
}
|
||||
|
||||
public void UpdateChannel(ChatChannel channel, bool? temporary = null, int? hierarchy = null, string password = null) {
|
||||
if(channel == null)
|
||||
throw new ArgumentNullException(nameof(channel));
|
||||
if(!Channels.Contains(channel))
|
||||
throw new ArgumentException("Provided channel is not registered with this manager.", nameof(channel));
|
||||
|
||||
if(temporary.HasValue)
|
||||
channel.IsTemporary = temporary.Value;
|
||||
|
||||
if(hierarchy.HasValue)
|
||||
channel.Rank = hierarchy.Value;
|
||||
|
||||
if(password != null)
|
||||
channel.Password = password;
|
||||
|
||||
// TODO: Users that no longer have access to the channel/gained access to the channel by the hierarchy change should receive delete and create packets respectively
|
||||
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank)) {
|
||||
SendTo(user, new ChannelUpdatePacket(channel.Name, channel));
|
||||
}
|
||||
}
|
||||
|
||||
public void RemoveChannel(ChatChannel channel) {
|
||||
if(channel == null || !Channels.Any())
|
||||
return;
|
||||
|
||||
ChatChannel defaultChannel = Channels.FirstOrDefault();
|
||||
if(defaultChannel == null)
|
||||
return;
|
||||
|
||||
// Remove channel from the listing
|
||||
Channels.Remove(channel);
|
||||
|
||||
// Move all users back to the main channel
|
||||
// TODO: Replace this with a kick. SCv2 supports being in 0 channels, SCv1 should force the user back to DefaultChannel.
|
||||
foreach(ChatUser user in GetChannelUsers(channel))
|
||||
SwitchChannel(user, defaultChannel, string.Empty);
|
||||
|
||||
// Broadcast deletion of channel
|
||||
foreach(ChatUser user in Users.Where(u => u.Rank >= channel.Rank))
|
||||
SendTo(user, new ChannelDeletePacket(channel));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
using System;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatPacketHandlerContext {
|
||||
public string Text { get; }
|
||||
public ChatContext Chat { get; }
|
||||
public ChatConnection Connection { get; }
|
||||
|
||||
public ChatPacketHandlerContext(
|
||||
string text,
|
||||
ChatContext chat,
|
||||
ChatConnection connection
|
||||
) {
|
||||
Text = text ?? throw new ArgumentNullException(nameof(text));
|
||||
Chat = chat ?? throw new ArgumentNullException(nameof(chat));
|
||||
Connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
}
|
||||
|
||||
public bool CheckPacketId(string packetId) {
|
||||
return Text == packetId || Text.StartsWith(packetId + '\t');
|
||||
}
|
||||
|
||||
public string[] SplitText(int expect) {
|
||||
return Text.Split('\t', expect + 1);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,110 +0,0 @@
|
|||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat {
|
||||
public class ChatUser : IEquatable<ChatUser> {
|
||||
public const int DEFAULT_SIZE = 30;
|
||||
public const int DEFAULT_MINIMUM_DELAY = 10000;
|
||||
public const int DEFAULT_RISKY_OFFSET = 5;
|
||||
|
||||
public long UserId { get; }
|
||||
public string UserName { get; set; }
|
||||
public ChatColour Colour { get; set; }
|
||||
public int Rank { get; set; }
|
||||
public ChatUserPermissions Permissions { get; set; }
|
||||
public bool IsSuper { get; set; }
|
||||
public string NickName { get; set; }
|
||||
public ChatUserStatus Status { get; set; }
|
||||
public string StatusText { get; set; }
|
||||
|
||||
public string LegacyName => string.IsNullOrWhiteSpace(NickName) ? UserName : $"~{NickName}";
|
||||
|
||||
public string LegacyNameWithStatus {
|
||||
get {
|
||||
StringBuilder sb = new();
|
||||
|
||||
if(Status == ChatUserStatus.Away)
|
||||
sb.AppendFormat("<{0}>_", StatusText[..Math.Min(StatusText.Length, 5)].ToUpperInvariant());
|
||||
|
||||
sb.Append(LegacyName);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public ChatUser(
|
||||
long userId,
|
||||
string userName,
|
||||
ChatColour colour,
|
||||
int rank,
|
||||
ChatUserPermissions perms,
|
||||
string nickName = null,
|
||||
ChatUserStatus status = ChatUserStatus.Online,
|
||||
string statusText = null,
|
||||
bool isSuper = false
|
||||
) {
|
||||
UserId = userId;
|
||||
UserName = userName ?? throw new ArgumentNullException(nameof(userName));
|
||||
Colour = colour;
|
||||
Rank = rank;
|
||||
Permissions = perms;
|
||||
NickName = nickName ?? string.Empty;
|
||||
Status = status;
|
||||
StatusText = statusText ?? string.Empty;
|
||||
}
|
||||
|
||||
public bool Can(ChatUserPermissions perm, bool strict = false) {
|
||||
ChatUserPermissions perms = Permissions & perm;
|
||||
return strict ? perms == perm : perms > 0;
|
||||
}
|
||||
|
||||
public string Pack() {
|
||||
StringBuilder sb = new();
|
||||
|
||||
sb.Append(UserId);
|
||||
sb.Append('\t');
|
||||
sb.Append(LegacyNameWithStatus);
|
||||
sb.Append('\t');
|
||||
sb.Append(Colour);
|
||||
sb.Append('\t');
|
||||
sb.Append(Rank);
|
||||
sb.Append(' ');
|
||||
sb.Append(Can(ChatUserPermissions.KickUser) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(Can(ChatUserPermissions.ViewLogs) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(Can(ChatUserPermissions.SetOwnNickname) ? '1' : '0');
|
||||
sb.Append(' ');
|
||||
sb.Append(Can(ChatUserPermissions.CreateChannel | ChatUserPermissions.SetChannelPermanent, true) ? '2' : (
|
||||
Can(ChatUserPermissions.CreateChannel) ? '1' : '0'
|
||||
));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public bool NameEquals(string name) {
|
||||
return string.Equals(name, UserName, StringComparison.InvariantCultureIgnoreCase)
|
||||
|| string.Equals(name, NickName, StringComparison.InvariantCultureIgnoreCase)
|
||||
|| string.Equals(name, LegacyName, StringComparison.InvariantCultureIgnoreCase)
|
||||
|| string.Equals(name, LegacyNameWithStatus, StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
public override int GetHashCode() {
|
||||
return UserId.GetHashCode();
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) {
|
||||
return Equals(obj as ChatUser);
|
||||
}
|
||||
|
||||
public bool Equals(ChatUser other) {
|
||||
return UserId == other?.UserId;
|
||||
}
|
||||
|
||||
public static string GetDMChannelName(ChatUser user1, ChatUser user2) {
|
||||
return user1.UserId < user2.UserId
|
||||
? $"@{user1.UserId}-{user2.UserId}"
|
||||
: $"@{user2.UserId}-{user1.UserId}";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
using System;
|
||||
|
||||
namespace SharpChat {
|
||||
[Flags]
|
||||
public enum ChatUserPermissions : int {
|
||||
KickUser = 0x00000001,
|
||||
BanUser = 0x00000002,
|
||||
//SilenceUser = 0x00000004,
|
||||
Broadcast = 0x00000008,
|
||||
SetOwnNickname = 0x00000010,
|
||||
SetOthersNickname = 0x00000020,
|
||||
CreateChannel = 0x00000040,
|
||||
DeleteChannel = 0x00010000,
|
||||
SetChannelPermanent = 0x00000080,
|
||||
SetChannelPassword = 0x00000100,
|
||||
SetChannelHierarchy = 0x00000200,
|
||||
JoinAnyChannel = 0x00020000,
|
||||
SendMessage = 0x00000400,
|
||||
DeleteOwnMessage = 0x00000800,
|
||||
DeleteAnyMessage = 0x00001000,
|
||||
EditOwnMessage = 0x00002000,
|
||||
EditAnyMessage = 0x00004000,
|
||||
SeeIPAddress = 0x00008000,
|
||||
ViewLogs = 0x00040000,
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
namespace SharpChat {
|
||||
public enum ChatUserStatus {
|
||||
Online,
|
||||
Away,
|
||||
Offline,
|
||||
}
|
||||
}
|
6
SharpChat/ClientCommand.cs
Normal file
6
SharpChat/ClientCommand.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace SharpChat;
|
||||
|
||||
public interface ClientCommand {
|
||||
bool IsMatch(ClientCommandContext ctx);
|
||||
Task Dispatch(ClientCommandContext ctx);
|
||||
}
|
42
SharpChat/ClientCommandContext.cs
Normal file
42
SharpChat/ClientCommandContext.cs
Normal file
|
@ -0,0 +1,42 @@
|
|||
using Microsoft.Extensions.Logging;
|
||||
using SharpChat.Channels;
|
||||
using SharpChat.Sessions;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat;
|
||||
|
||||
public class ClientCommandContext {
|
||||
public string Name { get; }
|
||||
public string[] Args { get; }
|
||||
public Context Chat { get; }
|
||||
public User User { get; }
|
||||
public Session Session { get; }
|
||||
public SockChatConnection Connection { get; }
|
||||
public Channel Channel { get; }
|
||||
public ILogger Logger => Session.Logger;
|
||||
|
||||
public ClientCommandContext(
|
||||
string text,
|
||||
Context chat,
|
||||
User user,
|
||||
Session session,
|
||||
SockChatConnection connection,
|
||||
Channel channel
|
||||
) {
|
||||
ArgumentNullException.ThrowIfNull(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));
|
||||
Session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
|
||||
string[] parts = text[1..].Split(' ');
|
||||
Name = parts.First().Replace(".", string.Empty);
|
||||
Args = [.. parts.Skip(1)];
|
||||
}
|
||||
|
||||
public bool NameEquals(string name) {
|
||||
return Name.Equals(name, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
26
SharpChat/ClientCommands/AFKClientCommand.cs
Normal file
26
SharpChat/ClientCommands/AFKClientCommand.cs
Normal file
|
@ -0,0 +1,26 @@
|
|||
using SharpChat.Users;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class AFKClientCommand : ClientCommand {
|
||||
private const string DEFAULT = "AFK";
|
||||
public const int MAX_GRAPHEMES = 5;
|
||||
public const int MAX_BYTES = MAX_GRAPHEMES * 10;
|
||||
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("afk");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
string? statusText = ctx.Args.FirstOrDefault();
|
||||
statusText = string.IsNullOrWhiteSpace(statusText) ? DEFAULT : statusText.TruncateIfTooLong(MAX_GRAPHEMES, MAX_BYTES).Trim();
|
||||
|
||||
await ctx.Chat.UpdateUser(
|
||||
ctx.User,
|
||||
status: UserStatus.Away,
|
||||
statusText: statusText
|
||||
);
|
||||
}
|
||||
}
|
33
SharpChat/ClientCommands/ActionClientCommand.cs
Normal file
33
SharpChat/ClientCommands/ActionClientCommand.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using SharpChat.Events;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class ActionClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("action")
|
||||
|| ctx.NameEquals("me");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
if(ctx.Args.Length < 1)
|
||||
return;
|
||||
|
||||
string actionStr = string.Join(' ', ctx.Args);
|
||||
if(string.IsNullOrWhiteSpace(actionStr))
|
||||
return;
|
||||
|
||||
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
|
||||
ctx.Chat.RandomSnowflake.Next(),
|
||||
ctx.Channel.Name,
|
||||
ctx.User.UserId,
|
||||
ctx.User.UserName,
|
||||
ctx.User.Colour,
|
||||
ctx.User.Rank,
|
||||
ctx.User.NickName,
|
||||
ctx.User.Permissions,
|
||||
DateTimeOffset.Now,
|
||||
actionStr,
|
||||
false, true, false
|
||||
));
|
||||
}
|
||||
}
|
31
SharpChat/ClientCommands/BanListClientCommand.cs
Normal file
31
SharpChat/ClientCommands/BanListClientCommand.cs
Normal file
|
@ -0,0 +1,31 @@
|
|||
using SharpChat.Bans;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class BanListClientCommand(BansClient bansClient) : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("bans")
|
||||
|| ctx.NameEquals("banned");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.ViewBanList)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
BanInfo[] banInfos = await bansClient.BanGetList();
|
||||
await ctx.Chat.SendTo(ctx.User, new BanListS2CPacket(
|
||||
msgId,
|
||||
banInfos.Select(bi => new BanListS2CPacket.Entry(bi.Kind, bi.ToString()))
|
||||
));
|
||||
} catch(Exception) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.GENERIC_ERROR, true));
|
||||
}
|
||||
}
|
||||
}
|
35
SharpChat/ClientCommands/BroadcastClientCommand.cs
Normal file
35
SharpChat/ClientCommands/BroadcastClientCommand.cs
Normal file
|
@ -0,0 +1,35 @@
|
|||
using SharpChat.Events;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class BroadcastClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("say")
|
||||
|| ctx.NameEquals("broadcast");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.SendBroadcast)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
|
||||
msgId,
|
||||
string.Empty,
|
||||
ctx.User.UserId,
|
||||
ctx.User.UserName,
|
||||
ctx.User.Colour,
|
||||
ctx.User.Rank,
|
||||
ctx.User.NickName,
|
||||
ctx.User.Permissions,
|
||||
DateTimeOffset.Now,
|
||||
string.Join(' ', ctx.Args),
|
||||
false, false, true
|
||||
));
|
||||
}
|
||||
}
|
59
SharpChat/ClientCommands/CreateChannelClientCommand.cs
Normal file
59
SharpChat/ClientCommands/CreateChannelClientCommand.cs
Normal file
|
@ -0,0 +1,59 @@
|
|||
using SharpChat.Channels;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class CreateChannelClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("create");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.CreateChannel)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
string firstArg = ctx.Args.First();
|
||||
|
||||
bool createChanHasHierarchy;
|
||||
if(ctx.Args.Length < 1 || (createChanHasHierarchy = firstArg.All(char.IsDigit) && ctx.Args.Length < 2)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
int createChanHierarchy = 0;
|
||||
if(createChanHasHierarchy)
|
||||
if(!int.TryParse(firstArg, out createChanHierarchy))
|
||||
createChanHierarchy = 0;
|
||||
|
||||
if(createChanHierarchy > ctx.User.Rank) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.INSUFFICIENT_HIERARCHY));
|
||||
return;
|
||||
}
|
||||
|
||||
string createChanName = string.Join('_', ctx.Args.Skip(createChanHasHierarchy ? 1 : 0));
|
||||
|
||||
try {
|
||||
Channel channel = ctx.Chat.Channels.CreateChannel(
|
||||
createChanName,
|
||||
temporary: !ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPermanent),
|
||||
rank: createChanHierarchy,
|
||||
ownerId: ctx.User.UserId
|
||||
);
|
||||
|
||||
foreach(User ccu in ctx.Chat.Users.GetUsersOfMinimumRank(ctx.Channel.Rank))
|
||||
await ctx.Chat.SendTo(ccu, new ChannelCreateS2CPacket(channel.Name, channel.HasPassword, channel.IsTemporary));
|
||||
|
||||
await ctx.Chat.SwitchChannel(ctx.User, channel, channel.Password);
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_CREATED, false, channel.Name));
|
||||
} catch(ChannelNameFormatException) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NAME_INVALID));
|
||||
} catch(ChannelExistsException) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_ALREADY_EXISTS, true, createChanName));
|
||||
}
|
||||
}
|
||||
}
|
39
SharpChat/ClientCommands/DeleteChannelClientCommand.cs
Normal file
39
SharpChat/ClientCommands/DeleteChannelClientCommand.cs
Normal file
|
@ -0,0 +1,39 @@
|
|||
using SharpChat.Channels;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class DeleteChannelClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("delchan") || (
|
||||
ctx.NameEquals("delete")
|
||||
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == false
|
||||
);
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(ctx.Args.Length < 1 || string.IsNullOrWhiteSpace(ctx.Args.FirstOrDefault())) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
string delChanName = string.Join('_', ctx.Args);
|
||||
Channel? delChan = ctx.Chat.Channels.GetChannel(delChanName);
|
||||
|
||||
if(delChan == null) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, delChanName));
|
||||
return;
|
||||
}
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.DeleteChannel) && delChan.IsOwner(ctx.User.UserId)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_DELETE_FAILED, true, delChan.Name));
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Chat.RemoveChannel(delChan);
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_DELETED, false, delChan.Name));
|
||||
}
|
||||
}
|
41
SharpChat/ClientCommands/DeleteMessageClientCommand.cs
Normal file
41
SharpChat/ClientCommands/DeleteMessageClientCommand.cs
Normal file
|
@ -0,0 +1,41 @@
|
|||
using SharpChat.Messages;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class DeleteMessageClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("delmsg") || (
|
||||
ctx.NameEquals("delete")
|
||||
&& ctx.Args.FirstOrDefault()?.All(char.IsDigit) == true
|
||||
);
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
bool deleteAnyMessage = ctx.User.Permissions.HasFlag(UserPermissions.DeleteAnyMessage);
|
||||
|
||||
if(!deleteAnyMessage && !ctx.User.Permissions.HasFlag(UserPermissions.DeleteOwnMessage)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, 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)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
Message? delMsg = await ctx.Chat.Messages.GetMessage(delSeqId);
|
||||
|
||||
if(delMsg?.SenderId is null || delMsg.SenderRank > ctx.User.Rank || (!deleteAnyMessage && delMsg.SenderId != ctx.User.UserId)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.MESSAGE_DELETE_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Chat.Messages.DeleteMessage(delMsg);
|
||||
await ctx.Chat.Send(new ChatMessageDeleteS2CPacket(delMsg.Id));
|
||||
}
|
||||
}
|
24
SharpChat/ClientCommands/JoinChannelClientCommand.cs
Normal file
24
SharpChat/ClientCommands/JoinChannelClientCommand.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
using SharpChat.Channels;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class JoinChannelClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("join");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
string joinChanStr = ctx.Args.FirstOrDefault() ?? "Channel";
|
||||
Channel? joinChan = ctx.Chat.Channels.GetChannel(joinChanStr);
|
||||
|
||||
if(joinChan is null) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, joinChanStr));
|
||||
await ctx.Chat.ForceChannel(ctx.User);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Chat.SwitchChannel(ctx.User, joinChan, string.Join(' ', ctx.Args.Skip(1)));
|
||||
}
|
||||
}
|
74
SharpChat/ClientCommands/KickBanClientCommand.cs
Normal file
74
SharpChat/ClientCommands/KickBanClientCommand.cs
Normal file
|
@ -0,0 +1,74 @@
|
|||
using SharpChat.Bans;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class KickBanClientCommand(BansClient bansClient) : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("kick")
|
||||
|| ctx.NameEquals("ban");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
bool isBanning = ctx.NameEquals("ban");
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(isBanning ? UserPermissions.BanUser : UserPermissions.KickUser)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
string? banUserTarget = ctx.Args.ElementAtOrDefault(0);
|
||||
string? banDurationStr = ctx.Args.ElementAtOrDefault(1);
|
||||
int banReasonIndex = 1;
|
||||
User? banUser;
|
||||
|
||||
if(banUserTarget == null || (banUser = ctx.Chat.Users.GetUserByLegacyName(banUserTarget)) == null) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, banUserTarget ?? "User"));
|
||||
return;
|
||||
}
|
||||
|
||||
if(banUser.Rank >= ctx.User.Rank && banUser != ctx.User) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.GetLegacyName()));
|
||||
return;
|
||||
}
|
||||
|
||||
TimeSpan duration = isBanning ? TimeSpan.MaxValue : TimeSpan.Zero;
|
||||
if(!string.IsNullOrWhiteSpace(banDurationStr) && double.TryParse(banDurationStr, out double durationSeconds)) {
|
||||
if(durationSeconds < 0) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
duration = TimeSpan.FromSeconds(durationSeconds);
|
||||
++banReasonIndex;
|
||||
}
|
||||
|
||||
if(duration <= TimeSpan.Zero) {
|
||||
await ctx.Chat.BanUser(banUser, duration);
|
||||
return;
|
||||
}
|
||||
|
||||
string banReason = string.Join(' ', ctx.Args.Skip(banReasonIndex));
|
||||
|
||||
BanInfo? banInfo = await bansClient.BanGet(banUser.UserId);
|
||||
if(banInfo is not null) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.KICK_NOT_ALLOWED, true, banUser.GetLegacyName()));
|
||||
return;
|
||||
}
|
||||
|
||||
await bansClient.BanCreate(
|
||||
BanKind.User,
|
||||
duration,
|
||||
ctx.Chat.Sessions.GetRemoteEndPoints(banUser).Select(e => e.Address).FirstOrDefault() ?? IPAddress.None,
|
||||
banUser.UserId,
|
||||
banReason,
|
||||
ctx.Connection.RemoteEndPoint.Address,
|
||||
ctx.User.UserId
|
||||
);
|
||||
|
||||
await ctx.Chat.BanUser(banUser, duration);
|
||||
}
|
||||
}
|
59
SharpChat/ClientCommands/NickClientCommand.cs
Normal file
59
SharpChat/ClientCommands/NickClientCommand.cs
Normal file
|
@ -0,0 +1,59 @@
|
|||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class NickClientCommand : ClientCommand {
|
||||
private const int MAX_GRAPHEMES = 16;
|
||||
private const int MAX_BYTES = MAX_GRAPHEMES * 10;
|
||||
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("nick");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
bool setOthersNick = ctx.User.Permissions.HasFlag(UserPermissions.SetOthersNickname);
|
||||
|
||||
if(!setOthersNick && !ctx.User.Permissions.HasFlag(UserPermissions.SetOwnNickname)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
User? targetUser = null;
|
||||
int offset = 0;
|
||||
|
||||
if(setOthersNick && long.TryParse(ctx.Args.FirstOrDefault(), out long targetUserId) && targetUserId > 0) {
|
||||
targetUser = ctx.Chat.Users.GetUser(targetUserId.ToString());
|
||||
++offset;
|
||||
}
|
||||
|
||||
targetUser ??= ctx.User;
|
||||
|
||||
if(ctx.Args.Length < offset) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, 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();
|
||||
|
||||
nickStr = nickStr == targetUser.UserName
|
||||
? string.Empty
|
||||
: (string.IsNullOrEmpty(nickStr)
|
||||
? string.Empty
|
||||
: nickStr.TruncateIfTooLong(MAX_GRAPHEMES, MAX_BYTES).Trim());
|
||||
|
||||
if(!string.IsNullOrWhiteSpace(nickStr) && ctx.Chat.Users.UserWithLegacyNameExists(nickStr)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.NAME_IN_USE, true, nickStr));
|
||||
return;
|
||||
}
|
||||
|
||||
string? previousName = targetUser.UserId == ctx.User.UserId ? (targetUser.NickName ?? targetUser.UserName) : null;
|
||||
await ctx.Chat.UpdateUser(targetUser, nickName: nickStr, silent: previousName == null);
|
||||
}
|
||||
}
|
41
SharpChat/ClientCommands/PardonAddressClientCommand.cs
Normal file
41
SharpChat/ClientCommands/PardonAddressClientCommand.cs
Normal file
|
@ -0,0 +1,41 @@
|
|||
using SharpChat.Bans;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class PardonAddressClientCommand(BansClient bansClient) : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("pardonip")
|
||||
|| ctx.NameEquals("unbanip");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.PardonIPAddress)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
string? unbanAddrTarget = ctx.Args.FirstOrDefault();
|
||||
if(string.IsNullOrWhiteSpace(unbanAddrTarget) || !IPAddress.TryParse(unbanAddrTarget, out IPAddress? unbanAddr)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
unbanAddrTarget = unbanAddr.ToString();
|
||||
|
||||
BanInfo? banInfo = await bansClient.BanGet(remoteAddr: unbanAddr);
|
||||
if(banInfo?.Kind != BanKind.IPAddress) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));
|
||||
return;
|
||||
}
|
||||
|
||||
if(await bansClient.BanRevoke(banInfo))
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanAddrTarget));
|
||||
else
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanAddrTarget));
|
||||
}
|
||||
}
|
47
SharpChat/ClientCommands/PardonUserClientCommand.cs
Normal file
47
SharpChat/ClientCommands/PardonUserClientCommand.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
using SharpChat.Bans;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class PardonUserClientCommand(BansClient bansClient) : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("pardon")
|
||||
|| ctx.NameEquals("unban");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.PardonUser)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
string? unbanUserTarget = ctx.Args.FirstOrDefault();
|
||||
if(string.IsNullOrWhiteSpace(unbanUserTarget)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
string unbanUserDisplay = unbanUserTarget;
|
||||
User? unbanUser = ctx.Chat.Users.GetUserByLegacyName(unbanUserTarget);
|
||||
if(unbanUser == null && long.TryParse(unbanUserTarget, out long unbanUserId))
|
||||
unbanUser = ctx.Chat.Users.GetUser(unbanUserId.ToString());
|
||||
if(unbanUser != null) {
|
||||
unbanUserTarget = unbanUser.UserId;
|
||||
unbanUserDisplay = unbanUser.UserName;
|
||||
}
|
||||
|
||||
BanInfo? banInfo = await bansClient.BanGet(unbanUserTarget);
|
||||
if(banInfo?.Kind != BanKind.User) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
|
||||
return;
|
||||
}
|
||||
|
||||
if(await bansClient.BanRevoke(banInfo))
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_UNBANNED, false, unbanUserDisplay));
|
||||
else
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_BANNED, true, unbanUserDisplay));
|
||||
}
|
||||
}
|
28
SharpChat/ClientCommands/PasswordChannelClientCommand.cs
Normal file
28
SharpChat/ClientCommands/PasswordChannelClientCommand.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class PasswordChannelClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("pwd")
|
||||
|| ctx.NameEquals("password");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.SetChannelPassword) || ctx.Channel.IsOwner(ctx.User.UserId)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
string chanPass = string.Join(' ', ctx.Args).Trim();
|
||||
|
||||
if(string.IsNullOrWhiteSpace(chanPass))
|
||||
chanPass = string.Empty;
|
||||
|
||||
await ctx.Chat.UpdateChannel(ctx.Channel, password: chanPass);
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_PASSWORD_CHANGED, false));
|
||||
}
|
||||
}
|
29
SharpChat/ClientCommands/RankChannelClientCommand.cs
Normal file
29
SharpChat/ClientCommands/RankChannelClientCommand.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class RankChannelClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("rank")
|
||||
|| ctx.NameEquals("privilege")
|
||||
|| ctx.NameEquals("priv");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.SetChannelMinimumRank) || ctx.Channel.IsOwner(ctx.User.UserId)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
if(ctx.Args.Length < 1 || !int.TryParse(ctx.Args.First(), out int chanHierarchy) || chanHierarchy > ctx.User.Rank) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.INSUFFICIENT_HIERARCHY));
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.Chat.UpdateChannel(ctx.Channel, rank: chanHierarchy);
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_HIERARCHY_CHANGED, false));
|
||||
}
|
||||
}
|
32
SharpChat/ClientCommands/RemoteAddressClientCommand.cs
Normal file
32
SharpChat/ClientCommands/RemoteAddressClientCommand.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
using System.Net;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class RemoteAddressClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("ip")
|
||||
|| ctx.NameEquals("whois");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(!ctx.User.Permissions.HasFlag(UserPermissions.ViewIPAddress)) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, "/ip"));
|
||||
return;
|
||||
}
|
||||
|
||||
string? ipUserStr = ctx.Args.FirstOrDefault();
|
||||
User? ipUser;
|
||||
|
||||
if(string.IsNullOrWhiteSpace(ipUserStr) || (ipUser = ctx.Chat.Users.GetUserByLegacyName(ipUserStr)) == null) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, ipUserStr ?? "User"));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach(IPEndPoint ep in ctx.Chat.Sessions.GetRemoteEndPoints(ipUser))
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.IP_ADDRESS, false, ipUser.UserName, ep.Address));
|
||||
}
|
||||
}
|
29
SharpChat/ClientCommands/ShutdownRestartClientCommand.cs
Normal file
29
SharpChat/ClientCommands/ShutdownRestartClientCommand.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using SharpChat.SockChat.S2CPackets;
|
||||
using ZLogger;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class ShutdownRestartClientCommand(SockChatServer server, CancellationTokenSource cancellationTokenSource) : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("shutdown")
|
||||
|| ctx.NameEquals("restart");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
if(!ctx.User.UserId.Equals("1")) {
|
||||
ctx.Logger.ZLogInformation($"{ctx.User.UserId}/{ctx.User.UserName} tried to issue /shutdown or /restart");
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_NOT_ALLOWED, true, $"/{ctx.Name}"));
|
||||
return;
|
||||
}
|
||||
|
||||
if(cancellationTokenSource.IsCancellationRequested)
|
||||
return;
|
||||
|
||||
server.IsRestarting = ctx.NameEquals("restart");
|
||||
ctx.Logger.ZLogInformation($"{(server.IsRestarting ? "Restart" : "Shutdown")} requested through Sock Chat command...");
|
||||
|
||||
await ctx.Chat.Update();
|
||||
await cancellationTokenSource.CancelAsync();
|
||||
}
|
||||
}
|
46
SharpChat/ClientCommands/WhisperClientCommand.cs
Normal file
46
SharpChat/ClientCommands/WhisperClientCommand.cs
Normal file
|
@ -0,0 +1,46 @@
|
|||
using SharpChat.Events;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class WhisperClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("whisper")
|
||||
|| ctx.NameEquals("msg");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
|
||||
if(ctx.Args.Length < 2) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.COMMAND_FORMAT_ERROR));
|
||||
return;
|
||||
}
|
||||
|
||||
string whisperUserStr = ctx.Args.FirstOrDefault() ?? string.Empty;
|
||||
User? whisperUser = ctx.Chat.Users.GetUserByLegacyName(whisperUserStr);
|
||||
|
||||
if(whisperUser == null) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USER_NOT_FOUND, true, whisperUserStr));
|
||||
return;
|
||||
}
|
||||
|
||||
if(whisperUser == ctx.User)
|
||||
return;
|
||||
|
||||
await ctx.Chat.DispatchEvent(new MessageCreateEvent(
|
||||
msgId,
|
||||
ctx.User.GetDMChannelNameWith(whisperUser),
|
||||
ctx.User.UserId,
|
||||
ctx.User.UserName,
|
||||
ctx.User.Colour,
|
||||
ctx.User.Rank,
|
||||
ctx.User.NickName,
|
||||
ctx.User.Permissions,
|
||||
DateTimeOffset.Now,
|
||||
string.Join(' ', ctx.Args.Skip(1)),
|
||||
true, false, false
|
||||
));
|
||||
}
|
||||
}
|
64
SharpChat/ClientCommands/WhoClientCommand.cs
Normal file
64
SharpChat/ClientCommands/WhoClientCommand.cs
Normal file
|
@ -0,0 +1,64 @@
|
|||
using SharpChat.Channels;
|
||||
using SharpChat.SockChat.S2CPackets;
|
||||
using SharpChat.Users;
|
||||
using System.Text;
|
||||
|
||||
namespace SharpChat.ClientCommands;
|
||||
|
||||
public class WhoClientCommand : ClientCommand {
|
||||
public bool IsMatch(ClientCommandContext ctx) {
|
||||
return ctx.NameEquals("who");
|
||||
}
|
||||
|
||||
public async Task Dispatch(ClientCommandContext ctx) {
|
||||
long msgId = ctx.Chat.RandomSnowflake.Next();
|
||||
StringBuilder whoChanSB = new();
|
||||
string? whoChanStr = ctx.Args.FirstOrDefault();
|
||||
|
||||
if(string.IsNullOrEmpty(whoChanStr)) {
|
||||
foreach(User whoUser in ctx.Chat.Users.GetUsers()) {
|
||||
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.GetLegacyNameWithStatus());
|
||||
whoChanSB.Append("</a>, ");
|
||||
}
|
||||
|
||||
if(whoChanSB.Length > 2)
|
||||
whoChanSB.Length -= 2;
|
||||
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_SERVER, false, whoChanSB));
|
||||
} else {
|
||||
Channel? whoChan = ctx.Chat.Channels.GetChannel(whoChanStr);
|
||||
|
||||
if(whoChan is null) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.CHANNEL_NOT_FOUND, true, whoChanStr));
|
||||
return;
|
||||
}
|
||||
|
||||
if(whoChan.Rank > ctx.User.Rank || (whoChan.HasPassword && !ctx.User.Permissions.HasFlag(UserPermissions.JoinAnyChannel))) {
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_ERROR, true, whoChanStr));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach(User whoUser in ctx.Chat.ChannelsUsers.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.GetLegacyNameWithStatus());
|
||||
whoChanSB.Append("</a>, ");
|
||||
}
|
||||
|
||||
if(whoChanSB.Length > 2)
|
||||
whoChanSB.Length -= 2;
|
||||
|
||||
await ctx.Chat.SendTo(ctx.User, new CommandResponseS2CPacket(msgId, LCR.USERS_LISTING_CHANNEL, false, whoChan.Name, whoChanSB));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
using SharpChat.Packet;
|
||||
using System.Linq;
|
||||
|
||||
namespace SharpChat.Commands {
|
||||
public class AFKCommand : IChatCommand {
|
||||
private const string DEFAULT = "AFK";
|
||||
private const int MAX_LENGTH = 5;
|
||||
|
||||
public bool IsMatch(ChatCommandContext ctx) {
|
||||
return ctx.NameEquals("afk");
|
||||
}
|
||||
|
||||
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[..MAX_LENGTH].Trim();
|
||||
}
|
||||
|
||||
ctx.Chat.UpdateUser(
|
||||
ctx.User,
|
||||
status: ChatUserStatus.Away,
|
||||
statusText: statusText
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,41 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
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)));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue