mirror of
https://github.com/stoatchat/python-client-sdk.git
synced 2026-07-21 01:55:23 -04:00
split up types into its own folder
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payloads import File as FilePayload
|
||||
from .types import File as FilePayload
|
||||
from .state import State
|
||||
from io import IOBase
|
||||
class Asset:
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ from .messageable import Messageable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payloads import Channel as ChannelPayload
|
||||
from .types import Channel as ChannelPayload
|
||||
from .state import State
|
||||
|
||||
class Channel:
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ except ImportError:
|
||||
import json
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payloads import ApiInfo
|
||||
from .types import ApiInfo
|
||||
from .user import User
|
||||
from .server import Server
|
||||
from .channel import Channel
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payloads import Embed as EmbedPayload
|
||||
from .types import Embed as EmbedPayload
|
||||
|
||||
class Embed:
|
||||
@classmethod
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ except ImportError:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
from .payloads import ApiInfo, Autumn as AutumnPayload, Message as MessagePayload, Embed as EmbedPayload
|
||||
from .types import ApiInfo, Autumn as AutumnPayload, Message as MessagePayload, Embed as EmbedPayload, GetServerMembers
|
||||
from .file import File
|
||||
|
||||
class HttpClient:
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .state import State
|
||||
from .payloads import Member as MemberPayload
|
||||
from .types import Member as MemberPayload
|
||||
from .server import Server
|
||||
|
||||
class Member:
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ from .channel import TextChannel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .state import State
|
||||
from .payloads import Message as MessagePayload
|
||||
from .types import Message as MessagePayload
|
||||
|
||||
class Message:
|
||||
def __init__(self, data: MessagePayload, state: State):
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
from typing import Literal, Type, TypedDict, Union
|
||||
|
||||
class ApiFeature(TypedDict):
|
||||
enabled: bool
|
||||
url: str
|
||||
|
||||
class VosoFeature(ApiFeature):
|
||||
ws: str
|
||||
|
||||
class Features(TypedDict):
|
||||
email: bool
|
||||
invite_only: bool
|
||||
captcha: ApiFeature
|
||||
autumn: ApiFeature
|
||||
january: ApiFeature
|
||||
voso: VosoFeature
|
||||
|
||||
class ApiInfo(TypedDict):
|
||||
revolt: str
|
||||
features: Features
|
||||
ws: str
|
||||
app: str
|
||||
vapid: str
|
||||
|
||||
class BasePayload(TypedDict):
|
||||
type: str
|
||||
|
||||
class AuthenticatePayload(BasePayload):
|
||||
token: str
|
||||
|
||||
class UserBot(TypedDict):
|
||||
owner: str
|
||||
|
||||
class SizedMetadata(TypedDict):
|
||||
type: Literal["Image", "Video"]
|
||||
height: int
|
||||
width: int
|
||||
|
||||
class SimpleMetadata(TypedDict):
|
||||
type: Literal["File", "Text", "Audio"]
|
||||
|
||||
FileMetadata = Union[SizedMetadata, SimpleMetadata]
|
||||
|
||||
class File(TypedDict):
|
||||
_id: str
|
||||
tag: str
|
||||
size: int
|
||||
filename: str
|
||||
metadata: FileMetadata
|
||||
content_type: str
|
||||
|
||||
class Status(TypedDict, total=False):
|
||||
text: str
|
||||
presence: Literal["Busy", "Idle", "Invisible", "Online"]
|
||||
|
||||
class _OptionalUser(TypedDict, total=False):
|
||||
avatar: File
|
||||
relations: list
|
||||
badges: int
|
||||
status: Status
|
||||
relationship: Literal["Blocked", "BlockedOther", "Friend", "Incoming", "None", "Outgoing", "User"]
|
||||
online: bool
|
||||
flags: int
|
||||
bot: UserBot
|
||||
|
||||
class User(_OptionalUser):
|
||||
_id: str
|
||||
username: str
|
||||
|
||||
class Server(TypedDict):
|
||||
_id: str
|
||||
|
||||
class UserAddContent(TypedDict):
|
||||
id: str
|
||||
by: str
|
||||
|
||||
class UserRemoveContent(TypedDict):
|
||||
id: str
|
||||
by: str
|
||||
|
||||
class UserJoinedContent(TypedDict):
|
||||
id: str
|
||||
by: str
|
||||
|
||||
class UserLeftContent(TypedDict):
|
||||
id: str
|
||||
|
||||
class UserKickedContent(TypedDict):
|
||||
id: str
|
||||
|
||||
class UserBanned(TypedDict):
|
||||
id: str
|
||||
|
||||
class ChannelRenameContent(TypedDict):
|
||||
name: str
|
||||
by: str
|
||||
|
||||
class ChannelDescriptionChangeContent(TypedDict):
|
||||
by: str
|
||||
|
||||
class ChannelIconChanged(TypedDict):
|
||||
by: str
|
||||
|
||||
class Embed(TypedDict):
|
||||
pass # TODO
|
||||
class _OptionalMessage(TypedDict):
|
||||
attachments: list[File]
|
||||
embeds: list[Embed]
|
||||
|
||||
class Message(_OptionalMessage):
|
||||
_id: str
|
||||
channel: str
|
||||
author: str
|
||||
content: Union[str, UserAddContent, UserRemoveContent, UserJoinedContent, UserLeftContent, UserKickedContent, UserBanned, ChannelRenameContent, ChannelDescriptionChangeContent, ChannelIconChanged]
|
||||
|
||||
class _NonceChannel(TypedDict, total=False):
|
||||
nonce: str
|
||||
|
||||
class BaseChannel(TypedDict):
|
||||
_id: str
|
||||
|
||||
class SavedMessages(_NonceChannel, BaseChannel):
|
||||
user: str
|
||||
channel_type: Literal["SavedMessage"]
|
||||
|
||||
class DirectMessage(_NonceChannel, BaseChannel):
|
||||
active: bool
|
||||
recipients: list[str]
|
||||
last_message: Message
|
||||
channel_type: Literal["DirectMessage"]
|
||||
|
||||
class _GroupOptional(TypedDict):
|
||||
icon: File
|
||||
permissions: int
|
||||
description: str
|
||||
|
||||
class Group(_NonceChannel, _GroupOptional, BaseChannel):
|
||||
recipients: list[str]
|
||||
name: str
|
||||
owner: str
|
||||
channel_type: Literal["Group"]
|
||||
|
||||
class _TextChannelOptional(TypedDict, total=False):
|
||||
icon: File
|
||||
default_permissions: int
|
||||
role_permissions: int
|
||||
|
||||
class TextChannel(_NonceChannel, _TextChannelOptional, BaseChannel):
|
||||
server: str
|
||||
name: str
|
||||
description: str
|
||||
last_message: Message
|
||||
channel_type: Literal["TextChannel"]
|
||||
|
||||
class _VoiceChannelOptional(TypedDict, total=False):
|
||||
icon: File
|
||||
default_permissions: int
|
||||
role_permissions: int
|
||||
|
||||
class VoiceChannel(_NonceChannel, _TextChannelOptional, BaseChannel):
|
||||
server: str
|
||||
name: str
|
||||
description: str
|
||||
channel_type: Literal["VoiceChannel"]
|
||||
|
||||
Channel = Union[SavedMessages, DirectMessage, Group, TextChannel, VoiceChannel]
|
||||
|
||||
class _MemberOptional(TypedDict, total=False):
|
||||
nickname: str
|
||||
avatar: File
|
||||
roles: list[str]
|
||||
|
||||
class Member(_MemberOptional):
|
||||
_id: str
|
||||
|
||||
class ReadyMemberUser(TypedDict):
|
||||
server: str
|
||||
user: str
|
||||
|
||||
class ReadyMember(_MemberOptional):
|
||||
_id: ReadyMemberUser
|
||||
|
||||
class ReadyEventPayload(BasePayload):
|
||||
users: list[User]
|
||||
servers: list[Server]
|
||||
channels: list[Channel]
|
||||
members: list[ReadyMember]
|
||||
|
||||
class MessageEventPayload(BasePayload, Message):
|
||||
pass
|
||||
|
||||
class Autumn(TypedDict):
|
||||
id: str
|
||||
|
||||
Permission = tuple[int, int]
|
||||
|
||||
class _RoleOptional(TypedDict, total=False):
|
||||
colour: str
|
||||
hoist: bool
|
||||
rank: int
|
||||
|
||||
class Role(_RoleOptional):
|
||||
name: str
|
||||
permissions: Permission
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payloads import Permission as PermissionTuple
|
||||
from .types import Permission as PermissionTuple
|
||||
|
||||
class Permissions:
|
||||
def __init__(self, permission_tuple: PermissionTuple):
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .state import State
|
||||
from .payloads import Role as RolePayload
|
||||
from .types import Role as RolePayload
|
||||
|
||||
class Role:
|
||||
def __init__(self, data: RolePayload, role_id: str, state: State):
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .payloads import Server as ServerPayload
|
||||
from .types import Server as ServerPayload
|
||||
from .state import State
|
||||
from .member import Member
|
||||
from .role import Role
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ from .member import Member
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .http import HttpClient
|
||||
from .payloads import ApiInfo, User as UserPayload, Channel as ChannelPayload, Server as ServerPayload, Message as MessagePayload, Member as MemberPayload
|
||||
from .types import ApiInfo, User as UserPayload, Channel as ChannelPayload, Server as ServerPayload, Message as MessagePayload, Member as MemberPayload
|
||||
|
||||
class State:
|
||||
def __init__(self, http: HttpClient, api_info: ApiInfo, max_messages: int):
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from .channel import *
|
||||
from .embed import *
|
||||
from .file import *
|
||||
from .gateway import *
|
||||
from .http import *
|
||||
from .member import *
|
||||
from .message import *
|
||||
from .role import *
|
||||
from .server import *
|
||||
from .user import *
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict, Literal, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .message import Message
|
||||
from .file import File
|
||||
|
||||
class _NonceChannel(TypedDict, total=False):
|
||||
nonce: str
|
||||
|
||||
class BaseChannel(TypedDict):
|
||||
_id: str
|
||||
|
||||
class SavedMessages(_NonceChannel, BaseChannel):
|
||||
user: str
|
||||
channel_type: Literal["SavedMessage"]
|
||||
|
||||
class DirectMessage(_NonceChannel, BaseChannel):
|
||||
active: bool
|
||||
recipients: list[str]
|
||||
last_message: Message
|
||||
channel_type: Literal["DirectMessage"]
|
||||
|
||||
class _GroupOptional(TypedDict):
|
||||
icon: File
|
||||
permissions: int
|
||||
description: str
|
||||
|
||||
class Group(_NonceChannel, _GroupOptional, BaseChannel):
|
||||
recipients: list[str]
|
||||
name: str
|
||||
owner: str
|
||||
channel_type: Literal["Group"]
|
||||
|
||||
class _TextChannelOptional(TypedDict, total=False):
|
||||
icon: File
|
||||
default_permissions: int
|
||||
role_permissions: int
|
||||
|
||||
class TextChannel(_NonceChannel, _TextChannelOptional, BaseChannel):
|
||||
server: str
|
||||
name: str
|
||||
description: str
|
||||
last_message: Message
|
||||
channel_type: Literal["TextChannel"]
|
||||
|
||||
class _VoiceChannelOptional(TypedDict, total=False):
|
||||
icon: File
|
||||
default_permissions: int
|
||||
role_permissions: int
|
||||
|
||||
class VoiceChannel(_NonceChannel, _TextChannelOptional, BaseChannel):
|
||||
server: str
|
||||
name: str
|
||||
description: str
|
||||
channel_type: Literal["VoiceChannel"]
|
||||
|
||||
Channel = Union[SavedMessages, DirectMessage, Group, TextChannel, VoiceChannel]
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
class Embed(TypedDict):
|
||||
pass # TODO
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict, Literal, Union
|
||||
|
||||
class SizedMetadata(TypedDict):
|
||||
type: Literal["Image", "Video"]
|
||||
height: int
|
||||
width: int
|
||||
|
||||
class SimpleMetadata(TypedDict):
|
||||
type: Literal["File", "Text", "Audio"]
|
||||
|
||||
FileMetadata = Union[SizedMetadata, SimpleMetadata]
|
||||
|
||||
class File(TypedDict):
|
||||
_id: str
|
||||
tag: str
|
||||
size: int
|
||||
filename: str
|
||||
metadata: FileMetadata
|
||||
content_type: str
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
from .message import Message
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
from .server import Server
|
||||
from .channel import Channel
|
||||
from .member import Member
|
||||
|
||||
class BasePayload(TypedDict):
|
||||
type: str
|
||||
|
||||
class AuthenticatePayload(BasePayload):
|
||||
token: str
|
||||
|
||||
class ReadyEventPayload(BasePayload):
|
||||
users: list[User]
|
||||
servers: list[Server]
|
||||
channels: list[Channel]
|
||||
members: list[Member]
|
||||
|
||||
class MessageEventPayload(BasePayload, Message):
|
||||
pass
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
from .member import Member
|
||||
|
||||
class ApiFeature(TypedDict):
|
||||
enabled: bool
|
||||
url: str
|
||||
|
||||
class VosoFeature(ApiFeature):
|
||||
ws: str
|
||||
|
||||
class Features(TypedDict):
|
||||
email: bool
|
||||
invite_only: bool
|
||||
captcha: ApiFeature
|
||||
autumn: ApiFeature
|
||||
january: ApiFeature
|
||||
voso: VosoFeature
|
||||
|
||||
class ApiInfo(TypedDict):
|
||||
revolt: str
|
||||
features: Features
|
||||
ws: str
|
||||
app: str
|
||||
vapid: str
|
||||
|
||||
class Autumn(TypedDict):
|
||||
id: str
|
||||
|
||||
class GetServerMembers(TypedDict):
|
||||
members: list[Member]
|
||||
users: list[User]
|
||||
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .file import File
|
||||
|
||||
class _MemberOptional(TypedDict, total=False):
|
||||
nickname: str
|
||||
avatar: File
|
||||
roles: list[str]
|
||||
|
||||
class MemberID(TypedDict):
|
||||
server: str
|
||||
user: str
|
||||
|
||||
class Member(_MemberOptional):
|
||||
_id: MemberID
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .file import File
|
||||
from .embed import Embed
|
||||
|
||||
class UserAddContent(TypedDict):
|
||||
id: str
|
||||
by: str
|
||||
|
||||
class UserRemoveContent(TypedDict):
|
||||
id: str
|
||||
by: str
|
||||
|
||||
class UserJoinedContent(TypedDict):
|
||||
id: str
|
||||
by: str
|
||||
|
||||
class UserLeftContent(TypedDict):
|
||||
id: str
|
||||
|
||||
class UserKickedContent(TypedDict):
|
||||
id: str
|
||||
|
||||
class UserBanned(TypedDict):
|
||||
id: str
|
||||
|
||||
class ChannelRenameContent(TypedDict):
|
||||
name: str
|
||||
by: str
|
||||
|
||||
class ChannelDescriptionChangeContent(TypedDict):
|
||||
by: str
|
||||
|
||||
class ChannelIconChanged(TypedDict):
|
||||
by: str
|
||||
|
||||
class _OptionalMessage(TypedDict):
|
||||
attachments: list[File]
|
||||
embeds: list[Embed]
|
||||
|
||||
class Message(_OptionalMessage):
|
||||
_id: str
|
||||
channel: str
|
||||
author: str
|
||||
content: Union[str, UserAddContent, UserRemoveContent, UserJoinedContent, UserLeftContent, UserKickedContent, UserBanned, ChannelRenameContent, ChannelDescriptionChangeContent, ChannelIconChanged]
|
||||
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
Permission = tuple[int, int]
|
||||
|
||||
class _RoleOptional(TypedDict, total=False):
|
||||
colour: str
|
||||
hoist: bool
|
||||
rank: int
|
||||
|
||||
class Role(_RoleOptional):
|
||||
name: str
|
||||
permissions: Permission
|
||||
@@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
class Server(TypedDict):
|
||||
_id: str
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, TypedDict, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .file import File
|
||||
|
||||
class UserBot(TypedDict):
|
||||
owner: str
|
||||
|
||||
class Status(TypedDict, total=False):
|
||||
text: str
|
||||
presence: Literal["Busy", "Idle", "Invisible", "Online"]
|
||||
|
||||
class _OptionalUser(TypedDict, total=False):
|
||||
avatar: File
|
||||
relations: list
|
||||
badges: int
|
||||
status: Status
|
||||
relationship: Literal["Blocked", "BlockedOther", "Friend", "Incoming", "None", "Outgoing", "User"]
|
||||
online: bool
|
||||
flags: int
|
||||
bot: UserBot
|
||||
|
||||
class User(_OptionalUser):
|
||||
_id: str
|
||||
username: str
|
||||
+1
-1
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .state import State
|
||||
from .payloads import User as UserPayload
|
||||
from .types import User as UserPayload
|
||||
|
||||
class User:
|
||||
def __init__(self, data: UserPayload, state: State):
|
||||
|
||||
+28
-9
@@ -4,16 +4,22 @@ from typing import Callable, TYPE_CHECKING, cast
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from .payloads import Message as MessagePayload
|
||||
from .types import Message as MessagePayload
|
||||
|
||||
try:
|
||||
import ujson as json
|
||||
except ImportError:
|
||||
import json
|
||||
|
||||
try:
|
||||
import msgpack
|
||||
use_msgpack = True
|
||||
except ImportError:
|
||||
use_msgpack = False
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import aiohttp
|
||||
from .payloads import BasePayload, AuthenticatePayload, ReadyEventPayload, MessageEventPayload, Member as MemberPayload
|
||||
from .types import BasePayload, AuthenticatePayload, ReadyEventPayload, MessageEventPayload, Member as MemberPayload
|
||||
from .state import State
|
||||
|
||||
logger = logging.getLogger("revolt")
|
||||
@@ -28,10 +34,14 @@ class WebsocketHandler:
|
||||
self.websocket: aiohttp.ClientWebSocketResponse
|
||||
|
||||
async def send_payload(self, payload: BasePayload):
|
||||
await self.websocket.send_str(json.dumps(payload))
|
||||
if use_msgpack:
|
||||
await self.websocket.send_bytes(msgpack.packb(payload)) # type: ignore
|
||||
else:
|
||||
await self.websocket.send_str(json.dumps(payload))
|
||||
|
||||
async def heartbeat(self):
|
||||
while not self.websocket.closed:
|
||||
logger.info("Sending hearbeat")
|
||||
await self.send_payload({"type": "Ping"})
|
||||
await asyncio.sleep(15)
|
||||
|
||||
@@ -45,7 +55,7 @@ class WebsocketHandler:
|
||||
|
||||
async def handle_event(self, payload: BasePayload):
|
||||
event_type = payload["type"].lower()
|
||||
|
||||
logger.debug("Recieved event %s %s", event_type, payload)
|
||||
try:
|
||||
func = getattr(self, f"handle_{event_type}")
|
||||
except:
|
||||
@@ -68,9 +78,9 @@ class WebsocketHandler:
|
||||
self.state.add_channel(channel)
|
||||
|
||||
for member in payload["members"]:
|
||||
server_id = member["_id"]["server"]
|
||||
member_payload: MemberPayload = member | {"_id": member["_id"]["user"]} # type: ignore
|
||||
self.state.add_member(server_id, member_payload)
|
||||
self.state.add_member(member["_id"]["server"], member)
|
||||
|
||||
await self.state.fetch_all_server_members()
|
||||
|
||||
self.dispatch("ready")
|
||||
|
||||
@@ -79,10 +89,19 @@ class WebsocketHandler:
|
||||
self.dispatch("message", message)
|
||||
|
||||
async def start(self):
|
||||
self.websocket = await self.session.ws_connect(self.ws_url)
|
||||
if use_msgpack:
|
||||
url = f"{self.ws_url}?format=msgpack"
|
||||
else:
|
||||
url = f"{self.ws_url}?format=json"
|
||||
|
||||
self.websocket = await self.session.ws_connect(url)
|
||||
await self.send_authenticate()
|
||||
asyncio.create_task(self.heartbeat())
|
||||
|
||||
async for msg in self.websocket:
|
||||
await self.handle_event(json.loads(msg.data))
|
||||
if use_msgpack:
|
||||
payload = msgpack.unpackb(msg.data)
|
||||
else:
|
||||
payload = json.loads(msg.data)
|
||||
|
||||
await self.handle_event(payload)
|
||||
|
||||
Reference in New Issue
Block a user