diff --git a/.gitignore b/.gitignore index 3f1c117..6ea918a 100644 --- a/.gitignore +++ b/.gitignore @@ -162,4 +162,8 @@ cython_debug/ #.idea/ /local -/local*.py \ No newline at end of file +/local*.py +/local*.json + +# ruff +.ruff_cache/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f27507d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +## Contributing to pyvolt + +First off, thanks for taking the time to contribute. It makes the library muuuuch better. + +The following is a set of guidelines for contributing to the repository. These are guidelines, not hard rules. + +## Submitting a Pull Request + +Submitting a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. It would be incredibly lovely if the style is consistent to that found in the project. This project follows PEP-8 guidelines (mostly) with a column limit of 125. + +### Git Commit Guidelines + +- Use present tense (e.g. "Add feature" not "Added feature") +- Limit all lines to 72 characters or less. +- Reference issues or pull requests outside of the first line. + - Please use the shorthand `#123` and not the full URL. +- Commits regarding the commands extension must be prefixed with `[commands]` + +If you do not meet any of these guidelines, don't fret. Chances are they will be fixed upon rebasing but please do try to meet them to remove some of the workload. + +### Code Style + +Use following order in models: +```py +@define(slots=True) +class Foo(Base): + # ... attrs properties + # ... getters (``get_x``) + # ... dunder methods (eq, hash, str), in alphabet order + # ... internal methods (`_update`, `_react`) + # ... properties + # ... async methods, in alphabet order + # ... methods +``` \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 6fb4b7a..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# pyvolt - -Revolt library \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..af2a54a --- /dev/null +++ b/README.rst @@ -0,0 +1,50 @@ +pyvolt +====== + +A simple, flexible API wrapper for Revolt. + +.. warning:: + This is alpha software. Please report bugs on GitHub issues if you will find any. + +Key Features +------------- + +- Built on ``asyncio``. +- Probably handles ratelimiting. +- Fast. Really faster than Revolt.py and voltage. +- Low memory usage. +- Customizable architecture. Build object parser in Rust to achieve high speeds. +- Focuses on supporting both, bot and user accounts. + +Quick Example +-------------- + +.. code:: py + + from pyvolt import Client, ReadyEvent, MessageCreateEvent + + class MyClient(Client): + @Client.listens_on(ReadyEvent) + async def on_ready(self, _): + print('Logged on as', self.me) + + @Client.listens_on(MessageCreateEvent) + async def on_message(self, event): + message = event.message + # don't respond to ourselves + if message.author_id == self.me.id: + return + + if message.content == 'ping': + await message.channel.send('pong') + + # You can pass ``bot=False`` to run as user account + client = MyClient(token='token') + client.run() + +Links +------ + +- `Documentation `_ +- `Official Revolt Server `_ +- `Revolt API `_ \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..7512e16 --- /dev/null +++ b/TODO.md @@ -0,0 +1,30 @@ +# TODO + +* TODO: events + + Source: https://github.com/MCausc78/pyvolt/blob/ready/pyvolt/cache.py#L149C7-L149C19 + +* TODO: Remove when backend will tell us to update all channels. (ServerUpdate event) + + Source: https://github.com/MCausc78/pyvolt/blob/ready/pyvolt/events.py#L234C11-L234C94 + +* TODO: What we should do in case of mentions? This solution sucks. + + Source: https://github.com/MCausc78/pyvolt/blob/ready/pyvolt/events.py#L321C15-L321C80 + +* TODO: Handle 10053? + + Source: https://github.com/MCausc78/pyvolt/blob/ready/pyvolt/http.py#L285C19-L285C38 + +* TODO: Remove when Revolt will fix this + + Source: https://github.com/MCausc78/pyvolt/blob/ready/pyvolt/http.py#L408C11-L408C49 + +* TODO: Remove when Revolt will fix this + + Source: https://github.com/MCausc78/pyvolt/blob/ready/pyvolt/http.py#L484C11-L484C49 + +* TODO: Handle 10053? + + Source: https://github.com/MCausc78/pyvolt/blob/ready/pyvolt/shard.py#L353C19-L353C38 + diff --git a/bench/__main__.py b/bench/__main__.py new file mode 100644 index 0000000..82991fa --- /dev/null +++ b/bench/__main__.py @@ -0,0 +1,32 @@ +import asyncio + +from .bench_channel import bench_dm_channels, bench_group_channels, bench_text_channels +from .bench_member import bench_members +from .bench_message import bench_messages +from .bench_server import bench_servers +from .bench_user import bench_users + +async def main(): + print('Benchmarking DMChannel parsing.') + await bench_dm_channels() + + print('Benchmarking GroupChannel parsing.') + await bench_group_channels() + + print('Benchmarking TextChannel parsing.') + await bench_text_channels() + + print('Benchmarking Member parsing.') + await bench_members() + + print('Benchmarking Message parsing.') + await bench_messages() + + print('Benchmarking Server parsing.') + await bench_servers() + + print('Benchmarking User parsing.') + await bench_users() + + +asyncio.run(main()) diff --git a/bench/bench_channel.py b/bench/bench_channel.py new file mode 100644 index 0000000..63a32fc --- /dev/null +++ b/bench/bench_channel.py @@ -0,0 +1,172 @@ +import asyncio + +import json +import pyvolt + +import revolt +import revolt.http +import revolt.state + +import timeit +import typing +import voltage +import voltage.internals + +async def bench_dm_channels(): + with open('./tests/data/channels/dm.json', 'r') as fp: + payload = json.load(fp) + + state = pyvolt.State() + parser = pyvolt.Parser(state) + state.setup(parser=parser) + + def using_pyvolt(): + return parser.parse_direct_message_channel(payload) + + import aiohttp + session = aiohttp.ClientSession() + + api_info: typing.Any = { + 'features': { + 'autumn': { + 'url': 'https://autumn.revolt.chat/' + } + } + } + rpy_http = revolt.http.HttpClient( + session, + '', + 'https://api.revolt.chat/', + api_info + ) + rpy_state = revolt.state.State(rpy_http, api_info, 1000) + + def using_revoltpy(): + return revolt.DMChannel(data=payload, state=rpy_state) + + vpy_http = voltage.internals.http.HTTPHandler(session, '') + vpy_cache = voltage.internals.cache.CacheHandler(vpy_http, asyncio.get_event_loop()) + + def using_voltage(): + return voltage.DMChannel(data=payload, cache=vpy_cache) + + time_pyvolt = timeit.timeit(using_pyvolt, number=100_000) + time_revoltpy = timeit.timeit(using_revoltpy, number=100_000) + time_voltage = timeit.timeit(using_voltage, number=100_000) + + print(f"[DMChannel] Time using pyvolt ----: {time_pyvolt:.6f} seconds") + print(f"[DMChannel] Time using revolt.py -: {time_revoltpy:.6f} seconds") + print(f"[DMChannel] Time using voltage ---: {time_voltage:.6f} seconds") + + await session.close() + +async def bench_group_channels(): + with open('./tests/data/channels/group.json', 'r') as fp: + payload = json.load(fp) + + state = pyvolt.State() + parser = pyvolt.Parser(state) + state.setup(parser=parser) + + def using_pyvolt(): + return parser.parse_group_channel(payload, (True, payload['recipients'])) + + import aiohttp + session = aiohttp.ClientSession() + + api_info: typing.Any = { + 'features': { + 'autumn': { + 'url': 'https://autumn.revolt.chat/' + } + } + } + rpy_http = revolt.http.HttpClient( + session, + '', + 'https://api.revolt.chat/', + api_info + ) + rpy_state = revolt.state.State(rpy_http, api_info, 1000) + + def using_revoltpy(): + return revolt.GroupDMChannel(data=payload, state=rpy_state) + + vpy_http = voltage.internals.http.HTTPHandler(session, '') + vpy_cache = voltage.internals.cache.CacheHandler(vpy_http, asyncio.get_event_loop()) + + with open('./tests/data/users/me.json', 'r') as fp: + me = json.load(fp) + vpy_cache.add_user(me) + + with open('./tests/data/users/mecha.json', 'r') as fp: + mecha = json.load(fp) + vpy_cache.add_user(mecha) + + + def using_voltage(): + return voltage.GroupDMChannel(data=payload, cache=vpy_cache) + + time_pyvolt = timeit.timeit(using_pyvolt, number=100_000) + time_revoltpy = timeit.timeit(using_revoltpy, number=100_000) + time_voltage = timeit.timeit(using_voltage, number=100_000) + + print(f"[GroupChannel] Time using pyvolt ----: {time_pyvolt:.6f} seconds") + print(f"[GroupChannel] Time using revolt.py -: {time_revoltpy:.6f} seconds") + print(f"[GroupChannel] Time using voltage ---: {time_voltage:.6f} seconds") + + await session.close() + +async def bench_text_channels(): + with open('./tests/data/channels/rules_channel.json', 'r') as fp: + payload = json.load(fp) + + with open('./tests/data/servers/server.json', 'r') as fp: + server_payload = json.load(fp) + + state = pyvolt.State() + parser = pyvolt.Parser(state) + state.setup(parser=parser) + + def using_pyvolt(): + return parser.parse_text_channel(payload) + + import aiohttp + session = aiohttp.ClientSession() + + api_info: typing.Any = { + 'features': { + 'autumn': { + 'url': 'https://autumn.revolt.chat/' + } + } + } + rpy_http = revolt.http.HttpClient( + session, + '', + 'https://api.revolt.chat/', + api_info + ) + rpy_state = revolt.state.State(rpy_http, api_info, 1000) + + def using_revoltpy(): + return revolt.TextChannel(data=payload, state=rpy_state) + + vpy_http = voltage.internals.http.HTTPHandler(session, '') + vpy_cache = voltage.internals.cache.CacheHandler(vpy_http, asyncio.get_event_loop()) + server_id = payload['server'] + vpy_server = voltage.Server(data=server_payload, cache=vpy_cache) + vpy_cache.servers[vpy_server.id] = vpy_server + + def using_voltage(): + return voltage.TextChannel(data=payload, cache=vpy_cache, server_id=server_id) + + time_pyvolt = timeit.timeit(using_pyvolt, number=100_000) + time_revoltpy = timeit.timeit(using_revoltpy, number=100_000) + time_voltage = timeit.timeit(using_voltage, number=100_000) + + print(f"[TextChannel] Time using pyvolt ----: {time_pyvolt:.6f} seconds") + print(f"[TextChannel] Time using revolt.py -: {time_revoltpy:.6f} seconds") + print(f"[TextChannel] Time using voltage ---: {time_voltage:.6f} seconds") + + await session.close() \ No newline at end of file diff --git a/bench/bench_member.py b/bench/bench_member.py new file mode 100644 index 0000000..02f168d --- /dev/null +++ b/bench/bench_member.py @@ -0,0 +1,73 @@ +import asyncio + +import json +import pyvolt + +import revolt +import revolt.http +import revolt.state + +import timeit +import typing +import voltage +import voltage.internals + +async def bench_members(): + with open('./tests/data/servers/member.json', 'r') as fp: + payload = json.load(fp) + + with open('./tests/data/servers/server.json', 'r') as fp: + server_payload = json.load(fp) + + with open('./tests/data/users/user.json', 'r') as fp: + user_payload = json.load(fp) + + state = pyvolt.State() + parser = pyvolt.Parser(state) + state.setup(parser=parser) + + def using_pyvolt(): + return parser.parse_member(payload) + + import aiohttp + session = aiohttp.ClientSession() + + api_info: typing.Any = { + 'features': { + 'autumn': { + 'url': 'https://autumn.revolt.chat/' + } + } + } + rpy_http = revolt.http.HttpClient( + session, + '', + 'https://api.revolt.chat/', + api_info + ) + rpy_state = revolt.state.State(rpy_http, api_info, 1000) + rpy_state.add_user(user_payload) + + rpy_server = revolt.Server(data=server_payload, state=rpy_state) + + def using_revoltpy(): + return revolt.Member(data=payload, server=rpy_server, state=rpy_state) + + vpy_http = voltage.internals.http.HTTPHandler(session, '') + vpy_cache = voltage.internals.cache.CacheHandler(vpy_http, asyncio.get_event_loop()) + vpy_cache.add_user(user_payload) + + vpy_server = voltage.Server(data=server_payload, cache=vpy_cache) + + def using_voltage(): + return voltage.Member(data=payload, server=vpy_server, cache=vpy_cache) + + time_pyvolt = timeit.timeit(using_pyvolt, number=10000) + time_revoltpy = timeit.timeit(using_revoltpy, number=10000) + time_voltage = timeit.timeit(using_voltage, number=10000) + + print(f"[Member] Time using pyvolt ----: {time_pyvolt:.6f} seconds") + print(f"[Member] Time using revolt.py -: {time_revoltpy:.6f} seconds") + print(f"[Member] Time using voltage ---: {time_voltage:.6f} seconds") + + await session.close() \ No newline at end of file diff --git a/bench/bench_message.py b/bench/bench_message.py new file mode 100644 index 0000000..c85550c --- /dev/null +++ b/bench/bench_message.py @@ -0,0 +1,86 @@ +import asyncio + +import json +import pyvolt + +import revolt +import revolt.http +import revolt.state + +import timeit +import typing +import voltage +import voltage.internals + +async def bench_messages(): + with open('./tests/data/channels/messages/rules.json', 'r') as fp: + payload = json.load(fp)[0] + + with open('./tests/data/channels/rules_channel.json', 'r') as fp: + channel_payload = json.load(fp) + + with open('./tests/data/servers/server.json', 'r') as fp: + server_payload = json.load(fp) + + with open('./tests/data/users/user.json', 'r') as fp: + user_payload = json.load(fp) + + with open('./tests/data/servers/member.json', 'r') as fp: + member_payload = json.load(fp) + + payload['reactions'] = {} + + state = pyvolt.State() + parser = pyvolt.Parser(state) + state.setup(parser=parser) + + def using_pyvolt(): + return parser.parse_message(payload) + + import aiohttp + session = aiohttp.ClientSession() + + api_info: typing.Any = { + 'features': { + 'autumn': { + 'url': 'https://autumn.revolt.chat/' + } + } + } + rpy_http = revolt.http.HttpClient( + session, + '', + 'https://api.revolt.chat/', + api_info + ) + rpy_state = revolt.state.State(rpy_http, api_info, 1000) + rpy_state.add_user(user_payload) + rpy_state.add_server(server_payload) + rpy_state.add_member(server_payload['_id'], member_payload) + rpy_state.add_channel(channel_payload) + + def using_revoltpy(): + return revolt.Message(data=payload, state=rpy_state) + + vpy_http = voltage.internals.http.HTTPHandler(session, '') + vpy_cache = voltage.internals.cache.CacheHandler(vpy_http, asyncio.get_event_loop()) + vpy_cache.add_user(user_payload) + + server = voltage.Server(data=server_payload, cache=vpy_cache) + vpy_cache.servers[server.id] = server + + vpy_cache.add_member(server_payload['_id'], member_payload) + vpy_cache.add_channel(channel_payload) + + def using_voltage(): + return voltage.Message(data=payload, cache=vpy_cache) + + time_pyvolt = timeit.timeit(using_pyvolt, number=10000) + time_revoltpy = timeit.timeit(using_revoltpy, number=10000) + time_voltage = timeit.timeit(using_voltage, number=10000) + + print(f"[Message] Time using pyvolt ----: {time_pyvolt:.6f} seconds") + print(f"[Message] Time using revolt.py -: {time_revoltpy:.6f} seconds") + print(f"[Message] Time using voltage ---: {time_voltage:.6f} seconds") + + await session.close() \ No newline at end of file diff --git a/bench/bench_server.py b/bench/bench_server.py new file mode 100644 index 0000000..d438364 --- /dev/null +++ b/bench/bench_server.py @@ -0,0 +1,62 @@ +import asyncio + +import json +import pyvolt + +import revolt +import revolt.http +import revolt.state + +import timeit +import typing +import voltage +import voltage.internals + +async def bench_servers(): + with open('./tests/data/servers/server.json', 'r') as fp: + payload = json.load(fp) + + state = pyvolt.State() + parser = pyvolt.Parser(state) + state.setup(parser=parser) + + def using_pyvolt(): + return parser.parse_server(payload, (True, payload['channels'])) + + import aiohttp + session = aiohttp.ClientSession() + + api_info: typing.Any = { + 'features': { + 'autumn': { + 'url': 'https://autumn.revolt.chat/' + } + } + } + rpy_http = revolt.http.HttpClient( + session, + '', + 'https://api.revolt.chat/', + api_info + ) + rpy_state = revolt.state.State(rpy_http, api_info, 1000) + + def using_revoltpy(): + return revolt.Server(data=payload, state=rpy_state) + + vpy_http = voltage.internals.http.HTTPHandler(session, '') + vpy_cache = voltage.internals.cache.CacheHandler(vpy_http, asyncio.get_event_loop()) + + def using_voltage(): + return voltage.Server(data=payload, cache=vpy_cache) + + time_pyvolt = timeit.timeit(using_pyvolt, number=10000) + time_revoltpy = timeit.timeit(using_revoltpy, number=10000) + time_voltage = timeit.timeit(using_voltage, number=10000) + + print(f"[Server] Time using pyvolt ----: {time_pyvolt:.6f} seconds") + print(f"[Server] Time using revolt.py -: {time_revoltpy:.6f} seconds") + print(f"[Server] Time using voltage ---: {time_voltage:.6f} seconds") + + await session.close() + diff --git a/bench/bench_user.py b/bench/bench_user.py new file mode 100644 index 0000000..af7ba3e --- /dev/null +++ b/bench/bench_user.py @@ -0,0 +1,61 @@ +import asyncio + +import json +import pyvolt + +import revolt +import revolt.http +import revolt.state + +import timeit +import typing +import voltage +import voltage.internals + +async def bench_users(): + with open('./tests/data/users/user.json', 'r') as fp: + payload = json.load(fp) + + state = pyvolt.State() + parser = pyvolt.Parser(state) + state.setup(parser=parser) + + def using_pyvolt(): + return parser.parse_user(payload) + + import aiohttp + session = aiohttp.ClientSession() + + api_info: typing.Any = { + 'features': { + 'autumn': { + 'url': 'https://autumn.revolt.chat/' + } + } + } + rpy_http = revolt.http.HttpClient( + session, + '', + 'https://api.revolt.chat/', + api_info + ) + rpy_state = revolt.state.State(rpy_http, api_info, 1000) + + def using_revoltpy(): + return revolt.User(data=payload, state=rpy_state) + + vpy_http = voltage.internals.http.HTTPHandler(session, '') + vpy_cache = voltage.internals.cache.CacheHandler(vpy_http, asyncio.get_event_loop()) + + def using_voltage(): + return voltage.User(data=payload, cache=vpy_cache) + + time_pyvolt = timeit.timeit(using_pyvolt, number=100_000) + time_revoltpy = timeit.timeit(using_revoltpy, number=100_000) + time_voltage = timeit.timeit(using_voltage, number=100_000) + + print(f"[User] Time using pyvolt ----: {time_pyvolt:.6f} seconds") + print(f"[User] Time using revolt.py -: {time_revoltpy:.6f} seconds") + print(f"[User] Time using voltage ---: {time_voltage:.6f} seconds") + + await session.close() \ No newline at end of file diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index b0f3783..0000000 --- a/docs/api.rst +++ /dev/null @@ -1,743 +0,0 @@ -.. currentmodule:: pyvolt - -API Reference -=============== - - -.. autoclass:: ALLOW_PERMISSIONS_IN_TIMEOUT - :members: - :inherited-members: - -.. autoclass:: APIError - :members: - :inherited-members: - -.. autoclass:: Asset - :members: - :inherited-members: - -.. autoclass:: AssetMetadata - :members: - :inherited-members: - -.. autoclass:: AssetMetadataType - :members: - -.. autoexception:: AuthenticationError - :members: - :inherited-members: - -.. autoexception:: BadGateway - :members: - :inherited-members: - -.. autoclass:: Ban - :members: - :inherited-members: - -.. autoclass:: Base - :members: - :inherited-members: - -.. autoclass:: BaseBot - :members: - :inherited-members: - -.. autoclass:: BaseChannel - :members: - :inherited-members: - -.. autoclass:: BaseContext - :members: - :inherited-members: - -.. autoclass:: BaseEmoji - :members: - :inherited-members: - -.. autoclass:: BaseEvent - :members: - :inherited-members: - -.. autoclass:: BaseInvite - :members: - :inherited-members: - -.. autoclass:: BaseMember - :members: - :inherited-members: - -.. autoclass:: BaseMessage - :members: - :inherited-members: - -.. autoclass:: BaseRole - :members: - :inherited-members: - -.. autoclass:: BaseServer - :members: - :inherited-members: - -.. autoclass:: BaseUser - :members: - :inherited-members: - -.. autoclass:: Bot - :members: - :inherited-members: - -.. autoclass:: BotFlags - :members: - -.. autoclass:: BotSearchResult - :members: - :inherited-members: - -.. autoclass:: BotUsage - :members: - -.. autoclass:: BotUserInfo - :members: - :inherited-members: - -.. autoclass:: BotWithUser - :members: - :inherited-members: - -.. autoclass:: BulkMessageDeleteEvent - :members: - :inherited-members: - -.. autoclass:: CDNClient - :members: - :inherited-members: - -.. autoclass:: Cache - :members: - :inherited-members: - -.. autoclass:: Category - :members: - :inherited-members: - -.. autoclass:: Channel - :members: - :inherited-members: - -.. autoclass:: ChannelCreateEvent - :members: - :inherited-members: - -.. autoclass:: ChannelDeleteEvent - :members: - :inherited-members: - -.. autoclass:: ChannelDescriptionChangedSystemEvent - :members: - :inherited-members: - -.. autoclass:: ChannelIconChangedSystemEvent - :members: - :inherited-members: - -.. autoclass:: ChannelOwnershipChangedSystemEvent - :members: - :inherited-members: - -.. autoclass:: ChannelRenamedSystemEvent - :members: - :inherited-members: - -.. autoclass:: ChannelType - :members: - -.. autoclass:: ChannelUpdateEvent - :members: - :inherited-members: - -.. autoclass:: Close - :members: - :inherited-members: - -.. autoclass:: ConnectError - :members: - :inherited-members: - -.. autoclass:: Content - :members: - :inherited-members: - -.. autoclass:: ContentReportReason - :members: - -.. autoclass:: ContextType - :members: - :inherited-members: - -.. autoclass:: DEFAULT_API_USER_AGENT - :members: - :inherited-members: - -.. autoclass:: DEFAULT_DM_PERMISSIONS - :members: - :inherited-members: - -.. autoclass:: DEFAULT_PERMISSIONS - :members: - :inherited-members: - -.. autoclass:: DEFAULT_SAVED_MESSAGES_PERMISSIONS - :members: - :inherited-members: - -.. autoclass:: DEFAULT_SERVER_PERMISSIONS - :members: - :inherited-members: - -.. autoclass:: DEFAULT_USER_AGENT - :members: - :inherited-members: - -.. autoclass:: DMChannel - :members: - :inherited-members: - -.. autoclass:: DetachedEmoji - :members: - :inherited-members: - -.. autoclass:: DisabledAccountResult - :members: - :inherited-members: - -.. autoclass:: DiscoveryBot - :members: - :inherited-members: - -.. autoclass:: DiscoveryBotsPage - :members: - :inherited-members: - -.. autoclass:: DiscoveryClient - :members: - :inherited-members: - -.. autoclass:: DiscoveryError - :members: - :inherited-members: - -.. autoclass:: DiscoveryServer - :members: - :inherited-members: - -.. autoclass:: DiscoveryServersPage - :members: - :inherited-members: - -.. autoclass:: DiscoveryTheme - :members: - :inherited-members: - -.. autoclass:: DiscoveryThemesPage - :members: - :inherited-members: - -.. autoclass:: DisplayUser - :members: - :inherited-members: - -.. autoclass:: Emoji - :members: - :inherited-members: - -.. autoclass:: EmptyCache - :members: - :inherited-members: - -.. autoclass:: EventHandler - :members: - :inherited-members: - -.. autoexception:: Forbidden - :members: - :inherited-members: - -.. autoclass:: GroupChannel - :members: - :inherited-members: - -.. autoclass:: GroupInvite - :members: - :inherited-members: - -.. autoclass:: GroupPublicInvite - :members: - :inherited-members: - -.. autoclass:: HTTPClient - :members: - :inherited-members: - -.. autoclass:: HasID - :members: - :inherited-members: - -.. autoclass:: Interactions - :members: - :inherited-members: - -.. autoexception:: InternalServerError - :members: - :inherited-members: - -.. autoclass:: Invite - :members: - :inherited-members: - -.. autoclass:: LoginResult - :members: - :inherited-members: - -.. autoclass:: MFAMethod - :members: - -.. autoclass:: MFARequired - :members: - :inherited-members: - -.. autoclass:: MFATicket - :members: - :inherited-members: - -.. autoclass:: MapCache - :members: - :inherited-members: - -.. autoclass:: Masquerade - :members: - :inherited-members: - -.. autoclass:: Member - :members: - :inherited-members: - -.. autoclass:: Message - :members: - :inherited-members: - -.. autoclass:: MessageAppendData - :members: - :inherited-members: - -.. autoclass:: MessageAppendEvent - :members: - :inherited-members: - -.. autoclass:: MessageClearReactionEvent - :members: - :inherited-members: - -.. autoclass:: MessageContext - :members: - :inherited-members: - -.. autoclass:: MessageCreateEvent - :members: - :inherited-members: - -.. autoclass:: MessageDeleteEvent - :members: - :inherited-members: - -.. autoclass:: MessageReactEvent - :members: - :inherited-members: - -.. autoclass:: MessageSort - :members: - -.. autoclass:: MessageUnreactEvent - :members: - :inherited-members: - -.. autoclass:: MessageUpdateEvent - :members: - :inherited-members: - -.. autoclass:: MessageWebhook - :members: - :inherited-members: - -.. autoclass:: Mutuals - :members: - :inherited-members: - -.. autoexception:: NoData - :members: - :inherited-members: - -.. autoexception:: NotFound - :members: - :inherited-members: - -.. autoclass:: Parser - :members: - :inherited-members: - -.. autoclass:: PartialAccount - :members: - :inherited-members: - -.. autoclass:: PartialBot - :members: - :inherited-members: - -.. autoclass:: PartialChannel - :members: - :inherited-members: - -.. autoclass:: PartialMember - :members: - :inherited-members: - -.. autoclass:: PartialMessage - :members: - :inherited-members: - -.. autoclass:: PartialRole - :members: - :inherited-members: - -.. autoclass:: PartialServer - :members: - :inherited-members: - -.. autoclass:: PartialUser - :members: - :inherited-members: - -.. autoclass:: PermissionOverride - :members: - :inherited-members: - -.. autoclass:: Permissions - :members: - -.. autoclass:: Presence - :members: - -.. autoclass:: PrivateBaseInvite - :members: - :inherited-members: - -.. autoclass:: PublicBot - :members: - :inherited-members: - -.. autoexception:: PyvoltError - :members: - :inherited-members: - -.. autoexception:: Ratelimited - :members: - :inherited-members: - -.. autoclass:: RawServerRoleUpdateEvent - :members: - :inherited-members: - -.. autoclass:: ReadState - :members: - :inherited-members: - -.. autoclass:: ReadyEvent - :members: - :inherited-members: - -.. autoclass:: Reconnect - :members: - :inherited-members: - -.. autoclass:: Relationship - :members: - :inherited-members: - -.. autoclass:: RelationshipStatus - :members: - -.. autoclass:: Reply - :members: - :inherited-members: - -.. autoclass:: ResolvableEmoji - :members: - :inherited-members: - -.. autoclass:: ResolvableResource - :members: - :inherited-members: - -.. autoclass:: ResolvableULID - :members: - :inherited-members: - -.. autoclass:: Resource - :members: - :inherited-members: - -.. autoclass:: Role - :members: - :inherited-members: - -.. autoclass:: SavedMessagesChannel - :members: - :inherited-members: - -.. autoclass:: SelfUser - :members: - :inherited-members: - -.. autoclass:: SendableEmbed - :members: - :inherited-members: - -.. autoclass:: Server - :members: - :inherited-members: - -.. autoclass:: ServerActivity - :members: - -.. autoclass:: ServerChannel - :members: - :inherited-members: - -.. autoclass:: ServerCreateEvent - :members: - :inherited-members: - -.. autoclass:: ServerDeleteEvent - :members: - :inherited-members: - -.. autoclass:: ServerEmoji - :members: - :inherited-members: - -.. autoclass:: ServerFlags - :members: - -.. autoclass:: ServerInvite - :members: - :inherited-members: - -.. autoclass:: ServerMemberJoinEvent - :members: - :inherited-members: - -.. autoclass:: ServerMemberLeaveEvent - :members: - :inherited-members: - -.. autoclass:: ServerMemberUpdateEvent - :members: - :inherited-members: - -.. autoclass:: ServerPublicInvite - :members: - :inherited-members: - -.. autoclass:: ServerRoleDeleteEvent - :members: - :inherited-members: - -.. autoclass:: ServerSearchResult - :members: - :inherited-members: - -.. autoclass:: ServerTextChannel - :members: - :inherited-members: - -.. autoclass:: ServerUpdateEvent - :members: - :inherited-members: - -.. autoclass:: Session - :members: - :inherited-members: - -.. autoclass:: Shard - :members: - :inherited-members: - -.. autoexception:: ShardError - :members: - :inherited-members: - -.. autoclass:: State - :members: - :inherited-members: - -.. autoclass:: StatelessAsset - :members: - :inherited-members: - -.. autoclass:: StatelessUserProfile - :members: - :inherited-members: - -.. autoclass:: SystemEvent - :members: - :inherited-members: - -.. autoclass:: SystemMessageChannels - :members: - :inherited-members: - -.. autoclass:: Tag - :members: - :inherited-members: - -.. autoclass:: TextChannel - :members: - :inherited-members: - -.. autoclass:: TextSystemEvent - :members: - :inherited-members: - -.. autoclass:: ThemeSearchResult - :members: - :inherited-members: - -.. autoclass:: Token - :members: - :inherited-members: - -.. autoclass:: TokenType - :members: - -.. autoclass:: Typing - :members: - :inherited-members: - -.. autoclass:: ULID - :members: - -.. autoclass:: UNDEFINED - :members: - :inherited-members: - -.. autoexception:: Unauthorized - :members: - :inherited-members: - -.. autoclass:: Undefined - :members: - :inherited-members: - -.. autoclass:: UndefinedOr - :members: - :inherited-members: - -.. autoclass:: UnknownPublicInvite - :members: - :inherited-members: - -.. autoclass:: Upload - :members: - :inherited-members: - -.. autoclass:: User - :members: - :inherited-members: - -.. autoclass:: UserAddedSystemEvent - :members: - :inherited-members: - -.. autoclass:: UserBadges - :members: - -.. autoclass:: UserBannedSystemEvent - :members: - :inherited-members: - -.. autoclass:: UserFlags - :members: - -.. autoclass:: UserJoinedSystemEvent - :members: - :inherited-members: - -.. autoclass:: UserKickedSystemEvent - :members: - :inherited-members: - -.. autoclass:: UserLeftSystemEvent - :members: - :inherited-members: - -.. autoclass:: UserPermissions - :members: - -.. autoclass:: UserPlatformWipeEvent - :members: - :inherited-members: - -.. autoclass:: UserProfile - :members: - :inherited-members: - -.. autoclass:: UserProfileEdit - :members: - :inherited-members: - -.. autoclass:: UserRelationshipUpdateEvent - :members: - :inherited-members: - -.. autoclass:: UserRemovedSystemEvent - :members: - :inherited-members: - -.. autoclass:: UserReportReason - :members: - -.. autoclass:: UserSettings - :members: - :inherited-members: - -.. autoclass:: UserSettingsUpdateEvent - :members: - :inherited-members: - -.. autoclass:: UserStatus - :members: - :inherited-members: - -.. autoclass:: UserStatusEdit - :members: - :inherited-members: - -.. autoclass:: UserUpdateEvent - :members: - :inherited-members: - -.. autoclass:: VIEW_ONLY_PERMISSIONS - :members: - :inherited-members: - -.. autoclass:: VoiceChannel - :members: - :inherited-members: - -.. autoclass:: WebPushSubscription - :members: - :inherited-members: - -.. autoclass:: Webhook - :members: - :inherited-members: \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index ef9f0b8..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,44 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# For the full list of built-in configuration values, see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Project information ----------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information - -import os -import sys - -sys.path.insert(0, os.path.abspath('..')) - -import pyvolt - -project = 'pyvolt' -copyright = '2024, MCausc78' -author = 'MCausc78' -release = '0.1.0' - -# -- General configuration --------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon' -] -rst_prolog = """ -.. |coro| replace:: This function is a |coroutine_link|_. -.. |maybecoro| replace:: This function *could be a* |coroutine_link|_. -.. |coroutine_link| replace:: *coroutine* -.. _coroutine_link: https://docs.python.org/3/library/asyncio-task.html#coroutine -""" - -templates_path = ['_templates'] -exclude_patterns = [] - - - -# -- Options for HTML output ------------------------------------------------- -# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -html_theme = 'alabaster' -html_static_path = ['_static'] diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index c92482d..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,19 +0,0 @@ -.. pyvolt documentation master file, created by - sphinx-quickstart on Thu Jun 20 15:43:58 2024. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to pyvolt's documentation! -================================== - -.. toctree:: - :maxdepth: 2 - - api - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index dc1312a..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=source -set BUILDDIR=build - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.https://www.sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "" goto help - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/examples/get_pms.py b/examples/get_pms.py new file mode 100644 index 0000000..979b21f --- /dev/null +++ b/examples/get_pms.py @@ -0,0 +1,27 @@ +import pyvolt + +client = pyvolt.Client(token='token', bot=False) + + +def sort_private_channel(channel: pyvolt.DMChannel | pyvolt.GroupChannel) -> str: + return channel.last_message_id or '0' + + +@client.on(pyvolt.ReadyEvent) +async def on_ready(event: pyvolt.ReadyEvent): + event.process() + event.cancel() + + for channel in client.ordered_private_channels: + if isinstance(channel, pyvolt.DMChannel): + target_id = channel.recipient_id + target = client.get_user(target_id) or await client.fetch_user(target_id) + if channel.active: + print('DM with', target.display_name or target.name) + else: + print('Inactive DM with', target.display_name or target.name) + elif isinstance(channel, pyvolt.GroupChannel): + print('Group', channel.name) + + +client.run() diff --git a/examples/mfa.py b/examples/mfa.py new file mode 100644 index 0000000..af05961 --- /dev/null +++ b/examples/mfa.py @@ -0,0 +1,42 @@ +import json +import pyotp +import pyvolt + +bot = pyvolt.Client() + +token = 'token' +password = 'password' + + +@bot.on(pyvolt.MessageCreateEvent) +async def on_message(event: pyvolt.MessageCreateEvent): + message = event.message + + if message.author.relationship is not pyvolt.RelationshipStatus.user: + return + + if message.content == 'disable mfa': + ticket = await bot.http.create_password_ticket(password) + await bot.http.disable_totp_2fa(mfa_ticket=ticket.token) + await message.reply('Turned off MFA.') + elif message.content == 'enable mfa': + await message.reply('Enabling on MFA.') + ticket = await bot.http.create_password_ticket(password) + + codes = await bot.http.generate_recovery_codes(mfa_ticket=ticket.token) + + with open('./local_codes.json', 'w') as fp: + json.dump(codes, fp, indent=4) + + ticket = await bot.http.create_password_ticket(password) + + secret = await bot.http.generate_totp_secret(mfa_ticket=ticket.token) + totp = pyotp.TOTP(secret) + await message.reply(f'MFA secret: {secret}') + code = totp.now() + await bot.http.enable_totp_2fa(pyvolt.ByTOTP(code)) + await message.reply('Turned on MFA.') + + +# This is also possible +bot.run(token, bot=False) diff --git a/examples/ping_pong.py b/examples/ping_pong.py new file mode 100644 index 0000000..467bcd1 --- /dev/null +++ b/examples/ping_pong.py @@ -0,0 +1,28 @@ +import pyvolt + +import pyvolt + +# Whether the example should be ran as a user account or not +self_bot = False + +client = pyvolt.Client(token='token', bot=not self_bot) + + +@client.on(pyvolt.ReadyEvent) +async def on_ready(_) -> None: + print('Logged on as', client.me) + + +@client.on(pyvolt.MessageCreateEvent) +async def on_message(event: pyvolt.MessageCreateEvent): + message = event.message + + # don't respond to ourselves/others + if (message.author.relationship is pyvolt.RelationshipStatus.user) ^ self_bot: + return + + if message.content == 'ping': + await message.channel.send('pong') + + +client.run() diff --git a/gen_todo.py b/gen_todo.py new file mode 100644 index 0000000..745c093 --- /dev/null +++ b/gen_todo.py @@ -0,0 +1,56 @@ +from glob import glob +import re +import subprocess +import sys + + +def get_branch() -> str: + proc = subprocess.run("git status", stdout=subprocess.PIPE, stderr=subprocess.PIPE) + proc.check_returncode() + + RE_BRANCH = re.compile(rb"On branch ([0-9A-Za-z/]+)\n", re.MULTILINE) + + match = RE_BRANCH.findall(proc.stdout) + if not match: + print(f"Unable to get git branch! Stdout:") + sys.stdout.write(proc.stdout.decode()) + sys.exit(1) + + return match[0].decode() + + +def main(): + branch = get_branch() + + RE_TODO = re.compile(r"TODO: (.*)") + + todos = {} + for filename in glob("./pyvolt/**.py", recursive=True): + with open(filename, "r") as fp: + todos.setdefault(filename, []) + for i, line in enumerate(fp): + match = RE_TODO.search(line) + if match: + span = match.span() + span = (span[0] + 1, min(len(line), span[1] + 1)) + + todos[filename].append((i + 1, span, match[0])) + + with open("TODO.md", "w") as todo_md: + todo_md.write("# TODO\n\n") + for file, items in todos.items(): + if not items: + continue + + for item in items: + file = file[file.find("pyvolt") :].replace("\\", "/") + span = item[1] + fragment = f"L{item[0]}C{span[0]}-L{item[0]}C{span[1]}" + todo_md.write( + f"* {item[2]}\n\n" + f" Source: https://github.com/MCausc78/pyvolt/blob/{branch}/{file}#{fragment}\n\n" + ) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c152a6c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,82 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyvolt" +description = "A Python wrapper for the Revolt API" +readme = { file = "README.md", content-type = "text/markdown" } +license = { file = "LICENSE" } +requires-python = ">=3.9" +authors = [{ name = "MCausc78" }] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "License :: OSI Approved :: MIT License", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Utilities", + "Typing :: Typed", +] +dynamic = ["version", "dependencies"] + +[project.urls] +Documentation = "https://pyvolt.readthedocs.io/en/latest/" +"Issue tracker" = "https://github.com/MCausc78/pyvolt/issues" + +[tool.setuptools.dynamic] +dependencies = { file = "requirements.txt" } + +[project.optional-dependencies] +speed = [ + "orjson>=3.5.4", + "aiodns>=1.1", + "Brotli", + "cchardet==2.1.7; python_version < '3.10'", +] +test = [ + "pytest", + "pytest-asyncio", + "typing-extensions>=4.3,<5", + "tzdata; sys_platform == 'win32'", +] + +[tool.ruff] +line-length = 120 +target-version = "py310" +# convert-typed-dict-functional-to-class = true + +[tool.ruff.format] +quote-style = "single" +docstring-code-format = true + +[tool.ruff.lint] +select = ["UP037"] + +[tool.ruff.lint.flake8-copyright] +author = "MCausc78" + +[tool.ruff.lint.isort] +required-imports = ["from __future__ import annotations"] +section-order = ["future", "standard-library", "first-party", "local-folder", "third-party"] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F403"] + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = false +# quoted-annotation = false + +[tool.setuptools] +packages = [ + "pyvolt", + "pyvolt.raw", +] +include-package-data = true \ No newline at end of file diff --git a/pyvolt/__init__.py b/pyvolt/__init__.py index a74b114..b940ea3 100644 --- a/pyvolt/__init__.py +++ b/pyvolt/__init__.py @@ -1,3 +1,14 @@ +""" +Revolt API Wrapper +~~~~~~~~~~~~~~~~~~~ + +A basic wrapper for the Revolt API. + +:copyright: (c) 2024-present MCausc78 +:license: MIT, see LICENSE for more details. + +""" + from .auth import * from .base import * from .bot import * @@ -8,15 +19,19 @@ from .core import * from .discovery import * from .emoji import * +from .enums import * from .errors import * from .events import * +from .flags import * from .http import * from .invite import * from .message import * from .parser import * from .permissions import * from .read_state import * -from .safety_reports import * + +# Explicitly re-export, this is public API. +from . import routes as routes from .server import * from .shard import * from .state import * diff --git a/pyvolt/__main__.py b/pyvolt/__main__.py new file mode 100644 index 0000000..7990191 --- /dev/null +++ b/pyvolt/__main__.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import aiohttp +import argparse +import asyncio +import platform +import pyvolt +import sys + + +def show_version() -> None: + entries = [] + + entries.append('- Python v{0.major}.{0.minor}.{0.micro}-{0.releaselevel}'.format(sys.version_info)) + entries.append('- pyvolt v{}'.format(pyvolt.__version__)) + + entries.append(f'- aiohttp v{aiohttp.__version__}') + uname = platform.uname() + entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname)) + print('\n'.join(entries)) + + +async def login(email: str, password: str, friendly_name: str | None): + session = aiohttp.ClientSession() + + state = pyvolt.State() + http = pyvolt.HTTPClient(session=session, state=state) + parser = pyvolt.Parser(state) + + state.setup(http=http, parser=parser) + + async with session: + resp = await http.login_with_email(email, password, friendly_name=friendly_name) + if isinstance(resp, pyvolt.MFARequired): + print('--- MFA required. ---') + print('---------------------') + print('| Whats available |') + methods = resp.allowed_methods + + if pyvolt.MFAMethod.recovery in methods: + print('| Recovery code |') + if pyvolt.MFAMethod.totp in methods: + print('| TOTP code |') + print('---------------------') + print("| Choose what you want to use. Choices: 'recovery', 'totp': ") + method = input('> ').casefold() + if method in ('recovery', 'recover'): + code = input('Recovery code > ') + resp = await resp.use_recovery_code(code) + elif method in ('2fa', 'mfa', 'totp'): + code = input('TOTP code > ') + resp = await resp.use_totp(code) + else: + print("Invalid choice, available choices are: 'recovery', 'totp'", file=sys.stderr) + sys.exit(1) + + if isinstance(resp, pyvolt.AccountDisabled): + print('Account disabled. Your user ID is', resp.user_id) + sys.exit(1) + + print('Logged in successfully! Here is session data:') + print('Session ID:', resp.id) + print('User ID:', resp.user_id) + + subscription = resp.subscription + if subscription: + print('You also have Web Push subscription:') + print('> Endpoint:', subscription.endpoint) + print('> P256DH:', subscription.p256dh) + print('> Auth:', subscription.auth) + + print('Token:', resp.token) + + +def _login(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + asyncio.run(login(args.email, args.password, args.friendly_name)) + + +def add_login_args(subparser: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + parser = subparser.add_parser('login', help='log in to account') + parser.set_defaults(func=_login) + + parser.add_argument('email', help='account email') + parser.add_argument('password', help='account password') + parser.add_argument('--friendly-name', nargs=1, required=False, help='device name') + + +def core(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None: + if args.version: + show_version() + else: + parser.print_help() + + +def parse_args() -> tuple[argparse.ArgumentParser, argparse.Namespace]: + parser = argparse.ArgumentParser(prog='pyvolt', description='Tools for helping with pyvolt') + parser.add_argument('-v', '--version', action='store_true', help='shows the library version') + parser.set_defaults(func=core) + + subparser = parser.add_subparsers(dest='subcommand', title='subcommands') + add_login_args(subparser) + + return parser, parser.parse_args() + + +def main() -> None: + parser, args = parse_args() + args.func(parser, args) + + +if __name__ == '__main__': + main() diff --git a/pyvolt/auth.py b/pyvolt/auth.py index c077273..7623665 100644 --- a/pyvolt/auth.py +++ b/pyvolt/auth.py @@ -1,74 +1,103 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field -from enum import StrEnum -import typing as t +import typing -from . import base, core +from .base import Base +from .core import UNDEFINED, UndefinedOr +from .enums import MFAMethod from .state import State -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from . import raw -@define(slots=True) +@define(slots=True, eq=True) class PartialAccount: - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True, eq=True) """The unique account ID.""" - email: str = field(repr=True, hash=True, kw_only=True, eq=True) + email: str = field(repr=True, kw_only=True) """The email associated with this account.""" + def __hash__(self) -> int: + return hash(self.id) -@define(slots=True) + +@define(slots=True, eq=True) class MFATicket: """The Multi-factor authentication ticket.""" - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True, eq=True) """The unique ticket ID.""" - account_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + account_id: str = field(repr=True, kw_only=True) """The associated account ID.""" - token: str = field(repr=True, hash=True, kw_only=True, eq=True) + token: str = field(repr=True, kw_only=True) """The unique token.""" - validated: bool = field(repr=True, hash=True, kw_only=True, eq=True) + validated: bool = field(repr=True, kw_only=True) """Whether this ticket has been validated (can be used for account actions).""" - authorised: bool = field(repr=True, hash=True, kw_only=True, eq=True) + authorised: bool = field(repr=True, kw_only=True) """Whether this ticket is authorised (can be used to log a user in).""" - last_totp_code: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + last_totp_code: str | None = field(repr=True, kw_only=True) """The TOTP code at time of ticket creation.""" + def __hash__(self) -> int: + return hash(self.id) + @define(slots=True) class WebPushSubscription: """The Web Push subscription object.""" - endpoint: str = field(repr=True, hash=True, kw_only=True, eq=True) - p256dh: str = field(repr=True, hash=True, kw_only=True, eq=True) - auth: str = field(repr=True, hash=True, kw_only=True, eq=True) + endpoint: str = field(repr=True, kw_only=True) + p256dh: str = field(repr=True, kw_only=True) + auth: str = field(repr=True, kw_only=True) @define(slots=True) -class PartialSession(base.Base): +class PartialSession(Base): """Partially represents Revolt auth session.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """The session friendly name.""" - async def edit(self, *, friendly_name: core.UndefinedOr[str]) -> PartialSession: + async def edit(self, *, friendly_name: UndefinedOr[str]) -> PartialSession: """|coro| Edits the session. """ return await self.state.http.edit_session( self.id, - friendly_name=( - friendly_name if core.is_defined(friendly_name) else self.name - ), + friendly_name=friendly_name if friendly_name is not UNDEFINED else self.name, ) async def revoke(self) -> None: @@ -76,48 +105,45 @@ async def revoke(self) -> None: Deletes this session. """ - await self.state.http.revoke_session(self.id) + return await self.state.http.revoke_session(self.id) @define(slots=True) class Session(PartialSession): """The session information.""" - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + user_id: str = field(repr=True, kw_only=True) """The ID of associated user.""" - token: str = field(repr=True, hash=True, kw_only=True, eq=True) + token: str = field(repr=True, kw_only=True) """The session token.""" - subscription: WebPushSubscription | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + subscription: WebPushSubscription | None = field(repr=True, kw_only=True) """The Web Push subscription.""" -class MFAMethod(StrEnum): - PASSWORD = "Password" - RECOVERY = "Recovery" - TOTP = "Totp" - - @define(slots=True) class MFARequired: """The password is valid, but MFA is required.""" - ticket: str = field(repr=True, hash=True, kw_only=True, eq=True) + ticket: str = field(repr=True, kw_only=True) """The MFA ticket.""" - allowed_methods: list[MFAMethod] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + allowed_methods: list[MFAMethod] = field(repr=True, kw_only=True) """The allowed methods.""" # internals - state: State = field(repr=True, hash=True, kw_only=True, eq=True) - internal_friendly_name: str | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + state: State = field(repr=True, kw_only=True) + internal_friendly_name: str | None = field(repr=True, kw_only=True) + + async def use_recovery_code(self, code: str, /) -> Session | AccountDisabled: + """|coro| + + Login to an account. + """ + return await self.state.http.login_with_mfa( + self.ticket, ByRecoveryCode(code), friendly_name=self.internal_friendly_name + ) async def use_totp(self, code: str, /) -> Session | AccountDisabled: """|coro| @@ -133,16 +159,16 @@ async def use_totp(self, code: str, /) -> Session | AccountDisabled: class AccountDisabled: """The password/MFA are valid, but account is disabled.""" - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + user_id: str = field(repr=True, kw_only=True) """The ID of the disabled user account.""" -@define(slots=True) -class MultiFactorStatus: - totp_mfa: bool = field(repr=True, hash=True, kw_only=True, eq=True) +@define(slots=True, eq=True) +class MFAStatus: + totp_mfa: bool = field(repr=True, kw_only=True, eq=True) """Whether the account has MFA TOTP enabled.""" - recovery_active: bool = field(repr=True, hash=True, kw_only=True, eq=True) + recovery_active: bool = field(repr=True, kw_only=True, eq=True) """Whether the account has recovery codes.""" @@ -151,33 +177,42 @@ class BaseMFAResponse: class ByPassword(BaseMFAResponse): - __slots__ = ("password",) + __slots__ = ('password',) def __init__(self, password: str, /) -> None: self.password = password + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, ByPassword) and self.password == other.password + def build(self) -> raw.a.PasswordMFAResponse: - return {"password": self.password} + return {'password': self.password} class ByRecoveryCode(BaseMFAResponse): - __slots__ = ("code",) + __slots__ = ('code',) def __init__(self, code: str, /) -> None: self.code = code + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, ByRecoveryCode) and self.code == other.code + def build(self) -> raw.a.RecoveryMFAResponse: - return {"recovery_code": self.code} + return {'recovery_code': self.code} class ByTOTP(BaseMFAResponse): - __slots__ = ("code",) + __slots__ = ('code',) def __init__(self, code: str, /) -> None: self.code = code + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, ByTOTP) and self.code == other.code + def build(self) -> raw.a.TotpMFAResponse: - return {"totp_code": self.code} + return {'totp_code': self.code} MFAResponse = ByPassword | ByRecoveryCode | ByTOTP @@ -185,19 +220,18 @@ def build(self) -> raw.a.TotpMFAResponse: LoginResult = Session | MFARequired | AccountDisabled __all__ = ( - "PartialAccount", - "MFATicket", - "WebPushSubscription", - "PartialSession", - "Session", - "MFAMethod", - "MFARequired", - "AccountDisabled", - "MultiFactorStatus", - "BaseMFAResponse", - "ByPassword", - "ByRecoveryCode", - "ByTOTP", - "MFAResponse", - "LoginResult", + 'PartialAccount', + 'MFATicket', + 'WebPushSubscription', + 'PartialSession', + 'Session', + 'MFARequired', + 'AccountDisabled', + 'MFAStatus', + 'BaseMFAResponse', + 'ByPassword', + 'ByRecoveryCode', + 'ByTOTP', + 'MFAResponse', + 'LoginResult', ) diff --git a/pyvolt/base.py b/pyvolt/base.py index db0964b..d40e540 100644 --- a/pyvolt/base.py +++ b/pyvolt/base.py @@ -1,27 +1,57 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field from datetime import datetime -import typing as t +import typing -from . import core +from .core import ulid_time -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from .state import State @define(slots=True) class Base: - state: State = field(repr=False, hash=True, eq=True) + state: State = field(repr=False, kw_only=True) """State that controls this entity.""" - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """The unique ID of the entity.""" + id: str = field(repr=True, kw_only=True) + """The ID of the entity.""" + + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, Base) and self.id == other.id @property def created_at(self) -> datetime: - return self.id.created_at + return ulid_time(self.id) -__all__ = ("Base",) +__all__ = ('Base',) diff --git a/pyvolt/bot.py b/pyvolt/bot.py index 4e09ab2..089add8 100644 --- a/pyvolt/bot.py +++ b/pyvolt/bot.py @@ -1,22 +1,39 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field -from enum import IntFlag - - -from . import base, core, user as users - - -class BotFlags(IntFlag): - """Flags that may be attributed to a bot.""" - NONE = 0 - VERIFIED = 1 << 0 - OFFICIAL = 1 << 1 +from .base import Base +from .core import UNDEFINED, UndefinedOr +from .flags import BotFlags +from .user import User @define(slots=True) -class BaseBot(base.Base): +class BaseBot(Base): """Base representation of a bot on Revolt.""" async def delete(self) -> None: @@ -29,10 +46,10 @@ async def delete(self) -> None: async def edit( self, *, - name: core.UndefinedOr[str] = core.UNDEFINED, - public: core.UndefinedOr[bool] = core.UNDEFINED, - analytics: core.UndefinedOr[bool] = core.UNDEFINED, - interactions_url: core.UndefinedOr[str | None] = core.UNDEFINED, + name: UndefinedOr[str] = UNDEFINED, + public: UndefinedOr[bool] = UNDEFINED, + analytics: UndefinedOr[bool] = UNDEFINED, + interactions_url: UndefinedOr[str | None] = UNDEFINED, reset_token: bool = False, ) -> Bot: """|coro| @@ -50,39 +67,37 @@ async def edit( @define(slots=True) -class Bot(base.Base): +class Bot(BaseBot): """Partial representation of a bot on Revolt.""" - owner_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + owner_id: str = field(repr=True, kw_only=True) """The user ID of the bot owner.""" - token: str = field(repr=False, hash=True, eq=True) + token: str = field(repr=False) """Token used to authenticate requests for this bot.""" - public: bool = field(repr=True, hash=True, kw_only=True, eq=True) + public: bool = field(repr=True, kw_only=True) """Whether the bot is public (may be invited by anyone).""" - analytics: bool = field(repr=True, hash=True, kw_only=True, eq=True) + analytics: bool = field(repr=True, kw_only=True) """Whether to enable analytics.""" - discoverable: bool = field(repr=True, hash=True, kw_only=True, eq=True) + discoverable: bool = field(repr=True, kw_only=True) """Whether this bot should be publicly discoverable.""" - interactions_url: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + interactions_url: str | None = field(repr=True, kw_only=True) """Reserved; URL for handling interactions.""" - terms_of_service_url: str | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + terms_of_service_url: str | None = field(repr=True, kw_only=True) """URL for terms of service.""" - privacy_policy_url: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + privacy_policy_url: str | None = field(repr=True, kw_only=True) """URL for privacy policy.""" - flags: BotFlags = field(repr=True, hash=True, kw_only=True, eq=True) + flags: BotFlags = field(repr=True, kw_only=True) """Enum of bot flags.""" - user: users.User = field(repr=True, hash=True, kw_only=True, eq=True) + user: User = field(repr=True, kw_only=True) """The user associated with this bot.""" @@ -90,14 +105,14 @@ class Bot(base.Base): class PublicBot(BaseBot): """Representation of a public bot on Revolt.""" - username: str = field(repr=True, hash=True, kw_only=True, eq=True) + username: str = field(repr=True, kw_only=True) """The bot username.""" - internal_avatar_id: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + internal_avatar_id: str | None = field(repr=True, kw_only=True) """The bot avatar ID.""" - description: str = field(repr=True, hash=True, kw_only=True, eq=True) + description: str = field(repr=True, kw_only=True) """The bot description.""" -__all__ = ("BotFlags", "BaseBot", "Bot", "PublicBot") +__all__ = ('BaseBot', 'Bot', 'PublicBot') diff --git a/pyvolt/cache.py b/pyvolt/cache.py index 485ae01..8ec287c 100644 --- a/pyvolt/cache.py +++ b/pyvolt/cache.py @@ -1,76 +1,97 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import abc from attrs import define, field -from enum import Enum, auto import logging -import typing as t - +import typing -from . import core, emoji as emojis, user as users +from .emoji import ServerEmoji, Emoji +from .enums import Enum +from .user import User -if t.TYPE_CHECKING: - from . import ( - channel as channels, - message as messages, - read_state as read_states, - server as servers, - user as users, - ) +if typing.TYPE_CHECKING: + from .channel import DMChannel, GroupChannel, Channel + from .message import Message + from .read_state import ReadState + from .server import Server, Member _L = logging.getLogger(__name__) class ContextType(Enum): - UNDEFINED = auto() + UNDEFINED = 'UNDEFINED' """Context is not provided.""" - USER_REQUEST = auto() + USER_REQUEST = 'USER_REQUEST' """The end user is asking for object.""" - LIBRARY_REQUEST = auto() + LIBRARY_REQUEST = 'LIBRARY_REQUEST' """Library needs the object for internal purposes.""" - READY = auto() + READY = 'READY' """Populated data from Ready event.""" - MESSAGE_ACK = auto() - MESSAGE_CREATE = auto() - MESSAGE_UPDATE = auto() - MESSAGE_APPEND = auto() - MESSAGE_DELETE = auto() - MESSAGE_REACT = auto() - MESSAGE_UNREACT = auto() - MESSAGE_REMOVE_REACTION = auto() - MESSAGE_BULK_DELETE = auto() + message_ack = 'MESSAGE_ACK' + message_create = 'MESSAGE_CREATE' + message_update = 'MESSAGE_UPDATE' + message_append = 'MESSAGE_APPEND' + message_delete = 'MESSAGE_DELETE' + message_react = 'MESSAGE_REACT' + message_unreact = 'MESSAGE_UNREACT' + message_remove_reaction = 'MESSAGE_REMOVE_REACTION' + message_bulk_delete = 'MESSAGE_BULK_DELETE' - SERVER_CREATE = auto() - SERVER_UPDATE = auto() - SERVER_DELETE = auto() + server_create = 'SERVER_CREATE' + server_update = 'SERVER_UPDATE' + server_delete = 'SERVER_DELETE' - SERVER_MEMBER_CREATE = auto() - SERVER_MEMBER_UPDATE = auto() - SERVER_MEMBER_DELETE = auto() + server_member_create = 'SERVER_MEMBER_CREATE' + server_member_update = 'SERVER_MEMBER_UPDATE' + server_member_delete = 'SERVER_MEMBER_DELETE' - SERVER_ROLE_UPDATE = auto() - SERVER_ROLE_DELETE = auto() + server_role_update = 'SERVER_ROLE_UPDATE' + server_role_delete = 'SERVER_ROLE_DELETE' - USER_UPDATE = auto() - USER_RELATIONSHIP_UPDATE = auto() - USER_PLATFORM_WIPE = auto() + user_update = 'USER_UPDATE' + user_relationship_update = 'USER_RELATIONSHIP_UPDATE' + user_platform_wipe = 'USER_PLATFORM_WIPE' - EMOJI_CREATE = auto() - EMOJI_DELETE = auto() + emoji_create = 'EMOJI_CREATE' + emoji_delete = 'EMOJI_DELETE' - CHANNEL_CREATE = auto() - CHANNEL_UPDATE = auto() - CHANNEL_DELETE = auto() + channel_create = 'CHANNEL_CREATE' + channel_update = 'CHANNEL_UPDATE' + channel_delete = 'CHANNEL_DELETE' - CHANNEL_GROUP_JOIN = auto() - CHANNEL_GROUP_LEAVE = auto() + channel_group_join = 'CHANNEL_GROUP_JOIN' + channel_group_leave = 'CHANNEL_GROUP_LEAVE' """Data from websocket event.""" - MESSAGE = auto() + message = 'MESSAGE' """The library asks for object to provide value for `message.get_x()`.""" @@ -81,39 +102,76 @@ class BaseContext: @define(slots=True) class MessageContext(BaseContext): - message: messages.Message = field(repr=True, hash=True, kw_only=True, eq=True) + message: Message = field(repr=True, hash=True, kw_only=True, eq=True) _UNDEFINED = BaseContext(type=ContextType.UNDEFINED) _USER_REQUEST = BaseContext(type=ContextType.USER_REQUEST) _READY = BaseContext(type=ContextType.READY) -_MESSAGE_ACK = BaseContext(type=ContextType.MESSAGE_ACK) -_MESSAGE_CREATE = BaseContext(type=ContextType.MESSAGE_CREATE) -_MESSAGE_UPDATE = BaseContext(type=ContextType.MESSAGE_UPDATE) -_MESSAGE_APPEND = BaseContext(type=ContextType.MESSAGE_APPEND) -_MESSAGE_DELETE = BaseContext(type=ContextType.MESSAGE_DELETE) -_MESSAGE_REACT = BaseContext(type=ContextType.MESSAGE_REACT) -_MESSAGE_UNREACT = BaseContext(type=ContextType.MESSAGE_UNREACT) -_MESSAGE_REMOVE_REACTION = BaseContext(type=ContextType.MESSAGE_REMOVE_REACTION) -_MESSAGE_BULK_DELETE = BaseContext(type=ContextType.MESSAGE_BULK_DELETE) -_SERVER_CREATE = BaseContext(type=ContextType.SERVER_CREATE) -_SERVER_UPDATE = BaseContext(type=ContextType.SERVER_UPDATE) -_SERVER_DELETE = BaseContext(type=ContextType.SERVER_DELETE) -_SERVER_MEMBER_CREATE = BaseContext(type=ContextType.SERVER_MEMBER_CREATE) -_SERVER_MEMBER_UPDATE = BaseContext(type=ContextType.SERVER_MEMBER_UPDATE) -_SERVER_MEMBER_DELETE = BaseContext(type=ContextType.SERVER_MEMBER_DELETE) -_SERVER_ROLE_UPDATE = BaseContext(type=ContextType.SERVER_ROLE_UPDATE) -_SERVER_ROLE_DELETE = BaseContext(type=ContextType.SERVER_ROLE_DELETE) -_USER_UPDATE = BaseContext(type=ContextType.USER_UPDATE) -_USER_RELATIONSHIP_UPDATE = BaseContext(type=ContextType.USER_RELATIONSHIP_UPDATE) -_USER_PLATFORM_WIPE = BaseContext(type=ContextType.USER_PLATFORM_WIPE) -_EMOJI_CREATE = BaseContext(type=ContextType.EMOJI_CREATE) -_EMOJI_DELETE = BaseContext(type=ContextType.EMOJI_DELETE) -_CHANNEL_CREATE = BaseContext(type=ContextType.CHANNEL_CREATE) -_CHANNEL_UPDATE = BaseContext(type=ContextType.CHANNEL_UPDATE) -_CHANNEL_DELETE = BaseContext(type=ContextType.CHANNEL_DELETE) -_CHANNEL_GROUP_JOIN = BaseContext(type=ContextType.CHANNEL_GROUP_JOIN) -_CHANNEL_GROUP_LEAVE = BaseContext(type=ContextType.CHANNEL_GROUP_LEAVE) +_MESSAGE_ACK = BaseContext(type=ContextType.message_ack) +_MESSAGE_CREATE = BaseContext(type=ContextType.message_create) +_MESSAGE_UPDATE = BaseContext(type=ContextType.message_update) +_MESSAGE_APPEND = BaseContext(type=ContextType.message_append) +_MESSAGE_DELETE = BaseContext(type=ContextType.message_delete) +_MESSAGE_REACT = BaseContext(type=ContextType.message_react) +_MESSAGE_UNREACT = BaseContext(type=ContextType.message_unreact) +_MESSAGE_REMOVE_REACTION = BaseContext(type=ContextType.message_remove_reaction) +_MESSAGE_BULK_DELETE = BaseContext(type=ContextType.message_bulk_delete) +_SERVER_CREATE = BaseContext(type=ContextType.server_create) +_SERVER_UPDATE = BaseContext(type=ContextType.server_update) +_SERVER_DELETE = BaseContext(type=ContextType.server_delete) +_SERVER_MEMBER_CREATE = BaseContext(type=ContextType.server_member_create) +_SERVER_MEMBER_UPDATE = BaseContext(type=ContextType.server_member_update) +_SERVER_MEMBER_DELETE = BaseContext(type=ContextType.server_member_delete) +_SERVER_ROLE_UPDATE = BaseContext(type=ContextType.server_role_update) +_SERVER_ROLE_DELETE = BaseContext(type=ContextType.server_role_delete) +_USER_UPDATE = BaseContext(type=ContextType.user_update) +_USER_RELATIONSHIP_UPDATE = BaseContext(type=ContextType.user_relationship_update) +_USER_PLATFORM_WIPE = BaseContext(type=ContextType.user_platform_wipe) +_EMOJI_CREATE = BaseContext(type=ContextType.emoji_create) +_EMOJI_DELETE = BaseContext(type=ContextType.emoji_delete) +_CHANNEL_CREATE = BaseContext(type=ContextType.channel_create) +_CHANNEL_UPDATE = BaseContext(type=ContextType.channel_update) +_CHANNEL_DELETE = BaseContext(type=ContextType.channel_delete) +_CHANNEL_GROUP_JOIN = BaseContext(type=ContextType.channel_group_join) +_CHANNEL_GROUP_LEAVE = BaseContext(type=ContextType.channel_group_leave) + + +ProvideCacheContextIn = typing.Literal[ + 'DMChannel.recipients', + 'DMChannel.get_initiator', + 'DMChannel.get_target', + 'GroupChannel.get_owner', + 'GroupChannel.last_message', + 'GroupChannel.recipients', + 'BaseServerChannel.get_server', + 'ServerEmoji.get_server', + # TODO: events + 'BaseMessage.channel', + 'UserAddedSystemEvent.get_user', + 'UserAddedSystemEvent.get_by', + 'UserRemovedSystemEvent.get_user', + 'UserRemovedSystemEvent.get_by', + 'UserJoinedSystemEvent.get_user', + 'UserLeftSystemEvent.get_user', + 'UserKickedSystemEvent.get_user', + 'UserBannedSystemEvent.get_user', + 'ChannelRenamedSystemEvent.get_by', + 'ChannelDescriptionChangedSystemEvent.get_by', + 'ChannelIconChangedSystemEvent.get_by', + 'ChannelOwnershipChangedSystemEvent.get_from', + 'ChannelOwnershipChangedSystemEvent.get_to', + 'MessagePinnedSystemEvent.get_by', + 'MessageUnpinnedSystemEvent.get_by', + 'Message.get_author', + 'ReadState.get_channel', + 'BaseRole.get_server', + 'Server.get_owner', + 'BaseMember.get_server', + 'BaseUser.dm_channel_id', + 'BaseUser.dm_channel', + 'Webhook.channel', +] class Cache(abc.ABC): @@ -122,124 +180,110 @@ class Cache(abc.ABC): ############ @abc.abstractmethod - def get_channel( - self, channel_id: core.ULID, ctx: BaseContext, / - ) -> channels.Channel | None: ... + def get_channel(self, channel_id: str, ctx: BaseContext, /) -> Channel | None: ... - def get_all_channels(self, ctx: BaseContext, /) -> list[channels.Channel]: + def get_all_channels(self, ctx: BaseContext, /) -> list[Channel]: return list(self.get_channels_mapping().values()) @abc.abstractmethod - def get_channels_mapping(self) -> dict[core.ULID, channels.Channel]: ... + def get_channels_mapping(self) -> dict[str, Channel]: ... @abc.abstractmethod - def store_channel(self, channel: channels.Channel, ctx: BaseContext, /) -> None: ... + def store_channel(self, channel: Channel, ctx: BaseContext, /) -> None: ... @abc.abstractmethod - def delete_channel(self, channel_id: core.ULID, ctx: BaseContext, /) -> None: ... + def delete_channel(self, channel_id: str, ctx: BaseContext, /) -> None: ... + + @abc.abstractmethod + def get_private_channels_mapping(self) -> dict[str, DMChannel | GroupChannel]: ... ############### # Read States # ############### @abc.abstractmethod - def get_read_state( - self, channel_id: core.ULID, ctx: BaseContext, / - ) -> read_states.ReadState | None: ... + def get_read_state(self, channel_id: str, ctx: BaseContext, /) -> ReadState | None: ... - def get_all_read_states(self, ctx: BaseContext, /) -> list[read_states.ReadState]: + def get_all_read_states(self, ctx: BaseContext, /) -> list[ReadState]: return list(self.get_read_states_mapping().values()) @abc.abstractmethod - def get_read_states_mapping(self) -> dict[core.ULID, read_states.ReadState]: ... + def get_read_states_mapping(self) -> dict[str, ReadState]: ... @abc.abstractmethod - def store_read_state( - self, read_state: read_states.ReadState, ctx: BaseContext, / - ) -> None: ... + def store_read_state(self, read_state: ReadState, ctx: BaseContext, /) -> None: ... @abc.abstractmethod - def delete_read_state(self, channel_id: core.ULID, ctx: BaseContext, /) -> None: ... + def delete_read_state(self, channel_id: str, ctx: BaseContext, /) -> None: ... ########## # Emojis # ########## @abc.abstractmethod - def get_emoji( - self, emoji_id: core.ULID, ctx: BaseContext, / - ) -> emojis.Emoji | None: ... + def get_emoji(self, emoji_id: str, ctx: BaseContext, /) -> Emoji | None: ... - def get_all_emojis(self, ctx: BaseContext, /) -> list[emojis.Emoji]: + def get_all_emojis(self, ctx: BaseContext, /) -> list[Emoji]: return list(self.get_emojis_mapping().values()) @abc.abstractmethod - def get_emojis_mapping(self) -> dict[core.ULID, emojis.Emoji]: ... + def get_emojis_mapping(self) -> dict[str, Emoji]: ... @abc.abstractmethod def get_server_emojis_mapping( self, - ) -> dict[core.ULID, dict[core.ULID, emojis.ServerEmoji]]: ... + ) -> dict[str, dict[str, ServerEmoji]]: ... @abc.abstractmethod - def get_server_emojis_mapping_of( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> dict[core.ULID, emojis.ServerEmoji] | None: ... + def get_server_emojis_mapping_of(self, server_id: str, ctx: BaseContext, /) -> dict[str, ServerEmoji] | None: ... @abc.abstractmethod - def store_emoji(self, emoji: emojis.Emoji, ctx: BaseContext, /) -> None: ... + def delete_server_emojis_of(self, server_id: str, ctx: BaseContext, /) -> None: ... @abc.abstractmethod - def delete_emoji( - self, emoji_id: core.ULID, server_id: core.ULID | None, ctx: BaseContext, / - ) -> None: ... + def store_emoji(self, emoji: Emoji, ctx: BaseContext, /) -> None: ... + + @abc.abstractmethod + def delete_emoji(self, emoji_id: str, server_id: str | None, ctx: BaseContext, /) -> None: ... ########### # Servers # ########### @abc.abstractmethod - def get_server( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> servers.Server | None: ... + def get_server(self, server_id: str, ctx: BaseContext, /) -> Server | None: ... - def get_all_servers(self, ctx: BaseContext, /) -> list[servers.Server]: + def get_all_servers(self, ctx: BaseContext, /) -> list[Server]: return list(self.get_servers_mapping().values()) @abc.abstractmethod - def get_servers_mapping(self) -> dict[core.ULID, servers.Server]: ... + def get_servers_mapping(self) -> dict[str, Server]: ... @abc.abstractmethod - def store_server(self, server: servers.Server, ctx: BaseContext, /) -> None: ... + def store_server(self, server: Server, ctx: BaseContext, /) -> None: ... @abc.abstractmethod - def delete_server(self, server_id: core.ULID, ctx: BaseContext, /) -> None: ... + def delete_server(self, server_id: str, ctx: BaseContext, /) -> None: ... ################## # Server Members # ################## @abc.abstractmethod - def get_server_member( - self, server_id: core.ULID, user_id: core.ULID, ctx: BaseContext, / - ) -> servers.Member | None: ... + def get_server_member(self, server_id: str, user_id: str, ctx: BaseContext, /) -> Member | None: ... - def get_all_server_members_of( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> list[servers.Member] | None: + def get_all_server_members_of(self, server_id: str, ctx: BaseContext, /) -> list[Member] | None: ms = self.get_server_members_mapping_of(server_id, ctx) if ms is None: return None return list(ms.values()) @abc.abstractmethod - def get_server_members_mapping_of( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> dict[core.ULID, servers.Member] | None: ... + def get_server_members_mapping_of(self, server_id: str, ctx: BaseContext, /) -> dict[str, Member] | None: ... @abc.abstractmethod def bulk_store_server_members( self, - server_id: core.ULID, - members: dict[core.ULID, servers.Member], + server_id: str, + members: dict[str, Member], ctx: BaseContext, /, ) -> None: ... @@ -247,44 +291,58 @@ def bulk_store_server_members( @abc.abstractmethod def overwrite_server_members( self, - server_id: core.ULID, - members: dict[core.ULID, servers.Member], + server_id: str, + members: dict[str, Member], ctx: BaseContext, /, ) -> None: ... @abc.abstractmethod - def store_server_member( - self, member: servers.Member, ctx: BaseContext, / - ) -> None: ... + def store_server_member(self, member: Member, ctx: BaseContext, /) -> None: ... @abc.abstractmethod - def delete_server_member( - self, server_id: core.ULID, user_id: core.ULID, ctx: BaseContext, / - ) -> None: ... + def delete_server_member(self, server_id: str, user_id: str, ctx: BaseContext, /) -> None: ... + + @abc.abstractmethod + def delete_server_members_of(self, server_id: str, ctx: BaseContext, /) -> None: ... ######### # Users # ######### @abc.abstractmethod - def get_user( - self, user_id: core.ULID, ctx: BaseContext, / - ) -> users.User | None: ... + def get_user(self, user_id: str, ctx: BaseContext, /) -> User | None: ... - def get_all_users(self, ctx: BaseContext, /) -> list[users.User]: + def get_all_users(self, ctx: BaseContext, /) -> list[User]: return list(self.get_users_mapping().values()) @abc.abstractmethod - def get_users_mapping(self) -> dict[core.ULID, users.User]: ... + def get_users_mapping(self) -> dict[str, User]: ... @abc.abstractmethod - def store_user(self, user: users.User, ctx: BaseContext, /) -> None: ... + def store_user(self, user: User, ctx: BaseContext, /) -> None: ... @abc.abstractmethod - def bulk_store_users( - self, users: dict[core.ULID, users.User], ctx: BaseContext, / - ) -> None: ... + def bulk_store_users(self, users: dict[str, User], ctx: BaseContext, /) -> None: ... + + ############################ + # Private Channels by User # + ############################ + @abc.abstractmethod + def get_private_channel_by_user(self, user_id: str, ctx: BaseContext, /) -> str | None: ... + + def get_all_private_channels_by_users(self, ctx: BaseContext, /) -> list[str]: + return list(self.get_private_channels_by_users_mapping().values()) + + @abc.abstractmethod + def get_private_channels_by_users_mapping(self) -> dict[str, str]: ... + + @abc.abstractmethod + def store_private_channel_by_user(self, channel: DMChannel, ctx: BaseContext, /) -> None: ... + + # Should be implemented in `delete_channel`, or in event + @abc.abstractmethod + def delete_private_channel_by_user(self, user_id: str, ctx: BaseContext, /) -> None: ... class EmptyCache(Cache): @@ -292,104 +350,92 @@ class EmptyCache(Cache): # Channels # ############ - def get_channel( - self, channel_id: core.ULID, ctx: BaseContext, / - ) -> channels.Channel | None: + def get_channel(self, channel_id: str, ctx: BaseContext, /) -> Channel | None: return None - def get_channels_mapping(self) -> dict[core.ULID, channels.Channel]: + def get_channels_mapping(self) -> dict[str, Channel]: return {} - def store_channel(self, channel: channels.Channel, ctx: BaseContext, /) -> None: + def store_channel(self, channel: Channel, ctx: BaseContext, /) -> None: pass - def delete_channel(self, channel_id: core.ULID, ctx: BaseContext, /) -> None: + def delete_channel(self, channel_id: str, ctx: BaseContext, /) -> None: pass + def get_private_channels_mapping(self) -> dict[str, DMChannel | GroupChannel]: + return {} + ############### # Read States # ############### - def get_read_state( - self, channel_id: core.ULID, ctx: BaseContext, / - ) -> read_states.ReadState | None: + def get_read_state(self, channel_id: str, ctx: BaseContext, /) -> ReadState | None: return None - def get_read_states_mapping(self) -> dict[core.ULID, read_states.ReadState]: + def get_read_states_mapping(self) -> dict[str, ReadState]: return {} - def store_read_state( - self, read_state: read_states.ReadState, ctx: BaseContext, / - ) -> None: + def store_read_state(self, read_state: ReadState, ctx: BaseContext, /) -> None: pass - def delete_read_state(self, channel_id: core.ULID, ctx: BaseContext, /) -> None: + def delete_read_state(self, channel_id: str, ctx: BaseContext, /) -> None: pass ########## # Emojis # ########## - def get_emoji( - self, emoji_id: core.ULID, ctx: BaseContext, / - ) -> emojis.Emoji | None: + def get_emoji(self, emoji_id: str, ctx: BaseContext, /) -> Emoji | None: return None - def get_emojis_mapping(self) -> dict[core.ULID, emojis.Emoji]: + def get_emojis_mapping(self) -> dict[str, Emoji]: return {} def get_server_emojis_mapping( self, - ) -> dict[core.ULID, dict[core.ULID, emojis.ServerEmoji]]: + ) -> dict[str, dict[str, ServerEmoji]]: return {} - def get_server_emojis_mapping_of( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> dict[core.ULID, emojis.ServerEmoji] | None: + def get_server_emojis_mapping_of(self, server_id: str, ctx: BaseContext, /) -> dict[str, ServerEmoji] | None: return None - def store_emoji(self, emoji: emojis.Emoji, ctx: BaseContext, /) -> None: + def delete_server_emojis_of(self, server_id: str, ctx: BaseContext, /) -> None: pass - def delete_emoji( - self, emoji_id: core.ULID, server_id: core.ULID | None, ctx: BaseContext, / - ) -> None: + def store_emoji(self, emoji: Emoji, ctx: BaseContext, /) -> None: + pass + + def delete_emoji(self, emoji_id: str, server_id: str | None, ctx: BaseContext, /) -> None: pass ########### # Servers # ########### - def get_server( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> servers.Server | None: + def get_server(self, server_id: str, ctx: BaseContext, /) -> Server | None: return None - def get_servers_mapping(self) -> dict[core.ULID, servers.Server]: + def get_servers_mapping(self) -> dict[str, Server]: return {} - def store_server(self, server: servers.Server, ctx: BaseContext, /) -> None: + def store_server(self, server: Server, ctx: BaseContext, /) -> None: pass - def delete_server(self, server_id: core.ULID, ctx: BaseContext, /) -> None: + def delete_server(self, server_id: str, ctx: BaseContext, /) -> None: pass ################## # Server Members # ################## - def get_server_member( - self, server_id: core.ULID, user_id: core.ULID, ctx: BaseContext, / - ) -> servers.Member | None: + def get_server_member(self, server_id: str, user_id: str, ctx: BaseContext, /) -> Member | None: return None - def get_server_members_mapping_of( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> dict[core.ULID, servers.Member] | None: + def get_server_members_mapping_of(self, server_id: str, ctx: BaseContext, /) -> dict[str, Member] | None: return None def bulk_store_server_members( self, - server_id: core.ULID, - members: dict[core.ULID, servers.Member], + server_id: str, + members: dict[str, Member], ctx: BaseContext, /, ) -> None: @@ -397,46 +443,58 @@ def bulk_store_server_members( def overwrite_server_members( self, - server_id: core.ULID, - members: dict[core.ULID, servers.Member], + server_id: str, + members: dict[str, Member], ctx: BaseContext, /, ) -> None: pass - def store_server_member(self, member: servers.Member, ctx: BaseContext, /) -> None: + def store_server_member(self, member: Member, ctx: BaseContext, /) -> None: pass - def delete_server_member( - self, server_id: core.ULID, user_id: core.ULID, ctx: BaseContext, / - ) -> None: + def delete_server_member(self, server_id: str, user_id: str, ctx: BaseContext, /) -> None: + pass + + def delete_server_members_of(self, server_id: str, ctx: BaseContext, /) -> None: pass ######### # Users # ######### - def get_user(self, user_id: core.ULID, ctx: BaseContext, /) -> users.User | None: + def get_user(self, user_id: str, ctx: BaseContext, /) -> User | None: return None - def get_users_mapping(self) -> dict[core.ULID, users.User]: + def get_users_mapping(self) -> dict[str, User]: return {} - def store_user(self, user: users.User, ctx: BaseContext, /) -> None: + def store_user(self, user: User, ctx: BaseContext, /) -> None: return None - def bulk_store_users( - self, users: dict[core.ULID, users.User], ctx: BaseContext, / - ) -> None: + def bulk_store_users(self, users: dict[str, User], ctx: BaseContext, /) -> None: + pass + + ############################ + # Private Channels by User # + ############################ + def get_private_channel_by_user(self, user_id: str, ctx: BaseContext, /) -> str | None: + return None + + def get_private_channels_by_users_mapping(self) -> dict[str, str]: + return {} + + def store_private_channel_by_user(self, channel: DMChannel, ctx: BaseContext, /) -> None: + pass + + def delete_private_channel_by_user(self, user_id: str, ctx: BaseContext, /) -> None: pass -V = t.TypeVar("V") +V = typing.TypeVar('V') -def _put0( - d: dict[core.ULID, V], k: core.ULID, max_size: int, required_keys: int = 1 -) -> bool: +def _put0(d: dict[str, V], k: str, max_size: int, required_keys: int = 1) -> bool: if max_size == 0: return False map_size = len(d) @@ -453,57 +511,67 @@ def _put0( return True -def _put1(d: dict[core.ULID, V], k: core.ULID, v: V, max_size: int) -> None: +def _put1(d: dict[str, V], k: str, v: V, max_size: int) -> None: if _put0(d, k, max_size): d[k] = v class MapCache(Cache): - _channels: dict[core.ULID, channels.Channel] + _channels: dict[str, Channel] _channels_max_size: int - _read_states: dict[core.ULID, read_states.ReadState] + _read_states: dict[str, ReadState] _read_states_max_size: int - _emojis: dict[core.ULID, emojis.Emoji] + _emojis: dict[str, Emoji] _emojis_max_size: int - _server_emojis: dict[core.ULID, dict[core.ULID, emojis.ServerEmoji]] + _server_emojis: dict[str, dict[str, ServerEmoji]] _server_emojis_max_size: int - _servers: dict[core.ULID, servers.Server] + _servers: dict[str, Server] _servers_max_size: int - _server_members: dict[core.ULID, dict[core.ULID, servers.Member]] + _server_members: dict[str, dict[str, Member]] _server_members_max_size: int - _users: dict[core.ULID, users.User] + _users: dict[str, User] _users_max_size: int + _private_channels_by_user: dict[str, str] + _private_channels_by_user_max_size: int __slots__ = ( - "_channels", - "_channels_max_size", - "_read_states", - "_read_states_max_size", - "_emojis", - "_emojis_max_size", - "_server_emojis", - "_server_emojis_max_size", - "_servers", - "_servers_max_size", - "_server_members", - "_server_members_max_size", - "_users", - "_users_max_size", + '_channels', + '_channels_max_size', + '_private_channels', + '_private_channels_max_size', + '_read_states', + '_read_states_max_size', + '_emojis', + '_emojis_max_size', + '_server_emojis', + '_server_emojis_max_size', + '_servers', + '_servers_max_size', + '_server_members', + '_server_members_max_size', + '_users', + '_users_max_size', + '_private_channels_by_user', + '_private_channels_by_user_max_size', ) def __init__( self, *, channels_max_size: int = -1, + private_channels_max_size: int = -1, read_states_max_size: int = -1, emojis_max_size: int = -1, server_emojis_max_size: int = -1, servers_max_size: int = -1, server_members_max_size: int = -1, users_max_size: int = -1, + private_channels_by_user_max_size: int = -1, ) -> None: self._channels = {} self._channels_max_size = channels_max_size + self._private_channels = {} + self._private_channels_max_size = private_channels_max_size self._read_states = {} self._read_states_max_size = read_states_max_size self._emojis = {} @@ -516,55 +584,43 @@ def __init__( self._server_members_max_size = server_members_max_size self._users = {} self._users_max_size = users_max_size + self._private_channels_by_user = {} + self._private_channels_by_user_max_size = private_channels_by_user_max_size ############ # Channels # ############ - def get_channel( - self, channel_id: core.ULID, ctx: BaseContext, / - ) -> channels.Channel | None: + def get_channel(self, channel_id: str, ctx: BaseContext, /) -> Channel | None: return self._channels.get(channel_id) - def get_channels_mapping(self) -> dict[core.ULID, channels.Channel]: + def get_channels_mapping(self) -> dict[str, Channel]: return self._channels - def store_channel(self, channel: channels.Channel, ctx: BaseContext, /) -> None: - from . import channel as channels - - if isinstance(channel, channels.ServerChannel): - server = channel.get_server() - if server: - server.internal_channels[1].append(channel.id) # type: ignore # Cached servers always have IDs instead + def store_channel(self, channel: Channel, ctx: BaseContext, /) -> None: _put1(self._channels, channel.id, channel, self._channels_max_size) - def delete_channel(self, channel_id: core.ULID, ctx: BaseContext, /) -> None: - from . import channel as channels + from .channel import DMChannel, GroupChannel - channel = self._channels.get(channel_id) - if isinstance(channel, channels.ServerChannel): - server = channel.get_server() - if server: - for i, server_channel in enumerate(server.internal_channels[1]): - if server_channel == channel_id: - del server.internal_channels[1][i] - break - del self._channels[channel_id] + if isinstance(channel, (DMChannel, GroupChannel)): + _put1(self._private_channels, channel.id, channel, self._private_channels_max_size) + + def delete_channel(self, channel_id: str, ctx: BaseContext, /) -> None: + self._channels.pop(channel_id, None) + + def get_private_channels_mapping(self) -> dict[str, DMChannel | GroupChannel]: + return self._private_channels ############### # Read States # ############### - def get_read_state( - self, channel_id: core.ULID, ctx: BaseContext, / - ) -> read_states.ReadState | None: + def get_read_state(self, channel_id: str, ctx: BaseContext, /) -> ReadState | None: return self._read_states.get(channel_id) - def get_read_states_mapping(self) -> dict[core.ULID, read_states.ReadState]: + def get_read_states_mapping(self) -> dict[str, ReadState]: return self._read_states - def store_read_state( - self, read_state: read_states.ReadState, ctx: BaseContext, / - ) -> None: + def store_read_state(self, read_state: ReadState, ctx: BaseContext, /) -> None: _put1( self._read_states, read_state.channel_id, @@ -572,53 +628,45 @@ def store_read_state( self._read_states_max_size, ) - def delete_read_state(self, channel_id: core.ULID, ctx: BaseContext, /) -> None: - del self._read_states[channel_id] + def delete_read_state(self, channel_id: str, ctx: BaseContext, /) -> None: + self._read_states.pop(channel_id, None) ########## # Emojis # ########## - def get_emoji( - self, emoji_id: core.ULID, ctx: BaseContext, / - ) -> emojis.Emoji | None: + def get_emoji(self, emoji_id: str, ctx: BaseContext, /) -> Emoji | None: return self._emojis.get(emoji_id) - def get_emojis_mapping(self) -> dict[core.ULID, emojis.Emoji]: + def get_emojis_mapping(self) -> dict[str, Emoji]: return self._emojis def get_server_emojis_mapping( self, - ) -> dict[core.ULID, dict[core.ULID, emojis.ServerEmoji]]: + ) -> dict[str, dict[str, ServerEmoji]]: return self._server_emojis - def get_server_emojis_mapping_of( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> dict[core.ULID, emojis.ServerEmoji] | None: + def get_server_emojis_mapping_of(self, server_id: str, ctx: BaseContext, /) -> dict[str, ServerEmoji] | None: return self._server_emojis.get(server_id) - def store_emoji(self, emoji: emojis.Emoji, ctx: BaseContext, /) -> None: - if isinstance(emoji, emojis.ServerEmoji): + def delete_server_emojis_of(self, server_id: str, ctx: BaseContext, /) -> None: + self._server_emojis.pop(server_id) + + def store_emoji(self, emoji: Emoji, ctx: BaseContext, /) -> None: + if isinstance(emoji, ServerEmoji): server_id = emoji.server_id if _put0(self._server_emojis, server_id, self._server_emojis_max_size): self._server_emojis[server_id] = { - **(self._server_emojis.get(server_id, {})), + **self._server_emojis.get(server_id, {}), emoji.id: emoji, } _put1(self._emojis, emoji.id, emoji, self._emojis_max_size) - def delete_emoji( - self, emoji_id: core.ULID, server_id: core.ULID | None, ctx: BaseContext, / - ) -> None: - emoji = self._emojis.get(emoji_id) - - try: - del self._emojis[emoji_id] - except KeyError: - pass + def delete_emoji(self, emoji_id: str, server_id: str | None, ctx: BaseContext, /) -> None: + emoji = self._emojis.pop(emoji_id, None) - server_ids: tuple[core.ULID, ...] = () - if isinstance(emoji, emojis.ServerEmoji): + server_ids: tuple[str, ...] = () + if isinstance(emoji, ServerEmoji): if server_id: server_ids = (server_id, emoji.server_id) else: @@ -626,56 +674,48 @@ def delete_emoji( for server_id in server_ids: server_emojis = self._server_emojis.get(server_id, {}) - try: - del server_emojis[emoji_id] - except KeyError: - pass + server_emojis.pop(emoji_id, None) ########### # Servers # ########### - def get_server( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> servers.Server | None: + def get_server(self, server_id: str, ctx: BaseContext, /) -> Server | None: return self._servers.get(server_id) - def get_servers_mapping(self) -> dict[core.ULID, servers.Server]: + def get_servers_mapping(self) -> dict[str, Server]: return self._servers - def store_server(self, server: servers.Server, ctx: BaseContext, /) -> None: - _put1(self._server_emojis, server.id, {}, self._server_emojis_max_size) + def store_server(self, server: Server, ctx: BaseContext, /) -> None: + if server.id not in self._server_emojis: + _put1(self._server_emojis, server.id, {}, self._server_emojis_max_size) + if ( _put0(self._server_members, server.id, self._server_members_max_size) and server.id not in self._server_members ): self._server_members[server.id] = {} - server._ensure_cached() _put1(self._servers, server.id, server, self._servers_max_size) - def delete_server(self, server_id: core.ULID, ctx: BaseContext, /) -> None: - del self._servers[server_id] + def delete_server(self, server_id: str, ctx: BaseContext, /) -> None: + self._servers.pop(server_id, None) ################## # Server Members # ################## - def get_server_member( - self, server_id: core.ULID, user_id: core.ULID, ctx: BaseContext, / - ) -> servers.Member | None: + def get_server_member(self, server_id: str, user_id: str, ctx: BaseContext, /) -> Member | None: d = self._server_members.get(server_id) if d is None: return None return d.get(user_id) - def get_server_members_mapping_of( - self, server_id: core.ULID, ctx: BaseContext, / - ) -> dict[core.ULID, servers.Member] | None: + def get_server_members_mapping_of(self, server_id: str, ctx: BaseContext, /) -> dict[str, Member] | None: return self._server_members.get(server_id) def bulk_store_server_members( self, - server_id: core.ULID, - members: dict[core.ULID, servers.Member], + server_id: str, + members: dict[str, Member], ctx: BaseContext, /, ) -> None: @@ -687,15 +727,15 @@ def bulk_store_server_members( def overwrite_server_members( self, - server_id: core.ULID, - members: dict[core.ULID, servers.Member], + server_id: str, + members: dict[str, Member], ctx: BaseContext, /, ) -> None: self._server_members[server_id] = members - def store_server_member(self, member: servers.Member, ctx: BaseContext, /) -> None: - if isinstance(member._user, users.User): + def store_server_member(self, member: Member, ctx: BaseContext, /) -> None: + if isinstance(member._user, User): self.store_user(member._user, ctx) member._user = member._user.id d = self._server_members.get(member.server_id) @@ -704,70 +744,85 @@ def store_server_member(self, member: servers.Member, ctx: BaseContext, /) -> No else: d[member.id] = member - def delete_server_member( - self, server_id: core.ULID, user_id: core.ULID, ctx: BaseContext, / - ) -> None: + def delete_server_member(self, server_id: str, user_id: str, ctx: BaseContext, /) -> None: members = self._server_members.get(server_id) if members: - del members[user_id] + members.pop(user_id, None) + + def delete_server_members_of(self, server_id: str, ctx: BaseContext, /) -> None: + self._server_members.pop(server_id, None) ######### # Users # ######### - def get_user(self, user_id: core.ULID, ctx: BaseContext, /) -> users.User | None: + def get_user(self, user_id: str, ctx: BaseContext, /) -> User | None: return self._users.get(user_id) - def get_users_mapping(self) -> dict[core.ULID, users.User]: + def get_users_mapping(self) -> dict[str, User]: return self._users - def store_user(self, user: users.User, ctx: BaseContext, /) -> None: + def store_user(self, user: User, ctx: BaseContext, /) -> None: _put1(self._users, user.id, user, self._users_max_size) - def bulk_store_users( - self, users: dict[core.ULID, users.User], ctx: BaseContext, / - ) -> None: + def bulk_store_users(self, users: dict[str, User], ctx: BaseContext, /) -> None: self._users.update(users) + ############################ + # Private Channels by User # + ############################ + def get_private_channel_by_user(self, user_id: str, ctx: BaseContext, /) -> str | None: + return self._private_channels_by_user.get(user_id) + + def get_private_channels_by_users_mapping(self) -> dict[str, str]: + return self._private_channels_by_user + + def store_private_channel_by_user(self, channel: DMChannel, ctx: BaseContext, /) -> None: + _put1(self._private_channels_by_user, channel.recipient_id, channel.id, self._private_channels_by_user_max_size) + + def delete_private_channel_by_user(self, user_id: str, ctx: BaseContext, /) -> None: + self._private_channels_by_user.pop(user_id, None) + # re-export internal functions as well for future usage __all__ = ( - "ContextType", - "BaseContext", - "MessageContext", - "_UNDEFINED", - "_USER_REQUEST", - "_READY", - "_MESSAGE_ACK", - "_MESSAGE_CREATE", - "_MESSAGE_UPDATE", - "_MESSAGE_APPEND", - "_MESSAGE_DELETE", - "_MESSAGE_REACT", - "_MESSAGE_UNREACT", - "_MESSAGE_REMOVE_REACTION", - "_MESSAGE_BULK_DELETE", - "_SERVER_CREATE", - "_SERVER_UPDATE", - "_SERVER_DELETE", - "_SERVER_MEMBER_CREATE", - "_SERVER_MEMBER_UPDATE", - "_SERVER_MEMBER_DELETE", - "_SERVER_ROLE_UPDATE", - "_SERVER_ROLE_DELETE", - "_USER_UPDATE", - "_USER_RELATIONSHIP_UPDATE", - "_USER_PLATFORM_WIPE", - "_EMOJI_CREATE", - "_EMOJI_DELETE", - "_CHANNEL_CREATE", - "_CHANNEL_UPDATE", - "_CHANNEL_DELETE", - "_CHANNEL_GROUP_JOIN", - "_CHANNEL_GROUP_LEAVE", - "Cache", - "EmptyCache", - "_put0", - "_put1", - "MapCache", + 'ContextType', + 'BaseContext', + 'MessageContext', + '_UNDEFINED', + '_USER_REQUEST', + '_READY', + '_MESSAGE_ACK', + '_MESSAGE_CREATE', + '_MESSAGE_UPDATE', + '_MESSAGE_APPEND', + '_MESSAGE_DELETE', + '_MESSAGE_REACT', + '_MESSAGE_UNREACT', + '_MESSAGE_REMOVE_REACTION', + '_MESSAGE_BULK_DELETE', + '_SERVER_CREATE', + '_SERVER_UPDATE', + '_SERVER_DELETE', + '_SERVER_MEMBER_CREATE', + '_SERVER_MEMBER_UPDATE', + '_SERVER_MEMBER_DELETE', + '_SERVER_ROLE_UPDATE', + '_SERVER_ROLE_DELETE', + '_USER_UPDATE', + '_USER_RELATIONSHIP_UPDATE', + '_USER_PLATFORM_WIPE', + '_EMOJI_CREATE', + '_EMOJI_DELETE', + '_CHANNEL_CREATE', + '_CHANNEL_UPDATE', + '_CHANNEL_DELETE', + '_CHANNEL_GROUP_JOIN', + '_CHANNEL_GROUP_LEAVE', + 'ProvideCacheContextIn', + 'Cache', + 'EmptyCache', + '_put0', + '_put1', + 'MapCache', ) diff --git a/pyvolt/cdn.py b/pyvolt/cdn.py index edc53d3..df0a1ab 100644 --- a/pyvolt/cdn.py +++ b/pyvolt/cdn.py @@ -1,17 +1,43 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import abc import aiohttp from attrs import define, field -from enum import StrEnum import io import logging -import typing as t +import typing from urllib.parse import quote -from . import core, errors, utils +from . import utils +from .core import __version__ as version +from .enums import AssetMetadataType +from .errors import HTTPException -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from .state import State import typing_extensions as te @@ -19,33 +45,16 @@ _L = logging.getLogger(__name__) -class AssetMetadataType(StrEnum): - FILE = "File" - """File is just a generic uncategorised file.""" - - TEXT = "Text" - """File contains textual data and should be displayed as such.""" - - IMAGE = "Image" - """File is an image with specific dimensions.""" - - VIDEO = "Video" - """File is a video with specific dimensions.""" - - AUDIO = "Audio" - """File is audio.""" - - @define(slots=True) class AssetMetadata: """Metadata associated with a file.""" - type: AssetMetadataType = field(repr=True, hash=True, kw_only=True, eq=True) - width: int | None = field(repr=True, hash=True, kw_only=True, eq=True) - height: int | None = field(repr=True, hash=True, kw_only=True, eq=True) + type: AssetMetadataType = field(repr=True, kw_only=True, eq=True) + width: int | None = field(repr=True, kw_only=True, eq=True) + height: int | None = field(repr=True, kw_only=True, eq=True) -Tag = t.Literal["icons", "banners", "emojis", "backgrounds", "avatars", "attachments"] +Tag = typing.Literal['icons', 'banners', 'emojis', 'backgrounds', 'avatars', 'attachments'] @define(slots=True) @@ -55,39 +64,45 @@ class StatelessAsset: For better user experience, prefer using `parent.foo` rather than `parent.internal_foo`. """ - id: str = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True) """Unique ID.""" - filename: str = field(repr=True, hash=True, kw_only=True, eq=True) + filename: str = field(repr=True, kw_only=True) """Original filename.""" - metadata: AssetMetadata = field(repr=True, hash=True, kw_only=True, eq=True) + metadata: AssetMetadata = field(repr=True, kw_only=True) """Parsed metadata of this file.""" - content_type: str = field(repr=True, hash=True, kw_only=True, eq=True) + content_type: str = field(repr=True, kw_only=True) """Raw content type of this file.""" - size: int = field(repr=True, hash=True, kw_only=True, eq=True) + size: int = field(repr=True, kw_only=True) """Size of this file (in bytes).""" - deleted: bool = field(repr=True, hash=True, kw_only=True, eq=True) + deleted: bool = field(repr=True, kw_only=True) """Whether this file was deleted.""" - reported: bool = field(repr=True, hash=True, kw_only=True, eq=True) + reported: bool = field(repr=True, kw_only=True) """Whether this file was reported.""" - message_id: core.ULID | None = field(repr=True, hash=True, kw_only=True, eq=True) + message_id: str | None = field(repr=True, kw_only=True) """ID of the message this file is associated with.""" - user_id: core.ULID | None = field(repr=True, hash=True, kw_only=True, eq=True) + user_id: str | None = field(repr=True, kw_only=True) """ID of the user this file is associated with.""" - server_id: core.ULID | None = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str | None = field(repr=True, kw_only=True) """ID of the server this file is associated with.""" - object_id: core.ULID | None = field(repr=True, hash=True, kw_only=True, eq=True) + object_id: str | None = field(repr=True, kw_only=True) """ID of the object this file is associated with.""" + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, StatelessAsset) and self.id == other.id + def _stateful(self, state: State, tag: Tag) -> Asset: return Asset( id=self.id, @@ -109,15 +124,33 @@ def _stateful(self, state: State, tag: Tag) -> Asset: @define(slots=True) class Asset(StatelessAsset): - state: "State" = field(repr=False, hash=False, kw_only=True, eq=False) - tag: Tag = field(repr=True, hash=True, kw_only=True, eq=True) + state: State = field(repr=False, hash=False, kw_only=True, eq=False) + tag: Tag = field(repr=True, kw_only=True) + + def __hash__(self) -> int: + return hash(self.id) - def url(self) -> str: + def url( + self, + *, + size: int | None = None, + width: int | None = None, + height: int | None = None, + max_side: int | None = None, + ) -> str: """:class:`str`: The asset URL.""" - url = f"{self.state.cdn_client.base}/{quote(self.tag)}/{quote(self.id)}" - return url + return self.state.cdn_client.url_for( + self.id, self.tag, size=size, width=width, height=height, max_side=max_side + ) - async def read(self) -> bytes: + async def read( + self, + *, + size: int | None = None, + width: int | None = None, + height: int | None = None, + max_side: int | None = None, + ) -> bytes: """|coro| Read asset contents. @@ -127,7 +160,9 @@ async def read(self) -> bytes: :class:`bytes` The asset contents. """ - return await self.state.cdn_client.read(self.tag, self.id) + return await self.state.cdn_client.read( + self.tag, self.id, size=size, width=width, height=height, max_side=max_side + ) class Resource(abc.ABC): @@ -139,9 +174,7 @@ async def upload(self, cdn_client: CDNClient, tag: Tag, /) -> str: _cdn_session: aiohttp.ClientSession | None = None -DEFAULT_USER_AGENT = ( - f"pyvolt CDN client (https://github.com/MCausc78/pyvolt, {core.__version__})" -) +DEFAULT_USER_AGENT = f'pyvolt CDN client (https://github.com/MCausc78/pyvolt, {version})' def _get_session() -> aiohttp.ClientSession: @@ -165,48 +198,53 @@ def resolve_content(content: Content) -> bytes | io.IOBase: class Upload(Resource): - """A file upload.""" - - content: bytes | io.IOBase - tag: Tag | None - filename: str + """Represents a file upload. + + Attributes + ---------- + content: Union[:class:`bytes`, :class:`~io.IOBase`] + The file contents. + tag: Optional[Tag] + The attachment tag. If none, this is determined automatically. + filename: :class:`str` + The file name. + """ - __slots__ = ("tag", "content", "filename") + __slots__ = ('tag', 'content', 'filename') - def __init__( - self, content: Content, *, tag: Tag | None = None, filename: str - ) -> None: + def __init__(self, content: Content, *, tag: Tag | None = None, filename: str) -> None: self.content = resolve_content(content) - self.tag = tag + # Pyright sucks massive balls here. + self.tag: Tag | None = tag self.filename = filename @classmethod def attachment(cls, content: Content, *, filename: str) -> te.Self: - return cls(content, tag="attachments", filename=filename) + return cls(content, tag='attachments', filename=filename) @classmethod def avatar(cls, content: Content, *, filename: str) -> te.Self: - return cls(content, tag="avatars", filename=filename) + return cls(content, tag='avatars', filename=filename) @classmethod def background(cls, content: Content, *, filename: str) -> te.Self: - return cls(content, tag="backgrounds", filename=filename) + return cls(content, tag='backgrounds', filename=filename) @classmethod def banner(cls, content: Content, *, filename: str) -> te.Self: - return cls(content, tag="banners", filename=filename) + return cls(content, tag='banners', filename=filename) @classmethod def emoji(cls, content: Content, *, filename: str) -> te.Self: - return cls(content, tag="emojis", filename=filename) + return cls(content, tag='emojis', filename=filename) @classmethod def icon(cls, content: Content, *, filename: str) -> te.Self: - return cls(content, tag="icons", filename=filename) + return cls(content, tag='icons', filename=filename) - async def upload(self, cdn_client: "CDNClient", tag: Tag, /) -> str: + async def upload(self, cdn_client: CDNClient, tag: Tag, /) -> str: form = aiohttp.FormData() - form.add_field("file", self.content, filename=self.filename) + form.add_field('file', self.content, filename=self.filename) return await cdn_client.upload(self.tag or tag, form) @@ -214,25 +252,19 @@ async def upload(self, cdn_client: "CDNClient", tag: Tag, /) -> str: ResolvableResource = Resource | str | bytes | tuple[str, Content] -async def resolve_resource( - state: State, resolvable: ResolvableResource, *, tag: Tag -) -> str: +async def resolve_resource(state: State, resolvable: ResolvableResource, *, tag: Tag) -> str: if isinstance(resolvable, Resource): return await resolvable.upload(state.cdn_client, tag) elif isinstance(resolvable, str): return resolvable elif isinstance(resolvable, bytes): - return await Upload(resolvable, filename="untitled0.png").upload( - state.cdn_client, tag - ) + return await Upload(resolvable, filename='untitled0.png').upload(state.cdn_client, tag) # return await state.cdn_client.upload(Upload(resolvable, filename="untitled0"), tag) elif isinstance(resolvable, tuple): - return await Upload( - resolve_content(resolvable[1]), filename=resolvable[0] - ).upload(state.cdn_client, tag) + return await Upload(resolve_content(resolvable[1]), filename=resolvable[0]).upload(state.cdn_client, tag) # return await state.cdn_client.upload(Upload(resolve_content(resolvable[1]), filename=resolvable[0]), tag) else: - return "" + return '' class CDNClient: @@ -240,32 +272,26 @@ def __init__( self, state: State, *, - session: ( - utils.MaybeAwaitableFunc[[CDNClient], aiohttp.ClientSession] - | aiohttp.ClientSession - ), + session: (utils.MaybeAwaitableFunc[[CDNClient], aiohttp.ClientSession] | aiohttp.ClientSession), base: str | None = None, user_agent: str | None = None, ) -> None: self.state = state self._session = session if base is None: - base = "https://autumn.revolt.chat" - self._base = base.rstrip("/") + base = 'https://autumn.revolt.chat' + self._base = base.rstrip('/') self.user_agent = user_agent or DEFAULT_USER_AGENT @property def base(self) -> str: return self._base - async def request( - self, method: str, route: str, **kwargs - ) -> aiohttp.ClientResponse: - headers: dict[str, t.Any] = kwargs.pop("headers", {}) - if not kwargs.pop("manual_accept", False): - headers["Accept"] = "application/json" - if "User-Agent" not in headers: - headers["User-Agent"] = self.user_agent + async def request(self, method: str, route: str, **kwargs) -> aiohttp.ClientResponse: + headers: dict[str, typing.Any] = kwargs.pop('headers', {}) + if not kwargs.pop('manual_accept', False): + headers['accept'] = 'application/json' + headers['user-agent'] = self.user_agent url = self._base + route @@ -274,13 +300,11 @@ async def request( session = await utils._maybe_coroutine(session, self) # detect recursion if callable(session): - raise TypeError( - f"Expected aiohttp.ClientSession, not {type(session)!r}" - ) + raise TypeError(f'Expected aiohttp.ClientSession, not {type(session)!r}') # Do not call factory on future requests self._session = session - _L.debug("sending request to %s", route) + _L.debug('sending request to %s', route) response = await session.request( method, @@ -290,28 +314,77 @@ async def request( ) if response.status >= 400: j = await utils._json_or_text(response) - if isinstance(j, dict) and isinstance(j.get("error"), dict): - error = j["error"] - code = error.get("code") - reason = error.get("reason") - description = error.get("description") - j["type"] = "Rocket error" - j["err"] = f"{code} {reason}: {description}" - raise { - 401: errors.Unauthorized, - 403: errors.Forbidden, - 404: errors.NotFound, - 429: errors.Ratelimited, - 500: errors.InternalServerError, - }.get(response.status, errors.APIError)(response, j) + if isinstance(j, dict) and isinstance(j.get('error'), dict): + error = j['error'] + code = error.get('code') + reason = error.get('reason') + description = error.get('description') + j['type'] = 'Rocket error' + j['err'] = f'{code} {reason}: {description}' + + from .http import _STATUS_TO_ERRORS + + raise _STATUS_TO_ERRORS.get(response.status, HTTPException)(response, j) return response + def url_for( + self, + id: str, + tag: Tag, + *, + size: int | None = None, + width: int | None = None, + height: int | None = None, + max_side: int | None = None, + ) -> str: + """:class:`str`: Generates asset URL.""" + + url = f'{self._base}/{tag}/{quote(id)}' + + params = [] + + if size is not None: + params.append(f'size={size}') + + if width is not None: + params.append(f'width={width}') + + if height is not None: + params.append(f'height={height}') + + if max_side is not None: + params.append(f'max_side={max_side}') + + if params: + url += '?' + '&'.join(params) + + return url + async def read( self, tag: Tag, id: str, + *, + size: int | None = None, + width: int | None = None, + height: int | None = None, + max_side: int | None = None, ) -> bytes: - response = await self.request("GET", f"/{quote(tag)}/{quote(id)}") + params = {} + + if size is not None: + params['size'] = size + + if width is not None: + params['width'] = width + + if height is not None: + params['height'] = height + + if max_side is not None: + params['max_side'] = max_side + + response = await self.request('GET', f'/{tag}/{quote(id)}', params=params) data = await response.read() response.close() return data @@ -319,28 +392,27 @@ async def read( async def upload( self, tag: Tag, - data: t.Any, + data: typing.Any, ) -> str: - response = await self.request("POST", f"/{quote(tag)}", data=data) - data = await response.json() + response = await self.request('POST', f'/{tag}', data=data) + data = await response.json(loads=utils.from_json) response.close() - return data["id"] + return data['id'] __all__ = ( - "AssetMetadataType", - "AssetMetadata", - "StatelessAsset", - "Asset", - "Tag", - "Resource", - "_cdn_session", - "DEFAULT_USER_AGENT", - "_get_session", - "Content", - "resolve_content", - "Upload", - "ResolvableResource", - "resolve_resource", - "CDNClient", + 'AssetMetadata', + 'StatelessAsset', + 'Asset', + 'Tag', + 'Resource', + '_cdn_session', + 'DEFAULT_USER_AGENT', + '_get_session', + 'Content', + 'resolve_content', + 'Upload', + 'ResolvableResource', + 'resolve_resource', + 'CDNClient', ) diff --git a/pyvolt/channel.py b/pyvolt/channel.py index 4821003..eda18c5 100644 --- a/pyvolt/channel.py +++ b/pyvolt/channel.py @@ -1,39 +1,76 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import abc -import contextlib -import typing as t -from enum import StrEnum - from attrs import define, field - -from . import ( - base, - cache as caching, - cdn, - core, - permissions as permissions_, - user as users, +import contextlib +import typing + +from . import cache as caching + +from .base import Base +from .bot import BaseBot +from .cdn import StatelessAsset, Asset, ResolvableResource +from .core import ( + UNDEFINED, + UndefinedOr, + ULIDOr, + resolve_id, ) - -if t.TYPE_CHECKING: - from . import invite as invites, message as messages, server as servers +from .enums import MessageSort +from .errors import NoData +from .flags import ( + Permissions, + UserPermissions, + VIEW_ONLY_PERMISSIONS, + DEFAULT_SAVED_MESSAGES_PERMISSIONS, + DEFAULT_DM_PERMISSIONS, +) +from .invite import Invite +from .permissions import PermissionOverride +from .server import BaseRole, Role, Server, Member +from .user import BaseUser, User + +if typing.TYPE_CHECKING: + from .message import ( + Reply, + Interactions, + Masquerade, + SendableEmbed, + BaseMessage, + Message, + ) from .shard import Shard -class ChannelType(StrEnum): - TEXT = "Text" - """Text channel.""" - - VOICE = "Voice" - """Voice channel.""" - - class Typing(contextlib.AbstractAsyncContextManager): shard: Shard - channel_id: core.ULID + channel_id: str - def __init__(self, shard: Shard, channel_id: core.ULID) -> None: + def __init__(self, shard: Shard, channel_id: str) -> None: self.shard = shard self.channel_id = channel_id @@ -48,15 +85,13 @@ async def __aexit__(self, exc_type, exc_value, tb) -> None: return -class BaseChannel(base.Base, abc.ABC): +class BaseChannel(Base, abc.ABC): """Representation of channel on Revolt.""" - def message(self, message: core.ResolvableULID) -> messages.BaseMessage: - from . import messages + def message(self, message: ULIDOr[BaseMessage]) -> BaseMessage: + from .message import BaseMessage - return messages.BaseMessage( - state=self.state, id=core.resolve_ulid(message), channel_id=self.id - ) + return BaseMessage(state=self.state, id=resolve_id(message), channel_id=self.id) async def close(self, *, silent: bool | None = None) -> None: """|coro| @@ -65,14 +100,14 @@ async def close(self, *, silent: bool | None = None) -> None: Parameters ---------- - silent: :class:`bool` + silent: Optional[:class:`bool`] Whether to not send message when leaving group. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to close the channel. - :class:`APIError` + HTTPException Closing the channel failed. """ return await self.state.http.close_channel(self.id, silent=silent) @@ -80,24 +115,46 @@ async def close(self, *, silent: bool | None = None) -> None: async def edit( self, *, - name: core.UndefinedOr[str] = core.UNDEFINED, - description: core.UndefinedOr[str | None] = core.UNDEFINED, - owner: core.UndefinedOr[core.ResolvableULID] = core.UNDEFINED, - icon: core.UndefinedOr[str | None] = core.UNDEFINED, - nsfw: core.UndefinedOr[bool] = core.UNDEFINED, - archived: core.UndefinedOr[bool] = core.UNDEFINED, - default_permissions: core.UndefinedOr[None] = core.UNDEFINED, - ) -> "Channel": + name: UndefinedOr[str] = UNDEFINED, + description: UndefinedOr[str | None] = UNDEFINED, + owner: UndefinedOr[ULIDOr[BaseUser]] = UNDEFINED, + icon: UndefinedOr[str | None] = UNDEFINED, + nsfw: UndefinedOr[bool] = UNDEFINED, + archived: UndefinedOr[bool] = UNDEFINED, + default_permissions: UndefinedOr[None] = UNDEFINED, + ) -> Channel: """|coro| Edits the channel. + Parameters + ---------- + name: :class:`UndefinedOr`[:class:`str`] + The new channel name. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + description: :class:`UndefinedOr`[Optional[:class:`str`]] + The new channel description. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + owner: :class:`UndefinedOr`[:clsas:`ULIDOr`[:class:`BaseUser`]] + The new channel owner. Only applicable when target channel is :class:`GroupChannel`. + icon: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + The new channel icon. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + nsfw: :class:`UndefinedOr`[:class:`bool`] + To mark the channel as NSFW or not. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + archived: :class:`UndefinedOr`[:class:`bool`] + To mark the channel as archived or not. + default_permissions: :class:`UndefinedOr`[None] + To remove default permissions or not. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the channel. - :class:`APIError` + HTTPException Editing the channel failed. + + Returns + ------- + :class:`Channel` + The newly updated channel. """ return await self.state.http.edit_channel( self.id, @@ -122,7 +179,7 @@ async def join_call(self) -> str: Raises ------ - :class:`APIError` + HTTPException Asking the token failed. """ return await self.state.http.join_call(self.id) @@ -132,70 +189,54 @@ async def join_call(self) -> str: class PartialChannel(BaseChannel): """Partial representation of a channel on Revolt.""" - name: core.UndefinedOr[str] = field(repr=True, hash=True, kw_only=True, eq=True) - owner_id: core.UndefinedOr[core.ULID] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - description: core.UndefinedOr[str | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - internal_icon: core.UndefinedOr[cdn.StatelessAsset | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - nsfw: core.UndefinedOr[bool] = field(repr=True, hash=True, kw_only=True, eq=True) - active: core.UndefinedOr[bool] = field(repr=True, hash=True, kw_only=True, eq=True) - permissions: core.UndefinedOr[permissions_.Permissions] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - role_permissions: core.UndefinedOr[ - dict[core.ULID, permissions_.PermissionOverride] - ] = field(repr=True, hash=True, kw_only=True, eq=True) - default_permissions: core.UndefinedOr[permissions_.PermissionOverride | None] = ( - field(repr=True, hash=True, kw_only=True, eq=True) - ) - last_message_id: core.UndefinedOr[core.ULID] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + name: UndefinedOr[str] = field(repr=True, kw_only=True, eq=True) + owner_id: UndefinedOr[str] = field(repr=True, kw_only=True, eq=True) + description: UndefinedOr[str | None] = field(repr=True, kw_only=True, eq=True) + internal_icon: UndefinedOr[StatelessAsset | None] = field(repr=True, kw_only=True, eq=True) + nsfw: UndefinedOr[bool] = field(repr=True, kw_only=True, eq=True) + active: UndefinedOr[bool] = field(repr=True, kw_only=True, eq=True) + permissions: UndefinedOr[Permissions] = field(repr=True, kw_only=True, eq=True) + role_permissions: UndefinedOr[dict[str, PermissionOverride]] = field(repr=True, kw_only=True, eq=True) + default_permissions: UndefinedOr[PermissionOverride | None] = field(repr=True, kw_only=True, eq=True) + last_message_id: UndefinedOr[str] = field(repr=True, kw_only=True, eq=True) -def _calculate_saved_messages_channel_permissions( - perspective_id: core.ULID, user_id: core.ULID -) -> permissions_.Permissions: +def _calculate_saved_messages_channel_permissions(perspective_id: str, user_id: str) -> Permissions: if perspective_id == user_id: - return permissions_.DEFAULT_SAVED_MESSAGES_PERMISSIONS - return permissions_.Permissions.NONE + return DEFAULT_SAVED_MESSAGES_PERMISSIONS + return Permissions.NONE def _calculate_dm_channel_permissions( - user_permissions: permissions_.UserPermissions, -) -> permissions_.Permissions: - if user_permissions & permissions_.UserPermissions.SEND_MESSAGE: - return permissions_.DEFAULT_DM_PERMISSIONS - return permissions_.VIEW_ONLY_PERMISSIONS + user_permissions: UserPermissions, +) -> Permissions: + if user_permissions.send_messages: + return DEFAULT_DM_PERMISSIONS + return VIEW_ONLY_PERMISSIONS def _calculate_group_channel_permissions( - perspective_id: core.ULID, + perspective_id: str, *, - group_owner_id: core.ULID, - group_permissions: permissions_.Permissions, - group_recipients: list[core.ULID], -) -> permissions_.Permissions: + group_owner_id: str, + group_permissions: Permissions, + group_recipients: list[str], +) -> Permissions: if perspective_id == group_owner_id: - return permissions_.Permissions.ALL + return Permissions.ALL elif perspective_id in group_recipients: - return permissions_.VIEW_ONLY_PERMISSIONS | group_permissions - return permissions_.Permissions.NONE + return VIEW_ONLY_PERMISSIONS | group_permissions + return Permissions.NONE def _calculate_server_channel_permissions( - initial_permissions: permissions_.Permissions, - roles: list[servers.Role], + initial_permissions: Permissions, + roles: list[Role], /, *, - default_permissions: permissions_.PermissionOverride | None, - role_permissions: dict[core.ULID, permissions_.PermissionOverride], -) -> permissions_.Permissions: + default_permissions: PermissionOverride | None, + role_permissions: dict[str, PermissionOverride], +) -> Permissions: result = initial_permissions.value if default_permissions: @@ -210,7 +251,7 @@ def _calculate_server_channel_permissions( except KeyError: pass - return permissions_.Permissions(result) + return Permissions(result) @define(slots=True) @@ -233,62 +274,57 @@ async def end_typing(self) -> None: """Ends typing in channel.""" await self.state.shard.end_typing(self.id) - async def pins(self) -> list[messages.Message]: - """|coro| - - Retrieves all messages that are currently pinned in the channel. - - Raises - ------ - :class:`APIError` - Getting channel pins failed. - """ - return await self.state.http.get_channel_pins(self.id) - async def search( self, - query: str, + query: str | None = None, *, + pinned: bool | None = None, limit: int | None = None, - before: core.ResolvableULID | None = None, - after: core.ResolvableULID | None = None, - sort: "messages.MessageSort | None" = None, + before: ULIDOr[BaseMessage] | None = None, + after: ULIDOr[BaseMessage] | None = None, + sort: MessageSort | None = None, populate_users: bool | None = None, - ) -> list["messages.Message"]: + ) -> list[Message]: """|coro| - Searches for messages within the given parameters. + Searches for messages. + + .. note:: + This can only be used by non-bot accounts. Parameters ---------- - query: :class:`str` + query: Optional[:class:`str`] Full-text search query. See [MongoDB documentation](https://docs.mongodb.com/manual/text-search/#-text-operator) for more information. - limit: :class:`int` + pinned: Optional[:class:`bool`] + Whether to search for (un-)pinned messages or not. + limit: Optional[:class:`int`] Maximum number of messages to fetch. - before: :class:`core.ResolvableULID` - Message ID before which messages should be fetched. - after: :class:`core.ResolvableULID` - Message ID after which messages should be fetched. - sort: :class:`messages.MessageSort` - Sort used for retrieving messages. - populate_users: :class:`bool` + before: Optional[:class:`ULIDOr`[:class:`BaseMessage`]] + The message before which messages should be fetched. + after: Optional[:class:`ULIDOr`[:class:`BaseMessage`]] + The message after which messages should be fetched. + sort: Optional[:class:`MessageSort`] + Sort used for retrieving. + populate_users: Optional[:class:`bool`] Whether to populate user (and member, if server channel) objects. - Returns - ------- - :class:`list`[:class:`messages.Message`] - The messages matched. - Raises ------ - :class:`Forbidden` - You do not have permissions to search messages. - :class:`APIError` + Forbidden + You do not have permissions to search + HTTPException Searching messages failed. + + Returns + ------- + List[:class:`Message`] + The messages matched. """ return await self.state.http.search_for_messages( self.id, - query, + query=query, + pinned=pinned, limit=limit, before=before, after=after, @@ -301,27 +337,48 @@ async def send( content: str | None = None, *, nonce: str | None = None, - attachments: list[cdn.ResolvableResource] | None = None, - replies: list[messages.Reply | core.ResolvableULID] | None = None, - embeds: list[messages.SendableEmbed] | None = None, - masquerade: messages.Masquerade | None = None, - interactions: messages.Interactions | None = None, + attachments: list[ResolvableResource] | None = None, + replies: list[Reply | ULIDOr[BaseMessage]] | None = None, + embeds: list[SendableEmbed] | None = None, + masquerade: Masquerade | None = None, + interactions: Interactions | None = None, silent: bool | None = None, - ) -> "messages.Message": + ) -> Message: """|coro| Sends a message to the given channel. You must have `SendMessages` permission. + Parameters + ---------- + content: Optional[:class:`str`] + The message content. + nonce: Optional[:class:`str`] + The message nonce. + attachments: Optional[List[:class:`ResolvableResource`]] + The message attachments. + replies: Optional[List[Union[:class:`Reply`, :class:`ULIDOr`[:class:`BaseMessage`]]]] + The message replies. + embeds: Optional[List[:class:`SendableEmbed`]] + The message embeds. + masquearde: Optional[:class:`Masquerade`] + The message masquerade. + interactions: Optional[:class:`Interactions`] + The message interactions. + silent: Optional[:class:`bool`] + Whether to suppress notifications or not. + + Raises + ------ + Forbidden + You do not have permissions to send + HTTPException + Sending the message failed. + Returns ------- :class:`Message` - The message sent. - - :class:`Forbidden` - You do not have permissions to send messages. - :class:`APIError` - Sending the message failed. + The message that was sent. """ return await self.state.http.send_message( self.id, @@ -340,42 +397,63 @@ async def send( class SavedMessagesChannel(TextChannel): """Personal "Saved Notes" channel which allows users to save messages.""" - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + user_id: str = field(repr=True, kw_only=True) """ID of the user this channel belongs to.""" + def __hash__(self) -> int: + return hash((self.id, self.user_id)) + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, SavedMessagesChannel) + and self.id == other.id + and self.user_id == other.user_id + ) + def _update(self, data: PartialChannel) -> None: # PartialChannel has no fields that are related to SavedMessages yet pass - def permissions_for( - self, perspective: users.User | servers.Member, / - ) -> permissions_.Permissions: - return _calculate_saved_messages_channel_permissions( - perspective.id, self.user_id - ) + def permissions_for(self, perspective: User | Member, /) -> Permissions: + return _calculate_saved_messages_channel_permissions(perspective.id, self.user_id) @define(slots=True) class DMChannel(TextChannel): - """Direct message channel between two users.""" + """The PM channel between two users.""" - active: bool = field(repr=True, hash=True, kw_only=True, eq=True) + active: bool = field(repr=True, kw_only=True) """Whether this DM channel is currently open on both sides.""" - recipient_ids: tuple[core.ULID, core.ULID] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - """2-tuple of user IDs participating in DM.""" + recipient_ids: tuple[str, str] = field(repr=True, kw_only=True) + """The tuple of user IDs participating in DM.""" - last_message_id: core.ULID | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) - """ID of the last message sent in this channel.""" + last_message_id: str | None = field(repr=True, kw_only=True) + """The ID of the last message sent in this channel.""" + + @property + def initiator_id(self) -> str: + """:class:`str`: The user's ID that started this PM.""" + return self.recipient_ids[0] + + @property + def recipient_id(self) -> str: + """:class:`str`: The recipient's ID.""" + me = self.state.me + + if not me: + return '' + + a = self.recipient_ids[0] + b = self.recipient_ids[1] + + return a if me.id != a else b def _update(self, data: PartialChannel) -> None: - if core.is_defined(data.active): + if data.active is not UNDEFINED: self.active = data.active - if core.is_defined(data.last_message_id): + if data.last_message_id is not UNDEFINED: self.last_message_id = data.last_message_id @@ -383,91 +461,87 @@ def _update(self, data: PartialChannel) -> None: class GroupChannel(TextChannel): """Group channel between 1 or more participants.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """Display name of the channel.""" - owner_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + owner_id: str = field(repr=True, kw_only=True) """User ID of the owner of the group.""" - description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + description: str | None = field(repr=True, kw_only=True) """Channel description.""" - _recipients: ( - tuple[t.Literal[True], list[core.ULID]] - | tuple[t.Literal[False], list[users.User]] - ) = field(repr=True, hash=True, eq=True, alias="internal_recipients") - - internal_icon: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True + _recipients: tuple[typing.Literal[True], list[str]] | tuple[typing.Literal[False], list[User]] = field( + repr=True, kw_only=True, alias='internal_recipients' ) + + internal_icon: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless group icon.""" - last_message_id: core.ULID | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + last_message_id: str | None = field(repr=True, kw_only=True) """ID of the last message sent in this channel.""" - permissions: permissions_.Permissions | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + permissions: Permissions | None = field(repr=True, kw_only=True) """Permissions assigned to members of this group. (does not apply to the owner of the group)""" - nsfw: bool = field(repr=True, hash=True, kw_only=True, eq=True) + nsfw: bool = field(repr=True, kw_only=True) """Whether this group is marked as not safe for work.""" def _update(self, data: PartialChannel) -> None: - if core.is_defined(data.name): + if data.name is not UNDEFINED: self.name = data.name - if core.is_defined(data.owner_id): + if data.owner_id is not UNDEFINED: self.owner_id = data.owner_id - if core.is_defined(data.description): + if data.description is not UNDEFINED: self.description = data.description - if core.is_defined(data.internal_icon): + if data.internal_icon is not UNDEFINED: self.internal_icon = data.internal_icon - if core.is_defined(data.last_message_id): + if data.last_message_id is not UNDEFINED: self.last_message_id = data.last_message_id - if core.is_defined(data.permissions): + if data.permissions is not UNDEFINED: self.permissions = data.permissions - if core.is_defined(data.nsfw): + if data.nsfw is not UNDEFINED: self.nsfw = data.nsfw - def _join(self, user_id: core.ULID) -> None: + def _join(self, user_id: str) -> None: if self._recipients[0]: self._recipients[1].append(user_id) # type: ignore # Pyright doesn't understand `if` else: self._recipients = (True, [u.id for u in self._recipients[1]]) # type: ignore self._recipients[1].append(user_id) - def _leave(self, user_id: core.ULID) -> None: + def _leave(self, user_id: str) -> None: if self._recipients[0]: try: self._recipients[1].remove(user_id) # type: ignore except ValueError: pass else: - self._recipients = (True, [u.id for u in self._recipients[1] if u.id != user_id]) # type: ignore + self._recipients = ( + True, + [u.id for u in self._recipients[1] if u.id != user_id], # type: ignore + ) @property - def icon(self) -> cdn.Asset | None: - """The group icon.""" - return self.internal_icon and self.internal_icon._stateful(self.state, "icons") + def icon(self) -> Asset | None: + """Optional[:class:`Asset`]: The group icon.""" + return self.internal_icon and self.internal_icon._stateful(self.state, 'icons') @property - def recipient_ids(self) -> list[core.ULID]: - """The IDs of users participating in channel.""" + def recipient_ids(self) -> list[str]: + """List[:class:`str`]: The IDs of users participating in channel.""" if self._recipients[0]: return self._recipients[1] # type: ignore else: return [u.id for u in self._recipients[1]] # type: ignore @property - def recipients(self) -> list[users.User]: - """The users participating in channel.""" + def recipients(self) -> list[User]: + """List[:class:`User`]: The users participating in channel.""" if self._recipients[0]: cache = self.state.cache if not cache: return [] - recipient_ids = t.cast("list[core.ULID]", self._recipients[1]) + recipient_ids: list[str] = self._recipients[1] # type: ignore recipients = [] for recipient_id in recipient_ids: user = cache.get_user(recipient_id, caching._USER_REQUEST) @@ -475,11 +549,11 @@ def recipients(self) -> list[users.User]: recipients.append(user) return recipients else: - return t.cast("list[users.User]", self._recipients[1]) + return self._recipients[1] # type: ignore async def add( self, - user: core.ResolvableULID, + user: ULIDOr[BaseUser], ) -> None: """|coro| @@ -487,21 +561,21 @@ async def add( Parameters ---------- - user: :class:`ResolvableULID` + user: :class:`ULIDOr`[:class:`BaseUser`] The user to add. Raises ------ - :class:`Forbidden` + Forbidden You're bot, lacking `InviteOthers` permission, or not friends with this user. - :class:`APIError` + HTTPException Adding user to the group failed. """ - return await self.state.http.add_member_to_group(self.id, user) + return await self.state.http.add_recipient_to_group(self.id, user) async def add_bot( self, - bot: core.ResolvableULID, + bot: ULIDOr[BaseBot | BaseUser], ) -> None: """|coro| @@ -509,23 +583,23 @@ async def add_bot( Raises ------ - :class:`Forbidden` + Forbidden You're bot, or lacking `InviteOthers` permission. - :class:`APIError` + HTTPException Adding bot to the group failed. """ return await self.state.http.invite_bot(bot, group=self.id) - async def create_invite(self) -> "invites.Invite": + async def create_invite(self) -> Invite: """|coro| Creates an invite to this channel. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create invite in that channel. - :class:`APIError` + HTTPException Creating invite failed. """ return await self.state.http.create_invite(self.id) @@ -542,89 +616,116 @@ async def leave(self, *, silent: bool | None = None) -> None: Raises ------ - :class:`APIError` + HTTPException Leaving the group failed. """ return await self.close(silent=silent) + async def set_default_permissions(self, permissions: Permissions, /) -> GroupChannel: + """|coro| + + Sets default permissions in a channel. + + Parameters + ---------- + permissions: :class:`Permissions` + The new permissions. Should be :class:`Permissions` for groups and :class:`PermissionOverride` for server channels. + + Raises + ------ + Forbidden + You do not have permissions to set default permissions on the channel. + HTTPException + Setting permissions failed. + + Returns + ------- + :class:`GroupChannel` + The updated group with new permissions. + """ + result = await self.state.http.set_default_channel_permissions(self.id, permissions) + assert isinstance(result, GroupChannel) + return result + PrivateChannel = SavedMessagesChannel | DMChannel | GroupChannel @define(slots=True) class BaseServerChannel(BaseChannel): - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) """The server ID that channel belongs to.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """The display name of the channel.""" - description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + description: str | None = field(repr=True, kw_only=True) """The channel description.""" - internal_icon: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_icon: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless custom channel icon.""" - default_permissions: permissions_.PermissionOverride | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + default_permissions: PermissionOverride | None = field(repr=True, kw_only=True) """Default permissions assigned to users in this channel.""" - role_permissions: dict[core.ULID, permissions_.PermissionOverride] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + role_permissions: dict[str, PermissionOverride] = field(repr=True, kw_only=True) """Permissions assigned based on role to this channel.""" - nsfw: bool = field(repr=True, hash=True, kw_only=True, eq=True) + nsfw: bool = field(repr=True, kw_only=True) """Whether this channel is marked as not safe for work.""" def _update(self, data: PartialChannel) -> None: - if core.is_defined(data.name): + if data.name is not UNDEFINED: self.name = data.name - if core.is_defined(data.description): + if data.description is not UNDEFINED: self.description = data.description - if core.is_defined(data.internal_icon): + if data.internal_icon is not UNDEFINED: self.internal_icon = data.internal_icon - if core.is_defined(data.nsfw): + if data.nsfw is not UNDEFINED: self.nsfw = data.nsfw - if core.is_defined(data.role_permissions): + if data.role_permissions is not UNDEFINED: self.role_permissions = data.role_permissions - if core.is_defined(data.default_permissions): + if data.default_permissions is not UNDEFINED: self.default_permissions = data.default_permissions @property - def icon(self) -> cdn.Asset | None: - """The custom channel icon.""" - return self.internal_icon and self.internal_icon._stateful(self.state, "icons") + def icon(self) -> Asset | None: + """Optional[:class:`Asset`]: The custom channel icon.""" + return self.internal_icon and self.internal_icon._stateful(self.state, 'icons') @property - def server(self) -> servers.Server: - """The server that channel belongs to.""" + def server(self) -> Server: + """:class:`Server`: The server that channel belongs to.""" server = self.get_server() if server: return server - raise TypeError("Server is not in cache") + raise NoData(self.server_id, 'channel server') - def get_server(self) -> servers.Server | None: - """The server that channel belongs to.""" + def get_server(self) -> Server | None: + """Optional[:class:`Server`]: The server that channel belongs to.""" if not self.state.cache: return None return self.state.cache.get_server(self.server_id, caching._USER_REQUEST) - async def create_invite(self) -> invites.Invite: + async def create_invite(self) -> Invite: """|coro| - Creates an invite to this channel. - Channel must be a `TextChannel`. + Creates an invite to channel. The destination channel must be a server channel. + + .. note:: + This can only be used by non-bot accounts. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create invite in that channel. - :class:`APIError` + HTTPException Creating invite failed. + + Returns + ------- + :class:`Invite` + The invite that was created. """ return await self.state.http.create_invite(self.id) @@ -635,19 +736,19 @@ async def delete(self) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete the channel. - :class:`APIError` + HTTPException Deleting the channel failed. """ return await self.close() async def set_role_permissions( self, - role: core.ResolvableULID, + role: ULIDOr[BaseRole], *, - allow: permissions_.Permissions = permissions_.Permissions.NONE, - deny: permissions_.Permissions = permissions_.Permissions.NONE, + allow: Permissions = Permissions.NONE, + deny: Permissions = Permissions.NONE, ) -> ServerChannel: """|coro| @@ -656,53 +757,54 @@ async def set_role_permissions( Parameters ---------- - role: :class:`core.ResolvableULID` + role: :class:`ULIDOr`[:class:`BaseRole`] The role. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set role permissions on the channel. - :class:`APIError` + HTTPException Setting permissions failed. """ - return await self.state.http.set_role_channel_permissions( - self.id, role, allow=allow, deny=deny - ) # type: ignore + return await self.state.http.set_role_channel_permissions(self.id, role, allow=allow, deny=deny) # type: ignore - async def set_default_permissions( - self, - permissions: "permissions_.Permissions | permissions_.PermissionOverride", - ) -> ServerChannel: + async def set_default_permissions(self, permissions: PermissionOverride, /) -> ServerChannel: """|coro| - Sets permissions for the default role in this channel. - Channel must be a `Group`, `TextChannel` or `VoiceChannel`. + Sets permissions for the default role in a channel. + + Parameters + ---------- + permissions: :class:`PermissionOverride` + The new permissions. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set default permissions on the channel. - :class:`APIError` + HTTPException Setting permissions failed. + + Returns + ------- + :class:`ServerChannel` + The updated server channel with new permissions. """ - return await self.state.http.set_default_channel_permissions( - self.id, permissions - ) # type: ignore + result = await self.state.http.set_default_channel_permissions(self.id, permissions) + return result # type: ignore @define(slots=True) class ServerTextChannel(BaseServerChannel, TextChannel): """Text channel belonging to a server.""" - last_message_id: core.ULID | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + last_message_id: str | None = field(repr=True, kw_only=True) """ID of the last message sent in this channel.""" def _update(self, data: PartialChannel) -> None: BaseServerChannel._update(self, data) - if core.is_defined(data.last_message_id): + if data.last_message_id is not UNDEFINED: self.last_message_id = data.last_message_id @@ -713,27 +815,24 @@ class VoiceChannel(BaseServerChannel): ServerChannel = ServerTextChannel | VoiceChannel -Channel = ( - SavedMessagesChannel | DMChannel | GroupChannel | ServerTextChannel | VoiceChannel -) +Channel = SavedMessagesChannel | DMChannel | GroupChannel | ServerTextChannel | VoiceChannel __all__ = ( - "ChannelType", - "BaseChannel", - "PartialChannel", - "Typing", - "_calculate_saved_messages_channel_permissions", - "_calculate_dm_channel_permissions", - "_calculate_group_channel_permissions", - "_calculate_server_channel_permissions", - "TextChannel", - "SavedMessagesChannel", - "DMChannel", - "GroupChannel", - "PrivateChannel", - "BaseServerChannel", - "ServerTextChannel", - "VoiceChannel", - "ServerChannel", - "Channel", + 'BaseChannel', + 'PartialChannel', + 'Typing', + '_calculate_saved_messages_channel_permissions', + '_calculate_dm_channel_permissions', + '_calculate_group_channel_permissions', + '_calculate_server_channel_permissions', + 'TextChannel', + 'SavedMessagesChannel', + 'DMChannel', + 'GroupChannel', + 'PrivateChannel', + 'BaseServerChannel', + 'ServerTextChannel', + 'VoiceChannel', + 'ServerChannel', + 'Channel', ) diff --git a/pyvolt/client.py b/pyvolt/client.py index 8833ea9..daa220a 100644 --- a/pyvolt/client.py +++ b/pyvolt/client.py @@ -1,26 +1,64 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import aiohttp import asyncio import builtins -from collections import abc as ca +from collections.abc import Callable, Coroutine, Generator, Mapping +import inspect +from functools import wraps import logging import sys -import typing as t +import typing +from . import cache as caching, utils from .cache import Cache, MapCache from .cdn import CDNClient -from .channel import SavedMessagesChannel +from .channel import SavedMessagesChannel, DMChannel, GroupChannel, Channel +from .core import ( + UNDEFINED, + UndefinedOr, + ULIDOr, +) +from .emoji import Emoji +from .events import BaseEvent from .http import HTTPClient +from .message import Message from .parser import Parser +from .read_state import ReadState from .server import Server from .shard import EventHandler, Shard from .state import State -from .user import SelfUser +from .user_settings import UserSettings +from .user import BaseUser, User, OwnUser -from . import core, events, utils -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: + from types import TracebackType + from typing_extensions import Self from . import raw @@ -31,8 +69,10 @@ def _session_factory(_) -> aiohttp.ClientSession: return aiohttp.ClientSession() -class _ClientHandler(EventHandler): - __slots__ = ("_client", "_state", "dispatch", "_handlers") +class ClientEventHandler(EventHandler): + """The default event handler for the client.""" + + __slots__ = ('_client', '_state', 'dispatch', '_handlers') def __init__(self, client: Client) -> None: self._client = client @@ -40,54 +80,52 @@ def __init__(self, client: Client) -> None: self.dispatch = client.dispatch self._handlers = { - "Bulk": self.handle_bulk, - "Authenticated": self.handle_authenticated, - "Logout": self.handle_logout, - "Ready": self.handle_ready, - "Pong": self.handle_pong, - "Message": self.handle_message, - "MessageUpdate": self.handle_message_update, - "MessageAppend": self.handle_message_append, - "MessageDelete": self.handle_message_delete, - "MessageReact": self.handle_message_react, - "MessageUnreact": self.handle_message_unreact, - "MessageRemoveReaction": self.handle_message_remove_reaction, - "BulkMessageDelete": self.handle_bulk_message_delete, - "ServerCreate": self.handle_server_create, - "ServerUpdate": self.handle_server_update, - "ServerDelete": self.handle_server_delete, - "ServerMemberJoin": self.handle_server_member_join, - "ServerMemberLeave": self.handle_server_member_leave, - "ServerRoleUpdate": self.handle_server_role_update, - "ServerRoleDelete": self.handle_server_role_delete, - "UserUpdate": self.handle_user_update, - "UserRelationship": self.handle_user_relationship, - "UserSettingsUpdate": self.handle_user_settings_update, - "UserPlatformWipe": self.handle_user_platform_wipe, - "EmojiCreate": self.handle_emoji_create, - "EmojiDelete": self.handle_emoji_delete, - "ChannelCreate": self.handle_channel_create, - "ChannelUpdate": self.handle_channel_update, - "ChannelDelete": self.handle_channel_delete, - "ChannelGroupJoin": self.handle_channel_group_join, - "ChannelGroupLeave": self.handle_channel_group_leave, - "ChannelStartTyping": self.handle_channel_start_typing, - "ChannelStopTyping": self.handle_channel_stop_typing, - "ChannelAck": self.handle_channel_ack, - "WebhookCreate": self.handle_webhook_create, - "WebhookUpdate": self.handle_webhook_update, - "WebhookDelete": self.handle_webhook_delete, - "Auth": self.handle_auth, + 'Bulk': self.handle_bulk, + 'Authenticated': self.handle_authenticated, + 'Logout': self.handle_logout, + 'Ready': self.handle_ready, + 'Pong': self.handle_pong, + 'Message': self.handle_message, + 'MessageUpdate': self.handle_message_update, + 'MessageAppend': self.handle_message_append, + 'MessageDelete': self.handle_message_delete, + 'MessageReact': self.handle_message_react, + 'MessageUnreact': self.handle_message_unreact, + 'MessageRemoveReaction': self.handle_message_remove_reaction, + 'BulkMessageDelete': self.handle_bulk_message_delete, + 'ServerCreate': self.handle_server_create, + 'ServerUpdate': self.handle_server_update, + 'ServerDelete': self.handle_server_delete, + 'ServerMemberJoin': self.handle_server_member_join, + 'ServerMemberLeave': self.handle_server_member_leave, + 'ServerRoleUpdate': self.handle_server_role_update, + 'ServerRoleDelete': self.handle_server_role_delete, + 'UserUpdate': self.handle_user_update, + 'UserRelationship': self.handle_user_relationship, + 'UserSettingsUpdate': self.handle_user_settings_update, + 'UserPlatformWipe': self.handle_user_platform_wipe, + 'EmojiCreate': self.handle_emoji_create, + 'EmojiDelete': self.handle_emoji_delete, + 'ChannelCreate': self.handle_channel_create, + 'ChannelUpdate': self.handle_channel_update, + 'ChannelDelete': self.handle_channel_delete, + 'ChannelGroupJoin': self.handle_channel_group_join, + 'ChannelGroupLeave': self.handle_channel_group_leave, + 'ChannelStartTyping': self.handle_channel_start_typing, + 'ChannelStopTyping': self.handle_channel_stop_typing, + 'ChannelAck': self.handle_channel_ack, + 'WebhookCreate': self.handle_webhook_create, + 'WebhookUpdate': self.handle_webhook_update, + 'WebhookDelete': self.handle_webhook_delete, + 'Auth': self.handle_auth, } async def handle_bulk(self, shard: Shard, payload: raw.ClientBulkEvent, /) -> None: - for v in payload["v"]: + for v in payload['v']: await self._handle(shard, v) - def handle_authenticated( - self, shard: Shard, payload: raw.ClientAuthenticatedEvent, / - ) -> None: - pass + def handle_authenticated(self, shard: Shard, payload: raw.ClientAuthenticatedEvent, /) -> None: + self.dispatch(self._state.parser.parse_authenticated_event(shard, payload)) def handle_logout(self, shard: Shard, payload: raw.ClientLogoutEvent, /) -> None: self.dispatch(self._state.parser.parse_logout_event(shard, payload)) @@ -103,192 +141,129 @@ def handle_message(self, shard: Shard, payload: raw.ClientMessageEvent, /) -> No event = self._state.parser.parse_message_event(shard, payload) self.dispatch(event) - def handle_message_update( - self, shard: Shard, payload: raw.ClientMessageUpdateEvent, / - ) -> None: + def handle_message_update(self, shard: Shard, payload: raw.ClientMessageUpdateEvent, /) -> None: event = self._state.parser.parse_message_update_event(shard, payload) self.dispatch(event) - def handle_message_append( - self, shard: Shard, payload: raw.ClientMessageAppendEvent, / - ) -> None: + def handle_message_append(self, shard: Shard, payload: raw.ClientMessageAppendEvent, /) -> None: event = self._state.parser.parse_message_append_event(shard, payload) self.dispatch(event) - def handle_message_delete( - self, shard: Shard, payload: raw.ClientMessageDeleteEvent, / - ) -> None: + def handle_message_delete(self, shard: Shard, payload: raw.ClientMessageDeleteEvent, /) -> None: event = self._state.parser.parse_message_delete_event(shard, payload) self.dispatch(event) - def handle_message_react( - self, shard: Shard, payload: raw.ClientMessageReactEvent, / - ) -> None: + def handle_message_react(self, shard: Shard, payload: raw.ClientMessageReactEvent, /) -> None: event = self._state.parser.parse_message_react_event(shard, payload) self.dispatch(event) - def handle_message_unreact( - self, shard: Shard, payload: raw.ClientMessageUnreactEvent, / - ) -> None: + def handle_message_unreact(self, shard: Shard, payload: raw.ClientMessageUnreactEvent, /) -> None: event = self._state.parser.parse_message_unreact_event(shard, payload) self.dispatch(event) - def handle_message_remove_reaction( - self, shard: Shard, payload: raw.ClientMessageRemoveReactionEvent, / - ) -> None: + def handle_message_remove_reaction(self, shard: Shard, payload: raw.ClientMessageRemoveReactionEvent, /) -> None: event = self._state.parser.parse_message_remove_reaction_event(shard, payload) self.dispatch(event) - def handle_bulk_message_delete( - self, shard: Shard, payload: raw.ClientBulkMessageDeleteEvent, / - ) -> None: + def handle_bulk_message_delete(self, shard: Shard, payload: raw.ClientBulkMessageDeleteEvent, /) -> None: event = self._state.parser.parse_bulk_message_delete_event(shard, payload) self.dispatch(event) - def handle_server_create( - self, shard: Shard, payload: raw.ClientServerCreateEvent, / - ) -> None: - event = self._state.parser.parse_server_create_event(shard, payload) + def handle_server_create(self, shard: Shard, payload: raw.ClientServerCreateEvent, /) -> None: + joined_at = utils.utcnow() + event = self._state.parser.parse_server_create_event(shard, payload, joined_at) self.dispatch(event) - def handle_server_update( - self, shard: Shard, payload: raw.ClientServerUpdateEvent, / - ) -> None: + def handle_server_update(self, shard: Shard, payload: raw.ClientServerUpdateEvent, /) -> None: event = self._state.parser.parse_server_update_event(shard, payload) self.dispatch(event) - def handle_server_delete( - self, shard: Shard, payload: raw.ClientServerDeleteEvent, / - ) -> None: + def handle_server_delete(self, shard: Shard, payload: raw.ClientServerDeleteEvent, /) -> None: event = self._state.parser.parse_server_delete_event(shard, payload) self.dispatch(event) - def handle_server_member_join( - self, shard: Shard, payload: raw.ClientServerMemberJoinEvent, / - ) -> None: + def handle_server_member_join(self, shard: Shard, payload: raw.ClientServerMemberJoinEvent, /) -> None: joined_at = utils.utcnow() - event = self._state.parser.parse_server_member_join_event( - shard, payload, joined_at - ) + event = self._state.parser.parse_server_member_join_event(shard, payload, joined_at) self.dispatch(event) - def handle_server_member_leave( - self, shard: Shard, payload: raw.ClientServerMemberLeaveEvent, / - ) -> None: + def handle_server_member_leave(self, shard: Shard, payload: raw.ClientServerMemberLeaveEvent, /) -> None: event = self._state.parser.parse_server_member_leave_event(shard, payload) self.dispatch(event) - def handle_server_role_update( - self, shard: Shard, payload: raw.ClientServerRoleUpdateEvent, / - ) -> None: + def handle_server_role_update(self, shard: Shard, payload: raw.ClientServerRoleUpdateEvent, /) -> None: event = self._state.parser.parse_server_role_update_event(shard, payload) self.dispatch(event) - def handle_server_role_delete( - self, shard: Shard, payload: raw.ClientServerRoleDeleteEvent, / - ) -> None: + def handle_server_role_delete(self, shard: Shard, payload: raw.ClientServerRoleDeleteEvent, /) -> None: event = self._state.parser.parse_server_role_delete_event(shard, payload) self.dispatch(event) - def handle_user_update( - self, shard: Shard, payload: raw.ClientUserUpdateEvent, / - ) -> None: + def handle_user_update(self, shard: Shard, payload: raw.ClientUserUpdateEvent, /) -> None: event = self._state.parser.parse_user_update_event(shard, payload) self.dispatch(event) - def handle_user_relationship( - self, shard: Shard, payload: raw.ClientUserRelationshipEvent, / - ) -> None: + def handle_user_relationship(self, shard: Shard, payload: raw.ClientUserRelationshipEvent, /) -> None: event = self._state.parser.parse_user_relationship_event(shard, payload) self.dispatch(event) - def handle_user_settings_update( - self, shard: Shard, payload: raw.ClientUserSettingsUpdateEvent, / - ) -> None: + def handle_user_settings_update(self, shard: Shard, payload: raw.ClientUserSettingsUpdateEvent, /) -> None: event = self._state.parser.parse_user_settings_update_event(shard, payload) self.dispatch(event) - def handle_user_platform_wipe( - self, shard: Shard, payload: raw.ClientUserPlatformWipeEvent, / - ) -> None: + def handle_user_platform_wipe(self, shard: Shard, payload: raw.ClientUserPlatformWipeEvent, /) -> None: event = self._state.parser.parse_user_platform_wipe_event(shard, payload) self.dispatch(event) - def handle_emoji_create( - self, shard: Shard, payload: raw.ClientEmojiCreateEvent, / - ) -> None: + def handle_emoji_create(self, shard: Shard, payload: raw.ClientEmojiCreateEvent, /) -> None: event = self._state.parser.parse_emoji_create_event(shard, payload) self.dispatch(event) - def handle_emoji_delete( - self, shard: Shard, payload: raw.ClientEmojiDeleteEvent, / - ) -> None: + def handle_emoji_delete(self, shard: Shard, payload: raw.ClientEmojiDeleteEvent, /) -> None: event = self._state.parser.parse_emoji_delete_event(shard, payload) self.dispatch(event) - def handle_channel_create( - self, shard: Shard, payload: raw.ClientChannelCreateEvent, / - ) -> None: + def handle_channel_create(self, shard: Shard, payload: raw.ClientChannelCreateEvent, /) -> None: event = self._state.parser.parse_channel_create_event(shard, payload) self.dispatch(event) - def handle_channel_update( - self, shard: Shard, payload: raw.ClientChannelUpdateEvent, / - ) -> None: + def handle_channel_update(self, shard: Shard, payload: raw.ClientChannelUpdateEvent, /) -> None: event = self._state.parser.parse_channel_update_event(shard, payload) self.dispatch(event) - def handle_channel_delete( - self, shard: Shard, payload: raw.ClientChannelDeleteEvent, / - ) -> None: + def handle_channel_delete(self, shard: Shard, payload: raw.ClientChannelDeleteEvent, /) -> None: event = self._state.parser.parse_channel_delete_event(shard, payload) self.dispatch(event) - def handle_channel_group_join( - self, shard: Shard, payload: raw.ClientChannelGroupJoinEvent, / - ) -> None: + def handle_channel_group_join(self, shard: Shard, payload: raw.ClientChannelGroupJoinEvent, /) -> None: event = self._state.parser.parse_channel_group_join_event(shard, payload) self.dispatch(event) - def handle_channel_group_leave( - self, shard: Shard, payload: raw.ClientChannelGroupLeaveEvent, / - ) -> None: + def handle_channel_group_leave(self, shard: Shard, payload: raw.ClientChannelGroupLeaveEvent, /) -> None: event = self._state.parser.parse_channel_group_leave_event(shard, payload) self.dispatch(event) - def handle_channel_start_typing( - self, shard: Shard, payload: raw.ClientChannelStartTypingEvent, / - ) -> None: + def handle_channel_start_typing(self, shard: Shard, payload: raw.ClientChannelStartTypingEvent, /) -> None: event = self._state.parser.parse_channel_start_typing_event(shard, payload) self.dispatch(event) - def handle_channel_stop_typing( - self, shard: Shard, payload: raw.ClientChannelStopTypingEvent, / - ) -> None: + def handle_channel_stop_typing(self, shard: Shard, payload: raw.ClientChannelStopTypingEvent, /) -> None: event = self._state.parser.parse_channel_stop_typing_event(shard, payload) self.dispatch(event) - def handle_channel_ack( - self, shard: Shard, payload: raw.ClientChannelAckEvent, / - ) -> None: + def handle_channel_ack(self, shard: Shard, payload: raw.ClientChannelAckEvent, /) -> None: event = self._state.parser.parse_channel_ack_event(shard, payload) self.dispatch(event) - def handle_webhook_create( - self, shard: Shard, payload: raw.ClientWebhookCreateEvent, / - ) -> None: + def handle_webhook_create(self, shard: Shard, payload: raw.ClientWebhookCreateEvent, /) -> None: event = self._state.parser.parse_webhook_create_event(shard, payload) self.dispatch(event) - def handle_webhook_update( - self, shard: Shard, payload: raw.ClientWebhookUpdateEvent, / - ) -> None: + def handle_webhook_update(self, shard: Shard, payload: raw.ClientWebhookUpdateEvent, /) -> None: event = self._state.parser.parse_webhook_update_event(shard, payload) self.dispatch(event) - def handle_webhook_delete( - self, shard: Shard, payload: raw.ClientWebhookDeleteEvent, / - ) -> None: + def handle_webhook_delete(self, shard: Shard, payload: raw.ClientWebhookDeleteEvent, /) -> None: event = self._state.parser.parse_webhook_delete_event(shard, payload) self.dispatch(event) @@ -296,92 +271,221 @@ def handle_auth(self, shard: Shard, payload: raw.ClientAuthEvent, /) -> None: event = self._state.parser.parse_auth_event(shard, payload) self.dispatch(event) + async def _handle_library_error(self, shard: Shard, payload: raw.ClientEvent, exc: Exception, name: str, /) -> None: + try: + await utils._maybe_coroutine(self._client.on_library_error, shard, payload, exc) + except Exception as exc: + _L.exception('on_library_error (task: %s) raised an exception', name, exc_info=exc) + async def _handle(self, shard: Shard, payload: raw.ClientEvent, /) -> None: - type = payload["type"] + type = payload['type'] try: handler = self._handlers[type] except KeyError: - _L.debug("Received unknown event: %s. Discarding.", type) + _L.debug('Received unknown event: %s. Discarding.', type) else: - _L.debug("Handling %s", type) + _L.debug('Handling %s', type) try: await utils._maybe_coroutine(handler, shard, payload) except Exception as exc: - _L.exception("%s handler raised an exception", type, exc_info=exc) + if type == 'Ready': + # This is fatal + raise + + _L.exception('%s handler raised an exception', type, exc_info=exc) + + name = f'pyvolt-dispatch-{self._client._get_i()}' + asyncio.create_task(self._handle_library_error(shard, payload, exc, name), name=name) - async def handle_raw(self, shard: Shard, d: raw.ClientEvent) -> None: - return await self._handle(shard, d) + async def handle_raw(self, shard: Shard, payload: raw.ClientEvent) -> None: + return await self._handle(shard, payload) -EventT = t.TypeVar("EventT", bound="events.BaseEvent") +# OOP in Python sucks. +ClientT = typing.TypeVar('ClientT', bound='Client') +EventT = typing.TypeVar('EventT', bound='BaseEvent') -def _parents_of(type: type[events.BaseEvent]) -> tuple[type[events.BaseEvent], ...]: - if type is events.BaseEvent: - return () - tmp: t.Any = type.__mro__[:-2] +def _parents_of(type: type[BaseEvent]) -> tuple[type[BaseEvent], ...]: + """Returns parents of BaseEvent, including BaseEvent itself.""" + if type is BaseEvent: + return (BaseEvent,) + tmp: typing.Any = type.__mro__[:-1] return tmp +class EventSubscription(typing.Generic[EventT]): + """Represents a event subscription. + + Attributes + ---------- + client: :class:`Client` + The client that this subscription is tied to. + id: :class:`int` + The ID of the subscription. + callback + The callback. + """ + + __slots__ = ( + 'client', + 'id', + 'callback', + 'event', + ) + + def __init__( + self, + *, + client: Client, + id: int, + callback: utils.MaybeAwaitableFunc[[EventT], None], + event: type[EventT], + ) -> None: + self.client = client + self.id = id + self.callback = callback + self.event = event + + def __call__(self, arg: EventT, /) -> utils.MaybeAwaitable[None]: + return self.callback(arg) + + async def _handle(self, arg: EventT, name: str, /) -> None: + try: + await utils._maybe_coroutine(self.callback, arg) + except Exception: + try: + await utils._maybe_coroutine(self.client.on_user_error, arg) + except Exception as exc: + _L.exception('on_user_error (task: %s) raised an exception', name, exc_info=exc) + + def remove(self) -> None: + """Removes the event subscription.""" + self.client._handlers[self.event][0].pop(self.id, None) + + +class TemporarySubscription(typing.Generic[EventT]): + """Represents a temporary event subscription.""" + + __slots__ = ( + 'client', + 'id', + 'event', + 'future', + 'check', + 'coro', + ) + + def __init__( + self, + *, + client: Client, + id: int, + event: type[EventT], + future: asyncio.Future[EventT], + coro: Coroutine[typing.Any, typing.Any, EventT], + check: Callable[[EventT], utils.MaybeAwaitable[bool]], + ) -> None: + self.client = client + self.id = id + self.event = event + self.future = future + self.check = check + self.coro = coro + + def __await__(self) -> Generator[typing.Any, typing.Any, EventT]: + return self.coro.__await__() + + async def _handle(self, arg: EventT, name: str, /) -> bool: + try: + can = self.check(arg) + if inspect.isawaitable(can): + can = await can + + if can: + self.future.set_result(arg) + return can + except Exception as exc: + try: + self.future.set_exception(exc) + except Exception as exc: + _L.exception('Checker function (task: %s) raised an exception', name, exc_info=exc) + return True + + def cancel(self) -> None: + """Cancels the subscription.""" + self.future.cancel() + self.client._handlers[self.event][1].pop(self.id, None) + + +_DEFAULT_HANDLERS = ({}, {}) + + +def _private_channel_sort_old(channel: DMChannel | GroupChannel) -> str: + return channel.last_message_id or '0' + + +def _private_channel_sort_new(channel: DMChannel | GroupChannel) -> str: + return channel.last_message_id or channel.id + + class Client: """A Revolt client.""" - __slots__ = ("_handlers", "_types", "_i", "_state", "extra") + __slots__ = ('_handlers', '_types', '_i', '_state', 'extra', '_token', 'bot') - @t.overload + @typing.overload def __init__( self, *, - token: str, + token: str = '', bot: bool = True, - state: ca.Callable[[Client], State] | State | None = None, + state: Callable[[Client], State] | State | None = None, ) -> None: ... - @t.overload + @typing.overload def __init__( self, *, - token: str, + token: str = '', bot: bool = True, - cache: ( - ca.Callable[[Client, State], core.UndefinedOr[Cache | None]] | None - ) = None, + cache: Callable[[Client, State], UndefinedOr[Cache | None]] | UndefinedOr[Cache | None] = UNDEFINED, cdn_base: str | None = None, - cdn_client: ca.Callable[[Client, State], CDNClient] | None = None, + cdn_client: Callable[[Client, State], CDNClient] | None = None, http_base: str | None = None, - http: ca.Callable[[Client, State], HTTPClient] | None = None, - parser: ca.Callable[[Client, State], Parser] | None = None, - shard: ca.Callable[[Client, State], Shard] | None = None, + http: Callable[[Client, State], HTTPClient] | None = None, + parser: Callable[[Client, State], Parser] | None = None, + shard: Callable[[Client, State], Shard] | None = None, + request_user_settings: list[str] | None = None, websocket_base: str | None = None, ) -> None: ... def __init__( self, *, - token: str, + token: str = '', bot: bool = True, - cache: ( - ca.Callable[[Client, State], core.UndefinedOr[Cache | None]] - | core.UndefinedOr[Cache | None] - ) = core.UNDEFINED, + cache: Callable[[Client, State], UndefinedOr[Cache | None]] | UndefinedOr[Cache | None] = UNDEFINED, cdn_base: str | None = None, - cdn_client: ca.Callable[[Client, State], CDNClient] | None = None, + cdn_client: Callable[[Client, State], CDNClient] | None = None, http_base: str | None = None, - http: ca.Callable[[Client, State], HTTPClient] | None = None, - parser: ca.Callable[[Client, State], Parser] | None = None, - shard: ca.Callable[[Client, State], Shard] | None = None, + http: Callable[[Client, State], HTTPClient] | None = None, + parser: Callable[[Client, State], Parser] | None = None, + shard: Callable[[Client, State], Shard] | None = None, + state: Callable[[Client], State] | State | None = None, + request_user_settings: list[str] | None = None, websocket_base: str | None = None, - state: ca.Callable[[Client], State] | State | None = None, ) -> None: # {Type[BaseEvent]: List[utils.MaybeAwaitableFunc[[BaseEvent], None]]} self._handlers: dict[ - type[events.BaseEvent], - list[utils.MaybeAwaitableFunc[[events.BaseEvent], None]], + type[BaseEvent], + tuple[ + dict[int, EventSubscription[BaseEvent]], + dict[int, TemporarySubscription[BaseEvent]], + ], ] = {} # {Type[BaseEvent]: Tuple[Type[BaseEvent], ...]} - self._types: dict[ - type[events.BaseEvent], tuple[type[events.BaseEvent], ...] - ] = {} + self._types: dict[type[BaseEvent], tuple[type[BaseEvent], ...]] = {} self._i = 0 self.extra = {} @@ -398,14 +502,12 @@ def __init__( cr = cache(self, state) else: cr = cache - c = cr if core.is_defined(cr) else MapCache() + c = cr if cr is not UNDEFINED else MapCache() state.setup( cache=c, cdn_client=( - cdn_client(self, state) - if cdn_client - else CDNClient(state, session=_session_factory, base=cdn_base) + cdn_client(self, state) if cdn_client else CDNClient(state, session=_session_factory, base=cdn_base) ), http=( http(self, state) @@ -428,107 +530,282 @@ def __init__( else Shard( token, base=websocket_base, - handler=_ClientHandler(self), + handler=ClientEventHandler(self), + request_user_settings=request_user_settings, session=_session_factory, state=state, ) ) ) + self._token = token + self.bot = bot + self._subscribe_methods() def _get_i(self) -> int: self._i += 1 return self._i - async def on_error(self, event: events.BaseEvent) -> None: + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException], + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + async def on_user_error(self, event: BaseEvent) -> None: + """Handles user errors that came from handlers. + You can get current exception being raised via :func:`sys.exc_info`. + By default, this logs exception. + """ _, exc, _ = sys.exc_info() _L.exception( - "one of %s handlers raised an exception", + 'One of %s handlers raised an exception', event.__class__.__name__, exc_info=exc, ) - async def _dispatch( - self, - handlers: list[utils.MaybeAwaitableFunc[[EventT], None]], - event: EventT, - name: str, - /, - ) -> None: - event.before_dispatch() - await event.abefore_dispatch() - for handler in handlers: - try: - await utils._maybe_coroutine(handler, event) - except Exception: - try: - await utils._maybe_coroutine(self.on_error, event) - except Exception as exc: - _L.exception( - "on_error (task: %s) raised an exception", name, exc_info=exc - ) - - if not event.is_cancelled: - _L.debug("Processing %s", event.__class__.__name__) + async def on_library_error(self, shard: Shard, payload: raw.ClientEvent, exc: Exception) -> None: + """Handles library errors. By default, this logs exception. - event.process() - await event.aprocess() - else: - _L.debug("%s processing was cancelled", event.__class__.__name__) + .. note:: + This won't be called if handling `Ready` will raise a exception as it is fatal. + """ - def dispatch(self, event: events.BaseEvent) -> None: - """Dispatches a event.""" + type = payload['type'] - et = builtins.type(event) - try: - types = self._types[et] - except KeyError: - types = self._types[et] = _parents_of(et) + _L.exception('%s handler raised an exception', type, exc_info=exc) - for type in types: + async def _dispatch(self, types: list[type[BaseEvent]], event: BaseEvent, name: str, /) -> None: + event.before_dispatch() + await event.abefore_dispatch() - handlers = self._handlers.get(type, []) + for i, type in enumerate(types): + handlers, temporary_handlers = self._handlers.get(type, _DEFAULT_HANDLERS) if _L.isEnabledFor(logging.DEBUG): _L.debug( - "Dispatching %s (%i handlers, originating from %s)", + 'Dispatching %s (%i handlers, originating from %s)', type.__name__, len(handlers), event.__class__.__name__, ) - name = name = f"pyvolt-dispatch-{self._get_i()}" - asyncio.create_task(self._dispatch(handlers, event, name), name=name) + for handler in temporary_handlers.values(): + if await handler._handle(event, name): + temporary_handlers.pop(handler.id, None) + break + + for handler in handlers.values(): + await handler._handle(event, name) + + if event.is_cancelled: + _L.debug('%s processing was cancelled', event.__class__.__name__) + return + + _L.debug('Processing %s', event.__class__.__name__) + event.process() + await event.aprocess() + + def dispatch(self, event: BaseEvent) -> asyncio.Task[None]: + """Dispatches a event. + + Parameters + ---------- + event: :class:`BaseEvent` + The event to dispatch. + + Returns + ------- + :class:`asyncio.Task`[None] + The asyncio task. + """ + + et = builtins.type(event) + try: + types = self._types[et] + except KeyError: + types = self._types[et] = _parents_of(et) + + name = f'pyvolt-dispatch-{self._get_i()}' + return asyncio.create_task(self._dispatch(types, event, name), name=name) # type: ignore def subscribe( - self, event: type[EventT], callback: utils.MaybeAwaitableFunc[[EventT], None] - ) -> None: - """Subscribes to event.""" - tmp: t.Any = callback + self, + event: type[EventT], + callback: utils.MaybeAwaitableFunc[[EventT], None], + ) -> EventSubscription[EventT]: + """Subscribes to event. + + Parameters + ---------- + event: Type[EventT] + The type of the event. + callback + The callback for the event. + """ + sub: EventSubscription[EventT] = EventSubscription( + client=self, + id=self._get_i(), + callback=callback, + event=event, + ) + # The actual generic of value type is same as key try: - self._handlers[event].append(tmp) + self._handlers[event][0][sub.id] = sub # type: ignore except KeyError: - self._handlers[event] = [tmp] + self._handlers[event] = ({sub.id: sub}, {}) # type: ignore + return sub - def on(self, event: type[EventT]) -> ca.Callable[ + def on( + self, event: type[EventT] + ) -> Callable[ [utils.MaybeAwaitableFunc[[EventT], None]], - utils.MaybeAwaitableFunc[[EventT], None], + EventSubscription[EventT], + ]: + def decorator( + handler: utils.MaybeAwaitableFunc[[EventT], None], + ) -> EventSubscription[EventT]: + return self.subscribe(event, handler) + + return decorator + + @staticmethod + def listens_on( + event: type[EventT], + ) -> Callable[ + [utils.MaybeAwaitableFunc[[ClientT, EventT], None]], + utils.MaybeAwaitableFunc[[ClientT, EventT], None], ]: def decorator( - callback: utils.MaybeAwaitableFunc[[EventT], None] - ) -> utils.MaybeAwaitableFunc[[EventT], None]: - self.subscribe(event, callback) + handler: utils.MaybeAwaitableFunc[[ClientT, EventT], None], + ) -> utils.MaybeAwaitableFunc[[ClientT, EventT], None]: + @wraps(handler) + def callback(*args, **kwargs) -> utils.MaybeAwaitable[None]: + return handler(*args, **kwargs) + + callback.__pyvolt_handles__ = event # type: ignore return callback return decorator + def _subscribe_methods(self) -> None: + for _, callback in inspect.getmembers(self, lambda func: hasattr(func, '__pyvolt_handles__')): + self.subscribe(callback.__pyvolt_handles__, callback) + + def wait_for( + self, + event: type[EventT], + /, + *, + check: Callable[[EventT], bool] | None = None, + timeout: float | None = None, + ) -> TemporarySubscription[EventT]: + """|coro| + + Waits for a WebSocket event to be dispatched. + + This could be used to wait for a user to reply to a message, + or to react to a message, or to edit a message in a self-contained + way. + + The ``timeout`` parameter is passed onto :func:`asyncio.wait_for`. By default, + it does not timeout. Note that this does propagate the + :exc:`asyncio.TimeoutError` for you in case of timeout and is provided for + ease of use. + + This function returns the **first event that meets the requirements**. + + Examples + --------- + + Waiting for a user reply: :: + + @client.on(pyvolt.MessageCreateEvent) + async def on_message_create(event): + message = event.message + if message.content.startswith('$greet'): + channel = message.channel + await channel.send('Say hello!') + + def check(event): + return event.message.content == 'hello' and event.message.channel.id == channel.id + + msg = await client.wait_for(pyvolt.MessageCreateEvent, check=check) + await channel.send(f'Hello {msg.author}!') + + Waiting for a thumbs up reaction from the message author: :: + + @client.on(pyvolt.MessageCreateEvent) + async def on_message_create(event): + message = event.message + if message.content.startswith('$thumb'): + channel = message.channel + await channel.send('Send me that \N{THUMBS UP SIGN} reaction, mate') + + def check(event): + return event.user_id == message.author.id and event.emoji == '\N{THUMBS UP SIGN}' + + try: + await client.wait_for(pyvolt.MessageReactEvent, timeout=60.0, check=check) + except asyncio.TimeoutError: + await channel.send('\N{THUMBS DOWN SIGN}') + else: + await channel.send('\N{THUMBS UP SIGN}') + + Parameters + ------------ + event: Type[EventT] + The event to wait for. + check: Optional[Callable[[EventT], :class:`bool`]] + A predicate to check what to wait for. + timeout: Optional[:class:`float`] + The number of seconds to wait before timing out and raising + :exc:`asyncio.TimeoutError`. + + Raises + ------- + asyncio.TimeoutError + If a timeout is provided and it was reached. + + Returns + -------- + :class:`TemporarySubscription`[EventT] + The subscription. This can be ``await``'ed. + """ + + if not check: + check = lambda _: True + + future = asyncio.get_running_loop().create_future() + + coro = asyncio.wait_for(future, timeout=timeout) + sub = TemporarySubscription( + client=self, + id=self._get_i(), + event=event, + future=future, + check=check, + coro=coro, + ) + + try: + self._handlers[event][1][sub.id] = sub # type: ignore + except KeyError: + self._handlers[event] = ({sub.id: sub}, {}) # type: ignore + return sub + @property - def me(self) -> SelfUser | None: - """:class:`SelfUser` | None: The currently logged in user. ``None`` if not logged in.""" + def me(self) -> OwnUser | None: + """Optional[:class:`OwnUser`]: The currently logged in user. ``None`` if not logged in.""" return self._state._me @property def saved_notes(self) -> SavedMessagesChannel | None: - """:class:`SavedMessagesChannel` | None: The Saved Notes channel.""" + """Optional[:class:`SavedMessagesChannel`]: The Saved Notes channel.""" return self._state._saved_notes @property @@ -537,14 +814,420 @@ def http(self) -> HTTPClient: return self._state.http @property - def servers(self) -> ca.Mapping[core.ULID, Server]: + def shard(self) -> Shard: + """:class:`Shard`: The Revolt WebSocket client.""" + return self._state.shard + + @property + def state(self) -> State: + """:class:`State`: The controller for all entities and components.""" + return self._state + + @property + def channels(self) -> Mapping[str, Channel]: + """Mapping[:class:`str`, :class:`Channel`]: Mapping of cached channels.""" + cache = self._state.cache + if cache: + return cache.get_channels_mapping() + return {} + + @property + def emojis(self) -> Mapping[str, Emoji]: + """Mapping[:class:`str`, :class:`Emoji`]: Mapping of cached emojis.""" + cache = self._state.cache + if cache: + return cache.get_emojis_mapping() + return {} + + @property + def servers(self) -> Mapping[str, Server]: + """Mapping[:class:`str`, :class:`Server`]: Mapping of cached servers.""" cache = self._state.cache if cache: return cache.get_servers_mapping() return {} + @property + def users(self) -> Mapping[str, User]: + """Mapping[:class:`str`, :class:`User`]: Mapping of cached users.""" + cache = self._state.cache + if cache: + return cache.get_users_mapping() + return {} + + @property + def dm_channel_ids(self) -> Mapping[str, str]: + """Mapping[:class:`str`, :class:`str`]: Mapping of user IDs to cached DM channel IDs.""" + cache = self._state.cache + if cache: + return cache.get_private_channels_by_users_mapping() + return {} + + @property + def dm_channels(self) -> Mapping[str, DMChannel]: + """Mapping[:class:`str`, :class:`DMChannel`]: Mapping of user IDs to cached DM channels.""" + + cache = self._state.cache + if not cache: + return {} + + result = {} + for k, v in self.dm_channel_ids.items(): + channel = cache.get_channel(v, caching._USER_REQUEST) + if channel and channel.__class__ is DMChannel or isinstance(channel, DMChannel): + result[k] = channel + return result + + @property + def private_channels(self) -> Mapping[str, DMChannel | GroupChannel]: + """Mapping[:class:`str`, Union[:class:`DMChannel`, :class:`GroupChannel`]]: Mapping of channel IDs to private channels. + Useful when developing Revolt client.""" + cache = self._state.cache + if not cache: + return {} + return cache.get_private_channels_mapping() + + @property + def ordered_private_channels_old(self) -> list[DMChannel | GroupChannel]: + """List[Union[:class:`DMChannel`, :class:`GroupChannel`]]: The list of private channels in Revite order. + Useful when developing Revolt client.""" + return sorted(self.private_channels.values(), key=_private_channel_sort_old, reverse=True) + + @property + def ordered_private_channels(self) -> list[DMChannel | GroupChannel]: + """List[Union[:class:`DMChannel`, :class:`GroupChannel`]]: The list of private channels in new web client order. + Useful when developing Revolt client.""" + return sorted(self.private_channels.values(), key=_private_channel_sort_new, reverse=True) + + def get_channel(self, channel_id: str, /) -> Channel | None: + """Retrieves a channel from cache. + + Parameters + ---------- + channel_id: :class:`str` + The channel ID. + + Returns + ------- + Optional[:class:`Channel`] + The channel or ``None`` if not found. + """ + cache = self._state.cache + if cache: + return cache.get_channel(channel_id, caching._USER_REQUEST) + + async def fetch_channel(self, channel_id: str, /) -> Channel: + """|coro| + + Retrieves a channel from API. This is shortcut to :meth:`HTTPClient.get_channel`. + + Parameters + ---------- + channel_id: :class:`str` + The channel ID. + + Returns + ------- + :class:`Channel` + The retrieved channel. + """ + return await self.http.get_channel(channel_id) + + def get_emoji(self, emoji_id: str, /) -> Emoji | None: + """Retrieves a emoji from cache. + + Parameters + ---------- + emoji_id: :class:`str` + The emoji ID. + + Returns + ------- + Optional[:class:`Emoji`] + The emoji or ``None`` if not found. + """ + cache = self._state.cache + if cache: + return cache.get_emoji(emoji_id, caching._USER_REQUEST) + + async def fetch_emoji(self, emoji_id: str, /) -> Emoji: + """|coro| + + Retrieves a emoji from API. This is shortcut to :meth:`HTTPClient.get_emoji`. + + Parameters + ---------- + emoji_id: :class:`str` + The emoji ID. + + Returns + ------- + :class:`Emoji` + The retrieved emoji. + """ + return await self.http.get_emoji(emoji_id) + + def get_read_state(self, channel_id: str, /) -> ReadState | None: + """Retrieves a read state from cache. + + Parameters + ---------- + channel_id: :class:`str` + The channel ID of read state. + + Returns + ------- + Optional[:class:`ReadState`] + The read state or ``None`` if not found. + """ + cache = self._state.cache + if cache: + return cache.get_read_state(channel_id, caching._USER_REQUEST) + + def get_server(self, server_id: str, /) -> Server | None: + """Retrieves a server from cache. + + Parameters + ---------- + server_id: :class:`str` + The server ID. + + Returns + ------- + Optional[:class:`Server`] + The server or ``None`` if not found. + """ + cache = self._state.cache + if cache: + return cache.get_server(server_id, caching._USER_REQUEST) + + async def fetch_server(self, server_id: str, /) -> Server: + """|coro| + + Retrieves a server from API. This is shortcut to :meth:`HTTPClient.get_server`. + + Parameters + ---------- + server_id: :class:`str` + The server ID. + + Returns + ------- + :class:`Server` + The server. + """ + return await self.http.get_server(server_id) + + def get_user(self, user_id: str, /) -> User | None: + """Retrieves a user from cache. + + Parameters + ---------- + user_id: :class:`str` + The user ID. + + Returns + ------- + Optional[:class:`User`] + The user or ``None`` if not found. + """ + cache = self._state.cache + if cache: + return cache.get_user(user_id, caching._USER_REQUEST) + + async def fetch_user(self, user_id: str, /) -> User: + """|coro| + + Retrieves a user from API. This is shortcut to :meth:`HTTPClient.get_user`. + + Parameters + ---------- + user_id: :class:`str` + The user ID. + + Returns + ------- + :class:`User` + The user. + """ + return await self.http.get_user(user_id) + + @property + def settings(self) -> UserSettings: + """:class:`UserSettings`: The current user settings.""" + return self._state.settings + + @property + def system(self) -> User: + """:class:`User`: The Revolt sentinel user.""" + return self._state.system + async def start(self) -> None: + """|coro| + + Starts up the client. + """ await self._state.shard.connect() + async def close(self) -> None: + """|coro| -__all__ = ("Client",) + Closes all HTTP sessions, and websocket connections. + """ + if self._state.shard.ws: + try: + await self._state.shard.close() + except Exception: + pass + + def run( + self, + token: str = '', + *, + bot: UndefinedOr[bool] = UNDEFINED, + log_handler: UndefinedOr[logging.Handler | None] = UNDEFINED, + log_formatter: UndefinedOr[logging.Formatter] = UNDEFINED, + log_level: UndefinedOr[int] = UNDEFINED, + root_logger: bool = False, + asyncio_debug: bool = False, + ) -> None: + """A blocking call that abstracts away the event loop + initialisation from you. + + If you want more control over the event loop then this + function should not be used. Use :meth:`.start` coroutine. + + This function also sets up the logging library to make it easier + for beginners to know what is going on with the library. For more + advanced users, this can be disabled by passing ``None`` to + the ``log_handler`` parameter. + + .. warning:: + + This function must be the last function to call due to the fact that it + is blocking. That means that registration of events or anything being + called after this function call will not execute until it returns. + + Parameters + ----------- + log_handler: Optional[:class:`logging.Handler`] + The log handler to use for the library's logger. If this is ``None`` + then the library will not set up anything logging related. Logging + will still work if ``None`` is passed, though it is your responsibility + to set it up. + + The default log handler if not provided is :class:`logging.StreamHandler`. + log_formatter: :class:`logging.Formatter` + The formatter to use with the given log handler. If not provided then it + defaults to a colour based logging formatter (if available). + log_level: :class:`int` + The default log level for the library's logger. This is only applied if the + ``log_handler`` parameter is not ``None``. Defaults to ``logging.INFO``. + root_logger: :class:`bool` + Whether to set up the root logger rather than the library logger. + By default, only the library logger (``'pyvolt'``) is set up. If this + is set to ``True`` then the root logger is set up as well. + + Defaults to ``False``. + asyncio_debug: :class:`bool` + Whether to run with asyncio debug mode enabled or not. + + Defaults to ``False``. + """ + + if token: + bot = self.bot if bot is UNDEFINED else bot + + self.http.with_credentials(token, bot=bot) + self.shard.with_credentials(token, bot=bot) + elif not self._token: + raise TypeError('No token was provided') + + async def runner(): + async with self: + await self.start() + + if log_handler is not None: + utils.setup_logging( + handler=log_handler, + formatter=log_formatter, + level=log_level, + root=root_logger, + ) + + try: + asyncio.run(runner(), debug=asyncio_debug) + except KeyboardInterrupt: + # nothing to do here + # `asyncio.run` handles the loop cleanup + # and `self.start` closes all sockets and the HTTPClient instance. + return + + async def create_group( + self, + name: str, + *, + description: str | None = None, + recipients: list[ULIDOr[BaseUser]] | None = None, + nsfw: bool | None = None, + ) -> GroupChannel: + """|coro| + + Creates the new group channel. + + .. note:: + This can only be used by non-bot accounts. + + Parameters + ---------- + name: :class:`str` + The group name. + description: Optional[:class:`str`] + The group description. + recipients: Optional[List[:class:`ULIDOr`[:class:`BaseUser`]]] + The list of recipients to add to the group. You must be friends with these users. + nsfw: Optional[:class:`bool`] + Whether this group should be age-restricted. + + Raises + ------ + HTTPException + Creating the group failed. + + Returns + ------- + :class:`GroupChannel` + The new group. + """ + return await self.http.create_group(name, description=description, recipients=recipients, nsfw=nsfw) + + async def create_server(self, name: str, /, *, description: str | None = None, nsfw: bool | None = None) -> Server: + """|coro| + + Create a new server. + + Parameters + ---------- + name: :class:`str` + The server name. + description: Optional[:class:`str`] + The server description. + nsfw: Optional[:class:`bool`] + Whether this server is age-restricted. + + Returns + ------- + :class:`Server` + The created server. + """ + return await self.http.create_server(name, description=description, nsfw=nsfw) + + +__all__ = ( + 'EventSubscription', + 'TemporarySubscription', + 'ClientEventHandler', + '_private_channel_sort_old', + '_private_channel_sort_new', + 'Client', +) diff --git a/pyvolt/core.py b/pyvolt/core.py index 3aee500..27da3cc 100644 --- a/pyvolt/core.py +++ b/pyvolt/core.py @@ -1,70 +1,96 @@ -from collections.abc import MutableMapping -import datetime -import typing as t -import ulid +""" +The MIT License (MIT) +Copyright (c) 2024-present MCausc78 -class Undefined: - """Undefined sentinel""" +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: - __slots__ = () +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. - def __init__(self) -> None: - pass +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" - def __bool__(self) -> t.Literal[False]: +from __future__ import annotations + +from datetime import datetime, timezone +import typing + +from .enums import Enum +from .ulid import _ulid_timestamp + + +class _Sentinel(Enum): + """The library sentinels.""" + + _undefined = 'UNDEFINED' + + def __bool__(self) -> typing.Literal[False]: return False + def __repr__(self) -> typing.Literal['UNDEFINED']: + return self.value -UNDEFINED = Undefined() + def __eq__(self, other) -> bool: + return self is other -T = t.TypeVar("T") -UndefinedOr = Undefined | T +Undefined: typing.TypeAlias = typing.Literal[_Sentinel._undefined] +UNDEFINED: Undefined = _Sentinel._undefined -def is_defined(x: UndefinedOr[T]) -> t.TypeGuard[T]: - return x is not UNDEFINED and not isinstance(x, Undefined) +T = typing.TypeVar('T') +UndefinedOr = Undefined | T -class ULID(str): - EPOCH: int = 1420070400000 +def ulid_timestamp(val: str) -> float: + return _ulid_timestamp(val.encode('ascii')) - @property - def timestamp(self) -> float: - return ulid.parse(self).timestamp().timestamp - @property - def created_at(self) -> datetime.datetime: - return datetime.datetime.fromtimestamp(self.timestamp) +def ulid_time(val: str) -> datetime: + return datetime.fromtimestamp(ulid_timestamp(val), timezone.utc) -class HasID(t.Protocol): - id: ULID +class HasID(typing.Protocol): + id: str -ResolvableULID = ULID | HasID | str +U = typing.TypeVar('U', bound='HasID') +ULIDOr = str | U -def resolve_ulid(resolvable: ResolvableULID) -> ULID: - if isinstance(resolvable, ULID): - return resolvable +def resolve_id(resolvable: ULIDOr) -> str: if isinstance(resolvable, str): - return ULID(resolvable) + return resolvable return resolvable.id -__version__: str = "1.0.0" +# zero ID +ZID = '00000000000000000000000000' + +__version__: str = '0.7.0' __all__ = ( - "Undefined", - "UNDEFINED", - "T", - "UndefinedOr", - "is_defined", - "ULID", - "HasID", - "ResolvableULID", - "resolve_ulid", - "__version__", + 'Undefined', + 'UNDEFINED', + 'T', + 'UndefinedOr', + 'ulid_timestamp', + 'ulid_time', + 'HasID', + 'ULIDOr', + 'resolve_id', + '__version__', + 'ZID', ) diff --git a/pyvolt/discovery.py b/pyvolt/discovery.py index 7e22d04..dc76ac4 100644 --- a/pyvolt/discovery.py +++ b/pyvolt/discovery.py @@ -1,217 +1,212 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import aiohttp from attrs import define, field -from enum import StrEnum import logging -import typing as t - -from . import ( - bot as bots, - cdn, - core, - errors, - server as servers, - user as users, - utils, -) +import typing + +from . import utils +from .bot import BaseBot +from .cdn import StatelessAsset, Asset +from .core import UNDEFINED, UndefinedOr, __version__ as version +from .enums import ServerActivity, BotUsage, ReviteBaseTheme +from .errors import DiscoveryError +from .server import ServerFlags, BaseServer from .state import State +from .user import StatelessUserProfile, UserProfile -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from . import raw + from .user_settings import ReviteThemeVariable _L = logging.getLogger(__name__) -DEFAULT_DISCOVERY_USER_AGENT = ( - f"pyvolt Discovery client (https://github.com/MCausc78/pyvolt, {core.__version__})" -) - - -class ServerActivity(StrEnum): - HIGH = "high" - MEDIUM = "medium" - LOW = "low" - NO = "no" +DEFAULT_DISCOVERY_USER_AGENT = f'pyvolt Discovery client (https://github.com/MCausc78/pyvolt, {version})' @define(slots=True) -class DiscoveryServer(servers.BaseServer): +class DiscoveryServer(BaseServer): """Representation of a server on Revolt Discovery. The ID is a invite code.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """The server name.""" - description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + description: str | None = field(repr=True, kw_only=True) """The server description.""" - internal_icon: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_icon: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless server icon.""" - internal_banner: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_banner: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless server banner.""" - flags: servers.ServerFlags = field(repr=True, hash=True, kw_only=True, eq=True) + flags: ServerFlags = field(repr=True, kw_only=True) """The server flags.""" - tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + tags: list[str] = field(repr=True, kw_only=True) """The server tags.""" - member_count: int = field(repr=True, hash=True, kw_only=True, eq=True) + member_count: int = field(repr=True, kw_only=True) """The server member count.""" - activity: ServerActivity = field(repr=True, hash=True, kw_only=True, eq=True) + activity: ServerActivity = field(repr=True, kw_only=True) """The server activity.""" @property - def icon(self) -> cdn.Asset | None: - """The server icon.""" - return self.internal_icon and self.internal_icon._stateful(self.state, "icons") + def icon(self) -> Asset | None: + """Optional[:class:`Asset`]: The server icon.""" + return self.internal_icon and self.internal_icon._stateful(self.state, 'icons') @property def invite_code(self) -> str: - """The server invite code. As an implementation detail, right now it returns server ID, but don't depend on that in future.""" + """:class:`str`: The server invite code. As an implementation detail, right now it returns server ID, but don't depend on that in future.""" return self.id @property - def banner(self) -> cdn.Asset | None: - """The server banner.""" - return self.internal_banner and self.internal_banner._stateful( - self.state, "banners" - ) - - async def join(self) -> servers.Server: - """|coro| - - Joins the server. - - Raises - ------ - :class:`Forbidden` - You're banned. - :class:`APIError` - Accepting the invite failed. - """ - server = await self.state.http.accept_invite(self.invite_code) - assert isinstance(server, servers.Server) - return server + def banner(self) -> Asset | None: + """Optional[:class:`Asset`]: The server banner.""" + return self.internal_banner and self.internal_banner._stateful(self.state, 'banners') @define(slots=True) class DiscoveryServersPage: - servers: list[DiscoveryServer] = field(repr=True, hash=True, kw_only=True, eq=True) + servers: list[DiscoveryServer] = field(repr=True, kw_only=True) """The listed servers, up to 200 servers.""" - popular_tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + popular_tags: list[str] = field(repr=True, kw_only=True) """Popular tags used in discovery servers.""" -class BotUsage(StrEnum): - HIGH = "high" - MEDIUM = "medium" - LOW = "low" - - @define(slots=True) -class DiscoveryBot(bots.BaseBot): - username: str = field(repr=True, hash=True, kw_only=True, eq=True) - """The bot's username.""" +class DiscoveryBot(BaseBot): + name: str = field(repr=True, kw_only=True) + """The bot's name.""" - internal_avatar: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_avatar: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless bot's avatar.""" - internal_profile: users.StatelessUserProfile = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_profile: StatelessUserProfile = field(repr=True, kw_only=True) """The stateless bot's profile.""" - tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + tags: list[str] = field(repr=True, kw_only=True) """The bot's tags.""" - server_count: int = field(repr=True, hash=True, kw_only=True, eq=True) + server_count: int = field(repr=True, kw_only=True) """The bot's servers count.""" - usage: BotUsage = field(repr=True, hash=True, kw_only=True, eq=True) + usage: BotUsage = field(repr=True, kw_only=True) """How frequently is bot being used.""" @property - def avatar(self) -> cdn.Asset | None: - """The bot's avatar.""" - return self.internal_avatar and self.internal_avatar._stateful( - self.state, "avatars" - ) + def avatar(self) -> Asset | None: + """Optional[:class:`Asset`]: The bot's avatar.""" + return self.internal_avatar and self.internal_avatar._stateful(self.state, 'avatars') @property def description(self) -> str: - """The bot's profile description.""" - return self.internal_profile.content or "" + """:class:`str`: The bot's profile description.""" + return self.internal_profile.content or '' @property - def profile(self) -> users.UserProfile: - """The bot's profile.""" + def profile(self) -> UserProfile: + """:class:`UserProfile`: The bot's profile.""" return self.internal_profile._stateful(self.state, self.id) @define(slots=True) class DiscoveryBotsPage: - bots: list[DiscoveryBot] = field(repr=True, hash=True, kw_only=True, eq=True) + bots: list[DiscoveryBot] = field(repr=True, kw_only=True) """The listed bots, up to 200 bots.""" - popular_tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + popular_tags: list[str] = field(repr=True, kw_only=True) """Popular tags used in discovery bots.""" @define(slots=True) class DiscoveryTheme: - state: "State" = field(repr=False, hash=True, eq=True) + state: State = field(repr=False) """State that controls this theme.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """The theme name.""" - description: str = field(repr=True, hash=True, kw_only=True, eq=True) + description: str = field(repr=True, kw_only=True) """The theme description.""" - slug: str = field(repr=True, hash=True, kw_only=True, eq=True) + creator: str = field(repr=True, kw_only=True) + """The theme creator.""" + + slug: str = field(repr=True, kw_only=True) """The theme slug.""" - tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + tags: list[str] = field(repr=True, kw_only=True) """The theme tags.""" - variables: dict[str, str] = field(repr=True, hash=True, kw_only=True, eq=True) - """The theme color mapping in format `{css_class: '#RRGGBB'}`.""" + overrides: dict[ReviteThemeVariable, str] = field(repr=True, kw_only=True) + """The theme overrides in format `{css_class: css_color}`.""" - version: str = field(repr=True, hash=True, kw_only=True, eq=True) + version: str = field(repr=True, kw_only=True) """The theme version.""" - css: str | None = field(repr=True, hash=True, kw_only=True, eq=True) - """The theme CSS.""" + custom_css: str | None = field(repr=True, kw_only=True) + """The theme CSS string.""" - # TODO: `apply` method - # The `theme` user setting has following JSON payload: - # - `appearance:theme:overrides` object, containing `Theme.variables` value - # - [Optional, if `Theme.css` is not none] `appearance:theme:css` object, containing `Theme.css` value - # Example: - # setting = { - # "appearance:theme:base": "dark", - # "appearance:theme:overrides": theme.variables, - # } - # if theme.css is not None: - # setting["appearance:theme:css"] = theme.css - # payload = {"theme": to_json(setting)} # "Modify User Settings" payload + def __hash__(self) -> int: + return hash((self.name, self.creator)) + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, DiscoveryTheme) + and self.name == other.name + and self.creator == other.creator + ) + + async def apply(self, *, base_theme: UndefinedOr[ReviteBaseTheme] = UNDEFINED) -> None: + """|coro| + + Applies the theme to current user account. + """ + await self.state.settings.revite.edit( + overrides=self.overrides, + base_theme=base_theme, + custom_css=self.custom_css, + ) @define(slots=True) class DiscoveryThemesPage: - themes: list[DiscoveryTheme] = field(repr=True, hash=True, kw_only=True, eq=True) + themes: list[DiscoveryTheme] = field( + repr=True, + kw_only=True, + ) """The listed themes, up to 200 themes.""" - popular_tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + popular_tags: list[str] = field(repr=True, kw_only=True) """Popular tags used in discovery themes.""" @@ -229,16 +224,16 @@ class DiscoveryThemesPage: class ServerSearchResult: """The server search result object.""" - query: str = field(repr=True, hash=True, kw_only=True, eq=True) + query: str = field(repr=True, kw_only=True) """The lower-cased query.""" - count: int = field(repr=True, hash=True, kw_only=True, eq=True) + count: int = field(repr=True, kw_only=True) """The servers count.""" - servers: list[DiscoveryServer] = field(repr=True, hash=True, kw_only=True, eq=True) + servers: list[DiscoveryServer] = field(repr=True, kw_only=True) """The listed servers.""" - related_tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + related_tags: list[str] = field(repr=True, kw_only=True) """All of tags that listed servers have.""" @@ -246,16 +241,16 @@ class ServerSearchResult: class BotSearchResult: """The bot search result object.""" - query: str = field(repr=True, hash=True, kw_only=True, eq=True) + query: str = field(repr=True, kw_only=True) """The lower-cased query.""" - count: int = field(repr=True, hash=True, kw_only=True, eq=True) + count: int = field(repr=True, kw_only=True) """The bots count.""" - bots: list[DiscoveryBot] = field(repr=True, hash=True, kw_only=True, eq=True) + bots: list[DiscoveryBot] = field(repr=True, kw_only=True) """The listed bots.""" - related_tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + related_tags: list[str] = field(repr=True, kw_only=True) """All of tags that listed bots have.""" @@ -263,122 +258,186 @@ class BotSearchResult: class ThemeSearchResult: """The theme search result object.""" - query: str = field(repr=True, hash=True, kw_only=True, eq=True) + query: str = field(repr=True, kw_only=True) """The lower-cased query.""" - count: int = field(repr=True, hash=True, kw_only=True, eq=True) + count: int = field(repr=True, kw_only=True) """The themes count.""" - themes: list[DiscoveryTheme] = field(repr=True, hash=True, kw_only=True, eq=True) + themes: list[DiscoveryTheme] = field(repr=True, kw_only=True) """The listed themes.""" - related_tags: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) + related_tags: list[str] = field(repr=True, kw_only=True) """All of tags that listed themes have.""" class DiscoveryClient: - def __init__( - self, *, base: str | None = None, session: aiohttp.ClientSession, state: State - ) -> None: - self._base = ( - "https://rvlt.gg/_next/data/OddIUaX26creykRzYdVYw/" - if base is None - else base.rstrip("/") - ) + __slots__ = ( + '_base', + 'session', + 'state', + ) + + def __init__(self, *, base: str | None = None, session: aiohttp.ClientSession, state: State) -> None: + self._base = 'https://rvlt.gg/_next/data/OddIUaX26creykRzYdVYw/' if base is None else base.rstrip('/') + '/' self.session = session self.state = state - async def _request( - self, method: str, path: str, **kwargs - ) -> aiohttp.ClientResponse: - _L.debug("sending %s to %s params=%s", method, path, kwargs.get("params")) - headers = {"User-Agent": DEFAULT_DISCOVERY_USER_AGENT} - headers.update(kwargs.pop("headers", {})) - response = await self.session.request( - method, self._base + path.lstrip("/"), headers=headers, **kwargs - ) + async def _request(self, method: str, path: str, **kwargs) -> aiohttp.ClientResponse: + _L.debug('sending %s to %s params=%s', method, path, kwargs.get('params')) + headers = {'user-agent': DEFAULT_DISCOVERY_USER_AGENT} + headers.update(kwargs.pop('headers', {})) + response = await self.session.request(method, self._base + path.lstrip('/'), headers=headers, **kwargs) if response.status >= 400: - raise errors.DiscoveryError( - response, response.status, await utils._json_or_text(response) - ) + body = await utils._json_or_text(response) + raise DiscoveryError(response, response.status, body) return response - async def request(self, method: str, path: str, **kwargs) -> t.Any: - async with await self._request(method, path, **kwargs) as response: - result = await utils._json_or_text(response) - _L.debug("received from %s %s: %s", method, path, result) + async def request(self, method: str, path: str, **kwargs) -> typing.Any: + response = await self._request(method, path, **kwargs) + result = await utils._json_or_text(response) + _L.debug('received from %s %s: %s', method, path, result) + response.close() return result async def servers(self) -> DiscoveryServersPage: + """|coro| + + Retrieves servers on a main page. + + Returns + ------- + :class:`DiscoveryServersPage` + The servers page. + """ page: raw.NextPage[raw.DiscoveryServersPage] = await self.request( - "GET", "/discover/servers.json", params={"embedded": "true"} + 'GET', '/discover/servers.json', params={'embedded': 'true'} ) - return self.state.parser.parse_discovery_servers_page(page["pageProps"]) + return self.state.parser.parse_discovery_servers_page(page['pageProps']) async def bots(self) -> DiscoveryBotsPage: + """|coro| + + Retrieves bots on a main page. + + Returns + ------- + :class:`DiscoveryBotsPage` + The bots page. + """ + page: raw.NextPage[raw.DiscoveryBotsPage] = await self.request( - "GET", "/discover/bots.json", params={"embedded": "true"} + 'GET', '/discover/bots.json', params={'embedded': 'true'} ) - return self.state.parser.parse_discovery_bots_page(page["pageProps"]) + return self.state.parser.parse_discovery_bots_page(page['pageProps']) async def themes(self) -> DiscoveryThemesPage: + """|coro| + + Retrieves themes on a main page. + + Returns + ------- + :class:`DiscoveryThemesPage` + The themes page. + """ page: raw.NextPage[raw.DiscoveryThemesPage] = await self.request( - "GET", "/discover/themes.json", params={"embedded": "true"} + 'GET', '/discover/themes.json', params={'embedded': 'true'} ) - return self.state.parser.parse_discovery_themes_page(page["pageProps"]) + return self.state.parser.parse_discovery_themes_page(page['pageProps']) async def search_servers(self, query: str) -> ServerSearchResult: + """|coro| + + Searches for servers. + + Parameters + ---------- + query: :class:`str` + The query to search for. + + Returns + ------- + :class:`ServerSearchResult` + The search results. + """ page: raw.NextPage[raw.DiscoveryServerSearchResult] = await self.request( - "GET", - "/discover/search.json", + 'GET', + '/discover/search.json', params={ - "embedded": "true", - "query": query, - "type": "servers", + 'embedded': 'true', + 'query': query, + 'type': 'servers', }, ) - return self.state.parser.parse_discovery_server_search_result(page["pageProps"]) + return self.state.parser.parse_discovery_server_search_result(page['pageProps']) async def search_bots(self, query: str) -> BotSearchResult: + """|coro| + + Searches for bots. + + Parameters + ---------- + query: :class:`str` + The query to search for. + + Returns + ------- + :class:`BotSearchResult` + The search results. + """ page: raw.NextPage[raw.DiscoveryBotSearchResult] = await self.request( - "GET", - "/discover/search.json", + 'GET', + '/discover/search.json', params={ - "embedded": "true", - "query": query, - "type": "bots", + 'embedded': 'true', + 'query': query, + 'type': 'bots', }, ) - return self.state.parser.parse_discovery_bot_search_result(page["pageProps"]) + return self.state.parser.parse_discovery_bot_search_result(page['pageProps']) async def search_themes(self, query: str) -> ThemeSearchResult: + """|coro| + + Searches for themes. + + Parameters + ---------- + query: :class:`str` + The query to search for. + + Returns + ------- + :class:`ThemeSearchResult` + The search results. + """ page: raw.NextPage[raw.DiscoveryThemeSearchResult] = await self.request( - "GET", - "/discover/search.json", + 'GET', + '/discover/search.json', params={ - "embedded": "true", - "query": query, - "type": "themes", + 'embedded': 'true', + 'query': query, + 'type': 'themes', }, ) - return self.state.parser.parse_discovery_theme_search_result(page["pageProps"]) + return self.state.parser.parse_discovery_theme_search_result(page['pageProps']) __all__ = ( - "ServerActivity", - "DiscoveryServer", - "DiscoveryServersPage", - "BotUsage", - "DiscoveryBot", - "DiscoveryBotsPage", - "DiscoveryTheme", - "DiscoveryThemesPage", - "ServerSearchResult", - "BotSearchResult", - "ThemeSearchResult", - "DiscoveryClient", + 'DiscoveryServer', + 'DiscoveryServersPage', + 'DiscoveryBot', + 'DiscoveryBotsPage', + 'DiscoveryTheme', + 'DiscoveryThemesPage', + 'ServerSearchResult', + 'BotSearchResult', + 'ThemeSearchResult', + 'DiscoveryClient', ) diff --git a/pyvolt/embed.py b/pyvolt/embed.py index 3590bf2..bf93f2b 100644 --- a/pyvolt/embed.py +++ b/pyvolt/embed.py @@ -1,11 +1,37 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + import abc from attrs import define, field -from enum import StrEnum -import typing as t +import typing -from . import cdn +from .cdn import StatelessAsset, Asset +from .enums import LightspeedContentType, TwitchContentType, BandcampContentType, ImageSize -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from .state import State @@ -14,10 +40,11 @@ class _BaseEmbed(abc.ABC): """The message embed.""" @abc.abstractmethod - def _stateful(self, state: "State") -> "Embed": ... + def _stateful(self, state: State) -> Embed: ... -class EmbedSpecial(abc.ABC): +@define(slots=True) +class EmbedSpecial: """Information about special remote content.""" @@ -39,48 +66,32 @@ class GifEmbedSpecial(EmbedSpecial): class YouTubeEmbedSpecial(EmbedSpecial): """Represents information about Youtube video.""" - id: str = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True, eq=True) """The video ID.""" - timestamp: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + timestamp: str | None = field(repr=True, kw_only=True, eq=True) """The video timestamp.""" -class LightspeedContentType(StrEnum): - """Type of remote Lightspeed.tv content.""" - - CHANNEL = "Channel" - - @define(slots=True) class LightspeedEmbedSpecial(EmbedSpecial): """Represents information about Lightspeed.tv stream.""" - content_type: LightspeedContentType = field( - repr=True, hash=True, kw_only=True, eq=True - ) + content_type: LightspeedContentType = field(repr=True, kw_only=True, eq=True) """The Lightspeed.tv content type.""" - id: str = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True, eq=True) """The Lightspeed.tv stream ID.""" -class TwitchContentType(StrEnum): - """Type of remote Twitch content.""" - - CHANNEL = "Channel" - VIDEO = "Video" - CLIP = "Clip" - - @define(slots=True) class TwitchEmbedSpecial(EmbedSpecial): """Represents information about Twitch stream or clip.""" - content_type: TwitchContentType = field(repr=True, hash=True, kw_only=True, eq=True) + content_type: TwitchContentType = field(repr=True, kw_only=True, eq=True) """The Twitch content type.""" - id: str = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True, eq=True) """The Twitch content ID.""" @@ -88,10 +99,10 @@ class TwitchEmbedSpecial(EmbedSpecial): class SpotifyEmbedSpecial(EmbedSpecial): """Represents information about Spotify track.""" - content_type: str = field(repr=True, hash=True, kw_only=True, eq=True) + content_type: str = field(repr=True, kw_only=True, eq=True) """The Spotify content type.""" - id: str = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True, eq=True) """The Spotify content ID.""" @@ -102,61 +113,53 @@ class SoundcloudEmbedSpecial(EmbedSpecial): _SOUNDCLOUD_EMBED_SPECIAL = SoundcloudEmbedSpecial() -class BandcampContentType(StrEnum): - """Type of remote Bandcamp content.""" - - ALBUM = "Album" - TRACK = "Track" - - @define(slots=True) class BandcampEmbedSpecial(EmbedSpecial): """Represents information about Bandcamp track.""" - content_type: BandcampContentType = field( - repr=True, hash=True, kw_only=True, eq=True - ) + content_type: BandcampContentType = field(repr=True, kw_only=True, eq=True) """The Bandcamp content type.""" - id: str = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True, eq=True) """The Bandcamp content ID.""" @define(slots=True) -class StreamableEmbedSpecial(EmbedSpecial): - """Represents information about Streamable video.""" +class AppleMusicEmbedSpecial(EmbedSpecial): + """Represents information about Apple Music track.""" - id: str = field(repr=True, hash=True, kw_only=True, eq=True) - """The video ID.""" + album_id: str = field(repr=True, kw_only=True, eq=True) + """The Apple Music album ID.""" + track_id: str | None = field(repr=True, kw_only=True, eq=True) + """The Apple Music track ID.""" -class ImageSize(StrEnum): - """Controls image positioning and size.""" - LARGE = "Large" - """Show large preview at the bottom of the embed.""" +@define(slots=True) +class StreamableEmbedSpecial(EmbedSpecial): + """Represents information about Streamable video.""" - PREVIEW = "Preview" - """Show small preview to the side of the embed.""" + id: str = field(repr=True, kw_only=True, eq=True) + """The video ID.""" @define(slots=True) class ImageEmbed(_BaseEmbed): """Represents an image in a embed.""" - url: str = field(repr=True, hash=True, kw_only=True, eq=True) + url: str = field(repr=True, kw_only=True, eq=True) """The URL to the original image.""" - width: int = field(repr=True, hash=True, kw_only=True, eq=True) + width: int = field(repr=True, kw_only=True, eq=True) """The width of the image.""" - height: int = field(repr=True, hash=True, kw_only=True, eq=True) + height: int = field(repr=True, kw_only=True, eq=True) """The height of the image.""" - size: ImageSize = field(repr=True, hash=True, kw_only=True, eq=True) + size: ImageSize = field(repr=True, kw_only=True, eq=True) """The positioning and size of the image.""" - def _stateful(self, state: "State") -> "Embed": + def _stateful(self, state: State) -> Embed: return self @@ -164,16 +167,16 @@ def _stateful(self, state: "State") -> "Embed": class VideoEmbed(_BaseEmbed): """Represents an video in a embed.""" - url: str = field(repr=True, hash=True, kw_only=True, eq=True) + url: str = field(repr=True, kw_only=True, eq=True) """The URL to the original video.""" - width: int = field(repr=True, hash=True, kw_only=True, eq=True) + width: int = field(repr=True, kw_only=True, eq=True) """The width of the video.""" - height: int = field(repr=True, hash=True, kw_only=True, eq=True) + height: int = field(repr=True, kw_only=True, eq=True) """The height of the video.""" - def _stateful(self, state: "State") -> "Embed": + def _stateful(self, state: State) -> Embed: return self @@ -181,37 +184,37 @@ def _stateful(self, state: "State") -> "Embed": class WebsiteEmbed(_BaseEmbed): """Representation of website embed within Revolt message.""" - url: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + url: str | None = field(repr=True, kw_only=True, eq=True) """The direct URL to web page.""" - original_url: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + original_url: str | None = field(repr=True, kw_only=True, eq=True) """The original direct URL.""" - special: EmbedSpecial | None = field(repr=True, hash=True, kw_only=True, eq=True) + special: EmbedSpecial | None = field(repr=True, kw_only=True, eq=True) """The remote content.""" - title: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + title: str | None = field(repr=True, kw_only=True, eq=True) """The title of website.""" - description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + description: str | None = field(repr=True, kw_only=True, eq=True) """The description of website.""" - image: ImageEmbed | None = field(repr=True, hash=True, kw_only=True, eq=True) + image: ImageEmbed | None = field(repr=True, kw_only=True, eq=True) """The embedded image.""" - video: VideoEmbed | None = field(repr=True, hash=True, kw_only=True, eq=True) + video: VideoEmbed | None = field(repr=True, kw_only=True, eq=True) """The embedded video.""" - site_name: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + site_name: str | None = field(repr=True, kw_only=True, eq=True) """The site name.""" - icon_url: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + icon_url: str | None = field(repr=True, kw_only=True, eq=True) """The URL to site icon.""" - colour: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + colour: str | None = field(repr=True, kw_only=True, eq=True) """The CSS colour of this embed.""" - def _stateful(self, state: "State") -> "Embed": + def _stateful(self, state: State) -> Embed: return self @@ -219,27 +222,25 @@ def _stateful(self, state: "State") -> "Embed": class StatelessTextEmbed(_BaseEmbed): """Stateless representation of text embed within Revolt message.""" - icon_url: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + icon_url: str | None = field(repr=True, kw_only=True, eq=True) """The URL to site icon.""" - url: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + url: str | None = field(repr=True, kw_only=True, eq=True) """The direct URL to web page.""" - title: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + title: str | None = field(repr=True, kw_only=True, eq=True) """The title of text embed.""" - description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + description: str | None = field(repr=True, kw_only=True, eq=True) """The description of text embed.""" - internal_media: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_media: StatelessAsset | None = field(repr=True, kw_only=True, eq=True) """The stateless embed media.""" - colour: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + colour: str | None = field(repr=True, kw_only=True, eq=True) """The CSS colour of this embed.""" - def _stateful(self, state: "State") -> "Embed": + def _stateful(self, state: State) -> Embed: return TextEmbed( icon_url=self.icon_url, url=self.url, @@ -255,22 +256,18 @@ def _stateful(self, state: "State") -> "Embed": class TextEmbed(StatelessTextEmbed): """Representation of text embed within Revolt message.""" - state: "State" = field(repr=False, hash=False, kw_only=True, eq=False) + state: State = field(repr=False, hash=False, kw_only=True, eq=False) @property - def media(self) -> cdn.Asset | None: - """The embed media.""" - return ( - self.internal_media._stateful(self.state, "attachments") - if self.internal_media - else None - ) + def media(self) -> Asset | None: + """Optional[:class:`Asset`]: The embed media.""" + return self.internal_media._stateful(self.state, 'attachments') if self.internal_media else None class NoneEmbed(_BaseEmbed): """Embed that holds nothing.""" - def _stateful(self, state: "State") -> "Embed": + def _stateful(self, state: State) -> Embed: return self @@ -280,30 +277,28 @@ def _stateful(self, state: "State") -> "Embed": Embed = WebsiteEmbed | ImageEmbed | VideoEmbed | TextEmbed | NoneEmbed __all__ = ( - "_BaseEmbed", - "EmbedSpecial", - "NoneEmbedSpecial", - "_NONE_EMBED_SPECIAL", - "GifEmbedSpecial", - "_GIF_EMBED_SPECIAL", - "YouTubeEmbedSpecial", - "LightspeedContentType", - "LightspeedEmbedSpecial", - "TwitchContentType", - "TwitchEmbedSpecial", - "SpotifyEmbedSpecial", - "SoundcloudEmbedSpecial", - "_SOUNDCLOUD_EMBED_SPECIAL", - "BandcampContentType", - "BandcampEmbedSpecial", - "StreamableEmbedSpecial", - "ImageSize", - "ImageEmbed", - "VideoEmbed", - "WebsiteEmbed", - "TextEmbed", - "NoneEmbed", - "_NONE_EMBED", - "StatelessEmbed", - "Embed", + '_BaseEmbed', + 'EmbedSpecial', + 'NoneEmbedSpecial', + '_NONE_EMBED_SPECIAL', + 'GifEmbedSpecial', + '_GIF_EMBED_SPECIAL', + 'YouTubeEmbedSpecial', + 'LightspeedEmbedSpecial', + 'TwitchEmbedSpecial', + 'SpotifyEmbedSpecial', + 'SoundcloudEmbedSpecial', + '_SOUNDCLOUD_EMBED_SPECIAL', + 'BandcampEmbedSpecial', + 'AppleMusicEmbedSpecial', + 'StreamableEmbedSpecial', + 'ImageEmbed', + 'VideoEmbed', + 'WebsiteEmbed', + 'StatelessTextEmbed', + 'TextEmbed', + 'NoneEmbed', + '_NONE_EMBED', + 'StatelessEmbed', + 'Embed', ) diff --git a/pyvolt/emoji.py b/pyvolt/emoji.py index f731e0d..cc88ff8 100644 --- a/pyvolt/emoji.py +++ b/pyvolt/emoji.py @@ -1,58 +1,118 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + from attrs import define, field -from . import base, core +from .base import Base +from .cdn import AssetMetadataType, AssetMetadata, Asset @define(slots=True) -class BaseEmoji(base.Base): +class BaseEmoji(Base): """Representation of an emoji on Revolt.""" - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """Unique emoji ID.""" + creator_id: str = field(repr=True, kw_only=True) + """The user ID of the uploader.""" - creator_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """Uploader user ID.""" - - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """Emoji name.""" - animated: bool = field(repr=True, hash=True, kw_only=True, eq=True) + animated: bool = field(repr=True, kw_only=True) """Whether the emoji is animated.""" - nsfw: bool = field(repr=True, hash=True, kw_only=True, eq=True) + nsfw: bool = field(repr=True, kw_only=True) """Whether the emoji is marked as NSFW.""" + def __str__(self) -> str: + return f':{self.id}:' + + @property + def image(self) -> Asset: + """:class:`Asset`: The emoji asset.""" + return Asset( + id=self.id, + filename='', + metadata=AssetMetadata( + type=AssetMetadataType.video if self.animated else AssetMetadataType.image, + width=0, + height=0, + ), + content_type='', + size=0, + deleted=False, + reported=False, + message_id=None, + user_id=None, + server_id=None, + object_id=None, + state=self.state, + tag='emojis', + ) + @define(slots=True) class ServerEmoji(BaseEmoji): """Representation of an emoji in server on Revolt.""" - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) """What server owns this emoji.""" + async def delete_emoji(self) -> None: + """|coro| + + Deletes the emoji. + + Raises + ------ + Forbidden + You do not have permissions to delete emojis. + HTTPException + Deleting the emoji failed. + """ + return await self.state.http.delete_emoji(self.id) + @define(slots=True) class DetachedEmoji(BaseEmoji): - """Representation of an deleted emoji on Revolt.""" + """Represents of an deleted emoji on Revolt.""" Emoji = ServerEmoji | DetachedEmoji -ResolvableEmoji = Emoji | str +ResolvableEmoji = BaseEmoji | str def resolve_emoji(resolvable: ResolvableEmoji) -> str: - return str( - resolvable.id - if isinstance(resolvable, (ServerEmoji, DetachedEmoji)) - else resolvable - ) + return resolvable.id if isinstance(resolvable, BaseEmoji) else resolvable __all__ = ( - "BaseEmoji", - "ServerEmoji", - "DetachedEmoji", - "Emoji", - "ResolvableEmoji", - "resolve_emoji", + 'BaseEmoji', + 'ServerEmoji', + 'DetachedEmoji', + 'Emoji', + 'ResolvableEmoji', + 'resolve_emoji', ) diff --git a/pyvolt/enums.py b/pyvolt/enums.py new file mode 100644 index 0000000..38d8ba4 --- /dev/null +++ b/pyvolt/enums.py @@ -0,0 +1,542 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +# Thanks Danny https://github.com/Rapptz/discord.py/blob/7d3eff9d9d115dc29b5716c42eaeedf1a008e9b0/discord/enums.py +from __future__ import annotations + +from collections import abc as ca, namedtuple +import types +import typing + + +def _create_value_cls(name: str, comparable: bool): + # All the type ignores here are due to the type checker being unable to recognise + # Runtime type creation without exploding. + cls = namedtuple('_EnumValue_' + name, 'name value') + cls.__repr__ = lambda self: f'<{name}.{self.name}: {self.value!r}>' # type: ignore + cls.__str__ = lambda self: f'{name}.{self.name}' # type: ignore + if comparable: + cls.__le__ = lambda self, other: isinstance(other, self.__class__) and self.value <= other.value # type: ignore + cls.__ge__ = lambda self, other: isinstance(other, self.__class__) and self.value >= other.value # type: ignore + cls.__lt__ = lambda self, other: isinstance(other, self.__class__) and self.value < other.value # type: ignore + cls.__gt__ = lambda self, other: isinstance(other, self.__class__) and self.value > other.value # type: ignore + return cls + + +def _is_descriptor(obj): + return hasattr(obj, '__get__') or hasattr(obj, '__set__') or hasattr(obj, '__delete__') + + +class EnumMeta(type): + if typing.TYPE_CHECKING: + __name__: typing.ClassVar[str] # type: ignore + _enum_member_names_: typing.ClassVar[list[str]] + _enum_member_map_: typing.ClassVar[dict[str, typing.Any]] + _enum_value_map_: typing.ClassVar[dict[typing.Any, typing.Any]] + + def __new__( + cls, + name: str, + bases: tuple[type, ...], + attrs: dict[str, typing.Any], + *, + comparable: bool = False, + ) -> EnumMeta: + value_mapping = {} + member_mapping = {} + member_names = [] + + value_cls = _create_value_cls(name, comparable) + for key, value in list(attrs.items()): + is_descriptor = _is_descriptor(value) + if key[0] == '_' and not is_descriptor: + continue + + # Special case classmethod to just pass through + if isinstance(value, classmethod): + continue + + if is_descriptor: + setattr(value_cls, key, value) + del attrs[key] + continue + + try: + new_value = value_mapping[value] + except KeyError: + new_value = value_cls(name=key, value=value) + value_mapping[value] = new_value + member_names.append(key) + + member_mapping[key] = new_value + attrs[key] = new_value + + attrs['_enum_value_map_'] = value_mapping + attrs['_enum_member_map_'] = member_mapping + attrs['_enum_member_names_'] = member_names + attrs['_enum_value_cls_'] = value_cls + actual_cls = super().__new__(cls, name, bases, attrs) + value_cls._actual_enum_cls_ = actual_cls # type: ignore # Runtime attribute isn't understood + return actual_cls + + def __iter__(cls) -> ca.Iterator[typing.Any]: + return (cls._enum_member_map_[name] for name in cls._enum_member_names_) + + def __reversed__(cls) -> ca.Iterator[typing.Any]: + return (cls._enum_member_map_[name] for name in reversed(cls._enum_member_names_)) + + def __len__(cls) -> int: + return len(cls._enum_member_names_) + + def __repr__(cls) -> str: + return f'' + + @property + def __members__(cls) -> ca.Mapping[str, typing.Any]: + return types.MappingProxyType(cls._enum_member_map_) + + def __call__(cls, value: str) -> typing.Any: + try: + return cls._enum_value_map_[value] + except (KeyError, TypeError): + raise ValueError(f'{value!r} is not a valid {cls.__name__}') + + def __getitem__(cls, key: str) -> typing.Any: + return cls._enum_member_map_[key] + + def __setattr__(cls, name: str, value: typing.Any) -> None: + raise TypeError('Enums are immutable.') + + def __delattr__(cls, attr: str) -> None: + raise TypeError('Enums are immutable') + + def __instancecheck__(self, instance: typing.Any) -> bool: + # isinstance(x, Y) + # -> __instancecheck__(Y, x) + try: + return instance._actual_enum_cls_ is self + except AttributeError: + return False + + +if typing.TYPE_CHECKING: + from enum import Enum +else: + + class Enum(metaclass=EnumMeta): + @classmethod + def try_value(cls, value) -> typing.Any: + try: + return cls._enum_value_map_[value] + except (KeyError, TypeError): + return value + + +class MFAMethod(Enum): + password = 'Password' + recovery = 'Recovery' + totp = 'Totp' + + +class AssetMetadataType(Enum): + file = 'File' + """File is just a generic uncategorised file.""" + + text = 'Text' + """File contains textual data and should be displayed as such.""" + + image = 'Image' + """File is an image with specific dimensions.""" + + video = 'Video' + """File is a video with specific dimensions.""" + + audio = 'Audio' + """File is audio.""" + + +class ChannelType(Enum): + text = 'Text' + """Text channel.""" + + voice = 'Voice' + """Voice channel.""" + + +class ServerActivity(Enum): + high = 'high' + medium = 'medium' + low = 'low' + no = 'no' + + +class BotUsage(Enum): + high = 'high' + medium = 'medium' + low = 'low' + + +class LightspeedContentType(Enum): + """Type of remote Lightspeed.tv content.""" + + channel = 'Channel' + + +class TwitchContentType(Enum): + """Type of remote Twitch content.""" + + channel = 'Channel' + video = 'Video' + clip = 'Clip' + + +class BandcampContentType(Enum): + """Type of remote Bandcamp content.""" + + album = 'Album' + track = 'Track' + + +class ImageSize(Enum): + """Controls image positioning and size.""" + + large = 'Large' + """Show large preview at the bottom of the embed.""" + + preview = 'Preview' + """Show small preview to the side of the embed.""" + + +class Language(Enum): + english = 'en' + english_simplified = 'en_US' + + arabic = 'ar' + assamese = 'as' + azerbaijani = 'az' + belarusian = 'be' + bulgarian = 'bg' + bengali = 'bn' + breton = 'br' + catalonian = 'ca' + cebuano = 'ceb' + central_kurdish = 'ckb' + czech = 'cs' + danish = 'da' + german = 'de' + greek = 'el' + spanish = 'es' + spanish_latin_america = 'es_419' + estonian = 'et' + finnish = 'fi' + filipino = 'fil' + french = 'fr' + irish = 'ga' + hindi = 'hi' + croatian = 'hr' + hungarian = 'hu' + armenian = 'hy' + indonesian = 'id' + icelandic = 'is' + italian = 'it' + japanese = 'ja' + korean = 'ko' + luxembourgish = 'lb' + lithuanian = 'lt' + macedonian = 'mk' + malay = 'ms' + norwegian_bokmal = 'nb_NO' + dutch = 'nl' + persian = 'fa' + polish = 'pl' + portuguese_brazil = 'pt_BR' + portuguese_portugal = 'pt_PT' + romanian = 'ro' + russian = 'ru' + slovak = 'sk' + slovenian = 'sl' + albanian = 'sq' + serbian = 'sr' + sinhalese = 'si' + swedish = 'sv' + tamil = 'ta' + thai = 'th' + turkish = 'tr' + ukranian = 'uk' + urdu = 'ur' + venetian = 'vec' + vietnamese = 'vi' + chinese_simplified = 'zh_Hans' + chinese_traditional = 'zh_Hant' + latvian = 'lv' + + tokipona = 'tokipona' + esperanto = 'esperanto' + + owo = 'owo' + pirate = 'pr' + bottom = 'bottom' + leet = 'leet' + piglatin = 'piglatin' + enchantment_table = 'enchantment' + + +class MessageSort(Enum): + relevance = 'Relevance' + latest = 'Latest' + oldest = 'Oldest' + + +class ContentReportReason(Enum): + """The reason for reporting content (message or server).""" + + none = 'NoneSpecified' + """No reason has been specified.""" + + illegal = 'Illegal' + """Illegal content catch-all reason.""" + + illegal_goods = 'IllegalGoods' + """Selling or facilitating use of drugs or other illegal goods.""" + + illegal_extortion = 'IllegalExtortion' + """Extortion or blackmail.""" + + illegal_pornography = 'IllegalPornography' + """Revenge or child pornography.""" + + illegal_hacking = 'IllegalHacking' + """Illegal hacking activity.""" + + extreme_violence = 'ExtremeViolence' + """Extreme violence, gore, or animal cruelty. With exception to violence potrayed in media / creative arts.""" + + promotes_harm = 'PromotesHarm' + """Content that promotes harm to others / self.""" + + unsolicited_spam = 'UnsolicitedSpam' + """Unsolicited advertisements.""" + + raid = 'Raid' + """This is a raid.""" + + spam_abuse = 'SpamAbuse' + """Spam or platform abuse.""" + + scams_fraud = 'ScamsFraud' + """Scams or fraud.""" + + malware = 'Malware' + """Distribution of malware or malicious links.""" + + harassment = 'Harassment' + """Harassment or abuse targeted at another user.""" + + +class UserReportReason(Enum): + """Reason for reporting a user.""" + + none = 'NoneSpecified' + """No reason has been specified.""" + + unsolicited_spam = 'UnsolicitedSpam' + """Unsolicited advertisements.""" + + spam_abuse = 'SpamAbuse' + """User is sending spam or otherwise abusing the platform.""" + + inappropriate_profile = 'InappropriateProfile' + """User's profile contains inappropriate content for a general audience.""" + + impersonation = 'Impersonation' + """User is impersonating another user.""" + + ban_evasion = 'BanEvasion' + """User is evading a ban.""" + + underage = 'Underage' + """User is not of minimum age to use the platform.""" + + +class MemberRemovalIntention(Enum): + """Reason why member was removed from server.""" + + leave = 'Leave' + kick = 'Kick' + ban = 'Ban' + + +class ShardFormat(Enum): + json = 'json' + msgpack = 'msgpack' + + +class AndroidTheme(Enum): + revolt = 'Revolt' + light = 'Light' + pure_black = 'Amoled' + system = 'None' + material_you = 'M3Dynamic' + + +class AndroidProfilePictureShape(Enum): + sharp = 0 + rounded = 15 + circular = 50 + + +class AndroidMessageReplyStyle(Enum): + long_press_to_reply = 'None' + swipe_to_reply = 'SwipeFromEnd' + double_tap_to_reply = 'DoubleTap' + + +class ReviteChangelogEntry(Enum): + mfa_feature = 1 + """Title: Secure your account with 2FA.""" + + iar_reporting_feature = 2 + """Title: In-App Reporting Is Here""" + + discriminators_feature = 3 + """Title: Usernames are Changing""" + + +class ReviteNotificationState(Enum): + all_messages = 'all' + mentions_only = 'mention' + none = 'none' + muted = 'muted' + + +class ReviteEmojiPack(Enum): + mutant_remix = 'mutant' + twemoji = 'twemoji' + openmoji = 'openmoji' + noto_emoji = 'noto' + + +class ReviteBaseTheme(Enum): + dark = 'dark' + light = 'light' + + +class ReviteFont(Enum): + open_sans = 'Open Sans' + opendyslexic = 'OpenDyslexic' + inter = 'Inter' + atkinson_hyperlegible = 'Atkinson Hyperlegible' + roboto = 'Roboto' + noto_sans = 'Noto Sans' + lato = 'Lato' + bitter = 'Bitter' + montserrat = 'Montserrat' + poppins = 'Poppins' + raleway = 'Raleway' + ubuntu = 'Ubuntu' + comic_neue = 'Comic Neue' + lexend = 'Lexend' + + +class ReviteMonoFont(Enum): + fira_code = 'Fira Code' + roboto_mono = 'Robot Mono' + source_code_pro = 'Source Code Pro' + space_mono = 'Space Mono' + ubuntu_mono = 'Ubuntu Mono' + jetbrains_mono = 'JetBrains Mono' + + +class Presence(Enum): + online = 'Online' + """User is online.""" + + idle = 'Idle' + """User is not currently available.""" + + focus = 'Focus' + """User is focusing / will only receive mentions.""" + + busy = 'Busy' + """User is busy / will not receive any notifications.""" + + invisible = 'Invisible' + """User appears to be offline.""" + + +class RelationshipStatus(Enum): + """User's relationship with another user (or themselves).""" + + none = 'None' + """No relationship with other user.""" + + user = 'User' + """Other user is us.""" + + friend = 'Friend' + """Friends with the other user.""" + + outgoing = 'Outgoing' + """Pending friend request to user.""" + + incoming = 'Incoming' + """Incoming friend request from user.""" + + blocked = 'Blocked' + """Blocked this user.""" + + blocked_other = 'BlockedOther' + """Blocked by this user.""" + + +__all__ = ( + 'EnumMeta', + 'Enum', + 'MFAMethod', + 'AssetMetadataType', + 'ChannelType', + 'ServerActivity', + 'BotUsage', + 'LightspeedContentType', + 'TwitchContentType', + 'BandcampContentType', + 'ImageSize', + 'Language', + 'MessageSort', + 'ContentReportReason', + 'UserReportReason', + 'MemberRemovalIntention', + 'ShardFormat', + 'AndroidTheme', + 'AndroidProfilePictureShape', + 'AndroidMessageReplyStyle', + 'ReviteChangelogEntry', + 'ReviteNotificationState', + 'ReviteEmojiPack', + 'ReviteBaseTheme', + 'ReviteFont', + 'ReviteMonoFont', + 'Presence', + 'RelationshipStatus', +) diff --git a/pyvolt/errors.py b/pyvolt/errors.py index cde1679..c115a23 100644 --- a/pyvolt/errors.py +++ b/pyvolt/errors.py @@ -1,15 +1,40 @@ -from typing import Any +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations import aiohttp +import typing Response = aiohttp.ClientResponse class PyvoltError(Exception): - pass + __slots__ = () -class APIError(PyvoltError): +class HTTPException(PyvoltError): response: Response type: str retry_after: float | None @@ -21,10 +46,24 @@ class APIError(PyvoltError): location: str | None with_: str | None + __slots__ = ( + 'response', + 'data', + 'type', + 'retry_after', + 'err', + 'max', + 'permission', + 'operation', + 'collection', + 'location', + 'with_', + ) + def __init__( self, response: Response, - data: dict[str, Any] | str, + data: dict[str, typing.Any] | str, *, message: str | None = None, ) -> None: @@ -34,7 +73,7 @@ def __init__( if message is not None: errors.append(message) if isinstance(data, str): - self.type = "NonJSON" + self.type = 'NonJSON' self.retry_after = None self.err = data errors.append(data) @@ -45,78 +84,80 @@ def __init__( self.location = None self.with_ = None else: - self.type = data.get("type", "Unknown") - self.retry_after = data.get("retry_after") + self.type = data.get('type', 'Unknown') + self.retry_after = data.get('retry_after') if self.retry_after is not None: - errors.append(f"retry_after={self.retry_after}") - self.error = data.get("error") + errors.append(f'retry_after={self.retry_after}') + self.error = data.get('error') if self.error is not None: - errors.append(f"error={self.error}") - self.max = data.get("max") + errors.append(f'error={self.error}') + self.max = data.get('max') if self.max is not None: - errors.append(f"max={self.max}") - self.permission = data.get("permission") + errors.append(f'max={self.max}') + self.permission = data.get('permission') if self.permission is not None: - errors.append(f"permission={self.permission}") - self.operation = data.get("operation") + errors.append(f'permission={self.permission}') + self.operation = data.get('operation') if self.operation is not None: - errors.append(f"operation={self.operation}") - self.collection = data.get("collection") + errors.append(f'operation={self.operation}') + self.collection = data.get('collection') if self.collection is not None: - errors.append(f"collection={self.collection}") - self.location = data.get("location") + errors.append(f'collection={self.collection}') + self.location = data.get('location') if self.location is not None: - errors.append(f"location={self.location}") - self.with_ = data.get("with") + errors.append(f'location={self.location}') + self.with_ = data.get('with') if self.with_ is not None: - errors.append(f"with={self.with_}") - super().__init__( - self.type - if len(errors) == 0 - else f"{self.type}: {' '.join(errors)} (raw={data})\n" - ) + errors.append(f'with={self.with_}') + super().__init__(self.type if len(errors) == 0 else f"{self.type}: {' '.join(errors)} (raw={data})\n") -class Unauthorized(APIError): - pass +class Unauthorized(HTTPException): + __slots__ = () -class Forbidden(APIError): - pass +class Forbidden(HTTPException): + __slots__ = () -class NotFound(APIError): - pass +class NotFound(HTTPException): + __slots__ = () -class Ratelimited(APIError): - pass +class Ratelimited(HTTPException): + __slots__ = () -class InternalServerError(APIError): - pass +class InternalServerError(HTTPException): + __slots__ = () -class BadGateway(APIError): - pass +class BadGateway(HTTPException): + __slots__ = () class ShardError(PyvoltError): - pass + __slots__ = () class AuthenticationError(ShardError): - def __init__(self, a: Any) -> None: - super().__init__("Failed to connect shard", a) + __slots__ = () + + def __init__(self, a: typing.Any) -> None: + super().__init__('Failed to connect shard', a) class ConnectError(ShardError): + __slots__ = ('errors',) + def __init__(self, tries: int, errors: list[Exception]) -> None: self.errors = errors - super().__init__(f"Giving up, after {tries} tries, last 3 errors:", errors[-3:]) + super().__init__(f'Giving up, after {tries} tries, last 3 errors:', errors[-3:]) class DiscoveryError(PyvoltError): + __slots__ = ('response', 'status', 'data') + def __init__( self, response: aiohttp.ClientResponse, @@ -130,24 +171,26 @@ def __init__( class NoData(PyvoltError): + __slots__ = ('what', 'type') + def __init__(self, what: str, type: str) -> None: self.what = what self.type = type - super().__init__(f"Unable to find {type} {what} in cache") + super().__init__(f'Unable to find {type} {what} in cache') __all__ = ( - "PyvoltError", - "APIError", - "Unauthorized", - "Forbidden", - "NotFound", - "Ratelimited", - "InternalServerError", - "BadGateway", - "ShardError", - "AuthenticationError", - "ConnectError", - "DiscoveryError", - "NoData", + 'PyvoltError', + 'HTTPException', + 'Unauthorized', + 'Forbidden', + 'NotFound', + 'Ratelimited', + 'InternalServerError', + 'BadGateway', + 'ShardError', + 'AuthenticationError', + 'ConnectError', + 'DiscoveryError', + 'NoData', ) diff --git a/pyvolt/events.py b/pyvolt/events.py index dd3394a..ebeb375 100644 --- a/pyvolt/events.py +++ b/pyvolt/events.py @@ -1,70 +1,123 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field from copy import copy -import typing as t - -from . import ( - auth, - cache as caching, - channel as channels, - core, - emoji as emojis, - message as messages, - server as servers, - shard as sharding, - user_settings, - user as users, - webhook as webhooks, +from datetime import datetime +import typing + +from . import cache as caching + +from .auth import Session +from .channel import ( + PartialChannel, + DMChannel, + GroupChannel, + PrivateChannel, + ServerTextChannel, + ServerChannel, + Channel, +) +from .emoji import ServerEmoji, DetachedEmoji +from .enums import MemberRemovalIntention, RelationshipStatus +from .message import PartialMessage, MessageAppendData, Message +from .read_state import ReadState +from .server import ( + PartialRole, + Role, + PartialServer, + Server, + PartialMember, + Member, +) +from .shard import Shard +from .user_settings import UserSettings +from .user import ( + UserFlags, + Relationship, + PartialUser, + User, + OwnUser, ) +from .webhook import Webhook, PartialWebhook @define(slots=True) class BaseEvent: - shard: sharding.Shard = field(repr=True, hash=True, kw_only=True, eq=True) - is_cancelled: bool = field( - default=False, repr=True, hash=True, kw_only=True, eq=True - ) + shard: Shard = field(repr=True, kw_only=True) + is_cancelled: bool = field(default=False, repr=True, kw_only=True) - def set_cancel(self, value: bool, /) -> bool: + def set_cancelled(self, value: bool, /) -> bool: """Whether to cancel event processing (updating cache) or not.""" if self.is_cancelled is value: return False self.is_cancelled = value return True + def cancel(self) -> bool: + """Cancels the event processing (updating cache).""" + return self.set_cancelled(True) + + def uncancel(self, value: bool, /) -> bool: + """Uncancels the event processing (updating cache).""" + return self.set_cancelled(False) + async def abefore_dispatch(self) -> None: pass def before_dispatch(self) -> None: pass - async def aprocess(self) -> t.Any: + async def aprocess(self) -> typing.Any: pass - def process(self) -> t.Any: + def process(self) -> typing.Any: pass @define(slots=True) class ReadyEvent(BaseEvent): - users: list[users.User] = field(repr=True, hash=True, kw_only=True, eq=True) - servers: list[servers.Server] = field(repr=True, hash=True, kw_only=True, eq=True) - channels: list[channels.Channel] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - members: list[servers.Member] = field(repr=True, hash=True, kw_only=True, eq=True) - emojis: list[emojis.ServerEmoji] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - - old_me: users.SelfUser | None = field(repr=True, hash=True, kw_only=True, eq=True) - new_me: users.SelfUser | None = field(repr=True, hash=True, kw_only=True, eq=True) + users: list[User] = field(repr=True, kw_only=True) + servers: list[Server] = field(repr=True, kw_only=True) + channels: list[Channel] = field(repr=True, kw_only=True) + members: list[Member] = field(repr=True, kw_only=True) + emojis: list[ServerEmoji] = field(repr=True, kw_only=True) - def process(self) -> bool: + me: OwnUser = field(repr=True, kw_only=True) + user_settings: UserSettings = field(repr=True, kw_only=True) + read_states: list[ReadState] = field(repr=True, kw_only=True) + + def before_dispatch(self) -> None: + # People expect bot.me to be available upon `ReadyEvent` dispatching state = self.shard.state - state._me = self.new_me + state._me = self.me + state._settings = self.user_settings + def process(self) -> bool: + state = self.shard.state cache = state.cache if not cache: @@ -76,8 +129,10 @@ def process(self) -> bool: for s in self.servers: cache.store_server(s, caching._READY) - for c in self.channels: - cache.store_channel(c, caching._READY) + for channel in self.channels: + cache.store_channel(channel, caching._READY) + if channel.__class__ is DMChannel or isinstance(channel, DMChannel): + cache.store_private_channel_by_user(channel, caching._READY) # type: ignore for m in self.members: cache.store_server_member(m, caching._READY) @@ -85,35 +140,46 @@ def process(self) -> bool: for e in self.emojis: cache.store_emoji(e, caching._READY) + for rs in self.read_states: + cache.store_read_state(rs, caching._READY) + return True @define(slots=True) class BaseChannelCreateEvent(BaseEvent): - def _process(self, channel: channels.Channel) -> bool: - cache = self.shard.state.cache - if not cache: - return False - cache.store_channel(channel, caching._CHANNEL_CREATE) - return True + pass @define(slots=True) class PrivateChannelCreateEvent(BaseChannelCreateEvent): - channel: channels.PrivateChannel = field( - repr=True, hash=True, kw_only=True, eq=True - ) + channel: PrivateChannel = field(repr=True, kw_only=True) def process(self) -> bool: - return self._process(self.channel) + cache = self.shard.state.cache + if not cache: + return False + + channel = self.channel + cache.store_channel(channel, caching._CHANNEL_CREATE) + + if isinstance(channel, DMChannel): + cache.store_private_channel_by_user(channel, caching._CHANNEL_CREATE) + + return True @define(slots=True) class ServerChannelCreateEvent(BaseChannelCreateEvent): - channel: channels.ServerChannel = field(repr=True, hash=True, kw_only=True, eq=True) + channel: ServerChannel = field(repr=True, kw_only=True) def process(self) -> bool: - return self._process(self.channel) + cache = self.shard.state.cache + if not cache: + return False + + cache.store_channel(self.channel, caching._CHANNEL_CREATE) + return True ChannelCreateEvent = PrivateChannelCreateEvent | ServerChannelCreateEvent @@ -121,12 +187,10 @@ def process(self) -> bool: @define(slots=True) class ChannelUpdateEvent(BaseEvent): - channel: channels.PartialChannel = field( - repr=True, hash=True, kw_only=True, eq=True - ) + channel: PartialChannel = field(repr=True, kw_only=True) - before: channels.Channel | None = field(repr=True, hash=True, kw_only=True, eq=True) - after: channels.Channel | None = field(repr=True, hash=True, kw_only=True, eq=True) + before: Channel | None = field(repr=True, kw_only=True) + after: Channel | None = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache @@ -151,11 +215,9 @@ def process(self) -> bool: @define(slots=True) class ChannelDeleteEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) - channel: channels.Channel | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + channel: Channel | None = field(repr=True, kw_only=True) def before_process(self) -> None: cache = self.shard.state.cache @@ -167,25 +229,37 @@ def process(self) -> bool: cache = self.shard.state.cache if not cache: return False + cache.delete_channel(self.channel_id, caching._CHANNEL_DELETE) + # TODO: Remove when backend will tell us to update all channels. (ServerUpdate event) + if isinstance(self.channel, ServerChannel): + server = cache.get_server(self.channel.server_id, caching._CHANNEL_DELETE) + if server: + try: + server.internal_channels[1].remove(self.channel.id) # type: ignore # cached servers have only channel IDs internally + except ValueError: + pass + else: + cache.store_server(server, caching._CHANNEL_DELETE) + elif isinstance(self.channel, DMChannel): + cache.delete_private_channel_by_user(self.channel.recipient_id, caching._CHANNEL_DELETE) + return True @define(slots=True) class GroupRecipientAddEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) - group: channels.GroupChannel | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + group: GroupChannel | None = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache if not cache: return group = cache.get_channel(self.channel_id, caching._CHANNEL_GROUP_JOIN) - if not isinstance(group, channels.GroupChannel): + if not isinstance(group, GroupChannel): return self.group = group @@ -198,19 +272,17 @@ def process(self) -> bool: @define(slots=True) class GroupRecipientRemoveEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) - group: channels.GroupChannel | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + group: GroupChannel | None = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache if not cache: return group = cache.get_channel(self.channel_id, caching._CHANNEL_GROUP_LEAVE) - if not isinstance(group, channels.GroupChannel): + if not isinstance(group, GroupChannel): return self.group = group @@ -223,21 +295,21 @@ def process(self) -> bool: @define(slots=True) class ChannelStartTypingEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) @define(slots=True) class ChannelStopTypingEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) @define(slots=True) class MessageAckEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - message_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + message_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) def process(self) -> bool: cache = self.shard.state.cache @@ -246,11 +318,8 @@ def process(self) -> bool: read_state = cache.get_read_state(self.channel_id, caching._MESSAGE_ACK) if read_state: - # TODO: Still think what we should do in case of mentions. - if ( - read_state.last_message_id - and self.message_id >= read_state.last_message_id - ): + # TODO: What we should do in case of mentions? This solution sucks. + if read_state.last_message_id and self.message_id >= read_state.last_message_id: try: read_state.mentioned_in.remove(self.message_id) except ValueError: @@ -264,7 +333,7 @@ def process(self) -> bool: @define(slots=True) class MessageCreateEvent(BaseEvent): - message: messages.Message = field(repr=True, hash=True, kw_only=True, eq=True) + message: Message = field(repr=True, kw_only=True) def process(self) -> bool: cache = self.shard.state.cache @@ -272,28 +341,23 @@ def process(self) -> bool: return False author = self.message._author - if isinstance(author, servers.Member): - if isinstance(author._user, users.User): + if isinstance(author, Member): + if isinstance(author._user, User): cache.store_user(author._user, caching._MESSAGE_CREATE) cache.store_server_member(author, caching._MESSAGE_CREATE) - elif isinstance(author, users.User): + elif isinstance(author, User): cache.store_user(author, caching._MESSAGE_CREATE) - read_state = cache.get_read_state( - self.message.channel_id, caching._MESSAGE_CREATE - ) + read_state = cache.get_read_state(self.message.channel_id, caching._MESSAGE_CREATE) if read_state: - if ( - read_state.user_id in self.message.mention_ids - and self.message.id not in read_state.mentioned_in - ): + if read_state.user_id in self.message.mention_ids and self.message.id not in read_state.mentioned_in: read_state.mentioned_in.append(self.message.id) cache.store_read_state(read_state, caching._MESSAGE_CREATE) channel = cache.get_channel(self.message.channel_id, caching._MESSAGE_CREATE) if channel and isinstance( channel, - (channels.DMChannel, channels.GroupChannel, channels.ServerTextChannel), + (DMChannel, GroupChannel, ServerTextChannel), ): channel.last_message_id = self.message.id @@ -302,74 +366,90 @@ def process(self) -> bool: @define(slots=True) class MessageUpdateEvent(BaseEvent): - message: messages.PartialMessage = field( - repr=True, hash=True, kw_only=True, eq=True - ) + message: PartialMessage = field(repr=True, kw_only=True) - before: messages.Message | None = field(repr=True, hash=True, kw_only=True, eq=True) - after: messages.Message | None = field(repr=True, hash=True, kw_only=True, eq=True) + before: Message | None = field(repr=True, kw_only=True) + after: Message | None = field(repr=True, kw_only=True) @define(slots=True) class MessageAppendEvent(BaseEvent): - data: messages.MessageAppendData = field( - repr=True, hash=True, kw_only=True, eq=True - ) + data: MessageAppendData = field(repr=True, kw_only=True) @define(slots=True) class MessageDeleteEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - message_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + message_id: str = field(repr=True, kw_only=True) @define(slots=True) class MessageReactEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - message_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + message_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) - emoji: str = field(repr=True, hash=True, kw_only=True, eq=True) + emoji: str = field(repr=True, kw_only=True) """May be either ULID or Unicode.""" @define(slots=True) class MessageUnreactEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - message_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + message_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) - emoji: str = field(repr=True, hash=True, kw_only=True, eq=True) + emoji: str = field(repr=True, kw_only=True) """May be either ULID or Unicode.""" @define(slots=True) class MessageClearReactionEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - message_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + message_id: str = field(repr=True, kw_only=True) - emoji: str = field(repr=True, hash=True, kw_only=True, eq=True) + emoji: str = field(repr=True, kw_only=True) """May be either ULID or Unicode.""" @define(slots=True) class BulkMessageDeleteEvent(BaseEvent): - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - message_ids: list[core.ULID] = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) + message_ids: list[str] = field(repr=True, kw_only=True) @define(slots=True) class ServerCreateEvent(BaseEvent): - server: servers.Server = field(repr=True, hash=True, kw_only=True, eq=True) - emojis: list[emojis.ServerEmoji] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + joined_at: datetime = field(repr=True, kw_only=True) + server: Server = field(repr=True, kw_only=True) + emojis: list[ServerEmoji] = field(repr=True, kw_only=True) def process(self) -> bool: - cache = self.shard.state.cache + state = self.shard.state + + cache = state.cache if not cache: return False + + for channel in self.server._prepare_cached(): + cache.store_channel(channel, caching._SERVER_CREATE) cache.store_server(self.server, caching._SERVER_CREATE) + + if state.me: + cache.store_server_member( + Member( + state=state, + server_id=self.server.id, + _user=state.me.id, + joined_at=self.joined_at, + nick=None, + internal_server_avatar=None, + roles=[], + timed_out_until=None, + ), + caching._SERVER_CREATE, + ) + for emoji in self.emojis: cache.store_emoji(emoji, caching._SERVER_CREATE) return True @@ -377,7 +457,7 @@ def process(self) -> bool: @define(slots=True) class ServerEmojiCreateEvent(BaseEvent): - emoji: emojis.ServerEmoji = field(repr=True, hash=True, kw_only=True, eq=True) + emoji: ServerEmoji = field(repr=True, kw_only=True) def process(self) -> bool: cache = self.shard.state.cache @@ -389,18 +469,16 @@ def process(self) -> bool: @define(slots=True) class ServerEmojiDeleteEvent(BaseEvent): - emoji: emojis.ServerEmoji | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) - server_id: core.ULID | None = field(repr=True, hash=True, kw_only=True, eq=True) - emoji_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + emoji: ServerEmoji | None = field(repr=True, kw_only=True) + server_id: str | None = field(repr=True, kw_only=True) + emoji_id: str = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache if not cache: return emoji = cache.get_emoji(self.emoji_id, caching._EMOJI_DELETE) - if not isinstance(emoji, emojis.DetachedEmoji): + if not isinstance(emoji, DetachedEmoji): self.emoji = emoji def process(self) -> bool: @@ -413,10 +491,10 @@ def process(self) -> bool: @define(slots=True) class ServerUpdateEvent(BaseEvent): - server: servers.PartialServer = field(repr=True, hash=True, kw_only=True, eq=True) + server: PartialServer = field(repr=True, kw_only=True) - before: servers.Server | None = field(repr=True, hash=True, kw_only=True, eq=True) - after: servers.Server | None = field(repr=True, hash=True, kw_only=True, eq=True) + before: Server | None = field(repr=True, kw_only=True) + after: Server | None = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache @@ -441,8 +519,8 @@ def process(self) -> bool: @define(slots=True) class ServerDeleteEvent(BaseEvent): - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - server: servers.Server | None = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) + server: Server | None = field(repr=True, kw_only=True) def before_process(self) -> None: cache = self.shard.state.cache @@ -454,24 +532,36 @@ def process(self) -> bool: cache = self.shard.state.cache if not cache: return False + cache.delete_server_emojis_of(self.server_id, caching._SERVER_DELETE) + cache.delete_server_members_of(self.server_id, caching._SERVER_DELETE) cache.delete_server(self.server_id, caching._SERVER_DELETE) return True +@define(slots=True) +class ServerMemberJoinEvent(BaseEvent): + member: Member = field(repr=True, kw_only=True) + + def process(self) -> bool: + cache = self.shard.state.cache + if not cache: + return False + cache.store_server_member(self.member, caching._SERVER_MEMBER_CREATE) + return True + + @define(slots=True) class ServerMemberUpdateEvent(BaseEvent): - member: servers.PartialMember = field(repr=True, hash=True, kw_only=True, eq=True) + member: PartialMember = field(repr=True, kw_only=True) - before: servers.Member | None = field(repr=True, hash=True, kw_only=True, eq=True) - after: servers.Member | None = field(repr=True, hash=True, kw_only=True, eq=True) + before: Member | None = field(repr=True, kw_only=True) + after: Member | None = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache if not cache: return - before = cache.get_server_member( - self.member.server_id, self.member.id, caching._SERVER_MEMBER_UPDATE - ) + before = cache.get_server_member(self.member.server_id, self.member.id, caching._SERVER_MEMBER_UPDATE) self.before = before if not before: return @@ -489,31 +579,18 @@ def process(self) -> bool: @define(slots=True) -class ServerMemberJoinEvent(BaseEvent): - member: servers.Member = field(repr=True, hash=True, kw_only=True, eq=True) +class ServerMemberRemoveEvent(BaseEvent): + server_id: str = field(repr=True, kw_only=True) + user_id: str = field(repr=True, kw_only=True) - def process(self) -> bool: - cache = self.shard.state.cache - if not cache: - return False - cache.store_server_member(self.member, caching._SERVER_MEMBER_CREATE) - return True - - -@define(slots=True) -class ServerMemberLeaveEvent(BaseEvent): - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - - member: servers.Member | None = field(repr=True, hash=True, kw_only=True, eq=True) + member: Member | None = field(repr=True, kw_only=True) + reason: MemberRemovalIntention = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache if not cache: return - self.member = cache.get_server_member( - self.server_id, self.user_id, caching._SERVER_MEMBER_DELETE - ) + self.member = cache.get_server_member(self.server_id, self.user_id, caching._SERVER_MEMBER_DELETE) def process(self) -> bool: state = self.shard.state @@ -524,22 +601,22 @@ def process(self) -> bool: me = state.me is_me = me.id == self.user_id if me else False - cache.delete_server_member( - self.server_id, self.user_id, caching._SERVER_MEMBER_DELETE - ) + cache.delete_server_member(self.server_id, self.user_id, caching._SERVER_MEMBER_DELETE) if is_me: + cache.delete_server_emojis_of(self.server_id, caching._SERVER_MEMBER_DELETE) + cache.delete_server_members_of(self.server_id, caching._SERVER_MEMBER_DELETE) cache.delete_server(self.server_id, caching._SERVER_MEMBER_DELETE) return True @define(slots=True) class RawServerRoleUpdateEvent(BaseEvent): - role: servers.PartialRole = field(repr=True, hash=True, kw_only=True, eq=True) + role: PartialRole = field(repr=True, kw_only=True) - old_role: servers.Role | None = field(repr=True, hash=True, kw_only=True, eq=True) - new_role: servers.Role | None = field(repr=True, hash=True, kw_only=True, eq=True) + old_role: Role | None = field(repr=True, kw_only=True) + new_role: Role | None = field(repr=True, kw_only=True) - server: servers.Server | None = field(repr=True, hash=True, kw_only=True, eq=True) + server: Server | None = field(repr=True, kw_only=True) def before_process(self) -> None: self.new_role = self.role.into_full() @@ -563,11 +640,11 @@ def process(self) -> bool: @define(slots=True) class ServerRoleDeleteEvent(BaseEvent): - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - role_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) + role_id: str = field(repr=True, kw_only=True) - server: servers.Server | None = field(repr=True, hash=True, kw_only=True, eq=True) - role: servers.Role | None = field(repr=True, hash=True, kw_only=True, eq=True) + server: Server | None = field(repr=True, kw_only=True) + role: Role | None = field(repr=True, kw_only=True) def before_process(self) -> None: cache = self.shard.state.cache @@ -582,10 +659,7 @@ def process(self) -> bool: if not cache or not self.server: return False - try: - del self.server.roles[self.role_id] - except KeyError: - pass + self.server.roles.pop(self.role_id, None) cache.store_server(self.server, caching._SERVER_ROLE_DELETE) return True @@ -593,10 +667,10 @@ def process(self) -> bool: @define(slots=True) class UserUpdateEvent(BaseEvent): - user: users.PartialUser = field(repr=True, hash=True, kw_only=True, eq=True) + user: PartialUser = field(repr=True, kw_only=True) - before: users.User | None = field(repr=True, hash=True, kw_only=True, eq=True) - after: users.User | None = field(repr=True, hash=True, kw_only=True, eq=True) + before: User | None = field(repr=True, kw_only=True) + after: User | None = field(repr=True, kw_only=True) def before_dispatch(self) -> None: cache = self.shard.state.cache @@ -621,19 +695,17 @@ def process(self) -> bool: @define(slots=True) class UserRelationshipUpdateEvent(BaseEvent): - current_user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + current_user_id: str = field(repr=True, kw_only=True) """The current user ID.""" - old_user: users.User | None = field(repr=True, hash=True, kw_only=True, eq=True) - new_user: users.User = field(repr=True, hash=True, kw_only=True, eq=True) + old_user: User | None = field(repr=True, kw_only=True) + new_user: User = field(repr=True, kw_only=True) - before: users.RelationshipStatus | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + before: RelationshipStatus | None = field(repr=True, kw_only=True) """Old relationship found in cache.""" @property - def after(self) -> users.RelationshipStatus: + def after(self) -> RelationshipStatus: """New relationship with the user.""" return self.new_user.relationship @@ -641,14 +713,27 @@ def before_dispatch(self) -> None: cache = self.shard.state.cache if not cache: return - self.old_user = cache.get_user( - self.new_user.id, caching._USER_RELATIONSHIP_UPDATE - ) + self.old_user = cache.get_user(self.new_user.id, caching._USER_RELATIONSHIP_UPDATE) if self.old_user: self.before = self.old_user.relationship def process(self) -> bool: + me = self.shard.state.me + + if me: + if self.new_user.relationship is RelationshipStatus.none: + me.relations.pop(self.new_user.id, None) + else: + relation = me.relations.get(self.new_user.id) + + if relation: + me.relations[self.new_user.id].status = self.new_user.relationship + else: + me.relations[self.new_user.id] = Relationship( + id=self.new_user.id, status=self.new_user.relationship + ) + cache = self.shard.state.cache if not cache: return False @@ -660,15 +745,20 @@ def process(self) -> bool: class UserSettingsUpdateEvent(BaseEvent): """User settings were updated remotely.""" - current_user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + current_user_id: str = field(repr=True, kw_only=True) """The current user ID.""" - before: user_settings.UserSettings | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) - after: user_settings.UserSettings = field( - repr=True, hash=True, kw_only=True, eq=True - ) + partial: UserSettings = field(repr=True, kw_only=True) + + before: UserSettings = field(repr=True, kw_only=True) + after: UserSettings = field(repr=True, kw_only=True) + + def process(self) -> bool: + settings = self.shard.state.settings + if settings.mocked: + return False + settings._update(self.partial) + return True @define(slots=True) @@ -685,28 +775,24 @@ class UserPlatformWipeEvent(BaseEvent): User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear. """ - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - flags: users.UserFlags = field(repr=True, hash=True, kw_only=True, eq=True) + user_id: str = field(repr=True, kw_only=True) + flags: UserFlags = field(repr=True, kw_only=True) @define(slots=True) class WebhookCreateEvent(BaseEvent): - webhook: webhooks.Webhook = field(repr=True, hash=True, kw_only=True, eq=True) + webhook: Webhook = field(repr=True, kw_only=True) @define(slots=True) class WebhookUpdateEvent(BaseEvent): - webhook: webhooks.PartialWebhook = field( - repr=True, hash=True, kw_only=True, eq=True - ) + new_webhook: PartialWebhook = field(repr=True, kw_only=True) @define(slots=True) class WebhookDeleteEvent(BaseEvent): - webhook: webhooks.Webhook | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) - webhook_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + webhook: Webhook | None = field(repr=True, kw_only=True) + webhook_id: str = field(repr=True, kw_only=True) @define(slots=True) @@ -716,23 +802,74 @@ class AuthifierEvent(BaseEvent): @define(slots=True) class SessionCreateEvent(AuthifierEvent): - session: auth.Session = field(repr=True, hash=True, kw_only=True, eq=True) + session: Session = field(repr=True, kw_only=True) @define(slots=True) class SessionDeleteEvent(AuthifierEvent): - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - session_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + user_id: str = field(repr=True, kw_only=True) + session_id: str = field(repr=True, kw_only=True) @define(slots=True) class SessionDeleteAllEvent(AuthifierEvent): - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - exclude_session_id: core.ULID | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + user_id: str = field(repr=True, kw_only=True) + exclude_session_id: str | None = field(repr=True, kw_only=True) @define(slots=True) class LogoutEvent(BaseEvent): pass + + +@define(slots=True) +class AuthenticatedEvent(BaseEvent): + pass + + +__all__ = ( + 'BaseEvent', + 'ReadyEvent', + 'BaseChannelCreateEvent', + 'PrivateChannelCreateEvent', + 'ServerChannelCreateEvent', + 'ChannelCreateEvent', + 'ChannelUpdateEvent', + 'ChannelDeleteEvent', + 'GroupRecipientAddEvent', + 'GroupRecipientRemoveEvent', + 'ChannelStartTypingEvent', + 'ChannelStopTypingEvent', + 'MessageAckEvent', + 'MessageCreateEvent', + 'MessageUpdateEvent', + 'MessageAppendEvent', + 'MessageDeleteEvent', + 'MessageReactEvent', + 'MessageUnreactEvent', + 'MessageClearReactionEvent', + 'BulkMessageDeleteEvent', + 'ServerCreateEvent', + 'ServerEmojiCreateEvent', + 'ServerEmojiDeleteEvent', + 'ServerUpdateEvent', + 'ServerDeleteEvent', + 'ServerMemberJoinEvent', + 'ServerMemberUpdateEvent', + 'ServerMemberRemoveEvent', + 'RawServerRoleUpdateEvent', + 'ServerRoleDeleteEvent', + 'UserUpdateEvent', + 'UserRelationshipUpdateEvent', + 'UserSettingsUpdateEvent', + 'UserPlatformWipeEvent', + 'WebhookCreateEvent', + 'WebhookUpdateEvent', + 'WebhookDeleteEvent', + 'AuthifierEvent', + 'SessionCreateEvent', + 'SessionDeleteEvent', + 'SessionDeleteAllEvent', + 'LogoutEvent', + 'AuthenticatedEvent', +) diff --git a/pyvolt/flags.py b/pyvolt/flags.py new file mode 100644 index 0000000..ad870f5 --- /dev/null +++ b/pyvolt/flags.py @@ -0,0 +1,779 @@ +from __future__ import annotations + +import inspect +import typing + +if typing.TYPE_CHECKING: + from collections.abc import Callable, Iterator + from typing_extensions import Self + + +class _MissingSentinel: + __slots__ = () + + def __bool__(self) -> typing.Literal[False]: + return False + + def __repr__(self) -> typing.Literal['...']: + return '...' + + +MISSING: typing.Any = _MissingSentinel() + +BF = typing.TypeVar('BF', bound='BaseFlags') + + +class flag(typing.Generic[BF]): + __slots__ = ( + '_func', + '_parent', + 'doc', + 'name', + 'value', + 'alias', + 'inverted', + 'use_any', + ) + + def __init__(self, *, inverted: bool = False, use_any: bool = False, alias: bool = False) -> None: + self._func: Callable[[BF], int] = MISSING + self._parent: type[BF] = MISSING + self.doc: str | None = None + self.name: str = '' + self.value: int = 0 + self.alias: bool = alias + self.inverted: bool = inverted + self.use_any: bool = use_any + + def __call__(self, func: Callable[[BF], int], /) -> Self: + self._func = func + self.doc = func.__doc__ + self.name = func.__name__ + return self + + @typing.overload + def __get__(self, instance: BF, owner: type[BF], /) -> bool: ... + + @typing.overload + def __get__(self, instance: None, owner: type[BF], /) -> int: ... + + def __get__(self, instance: BF | None, owner: type[BF], /) -> bool | int: + if instance is None: + if self._parent: + return self.value + # Needs to be here to allow prepare class + return self # type: ignore + else: + return instance._get(self) + + def __set__(self, instance: BF, value: bool) -> None: + instance._set(self, value) + + +class BaseFlags: + """Base class for flags.""" + + if typing.TYPE_CHECKING: + ALL_VALUE: typing.ClassVar[int] + INVERTED: typing.ClassVar[bool] + NONE_VALUE: typing.ClassVar[int] + VALID_FLAGS: typing.ClassVar[dict[str, int]] + + ALL: typing.ClassVar[Self] + NONE: typing.ClassVar[Self] + FLAGS: typing.ClassVar[dict[str, flag]] + + __slots__ = ('value',) + + def __init_subclass__(cls, *, inverted: bool = False, support_kwargs: bool = True) -> None: + valid_flags = {} + flags = {} + for k, f in inspect.getmembers(cls): + if isinstance(f, flag): + f.value = f._func(cls) + if f.alias: + continue + valid_flags[f.name] = f.value + flags[f.name] = f + f._parent = cls + + default = 0 + if inverted: + all = 0 + for value in valid_flags.values(): + default |= value + else: + all = 0 + for value in valid_flags.values(): + all |= value + + cls.ALL_VALUE = all + cls.INVERTED = inverted + cls.NONE_VALUE = default + cls.VALID_FLAGS = valid_flags + + if support_kwargs: + + def init_with_kwargs(self, value: int = cls.NONE_VALUE, /, **kwargs: bool) -> None: + self.value = value + + if kwargs: + for k, f in kwargs.items(): + if k not in self.VALID_FLAGS: + raise TypeError(f'Unknown flag {k}') + setattr(self, k, f) + + cls.__init__ = init_with_kwargs + else: + + def init_without_kwargs(self, value: int = cls.NONE_VALUE, /) -> None: + self.value = value + + cls.__init__ = init_without_kwargs # type: ignore + + cls.__slots__ = ('value',) + + if cls.INVERTED: + cls._get = cls._get1 + cls._set = cls._set1 + else: + cls._get = cls._get2 + cls._set = cls._set2 + cls.ALL = cls(cls.ALL_VALUE) + cls.NONE = cls(cls.NONE_VALUE) + cls.FLAGS = flags + + if typing.TYPE_CHECKING: + + def __init__(self, value: int = 0, /, **kwargs: bool) -> None: + pass + + def _get(self, other: flag[Self], /) -> bool: + return False + + def _set(self, flag: flag[Self], value: bool, /) -> None: + pass + + # used if flag is inverted + def _get1(self, other: flag[Self], /) -> bool: + if other.use_any and other.inverted: + # (ANY & INVERTED) + return (~self.value & ~other.value) != 0 + elif other.use_any: + # (ANY) + return (~self.value & other.value) != 0 + elif other.inverted: + # (INVERTED) + ov = other.value + return (~self.value & ~ov) == ov + else: + # () + ov = other.value + return (~self.value & ov) == ov + + # used if flag is uninverted + def _get2(self, other: flag[Self], /) -> bool: + if other.use_any and other.inverted: + # (ANY & INVERTED) + return (self.value & ~other.value) != 0 + elif other.use_any: + # (ANY) + return (self.value & other.value) != 0 + elif other.inverted: + # (INVERTED) + ov = other.value + return (self.value & ~ov) == ov + else: + # () + ov = other.value + return (self.value & ov) == ov + + # used if flag is inverted + def _set1(self, flag: flag[Self], value: bool, /) -> None: + if flag.inverted: + self.value &= ~flag.value + else: + self.value |= flag.value + + # used if flag is uninverted + def _set2(self, flag: flag[Self], value: bool, /) -> None: + if flag.inverted: + self.value |= flag.value + else: + self.value &= ~flag.value + + @classmethod + def all(cls) -> Self: + return cls(cls.ALL_VALUE) + + @classmethod + def none(cls) -> Self: + return cls(cls.NONE_VALUE) + + @classmethod + def from_value(cls, value: int, /) -> Self: + self = cls.__new__(cls) + self.value = value + return self + + def __hash__(self) -> int: + return hash(self.value) + + def __iter__(self) -> Iterator[tuple[str, bool]]: + for name, value in self.__class__.__dict__.items(): + if isinstance(value, flag): + if value.alias: + continue + yield (name, getattr(self, name)) + + def __repr__(self, /) -> str: + return f'<{self.__class__.__name__}: {self.value}>' + + def copy(self) -> Self: + """Copies the flag value.""" + return self.__class__(self.value) + + def is_subset(self, other: Self) -> bool: + """:class:`bool`: Returns ``True`` if self has the same or fewer flags as other.""" + if isinstance(other, self.__class__): + return (self.value & other.value) == self.value + else: + raise TypeError(f'cannot compare {self.__class__.__name__} with {other.__class__.__name__}') + + def is_superset(self, other: Self) -> bool: + """:class:`bool`: Returns ``True`` if self has the same or more flags as other.""" + if isinstance(other, self.__class__): + return (self.value | other.value) == self.value + else: + raise TypeError(f'cannot compare {self.__class__.__name__} with {other.__class__.__name__}') + + def is_strict_subset(self, other: Self) -> bool: + """:class:`bool`: Returns ``True`` if the flags on other are a strict subset of those on self.""" + return self.is_subset(other) and self != other + + def is_strict_superset(self, other: Self) -> bool: + """:class:`bool`: Returns ``True`` if the flags on other are a strict superset of those on self.""" + return self.is_superset(other) and self != other + + __le__ = is_subset + __ge__ = is_superset + __lt__ = is_strict_subset + __gt__ = is_strict_superset + + def __and__(self, other: Self | int, /) -> Self: + if isinstance(other, self.__class__): + other = other.value + return self.__class__(self.value & other) + + def __bool__(self) -> bool: + return self.value != self.NONE_VALUE + + def __contains__(self, other: Self, /) -> bool: + return (self.value & other.value) == other.value + + def __eq__(self, other: object, /) -> bool: + return self is other or isinstance(other, self.__class__) and self.value == other.value + + def __iand__(self, other: Self | int, /) -> Self: + if isinstance(other, int): + self.value &= other + else: + self.value &= other.value + return self + + def __int___(self) -> int: + return self.value + + def __invert__(self) -> Self: + max_bits = max(self.VALID_FLAGS.values()).bit_length() + max_value = -1 + (1 << max_bits) + return self.from_value(self.value ^ max_value) + + def __ior__(self, other: Self | int, /) -> Self: + if isinstance(other, int): + self.value |= other + else: + self.value |= other.value + return self + + def __ixor__(self, other: Self | int, /) -> Self: + if isinstance(other, int): + self.value ^= other + else: + self.value ^= other.value + return self + + def __ne__(self, other: object) -> bool: + return not self.__eq__(other) + + def __or__(self, other: Self | int, /) -> Self: + if isinstance(other, self.__class__): + other = other.value + return self.__class__(self.value | other) + + def __xor__(self, other: Self | int, /) -> Self: + if isinstance(other, self.__class__): + other = other.value + return self.__class__(self.value ^ other) + + +def doc_flags( + intro: str, + /, + *, + added_in: str | None = None, +) -> Callable[[type[BF]], type[BF]]: + def decorator(cls: type[BF]) -> type[BF]: + directives = '' + + if added_in: + directives += ' \n' + directives += f' .. versionadded:: {added_in}' + directives += '\n' + cls.__doc__ = f"""{intro} + + .. container:: operations + + .. describe:: x == y + + Checks if two flags are equal. + .. describe:: x != y + + Checks if two flags are not equal. + + .. describe:: x | y, x |= y + + Returns a {cls.__name__} instance with all enabled flags from + both x and y. + + .. versionadded:: 2.0 + + .. describe:: x & y, x &= y + + Returns a {cls.__name__} instance with only flags enabled on + both x and y. + + .. versionadded:: 2.0 + + .. describe:: x ^ y, x ^= y + + Returns a {cls.__name__} instance with only flags enabled on + only one of x or y, not on both. + + .. versionadded:: 2.0 + + .. describe:: ~x + Returns a {cls.__name__} instance with all flags inverted from x. + + .. describe:: hash(x) + Return the flag's hash. + + .. describe:: iter(x) + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + + .. describe:: bool(b) + Returns whether any flag is set to ``True``. +{directives} + Attributes + ---------- + value: :class:`int` + The raw value. This value is a bit array field of a 53-bit integer + representing the currently available flags. You should query + flags via the properties rather than using this raw value. +""" + return cls + + return decorator + + +@doc_flags('Wraps up a Revolt Bot flag value.') +class BotFlags(BaseFlags, support_kwargs=False): + __slots__ = () + + @flag() + def verified(cls) -> int: + """:class:`bool`: Whether the bot is verified.""" + return 1 << 0 + + @flag() + def official(cls) -> int: + """:class:`bool`: Whether the bot is created by Revolt.""" + return 1 << 1 + + +@doc_flags('Wraps up a Message flag value.') +class MessageFlags(BaseFlags, support_kwargs=False): + __slots__ = () + + SUPPRESS_NOTIFICATIONS = 1 << 0 + + @flag() + def suppress_notifications(cls) -> int: + """:class:`bool`: Whether the message will not send push/desktop notifications.""" + return cls.SUPPRESS_NOTIFICATIONS + + +@doc_flags('Wraps up a Permission flag value.') +class Permissions(BaseFlags, support_kwargs=True): + __slots__ = () + + # * Generic premissions + @flag() + def manage_channels(cls) -> int: + """:class:`bool`: Whether the user can edit, delete, or create channels in the server. + + This also corresponsd to the "Manage Channel" channel-specific override. + """ + return 1 << 0 + + @flag() + def manage_server(cls) -> int: + """:class:`bool`: Whether the user can edit server properties.""" + return 1 << 1 + + @flag() + def manage_permissions(cls) -> int: + """:class:`bool`: Whether the user can manage permissions on server or channels.""" + return 1 << 2 + + @flag() + def manage_roles(cls) -> int: + """:class:`bool`: Whether the user can manage roles on server.""" + return 1 << 3 + + @flag() + def manage_customization(cls) -> int: + """:class:`bool`: Whether the user can manage server customization (includes emoji).""" + return 1 << 4 + + @classmethod + def generic(cls) -> Self: + """:class:`Permissions`: Returns generic permissions.""" + return cls(0b00000000_00000000_00000000_00000000_00000000_00000000_00000000_00011111) + + # % 1 bit reserved + + # * Member permissions + + @flag() + def kick_members(cls) -> int: + """:class:`bool`: Whether the user can kick other members below his ranking.""" + return 1 << 6 + + @flag() + def ban_members(cls) -> int: + """:class:`bool`: Whether the user can ban other members below his ranking.""" + return 1 << 7 + + @flag() + def timeout_members(cls) -> int: + """:class:`bool`: Whether the user can timeout other members below his ranking.""" + return 1 << 8 + + @flag() + def assign_roles(cls) -> int: + """:class:`bool`: Whether the user can assign roles to members below his ranking.""" + return 1 << 9 + + @flag() + def change_nickname(cls) -> int: + """:class:`bool`: Whether the user can change own nick.""" + return 1 << 10 + + @flag() + def manage_nicknames(cls) -> int: + """:class:`bool`: Whether the user can change or remove other's nicknames below his ranking.""" + return 1 << 11 + + @flag() + def change_avatar(cls) -> int: + """:class:`bool`: Whether the user can change own avatar.""" + return 1 << 12 + + @flag() + def remove_avatars(cls) -> int: + """:class:`bool`: Whether the user can remove other's avatars below his ranking.""" + return 1 << 13 + + @classmethod + def member(cls) -> Self: + """:class:`Permissions`: Returns member-related permissions.""" + return cls(0b00000000_00000000_00000000_00000000_00000000_00000000_00111111_11000000) + + # % 7 bits reserved + + # * Channel permissions + + @flag() + def view_channel(cls) -> int: + """:class:`bool`: Whether the user can view a channel.""" + return 1 << 20 + + @flag() + def read_message_history(cls) -> int: + """:class:`bool`: Whether the user can read a channel's past message history.""" + return 1 << 21 + + @flag() + def send_messages(cls) -> int: + """:class:`bool`: Whether the user can send messages.""" + return 1 << 22 + + @flag() + def manage_messages(cls) -> int: + """:class:`bool`: Whether the user can delete other's messages (or pin them) in a channel.""" + return 1 << 23 + + @flag() + def manage_webhooks(cls) -> int: + """:class:`bool`: Whether user can manage webhooks that belong to a channel.""" + return 1 << 24 + + @flag() + def create_invites(cls) -> int: + """:class:`bool`: Whether user can create invites to this channel.""" + return 1 << 25 + + @flag() + def send_embeds(cls) -> int: + """:class:`bool`: Whether user can send embedded content in this channel.""" + return 1 << 26 + + @flag() + def upload_files(cls) -> int: + """:class:`bool`: Whether user can send attachments and media in this channel.""" + return 1 << 27 + + @flag() + def use_masquerade(cls) -> int: + """:class:`bool`: Whether the user can use masquerade on own messages with custom nickname and avatar.""" + return 1 << 28 + + @flag() + def react(cls) -> int: + """:class:`bool`: Whether the user can react to messages with emojis.""" + return 1 << 29 + + @classmethod + def channel(cls) -> Self: + """:class:`Permissions`: Returns channel-related permissions.""" + return cls(0b00000000_00000000_00000000_00000000_00111111_11110000_00000000_00000000) + + # * Voice permissions + + @flag() + def connect(cls) -> int: + """:class:`bool`: Whether the user can connect to a voice channel.""" + return 1 << 30 + + @flag() + def speak(cls) -> int: + """:class:`bool`: Whether the user can speak in a voice call.""" + return 1 << 31 + + @flag() + def video(cls) -> int: + """:class:`bool`: Whether the user can share video in a voice call.""" + return 1 << 32 + + @flag() + def mute_members(cls) -> int: + """:class:`bool`: Whether user can mute other members with lower ranking in a voice call.""" + return 1 << 33 + + @flag() + def deafen_members(cls) -> int: + """:class:`bool`: Whether user can deafen other members with lower ranking in a voice call.""" + return 1 << 34 + + @flag() + def move_members(cls) -> int: + """:class:`bool`: Whether the user can move members between voice channels.""" + return 1 << 35 + + @classmethod + def voice(cls) -> Self: + """:class:`Permissions`: Returns voice-related permissions.""" + return cls(0b00000000_00000000_00000000_00001111_11000000_00000000_00000000_00000000) + + # * Misc. permissions + # % Bits 36 to 52: free area + # % Bits 53 to 64: do not use + + +ALLOW_PERMISSIONS_IN_TIMEOUT = Permissions( + view_channel=True, + read_message_history=True, +) +VIEW_ONLY_PERMISSIONS = Permissions( + view_channel=True, + read_message_history=True, +) +DEFAULT_PERMISSIONS = VIEW_ONLY_PERMISSIONS | Permissions( + send_messages=True, + create_invites=True, + send_embeds=True, + upload_files=True, + connect=True, + speak=True, +) +DEFAULT_SAVED_MESSAGES_PERMISSIONS = Permissions.all() +DEFAULT_DM_PERMISSIONS = DEFAULT_PERMISSIONS | Permissions(manage_channels=True, react=True) +DEFAULT_SERVER_PERMISSIONS = DEFAULT_PERMISSIONS | Permissions( + react=True, + change_nickname=True, + change_avatar=True, +) + + +@doc_flags('Wraps up a user permission flag value.') +class UserPermissions(BaseFlags, support_kwargs=True): + __slots__ = () + + @flag() + def access(cls) -> int: + """:class:`bool`: Whether the user can access data of this user.""" + return 1 << 0 + + @flag() + def view_profile(cls) -> int: + """:class:`bool`: Whether the user can view this user's profile.""" + return 1 << 1 + + @flag() + def send_messages(cls) -> int: + """:class:`bool`: Whether the user can send messages to this user.""" + return 1 << 2 + + @flag() + def invite(cls) -> int: + """:class:`bool`: Unknown. This corresponds to ``1<<3``.""" + return 1 << 3 + + +@doc_flags('Wraps up a server flag value.') +class ServerFlags(BaseFlags, support_kwargs=True): + __slots__ = () + + @flag() + def verified(cls) -> int: + """:class:`bool`: Whether the server is verified.""" + return 1 << 0 + + @flag() + def official(cls) -> int: + """:class:`bool`: Whether the server is ran by Revolt team.""" + return 1 << 1 + + +@doc_flags('Wraps up a User Badges flag value.') +class UserBadges(BaseFlags, support_kwargs=True): + __slots__ = () + + @flag() + def developer(cls) -> int: + """:class:`bool`: Whether user is Revolt developer.""" + return 1 << 0 + + @flag() + def translator(cls) -> int: + """:class:`bool`: Whether the user helped translate Revolt.""" + return 1 << 1 + + @flag() + def supporter(cls) -> int: + """:class:`bool`: Whether the user monetarily supported Revolt.""" + return 1 << 2 + + @flag() + def responsible_disclosure(cls) -> int: + """:class:`bool`: Whether the user have responsibly disclosed a security issue.""" + return 1 << 3 + + @flag() + def founder(cls) -> int: + """:class:`bool`: Whether the user is a Revolt founder.""" + return 1 << 4 + + @flag() + def platform_moderation(cls) -> int: + """:class:`bool`: Whether the user is a platform moderator.""" + return 1 << 5 + + @flag() + def active_supporter(cls) -> int: + """:class:`bool`: Whether the user is active monetary supporter.""" + return 1 << 6 + + @flag() + def paw(cls) -> int: + """:class:`bool`: Whether the user likes 🦊 and 🦝.""" + return 1 << 7 + + @flag() + def early_adopter(cls) -> int: + """:class:`bool`: Whether the user joined as one of the first 1000 users in 2021.""" + return 1 << 8 + + @flag() + def reserved_relevant_joke_badge_1(cls) -> int: + """:class:`bool`: Whether the user is have given some funny joke. + + This is displayed as amogus (with 'sus' label) in Revite. + """ + return 1 << 9 + + @flag() + def reserved_relevant_joke_badge_2(cls) -> int: + """:class:`bool`: Whether the user is have given some funny joke. + + This is displayed as amorbus (Amogus with Morbin texture) in Revite, + and as 'Low resolution troll face' in new client. + """ + return 1 << 9 + + +@doc_flags('Wraps up a User Flags flag value.') +class UserFlags(BaseFlags, support_kwargs=True): + __slots__ = () + + @flag() + def suspended(cls) -> int: + """:class:`bool`: Whether the user has been suspended from the platform.""" + return 1 << 0 + + @flag() + def deleted(cls) -> int: + """:class:`bool`: Whether the user has deleted their account.""" + return 1 << 1 + + @flag() + def banned(cls) -> int: + """:class:`bool`: Whether the user was banned off the platform.""" + return 1 << 2 + + @flag() + def spam(cls) -> int: + """:class:`bool`: Whether the user was marked as spam and removed from platform.""" + return 1 << 3 + + +__all__ = ( + 'flag', + 'BaseFlags', + 'doc_flags', + 'BotFlags', + 'MessageFlags', + 'Permissions', + 'ALLOW_PERMISSIONS_IN_TIMEOUT', + 'VIEW_ONLY_PERMISSIONS', + 'DEFAULT_PERMISSIONS', + 'DEFAULT_SAVED_MESSAGES_PERMISSIONS', + 'DEFAULT_DM_PERMISSIONS', + 'DEFAULT_SERVER_PERMISSIONS', + 'UserPermissions', + 'ServerFlags', + 'UserBadges', + 'UserFlags', +) diff --git a/pyvolt/http.py b/pyvolt/http.py index 0b81da2..b18112a 100644 --- a/pyvolt/http.py +++ b/pyvolt/http.py @@ -1,3 +1,27 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import aiohttp @@ -5,57 +29,122 @@ from datetime import datetime, timedelta import logging import multidict -import typing as t - +import typing + +from . import routes, utils +from .auth import ( + PartialAccount, + MFATicket, + PartialSession, + Session, + MFAMethod, + MFARequired, + AccountDisabled, + MFAStatus, + MFAResponse, + LoginResult, +) +from .bot import BaseBot, Bot, PublicBot +from .channel import ( + BaseChannel, + TextChannel, + SavedMessagesChannel, + DMChannel, + GroupChannel, + VoiceChannel, + ServerChannel, + Channel, +) +from .cdn import ResolvableResource, resolve_resource +from .core import ( + UNDEFINED, + UndefinedOr, + ULIDOr, + resolve_id, + __version__ as version, +) +from .emoji import BaseEmoji, ServerEmoji, Emoji, ResolvableEmoji, resolve_emoji +from .enums import ChannelType, MessageSort, ContentReportReason, UserReportReason +from .errors import ( + HTTPException, + Unauthorized, + Forbidden, + NotFound, + Ratelimited, + InternalServerError, + BadGateway, +) +from .flags import MessageFlags, Permissions, ServerFlags, UserBadges, UserFlags +from .invite import BaseInvite, ServerInvite, Invite +from .message import ( + Reply, + Interactions, + Masquerade, + SendableEmbed, + BaseMessage, + Message, +) +from .permissions import PermissionOverride +from .read_state import ReadState +from .server import ( + Category, + SystemMessageChannels, + BaseRole, + Role, + BaseServer, + Server, + Ban, + BaseMember, + Member, + MemberList, +) +from .user_settings import UserSettings +from .user import ( + UserStatusEdit, + UserProfile, + UserProfileEdit, + Mutuals, + BaseUser, + User, + OwnUser, +) +from .webhook import BaseWebhook, Webhook _L = logging.getLogger(__name__) -from . import ( - auth, - bot as bots, - cdn, - channel as channels, - core, - emoji as emojis, - errors, - invite as invites, - message as messages, - permissions as permissions_, - read_state, - routes, - safety_reports, - server as servers, - user_settings, - user as users, - utils, - webhook as webhooks, -) -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from . import raw from .state import State -DEFAULT_HTTP_USER_AGENT = ( - f"pyvolt API client (https://github.com/MCausc78/pyvolt, {core.__version__})" -) +DEFAULT_HTTP_USER_AGENT = f'pyvolt API client (https://github.com/MCausc78/pyvolt, {version})' _L = logging.getLogger(__name__) +_STATUS_TO_ERRORS = { + 401: Unauthorized, + 403: Forbidden, + 404: NotFound, + 429: Ratelimited, + 500: InternalServerError, +} class HTTPClient: """The Revolt HTTP API client.""" + # To prevent unexpected 200's with HTML page the user must pass cookie with ``cf_clearance`` key. + __slots__ = ( - "token", - "bot", - "state", - "_session", - "base", - "cf_clearance", - "max_retries", - "user_agent", + 'token', + 'bot', + 'state', + '_session', + 'base', + 'cookie', + 'max_retries', + 'user_agent', ) def __init__( @@ -64,13 +153,10 @@ def __init__( *, base: str | None = None, bot: bool = True, - cf_clearance: str | None = None, + cookie: str | None = None, max_retries: int | None = None, state: State, - session: ( - utils.MaybeAwaitableFunc[[HTTPClient], aiohttp.ClientSession] - | aiohttp.ClientSession - ), + session: utils.MaybeAwaitableFunc[[HTTPClient], aiohttp.ClientSession] | aiohttp.ClientSession, user_agent: str | None = None, ) -> None: self.token = token @@ -78,84 +164,146 @@ def __init__( self.state = state if base is None: - base = "https://api.revolt.chat" + base = 'https://api.revolt.chat' self._session = session self.base = base - self.cf_clearance = cf_clearance + self.cookie = cookie self.max_retries = max_retries or 3 self.user_agent = user_agent or DEFAULT_HTTP_USER_AGENT - async def _request( + def url_for(self, route: routes.CompiledRoute) -> str: + """Returns a URL for route. + + Parameters + ---------- + route: :class:`~routes.CompiledRoute` + The route. + + Returns + ------- + :class:`str` + The URL for the route. + """ + return self.base.rstrip('/') + route.build() + + def with_credentials(self, token: str, *, bot: bool = True) -> None: + """Modifies HTTP client credentials. + + Parameters + ---------- + token: :class:`str` + The authentication token. + bot: :class:`bool` + Whether the token belongs to bot account or not. + """ + self.token = token + self.bot = bot + + async def raw_request( self, route: routes.CompiledRoute, *, authenticated: bool = True, - manual_accept: bool = False, - user_agent: str = "", + accept_json: bool = True, + user_agent: str = '', mfa_ticket: str | None = None, + json: UndefinedOr[typing.Any] = UNDEFINED, **kwargs, ) -> aiohttp.ClientResponse: - headers: multidict.CIMultiDict[t.Any] = multidict.CIMultiDict( - kwargs.pop("headers", {}) - ) + """|coro| - # Prevent UnderAttackMode error - cf_clearance = kwargs.pop("cf_clearance", self.cf_clearance) - if cf_clearance: - if "cookie" not in headers: - headers["cookie"] = f"cf_clearance={cf_clearance}" - else: + Perform a HTTP request, with ratelimiting and errors handling. + + Parameters + ---------- + route: :class:`~routes.CompiledRoute` + The route. + authenticated: :class:`bool` + Whether this route should have provided authentication or not. Defaults to ``True``. + accept_json: :class:`bool` + Whether to explicitly receive JSON or not. Defaults to ``True``. + user_agent: :class:`str` + The user agent to use for HTTP request. This is reserved field. + mfa_ticket: Optional[:class:`str`] + The MFA ticket to pass in headers. + json: :class:`UndefinedOr`[typing.Any] + The JSON payload to pass in. + + Raises + ------ + HTTPException + Something went wrong during request. - pass + Returns + ------- + :class:`aiohttp.ClientResponse` + The aiohttp response. + """ + headers: multidict.CIMultiDict[typing.Any] = multidict.CIMultiDict(kwargs.pop('headers', {})) + + # Allow users to set cookie if Revolt is under attack mode + cookie = kwargs.pop('cookie', self.cookie) + if cookie: + headers['cookie'] = cookie if self.token is not None and authenticated: - headers["x-bot-token" if self.bot else "x-session-token"] = self.token - if not manual_accept: - headers["accept"] = "application/json" - headers["user-agent"] = user_agent or self.user_agent + headers['x-bot-token' if self.bot else 'x-session-token'] = self.token + if accept_json: + headers['accept'] = 'application/json' + headers['user-agent'] = user_agent or self.user_agent if mfa_ticket is not None: - headers["x-mfa-ticket"] = mfa_ticket + headers['x-mfa-ticket'] = mfa_ticket retries = 0 + if json is not UNDEFINED: + headers['content-type'] = 'application/json' + kwargs['data'] = utils.to_json(json) + method = route.route.method - url = self.base.rstrip("/") + route.build() + url = self.url_for(route) while True: - _L.debug("sending request to %s, body=%s", route, kwargs.get("json")) + _L.debug('sending request to %s %s with %s', method, url, kwargs.get('data')) session = self._session if callable(session): session = await utils._maybe_coroutine(session, self) # detect recursion if callable(session): - raise TypeError( - f"Expected aiohttp.ClientSession, not {type(session)!r}" - ) + raise TypeError(f'Expected aiohttp.ClientSession, not {type(session)!r}') # Do not call factory on future requests self._session = session - response = await session.request( - method, - url, - headers=headers, - **kwargs, - ) + try: + response = await session.request( + method, + url, + headers=headers, + **kwargs, + ) + except OSError as exc: + # TODO: Handle 10053? + if exc.errno in (54, 10054): # Connection reset by peer + await asyncio.sleep(1.5) + continue + raise + if response.status >= 400: + _L.debug('%s %s has returned %s', method, url, kwargs.get('data'), response.status) + if response.status == 502: if retries >= self.max_retries: - raise errors.BadGateway( - response, await utils._json_or_text(response) - ) + raise BadGateway(response, await utils._json_or_text(response)) continue if response.status == 429: if retries < self.max_retries: j = await utils._json_or_text(response) if isinstance(j, dict): - retry_after: float = j["retry_after"] / 1000.0 + retry_after: float = j['retry_after'] / 1000.0 else: - retry_after = float("nan") + retry_after = 1 _L.debug( - "ratelimited on %s %s, retrying in %.3f seconds", + 'ratelimited on %s %s, retrying in %.3f seconds', method, url, retry_after, @@ -163,20 +311,14 @@ async def _request( await asyncio.sleep(retry_after) continue j = await utils._json_or_text(response) - if isinstance(j.get("error"), dict): - error = j["error"] - code = error.get("code") - reason = error.get("reason") - description = error.get("description") - j["type"] = "RocketError" - j["err"] = f"{code} {reason}: {description}" - raise { - 401: errors.Unauthorized, - 403: errors.Forbidden, - 404: errors.NotFound, - 429: errors.Ratelimited, - 500: errors.InternalServerError, - }.get(response.status, errors.APIError)(response, j) + if isinstance(j, dict) and isinstance(j.get('error'), dict): + error = j['error'] + code = error.get('code') + reason = error.get('reason') + description = error.get('description') + j['type'] = 'RocketError' + j['err'] = f'{code} {reason}: {description}' + raise _STATUS_TO_ERRORS.get(response.status, HTTPException)(response, j) return response async def request( @@ -184,370 +326,479 @@ async def request( route: routes.CompiledRoute, *, authenticated: bool = True, - manual_accept: bool = False, - user_agent: str = "", + accept_json: bool = True, + user_agent: str = '', mfa_ticket: str | None = None, + json: UndefinedOr[typing.Any] = UNDEFINED, + log: bool = True, **kwargs, - ) -> t.Any: - response = await self._request( + ) -> typing.Any: + """|coro| + + Perform a HTTP request, with ratelimiting and errors handling. + + Parameters + ---------- + route: :class:`~routes.CompiledRoute` + The route. + authenticated: :class:`bool` + Whether this route should have provided authentication or not. Defaults to ``True``. + accept_json: :class:`bool` + Whether to explicitly receive JSON or not. Defaults to ``True``. + user_agent: :class:`str` + The user agent to use for HTTP request. This is reserved field. + mfa_ticket: Optional[:class:`str`] + The MFA ticket to pass in headers. + json: :class:`UndefinedOr`[typing.Any] + The JSON payload to pass in. + log: :class:`bool` + Whether to log successful response or not. This option is intended to avoid catastrophic spam. + + Raises + ------ + HTTPException + Something went wrong during request. + + Returns + ------- + Any + The parsed JSON response. + """ + response = await self.raw_request( route, authenticated=authenticated, - manual_accept=manual_accept, + accept_json=accept_json, user_agent=user_agent, mfa_ticket=mfa_ticket, + json=json, **kwargs, ) result = await utils._json_or_text(response) - if not response.closed: - response.close() + + if log: + method = response.request_info.method + url = response.request_info.url + + _L.debug('%s %s has received %s', method, url, result) + response.close() return result # Bots control - async def create_bot(self, name: str) -> bots.Bot: + async def create_bot(self, name: str) -> Bot: """|coro| Create a new Revolt bot. .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + name: :class:`str` + The bot name. + + Returns + ------- + :class:`Bot` + The created bot. """ - j: raw.DataCreateBot = {"name": name} - d: raw.BotWithUserResponse = await self.request( - routes.BOTS_CREATE.compile(), json=j - ) - return self.state.parser.parse_bot(d, d["user"]) + payload: raw.DataCreateBot = {'name': name} + resp: raw.BotWithUserResponse = await self.request(routes.BOTS_CREATE.compile(), json=payload) + + # TODO: Remove when Revolt will fix this + if resp['user']['relationship'] == 'User': + resp['user']['relationship'] = 'None' + return self.state.parser.parse_bot(resp, resp['user']) - async def delete_bot(self, bot: core.ResolvableULID) -> None: + async def delete_bot(self, bot: ULIDOr[BaseBot]) -> None: """|coro| - Delete a bot. - https://developers.revolt.chat/api/#tag/Bots/operation/delete_delete_bot + Deletes the bot. .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + bot: :class:`ULIDOr`[:class:`BaseBot`] + The bot to delete. """ - await self.request(routes.BOTS_DELETE.compile(bot_id=core.resolve_ulid(bot))) + await self.request(routes.BOTS_DELETE.compile(bot_id=resolve_id(bot))) async def edit_bot( self, - bot: core.ResolvableULID, + bot: ULIDOr[BaseBot], *, - name: core.UndefinedOr[str] = core.UNDEFINED, - public: core.UndefinedOr[bool] = core.UNDEFINED, - analytics: core.UndefinedOr[bool] = core.UNDEFINED, - interactions_url: core.UndefinedOr[str | None] = core.UNDEFINED, + name: UndefinedOr[str] = UNDEFINED, + public: UndefinedOr[bool] = UNDEFINED, + analytics: UndefinedOr[bool] = UNDEFINED, + interactions_url: UndefinedOr[str | None] = UNDEFINED, reset_token: bool = False, - ) -> bots.Bot: + ) -> Bot: """|coro| Edits the bot. - """ - j: raw.DataEditBot = {} - r: list[raw.FieldsBot] = [] - if core.is_defined(name): - j["name"] = name - if core.is_defined(public): - j["public"] = public - if core.is_defined(analytics): - j["analytics"] = analytics - if core.is_defined(interactions_url): + + Parameters + ---------- + bot: :class:`ULIDOr`[:class:`BaseBot`] + The bot to edit. + name: :class:`UndefinedOr`[:class:`str`] + The new bot name. + public: :class:`UndefinedOr`[:class:`bool`] + Whether the bot should be public (could be invited by anyone). + analytics: :class:`UndefinedOr`[:class:`bool`] + Whether to allow Revolt collect analytics about the bot. + interactions_url: :class:`UndefinedOr`[Optional[:class:`str`]] + The new bot interactions URL. For now, this parameter is reserved and does not do anything. + reset_token: :class:`bool` + Whether to reset bot token. The new token can be accessed via ``bot.token``. + + Returns + ------- + :class:`Bot` + The updated bot. + """ + payload: raw.DataEditBot = {} + remove: list[raw.FieldsBot] = [] + if name is not UNDEFINED: + payload['name'] = name + if public is not UNDEFINED: + payload['public'] = public + if analytics is not UNDEFINED: + payload['analytics'] = analytics + if interactions_url is not UNDEFINED: if interactions_url is None: - r.append("InteractionsURL") + remove.append('InteractionsURL') else: - j["interactions_url"] = interactions_url + payload['interactions_url'] = interactions_url if reset_token: - r.append("Token") - if len(r) > 0: - j["remove"] = r + remove.append('Token') + if len(remove) > 0: + payload['remove'] = remove - d: raw.BotWithUserResponse = await self.request( - routes.BOTS_EDIT.compile(bot_id=core.resolve_ulid(bot)), json=j + resp: raw.BotWithUserResponse = await self.request( + routes.BOTS_EDIT.compile(bot_id=resolve_id(bot)), json=payload ) + + # TODO: Remove when Revolt will fix this + if resp['user']['relationship'] == 'User': + resp['user']['relationship'] = 'None' + return self.state.parser.parse_bot( - d, - d["user"], + resp, + resp['user'], ) - async def get_bot(self, bot: core.ResolvableULID) -> bots.Bot: + async def get_bot(self, bot: ULIDOr[BaseBot]) -> Bot: """|coro| - Get details of a bot you own. - https://developers.revolt.chat/api/#tag/Bots/operation/fetch_fetch_bot + Retrieves the bot with the given ID. + + The bot must be owned by you. .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + bot: :class:`ULIDOr`[:class:`BaseBot`] + The ID of the bot. + + Returns + ------- + :class:`Bot` + The retrieved bot. """ - d: raw.FetchBotResponse = await self.request( - routes.BOTS_FETCH.compile(bot_id=core.resolve_ulid(bot)) - ) - return self.state.parser.parse_bot(d["bot"], d["user"]) + resp: raw.FetchBotResponse = await self.request(routes.BOTS_FETCH.compile(bot_id=resolve_id(bot))) + return self.state.parser.parse_bot(resp['bot'], resp['user']) - async def get_owned_bots(self) -> list[bots.Bot]: + async def get_owned_bots(self) -> list[Bot]: """|coro| - Get all of the bots that you have control over. - https://developers.revolt.chat/api/#tag/Bots/operation/fetch_owned_fetch_owned_bots + Retrieves all bots owned by you. .. note:: This can only be used by non-bot accounts. + + Returns + ------- + List[:class:`Bot`] + The owned bots. """ - return self.state.parser.parse_bots( - await self.request(routes.BOTS_FETCH_OWNED.compile()) - ) + return self.state.parser.parse_bots(await self.request(routes.BOTS_FETCH_OWNED.compile())) - async def get_public_bot(self, bot: core.ResolvableULID) -> bots.PublicBot: + async def get_public_bot(self, bot: ULIDOr[BaseBot]) -> PublicBot: """|coro| - Get details of a public (or owned) bot. - https://developers.revolt.chat/api/#tag/Bots/operation/fetch_public_fetch_public_bot + Retrieves the public bot with the given ID. .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + bot: :class:`ULIDOr`[:class:`BaseBot`] + The ID of the bot. + + Returns + ------- + :class:`PublicBot` + The retrieved bot. """ return self.state.parser.parse_public_bot( - await self.request( - routes.BOTS_FETCH_PUBLIC.compile(bot_id=core.resolve_ulid(bot)) - ) + await self.request(routes.BOTS_FETCH_PUBLIC.compile(bot_id=resolve_id(bot))) ) + @typing.overload + async def invite_bot( + self, + bot: ULIDOr[BaseBot | BaseUser], + *, + server: ULIDOr[BaseServer], + ) -> None: ... + + @typing.overload async def invite_bot( self, - bot: core.ResolvableULID, + bot: ULIDOr[BaseBot | BaseUser], *, - server: core.ResolvableULID | None = None, - group: core.ResolvableULID | None = None, + group: ULIDOr[GroupChannel], + ) -> None: ... + + async def invite_bot( + self, + bot: ULIDOr[BaseBot | BaseUser], + *, + server: ULIDOr[BaseServer] | None = None, + group: ULIDOr[GroupChannel] | None = None, ) -> None: """|coro| - Invite a bot to a server or group. - https://developers.revolt.chat/api/#tag/Bots/operation/invite_invite_bot + Invites a bot to a server or group. + **Specifying both ``server`` and ``group`` parameters (or no parameters at all) will lead to an exception.** .. note:: This can only be used by non-bot accounts. + Parameters + ---------- + bot: :class:`ULIDOr`[Union[:class:`BaseBot`, :class:`BaseUser`]] + The bot. + server: Optional[:class:`ULIDOr`[:class:`BaseServer`]] + The destination server. + group: Optional[:class:`ULIDOr`[:class:`GroupChannel`]] + The destination group. + + Raises + ------ + TypeError + You specified ``server`` and ``group`` parameters, or passed no parameters. """ if server and group: - raise TypeError("Cannot pass both server and group") + raise TypeError('Cannot pass both server and group') if not server and not group: - raise TypeError("Pass server or group") + raise TypeError('Pass server or group') - j: raw.InviteBotDestination + payload: raw.InviteBotDestination if server: - j = {"server": core.resolve_ulid(server)} + payload = {'server': resolve_id(server)} elif group: - j = {"group": core.resolve_ulid(group)} + payload = {'group': resolve_id(group)} else: - raise RuntimeError("Unreachable") + raise RuntimeError('Unreachable') - await self.request( - routes.BOTS_INVITE.compile(bot_id=core.resolve_ulid(bot)), json=j - ) + await self.request(routes.BOTS_INVITE.compile(bot_id=resolve_id(bot)), json=payload) # Channels control - async def acknowledge_message( - self, channel: core.ResolvableULID, message: core.ResolvableULID - ) -> None: + async def acknowledge_message(self, channel: ULIDOr[TextChannel], message: ULIDOr[BaseMessage]) -> None: """|coro| - Lets the server and all other clients know that we've seen this message in this channel. - https://developers.revolt.chat/api/#tag/Messaging/operation/channel_ack_req + Marks this message as read. .. note:: This can only be used by non-bot accounts. Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel message was sent to. - message: :class:`core.ResolvableULID` - Message to ack. + channel: :class:`ULIDOr`[:class:`TextChannel`] + The channel. + message: :class:`ULIDOr`[:class:`BaseMessage`] + The message. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to see that message. - :class:`APIError` - Acknowledging message failed. + HTTPException + Acking message failed. """ await self.request( routes.CHANNELS_CHANNEL_ACK.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), + channel_id=resolve_id(channel), + message_id=resolve_id(message), ) ) - async def close_channel( - self, channel: core.ResolvableULID, silent: bool | None = None - ) -> None: + async def close_channel(self, channel: ULIDOr[BaseChannel], silent: bool | None = None) -> None: """|coro| Deletes a server channel, leaves a group or closes a group. - https://developers.revolt.chat/api/#tag/Channel-Information/operation/channel_delete_req Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel to close. - silent: :class:`bool` + channel: :class:`ULIDOr`[:class:`BaseChannel`] + The channel. + silent: Optional[:class:`bool`] Whether to not send message when leaving. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to close the channel. - :class:`APIError` + HTTPException Closing the channel failed. """ p: raw.OptionsChannelDelete = {} if silent is not None: - p["leave_silently"] = utils._bool(silent) + p['leave_silently'] = utils._bool(silent) await self.request( - routes.CHANNELS_CHANNEL_DELETE.compile( - channel_id=core.resolve_ulid(channel) - ), + routes.CHANNELS_CHANNEL_DELETE.compile(channel_id=resolve_id(channel)), params=p, ) async def edit_channel( self, - channel: core.ResolvableULID, + channel: ULIDOr[BaseChannel], *, - name: core.UndefinedOr[str] = core.UNDEFINED, - description: core.UndefinedOr[str | None] = core.UNDEFINED, - owner: core.UndefinedOr[core.ResolvableULID] = core.UNDEFINED, - icon: core.UndefinedOr[str | None] = core.UNDEFINED, - nsfw: core.UndefinedOr[bool] = core.UNDEFINED, - archived: core.UndefinedOr[bool] = core.UNDEFINED, - default_permissions: core.UndefinedOr[None] = core.UNDEFINED, - ) -> channels.Channel: + name: UndefinedOr[str] = UNDEFINED, + description: UndefinedOr[str | None] = UNDEFINED, + owner: UndefinedOr[ULIDOr[BaseUser]] = UNDEFINED, + icon: UndefinedOr[ResolvableResource | None] = UNDEFINED, + nsfw: UndefinedOr[bool] = UNDEFINED, + archived: UndefinedOr[bool] = UNDEFINED, + default_permissions: UndefinedOr[None] = UNDEFINED, + ) -> Channel: """|coro| Edits the channel. - https://developers.revolt.chat/api/#tag/Channel-Information/operation/channel_edit_req Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel to edit. + channel: :class:`ULIDOr`[:class:`BaseChannel`] + The channel. + name: :class:`UndefinedOr`[:class:`str`] + The new channel name. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + description: :class:`UndefinedOr`[Optional[:class:`str`]] + The new channel description. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + owner: :class:`UndefinedOr`[:clsas:`ULIDOr`[:class:`BaseUser`]] + The new channel owner. Only applicable when target channel is :class:`GroupChannel`. + icon: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + The new channel icon. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + nsfw: :class:`UndefinedOr`[:class:`bool`] + To mark the channel as NSFW or not. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. + archived: :class:`UndefinedOr`[:class:`bool`] + To mark the channel as archived or not. + default_permissions: :class:`UndefinedOr`[None] + To remove default permissions or not. Only applicable when target channel is :class:`GroupChannel`, or :class:`ServerChannel`. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the channel. - :class:`APIError` + HTTPException Editing the channel failed. - """ - j: raw.DataEditChannel = {} - r: list[raw.FieldsChannel] = [] - if core.is_defined(name): - j["name"] = name - if core.is_defined(description): + + Returns + ------- + :class:`Channel` + The newly updated channel. + """ + payload: raw.DataEditChannel = {} + remove: list[raw.FieldsChannel] = [] + if name is not UNDEFINED: + payload['name'] = name + if description is not UNDEFINED: if description is None: - r.append("Description") + remove.append('Description') else: - j["description"] = description - if core.is_defined(owner): - j["owner"] = core.resolve_ulid(owner) - if core.is_defined(icon): + payload['description'] = description + if owner is not UNDEFINED: + payload['owner'] = resolve_id(owner) + if icon is not UNDEFINED: if icon is None: - r.append("Icon") + remove.append('Icon') else: - j["icon"] = icon - if core.is_defined(nsfw): - j["nsfw"] = nsfw - if core.is_defined(archived): - j["archived"] = archived - if core.is_defined(default_permissions): - r.append("DefaultPermissions") - if len(r) > 0: - j["remove"] = r + payload['icon'] = await resolve_resource(self.state, icon, tag='icons') + if nsfw is not UNDEFINED: + payload['nsfw'] = nsfw + if archived is not UNDEFINED: + payload['archived'] = archived + if default_permissions is not UNDEFINED: + remove.append('DefaultPermissions') + if len(remove) > 0: + payload['remove'] = remove return self.state.parser.parse_channel( await self.request( - routes.CHANNELS_CHANNEL_EDIT.compile( - channel_id=core.resolve_ulid(channel) - ), - json=j, + routes.CHANNELS_CHANNEL_EDIT.compile(channel_id=resolve_id(channel)), + json=payload, ) ) - async def get_channel(self, channel: core.ResolvableULID) -> channels.Channel: + async def get_channel(self, channel: ULIDOr[BaseChannel]) -> Channel: """|coro| - Gets the channel. - https://developers.revolt.chat/api/#tag/Channel-Information/operation/channel_fetch_req + Retrieves a :class:`Channel` with the specified ID. - Raises - ------ - :class:`NotFound` - Invalid Channel ID. - :class:`APIError` - Getting the channel failed. - """ - return self.state.parser.parse_channel( - await self.request( - routes.CHANNELS_CHANNEL_FETCH.compile( - channel_id=core.resolve_ulid(channel) - ) - ) - ) - - async def get_channel_pins( - self, channel: core.ResolvableULID - ) -> list[messages.Message]: - """|coro| - - Retrieves all messages that are currently pinned in the channel. + Parameters + ---------- + channel: :class:`ULIDOr`[:class:`BaseChannel`] + The ID of the channel. Raises ------ - :class:`APIError` - Getting channel pins failed. + NotFound + The channel does not exist. + HTTPException + Getting the channel failed. Returns ------- - :class:`list`[:class:`messages.Message`] - The pinned messages. + :class:`Channel` + The retrieved channel. """ - return [ - self.state.parser.parse_message(m) - for m in await self.request( - routes.CHANNELS_CHANNEL_PINS.compile( - channel_id=core.resolve_ulid(channel) - ) - ) - ] + return self.state.parser.parse_channel( + await self.request(routes.CHANNELS_CHANNEL_FETCH.compile(channel_id=resolve_id(channel))) + ) - async def add_member_to_group( + async def add_recipient_to_group( self, - channel: core.ResolvableULID, - user: core.ResolvableULID, + channel: ULIDOr[GroupChannel], + user: ULIDOr[BaseUser], ) -> None: """|coro| Adds another user to the group. - https://developers.revolt.chat/api/#tag/Groups/operation/group_add_member_req .. note:: This can only be used by non-bot accounts. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`GroupChannel`] The group. - user: :class:`core.ResolvableULID` + user: :class:`ULIDOr`[:class:`BaseUser`] The user to add. Raises ------ - :class:`Forbidden` + Forbidden You're bot, lacking `InviteOthers` permission, or not friends with this user. - :class:`APIError` + HTTPException Adding user to the group failed. """ await self.request( - routes.CHANNELS_GROUP_ADD_MEMBER.compile( - channel_id=core.resolve_ulid(channel), user_id=core.resolve_ulid(user) - ) + routes.CHANNELS_GROUP_ADD_MEMBER.compile(channel_id=resolve_id(channel), user_id=resolve_id(user)) ) async def create_group( @@ -555,13 +806,12 @@ async def create_group( name: str, *, description: str | None = None, - users: list[core.ResolvableULID] | None = None, + recipients: list[ULIDOr[BaseUser]] | None = None, nsfw: bool | None = None, - ) -> channels.GroupChannel: + ) -> GroupChannel: """|coro| Creates the new group channel. - https://developers.revolt.chat/api/#tag/Groups/operation/group_create_req .. note:: This can only be used by non-bot accounts. @@ -569,466 +819,473 @@ async def create_group( Parameters ---------- name: :class:`str` - Group name. - description: :class:`str` | None - Group description. - users: :class:`list`[`:class:`core.ResolvableULID`] | None - List of users to add to the group. Must be friends with these users. - nsfw: :class:`bool` | None + The group name. + description: Optional[:class:`str`] + The group description. + recipients: Optional[List[:class:`ULIDOr`[:class:`BaseUser`]]] + The list of recipients to add to the group. You must be friends with these users. + nsfw: Optional[:class:`bool`] Whether this group should be age-restricted. Raises ------ - :class:`APIError` + HTTPException Creating the group failed. + + Returns + ------- + :class:`GroupChannel` + The new group. """ - j: raw.DataCreateGroup = {"name": name} + payload: raw.DataCreateGroup = {'name': name} if description is not None: - j["description"] = description - if users is not None: - j["users"] = [core.resolve_ulid(user) for user in users] + payload['description'] = description + if recipients is not None: + payload['users'] = [resolve_id(recipient) for recipient in recipients] if nsfw is not None: - j["nsfw"] = nsfw + payload['nsfw'] = nsfw return self.state.parser.parse_group_channel( - await self.request(routes.CHANNELS_GROUP_CREATE.compile(), json=j), + await self.request(routes.CHANNELS_GROUP_CREATE.compile(), json=payload), recipients=(True, []), ) - async def remove_member_from_group( + async def remove_recipient_from_group( self, - channel: core.ResolvableULID, - member: core.ResolvableULID, + channel: ULIDOr[GroupChannel], + user: ULIDOr[BaseUser], ) -> None: """|coro| - Removes a user from the group. - https://developers.revolt.chat/api/#tag/Groups/operation/group_remove_member_req + Removes a recipient from the group. .. note:: This can only be used by non-bot accounts. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`GroupChannel`] The group. - member: :class:`core.ResolvableULID` - User to remove. + user: :class:`ULID`[:class:`BaseUser`] + The user to remove. Raises ------ - :class:`Forbidden` + Forbidden You're not owner of group. - :class:`APIError` + HTTPException Removing the member from group failed. """ await self.request( routes.CHANNELS_GROUP_REMOVE_MEMBER.compile( - channel_id=core.resolve_ulid(channel), - member_id=core.resolve_ulid(member), + channel_id=resolve_id(channel), + user_id=resolve_id(user), ) ) - async def create_invite(self, channel: core.ResolvableULID) -> invites.Invite: + async def create_invite(self, channel: ULIDOr[GroupChannel | ServerChannel]) -> Invite: """|coro| - Creates an invite to channel. - Channel must be a group or server text channel. - https://developers.revolt.chat/api/#tag/Channel-Invites/operation/invite_create_req + Creates an invite to channel. The destination channel must be a group or server channel. .. note:: This can only be used by non-bot accounts. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[Union[:class:`GroupChannel`, :class:`ServerChannel`]] The invite destination channel. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create invite in that channel. - :class:`APIError` + HTTPException Creating invite failed. + + Returns + ------- + :class:`Invite` + The invite that was created. """ return self.state.parser.parse_invite( - await self.request( - routes.CHANNELS_INVITE_CREATE.compile( - channel_id=core.resolve_ulid(channel) - ) - ) + await self.request(routes.CHANNELS_INVITE_CREATE.compile(channel_id=resolve_id(channel))) ) - async def get_group_members( + async def get_group_recipients( self, - channel: core.ResolvableULID, - ) -> list[users.User]: + channel: ULIDOr[GroupChannel], + ) -> list[User]: """|coro| - Retrieves all users who are part of this group. - https://developers.revolt.chat/api/#tag/Groups/operation/members_fetch_req + Retrieves all recipients who are part of this group. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`GroupChannel`] The group channel. Raises ------ - :class:`APIError` - Getting group members failed. + HTTPException + Getting group recipients failed. + + Returns + ------- + List[:class:`User`] + The group recipients. """ return [ self.state.parser.parse_user(user) for user in await self.request( routes.CHANNELS_MEMBERS_FETCH.compile( - channel_id=core.resolve_ulid(channel), + channel_id=resolve_id(channel), ) ) ] async def bulk_delete_messages( self, - channel: core.ResolvableULID, - messages: t.Sequence[core.ResolvableULID], + channel: ULIDOr[TextChannel], + messages: typing.Sequence[ULIDOr[BaseMessage]], ) -> None: """|coro| Delete multiple messages you've sent or one you have permission to delete. This will always require `ManageMessages` permission regardless of whether you own the message or not. Messages must have been sent within the past 1 week. - https://developers.revolt.chat/api/#tag/Messaging/operation/message_bulk_delete_req Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel to messages were sent. - messages: :class:`t.Sequence`[`:class:`core.ResolvableULID`] - Messages to delete. + channel: :class:`ULIDOr`[:class:`TextChannel`] + The channel. + messages: :class:`typing.Sequence`[:class:`ULIDOr`[:class:`BaseMessage`]] + The messages to delete. - :class:`Forbidden` - You do not have permissions to delete messages. - :class:`APIError` + Raises + ------ + Forbidden + You do not have permissions to delete + HTTPException Deleting messages failed. """ - j: raw.OptionsBulkDelete = { - "ids": [core.resolve_ulid(message) for message in messages] - } + payload: raw.OptionsBulkDelete = {'ids': [resolve_id(message) for message in messages]} await self.request( - routes.CHANNELS_MESSAGE_BULK_DELETE.compile( - channel_id=core.resolve_ulid(channel) - ), - json=j, + routes.CHANNELS_MESSAGE_BULK_DELETE.compile(channel_id=resolve_id(channel)), + json=payload, ) async def remove_all_reactions_from_message( self, - channel: core.ResolvableULID, - message: core.ResolvableULID, + channel: ULIDOr[TextChannel], + message: ULIDOr[BaseMessage], ) -> None: """|coro| - Remove your own, someone else's or all of a given reaction. + Removes your own, someone else's or all of a given reaction. Requires `ManageMessages` permission. - https://developers.revolt.chat/api/#tag/Interactions/operation/message_clear_reactions_clear_reactions Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - message: :class:`core.ResolvableULID` + message: :class:`ULIDOr`[:class:`BaseMessage`] The message. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to remove all reactions from message. - :class:`APIError` + HTTPException Removing reactions from message failed. """ await self.request( routes.CHANNELS_MESSAGE_CLEAR_REACTIONS.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), + channel_id=resolve_id(channel), + message_id=resolve_id(message), ) ) - async def delete_message( - self, channel: core.ResolvableULID, message: core.ResolvableULID - ) -> None: + async def delete_message(self, channel: ULIDOr[TextChannel], message: ULIDOr[BaseMessage]) -> None: """|coro| - Delete a message you've sent or one you have permission to delete. - https://developers.revolt.chat/api/#tag/Messaging/operation/message_delete_req + Deletes a message you've sent or one you have permission to delete. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - message: :class:`core.ResolvableULID` + message: :class:`ULIDOr`[:class:`BaseMessage`] The message. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete message. - :class:`APIError` + HTTPException Deleting the message failed. """ await self.request( routes.CHANNELS_MESSAGE_DELETE.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), + channel_id=resolve_id(channel), + message_id=resolve_id(message), ) ) async def edit_message( self, - channel: core.ResolvableULID, - message: core.ResolvableULID, + channel: ULIDOr[TextChannel], + message: ULIDOr[BaseMessage], *, - content: core.UndefinedOr[str] = core.UNDEFINED, - embeds: core.UndefinedOr[list[messages.SendableEmbed]] = core.UNDEFINED, - ) -> messages.Message: + content: UndefinedOr[str] = UNDEFINED, + embeds: UndefinedOr[list[SendableEmbed]] = UNDEFINED, + ) -> Message: """|coro| Edits the message that you've previously sent. - https://developers.revolt.chat/api/#tag/Messaging/operation/message_edit_req Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel to message was sent. - message: :class:`core.ResolvableULID` - Message edit to. - content: :class:`str` | None - New content. - embeds: :class:`list`[:class:`messages.SendableEmbed`] | None - New embeds. - """ - j: raw.DataEditMessage = {} - if core.is_defined(content): - j["content"] = content - if core.is_defined(embeds): - j["embeds"] = [await embed.build(self.state) for embed in embeds] + channel: :class:`ULIDOr`[:class:`TextChannel`] + The channel. + message: :class:`ULIDOr`[:class:`BaseMessage`] + The message. + content: :class:`UndefinedOr`[:class:`str`] + The new content to replace the message with. + embeds: :class:`UndefinedOr`[List[:class:`SendableEmbed`]] + The new embeds to replace the original with. Must be a maximum of 10. To remove all embeds ``[]`` should be passed. + + Raises + ------ + Forbidden + Tried to suppress a message without permissions or edited a message's content or embed that isn't yours. + HTTPException + Editing the message failed. + + Returns + ------- + :class:`Message` + The newly edited message. + """ + payload: raw.DataEditMessage = {} + if content is not UNDEFINED: + payload['content'] = content + if embeds is not UNDEFINED: + payload['embeds'] = [await embed.build(self.state) for embed in embeds] return self.state.parser.parse_message( await self.request( routes.CHANNELS_MESSAGE_EDIT.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), + channel_id=resolve_id(channel), + message_id=resolve_id(message), ), - json=j, + json=payload, ) ) - async def get_message( - self, channel: core.ResolvableULID, message: core.ResolvableULID - ) -> messages.Message: + async def get_message(self, channel: ULIDOr[TextChannel], message: ULIDOr[BaseMessage]) -> Message: """|coro| Retrieves a message. - https://developers.revolt.chat/api/#tag/Messaging/operation/message_fetch_req Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - message: :class:`core.ResolvableULID` + message: :class:`ULIDOr`[:class:`BaseMessage`] The message. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to get message. - :class:`APIError` + HTTPException Getting the message failed. Returns ------- - :class:`messages.Message` + :class:`Message` The retrieved message. """ return self.state.parser.parse_message( await self.request( routes.CHANNELS_MESSAGE_FETCH.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), + channel_id=resolve_id(channel), + message_id=resolve_id(message), ) ) ) async def get_messages( self, - channel: core.ResolvableULID, + channel: ULIDOr[TextChannel], *, limit: int | None = None, - before: core.ResolvableULID | None = None, - after: core.ResolvableULID | None = None, - sort: messages.MessageSort | None = None, - nearby: core.ResolvableULID | None = None, + before: ULIDOr[BaseMessage] | None = None, + after: ULIDOr[BaseMessage] | None = None, + sort: MessageSort | None = None, + nearby: ULIDOr[BaseMessage] | None = None, populate_users: bool | None = None, - ) -> list[messages.Message]: + ) -> list[Message]: """|coro| - Get multiple messages. - https://developers.revolt.chat/api/#tag/Messaging/operation/message_query_req + Get multiple messages from the channel. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - limit: :class:`int` | None - Maximum number of messages to get. For getting nearby messages, this is `(limit + 1)`. - before: :class:`core.ResolvableULID` | None - Message id before which messages should be fetched. - after: :class:`core.ResolvableULID` | None - Message after which messages should be fetched. - sort: :class:`messages.MessageSort` | None - Message sort direction. - nearby: :class:`core.ResolvableULID` - Message id to search around. Specifying 'nearby' ignores 'before', 'after' and 'sort'. It will also take half of limit rounded as the limits to each side. It also fetches the message ID specified. + limit: Optional[:class:`int`] + Maximum number of messages to get. For getting nearby messages, this is ``(limit + 1)``. + before: Optional[:class:`ULIDOr`[:class:`BaseMessage`]] + The message before which messages should be fetched. + after: Optional[:class:`ULIDOr`[:class:`BaseMessage`]] + The message after which messages should be fetched. + sort: Optional[:class:`MessageSort`] + The message sort direction. + nearby: Optional[:class:`ULIDOr`[:class:`BaseMessage`]] + The message to search around. Specifying ``nearby`` ignores ``before``, ``after`` and ``sort``. It will also take half of limit rounded as the limits to each side. It also fetches the message ID specified. populate_users: :class:`bool` Whether to populate user (and member, if server channel) objects. - Returns - ------- - :class:`list`[:class:`messages.Message`] - Raises ------ - :class:`Forbidden` - You do not have permissions to get messages. - :class:`APIError` + Forbidden + You do not have permissions to get channel message history. + HTTPException Getting messages failed. + + Returns + ------- + List[:class:`Message`] + The messages retrieved. """ - p: raw.OptionsQueryMessages = {} + params: raw.OptionsQueryMessages = {} if limit is not None: - p["limit"] = limit + params['limit'] = limit if before is not None: - p["before"] = core.resolve_ulid(before) + params['before'] = resolve_id(before) if after is not None: - p["after"] = core.resolve_ulid(after) + params['after'] = resolve_id(after) if sort is not None: - p["sort"] = sort.value + params['sort'] = sort.value if nearby is not None: - p["nearby"] = core.resolve_ulid(nearby) + params['nearby'] = resolve_id(nearby) if populate_users is not None: - p["include_users"] = utils._bool(populate_users) - return self.state.parser.parse_messages( - await self.request( - routes.CHANNELS_MESSAGE_QUERY.compile( - channel_id=core.resolve_ulid(channel) - ), - params=p, - ) + params['include_users'] = utils._bool(populate_users) + + resp: raw.BulkMessageResponse = await self.request( + routes.CHANNELS_MESSAGE_QUERY.compile(channel_id=resolve_id(channel)), + params=params, ) + return self.state.parser.parse_messages(resp) async def add_reaction_to_message( self, - channel: core.ResolvableULID, - message: core.ResolvableULID, - emoji: emojis.ResolvableEmoji, + channel: ULIDOr[TextChannel], + message: ULIDOr[BaseMessage], + emoji: ResolvableEmoji, ) -> None: """|coro| React to a given message. - https://developers.revolt.chat/api/#tag/Interactions/operation/message_react_react_message Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel to message was sent. - message: :class:`core.ResolvableULID` - Message react to. - emoji: :class:`emojis.ResolvableEmoji` - Emoji to add. + channel: :class:`ULIDOr`[:class:`TextChannel`] + The channel. + message: :class:`ULIDOr`[:class:`BaseMessage`] + The message. + emoji: :class:`ResolvableEmoji` + The emoji to react with. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to react to message. - :class:`APIError` + HTTPException Reacting to message failed. """ await self.request( routes.CHANNELS_MESSAGE_REACT.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), - emoji=emojis.resolve_emoji(emoji), + channel_id=resolve_id(channel), + message_id=resolve_id(message), + emoji=resolve_emoji(emoji), ) ) async def search_for_messages( self, - channel: core.ResolvableULID, - query: str, + channel: ULIDOr[TextChannel], + query: str | None = None, *, + pinned: bool | None = None, limit: int | None = None, - before: core.ResolvableULID | None = None, - after: core.ResolvableULID | None = None, - sort: messages.MessageSort | None = None, + before: ULIDOr[BaseMessage] | None = None, + after: ULIDOr[BaseMessage] | None = None, + sort: MessageSort | None = None, populate_users: bool | None = None, - ) -> list[messages.Message]: + ) -> list[Message]: """|coro| - Searches for messages within the given parameters. - https://developers.revolt.chat/api/#tag/Messaging/operation/message_search_req + Searches for messages. .. note:: This can only be used by non-bot accounts. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel to search in. - query: :class:`str` + query: Optional[:class:`str`] Full-text search query. See [MongoDB documentation](https://docs.mongodb.com/manual/text-search/#-text-operator) for more information. - limit: :class:`int` + pinned: Optional[:class:`bool`] + Whether to search for (un-)pinned messages or not. + limit: Optional[:class:`int`] Maximum number of messages to fetch. - before: :class:`core.ResolvableULID` - Message ID before which messages should be fetched. - after: :class:`core.ResolvableULID` - Message ID after which messages should be fetched. - sort: :class:`messages.MessageSort` - Sort used for retrieving messages. - populate_users: :class:`bool` + before: Optional[:class:`ULIDOr`[:class:`BaseMessage`]] + The message before which messages should be fetched. + after: Optional[:class:`ULIDOr`[:class:`BaseMessage`]] + The message after which messages should be fetched. + sort: Optional[:class:`MessageSort`] + Sort used for retrieving. + populate_users: Optional[:class:`bool`] Whether to populate user (and member, if server channel) objects. - Returns - ------- - :class:`list`[:class:`messages.Message`] - The messages matched. - Raises ------ - :class:`Forbidden` - You do not have permissions to search messages. - :class:`APIError` + Forbidden + You do not have permissions to search + HTTPException Searching messages failed. + + Returns + ------- + List[:class:`Message`] + The messages matched. """ - j: raw.DataMessageSearch = {"query": query} + payload: raw.DataMessageSearch = {} + if query is not None: + payload['query'] = query + if pinned is not None: + payload['pinned'] = pinned if limit is not None: - j["limit"] = limit + payload['limit'] = limit if before is not None: - j["before"] = core.resolve_ulid(before) + payload['before'] = resolve_id(before) if after is not None: - j["after"] = core.resolve_ulid(after) + payload['after'] = resolve_id(after) if sort is not None: - j["sort"] = sort.value + payload['sort'] = sort.value if populate_users is not None: - j["include_users"] = utils._bool(populate_users) + payload['include_users'] = populate_users return self.state.parser.parse_messages( await self.request( - routes.CHANNELS_MESSAGE_SEARCH.compile( - channel_id=core.resolve_ulid(channel) - ), - json=j, + routes.CHANNELS_MESSAGE_SEARCH.compile(channel_id=resolve_id(channel)), + json=payload, ) ) - async def pin_message( - self, channel: core.ResolvableULID, message: core.ResolvableULID, / - ) -> None: + async def pin_message(self, channel: ULIDOr[TextChannel], message: ULIDOr[BaseMessage], /) -> None: """|coro| Pins a message. @@ -1036,126 +1293,116 @@ async def pin_message( Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - message: :class:`core.ResolvableULID` + message: :class:`ULIDOr`[:class:`BaseMessage`] The message. Raises ------ - :class:`Forbidden` - You do not have permissions to pin messages. - :class:`APIError` + Forbidden + You do not have permissions to pin message. + HTTPException Pinning the message failed. """ await self.request( routes.CHANNELS_MESSAGE_PIN.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), + channel_id=resolve_id(channel), + message_id=resolve_id(message), ) ) async def send_message( self, - channel: core.ResolvableULID, + channel: ULIDOr[TextChannel], content: str | None = None, *, nonce: str | None = None, - attachments: list[cdn.ResolvableResource] | None = None, - replies: list[messages.Reply | core.ResolvableULID] | None = None, - embeds: list[messages.SendableEmbed] | None = None, - masquerade: messages.Masquerade | None = None, - interactions: messages.Interactions | None = None, + attachments: list[ResolvableResource] | None = None, + replies: list[Reply | ULIDOr[BaseMessage]] | None = None, + embeds: list[SendableEmbed] | None = None, + masquerade: Masquerade | None = None, + interactions: Interactions | None = None, silent: bool | None = None, - ) -> messages.Message: + ) -> Message: """|coro| Sends a message to the given channel. You must have `SendMessages` permission. - https://developers.revolt.chat/api/#tag/Messaging/operation/message_send_message_send Parameters ---------- - channel: :class:`~ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - content: :class:`str` | None + content: Optional[:class:`str`] The message content. - nonce: :class:`str` | None + nonce: Optional[:class:`str`] The message nonce. - attachments: :class:`list`[:class:`~ResolvableResource`] | None + attachments: Optional[List[:class:`ResolvableResource`]] The message attachments. - replies: :class:`list`[:class:`~Reply` | :class:`~ResolvableULID`] | None + replies: Optional[List[Union[:class:`Reply`, :class:`ULIDOr`[:class:`BaseMessage`]]]] The message replies. - embeds: :class:`list`[:class:`~SendableEmbed`] | None + embeds: Optional[List[:class:`SendableEmbed`]] The message embeds. - masquearde: :class:`~Masquerade` | None + masquearde: Optional[:class:`Masquerade`] The message masquerade. - interactions: :class:`~Interactions` | None + interactions: Optional[:class:`Interactions`] The message interactions. - silent: :class:`bool` | None - Whether to suppress notifications when fanning out event. - - Returns - ------- - :class:`~Message` - The message that was sent. + silent: Optional[:class:`bool`] + Whether to suppress notifications or not. Raises ------ Forbidden - You do not have permissions to send messages. - APIError + You do not have permissions to send + HTTPException Sending the message failed. + + Returns + ------- + :class:`Message` + The message that was sent. """ - j: raw.DataMessageSend = {} + payload: raw.DataMessageSend = {} if content is not None: - j["content"] = content + payload['content'] = content if attachments is not None: - j["attachments"] = [ - await cdn.resolve_resource(self.state, attachment, tag="attachments") - for attachment in attachments + payload['attachments'] = [ + await resolve_resource(self.state, attachment, tag='attachments') for attachment in attachments ] if replies is not None: - j["replies"] = [ - ( - reply.build() - if isinstance(reply, messages.Reply) - else {"id": core.resolve_ulid(reply), "mention": False} - ) + payload['replies'] = [ + (reply.build() if isinstance(reply, Reply) else {'id': resolve_id(reply), 'mention': False}) for reply in replies ] if embeds is not None: - j["embeds"] = [await embed.build(self.state) for embed in embeds] + payload['embeds'] = [await embed.build(self.state) for embed in embeds] if masquerade is not None: - j["masquerade"] = masquerade.build() + payload['masquerade'] = masquerade.build() if interactions is not None: - j["interactions"] = interactions.build() + payload['interactions'] = interactions.build() flags = None if silent is not None: flags = 0 if silent: - flags |= messages.MessageFlags.SUPPRESS_NOTIFICATIONS.value + flags |= MessageFlags.SUPPRESS_NOTIFICATIONS if flags is not None: - j["flags"] = flags + payload['flags'] = flags headers = {} if nonce is not None: - headers["Idempotency-Key"] = nonce + headers['Idempotency-Key'] = nonce return self.state.parser.parse_message( await self.request( - routes.CHANNELS_MESSAGE_SEND.compile( - channel_id=core.resolve_ulid(channel) - ), - json=j, + routes.CHANNELS_MESSAGE_SEND.compile(channel_id=resolve_id(channel)), + json=payload, headers=headers, ) ) - async def unpin_message( - self, channel: core.ResolvableULID, message: core.ResolvableULID, / - ) -> None: + async def unpin_message(self, channel: ULIDOr[TextChannel], message: ULIDOr[BaseMessage], /) -> None: """|coro| Unpins a message. @@ -1163,356 +1410,387 @@ async def unpin_message( Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - message: :class:`core.ResolvableULID` + message: :class:`ULIDOr`[:class:`BaseMessage`] The message. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to unpin messages. - :class:`APIError` + HTTPException Unpinning the message failed. """ await self.request( routes.CHANNELS_MESSAGE_PIN.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), + channel_id=resolve_id(channel), + message_id=resolve_id(message), ) ) async def remove_reactions_from_message( self, - channel: core.ResolvableULID, - message: core.ResolvableULID, - emoji: emojis.ResolvableEmoji, + channel: ULIDOr[TextChannel], + message: ULIDOr[BaseUser], + emoji: ResolvableEmoji, /, *, - user: core.ResolvableULID | None = None, + user: ULIDOr[BaseUser] | None = None, remove_all: bool | None = None, ) -> None: """|coro| Remove your own, someone else's or all of a given reaction. Requires `ManageMessages` permission if changing other's reactions. - https://developers.revolt.chat/api/#tag/Interactions/operation/message_unreact_unreact_message - Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`TextChannel`] The channel. - message: :class:`core.ResolvableULID` + message: :class:`ULIDOr`[:class:`BaseMessage`] The message. emoji: :class:`ResolvableEmoji` - Emoji to remove. - user: :class:`core.ResolvableULID` | None + The emoji to remove. + user: Optional[:class:`ULIDOr`[:class:`BaseUser`]] Remove reactions from this user. Requires `ManageMessages` permission if provided. - remove_all: :class:`bool` | None + remove_all: Optional[:class:`bool`] Whether to remove all reactions. Requires `ManageMessages` permission if provided. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to remove reactions from message. - :class:`APIError` + HTTPException Removing reactions from message failed. """ - p: raw.OptionsUnreact = {} + params: raw.OptionsUnreact = {} if user is not None: - p["user_id"] = core.resolve_ulid(user) + params['user_id'] = resolve_id(user) if remove_all is not None: - p["remove_all"] = utils._bool(remove_all) + params['remove_all'] = utils._bool(remove_all) await self.request( routes.CHANNELS_MESSAGE_UNREACT.compile( - channel_id=core.resolve_ulid(channel), - message_id=core.resolve_ulid(message), - emoji=emojis.resolve_emoji(emoji), + channel_id=resolve_id(channel), + message_id=resolve_id(message), + emoji=resolve_emoji(emoji), ), - params=p, + params=params, ) - async def set_role_channel_permissions( + async def set_channel_permissions_for_role( self, - channel: core.ResolvableULID, - role: core.ResolvableULID, + channel: ULIDOr[ServerChannel], + role: ULIDOr[BaseRole], /, *, - allow: permissions_.Permissions = permissions_.Permissions.NONE, - deny: permissions_.Permissions = permissions_.Permissions.NONE, - ) -> channels.Channel: + allow: Permissions = Permissions.NONE, + deny: Permissions = Permissions.NONE, + ) -> ServerChannel: """|coro| - Sets permissions for the specified role in this channel. - Channel must be a `TextChannel` or `VoiceChannel`. - https://developers.revolt.chat/api/#tag/Channel-Permissions/operation/permissions_set_req + Sets permissions for the specified role in a channel. Channel must be a :class:`ServerChannel`. Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[:class:`ServerChannel`] The channel. - role: :class:`core.ResolvableULID` + role: :class:`ULIDOr`[:class:`BaseRole`] The role. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set role permissions on the channel. - :class:`APIError` + HTTPException Setting permissions failed. + + Returns + ------- + :class:`ServerChannel` + The updated server channel with new permissions. """ - j: raw.DataSetRolePermissions = { - "permissions": {"allow": int(allow), "deny": int(deny)} - } - return self.state.parser.parse_channel( + payload: raw.DataSetRolePermissions = {'permissions': {'allow': allow.value, 'deny': deny.value}} + r = self.state.parser.parse_channel( await self.request( routes.CHANNELS_PERMISSIONS_SET.compile( - channel_id=core.resolve_ulid(channel), - role_id=core.resolve_ulid(role), + channel_id=resolve_id(channel), + role_id=resolve_id(role), ), - json=j, + json=payload, ) ) + return r # type: ignore async def set_default_channel_permissions( self, - channel: core.ResolvableULID, - permissions: permissions_.Permissions | permissions_.PermissionOverride, + channel: ULIDOr[GroupChannel | ServerChannel], + permissions: Permissions | PermissionOverride, /, - ) -> channels.Channel: + ) -> GroupChannel | ServerChannel: """|coro| - Sets permissions for the default role in this channel. - Channel must be a `Group`, `TextChannel` or `VoiceChannel`. - https://developers.revolt.chat/api/#tag/Channel-Permissions/operation/permissions_set_default_req + Sets permissions for the default role in a channel. + Channel must be a :class:`GroupChannel`, or :class:`ServerChannel`. + + Parameters + ---------- + channel: :class:`ULIDOr`[Union[:class:`GroupChannel`, :class:`ServerChannel`]] + The channel. + permissions: Union[:class:`Permissions`, :class:`PermissionOverride`] + The new permissions. Should be :class:`Permissions` for groups and :class:`PermissionOverride` for server channels. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set default permissions on the channel. - :class:`APIError` + HTTPException Setting permissions failed. + + Returns + ------- + Union[:class:`GroupChannel`, :class:`ServerChannel`] + The updated group/server channel with new permissions. """ - j: raw.DataDefaultChannelPermissions = { - "permissions": ( - permissions.build() - if isinstance(permissions, permissions_.PermissionOverride) - else int(permissions) - ) + payload: raw.DataDefaultChannelPermissions = { + 'permissions': (permissions.build() if isinstance(permissions, PermissionOverride) else permissions.value) } - return self.state.parser.parse_channel( + r = self.state.parser.parse_channel( await self.request( - routes.CHANNELS_PERMISSIONS_SET_DEFAULT.compile( - channel_id=core.resolve_ulid(channel) - ), - json=j, + routes.CHANNELS_PERMISSIONS_SET_DEFAULT.compile(channel_id=resolve_id(channel)), + json=payload, ) ) + return r # type: ignore - async def join_call(self, channel: core.ResolvableULID) -> str: + async def join_call(self, channel: ULIDOr[DMChannel | GroupChannel | VoiceChannel]) -> str: """|coro| Asks the voice server for a token to join the call. - https://developers.revolt.chat/api/#tag/Voice/operation/voice_join_req Parameters ---------- - channel: :class:`core.ResolvableULID` + channel: :class:`ULIDOr`[Union[:class:`DMChannel`, :class:`GroupChannel`, :class:`VoiceChannel`]] The channel. + Raises + ------ + HTTPException + Asking for the token failed. + Returns ------- :class:`str` Token for authenticating with the voice server. - - Raises - ------ - :class:`APIError` - Asking the token failed. """ - return ( - await self.request( - routes.CHANNELS_VOICE_JOIN.compile( - channel_id=core.resolve_ulid(channel) - ) - ) - )["token"] + d: raw.LegacyCreateVoiceUserResponse = await self.request( + routes.CHANNELS_VOICE_JOIN.compile(channel_id=resolve_id(channel)) + ) + return d['token'] async def create_webhook( self, - channel: core.ResolvableULID, + channel: ULIDOr[GroupChannel | TextChannel], /, *, name: str, - avatar: cdn.ResolvableResource | None = None, - ) -> webhooks.Webhook: + avatar: ResolvableResource | None = None, + ) -> Webhook: """|coro| - Creates a webhook which 3rd party platforms can use to send messages. - https://developers.revolt.chat/api/#tag/Webhooks/operation/webhook_create_req + Creates a webhook which 3rd party platforms can use to send. Parameters ---------- + channel: :class:`ULIDOr`[Union[:class:`GroupChannel`, :class:`TextChannel`]] + The channel to create webhook in. name: :class:`str` The webhook name. Must be between 1 and 32 chars long. - avatar: :class:`cdn.ResolvableResource` | None` + avatar: Optional[:class:`ResolvableResource`] The webhook avatar. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create the webhook. - :class:`APIError` + HTTPException Creating the webhook failed. + + Returns + ------- + :class:`Webhook` + The created webhook. """ - j: raw.CreateWebhookBody = {"name": name} + payload: raw.CreateWebhookBody = {'name': name} if avatar is not None: - j["avatar"] = await cdn.resolve_resource(self.state, avatar, tag="avatars") + payload['avatar'] = await resolve_resource(self.state, avatar, tag='avatars') return self.state.parser.parse_webhook( await self.request( - routes.CHANNELS_WEBHOOK_CREATE.compile( - channel_id=core.resolve_ulid(channel) - ), - json=j, + routes.CHANNELS_WEBHOOK_CREATE.compile(channel_id=resolve_id(channel)), + json=payload, ) ) - async def get_channel_webhooks( - self, channel: core.ResolvableULID, / - ) -> list[webhooks.Webhook]: + async def get_channel_webhooks(self, channel: ULIDOr[ServerChannel], /) -> list[Webhook]: """|coro| - Gets all webhooks inside the channel. - https://developers.revolt.chat/api/#tag/Webhooks/operation/webhook_fetch_all_req + Gets the list of webhooks from this channel. Raises ------ - :class:`Forbidden` - You do not have permissions to get channel webhooks. - :class:`APIError` + Forbidden + You don't have permissions to get the webhooks. + HTTPException Getting channel webhooks failed. + + Returns + ------- + List[:class:`Webhook`] + The webhooks for this channel. """ return [ self.state.parser.parse_webhook(e) - for e in await self.request( - routes.CHANNELS_WEBHOOK_FETCH_ALL.compile( - channel_id=core.resolve_ulid(channel) - ) - ) + for e in await self.request(routes.CHANNELS_WEBHOOK_FETCH_ALL.compile(channel_id=resolve_id(channel))) ] # Customization control (emojis) async def create_emoji( self, - server_id: core.ResolvableULID, - data: cdn.ResolvableResource, + server: ULIDOr[BaseServer], + data: ResolvableResource, /, *, name: str, nsfw: bool | None = None, - ) -> emojis.ServerEmoji: + ) -> ServerEmoji: """|coro| Create an emoji on the server. - https://developers.revolt.chat/api/#tag/Emojis/operation/emoji_create_create_emoji .. note:: This can only be used by non-bot accounts. + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + data: :class:`ResolvableResource` + The emoji data. + name: :class:`str` + The emoji name. Must be at least 2 characters. + nsfw: Optional[:class:`bool`] + To mark the emoji as NSFW or not. + Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create emoji. - :class:`APIError` + HTTPException Creating the emoji failed. + + Returns + ------- + :class:`ServerEmoji` + The created emoji. """ - j: raw.DataCreateEmoji = { - "name": name, - "parent": {"type": "Server", "id": core.resolve_ulid(server_id)}, + payload: raw.DataCreateEmoji = { + 'name': name, + 'parent': {'type': 'Server', 'id': resolve_id(server)}, } if nsfw is not None: - j["nsfw"] = nsfw + payload['nsfw'] = nsfw return self.state.parser.parse_server_emoji( await self.request( routes.CUSTOMISATION_EMOJI_CREATE.compile( - attachment_id=await cdn.resolve_resource( - self.state, data, tag="emojis" - ) + attachment_id=await resolve_resource(self.state, data, tag='emojis') ), - json=j, + json=payload, ) ) - async def delete_emoji(self, emoji: core.ResolvableULID, /) -> None: + async def delete_emoji(self, emoji: ULIDOr[ServerEmoji], /) -> None: """|coro| - Delete an emoji. - https://developers.revolt.chat/api/#tag/Emojis/operation/emoji_delete_delete_emoji + Deletes a emoji. + + Parameters + ---------- + emoji: :class:`ULIDOr`[:class:`ServerEmoji`] + The emoji to delete. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete emojis. - :class:`APIError` + HTTPException Deleting the emoji failed. """ - await self.request( - routes.CUSTOMISATION_EMOJI_DELETE.compile(emoji_id=core.resolve_ulid(emoji)) - ) + await self.request(routes.CUSTOMISATION_EMOJI_DELETE.compile(emoji_id=resolve_id(emoji))) - async def get_emoji(self, emoji: core.ResolvableULID, /) -> emojis.Emoji: + async def get_emoji(self, emoji: ULIDOr[BaseEmoji], /) -> Emoji: """|coro| - Get an emoji. - https://developers.revolt.chat/api/#tag/Emojis/operation/emoji_fetch_fetch_emoji + Retrieves a custom emoji. + + Parameters + ---------- + emoji: :class:`ULIDOr`[:class:`BaseEmoji`] + The emoji. Raises ------ - :class:`APIError` - Getting the emoji failed. + HTTPException + An error occurred fetching the emoji. + + Returns + ------- + :class:`Emoji` + The retrieved emoji. """ return self.state.parser.parse_emoji( - await self.request( - routes.CUSTOMISATION_EMOJI_FETCH.compile( - emoji_id=core.resolve_ulid(emoji) - ) - ) + await self.request(routes.CUSTOMISATION_EMOJI_FETCH.compile(emoji_id=resolve_id(emoji))) ) # Invites control - async def delete_invite(self, invite_code: str, /) -> None: + async def delete_invite(self, code: str | BaseInvite, /) -> None: """|coro| - Delete an invite. - https://developers.revolt.chat/api/#tag/Invites/operation/invite_delete_req + Deletes a invite. + + Parameters + ---------- + code: Union[:class:`str`, :class:`BaseInvite`] + The invite code. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete invite or not creator of that invite. - :class:`APIError` + HTTPException Deleting the invite failed. """ - await self.request( - routes.INVITES_INVITE_DELETE.compile(invite_code=invite_code) - ) + invite_code = code.code if isinstance(code, BaseInvite) else code + await self.request(routes.INVITES_INVITE_DELETE.compile(invite_code=invite_code)) - async def get_invite(self, invite_code: str, /) -> invites.BaseInvite: + async def get_invite(self, code: str | BaseInvite, /) -> BaseInvite: """|coro| - Get an invite. - https://developers.revolt.chat/api/#tag/Invites/operation/invite_fetch_req + Gets an invite. + Parameters + ---------- + code: Union[:class:`str`, :class:`BaseInvite`] + The invite code. Raises ------ - :class:`NotFound` - Invalid invite code. - :class:`APIError` + NotFound + The invite is invalid. + HTTPException Getting the invite failed. """ + invite_code = code.code if isinstance(code, BaseInvite) else code return self.state.parser.parse_public_invite( await self.request( routes.INVITES_INVITE_FETCH.compile(invite_code=invite_code), @@ -1520,51 +1798,64 @@ async def get_invite(self, invite_code: str, /) -> invites.BaseInvite: ) ) - async def accept_invite( - self, invite_code: str, / - ) -> servers.Server | channels.GroupChannel: + async def accept_invite(self, code: str | BaseInvite, /) -> Server | GroupChannel: """|coro| - Accept an invite. - https://developers.revolt.chat/api/#tag/Invites/operation/invite_join_req + Accepts an invite. .. note:: This can only be used by non-bot accounts. + Parameters + ---------- + code: Union[:class:`str`, :class:`BaseInvite`] + The invite code. + Raises ------ - :class:`Forbidden` + Forbidden You're banned. - :class:`APIError` + HTTPException Accepting the invite failed. + + Returns + ------- + Union[:class:`Server`, :class:`GroupChannel`] + The joined server or group. """ - d: raw.InviteJoinResponse = await self.request( - routes.INVITES_INVITE_JOIN.compile(invite_code=invite_code) - ) - if d["type"] == "Server": + invite_code = code.code if isinstance(code, BaseInvite) else code + resp: raw.InviteJoinResponse = await self.request(routes.INVITES_INVITE_JOIN.compile(invite_code=invite_code)) + if resp['type'] == 'Server': return self.state.parser.parse_server( - d["server"], - (False, d["channels"]), + resp['server'], + (False, resp['channels']), ) - elif d["type"] == "Group": + elif resp['type'] == 'Group': return self.state.parser.parse_group_channel( - d["channel"], - (False, [self.state.parser.parse_user(u) for u in d["users"]]), + resp['channel'], + (False, [self.state.parser.parse_user(u) for u in resp['users']]), ) else: - _L.error("Invalid payload: %s", d) - raise NotImplemented + raise NotImplementedError(resp) # Onboarding control - async def complete_onboarding(self, username: str, /) -> users.User: + async def complete_onboarding(self, username: str, /) -> OwnUser: """|coro| Set a new username, complete onboarding and allow a user to start using Revolt. + + Parameters + ---------- + username: :class:`str` + The username to use. + + Returns + ------- + :class:`OwnUser` + The updated user. """ - j: raw.DataOnboard = {"username": username} - return self.state.parser.parse_user( - await self.request(routes.ONBOARD_COMPLETE.compile(), json=j) - ) + payload: raw.DataOnboard = {'username': username} + return self.state.parser.parse_own_user(await self.request(routes.ONBOARD_COMPLETE.compile(), json=payload)) async def onboarding_status(self) -> bool: """|coro| @@ -1573,7 +1864,7 @@ async def onboarding_status(self) -> bool: You may skip calling this if you're restoring an existing session. """ d: raw.DataHello = await self.request(routes.ONBOARD_HELLO.compile()) - return d["onboarding"] + return d['onboarding'] # Web Push control async def push_subscribe(self, *, endpoint: str, p256dh: str, auth: str) -> None: @@ -1581,14 +1872,14 @@ async def push_subscribe(self, *, endpoint: str, p256dh: str, auth: str) -> None Create a new Web Push subscription. If an subscription already exists on this session, it will be removed. """ - j: raw.a.WebPushSubscription = { - "endpoint": endpoint, - "p256dh": p256dh, - "auth": auth, + payload: raw.a.WebPushSubscription = { + 'endpoint': endpoint, + 'p256dh': p256dh, + 'auth': auth, } await self.request( routes.PUSH_SUBSCRIBE.compile(), - json=j, + json=payload, ) async def unsubscribe(self) -> None: @@ -1599,44 +1890,43 @@ async def unsubscribe(self) -> None: await self.request(routes.PUSH_UNSUBSCRIBE.compile()) # Safety control - async def _report_content(self, j: raw.DataReportContent, /) -> None: - await self.request(routes.SAFETY_REPORT_CONTENT.compile(), json=j) + async def _report_content(self, payload: raw.DataReportContent, /) -> None: + await self.request(routes.SAFETY_REPORT_CONTENT.compile(), json=payload) async def report_message( self, - message: core.ResolvableULID, - reason: safety_reports.ContentReportReason, + message: ULIDOr[BaseMessage], + reason: ContentReportReason, *, additional_context: str | None = None, ) -> None: """|coro| Report a piece of content to the moderation team. - https://developers.revolt.chat/developers/api/reference.html#tag/user-safety/post/safety/report .. note:: This can only be used by non-bot accounts. Raises ------ - :class:`APIError` + HTTPException Trying to self-report, or reporting the message failed. """ - j: raw.DataReportContent = { - "content": { - "type": "Message", - "id": core.resolve_ulid(message), - "report_reason": reason.value, + payload: raw.DataReportContent = { + 'content': { + 'type': 'Message', + 'id': resolve_id(message), + 'report_reason': reason.value, } } if additional_context is not None: - j["additional_context"] = additional_context - await self._report_content(j) + payload['additional_context'] = additional_context + await self._report_content(payload) async def report_server( self, - server: core.ResolvableULID, - reason: safety_reports.ContentReportReason, + server: ULIDOr[BaseServer], + reason: ContentReportReason, /, *, additional_context: str | None = None, @@ -1644,561 +1934,643 @@ async def report_server( """|coro| Report a piece of content to the moderation team. - https://developers.revolt.chat/developers/api/reference.html#tag/user-safety/post/safety/report .. note:: This can only be used by non-bot accounts. Raises ------ - :class:`APIError` + HTTPException You're trying to self-report, or reporting the server failed. """ - j: raw.DataReportContent = { - "content": { - "type": "Server", - "id": core.resolve_ulid(server), - "report_reason": reason.value, + payload: raw.DataReportContent = { + 'content': { + 'type': 'Server', + 'id': resolve_id(server), + 'report_reason': reason.value, } } if additional_context is not None: - j["additional_context"] = additional_context - await self._report_content(j) + payload['additional_context'] = additional_context + await self._report_content(payload) async def report_user( self, - user: core.ResolvableULID, - reason: safety_reports.UserReportReason, + user: ULIDOr[BaseUser], + reason: UserReportReason, /, *, additional_context: str | None = None, - message_context: core.ResolvableULID, + message_context: ULIDOr[BaseMessage], ) -> None: """|coro| Report a piece of content to the moderation team. - https://developers.revolt.chat/developers/api/reference.html#tag/user-safety/post/safety/report .. note:: This can only be used by non-bot accounts. Raises ------ - :class:`APIError` + HTTPException You're trying to self-report, or reporting the user failed. """ content: raw.UserReportedContent = { - "type": "User", - "id": core.resolve_ulid(user), - "report_reason": reason.value, + 'type': 'User', + 'id': resolve_id(user), + 'report_reason': reason.value, } if message_context is not None: - content["message_id"] = core.resolve_ulid(message_context) + content['message_id'] = resolve_id(message_context) - j: raw.DataReportContent = {"content": content} + payload: raw.DataReportContent = {'content': content} if additional_context is not None: - j["additional_context"] = additional_context + payload['additional_context'] = additional_context - await self._report_content(j) + await self._report_content(payload) # Servers control - async def ban_user( + async def ban( self, - server: core.ResolvableULID, - user: core.ResolvableULID, + server: ULIDOr[BaseServer], + user: str | BaseUser | BaseMember, /, *, reason: str | None = None, - ) -> servers.Ban: + ) -> Ban: """|coro| - Ban a user by their ID. - https://developers.revolt.chat/api/#tag/Server-Members/operation/ban_create_req + Bans a user from the server. Parameters ---------- - reason: :class:`str` | None - Ban reason. Should be between 1 and 1024 chars long. + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + user: Union[:class:`str`, :class:`BaseUser`, :class:`BaseMember`] + The user to ban from the server. + reason: Optional[:class:`str`] + The ban reason. Should be between 1 and 1024 chars long. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to ban the user. - :class:`APIError` + HTTPException Banning the user failed. """ - j: raw.DataBanCreate = {"reason": reason} + payload: raw.DataBanCreate = {'reason': reason} return self.state.parser.parse_ban( await self.request( - routes.SERVERS_BAN_CREATE.compile( - server_id=core.resolve_ulid(server), user_id=core.resolve_ulid(user) - ), - json=j, + routes.SERVERS_BAN_CREATE.compile(server_id=resolve_id(server), user_id=resolve_id(user)), + json=payload, ), {}, ) - async def get_bans(self, server: core.ResolvableULID, /) -> list[servers.Ban]: + async def get_bans(self, server: ULIDOr[BaseServer], /) -> list[Ban]: """|coro| - Get all bans on a server. - https://developers.revolt.chat/api/#tag/Server-Members/operation/ban_list_req + Retrieves all bans on a server. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + + Returns + ------- + List[:class:`Ban`] + The ban entries. """ return self.state.parser.parse_bans( - await self.request( - routes.SERVERS_BAN_LIST.compile(server_id=core.resolve_ulid(server)) - ) + await self.request(routes.SERVERS_BAN_LIST.compile(server_id=resolve_id(server))) ) - async def unban_user( - self, server: core.ResolvableULID, user: core.ResolvableULID - ) -> None: + async def unban(self, server: ULIDOr[BaseServer], user: ULIDOr[BaseUser]) -> None: """|coro| - Remove a user's ban. - https://developers.revolt.chat/api/#tag/Server-Members/operation/ban_remove_req + Unbans a user from the server. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + user: :class:`ULIDOr`[:class:`BaseUser`] + The user to unban from the server. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to unban the user. - :class:`APIError` + HTTPException Unbanning the user failed. """ await self.request( - routes.SERVERS_BAN_REMOVE.compile( - server_id=core.resolve_ulid(server), user_id=core.resolve_ulid(user) - ), + routes.SERVERS_BAN_REMOVE.compile(server_id=resolve_id(server), user_id=resolve_id(user)), ) - async def create_channel( + async def create_server_channel( self, - server: core.ResolvableULID, + server: ULIDOr[BaseServer], /, *, - type: channels.ChannelType | None = None, + type: ChannelType | None = None, name: str, description: str | None = None, nsfw: bool | None = None, - ) -> channels.ServerChannel: + ) -> ServerChannel: """|coro| Create a new text or voice channel within server. - https://developers.revolt.chat/api/#tag/Server-Information/operation/channel_create_req + + Parameters + ---------- + type: Optional[:class:`ChannelType`] + The channel type. Defaults to :attr:`ChannelType.text` if not provided. + name: :class:`str` + The channel name. + description: Optional[:class:`str`] + The channel description. + nsfw: Optional[:class:`bool`] + To mark channel as NSFW or not. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create the channel. - :class:`APIError` + HTTPException Creating the channel failed. """ - j: raw.DataCreateServerChannel = {"name": name} + payload: raw.DataCreateServerChannel = {'name': name} if type is not None: - j["type"] = type.value + payload['type'] = type.value if description is not None: - j["description"] = description + payload['description'] = description if nsfw is not None: - j["nsfw"] = nsfw + payload['nsfw'] = nsfw channel = self.state.parser.parse_channel( await self.request( - routes.SERVERS_CHANNEL_CREATE.compile( - server_id=core.resolve_ulid(server) - ), - json=j, + routes.SERVERS_CHANNEL_CREATE.compile(server_id=resolve_id(server)), + json=payload, ) ) - assert isinstance(channel, channels.ServerChannel) + assert isinstance(channel, ServerChannel) return channel - async def get_server_emojis( - self, server: core.ResolvableULID - ) -> list[emojis.ServerEmoji]: + async def get_server_emojis(self, server: ULIDOr[BaseServer]) -> list[ServerEmoji]: """|coro| - Get all emojis on a server. + Retrieves all custom :class:`ServerEmoji`s from the server. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + + Returns + ------- + List[:class:`ServerEmoji`] + The retrieved emojis. """ return [ self.state.parser.parse_server_emoji(e) - for e in await self.request( - routes.SERVERS_EMOJI_LIST.compile(server_id=core.resolve_ulid(server)) - ) + for e in await self.request(routes.SERVERS_EMOJI_LIST.compile(server_id=resolve_id(server))) ] - async def get_invites( - self, server: core.ResolvableULID, / - ) -> list[invites.ServerInvite]: + async def get_server_invites(self, server: ULIDOr[BaseServer], /) -> list[ServerInvite]: """|coro| - Get all server invites. + Returns a list of all invites from the server. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to manage the server. - :class:`APIError` + HTTPException Getting the invites failed. + + Returns + ------- + List[:class:`ServerInvite`] + The retrieved invites. """ return [ self.state.parser.parse_server_invite(i) - for i in await self.request( - routes.SERVERS_INVITES_FETCH.compile( - server_id=core.resolve_ulid(server) - ) - ) + for i in await self.request(routes.SERVERS_INVITES_FETCH.compile(server_id=resolve_id(server))) ] async def edit_member( self, - server: core.ResolvableULID, - member: core.ResolvableULID, + server: ULIDOr[BaseServer], + member: str | BaseUser | BaseMember, /, - nickname: core.UndefinedOr[str | None] = core.UNDEFINED, - avatar: core.UndefinedOr[str | None] = core.UNDEFINED, - roles: core.UndefinedOr[list[core.ResolvableULID] | None] = core.UNDEFINED, - timeout: core.UndefinedOr[ - datetime | timedelta | float | int | None - ] = core.UNDEFINED, - ) -> servers.Member: - j: raw.DataMemberEdit = {} - r: list[raw.FieldsMember] = [] - if core.is_defined(nickname): - if nickname is not None: - j["nickname"] = nickname + *, + nick: UndefinedOr[str | None] = UNDEFINED, + avatar: UndefinedOr[ResolvableResource | None] = UNDEFINED, + roles: UndefinedOr[list[ULIDOr[BaseRole]] | None] = UNDEFINED, + timeout: UndefinedOr[datetime | timedelta | float | int | None] = UNDEFINED, + ) -> Member: + """|coro| + + Edits the member. + + Parameters + ---------- + server: :class:`BaseServer` + The server. + member: Union[:class:`str`, :class:`BaseUser`, :class:`BaseMember`] + The member. + nick: :class:`UndefinedOr`[Optional[:class:`str`]] + The member's new nick. Use ``None`` to remove the nickname. + avatar: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + The member's new avatar. Use ``None`` to remove the avatar. You can only change your own server avatar. + roles: :class:`UndefinedOr`[Optional[List[:class:`BaseRole`]]] + The member's new list of roles. This *replaces* the roles. + timeout: :class:`UndefinedOr`[Optional[Union[:class:`datetime`, :class:`timedelta`, :class:`float`, :class:`int`]]] + The duration/date the member's timeout should expire, or ``None`` to remove the timeout. + This must be a timezone-aware datetime object. Consider using :func:`utils.utcnow()`. + + Returns + ------- + :class:`Member` + The newly updated member. + """ + payload: raw.DataMemberEdit = {} + remove: list[raw.FieldsMember] = [] + if nick is not UNDEFINED: + if nick is not None: + payload['nickname'] = nick else: - r.append("Nickname") - if core.is_defined(avatar): + remove.append('Nickname') + if avatar is not UNDEFINED: if avatar is not None: - j["avatar"] = avatar + payload['avatar'] = await resolve_resource(self.state, avatar, tag='avatars') else: - r.append("Avatar") - if core.is_defined(roles): + remove.append('Avatar') + if roles is not UNDEFINED: if roles is not None: - j["roles"] = [core.resolve_ulid(e) for e in roles] + payload['roles'] = [resolve_id(e) for e in roles] else: - r.append("Roles") - if core.is_defined(timeout): + remove.append('Roles') + if timeout is not UNDEFINED: if timeout is None: - r.append("Timeout") + remove.append('Timeout') elif isinstance(timeout, datetime): - j["timeout"] = timeout.isoformat() + payload['timeout'] = timeout.isoformat() elif isinstance(timeout, timedelta): - j["timeout"] = (datetime.now() + timeout).isoformat() + payload['timeout'] = (datetime.now() + timeout).isoformat() elif isinstance(timeout, (float, int)): - j["timeout"] = (datetime.now() + timedelta(seconds=timeout)).isoformat() - if len(r) > 0: - j["remove"] = r - return self.state.parser.parse_member( - await self.request( - routes.SERVERS_MEMBER_EDIT.compile( - server_id=core.resolve_ulid(server), - member_id=core.resolve_ulid(member), - ), - json=j, - ) + payload['timeout'] = (datetime.now() + timedelta(seconds=timeout)).isoformat() + if len(remove) > 0: + payload['remove'] = remove + + resp: raw.Member = await self.request( + routes.SERVERS_MEMBER_EDIT.compile( + server_id=resolve_id(server), + member_id=resolve_id(member), + ), + json=payload, ) + return self.state.parser.parse_member(resp) - async def query_members_by_name( - self, server: core.ResolvableULID, query: str, / - ) -> list[servers.Member]: + async def query_members_by_name(self, server: ULIDOr[BaseServer], query: str, /) -> list[Member]: """|coro| Query members by a given name, this API is not stable and will be removed in the future. - https://developers.revolt.chat/api/#tag/Server-Members/operation/member_experimental_query_member_experimental_query + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + query: :class:`str` + The query to search members for. + + Returns + ------- + List[:class:`Member`] + The members matched. """ return self.state.parser.parse_members_with_users( await self.request( - routes.SERVERS_MEMBER_EXPERIMENTAL_QUERY.compile( - server_id=core.resolve_ulid(server) - ), + routes.SERVERS_MEMBER_EXPERIMENTAL_QUERY.compile(server_id=resolve_id(server)), params={ - "query": query, - "experimental_api": "true", + 'query': query, + 'experimental_api': 'true', }, ) ) async def get_member( - self, server: core.ResolvableULID, member: core.ResolvableULID, / - ) -> servers.Member: + self, + server: ULIDOr[BaseServer], + member: str | BaseUser | BaseMember, + /, + ) -> Member: """|coro| - Retrieve a member. - https://developers.revolt.chat/api/#tag/Server-Members/operation/member_fetch_req + Retrieves a Member from a server ID, and a user ID. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + member: Union[:class:`str`, :class:`BaseUser`, :class:`BaseMember`] + The ID of the user. + + Returns + ------- + :class:`Member` + The retrieved member. """ - return self.state.parser.parse_member( - await self.request( - routes.SERVERS_MEMBER_FETCH.compile( - server_id=core.resolve_ulid(server), - member_id=core.resolve_ulid(member), - ) + resp: raw.Member = await self.request( + routes.SERVERS_MEMBER_FETCH.compile( + server_id=resolve_id(server), + member_id=resolve_id(member), ) ) + return self.state.parser.parse_member(resp) - async def get_members( - self, server: core.ResolvableULID, /, *, exclude_offline: bool | None = None - ) -> list[servers.Member]: + async def get_members(self, server: ULIDOr[BaseServer], /, *, exclude_offline: bool | None = None) -> list[Member]: """|coro| - Retrieve all server members. - https://developers.revolt.chat/api/#tag/Server-Members/operation/member_fetch_all_req + Retrieves all server members. Parameters ---------- - exclude_offline: :class:`bool` | None + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + exclude_offline: Optional[:class:`bool`] Whether to exclude offline users. + + Returns + ------- + List[:class:`Member`] + The retrieved members. """ - p: raw.OptionsFetchAllMembers = {} + params: raw.OptionsFetchAllMembers = {} if exclude_offline is not None: - p["exclude_offline"] = utils._bool(exclude_offline) - return self.state.parser.parse_members_with_users( - await self.request( - routes.SERVERS_MEMBER_FETCH_ALL.compile( - server_id=core.resolve_ulid(server) - ), - params=p, - ) + params['exclude_offline'] = utils._bool(exclude_offline) + resp: raw.AllMemberResponse = await self.request( + routes.SERVERS_MEMBER_FETCH_ALL.compile(server_id=resolve_id(server)), + log=False, + params=params, ) + return self.state.parser.parse_members_with_users(resp) async def get_member_list( - self, server: core.ResolvableULID, /, *, exclude_offline: bool | None = None - ) -> servers.MemberList: + self, server: ULIDOr[BaseServer], /, *, exclude_offline: bool | None = None + ) -> MemberList: """|coro| - Retrieve server members list. - https://developers.revolt.chat/api/#tag/Server-Members/operation/member_fetch_all_req + Retrieves server members list. Parameters ---------- - exclude_offline: :class:`bool` | None + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + exclude_offline: Optional[:class:`bool`] Whether to exclude offline users. + + Returns + ------- + :class:`MemberList` + The member list. """ - p: raw.OptionsFetchAllMembers = {} + params: raw.OptionsFetchAllMembers = {} if exclude_offline is not None: - p["exclude_offline"] = utils._bool(exclude_offline) - return self.state.parser.parse_member_list( - await self.request( - routes.SERVERS_MEMBER_FETCH_ALL.compile( - server_id=core.resolve_ulid(server) - ), - params=p, - ) + params['exclude_offline'] = utils._bool(exclude_offline) + resp: raw.AllMemberResponse = await self.request( + routes.SERVERS_MEMBER_FETCH_ALL.compile(server_id=resolve_id(server)), + log=False, + params=params, ) + return self.state.parser.parse_member_list(resp) - async def kick_member( - self, server: core.ResolvableULID, member: core.ResolvableULID, / - ) -> None: + async def kick_member(self, server: ULIDOr[BaseServer], member: str | BaseUser | BaseMember, /) -> None: """|coro| Removes a member from the server. - https://developers.revolt.chat/api/#tag/Server-Members/operation/member_remove_req + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + member: :class:`Union`[:class:`str`, :class:`BaseUser`, :class:`BaseMember`] + The member to kick. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to kick the member. - :class:`APIError` + HTTPException Kicking the member failed. """ await self.request( - routes.SERVERS_MEMBER_REMOVE.compile( - server_id=core.resolve_ulid(server), member_id=core.resolve_ulid(member) - ) + routes.SERVERS_MEMBER_REMOVE.compile(server_id=resolve_id(server), member_id=resolve_id(member)) ) - async def set_role_server_permissions( + async def set_server_permissions_for_role( self, - server: core.ResolvableULID, - role: core.ResolvableULID, + server: ULIDOr[BaseServer], + role: ULIDOr[BaseRole], /, *, - allow: permissions_.Permissions = permissions_.Permissions.NONE, - deny: permissions_.Permissions = permissions_.Permissions.NONE, - ) -> servers.Server: + allow: Permissions = Permissions.NONE, + deny: Permissions = Permissions.NONE, + ) -> Server: """|coro| Sets permissions for the specified role in the server. - https://developers.revolt.chat/api/#tag/Server-Permissions/operation/permissions_set_req Parameters ---------- - allow: :class:`permissions.Permissions` - New allow bit flags. - deny: :class:`permissions.Permissions` - New deny bit flags. + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + role: :class:`ULIDOr`[:class:`BaseRole`] + The role. + allow: :class:`Permissions` + New allow flags. + deny: :class:`Permissions` + New deny flags. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set role permissions on the server. - :class:`APIError` + HTTPException Setting permissions failed. + + Returns + ------- + :class:`Server` + The newly updated server. """ - j: raw.DataSetRolePermissions = { - "permissions": {"allow": int(allow), "deny": int(deny)} - } - d: raw.Server = await self.request( - routes.SERVERS_PERMISSIONS_SET.compile( - server_id=core.resolve_ulid(server), role_id=core.resolve_ulid(role) - ), - json=j, + payload: raw.DataSetRolePermissions = {'permissions': {'allow': allow.value, 'deny': deny.value}} + resp: raw.Server = await self.request( + routes.SERVERS_PERMISSIONS_SET.compile(server_id=resolve_id(server), role_id=resolve_id(role)), + json=payload, ) - parser = self.state.parser - - return parser.parse_server( - d, (True, [parser.parse_id(channel) for channel in d["channels"]]) - ) + return self.state.parser.parse_server(resp, (True, resp['channels'])) - async def set_default_role_permissions( + async def set_default_server_permissions( self, - server: core.ResolvableULID, - permissions: permissions_.Permissions | permissions_.PermissionOverride, + server: ULIDOr[BaseServer], + permissions: Permissions, /, - ) -> servers.Server: + ) -> Server: """|coro| Sets permissions for the default role in this server. - https://developers.revolt.chat/api/#tag/Server-Permissions/operation/permissions_set_default_req + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + permissions: :class:`Permissions` + New default permissions. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set default permissions on the server. - :class:`APIError` + HTTPException Setting permissions failed. - """ - j: raw.DataDefaultChannelPermissions = { - "permissions": ( - permissions.build() - if isinstance(permissions, permissions_.PermissionOverride) - else int(permissions) - ) - } + + Returns + ------- + :class:`Server` + The newly updated server. + """ + payload: raw.DataPermissionsValue = {'permissions': permissions.value} d: raw.Server = await self.request( - routes.SERVERS_PERMISSIONS_SET_DEFAULT.compile( - server_id=core.resolve_ulid(server) - ), - json=j, + routes.SERVERS_PERMISSIONS_SET_DEFAULT.compile(server_id=resolve_id(server)), + json=payload, ) return self.state.parser.parse_server( - d, (True, [self.state.parser.parse_id(c) for c in d["channels"]]) + d, + (True, d['channels']), ) - async def create_role( - self, server: core.ResolvableULID, /, *, name: str, rank: int | None = None - ) -> servers.Role: + async def create_role(self, server: ULIDOr[BaseServer], /, *, name: str, rank: int | None = None) -> Role: """|coro| Creates a new server role. - https://developers.revolt.chat/api/#tag/Server-Permissions/operation/roles_create_req - Parameters ---------- - name: :class:`str` | None - Role name. Should be between 1 and 32 chars long. - rank: :class:`int` | None - Ranking position. Smaller values take priority. + name: :class:`str` + The role name. Should be between 1 and 32 chars long. + rank: Optional[:class:`int`] + The ranking position. Smaller values take priority. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create the role. - :class:`APIError` + HTTPException Creating the role failed. """ - server_id = core.resolve_ulid(server) - j: raw.DataCreateRole = {"name": name, "rank": rank} + server_id = resolve_id(server) + payload: raw.DataCreateRole = {'name': name, 'rank': rank} d: raw.NewRoleResponse = await self.request( routes.SERVERS_ROLES_CREATE.compile(server_id=server_id), - json=j, - ) - return self.state.parser.parse_role( - d["role"], self.state.parser.parse_id(d["id"]), server_id + json=payload, ) + return self.state.parser.parse_role(d['role'], d['id'], server_id) - async def delete_role( - self, server: core.ResolvableULID, role: core.ResolvableULID, / - ) -> None: + async def delete_role(self, server: ULIDOr[BaseServer], role: ULIDOr[BaseRole], /) -> None: """|coro| - Delete a server role. - https://developers.revolt.chat/api/#tag/Server-Permissions/operation/roles_delete_req + Deletes a server role. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + role: :class:`ULIDOr`[:class:`BaseRole`] + The role to delete. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete the role. - :class:`APIError` + HTTPException Deleting the role failed. """ - await self.request( - routes.SERVERS_ROLES_DELETE.compile( - server_id=core.resolve_ulid(server), role_id=core.resolve_ulid(role) - ) - ) + await self.request(routes.SERVERS_ROLES_DELETE.compile(server_id=resolve_id(server), role_id=resolve_id(role))) async def edit_role( self, - server: core.ResolvableULID, - role: core.ResolvableULID, + server: ULIDOr[BaseServer], + role: ULIDOr[BaseRole], /, *, - name: core.UndefinedOr[str] = core.UNDEFINED, - colour: core.UndefinedOr[str | None] = core.UNDEFINED, - hoist: core.UndefinedOr[bool] = core.UNDEFINED, - rank: core.UndefinedOr[int] = core.UNDEFINED, - ) -> servers.Role: + name: UndefinedOr[str] = UNDEFINED, + colour: UndefinedOr[str | None] = UNDEFINED, + hoist: UndefinedOr[bool] = UNDEFINED, + rank: UndefinedOr[int] = UNDEFINED, + ) -> Role: """|coro| - Edit a role. - https://developers.revolt.chat/api/#tag/Server-Permissions/operation/roles_edit_req + Edits the role. Parameters ---------- - name: :class:`core.UndefinedOr`[:class:`str`] + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + role: :class:`ULIDOr`[:class:`BaseRole`] + The role to edit. + name: :class:`UndefinedOr`[:class:`str`] New role name. Should be between 1 and 32 chars long. - colour: :class:`core.UndefinedOr`[:class:`str` | None] - New role colour. - hoist: :class:`core.UndefinedOr`[:class:`bool`] + colour: :class:`UndefinedOr`[Optional[:class:`str`]] + New role colour. This should be valid CSS colour. + hoist: :class:`UndefinedOr`[:class:`bool`] Whether this role should be displayed separately. - rank: :class:`core.UndefinedOr`[:class:`int`] - Ranking position. Smaller values take priority. + rank: :class:`UndefinedOr`[:class:`int`] + The new ranking position. Smaller values take priority. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the role. - :class:`APIError` + HTTPException Editing the role failed. - """ - j: raw.DataEditRole = {} - r: list[raw.FieldsRole] = [] - if core.is_defined(name): - j["name"] = name - if core.is_defined(colour): + + Returns + ------- + :class:`Role` + The newly updated role. + """ + payload: raw.DataEditRole = {} + remove: list[raw.FieldsRole] = [] + if name is not UNDEFINED: + payload['name'] = name + if colour is not UNDEFINED: if colour is not None: - j["colour"] = colour + payload['colour'] = colour else: - r.append("Colour") - if core.is_defined(hoist): - j["hoist"] = hoist - if core.is_defined(rank): - j["rank"] = rank - if len(r) > 0: - j["remove"] = r + remove.append('Colour') + if hoist is not UNDEFINED: + payload['hoist'] = hoist + if rank is not UNDEFINED: + payload['rank'] = rank + if len(remove) > 0: + payload['remove'] = remove - server_id = core.resolve_ulid(server) - role_id = core.resolve_ulid(role) + server_id = resolve_id(server) + role_id = resolve_id(role) return self.state.parser.parse_role( await self.request( - routes.SERVERS_ROLES_EDIT.compile( - server_id=core.resolve_ulid(server), role_id=core.resolve_ulid(role) - ), - json=j, + routes.SERVERS_ROLES_EDIT.compile(server_id=resolve_id(server), role_id=resolve_id(role)), + json=payload, ), role_id, server_id, @@ -2206,263 +2578,292 @@ async def edit_role( async def get_role( self, - server: core.ResolvableULID, - role: core.ResolvableULID, + server: ULIDOr[BaseServer], + role: ULIDOr[BaseRole], /, - ) -> servers.Role: + ) -> Role: """|coro| - Get a server role. + Retrieves a server role. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server. + role: :class:`ULIDOr`[:class:`BaseRole`] + The ID of the role to retrieve. Raises ------ - :class:`APIError` + NotFound + The role does not exist. + HTTPException Getting the role failed. + + Returns + ------- + :class:`Role` + The retrieved role. """ - server_id = core.resolve_ulid(server) - role_id = core.resolve_ulid(role) + server_id = resolve_id(server) + role_id = resolve_id(role) return self.state.parser.parse_role( - await self.request( - routes.SERVERS_ROLES_FETCH.compile(server_id=server_id, role_id=role_id) - ), + await self.request(routes.SERVERS_ROLES_FETCH.compile(server_id=server_id, role_id=role_id)), role_id, server_id, ) - async def mark_server_as_read(self, server: core.ResolvableULID, /) -> None: + async def mark_server_as_read(self, server: ULIDOr[BaseServer], /) -> None: """|coro| Mark all channels in a server as read. - https://developers.revolt.chat/api/#tag/Server-Information/operation/server_ack_req .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server to mark as read. """ - await self.request( - routes.SERVERS_SERVER_ACK.compile(server_id=core.resolve_ulid(server)) - ) + await self.request(routes.SERVERS_SERVER_ACK.compile(server_id=resolve_id(server))) - async def create_server( - self, name: str, /, *, description: str | None = None, nsfw: bool | None = None - ) -> servers.Server: + async def create_server(self, name: str, /, *, description: str | None = None, nsfw: bool | None = None) -> Server: """|coro| Create a new server. - https://developers.revolt.chat/api/#tag/Server-Information/operation/server_create_req Parameters ---------- name: :class:`str` The server name. - description: :class:`str` + description: Optional[:class:`str`] The server description. - nsfw: :class:`bool` + nsfw: Optional[:class:`bool`] Whether this server is age-restricted. + + Returns + ------- + :class:`Server` + The created server. """ - j: raw.DataCreateServer = {"name": name} + payload: raw.DataCreateServer = {'name': name} if description is not None: - j["description"] = description + payload['description'] = description if nsfw is not None: - j["nsfw"] = nsfw - d: raw.CreateServerLegacyResponse = await self.request( - routes.SERVERS_SERVER_CREATE.compile(), json=j - ) + payload['nsfw'] = nsfw + d: raw.CreateServerLegacyResponse = await self.request(routes.SERVERS_SERVER_CREATE.compile(), json=payload) return self.state.parser.parse_server( - d["server"], - (False, d["channels"]), + d['server'], + (False, d['channels']), ) - async def delete_server(self, server: core.ResolvableULID, /) -> None: + async def delete_server(self, server: ULIDOr[BaseServer], /) -> None: """|coro| Deletes a server if owner otherwise leaves. - https://developers.revolt.chat/api/#tag/Server-Information/operation/server_delete_req + + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server to delete. """ - await self.request( - routes.SERVERS_SERVER_DELETE.compile(server_id=core.resolve_ulid(server)) - ) + await self.request(routes.SERVERS_SERVER_DELETE.compile(server_id=resolve_id(server))) - async def leave_server( - self, server: core.ResolvableULID, /, *, leave_silently: bool | None = None - ) -> None: + async def leave_server(self, server: ULIDOr[BaseServer], /, *, silent: bool | None = None) -> None: """|coro| - Deletes a server if owner otherwise leaves. - https://developers.revolt.chat/api/#tag/Server-Information/operation/server_delete_req + Leaves the server if not owner otherwise deletes it. Parameters ---------- - leave_silently: :class:`bool` + server: :class:`ULIDOr`[:class:`BaseServer`] + The server to leave from. + silent: Optional[:class:`bool`] Whether to not send a leave message. """ p: raw.OptionsServerDelete = {} - if leave_silently is not None: - p["leave_silently"] = utils._bool(leave_silently) + if silent is not None: + p['leave_silently'] = utils._bool(silent) await self.request( - routes.SERVERS_SERVER_DELETE.compile(server_id=core.resolve_ulid(server)), + routes.SERVERS_SERVER_DELETE.compile(server_id=resolve_id(server)), params=p, ) async def edit_server( self, - server: core.ResolvableULID, + server: ULIDOr[BaseServer], /, *, - name: core.UndefinedOr[str] = core.UNDEFINED, - description: core.UndefinedOr[str | None] = core.UNDEFINED, - icon: core.UndefinedOr[cdn.ResolvableResource | None] = core.UNDEFINED, - banner: core.UndefinedOr[cdn.ResolvableResource | None] = core.UNDEFINED, - categories: core.UndefinedOr[list[servers.Category] | None] = core.UNDEFINED, - system_messages: core.UndefinedOr[ - servers.SystemMessageChannels | None - ] = core.UNDEFINED, - flags: core.UndefinedOr[servers.ServerFlags] = core.UNDEFINED, - discoverable: core.UndefinedOr[bool] = core.UNDEFINED, - analytics: core.UndefinedOr[bool] = core.UNDEFINED, - ) -> servers.Server: + name: UndefinedOr[str] = UNDEFINED, + description: UndefinedOr[str | None] = UNDEFINED, + icon: UndefinedOr[ResolvableResource | None] = UNDEFINED, + banner: UndefinedOr[ResolvableResource | None] = UNDEFINED, + categories: UndefinedOr[list[Category] | None] = UNDEFINED, + system_messages: UndefinedOr[SystemMessageChannels | None] = UNDEFINED, + flags: UndefinedOr[ServerFlags] = UNDEFINED, + discoverable: UndefinedOr[bool] = UNDEFINED, + analytics: UndefinedOr[bool] = UNDEFINED, + ) -> Server: """|coro| - Edit a server. - https://developers.revolt.chat/api/#tag/Server-Information/operation/server_edit_req + Edits the server. Parameters ---------- - name: :class:`core.UndefinedOr`[:class:`str`] + server: :class:`ULIDOr`[:class:`BaseServer`] + The server to edit. + name: :class:`UndefinedOr`[:class:`str`] New server name. Should be between 1 and 32 chars long. - description: :class:`core.UndefinedOr`[:class:`str` | None] + description: :class:`UndefinedOr`[Optional[:class:`str`]] New server description. Can be 1024 chars maximum long. - icon: :class:`core.UndefinedOr`[:class:`cdn.ResolvableResource` | None] + icon: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] New server icon. - banner: :class:`core.UndefinedOr`[:class:`cdn.ResolvableResource` | None] + banner: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] New server banner. - categories: :class:`core.UndefinedOr`[:class:`list`[:class:`Category`] | None] + categories: :class:`UndefinedOr`[Optional[List[:class:`Category`]]] New category structure for this server. - system_messsages: :class:`core.UndefinedOr`[:class:`SystemMessageChannels` | None] + system_messsages: :class:`UndefinedOr`[Optional[:class:`SystemMessageChannels`]] New system message channels configuration. - flags: :class:`core.UndefinedOr`[:class:`ServerFlags`] - Bitfield of server flags. Can be passed only if you're privileged user. - discoverable: :class:`core.UndefinedOr`[:class:`bool`] + flags: :class:`UndefinedOr`[:class:`ServerFlags`] + The new server flags. Can be passed only if you're privileged user. + discoverable: :class:`UndefinedOr`[:class:`bool`] Whether this server is public and should show up on [Revolt Discover](https://rvlt.gg). Can be passed only if you're privileged user. - analytics: :class:`core.UndefinedOr`[:class:`bool`] + analytics: :class:`UndefinedOr`[:class:`bool`] Whether analytics should be collected for this server. Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg). Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the server. - :class:`APIError` + HTTPException Editing the server failed. - """ - j: raw.DataEditServer = {} - r: list[raw.FieldsServer] = [] - if core.is_defined(name): - j["name"] = name - if core.is_defined(description): + + Returns + ------- + :class:`Server` + The newly updated server. + """ + payload: raw.DataEditServer = {} + remove: list[raw.FieldsServer] = [] + if name is not UNDEFINED: + payload['name'] = name + if description is not UNDEFINED: if description is not None: - j["description"] = description + payload['description'] = description else: - r.append("Description") - if core.is_defined(icon): + remove.append('Description') + if icon is not UNDEFINED: if icon is not None: - j["icon"] = await cdn.resolve_resource(self.state, icon, tag="icons") + payload['icon'] = await resolve_resource(self.state, icon, tag='icons') else: - r.append("Icon") - if core.is_defined(banner): + remove.append('Icon') + if banner is not UNDEFINED: if banner is not None: - j["banner"] = await cdn.resolve_resource( - self.state, banner, tag="banners" - ) + payload['banner'] = await resolve_resource(self.state, banner, tag='banners') else: - r.append("Banner") - if core.is_defined(categories): + remove.append('Banner') + if categories is not UNDEFINED: if categories is not None: - j["categories"] = [e.build() for e in categories] + payload['categories'] = [e.build() for e in categories] else: - r.append("Categories") - if core.is_defined(system_messages): + remove.append('Categories') + if system_messages is not UNDEFINED: if system_messages is not None: - j["system_messages"] = system_messages.build() + payload['system_messages'] = system_messages.build() else: - r.append("SystemMessages") - if core.is_defined(flags): - j["flags"] = int(flags) - if core.is_defined(discoverable): - j["discoverable"] = discoverable - if core.is_defined(analytics): - j["analytics"] = analytics - if len(r) > 0: - j["remove"] = r + remove.append('SystemMessages') + if flags is not UNDEFINED: + payload['flags'] = flags.value + if discoverable is not UNDEFINED: + payload['discoverable'] = discoverable + if analytics is not UNDEFINED: + payload['analytics'] = analytics + if len(remove) > 0: + payload['remove'] = remove d: raw.Server = await self.request( - routes.SERVERS_SERVER_EDIT.compile(server_id=core.resolve_ulid(server)), - json=j, + routes.SERVERS_SERVER_EDIT.compile(server_id=resolve_id(server)), + json=payload, ) return self.state.parser.parse_server( - d, (True, [self.state.parser.parse_id(c) for c in d["channels"]]) + d, + (True, d['channels']), ) async def get_server( - self, server: core.ResolvableULID, /, *, populate_channels: bool | None = None - ) -> servers.Server: + self, + server: ULIDOr[BaseServer], + /, + *, + populate_channels: bool | None = None, + ) -> Server: """|coro| - Get a server. - https://developers.revolt.chat/api/#tag/Server-Information/operation/server_fetch_req + Retrieves a :class:`Server`. Parameters ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The ID of the server. populate_channels: :class:`bool` - Whether to include channels. + Whether to populate channels. + + Returns + ------- + :class:`Server` + The retrieved server. """ p: raw.OptionsFetchServer = {} if populate_channels is not None: - p["include_channels"] = utils._bool(populate_channels) + p['include_channels'] = utils._bool(populate_channels) d: raw.FetchServerResponse = await self.request( - routes.SERVERS_SERVER_FETCH.compile(server_id=core.resolve_ulid(server)) + routes.SERVERS_SERVER_FETCH.compile(server_id=resolve_id(server)) ) return self.state.parser.parse_server( d, # type: ignore - (not populate_channels, d["channels"]), # type: ignore + (not populate_channels, d['channels']), # type: ignore ) # Sync control - async def get_user_settings(self, keys: list[str], /) -> user_settings.UserSettings: + async def get_user_settings(self, keys: list[str] = [], /) -> UserSettings: """|coro| Get user settings from server filtered by keys. - This will return an object with the requested keys, each value is a tuple of `(timestamp, value)`, the value is the previously uploaded data. - .. note:: This can only be used by non-bot accounts. """ # Sync endpoints aren't meant to be used with bot accounts - j: raw.OptionsFetchSettings = {"keys": keys} + payload: raw.OptionsFetchSettings = {'keys': keys} return self.state.parser.parse_user_settings( - await self.request(routes.SYNC_SET_SETTINGS.compile(), json=j), + await self.request(routes.SYNC_GET_SETTINGS.compile(), json=payload), + partial=True, ) - async def get_unreads(self) -> list[read_state.ReadState]: + async def get_read_states(self) -> list[ReadState]: """|coro| Get information about unread state on channels. .. note:: This can only be used by non-bot accounts. + + Returns + ------- + List[:class:`ReadState`] + The channel read states. """ - return [ - self.state.parser.parse_read_state(rs) - for rs in await self.request(routes.SYNC_GET_UNREADS.compile()) - ] + return [self.state.parser.parse_read_state(rs) for rs in await self.request(routes.SYNC_GET_UNREADS.compile())] - async def set_user_settings( + async def edit_user_settings( self, - timestamp: datetime | int | None = None, dict_settings: dict[str, str] = {}, + edited_at: datetime | int | None = None, /, - **kw_settings: str, + **kwargs: str, ) -> None: """|coro| @@ -2471,59 +2872,93 @@ async def set_user_settings( .. note:: This can only be used by non-bot accounts. """ - j: raw.DataSetSettings = dict_settings | kw_settings - p: raw.OptionsSetSettings = {} - if timestamp is not None: - if isinstance(timestamp, datetime): - timestamp = int(timestamp.timestamp()) - p["timestamp"] = timestamp - await self.request(routes.SYNC_SET_SETTINGS.compile(), json=j, params=p) + + params: raw.OptionsSetSettings = {} + + if edited_at is not None: + if isinstance(edited_at, datetime): + edited_at = int(edited_at.timestamp()) + params['timestamp'] = edited_at + + payload: dict[str, str] = {} + for k, v in (dict_settings | kwargs).items(): + if isinstance(v, str): + payload[k] = v + else: + payload[k] = utils.to_json(v) + + await self.request(routes.SYNC_SET_SETTINGS.compile(), json=payload, params=params) # Users control - async def accept_friend_request(self, user: core.ResolvableULID, /) -> users.User: + async def accept_friend_request(self, user: ULIDOr[BaseUser], /) -> User: """|coro| Accept another user's friend request. - https://developers.revolt.chat/api/#tag/Relationships/operation/add_friend_req .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + user: :class:`ULIDOr`[:class:`BaseUser`] + The user to friend with. + + Returns + ------- + :class:`User` + The friended user. """ return self.state.parser.parse_user( - await self.request( - routes.USERS_ADD_FRIEND.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_ADD_FRIEND.compile(user_id=resolve_id(user))) ) - async def block_user(self, user: core.ResolvableULID, /) -> users.User: + async def block_user(self, user: ULIDOr[BaseUser], /) -> User: """|coro| - Block another user by their ID. - https://developers.revolt.chat/api/#tag/Relationships/operation/block_user_req + Block another user. .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + user: :class:`ULIDOr`[:class:`BaseUser`] + The user to block. + + Returns + ------- + :class:`User` + The blocked user. """ return self.state.parser.parse_user( - await self.request( - routes.USERS_BLOCK_USER.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_BLOCK_USER.compile(user_id=resolve_id(user))) ) - async def change_username(self, username: str, /, *, password: str) -> users.User: + async def change_username(self, username: str, /, *, current_password: str) -> OwnUser: """|coro| Change your username. - https://developers.revolt.chat/api/#tag/User-Information/operation/change_username_req .. note:: This can only be used by non-bot accounts. + + Parameters + ---------- + username: :class:`str` + The new username. + current_password: :class:`str` + The current account password. + + Returns + ------- + :class:`OwnUser` + The newly updated user. """ - j: raw.DataChangeUsername = {"username": username, "password": password} - return self.state.parser.parse_user( + payload: raw.DataChangeUsername = {'username': username, 'password': current_password} + return self.state.parser.parse_own_user( await self.request( routes.USERS_CHANGE_USERNAME.compile(), - json=j, + json=payload, ) ) @@ -2532,77 +2967,79 @@ async def _edit_user( route: routes.CompiledRoute, /, *, - display_name: core.UndefinedOr[str | None] = core.UNDEFINED, - avatar: core.UndefinedOr[cdn.ResolvableResource | None] = core.UNDEFINED, - status: core.UndefinedOr[users.UserStatusEdit] = core.UNDEFINED, - profile: core.UndefinedOr[users.UserProfileEdit] = core.UNDEFINED, - badges: core.UndefinedOr[users.UserBadges] = core.UNDEFINED, - flags: core.UndefinedOr[users.UserFlags] = core.UNDEFINED, - ) -> users.User: - j: raw.DataEditUser = {} - r: list[raw.FieldsUser] = [] - if core.is_defined(display_name): + display_name: UndefinedOr[str | None] = UNDEFINED, + avatar: UndefinedOr[ResolvableResource | None] = UNDEFINED, + status: UndefinedOr[UserStatusEdit] = UNDEFINED, + profile: UndefinedOr[UserProfileEdit] = UNDEFINED, + badges: UndefinedOr[UserBadges] = UNDEFINED, + flags: UndefinedOr[UserFlags] = UNDEFINED, + ) -> raw.User: + payload: raw.DataEditUser = {} + remove: list[raw.FieldsUser] = [] + if display_name is not UNDEFINED: if display_name is None: - r.append("DisplayName") + remove.append('DisplayName') else: - j["display_name"] = display_name - if core.is_defined(avatar): + payload['display_name'] = display_name + if avatar is not UNDEFINED: if avatar is None: - r.append("Avatar") + remove.append('Avatar') else: - j["avatar"] = await cdn.resolve_resource( - self.state, avatar, tag="avatars" - ) - if core.is_defined(status): - j["status"] = status.build() - r.extend(status.remove) - if core.is_defined(profile): - j["profile"] = await profile.build(self.state) - r.extend(profile.remove) - if core.is_defined(badges): - j["badges"] = int(badges) - if core.is_defined(flags): - j["flags"] = int(flags) - if len(r) > 0: - j["remove"] = r - return self.state.parser.parse_user(await self.request(route, json=j)) - - async def edit_self_user( + payload['avatar'] = await resolve_resource(self.state, avatar, tag='avatars') + if status is not UNDEFINED: + payload['status'] = status.build() + remove.extend(status.remove) + if profile is not UNDEFINED: + payload['profile'] = await profile.build(self.state) + remove.extend(profile.remove) + if badges is not UNDEFINED: + payload['badges'] = badges.value + if flags is not UNDEFINED: + payload['flags'] = flags.value + if len(remove) > 0: + payload['remove'] = remove + return await self.request(route, json=payload) + + async def edit_my_user( self, *, - display_name: core.UndefinedOr[str] = core.UNDEFINED, - avatar: core.UndefinedOr[cdn.ResolvableResource | None] = core.UNDEFINED, - status: core.UndefinedOr[users.UserStatusEdit] = core.UNDEFINED, - profile: core.UndefinedOr[users.UserProfileEdit] = core.UNDEFINED, - badges: core.UndefinedOr[users.UserBadges] = core.UNDEFINED, - flags: core.UndefinedOr[users.UserFlags] = core.UNDEFINED, - ) -> users.User: + display_name: UndefinedOr[str] = UNDEFINED, + avatar: UndefinedOr[ResolvableResource | None] = UNDEFINED, + status: UndefinedOr[UserStatusEdit] = UNDEFINED, + profile: UndefinedOr[UserProfileEdit] = UNDEFINED, + badges: UndefinedOr[UserBadges] = UNDEFINED, + flags: UndefinedOr[UserFlags] = UNDEFINED, + ) -> OwnUser: """|coro| Edits the user. - https://developers.revolt.chat/api/#tag/User-Information/operation/edit_user_req Parameters ---------- - display_name: :class:`core.UndefinedOr`[:class:`str`] | None + display_name: :class:`UndefinedOr`[Optional[:class:`str`]] New display name. Pass ``None`` to remove it. - avatar: :class:`core.UndefinedOr`[:class:`cdn.ResolvableULID`] | None + avatar: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] New avatar. Pass ``None`` to remove it. - status: :class:`core.UndefinedOr`[:class:`users.UserStatusEdit`] + status: :class:`UndefinedOr`[:class:`UserStatusEdit`] New user status. - profile: :class:`core.UndefinedOr`[:class:`UserProfileEdit`] + profile: :class:`UndefinedOr`[:class:`UserProfileEdit`] New user profile data. This is applied as a partial. - badges: :class:`core.UndefinedOr`[:class:`users.UserBadges`] - Bitfield of new user badges. - flags: :class:`core.UndefinedOr`[:class:`users.UserFlags`] - Bitfield of new user flags. + badges: :class:`UndefinedOr`[:class:`UserBadges`] + The new user badges. + flags: :class:`UndefinedOr`[:class:`UserFlags`] + The new user flags. Raises ------ - :class:`APIError` + HTTPException Editing the user failed. + + Returns + ------- + :class:`OwnUser` + The newly updated authenticated user. """ - return await self._edit_user( + resp = await self._edit_user( routes.USERS_EDIT_SELF_USER.compile(), display_name=display_name, avatar=avatar, @@ -2611,49 +3048,53 @@ async def edit_self_user( badges=badges, flags=flags, ) + return self.state.parser.parse_own_user(resp) async def edit_user( self, - user: core.ResolvableULID, + user: ULIDOr[BaseUser], /, *, - display_name: core.UndefinedOr[str] = core.UNDEFINED, - avatar: core.UndefinedOr[cdn.ResolvableResource | None] = core.UNDEFINED, - status: core.UndefinedOr[users.UserStatusEdit] = core.UNDEFINED, - profile: core.UndefinedOr[users.UserProfileEdit] = core.UNDEFINED, - badges: core.UndefinedOr[users.UserBadges] = core.UNDEFINED, - flags: core.UndefinedOr[users.UserFlags] = core.UNDEFINED, - ) -> users.User: + display_name: UndefinedOr[str] = UNDEFINED, + avatar: UndefinedOr[ResolvableResource | None] = UNDEFINED, + status: UndefinedOr[UserStatusEdit] = UNDEFINED, + profile: UndefinedOr[UserProfileEdit] = UNDEFINED, + badges: UndefinedOr[UserBadges] = UNDEFINED, + flags: UndefinedOr[UserFlags] = UNDEFINED, + ) -> User: """|coro| Edits the user. - https://developers.revolt.chat/api/#tag/User-Information/operation/edit_user_req Parameters ---------- - display_name: :class:`core.UndefinedOr`[:class:`str`] | None + user: :class:`ULIDOr`[:class:`BaseUser`] + The user to edit. + display_name: :class:`UndefinedOr`[Optional[:class:`str`]] New display name. Pass ``None`` to remove it. - avatar: :class:`core.UndefinedOr`[:class:`cdn.ResolvableResource`] | None + avatar: :class:`UndefinedOr`[Optional[:class:`ResolvableULID`]] New avatar. Pass ``None`` to remove it. - status: :class:`core.UndefinedOr`[:class:`users.UserStatusEdit`] + status: :class:`UndefinedOr`[:class:`UserStatusEdit`] New user status. - profile: :class:`core.UndefinedOr`[:class:`users.UserProfileEdit`] + profile: :class:`UndefinedOr`[:class:`UserProfileEdit`] New user profile data. This is applied as a partial. - badges: :class:`core.UndefinedOr`[:class:`users.UserBadges`] - Bitfield of new user badges. - flags: :class:`core.UndefinedOr`[:class:`users.UserFlags`] - Bitfield of new user flags. - + badges: :class:`UndefinedOr`[:class:`UserBadges`] + The new user badges. + flags: :class:`UndefinedOr`[:class:`UserFlags`] + The new user flags. Raises ------ - :class:`Forbidden` - Target user have blocked you. - :class:`APIError` + HTTPException Editing the user failed. + + Returns + ------- + :class:`User` + The newly updated user. """ - return await self._edit_user( - routes.USERS_EDIT_USER.compile(user_id=core.resolve_ulid(user)), + resp = await self._edit_user( + routes.USERS_EDIT_USER.compile(user_id=resolve_id(user)), display_name=display_name, avatar=avatar, status=status, @@ -2661,141 +3102,145 @@ async def edit_user( badges=badges, flags=flags, ) + return self.state.parser.parse_user(resp) - async def get_direct_message_channels( + async def get_private_channels( self, - ) -> list[channels.DMChannel | channels.GroupChannel]: + ) -> list[SavedMessagesChannel | DMChannel | GroupChannel]: """|coro| Get all DMs and groups conversations. - https://developers.revolt.chat/api/#tag/Direct-Messaging/operation/fetch_dms_req + + Returns + ------- + List[Union[:class:`SavedMessagesChannel`, :class:`DMChannel`, :class:`GroupChannel`]] + The private channels. """ - return [ - self.state.parser.parse_channel(e) - for e in await self.request(routes.USERS_FETCH_DMS.compile()) - ] # type: ignore # The returned channels are always DM/Groups + result = [self.state.parser.parse_channel(e) for e in await self.request(routes.USERS_FETCH_DMS.compile())] + return result # type: ignore # The returned channels are always DM/Groups - async def get_user_profile(self, user: core.ResolvableULID, /) -> users.UserProfile: + async def get_user_profile(self, user: ULIDOr[BaseUser], /) -> UserProfile: """|coro| Retrieve a user's profile data. - Will fail if you do not have permission to access the other user's profile. - https://developers.revolt.chat/api/#tag/User-Information/operation/fetch_profile_req + + Parameters + ---------- + user: :class:`ULIDOr`[:class:`BaseUser`] + The user. + + Raises + ------ + Forbidden + You do not have permission to access the user's profile. + + Returns + ------- + :class:`UserProfile` + The retrieved user profile. """ - user_id = core.resolve_ulid(user) + user_id = resolve_id(user) return self.state.parser.parse_user_profile( await self.request(routes.USERS_FETCH_PROFILE.compile(user_id=user_id)) )._stateful(self.state, user_id) - async def get_self_user(self) -> users.User: + async def get_me(self) -> User: """|coro| Retrieve your user information. - https://developers.revolt.chat/api/#tag/User-Information/operation/fetch_self_req Raises ------ - APIError + Unauthorized Invalid token. """ - return self.state.parser.parse_user( - await self.request(routes.USERS_FETCH_SELF.compile()) - ) + return self.state.parser.parse_user(await self.request(routes.USERS_FETCH_SELF.compile())) - async def get_user(self, user: core.ResolvableULID, /) -> users.User: + async def get_user(self, user: ULIDOr[BaseUser], /) -> User: """|coro| Retrieve a user's information. - https://developers.revolt.chat/api/#tag/User-Information/operation/fetch_user_req Parameters ---------- - user: :class:`core.ResolvableULID` + user: :class:`ULIDOr`[:class:`BaseUser`] The user. Raises ------ - APIError - Invalid token, or you been blocked by that user. + Forbidden + You been blocked by that user. + HTTPException + Getting the user failed. + + Returns + ------- + :class:`User` + The retrieved user. """ return self.state.parser.parse_user( - await self.request( - routes.USERS_FETCH_USER.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_FETCH_USER.compile(user_id=resolve_id(user))) ) - async def get_user_flags(self, user: core.ResolvableULID, /) -> users.UserFlags: + async def get_user_flags(self, user: ULIDOr[BaseUser], /) -> UserFlags: """|coro| Retrieve a user's flags. - https://developers.revolt.chat/api/#tag/User-Information/operation/fetch_user_flags_fetch_user_flags - """ - return users.UserFlags( - ( - await self.request( - routes.USERS_FETCH_USER_FLAGS.compile( - user_id=core.resolve_ulid(user) - ) - ) - )["flags"] - ) + """ + resp: raw.FlagResponse = await self.request(routes.USERS_FETCH_USER_FLAGS.compile(user_id=resolve_id(user))) + return UserFlags(resp['flags']) - async def get_mutual_friends_and_servers( - self, user: core.ResolvableULID, / - ) -> users.Mutuals: + async def get_mutuals_with(self, user: ULIDOr[BaseUser], /) -> Mutuals: """|coro| - Retrieve a list of mutual friends and servers with another user. - https://developers.revolt.chat/api/#tag/Relationships/operation/find_mutual_req + Retrieves a list of mutual friends and servers with another user. Raises ------ - :class:`APIError` + HTTPException Finding mutual friends/servers failed. + + Returns + ------- + :class:`Mutuals` + The found mutuals. """ return self.state.parser.parse_mutuals( - await self.request( - routes.USERS_FIND_MUTUAL.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_FIND_MUTUAL.compile(user_id=resolve_id(user))) ) - async def get_default_avatar(self, user: core.ResolvableULID, /) -> bytes: + async def get_default_avatar(self, user: ULIDOr[BaseUser], /) -> bytes: """|coro| - This returns a default avatar based on the given ID. - https://developers.revolt.chat/api/#tag/User-Information/operation/get_default_avatar_req + Returns a default avatar based on the given ID. """ - response = await self._request( - routes.USERS_GET_DEFAULT_AVATAR.compile(user_id=core.resolve_ulid(user)) + response = await self.raw_request( + routes.USERS_GET_DEFAULT_AVATAR.compile(user_id=resolve_id(user)[-1]), + authenticated=False, + accept_json=False, ) avatar = await response.read() if not response.closed: response.close() return avatar - async def open_dm( - self, user: core.ResolvableULID, / - ) -> channels.SavedMessagesChannel | channels.DMChannel: + async def open_dm(self, user: ULIDOr[BaseUser], /) -> SavedMessagesChannel | DMChannel: """|coro| - Open a DM with another user. If the target is oneself, a saved messages channel is returned. - https://developers.revolt.chat/api/#tag/Direct-Messaging/operation/open_dm_req - + Open a DM with another user. If the target is oneself, a :class:`SavedMessagesChannel` is returned. Raises ------ - :class:`APIError` + HTTPException Opening DM failed. """ channel = self.state.parser.parse_channel( - await self.request( - routes.USERS_OPEN_DM.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_OPEN_DM.compile(user_id=resolve_id(user))) ) - assert isinstance(channel, (channels.SavedMessagesChannel, channels.DMChannel)) + assert isinstance(channel, (SavedMessagesChannel, DMChannel)) return channel - async def deny_friend_request(self, user: core.ResolvableULID, /) -> users.User: + async def deny_friend_request(self, user: ULIDOr[BaseUser], /) -> User: """|coro| Denies another user's friend request. @@ -2805,41 +3250,34 @@ async def deny_friend_request(self, user: core.ResolvableULID, /) -> users.User: Raises ------ - :class:`APIError` + HTTPException Denying the friend request failed. """ return self.state.parser.parse_user( - await self.request( - routes.USERS_REMOVE_FRIEND.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_REMOVE_FRIEND.compile(user_id=resolve_id(user))) ) - async def remove_friend(self, user: core.ResolvableULID, /) -> users.User: + async def remove_friend(self, user: ULIDOr[BaseUser], /) -> User: """|coro| - Removes an existing friend. + Removes the user as a friend. .. note:: This can only be used by non-bot accounts. Raises ------ - :class:`APIError` - Removing the friend request failed. + HTTPException + Removing the user as a friend failed. """ return self.state.parser.parse_user( - await self.request( - routes.USERS_REMOVE_FRIEND.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_REMOVE_FRIEND.compile(user_id=resolve_id(user))) ) - async def send_friend_request( - self, username: str, discriminator: str | None = None, / - ) -> users.User: + async def send_friend_request(self, username: str, discriminator: str | None = None, /) -> User: """|coro| Send a friend request to another user. - https://developers.revolt.chat/api/#tag/Relationships/operation/send_friend_request_req .. note:: This can only be used by non-bot accounts. @@ -2848,192 +3286,219 @@ async def send_friend_request( ---------- username: :class:`str` Username and discriminator combo separated by `#`. + discriminator: Optional[:class:`str`] + The user's discriminator. Raises ------ - :class:`Forbidden` + Forbidden Target user have blocked you. - :class:`APIError` + HTTPException Sending the friend request failed. """ if discriminator is not None: - username += "#" + discriminator - j: raw.DataSendFriendRequest = {"username": username} - return self.state.parser.parse_user( - await self.request( - routes.USERS_SEND_FRIEND_REQUEST.compile(), - json=j, - ) + username += '#' + discriminator + payload: raw.DataSendFriendRequest = {'username': username} + + resp: raw.User = await self.request( + routes.USERS_SEND_FRIEND_REQUEST.compile(), + json=payload, ) + return self.state.parser.parse_user(resp) - async def unblock_user(self, user: core.ResolvableULID, /) -> users.User: + async def unblock_user(self, user: ULIDOr[BaseUser], /) -> User: """|coro| - Unblock another user by their ID. - https://developers.revolt.chat/api/#tag/Relationships/operation/unblock_user_req + Unblock a user. .. note:: This can only be used by non-bot accounts. - Raises - ------ - :class:`Forbidden` - Target user have blocked you. - :class:`APIError` - Sending the friend request failed. + Parameters + ---------- + user: :class:`ULIDOr`[:class:`BaseUser`] + The user to unblock. """ return self.state.parser.parse_user( - await self.request( - routes.USERS_UNBLOCK_USER.compile(user_id=core.resolve_ulid(user)) - ) + await self.request(routes.USERS_UNBLOCK_USER.compile(user_id=resolve_id(user))) ) # Webhooks control - async def delete_webhook( - self, webhook: core.ResolvableULID, /, *, token: str | None = None - ) -> None: + async def delete_webhook(self, webhook: ULIDOr[BaseWebhook], /, *, token: str | None = None) -> None: """|coro| Deletes a webhook. If webhook token wasn't given, the library will attempt delete webhook with current bot/user token. + Parameters + ---------- + webhook: :class:`ULIDOr`[:class:`BaseWebhook`] + The webhook to delete. + token: Optional[:class:`str`] + The webhook token. + Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete the webhook. - :class:`APIError` + HTTPException Deleting the webhook failed. """ if token is None: - await self.request( - routes.WEBHOOKS_WEBHOOK_DELETE.compile( - webhook_id=core.resolve_ulid(webhook) - ) - ) + await self.request(routes.WEBHOOKS_WEBHOOK_DELETE.compile(webhook_id=resolve_id(webhook))) else: await self.request( - routes.WEBHOOKS_WEBHOOK_DELETE_TOKEN.compile( - webhook_id=core.resolve_ulid(webhook), webhook_token=token - ), + routes.WEBHOOKS_WEBHOOK_DELETE_TOKEN.compile(webhook_id=resolve_id(webhook), webhook_token=token), authenticated=False, ) async def edit_webhook( self, - webhook: core.ResolvableULID, + webhook: ULIDOr[BaseWebhook], /, *, token: str | None = None, - name: core.UndefinedOr[str] = core.UNDEFINED, - avatar: core.UndefinedOr[cdn.ResolvableResource | None] = core.UNDEFINED, - permissions: core.UndefinedOr[permissions_.Permissions] = core.UNDEFINED, - ) -> webhooks.Webhook: + name: UndefinedOr[str] = UNDEFINED, + avatar: UndefinedOr[ResolvableResource | None] = UNDEFINED, + permissions: UndefinedOr[Permissions] = UNDEFINED, + ) -> Webhook: """|coro| Edits a webhook. If webhook token wasn't given, the library will attempt edit webhook with current bot/user token. Parameters ---------- - name: :class:`core.UndefinedOr`[:class:`str` | None] + webhook: :class:`ULIDOr`[:class:`BaseWebhook`] + The webhook to edit. + token: Optional[:class:`str`] + The webhook token. + name: :class:`UndefinedOr`[:class:`str`] New webhook name. Should be between 1 and 32 chars long. - avatar: :class:`core.UndefinedOr`[:class:`cdn.ResolvableResource` | None] + avatar: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] New webhook avatar. - permissions: :class:`core.UndefinedOr`[:class:`permissions_.Permissions` | None] + permissions: :class:`UndefinedOr`[:class:`Permissions`] New webhook permissions. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the webhook. - :class:`APIError` + HTTPException Editing the webhook failed. - """ - j: raw.DataEditWebhook = {} - r: list[raw.FieldsWebhook] = [] - if core.is_defined(name): - j["name"] = name - if core.is_defined(avatar): + + Returns + ------- + :class:`Webhook` + The newly updated webhook. + """ + payload: raw.DataEditWebhook = {} + remove: list[raw.FieldsWebhook] = [] + if name is not UNDEFINED: + payload['name'] = name + if avatar is not UNDEFINED: if avatar is None: - r.append("Avatar") + remove.append('Avatar') else: - j["avatar"] = await cdn.resolve_resource( - self.state, avatar, tag="avatars" - ) - if core.is_defined(permissions): - j["permissions"] = int(permissions) - if len(r) > 0: - j["remove"] = r - return self.state.parser.parse_webhook( - await self.request( - routes.WEBHOOKS_WEBHOOK_EDIT.compile( - webhook_id=core.resolve_ulid(webhook) - ), - json=j, + payload['avatar'] = await resolve_resource(self.state, avatar, tag='avatars') + if permissions is not UNDEFINED: + payload['permissions'] = permissions.value + if len(remove) > 0: + payload['remove'] = remove + + if token is None: + resp: raw.Webhook = await self.request( + routes.WEBHOOKS_WEBHOOK_EDIT.compile(webhook_id=resolve_id(webhook)), + json=payload, ) - if token is None - else await self.request( - routes.WEBHOOKS_WEBHOOK_EDIT_TOKEN.compile( - webhook_id=core.resolve_ulid(webhook), webhook_token=token - ), - json=j, + else: + resp = await self.request( + routes.WEBHOOKS_WEBHOOK_EDIT_TOKEN.compile(webhook_id=resolve_id(webhook), webhook_token=token), + json=payload, authenticated=False, ) - ) + return self.state.parser.parse_webhook(resp) async def execute_webhook( self, - webhook: core.ResolvableULID, + webhook: ULIDOr[BaseWebhook], token: str, /, content: str | None = None, *, nonce: str | None = None, - attachments: list[cdn.ResolvableResource] | None = None, - replies: list[messages.Reply | core.ResolvableULID] | None = None, - embeds: list[messages.SendableEmbed] | None = None, - masquerade: messages.Masquerade | None = None, - interactions: messages.Interactions | None = None, - ) -> messages.Message: + attachments: list[ResolvableResource] | None = None, + replies: list[Reply | ULIDOr[BaseMessage]] | None = None, + embeds: list[SendableEmbed] | None = None, + masquerade: Masquerade | None = None, + interactions: Interactions | None = None, + silent: bool | None = None, + ) -> Message: """|coro| Executes a webhook and returns a message. + Parameters + ---------- + webhook: :class:`ULIDOr`[:class:`BaseWebhook`] + The ID of the webhook. + token: :class:`str` + The webhook token. + content: Optional[:class:`str`] + The message content. + nonce: Optional[:class:`str`] + The message nonce. + attachments: Optional[List[:class:`ResolvableResource`]] + The message attachments. + replies: Optional[List[Union[:class:`Reply`, :class:`ULIDOr`[:class:`BaseMessage`]]]] + The message replies. + embeds: Optional[List[:class:`SendableEmbed`]] + The message embeds. + masquearde: Optional[:class:`Masquerade`] + The message masquerade. + interactions: Optional[:class:`Interactions`] + The message interactions. + silent: Optional[:class:`bool`] + Whether to suppress notifications or not. + Returns ------- - :class:`messages.Message` + :class:`Message` The message sent. """ - j: raw.DataMessageSend = {} + payload: raw.DataMessageSend = {} if content is not None: - j["content"] = content + payload['content'] = content if attachments is not None: - j["attachments"] = [ - await cdn.resolve_resource(self.state, attachment, tag="attachments") - for attachment in attachments + payload['attachments'] = [ + await resolve_resource(self.state, attachment, tag='attachments') for attachment in attachments ] if replies is not None: - j["replies"] = [ - ( - reply.build() - if isinstance(reply, messages.Reply) - else {"id": core.resolve_ulid(reply), "mention": False} - ) + payload['replies'] = [ + (reply.build() if isinstance(reply, Reply) else {'id': resolve_id(reply), 'mention': False}) for reply in replies ] if embeds is not None: - j["embeds"] = [await embed.build(self.state) for embed in embeds] + payload['embeds'] = [await embed.build(self.state) for embed in embeds] if masquerade is not None: - j["masquerade"] = masquerade.build() + payload['masquerade'] = masquerade.build() if interactions is not None: - j["interactions"] = interactions.build() + payload['interactions'] = interactions.build() + + flags = None + if silent is not None: + flags = 0 + if silent: + flags |= MessageFlags.SUPPRESS_NOTIFICATIONS + + if flags is not None: + payload['flags'] = flags + headers = {} if nonce is not None: - headers["Idempotency-Key"] = nonce + headers['Idempotency-Key'] = nonce return self.state.parser.parse_message( await self.request( - routes.WEBHOOKS_WEBHOOK_EXECUTE.compile( - webhook_id=core.resolve_ulid(webhook), webhook_token=token - ), - json=j, + routes.WEBHOOKS_WEBHOOK_EXECUTE.compile(webhook_id=resolve_id(webhook), webhook_token=token), + json=payload, headers=headers, authenticated=False, ) @@ -3041,42 +3506,49 @@ async def execute_webhook( async def get_webhook( self, - webhook: core.ResolvableULID, + webhook: ULIDOr[BaseWebhook], /, *, token: str | None = None, - ) -> webhooks.Webhook: + ) -> Webhook: """|coro| - Gets a webhook. If webhook token wasn't given, the library will attempt get webhook with bot/user token. + Retrieves a webhook. If webhook token wasn't given, the library will attempt get webhook with bot/user token. .. note:: - Due to Revolt limitation, the webhook avatar information will be partial. Fields are guaranteed to be non-zero/non-empty: `id` and `user_id`. + Due to Revolt limitation, the webhook avatar information will be partial if no token is provided. Fields are guaranteed to be non-zero/non-empty: `id` and `user_id`. + + Parameters + ---------- + webhook: :class:`ULIDOr`[:class:`BaseWebhook`] + The ID of the webhook. + token: Optional[:class:`str`] + The webhook token. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to get the webhook. - :class:`APIError` + HTTPException Getting the webhook failed. + + Returns + ------- + :class:`Webhook` + The retrieved webhook. """ - return self.state.parser.parse_response_webhook( - await ( - self.request( - routes.WEBHOOKS_WEBHOOK_FETCH.compile( - webhook_id=core.resolve_ulid(webhook) - ) - ) - if token is None - else self.request( - routes.WEBHOOKS_WEBHOOK_FETCH_TOKEN.compile( - webhook_id=core.resolve_ulid(webhook), webhook_token=token - ), - authenticated=False, - ) + if token is None: + r1: raw.ResponseWebhook = await self.request( + routes.WEBHOOKS_WEBHOOK_FETCH.compile(webhook_id=resolve_id(webhook)) ) - ) + return self.state.parser.parse_response_webhook(r1) + else: + r2: raw.Webhook = await self.request( + routes.WEBHOOKS_WEBHOOK_FETCH_TOKEN.compile(webhook_id=resolve_id(webhook), webhook_token=token), + authenticated=False, + ) + return self.state.parser.parse_webhook(r2) # Account authentication control async def change_email( @@ -3101,16 +3573,16 @@ async def change_email( Raises ------ - :class:`APIError` + HTTPException Changing the account password failed. """ - j: raw.a.DataChangeEmail = { - "email": email, - "current_password": current_password, + payload: raw.a.DataChangeEmail = { + 'email': email, + 'current_password': current_password, } await self.request( routes.AUTH_ACCOUNT_CHANGE_EMAIL.compile(), - json=j, + json=payload, ) async def change_password( @@ -3135,16 +3607,16 @@ async def change_password( Raises ------ - :class:`APIError` + HTTPException Changing the account password failed. """ - j: raw.a.DataChangePassword = { - "password": new_password, - "current_password": current_password, + payload: raw.a.DataChangePassword = { + 'password': new_password, + 'current_password': current_password, } await self.request( routes.AUTH_ACCOUNT_CHANGE_PASSWORD.compile(), - json=j, + json=payload, ) async def confirm_account_deletion( @@ -3163,14 +3635,14 @@ async def confirm_account_deletion( Raises ------ - :class:`APIError` + HTTPException Confirming the account deletion failed. """ - j: raw.a.DataAccountDeletion = {"token": token} + payload: raw.a.DataAccountDeletion = {'token': token} await self.request( routes.AUTH_ACCOUNT_CONFIRM_DELETION.compile(), authenticated=False, - json=j, + json=payload, ) async def register( @@ -3192,26 +3664,26 @@ async def register( The account email. password: :class:`str` The account password. - invite: :class:`str` | None + invite: Optional[:class:`str`] The instance invite code. - captcha: :class:`str` | None + captcha: Optional[:class:`str`] The CAPTCHA verification code. Raises ------ - :class:`APIError` + HTTPException Registering the account failed. """ - j: raw.a.DataCreateAccount = { - "email": email, - "password": password, - "invite": invite, - "captcha": captcha, + payload: raw.a.DataCreateAccount = { + 'email': email, + 'password': password, + 'invite': invite, + 'captcha': captcha, } await self.request( routes.AUTH_ACCOUNT_CREATE_ACCOUNT.compile(), authenticated=False, - json=j, + json=payload, ) async def delete_account( @@ -3233,7 +3705,7 @@ async def delete_account( Raises ------ - :class:`APIError` + HTTPException Requesting the account to be deleted failed. """ await self.request(routes.AUTH_ACCOUNT_DELETE_ACCOUNT.compile(), mfa_ticket=mfa) @@ -3257,14 +3729,12 @@ async def disable_account( Raises ------ - :class:`APIError` + HTTPException Disabling the account failed. """ - await self.request( - routes.AUTH_ACCOUNT_DISABLE_ACCOUNT.compile(), mfa_ticket=mfa - ) + await self.request(routes.AUTH_ACCOUNT_DISABLE_ACCOUNT.compile(), mfa_ticket=mfa) - async def get_account(self) -> auth.PartialAccount: + async def get_account(self) -> PartialAccount: """|coro| Get account information from the current session. @@ -3274,12 +3744,10 @@ async def get_account(self) -> auth.PartialAccount: Raises ------ - :class:`APIError` + HTTPException Getting the account data failed. """ - return self.state.parser.parse_partial_account( - await self.request(routes.AUTH_ACCOUNT_FETCH_ACCOUNT.compile()) - ) + return self.state.parser.parse_partial_account(await self.request(routes.AUTH_ACCOUNT_FETCH_ACCOUNT.compile())) async def confirm_password_reset( self, token: str, /, *, new_password: str, remove_sessions: bool | None = None @@ -3294,23 +3762,23 @@ async def confirm_password_reset( The password reset token. new_password: :class:`str` New password for the account. - remove_sessions: :class:`bool` | None + remove_sessions: Optional[:class:`bool`] Whether to logout all sessions. Raises ------ - :class:`APIError` + HTTPException Sending the email failed. """ - j: raw.a.DataPasswordReset = { - "token": token, - "password": new_password, - "remove_sessions": remove_sessions, + payload: raw.a.DataPasswordReset = { + 'token': token, + 'password': new_password, + 'remove_sessions': remove_sessions, } await self.request( routes.AUTH_ACCOUNT_PASSWORD_RESET.compile(), authenticated=False, - json=j, + json=payload, ) async def resend_verification( @@ -3327,24 +3795,22 @@ async def resend_verification( ---------- email: :class:`str` The email associated with the account. - captcha: :class:`str` | None + captcha: Optional[:class:`str`] The CAPTCHA verification code. Raises ------ - :class:`APIError` + HTTPException Resending the verification mail failed. """ - j: raw.a.DataResendVerification = {"email": email, "captcha": captcha} + payload: raw.a.DataResendVerification = {'email': email, 'captcha': captcha} await self.request( routes.AUTH_ACCOUNT_RESEND_VERIFICATION.compile(), authenticated=False, - json=j, + json=payload, ) - async def send_password_reset( - self, *, email: str, captcha: str | None = None - ) -> None: + async def send_password_reset(self, *, email: str, captcha: str | None = None) -> None: """|coro| Send an email to reset account password. @@ -3353,22 +3819,22 @@ async def send_password_reset( ---------- email: :class:`str` The email associated with the account. - captcha: :class:`str` | None + captcha: Optional[:class:`str`] The CAPTCHA verification code. Raises ------ - :class:`APIError` + HTTPException Sending the email failed. """ - j: raw.a.DataSendPasswordReset = {"email": email, "captcha": captcha} + payload: raw.a.DataSendPasswordReset = {'email': email, 'captcha': captcha} await self.request( routes.AUTH_ACCOUNT_SEND_PASSWORD_RESET.compile(), authenticated=False, - json=j, + json=payload, ) - async def verify_email(self, code: str, /) -> auth.MFATicket | None: + async def verify_email(self, code: str, /) -> MFATicket | None: """|coro| Verify an email address. @@ -3380,48 +3846,44 @@ async def verify_email(self, code: str, /) -> auth.MFATicket | None: Raises ------ - :class:`APIError` + HTTPException Verifying the email address failed. """ - response = await self.request( - routes.AUTH_ACCOUNT_VERIFY_EMAIL.compile(code=code), authenticated=False - ) - if response is not None and isinstance(response, dict) and "ticket" in response: - return self.state.parser.parse_mfa_ticket(response["ticket"]) + response = await self.request(routes.AUTH_ACCOUNT_VERIFY_EMAIL.compile(code=code), authenticated=False) + if response is not None and isinstance(response, dict) and 'ticket' in response: + return self.state.parser.parse_mfa_ticket(response['ticket']) else: return None # MFA authentication control - async def _create_mfa_ticket(self, j: raw.a.MFAResponse, /) -> auth.MFATicket: + async def _create_mfa_ticket(self, payload: raw.a.MFAResponse, /) -> MFATicket: return self.state.parser.parse_mfa_ticket( - await self.request(routes.AUTH_MFA_CREATE_TICKET.compile(), json=j) + await self.request(routes.AUTH_MFA_CREATE_TICKET.compile(), json=payload) ) - async def create_password_ticket(self, password: str, /) -> auth.MFATicket: + async def create_password_ticket(self, password: str, /) -> MFATicket: """|coro| Create a new MFA ticket or validate an existing one. """ - j: raw.a.PasswordMFAResponse = {"password": password} - return await self._create_mfa_ticket(j) + payload: raw.a.PasswordMFAResponse = {'password': password} + return await self._create_mfa_ticket(payload) - async def create_recovery_code_ticket( - self, recovery_code: str, / - ) -> auth.MFATicket: + async def create_recovery_code_ticket(self, recovery_code: str, /) -> MFATicket: """|coro| Create a new MFA ticket or validate an existing one. """ - j: raw.a.RecoveryMFAResponse = {"recovery_code": recovery_code} - return await self._create_mfa_ticket(j) + payload: raw.a.RecoveryMFAResponse = {'recovery_code': recovery_code} + return await self._create_mfa_ticket(payload) - async def create_totp_ticket(self, totp_code: str, /) -> auth.MFATicket: + async def create_totp_ticket(self, totp_code: str, /) -> MFATicket: """|coro| Create a new MFA ticket or validate an existing one. """ - j: raw.a.TotpMFAResponse = {"totp_code": totp_code} - return await self._create_mfa_ticket(j) + payload: raw.a.TotpMFAResponse = {'totp_code': totp_code} + return await self._create_mfa_ticket(payload) async def get_recovery_codes(self) -> list[str]: """|coro| @@ -3430,33 +3892,28 @@ async def get_recovery_codes(self) -> list[str]: """ return await self.request(routes.AUTH_MFA_GENERATE_RECOVERY.compile()) - async def mfa_status(self) -> auth.MultiFactorStatus: + async def mfa_status(self) -> MFAStatus: """|coro| Gets MFA status of an account. """ - return self.state.parser.parse_multi_factor_status( - await self.request(routes.AUTH_MFA_FETCH_STATUS.compile()), - ) + resp: raw.a.MultiFactorStatus = await self.request(routes.AUTH_MFA_FETCH_STATUS.compile()) + return self.state.parser.parse_multi_factor_status(resp) async def generate_recovery_codes(self, *, mfa_ticket: str) -> list[str]: """|coro| Regenerates recovery codes for an account. """ - return await self.request( - routes.AUTH_MFA_GENERATE_RECOVERY.compile(), mfa_ticket=mfa_ticket - ) + resp: list[str] = await self.request(routes.AUTH_MFA_GENERATE_RECOVERY.compile(), mfa_ticket=mfa_ticket) + return resp - async def get_mfa_methods(self) -> list[auth.MFAMethod]: + async def get_mfa_methods(self) -> list[MFAMethod]: """|coro| Gets available MFA methods. """ - return [ - auth.MFAMethod(mm) - for mm in await self.request(routes.AUTH_MFA_GET_MFA_METHODS.compile()) - ] + return [MFAMethod(mm) for mm in await self.request(routes.AUTH_MFA_GET_MFA_METHODS.compile())] async def disable_totp_2fa(self, *, mfa_ticket: str) -> None: """|coro| @@ -3468,7 +3925,7 @@ async def disable_totp_2fa(self, *, mfa_ticket: str) -> None: mfa_ticket=mfa_ticket, ) - async def enable_totp_2fa(self, response: auth.MFAResponse, /) -> None: + async def enable_totp_2fa(self, response: MFAResponse, /) -> None: """|coro| Enables TOTP 2FA for an account. @@ -3483,104 +3940,138 @@ async def generate_totp_secret(self, *, mfa_ticket: str) -> str: Generates a new secret for TOTP. """ - d: raw.a.ResponseTotpSecret = await self.request( + resp: raw.a.ResponseTotpSecret = await self.request( routes.AUTH_MFA_TOTP_GENERATE_SECRET.compile(), mfa_ticket=mfa_ticket, ) - return d["secret"] + return resp['secret'] - async def edit_session( - self, session: core.ResolvableULID, *, friendly_name: str - ) -> auth.PartialSession: + async def edit_session(self, session: ULIDOr[PartialSession], *, friendly_name: str) -> PartialSession: """|coro| - Edit session information. + Edits the session information. + + Parameters + ---------- + session: :class:`ULIDOr`[:class:`PartialSession`] + The session to edit. + friendly_name: :class:`str` + The new device name. Because of Authifier limitation, this is not :class:`UndefinedOr`. + """ - j: raw.a.DataEditSession = {"friendly_name": friendly_name} + payload: raw.a.DataEditSession = {'friendly_name': friendly_name} return self.state.parser.parse_partial_session( - await self.request( - routes.AUTH_SESSION_EDIT.compile( - session_id=core.resolve_ulid(session), json=j - ) - ) + await self.request(routes.AUTH_SESSION_EDIT.compile(session_id=resolve_id(session)), json=payload) ) - async def get_all_sessions(self) -> list[auth.PartialSession]: + async def get_sessions(self) -> list[PartialSession]: """|coro| - Get all sessions associated with this account. + Retrieves all sessions associated with this account. + + Returns + ------- + List[:class:`PartialSession`] + The sessions. """ - sessions: list[raw.a.SessionInfo] = await self.request( - routes.AUTH_SESSION_FETCH_ALL.compile() - ) + sessions: list[raw.a.SessionInfo] = await self.request(routes.AUTH_SESSION_FETCH_ALL.compile()) return [self.state.parser.parse_partial_session(e) for e in sessions] - async def login_with_email( - self, email: str, password: str, /, *, friendly_name: str | None = None - ) -> auth.LoginResult: + async def login_with_email(self, email: str, password: str, /, *, friendly_name: str | None = None) -> LoginResult: """|coro| - Login to an account. + Logs in to an account using email and password. + + Parameters + ---------- + email: :class:`str` + The email. + password: :class:`str` + The password. + friendly_name: Optional[:class:`str`] + The device name. + + Returns + ------- + :class:`LoginResult` + The login response. """ - j: raw.a.EmailDataLogin = { - "email": email, - "password": password, - "friendly_name": friendly_name, + payload: raw.a.EmailDataLogin = { + 'email': email, + 'password': password, + 'friendly_name': friendly_name, } d: raw.a.ResponseLogin = await self.request( - routes.AUTH_SESSION_LOGIN.compile(), authenticated=False, json=j + routes.AUTH_SESSION_LOGIN.compile(), authenticated=False, json=payload ) return self.state.parser.parse_response_login(d, friendly_name) async def login_with_mfa( self, ticket: str, - by: auth.MFAResponse | None, + by: MFAResponse | None, /, *, friendly_name: str | None = None, - ) -> auth.Session | auth.AccountDisabled: + ) -> Session | AccountDisabled: """|coro| - Login to an account. + Logs in to an account. + + Parameters + ---------- + ticket: :class:`str` + The MFA ticket. + by: Optional[:class:`MFAResponse`] + The :class:`ByPassword`, :class:`ByRecoveryCode`, or :class:`ByTOTP` object. + friendly_name: Optional[:class:`str`] + The device name. + + Returns + ------- + Union[:class:`Session`, :class:`AccountDisabled`] + The session if successfully logged in, or :class:`AccountDisabled` containing user ID associated with the account. """ - j: raw.a.MFADataLogin = { - "mfa_ticket": ticket, - "mfa_response": by.build() if by else None, - "friendly_name": friendly_name, + payload: raw.a.MFADataLogin = { + 'mfa_ticket': ticket, + 'mfa_response': by.build() if by else None, + 'friendly_name': friendly_name, } d: raw.a.ResponseLogin = await self.request( - routes.AUTH_SESSION_LOGIN.compile(), authenticated=False, json=j + routes.AUTH_SESSION_LOGIN.compile(), authenticated=False, json=payload ) p = self.state.parser.parse_response_login(d, friendly_name) - assert not isinstance(p, auth.MFARequired), "Recursion detected" + assert not isinstance(p, MFARequired), 'Recursion detected' return p async def logout(self) -> None: """|coro| - Delete current session. + Deletes current session. """ await self.request(routes.AUTH_SESSION_LOGOUT.compile()) - async def revoke_session(self, session_id: core.ResolvableULID, /) -> None: + async def revoke_session(self, session_id: ULIDOr[PartialSession], /) -> None: """|coro| - Delete a specific active session. + Deletes a specific active session. """ - await self.request( - routes.AUTH_SESSION_REVOKE.compile(session_id=core.resolve_ulid(session_id)) - ) + await self.request(routes.AUTH_SESSION_REVOKE.compile(session_id=resolve_id(session_id))) async def revoke_all_sessions(self, *, revoke_self: bool | None = None) -> None: """|coro| - Delete all active sessions, optionally including current one. + Deletes all active sessions, optionally including current one. + + Parameters + ---------- + revoke_self: Optional[:class:`bool`] + Whether to revoke current session or not. """ - p = {} + params = {} if revoke_self is not None: - p["revoke_self"] = utils._bool(revoke_self) - await self.request(routes.AUTH_SESSION_REVOKE_ALL.compile(), params=p) + params['revoke_self'] = utils._bool(revoke_self) + await self.request(routes.AUTH_SESSION_REVOKE_ALL.compile(), params=params) -__all__ = ("DEFAULT_HTTP_USER_AGENT", "HTTPClient") +__all__ = ('DEFAULT_HTTP_USER_AGENT', '_STATUS_TO_ERRORS', 'HTTPClient') diff --git a/pyvolt/invite.py b/pyvolt/invite.py index 8afb2d0..c25b2ca 100644 --- a/pyvolt/invite.py +++ b/pyvolt/invite.py @@ -1,22 +1,57 @@ -import typing as t +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations from attrs import define, field +import typing -from . import cdn, channel as channels, core, server as servers +from .cdn import StatelessAsset, Asset +from .server import ServerFlags, Server from .state import State +if typing.TYPE_CHECKING: + from .channel import GroupChannel + @define(slots=True) class BaseInvite: """Representation of a invite on Revolt.""" - state: State = field(repr=False, hash=True, eq=True) + state: State = field(repr=False, kw_only=True) """State that controls this invite.""" - code: str = field(repr=True, hash=True, kw_only=True, eq=True) + code: str = field(repr=True, kw_only=True) """The invite code.""" - async def accept(self) -> "servers.Server | channels.GroupChannel": + def __hash__(self) -> int: + return hash(self.code) + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, BaseInvite) and self.code == other.code + + async def accept(self) -> Server | GroupChannel: """|coro| Accept this invite. @@ -26,9 +61,9 @@ async def accept(self) -> "servers.Server | channels.GroupChannel": Raises ------ - :class:`Forbidden` + Forbidden You're banned. - :class:`APIError` + HTTPException Accepting the invite failed. """ return await self.state.http.accept_invite(self.code) @@ -40,86 +75,74 @@ async def delete(self) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete invite or not creator of that invite. - :class:`APIError` + HTTPException Deleting the invite failed. """ - await self.state.http.delete_invite(self.code) + return await self.state.http.delete_invite(self.code) async def revoke(self) -> None: """|coro| - Alias to :meth:`BaseInvite.delete`. + Alias to :meth:`.delete`. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete invite or not creator of that invite. - :class:`APIError` + HTTPException Deleting the invite failed. """ - return await self.delete() + return await self.state.http.delete_invite(self.code) @define(slots=True) class ServerPublicInvite(BaseInvite): """Server channel invite.""" - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) """The ID of the server.""" - server_name: str = field(repr=True, hash=True, kw_only=True, eq=True) + server_name: str = field(repr=True, kw_only=True) """The name of the server.""" - internal_server_icon: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_server_icon: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless icon of the server.""" - internal_server_banner: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_server_banner: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless banner of the server.""" - flags: servers.ServerFlags | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + flags: ServerFlags | None = field(repr=True, kw_only=True) """The server flags.""" - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) """The ID of destination channel.""" - channel_name: str = field(repr=True, hash=True, kw_only=True, eq=True) + channel_name: str = field(repr=True, kw_only=True) """The name of destination channel.""" - channel_description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + channel_description: str | None = field(repr=True, kw_only=True) """The description of destination channel.""" - user_name: str = field(repr=True, hash=True, kw_only=True, eq=True) + user_name: str = field(repr=True, kw_only=True) """The name of inviter.""" - internal_user_avatar: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_user_avatar: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless avatar of the inviter.""" - members_count: int = field(repr=True, hash=True, kw_only=True, eq=True) + members_count: int = field(repr=True, kw_only=True) """The count of members in this server.""" - def server_icon(self) -> cdn.Asset | None: - """The icon of the server.""" - return self.internal_server_icon and self.internal_server_icon._stateful( - self.state, "icons" - ) + def server_icon(self) -> Asset | None: + """Optional[:class:`Asset`]: The icon of the server.""" + return self.internal_server_icon and self.internal_server_icon._stateful(self.state, 'icons') - def server_banner(self) -> cdn.Asset | None: - """The banner of the server.""" - return self.internal_server_banner and self.internal_server_banner._stateful( - self.state, "banners" - ) + def server_banner(self) -> Asset | None: + """Optional[:class:`Asset`]: The banner of the server.""" + return self.internal_server_banner and self.internal_server_banner._stateful(self.state, 'banners') - async def accept(self) -> servers.Server: + async def accept(self) -> Server: """|coro| Accept this invite. @@ -129,45 +152,40 @@ async def accept(self) -> servers.Server: Raises ------ - :class:`Forbidden` + Forbidden You're banned. - :class:`APIError` + HTTPException Accepting the invite failed. """ - response = await super().accept() - assert isinstance(response, servers.Server) - return response + server = await super().accept() + return server # type: ignore @define(slots=True) class GroupPublicInvite(BaseInvite): """Group channel invite.""" - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) """The ID of the destination channel.""" - channel_name: str = field(repr=True, hash=True, kw_only=True, eq=True) + channel_name: str = field(repr=True, kw_only=True) """The name of destination channel.""" - channel_description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + channel_description: str | None = field(repr=True, kw_only=True) """The description of destination channel.""" - user_name: str = field(repr=True, hash=True, kw_only=True, eq=True) + user_name: str = field(repr=True, kw_only=True) """The name of inviter.""" - internal_user_avatar: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_user_avatar: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless avatar of the inviter.""" @property - def user_avatar(self) -> cdn.Asset | None: - """The avatar of the inviter.""" - return self.internal_user_avatar and self.internal_user_avatar._stateful( - self.state, "avatars" - ) + def user_avatar(self) -> Asset | None: + """Optional[:class:`Asset`]: The avatar of the inviter.""" + return self.internal_user_avatar and self.internal_user_avatar._stateful(self.state, 'avatars') - async def accept(self) -> channels.GroupChannel: + async def accept(self) -> GroupChannel: """|coro| Accept this invite. @@ -177,19 +195,18 @@ async def accept(self) -> channels.GroupChannel: Raises ------ - :class:`Forbidden` + Forbidden You're banned. - :class:`APIError` + HTTPException Accepting the invite failed. """ - response = await super().accept() - assert isinstance(response, channels.GroupChannel) - return response + group = await super().accept() + return group # type: ignore @define(slots=True) class UnknownPublicInvite(BaseInvite): - d: dict[str, t.Any] = field(repr=True, hash=True, kw_only=True, eq=True) + payload: dict[str, typing.Any] = field(repr=True, kw_only=True) """The raw invite data.""" @@ -197,7 +214,7 @@ class UnknownPublicInvite(BaseInvite): class PrivateBaseInvite(BaseInvite): """Representation of a invite on Revolt.""" - creator_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + creator_id: str = field(repr=True, kw_only=True) """The ID of the user who created this invite.""" @@ -205,10 +222,10 @@ class PrivateBaseInvite(BaseInvite): class GroupInvite(PrivateBaseInvite): """Representation of a group invite on Revolt.""" - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) """ID of the server/group channel this invite points to.""" - async def accept(self) -> channels.GroupChannel: + async def accept(self) -> GroupChannel: """|coro| Accept this invite. @@ -218,27 +235,26 @@ async def accept(self) -> channels.GroupChannel: Raises ------ - :class:`Forbidden` + Forbidden You're banned. - :class:`APIError` + HTTPException Accepting the invite failed. """ - response = await super().accept() - assert isinstance(response, channels.GroupChannel) - return response + group = await super().accept() + return group # type: ignore @define(slots=True) class ServerInvite(PrivateBaseInvite): """Representation of a server invite on Revolt.""" - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) """ID of the server this invite points to.""" - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) """ID of the server channel this invite points to.""" - async def accept(self) -> servers.Server: + async def accept(self) -> Server: """|coro| Accept this invite. @@ -248,25 +264,24 @@ async def accept(self) -> servers.Server: Raises ------ - :class:`Forbidden` + Forbidden You're banned. - :class:`APIError` + HTTPException Accepting the invite failed. """ - response = await super().accept() - assert isinstance(response, servers.Server) - return response + server = await super().accept() + return server # type: ignore Invite = GroupInvite | ServerInvite __all__ = ( - "BaseInvite", - "ServerPublicInvite", - "GroupPublicInvite", - "UnknownPublicInvite", - "PrivateBaseInvite", - "GroupInvite", - "ServerInvite", - "Invite", + 'BaseInvite', + 'ServerPublicInvite', + 'GroupPublicInvite', + 'UnknownPublicInvite', + 'PrivateBaseInvite', + 'GroupInvite', + 'ServerInvite', + 'Invite', ) diff --git a/pyvolt/message.py b/pyvolt/message.py index 9494fab..677f3d5 100644 --- a/pyvolt/message.py +++ b/pyvolt/message.py @@ -1,60 +1,95 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import abc from attrs import define, field from datetime import datetime -from enum import IntFlag, StrEnum -import typing as t - -from . import ( - base, - cache as caching, - cdn, - channel as channels, - core, - embed, - emoji as emojis, - safety_reports, - server as servers, - user as users, +import typing + + +from . import cache as caching +from .base import Base +from .channel import TextChannel, ServerChannel +from .cdn import StatelessAsset, Asset, ResolvableResource, resolve_resource +from .core import ( + UNDEFINED, + UndefinedOr, + ULIDOr, + resolve_id, + ZID, ) - - -if t.TYPE_CHECKING: +from .embed import StatelessEmbed, Embed +from .emoji import ResolvableEmoji +from .enums import ContentReportReason +from .errors import NoData +from .flags import MessageFlags +from .server import Member +from .user import BaseUser, User + +if typing.TYPE_CHECKING: from . import raw from .state import State class Reply: - id: core.ULID - """The message ID reply to.""" + """Represents a message reply. - mention: bool - """Whether to mention author of that message.""" + Attributes + ---------- + id: :class:`str` + The ID of the message that being replied to. + mention: :class:`bool` + To mention author of reference message. + """ - __slots__ = ("id", "mention") + __slots__ = ('id', 'mention') - def __init__(self, id: core.ResolvableULID, mention: bool = False) -> None: - self.id = core.resolve_ulid(id) + def __init__(self, id: ULIDOr[BaseMessage], mention: bool = False) -> None: + self.id = resolve_id(id) self.mention = mention - def build(self) -> t.Any: + def build(self) -> raw.ReplyIntent: return { - "id": str(self.id), - "mention": self.mention, + 'id': self.id, + 'mention': self.mention, } class Interactions: - """Information to guide interactions on this message.""" + """Represents information how to guide interactions on the message. - reactions: list[str] - """Reactions which should always appear and be distinct.""" - - restrict_reactions: bool - """Whether reactions should be restricted to the given list. Can only be set to `True` if `reactions` list is of at least length 1. Defaults to `False`.""" + Attributes + ---------- + reactions: List[:class:`str`] + Reactions which should always appear and be distinct. + restrict_reactions: :class:`bool` + Whether reactions should be restricted to the given list. Can only be set to `True` if `reactions` list is of at least length 1. Defaults to `False`. + """ - __slots__ = ("reactions", "restrict_reactions") + __slots__ = ('reactions', 'restrict_reactions') def __init__(self, reactions: list[str], restrict_reactions: bool = False) -> None: self.reactions = reactions @@ -62,26 +97,26 @@ def __init__(self, reactions: list[str], restrict_reactions: bool = False) -> No def build(self) -> raw.Interactions: return { - "reactions": self.reactions, - "restrict_reactions": self.restrict_reactions, + 'reactions': self.reactions, + 'restrict_reactions': self.restrict_reactions, } class Masquerade: - """Name and / or avatar override information. + """Represents a override of name and/or avatar. - Parameters + Attributes ---------- - name: :class:`str` | `None` + name: Optional[:class:`str`] Replace the display name shown on this message. - avatar: :class:`str` | `None` + avatar: Optional[:class:`str`] Replace the avatar shown on this message (URL to image file). - colour: :class:`str` | `None` - Replace the display role colour shown on this message. Can be Any valid CSS colour. + colour: Optional[:class:`str`] + Replace the display role colour shown on this message. Can be any valid CSS colour. Must have `ManageRole` permission to use. """ - __slots__ = ("name", "avatar", "colour") + __slots__ = ('name', 'avatar', 'colour') def __init__( self, @@ -95,52 +130,45 @@ def __init__( self.colour = colour def build(self) -> raw.Masquerade: - j: raw.Masquerade = {} + payload: raw.Masquerade = {} if self.name is not None: - j["name"] = self.name + payload['name'] = self.name if self.avatar is not None: - j["avatar"] = self.avatar + payload['avatar'] = self.avatar if self.colour is not None: - j["colour"] = self.colour - return j + payload['colour'] = self.colour + return payload class SendableEmbed: - """Representation of a text embed before it is sent. + """Represents a text embed before it is sent. - Parameters + Attributes ---------- - icon_url: :class:`str` | `None` + icon_url: Optional[:class:`str`] The embed icon URL. - url: :class:`str` | `None` + url: Optional[:class:`str`] The embed URL. - title: :class:`str` | `None` + title: Optional[:class:`str`] The title of the embed. - description: :class:`str` | `None` + description: Optional[:class:`str`] The description of the embed. - media: :class:`cdn.ResolvableResource` | `None` - The file inside the embed, this is the ID of the file. - colour: :class:`str` | `None` - The embed color. This can be any valid [CSS color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value). - + media: Optional[:class:`ResolvableResource`] + The file inside the embed. + colour: Optional[:class:`str`] + The embed color. This can be any valid `CSS color None: self.icon_url = icon_url @@ -151,60 +179,63 @@ def __init__( self.colour = colour async def build(self, state: State) -> raw.SendableEmbed: - j: raw.SendableEmbed = {} + payload: raw.SendableEmbed = {} if self.icon_url is not None: - j["icon_url"] = self.icon_url + payload['icon_url'] = self.icon_url if self.url is not None: - j["url"] = self.url + payload['url'] = self.url if self.title is not None: - j["title"] = self.title + payload['title'] = self.title if self.description is not None: - j["description"] = self.description + payload['description'] = self.description if self.media is not None: - j["media"] = await cdn.resolve_resource( - state, self.media, tag="attachments" - ) + payload['media'] = await resolve_resource(state, self.media, tag='attachments') if self.colour is not None: - j["colour"] = self.colour - return j - - -class MessageSort(StrEnum): - RELEVANCE = "Relevance" - LATEST = "Latest" - OLDEST = "Oldest" + payload['colour'] = self.colour + return payload @define(slots=True) class MessageWebhook: """Information about the webhook bundled with Message.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """The name of the webhook - 1 to 32 chars.""" - avatar: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + avatar: str | None = field(repr=True, kw_only=True) """The ID of the avatar of the webhook, if it has one.""" @define(slots=True) -class BaseMessage(base.Base): - """Base representation of message in channel on Revolt.""" +class BaseMessage(Base): + """Represents a message in channel on Revolt.""" - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the channel this message was sent in.""" + channel_id: str = field(repr=True, kw_only=True) + """The ID of the channel this message was sent in.""" + + def __hash__(self) -> int: + return hash((self.channel_id, self.id)) + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, BaseMessage) + and self.channel_id == other.channel_id + and self.id == other.id + ) @property - def channel(self) -> channels.TextChannel: - """The channel this message was sent in.""" + def channel(self) -> TextChannel: + """:class:`TextChannel`: The channel this message was sent in.""" cache = self.state.cache if not cache: - return channels.TextChannel(state=self.state, id=self.channel_id) + return TextChannel(state=self.state, id=self.channel_id) channel = cache.get_channel(self.channel_id, caching._USER_REQUEST) if channel: - assert isinstance(channel, channels.TextChannel) + assert isinstance(channel, TextChannel), 'Cache returned non textable channel' return channel - return channels.TextChannel(state=self.state, id=self.channel_id) + return TextChannel(state=self.state, id=self.channel_id) async def acknowledge(self) -> None: """|coro| @@ -213,9 +244,9 @@ async def acknowledge(self) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to see that message. - :class:`APIError` + HTTPException Acknowledging message failed. """ return await self.state.http.acknowledge_message(self.channel_id, self.id) @@ -223,13 +254,13 @@ async def acknowledge(self) -> None: async def ack(self) -> None: """|coro| - Alias to :meth:`BaseMessage.acknowledge`. + Alias to :meth:`.acknowledge`. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to see that message. - :class:`APIError` + HTTPException Acknowledging message failed. """ return await self.acknowledge() @@ -241,9 +272,9 @@ async def delete(self) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete message. - :class:`APIError` + HTTPException Deleting the message failed. """ return await self.state.http.delete_message(self.channel_id, self.id) @@ -251,24 +282,33 @@ async def delete(self) -> None: async def edit( self, *, - content: core.UndefinedOr[str] = core.UNDEFINED, - embeds: core.UndefinedOr[list[SendableEmbed]] = core.UNDEFINED, + content: UndefinedOr[str] = UNDEFINED, + embeds: UndefinedOr[list[SendableEmbed]] = UNDEFINED, ) -> Message: """|coro| - Edits a message that you've previously sent. + Edits the message that you've previously sent. Parameters ---------- - content: :class:`str` | None - New content. - embeds: list[`:class:`SendableEmbed`] | None - New embeds. - """ + content: :class:`UndefinedOr`[:class:`str`] + The new content to replace the message with. + embeds: :class:`UndefinedOr`[List[:class:`SendableEmbed`]] + The new embeds to replace the original with. Must be a maximum of 10. To remove all embeds ``[]`` should be passed. - return await self.state.http.edit_message( - self.channel_id, self.id, content=content, embeds=embeds - ) + Raises + ------ + Forbidden + Tried to suppress a message without permissions or edited a message's content or embed that isn't yours. + HTTPException + Editing the message failed. + + Returns + ------- + :class:`Message` + The newly edited message. + """ + return await self.state.http.edit_message(self.channel_id, self.id, content=content, embeds=embeds) async def pin(self) -> None: """|coro| @@ -278,47 +318,41 @@ async def pin(self) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to pin messages. - :class:`APIError` + HTTPException Pinning the message failed. """ return await self.state.http.pin_message(self.channel_id, self.id) async def react( self, - emoji: emojis.ResolvableEmoji, + emoji: ResolvableEmoji, ) -> None: """|coro| - React to a given message. + Reacts to the message with given emoji. Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel to message was sent. - message: :class:`core.ResolvableULID` - Message react to. emoji: :class:`emojis.ResolvableEmoji` - Emoji to add. + The emoji to react with. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to react to message. - :class:`APIError` + HTTPException Reacting to message failed. """ - return await self.state.http.add_reaction_to_message( - self.channel_id, self.id, emoji - ) + return await self.state.http.add_reaction_to_message(self.channel_id, self.id, emoji) async def reply( self, content: str | None = None, *, idempotency_key: str | None = None, - attachments: list[cdn.ResolvableResource] | None = None, + attachments: list[ResolvableResource] | None = None, mention: bool = True, embeds: list[SendableEmbed] | None = None, masquerade: Masquerade | None = None, @@ -335,9 +369,11 @@ async def reply( :class:`Message` The message sent. - :class:`Forbidden` + Raises + ------ + Forbidden You do not have permissions to send messages. - :class:`APIError` + HTTPException Sending the message failed. """ return await self.state.http.send_message( @@ -354,7 +390,7 @@ async def reply( async def report( self, - reason: safety_reports.ContentReportReason, + reason: ContentReportReason, *, additional_context: str | None = None, ) -> None: @@ -367,12 +403,10 @@ async def report( Raises ------ - :class:`APIError` + HTTPException Trying to self-report, or reporting the message failed. """ - return await self.state.http.report_message( - self.id, reason, additional_context=additional_context - ) + return await self.state.http.report_message(self.id, reason, additional_context=additional_context) async def unpin(self) -> None: """|coro| @@ -382,18 +416,18 @@ async def unpin(self) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to unpin messages. - :class:`APIError` + HTTPException Unpinning the message failed. """ return await self.state.http.unpin_message(self.channel_id, self.id) async def unreact( self, - emoji: emojis.ResolvableEmoji, + emoji: ResolvableEmoji, *, - user: core.ResolvableULID | None = None, + user: ULIDOr[BaseUser] | None = None, remove_all: bool | None = None, ) -> None: """|coro| @@ -401,19 +435,21 @@ async def unreact( Remove your own, someone else's or all of a given reaction. Requires `ManageMessages` permission if changing other's reactions. - Parameters ---------- - channel: :class:`core.ResolvableULID` - Channel to message was sent. - message: :class:`core.ResolvableULID` - Message to remove reactions from. emoji: :class:`ResolvableEmoji` - Emoji to remove. - user: :class:`core.ResolvableULID` | `None` + The emoji to remove. + user: Optional[:class:`ULIDOr`[:class:`BaseUser`]] Remove reactions from this user. Requires `ManageMessages` permission if provided. - remove_all: :class:`bool` | `None` + remove_all: Optional[:class:`bool`] Whether to remove all reactions. Requires `ManageMessages` permission if provided. + + Raises + ------ + Forbidden + You do not have permissions to remove reactions from message. + HTTPException + Removing reactions from message failed. """ return await self.state.http.remove_reactions_from_message( self.channel_id, self.id, emoji, user=user, remove_all=remove_all @@ -424,31 +460,28 @@ async def unreact( class PartialMessage(BaseMessage): """Partial representation of message in channel on Revolt.""" - content: core.UndefinedOr[str] = field(repr=True, hash=True, kw_only=True, eq=True) + content: UndefinedOr[str] = field(repr=True, kw_only=True) """New message content.""" - edited_at: core.UndefinedOr[datetime] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + edited_at: UndefinedOr[datetime] = field(repr=True, kw_only=True) """When message was edited.""" - internal_embeds: core.UndefinedOr[list[embed.StatelessEmbed]] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_embeds: UndefinedOr[list[StatelessEmbed]] = field(repr=True, kw_only=True) """New message embeds.""" - reactions: core.UndefinedOr[dict[str, tuple[core.ULID, ...]]] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + pinned: UndefinedOr[bool] = field(repr=True, kw_only=True) + """Whether the message was just pinned.""" + + reactions: UndefinedOr[dict[str, tuple[str, ...]]] = field(repr=True, kw_only=True) """New message reactions.""" @property - def embeds(self) -> core.UndefinedOr[list[embed.Embed]]: - """New message embeds.""" + def embeds(self) -> UndefinedOr[list[Embed]]: + """:class:`UndefinedOr`[List[:class:`Embed`]]: New message embeds.""" return ( [e._stateful(self.state) for e in self.internal_embeds] - if core.is_defined(self.internal_embeds) - else core.UNDEFINED + if self.internal_embeds is not UNDEFINED + else UNDEFINED ) @@ -456,184 +489,975 @@ def embeds(self) -> core.UndefinedOr[list[embed.Embed]]: class MessageAppendData(BaseMessage): """Appended data to message in channel on Revolt.""" - internal_embeds: core.UndefinedOr[list[embed.StatelessEmbed]] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - """New message appended stateless embeds.""" + internal_embeds: UndefinedOr[list[StatelessEmbed]] = field(repr=True, kw_only=True) + """Appended stateless embeds.""" @property - def embeds(self) -> core.UndefinedOr[list[embed.Embed]]: - """New message appended embeds.""" + def embeds(self) -> UndefinedOr[list[Embed]]: + """:class:`UndefinedOr`[List[:class:`Embed`]]: Appended embeds.""" return ( [e._stateful(self.state) for e in self.internal_embeds] - if core.is_defined(self.internal_embeds) - else core.UNDEFINED + if self.internal_embeds is not UNDEFINED + else UNDEFINED + ) + + +class BaseSystemEvent(abc.ABC): + """Represents system event within message.""" + + +@define(slots=True, eq=True) +class TextSystemEvent(BaseSystemEvent): + content: str = field(repr=True, kw_only=True, eq=True) + """The event contents.""" + + def _stateful(self, message: Message) -> TextSystemEvent: + return self + + +@define(slots=True) +class StatelessUserAddedSystemEvent(BaseSystemEvent): + _user: User | Member | str = field(repr=False, kw_only=True, alias='internal_user') + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, StatelessUserAddedSystemEvent) + and self.user_id == other.user_id + and self.by_id == other.by_id + ) + + @property + def user_id(self) -> str: + """:class:`str`: The ID of the user that was added.""" + if isinstance(self._user, (User, Member)): + return self._user.id + return self._user + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was added.""" + if isinstance(self._user, (User, Member)): + return self._user + + _by: User | Member | str = field(repr=False, kw_only=True, alias='internal_by') + + @property + def by_id(self) -> str: + """:class:`str`: The ID of the user that added this user.""" + if isinstance(self._by, (User, Member)): + return self._by.id + return self._by + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that added this user.""" + if isinstance(self._by, (User, Member)): + return self._by + + def _stateful(self, message: Message) -> UserAddedSystemEvent: + return UserAddedSystemEvent( + message=message, + internal_user=self._user, + internal_by=self._by, + ) + + +@define(slots=True) +class UserAddedSystemEvent(StatelessUserAddedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was added.""" + if isinstance(self._user, (User, Member)): + return self._user + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._user, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._user, + caching._UNDEFINED, + ) + + @property + def user(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that was added.""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'added user') + return user + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that added this user.""" + if isinstance(self._by, (User, Member)): + return self._by + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._by, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._by, + caching._UNDEFINED, + ) + + @property + def by(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that added this user.""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'added user') + return user + + +@define(slots=True) +class StatelessUserRemovedSystemEvent(BaseSystemEvent): + _user: User | Member | str = field(repr=False, kw_only=True, alias='internal_user') + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, StatelessUserRemovedSystemEvent) + and self.user_id == other.user_id + and self.by_id == other.by_id + ) + + @property + def user_id(self) -> str: + """:class:`str`: The ID of the user that was removed.""" + if isinstance(self._user, (User, Member)): + return self._user.id + return self._user + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was removed.""" + if isinstance(self._user, (User, Member)): + return self._user + + _by: User | Member | str = field(repr=False, kw_only=True, alias='internal_by') + + @property + def by_id(self) -> str: + """:class:`str`: The ID of the user that removed this user.""" + if isinstance(self._by, (User, Member)): + return self._by.id + return self._by + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that removed this user.""" + if isinstance(self._by, (User, Member)): + return self._by + + def _stateful(self, message: Message) -> UserRemovedSystemEvent: + return UserRemovedSystemEvent( + message=message, + internal_user=self._user, + internal_by=self._by, + ) + + +@define(slots=True) +class UserRemovedSystemEvent(StatelessUserRemovedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was removed.""" + if isinstance(self._user, (User, Member)): + return self._user + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._user, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._user, + caching._UNDEFINED, + ) + + @property + def user(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that was removed.""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'removed user') + return user + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that removed this user.""" + if isinstance(self._by, (User, Member)): + return self._by + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._by, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._by, + caching._UNDEFINED, + ) + + @property + def by(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that removed this user.""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'remover') + return user + + +@define(slots=True) +class StatelessUserJoinedSystemEvent(BaseSystemEvent): + _user: User | Member | str = field(repr=False, kw_only=True, alias='internal_user') + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, StatelessUserJoinedSystemEvent) and self.user_id == other.user_id + + @property + def user_id(self) -> str: + """:class:`str`: The ID of the user that joined this server/group.""" + if isinstance(self._user, (User, Member)): + return self._user.id + return self._user + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that joined this server/group.""" + if isinstance(self._user, (User, Member)): + return self._user + + def _stateful(self, message: Message) -> UserJoinedSystemEvent: + return UserJoinedSystemEvent( + message=message, + internal_user=self._user, + ) + + +@define(slots=True) +class UserJoinedSystemEvent(StatelessUserJoinedSystemEvent): + message: Message = field(repr=False, kw_only=True) + """The message that holds this system event.""" + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was added.""" + if isinstance(self._user, (User, Member)): + return self._user + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._user, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._user, + caching._UNDEFINED, + ) + + @property + def user(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that joined this server/group..""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'joined user') + return user + + +@define(slots=True) +class StatelessUserLeftSystemEvent(BaseSystemEvent): + _user: User | Member | str = field(repr=False, kw_only=True, alias='internal_user') + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, StatelessUserLeftSystemEvent) and self.user_id == other.user_id + + @property + def user_id(self) -> str: + """:class:`str`: The ID of the user that left this server/group.""" + if isinstance(self._user, (User, Member)): + return self._user.id + return self._user + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that left this server/group.""" + if isinstance(self._user, (User, Member)): + return self._user + + def _stateful(self, message: Message) -> UserLeftSystemEvent: + return UserLeftSystemEvent( + message=message, + internal_user=self._user, ) @define(slots=True) -class SystemEvent(abc.ABC): - """Representation of system event within message.""" +class UserLeftSystemEvent(StatelessUserLeftSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that left this server/group.""" + if isinstance(self._user, (User, Member)): + return self._user + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._user, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._user, + caching._UNDEFINED, + ) + + @property + def user(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that left this server/group..""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'left user') + return user @define(slots=True) -class TextSystemEvent(SystemEvent): - content: str = field(repr=True, hash=True, kw_only=True, eq=True) - """Event contents.""" +class StatelessUserKickedSystemEvent(BaseSystemEvent): + _user: User | Member | str = field(repr=False, kw_only=True, alias='internal_user') + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, StatelessUserKickedSystemEvent) and self.user_id == other.user_id + + @property + def user_id(self) -> str: + """:class:`str`: The ID of the user that was kicked from this server.""" + if isinstance(self._user, (User, Member)): + return self._user.id + return self._user + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was kicked from this server.""" + if isinstance(self._user, (User, Member)): + return self._user + + def _stateful(self, message: Message) -> UserKickedSystemEvent: + return UserKickedSystemEvent( + message=message, + internal_user=self._user, + ) @define(slots=True) -class UserAddedSystemEvent(SystemEvent): - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that was added.""" +class UserKickedSystemEvent(StatelessUserKickedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was kicked from this server.""" + if isinstance(self._user, (User, Member)): + return self._user + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._user, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._user, + caching._UNDEFINED, + ) - by: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that added this user.""" + @property + def user(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that was kicked from this server.""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'kicked user') + return user @define(slots=True) -class UserRemovedSystemEvent(SystemEvent): - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that was removed.""" +class StatelessUserBannedSystemEvent(BaseSystemEvent): + _user: User | Member | str = field(repr=False, kw_only=True, alias='internal_user') + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, StatelessUserBannedSystemEvent) and self.user_id == other.user_id - by: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that removed this user.""" + @property + def user_id(self) -> str: + """:class:`str`: The ID of the user that was banned from this server.""" + if isinstance(self._user, (User, Member)): + return self._user.id + return self._user + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was banned from this server.""" + if isinstance(self._user, (User, Member)): + return self._user + + def _stateful(self, message: Message) -> UserBannedSystemEvent: + return UserBannedSystemEvent( + message=message, + internal_user=self._user, + ) @define(slots=True) -class UserJoinedSystemEvent(SystemEvent): - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that joined this server/group.""" +class UserBannedSystemEvent(StatelessUserBannedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_user(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that was banned from this server.""" + if isinstance(self._user, (User, Member)): + return self._user + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._user, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._user, + caching._UNDEFINED, + ) + + @property + def user(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that was banned from this server.""" + user = self.get_user() + if not user: + raise NoData(self.user_id, 'banned user') + return user @define(slots=True) -class UserLeftSystemEvent(SystemEvent): - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that left this server/group.""" +class StatelessChannelRenamedSystemEvent(BaseSystemEvent): + name: str = field(repr=True, kw_only=True) + """The new name of this group.""" + + _by: User | str = field(repr=False, kw_only=True, alias='internal_by') + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, StatelessChannelRenamedSystemEvent) + and self.name == other.name + and self.by_id == other.by_id + ) + + @property + def by_id(self) -> str: + """:class:`str`: The ID of the user that renamed this group.""" + if isinstance(self._by, User): + return self._by.id + return self._by + + def get_by(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that renamed this group.""" + if isinstance(self._by, User): + return self._by + + def _stateful(self, message: Message) -> ChannelRenamedSystemEvent: + return ChannelRenamedSystemEvent( + message=message, + name=self.name, + internal_by=self._by, + ) @define(slots=True) -class UserKickedSystemEvent(SystemEvent): - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that was kicked from this server.""" +class ChannelRenamedSystemEvent(StatelessChannelRenamedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_by(self) -> User | None: + """Optional[:class:`User`: Tries to get user that renamed this group.""" + if isinstance(self._by, User): + return self._by + state = self.message.state + if not state.cache: + return + return state.cache.get_user( + self._by, + caching._UNDEFINED, + ) + + @property + def by(self) -> User: + """Union[:class:`User`]: The user that renamed this group.""" + by = self.get_by() + if not by: + raise NoData(self.by_id, 'user') + return by @define(slots=True) -class UserBannedSystemEvent(SystemEvent): - id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that was banned from this server.""" +class StatelessChannelDescriptionChangedSystemEvent(BaseSystemEvent): + _by: User | str = field(repr=False, kw_only=True, alias='internal_by') + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, StatelessChannelDescriptionChangedSystemEvent) + and self.by_id == other.by_id + ) + + @property + def by_id(self) -> str: + """:class:`str`: The ID of the user that changed description of this group.""" + if isinstance(self._by, User): + return self._by.id + return self._by + + def get_by(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that changed description of this group.""" + if isinstance(self._by, User): + return self._by + + def _stateful(self, message: Message) -> ChannelDescriptionChangedSystemEvent: + return ChannelDescriptionChangedSystemEvent( + message=message, + internal_by=self._by, + ) @define(slots=True) -class ChannelRenamedSystemEvent(SystemEvent): - by: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that renamed this group.""" +class ChannelDescriptionChangedSystemEvent(StatelessChannelDescriptionChangedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_by(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that changed description of this group.""" + if isinstance(self._by, User): + return self._by + state = self.message.state + if not state.cache: + return + return state.cache.get_user( + self._by, + caching._UNDEFINED, + ) + + @property + def by(self) -> User: + """:class:`User`: The user that changed description of this group.""" + by = self.get_by() + if not by: + raise NoData(self.by_id, 'user') + return by @define(slots=True) -class ChannelDescriptionChangedSystemEvent(SystemEvent): - by: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that changed description of this group.""" +class StatelessChannelIconChangedSystemEvent(BaseSystemEvent): + _by: User | str = field(repr=False, kw_only=True, alias='internal_by') + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, StatelessChannelIconChangedSystemEvent) and self.by_id == other.by_id + + @property + def by_id(self) -> str: + """:class:`str`: The ID of the user that changed icon of this group.""" + if isinstance(self._by, User): + return self._by.id + return self._by + + def get_by(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that changed icon of this group.""" + if isinstance(self._by, User): + return self._by + + def _stateful(self, message: Message) -> ChannelIconChangedSystemEvent: + return ChannelIconChangedSystemEvent( + message=message, + internal_by=self._by, + ) @define(slots=True) -class ChannelIconChangedSystemEvent(SystemEvent): - by: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that changed icon of this group.""" +class ChannelIconChangedSystemEvent(StatelessChannelIconChangedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_by(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that changed icon of this group.""" + if isinstance(self._by, User): + return self._by + state = self.message.state + if not state.cache: + return + return state.cache.get_user( + self._by, + caching._UNDEFINED, + ) + + @property + def by(self) -> User: + """:class:`User`: The user that changed icon of this group.""" + by = self.get_by() + if not by: + raise NoData(self.by_id, 'user') + return by @define(slots=True) -class ChannelOwnershipChangedSystemEvent(SystemEvent): - from_: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that was previous owner of this group.""" +class StatelessChannelOwnershipChangedSystemEvent(BaseSystemEvent): + _from: User | str = field(repr=False, kw_only=True, alias='internal_from') + _to: User | str = field(repr=False, kw_only=True, alias='internal_to') - to: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the user that became owner of this group.""" + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, StatelessChannelOwnershipChangedSystemEvent) + and self.from_id == other.from_id + and self.to_id == other.to_id + ) + + @property + def from_id(self) -> str: + """:class:`str`: The ID of the user that was previous owner of this group.""" + if isinstance(self._from, User): + return self._from.id + return self._from + def get_from(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that was previous owner of this group.""" + if isinstance(self._from, User): + return self._from -class MessageFlags(IntFlag): - SUPPRESS_NOTIFICATIONS = 1 << 0 - """Whether the message will not send push/desktop notifications.""" + @property + def to_id(self) -> str: + """:class:`str`: The ID of the user that became owner of this group.""" + if isinstance(self._from, User): + return self._from.id + return self._from + + def get_to(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that became owner of this group.""" + if isinstance(self._from, User): + return self._from + + def _stateful(self, message: Message) -> ChannelOwnershipChangedSystemEvent: + return ChannelOwnershipChangedSystemEvent( + message=message, + internal_from=self._from, + internal_to=self._to, + ) + + +@define(slots=True) +class ChannelOwnershipChangedSystemEvent(StatelessChannelOwnershipChangedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_from(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that was previous owner of this group.""" + if isinstance(self._from, User): + return self._from + state = self.message.state + if not state.cache: + return + return state.cache.get_user( + self._from, + caching._UNDEFINED, + ) + + @property + def from_(self) -> User: + """:class:`User`: The user that was previous owner of this group.""" + from_ = self.get_from() + if not from_: + raise NoData(self.from_id, 'user') + return from_ + + def get_to(self) -> User | None: + """Optional[:class:`User`]: Tries to get user that became owner of this group.""" + if isinstance(self._to, User): + return self._to + state = self.message.state + if not state.cache: + return + return state.cache.get_user( + self._to, + caching._UNDEFINED, + ) + + @property + def to(self) -> User: + """:class:`User`: The user that became owner of this group.""" + to = self.get_from() + if not to: + raise NoData(self.to_id, 'user') + return to + + +@define(slots=True) +class StatelessMessagePinnedSystemEvent(BaseSystemEvent): + pinned_message_id: str = field(repr=True, kw_only=True) + """The ID of the message that was pinned.""" + + _by: User | Member | str = field(repr=False, kw_only=True, alias='internal_by') + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, StatelessMessagePinnedSystemEvent) + and self.pinned_message_id == other.pinned_message_id + and self.by_id == other.by_id + ) + + @property + def by_id(self) -> str: + """:class:`str`: The ID of the user that pinned a message.""" + if isinstance(self._by, (User, Member)): + return self._by.id + return self._by + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that pinned a message.""" + if isinstance(self._by, (User, Member)): + return self._by + + def _stateful(self, message: Message) -> MessagePinnedSystemEvent: + return MessagePinnedSystemEvent( + message=message, + pinned_message_id=self.pinned_message_id, + internal_by=self._by, + ) + + +@define(slots=True) +class MessagePinnedSystemEvent(StatelessMessagePinnedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that pinned a message.""" + if isinstance(self._by, (User, Member)): + return self._by + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._by, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._by, + caching._UNDEFINED, + ) + + @property + def by(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that pinned a message.""" + by = self.get_by() + if not by: + raise NoData(self.by_id, 'user') + return by + + +@define(slots=True) +class StatelessMessageUnpinnedSystemEvent(BaseSystemEvent): + unpinned_message_id: str = field(repr=True, kw_only=True) + """The ID of the message that was unpinned.""" + + _by: User | Member | str = field(repr=False, kw_only=True, alias='internal_by') + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, StatelessMessageUnpinnedSystemEvent) + and self.unpinned_message_id == other.unpinned_message_id + and self.by_id == other.by_id + ) + + @property + def by_id(self) -> str: + """:class:`str`: The ID of the user that unpinned a message.""" + if isinstance(self._by, (User, Member)): + return self._by.id + return self._by + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that unpinned a message.""" + if isinstance(self._by, (User, Member)): + return self._by + + def _stateful(self, message: Message) -> MessageUnpinnedSystemEvent: + return MessageUnpinnedSystemEvent( + message=message, + unpinned_message_id=self.unpinned_message_id, + internal_by=self._by, + ) + + +@define(slots=True) +class MessageUnpinnedSystemEvent(StatelessMessageUnpinnedSystemEvent): + message: Message = field(repr=False, hash=False, kw_only=True, eq=False) + """The message that holds this system event.""" + + def get_by(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get user that unpinned a message.""" + if isinstance(self._by, (User, Member)): + return self._by + state = self.message.state + if not state.cache: + return + channel = self.message.channel + if not isinstance(channel, ServerChannel): + return state.cache.get_user( + self._by, + caching._UNDEFINED, + ) + return state.cache.get_server_member( + channel.server_id, + self._by, + caching._UNDEFINED, + ) + + @property + def by(self) -> User | Member: + """Union[:class:`User`, class:`Member`]: The user that unpinned a message.""" + by = self.get_by() + if not by: + raise NoData(self.by_id, 'user') + return by + + +StatelessSystemEvent = ( + TextSystemEvent + | StatelessUserAddedSystemEvent + | StatelessUserRemovedSystemEvent + | StatelessUserJoinedSystemEvent + | StatelessUserLeftSystemEvent + | StatelessUserKickedSystemEvent + | StatelessUserBannedSystemEvent + | StatelessChannelDescriptionChangedSystemEvent + | StatelessChannelIconChangedSystemEvent + | StatelessChannelOwnershipChangedSystemEvent + | StatelessMessagePinnedSystemEvent + | StatelessMessageUnpinnedSystemEvent +) + +SystemEvent = ( + TextSystemEvent + | UserAddedSystemEvent + | UserRemovedSystemEvent + | UserJoinedSystemEvent + | UserLeftSystemEvent + | UserKickedSystemEvent + | UserBannedSystemEvent + | ChannelDescriptionChangedSystemEvent + | ChannelIconChangedSystemEvent + | ChannelOwnershipChangedSystemEvent + | MessagePinnedSystemEvent + | MessageUnpinnedSystemEvent +) @define(slots=True) class Message(BaseMessage): """Representation of message in channel on Revolt.""" - nonce: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + nonce: str | None = field(repr=True, kw_only=True) """Unique value generated by client sending this message.""" - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, kw_only=True) """ID of the channel this message was sent in.""" - _author: users.User | servers.Member | core.ULID = field( - repr=False, hash=True, kw_only=True, eq=True, alias="internal_author" - ) + _author: User | Member | str = field(repr=False, kw_only=True, alias='internal_author') - webhook: MessageWebhook | None = field(repr=True, hash=True, kw_only=True, eq=True) + webhook: MessageWebhook | None = field(repr=True, kw_only=True) """The webhook that sent this message.""" - content: str = field(repr=True, hash=True, kw_only=True, eq=True) + content: str = field(repr=True, kw_only=True) """The message content.""" - system_event: SystemEvent | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) - """The system event information, occured in this message, if any.""" + internal_system_event: StatelessSystemEvent | None = field(repr=True, kw_only=True) + """The stateless system event information, occured in this message, if any.""" - internal_attachments: list[cdn.StatelessAsset] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_attachments: list[StatelessAsset] = field(repr=True, kw_only=True) """The stateless attachments on this message.""" - edited_at: datetime | None = field(repr=True, hash=True, kw_only=True, eq=True) + edited_at: datetime | None = field(repr=True, kw_only=True) """Timestamp at which this message was last edited.""" - internal_embeds: list[embed.StatelessEmbed] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_embeds: list[StatelessEmbed] = field(repr=True, kw_only=True) """The attached stateless embeds to this message.""" - mention_ids: list[core.ULID] = field(repr=True, hash=True, kw_only=True, eq=True) + mention_ids: list[str] = field(repr=True, kw_only=True) """The list of user IDs mentioned in this message.""" - replies: list[core.ULID] = field(repr=True, hash=True, kw_only=True, eq=True) + replies: list[str] = field(repr=True, kw_only=True) """The list of message ID this message is replying to.""" - reactions: dict[str, tuple[core.ULID, ...]] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + reactions: dict[str, tuple[str, ...]] = field(repr=True, kw_only=True) """Mapping of emojis to array of user IDs.""" - interactions: Interactions | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + interactions: Interactions | None = field(repr=True, kw_only=True) """The information about how this message should be interacted with.""" - masquerade: Masquerade | None = field(repr=True, hash=True, kw_only=True, eq=True) + masquerade: Masquerade | None = field(repr=True, kw_only=True) """The name and / or avatar overrides for this message.""" - pinned: bool = field(repr=True, hash=True, kw_only=True, eq=True) + pinned: bool = field(repr=True, kw_only=True) """Whether this message is pinned.""" - flags: MessageFlags = field(repr=True, hash=True, kw_only=True, eq=True) + flags: MessageFlags = field(repr=True, kw_only=True) """The message flags.""" def _append(self, data: MessageAppendData) -> None: - if core.is_defined(data.internal_embeds): + if data.internal_embeds is not UNDEFINED: self.internal_embeds.extend(data.internal_embeds) def _update(self, data: PartialMessage) -> None: - if core.is_defined(data.content): + if data.content is not UNDEFINED: self.content = data.content - if core.is_defined(data.edited_at): + if data.edited_at is not UNDEFINED: self.edited_at = data.edited_at - if core.is_defined(data.internal_embeds): + if data.internal_embeds is not UNDEFINED: self.internal_embeds = data.internal_embeds - if core.is_defined(data.reactions): + if data.pinned is not UNDEFINED: + self.pinned = data.pinned + if data.reactions is not UNDEFINED: self.reactions = data.reactions - def _react(self, user_id: core.ULID, emoji: str) -> None: + def _react(self, user_id: str, emoji: str) -> None: try: reaction = self.reactions[emoji] except KeyError: @@ -641,101 +1465,115 @@ def _react(self, user_id: core.ULID, emoji: str) -> None: else: self.reactions[emoji] = (*reaction, user_id) - def _unreact(self, user_id: core.ULID, emoji: str) -> None: + def _unreact(self, user_id: str, emoji: str) -> None: try: reaction = self.reactions[emoji] except KeyError: self.reactions[emoji] = () else: - self.reactions[emoji] = tuple( - reactor_id for reactor_id in reaction if reactor_id != user_id - ) + self.reactions[emoji] = tuple(reactor_id for reactor_id in reaction if reactor_id != user_id) def _clear(self, emoji: str) -> None: self.reactions.pop(emoji, None) - def get_author(self) -> users.User | servers.Member | None: - if isinstance(self._author, (users.User, servers.Member)): + def get_author(self) -> User | Member | None: + """Optional[Union[:class:`User`, :class:`Member`]]: Tries to get message author.""" + if isinstance(self._author, (User, Member)): return self._author - if not self.state.cache: + if self._author == ZID: + return self.state.system + state = self.state + cache = state.cache + if not cache: return None - cache = self.state.cache - channel = self.get_channel() - if channel is None or not isinstance(channel, channels.ServerChannel): + channel = self.channel + if not isinstance(channel, ServerChannel): return cache.get_user( self._author, - # PROVIDE_CTX: caching.MessageContext(type=caching.ContextType.MESSAGE, message=self), - caching._UNDEFINED, + caching.MessageContext(type=caching.ContextType.message, message=self) + if 'Message.get_author' in state.provide_cache_context_in + else caching._UNDEFINED, ) return cache.get_server_member( channel.server_id, self._author, - # PROVIDE_CTX: caching.MessageContext(type=caching.ContextType.MESSAGE, message=self), - caching._UNDEFINED, + caching.MessageContext(type=caching.ContextType.message, message=self) + if 'Message.get_author' in state.provide_cache_context_in + else caching._UNDEFINED, ) - def get_channel(self) -> channels.TextChannel | None: - cache = self.state.cache - if not cache: - return None - channel = cache.get_channel( - self.channel_id, - caching.MessageContext(type=caching.ContextType.MESSAGE, message=self), - ) - if channel is None or not isinstance(channel, channels.TextChannel): - return None - return channel + def system_event(self) -> SystemEvent | None: + """Optional[:class:`SystemEvent`]: The system event information, occured in this message, if any.""" + if self.internal_system_event: + return self.internal_system_event._stateful(self) @property - def attachments(self) -> list[cdn.Asset]: - """The attachments on this message.""" - return [ - a._stateful(self.state, "attachments") for a in self.internal_attachments - ] + def attachments(self) -> list[Asset]: + """List[:class:`Asset`]: The attachments on this message.""" + return [a._stateful(self.state, 'attachments') for a in self.internal_attachments] @property - def author(self) -> users.User | servers.Member: + def author(self) -> User | Member: + """Union[:class:`User`, :class:`Member`]: The user that sent this message.""" author = self.get_author() if not author: - raise TypeError("No author was found in cache.") + raise NoData(self.author_id, 'message author') return author @property - def author_id(self) -> core.ULID: - """The ID of the user or webhook that sent this message.""" - if isinstance(self._author, (users.User, servers.Member)): + def author_id(self) -> str: + """:class:`str`: The ID of the user or webhook that sent this message.""" + if isinstance(self._author, (User, Member)): return self._author.id return self._author @property - def embeds(self) -> list[embed.Embed]: - """The attached embeds to this message.""" + def embeds(self) -> list[Embed]: + """List[:class:`Embed`]: The attached embeds to this message.""" return [e._stateful(self.state) for e in self.internal_embeds] + def is_silent(self) -> bool: + """:class:`bool`: Whether the message is silent.""" + return self.flags.suppress_notifications + __all__ = ( - "Reply", - "Interactions", - "Masquerade", - "SendableEmbed", - "MessageSort", - "MessageWebhook", - "BaseMessage", - "PartialMessage", - "MessageAppendData", - "SystemEvent", - "TextSystemEvent", - "UserAddedSystemEvent", - "UserRemovedSystemEvent", - "UserJoinedSystemEvent", - "UserLeftSystemEvent", - "UserKickedSystemEvent", - "UserBannedSystemEvent", - "ChannelRenamedSystemEvent", - "ChannelDescriptionChangedSystemEvent", - "ChannelIconChangedSystemEvent", - "ChannelOwnershipChangedSystemEvent", - "MessageFlags", - "Message", + 'Reply', + 'Interactions', + 'Masquerade', + 'SendableEmbed', + 'MessageWebhook', + 'BaseMessage', + 'PartialMessage', + 'MessageAppendData', + 'BaseSystemEvent', + 'TextSystemEvent', + 'StatelessUserAddedSystemEvent', + 'UserAddedSystemEvent', + 'StatelessUserRemovedSystemEvent', + 'UserRemovedSystemEvent', + 'StatelessUserJoinedSystemEvent', + 'UserJoinedSystemEvent', + 'StatelessUserLeftSystemEvent', + 'UserLeftSystemEvent', + 'StatelessUserKickedSystemEvent', + 'UserKickedSystemEvent', + 'StatelessUserBannedSystemEvent', + 'UserBannedSystemEvent', + 'StatelessChannelRenamedSystemEvent', + 'ChannelRenamedSystemEvent', + 'StatelessChannelDescriptionChangedSystemEvent', + 'ChannelDescriptionChangedSystemEvent', + 'StatelessChannelIconChangedSystemEvent', + 'ChannelIconChangedSystemEvent', + 'StatelessChannelOwnershipChangedSystemEvent', + 'ChannelOwnershipChangedSystemEvent', + 'StatelessMessagePinnedSystemEvent', + 'MessagePinnedSystemEvent', + 'StatelessMessageUnpinnedSystemEvent', + 'MessageUnpinnedSystemEvent', + 'StatelessSystemEvent', + 'SystemEvent', + 'Message', ) diff --git a/pyvolt/parser.py b/pyvolt/parser.py index 4ce7fc8..00f87b9 100644 --- a/pyvolt/parser.py +++ b/pyvolt/parser.py @@ -1,32 +1,215 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations +from copy import copy from datetime import datetime +from functools import partial import logging -import typing as t - - -from . import ( - auth, - bot as bots, - cdn, - channel as channels, - core, - discovery, - embed as embeds, - emoji as emojis, - events, - invite as invites, - message as messages, - permissions as permissions_, - read_state, - server as servers, - user_settings, - user as users, - webhook as webhooks, +import typing + +from . import discovery +from .auth import ( + PartialAccount, + MFATicket, + WebPushSubscription, + PartialSession, + Session, + MFARequired, + AccountDisabled, + MFAStatus, + LoginResult, ) +from .bot import Bot, PublicBot +from .cdn import ( + AssetMetadata, + StatelessAsset, +) +from .channel import ( + PartialChannel, + SavedMessagesChannel, + DMChannel, + GroupChannel, + ServerTextChannel, + VoiceChannel, + ServerChannel, + Channel, +) +from .core import UNDEFINED +from .embed import ( + EmbedSpecial, + NoneEmbedSpecial, + _NONE_EMBED_SPECIAL, + GifEmbedSpecial, + _GIF_EMBED_SPECIAL, + YouTubeEmbedSpecial, + LightspeedEmbedSpecial, + TwitchEmbedSpecial, + SpotifyEmbedSpecial, + SoundcloudEmbedSpecial, + _SOUNDCLOUD_EMBED_SPECIAL, + BandcampEmbedSpecial, + AppleMusicEmbedSpecial, + StreamableEmbedSpecial, + ImageSize, + ImageEmbed, + VideoEmbed, + WebsiteEmbed, + StatelessTextEmbed, + NoneEmbed, + _NONE_EMBED, + Embed, +) +from .emoji import ServerEmoji, DetachedEmoji, Emoji +from .enums import ( + MFAMethod, + AssetMetadataType, + ServerActivity, + BotUsage, + LightspeedContentType, + TwitchContentType, + BandcampContentType, + ImageSize, + MemberRemovalIntention, + Presence, + RelationshipStatus, +) +from .events import ( + ReadyEvent, + PrivateChannelCreateEvent, + ServerChannelCreateEvent, + ChannelCreateEvent, + ChannelUpdateEvent, + ChannelDeleteEvent, + GroupRecipientAddEvent, + GroupRecipientRemoveEvent, + ChannelStartTypingEvent, + ChannelStopTypingEvent, + MessageAckEvent, + MessageCreateEvent, + MessageUpdateEvent, + MessageAppendEvent, + MessageDeleteEvent, + MessageReactEvent, + MessageUnreactEvent, + MessageClearReactionEvent, + BulkMessageDeleteEvent, + ServerCreateEvent, + ServerEmojiCreateEvent, + ServerEmojiDeleteEvent, + ServerUpdateEvent, + ServerDeleteEvent, + ServerMemberJoinEvent, + ServerMemberUpdateEvent, + ServerMemberRemoveEvent, + RawServerRoleUpdateEvent, + ServerRoleDeleteEvent, + UserUpdateEvent, + UserRelationshipUpdateEvent, + UserSettingsUpdateEvent, + UserPlatformWipeEvent, + WebhookCreateEvent, + WebhookUpdateEvent, + WebhookDeleteEvent, + AuthifierEvent, + SessionCreateEvent, + SessionDeleteEvent, + SessionDeleteAllEvent, + LogoutEvent, + AuthenticatedEvent, +) +from .flags import ( + BotFlags, + MessageFlags, + Permissions, + ServerFlags, + UserBadges, + UserFlags, +) +from .invite import ( + BaseInvite, + ServerPublicInvite, + GroupPublicInvite, + UnknownPublicInvite, + GroupInvite, + ServerInvite, + Invite, +) +from .message import ( + Interactions, + Masquerade, + MessageWebhook, + PartialMessage, + MessageAppendData, + TextSystemEvent, + StatelessUserAddedSystemEvent, + StatelessUserRemovedSystemEvent, + StatelessUserJoinedSystemEvent, + StatelessUserLeftSystemEvent, + StatelessUserKickedSystemEvent, + StatelessUserBannedSystemEvent, + StatelessChannelRenamedSystemEvent, + StatelessChannelDescriptionChangedSystemEvent, + StatelessChannelIconChangedSystemEvent, + StatelessChannelOwnershipChangedSystemEvent, + StatelessMessagePinnedSystemEvent, + StatelessMessageUnpinnedSystemEvent, + StatelessSystemEvent, + Message, +) +from .permissions import Permissions, PermissionOverride +from .read_state import ReadState +from .server import ( + Category, + SystemMessageChannels, + PartialRole, + Role, + PartialServer, + Server, + Ban, + PartialMember, + Member, + MemberList, +) +from .user_settings import UserSettings +from .user import ( + UserStatus, + UserStatusEdit, + StatelessUserProfile, + PartialUserProfile, + Relationship, + Mutuals, + PartialUser, + DisplayUser, + BotUserInfo, + User, + OwnUser, +) +from .webhook import PartialWebhook, Webhook - -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from . import raw from .shard import Shard from .state import State @@ -34,628 +217,581 @@ _L = logging.getLogger(__name__) -_EMPTY_LIST: list[t.Any] = [] -_EMPTY_DICT: dict[t.Any, t.Any] = {} +_EMPTY_DICT: dict[typing.Any, typing.Any] = {} + +_new_server_flags = ServerFlags.__new__ +_new_user_badges = UserBadges.__new__ +_new_user_flags = UserFlags.__new__ class Parser: def __init__(self, state: State) -> None: self.state = state + self._channel_parsers = { + 'SavedMessages': self.parse_saved_messages_channel, + 'DirectMessage': self.parse_direct_message_channel, + 'Group': self._parse_group_channel, + 'TextChannel': self.parse_text_channel, + 'VoiceChannel': self.parse_voice_channel, + } + self._embed_parsers = { + 'Website': self.parse_website_embed, + 'Image': self.parse_image_embed, + 'Video': self.parse_video_embed, + 'Text': self.parse_text_embed, + 'None': self.parse_none_embed, + } + self._embed_special_parsers = { + 'None': self.parse_none_embed_special, + 'GIF': self.parse_gif_embed_special, + 'YouTube': self.parse_youtube_embed_special, + 'Lightspeed': self.parse_lightspeed_embed_special, + 'Twitch': self.parse_twitch_embed_special, + 'Spotify': self.parse_spotify_embed_special, + 'Soundcloud': self.parse_soundcloud_embed_special, + 'Bandcamp': self.parse_bandcamp_embed_special, + 'AppleMusic': self.parse_apple_music_embed_special, + 'Streamable': self.parse_streamable_embed_special, + } + self._emoji_parsers = { + 'Server': self.parse_server_emoji, + 'Detached': self.parse_detached_emoji, + } + self._invite_parsers = { + 'Server': self.parse_server_invite, + 'Group': self.parse_group_invite, + } + self._message_system_event_parsers = { + 'text': self.parse_message_text_system_event, + 'user_added': self.parse_message_user_added_system_event, + 'user_remove': self.parse_message_user_remove_system_event, + 'user_joined': self.parse_message_user_joined_system_event, + 'user_left': self.parse_message_user_left_system_event, + 'user_kicked': self.parse_message_user_kicked_system_event, + 'user_banned': self.parse_message_user_banned_system_event, + 'channel_renamed': self.parse_message_channel_renamed_system_event, + 'channel_description_changed': self.parse_message_channel_description_changed_system_event, + 'channel_icon_changed': self.parse_message_channel_icon_changed_system_event, + 'channel_ownership_changed': self.parse_message_channel_ownership_changed_system_event, + 'message_pinned': self.parse_message_message_pinned_system_event, + 'message_unpinned': self.parse_message_message_unpinned_system_event, + } + self._public_invite_parsers = { + 'Server': self.parse_server_public_invite, + 'Group': self.parse_group_public_invite, + } # basic start - def parse_id(self, d: t.Any) -> core.ULID: - return core.ULID(d) - - def parse_asset_metadata(self, d: raw.Metadata) -> cdn.AssetMetadata: - return cdn.AssetMetadata( - type=cdn.AssetMetadataType(d["type"]), - width=d.get("width"), - height=d.get("height"), - ) - - def parse_asset(self, d: raw.File) -> cdn.StatelessAsset: - deleted = d.get("deleted") - reported = d.get("reported") - - return cdn.StatelessAsset( - id=self.parse_id(d["_id"]), - filename=d["filename"], - metadata=self.parse_asset_metadata(d["metadata"]), - content_type=d["content_type"], - size=d["size"], - deleted=deleted or False, - reported=reported or False, - message_id=self.parse_id(d["message_id"]) if "message_id" in d else None, - user_id=self.parse_id(d["user_id"]) if "user_id" in d else None, - server_id=self.parse_id(d["server_id"]) if "server_id" in d else None, - object_id=self.parse_id(d["object_id"]) if "object_id" in d else None, - ) - - def parse_auth_event( - self, shard: Shard, d: raw.ClientAuthEvent - ) -> events.AuthifierEvent: - if d["event_type"] == "CreateSession": - return events.SessionCreateEvent( + def parse_apple_music_embed_special(self, d: raw.AppleMusicSpecial, /) -> AppleMusicEmbedSpecial: + return AppleMusicEmbedSpecial( + album_id=d['album_id'], + track_id=d.get('track_id'), + ) + + def parse_asset_metadata(self, d: raw.Metadata, /) -> AssetMetadata: + return AssetMetadata( + type=AssetMetadataType(d['type']), + width=d.get('width'), + height=d.get('height'), + ) + + def parse_asset(self, d: raw.File, /) -> StatelessAsset: + return StatelessAsset( + id=d['_id'], + filename=d['filename'], + metadata=self.parse_asset_metadata(d['metadata']), + content_type=d['content_type'], + size=d['size'], + deleted=d.get('deleted', False), + reported=d.get('reported', False), + message_id=d.get('message_id'), + user_id=d.get('user_id'), + server_id=d.get('server_id'), + object_id=d.get('object_id'), + ) + + def parse_auth_event(self, shard: Shard, d: raw.ClientAuthEvent) -> AuthifierEvent: + if d['event_type'] == 'CreateSession': + return SessionCreateEvent( shard=shard, - session=self.parse_session(d["session"]), + session=self.parse_session(d['session']), ) - elif d["event_type"] == "DeleteSession": - return events.SessionDeleteEvent( + elif d['event_type'] == 'DeleteSession': + return SessionDeleteEvent( shard=shard, - user_id=self.parse_id(d["user_id"]), - session_id=self.parse_id(d["session_id"]), + user_id=d['user_id'], + session_id=d['session_id'], ) - elif d["event_type"] == "DeleteAllSessions": - exclude_session_id = d.get("exclude_session_id") - return events.SessionDeleteAllEvent( + elif d['event_type'] == 'DeleteAllSessions': + return SessionDeleteAllEvent( shard=shard, - user_id=self.parse_id(d["user_id"]), - exclude_session_id=( - self.parse_id(exclude_session_id) if exclude_session_id else None - ), + user_id=d['user_id'], + exclude_session_id=d.get('exclude_session_id'), ) else: - raise NotImplementedError("Unimplemented auth event type", d) + raise NotImplementedError('Unimplemented auth event type', d) + + def parse_authenticated_event(self, shard: Shard, d: raw.ClientAuthenticatedEvent) -> AuthenticatedEvent: + return AuthenticatedEvent(shard=shard) # basic end, internals start - def _parse_group_channel(self, d: raw.GroupChannel) -> channels.GroupChannel: + def _parse_group_channel(self, d: raw.GroupChannel) -> GroupChannel: return self.parse_group_channel( d, ( True, - [self.parse_id(recipient_id) for recipient_id in d["recipients"]], + d['recipients'], ), ) # internals end - def parse_ban( - self, d: raw.ServerBan, users: dict[core.ULID, users.DisplayUser] - ) -> servers.Ban: - id = d["_id"] - user_id = self.parse_id(id["user"]) + def parse_ban(self, d: raw.ServerBan, users: dict[str, DisplayUser]) -> Ban: + id = d['_id'] + user_id = id['user'] - return servers.Ban( - server_id=self.parse_id(id["server"]), + return Ban( + server_id=id['server'], user_id=user_id, - reason=d["reason"], + reason=d['reason'], user=users.get(user_id), ) - def parse_bandcamp_embed_special( - self, d: raw.BandcampSpecial - ) -> embeds.BandcampEmbedSpecial: - return embeds.BandcampEmbedSpecial( - content_type=embeds.BandcampContentType(d["content_type"]), - id=d["id"], + def parse_bandcamp_embed_special(self, d: raw.BandcampSpecial) -> BandcampEmbedSpecial: + return BandcampEmbedSpecial( + content_type=BandcampContentType(d['content_type']), + id=d['id'], ) - def parse_bans(self, d: raw.BanListResult) -> list[servers.Ban]: - banned_users = { - bu.id: bu for bu in (self.parse_display_user(e) for e in d["users"]) - } - return [self.parse_ban(e, banned_users) for e in d["bans"]] + def parse_bans(self, d: raw.BanListResult) -> list[Ban]: + banned_users = {bu.id: bu for bu in (self.parse_display_user(e) for e in d['users'])} + return [self.parse_ban(e, banned_users) for e in d['bans']] - def _parse_bot(self, d: raw.Bot, user: users.User) -> bots.Bot: - return bots.Bot( + def _parse_bot(self, d: raw.Bot, user: User) -> Bot: + return Bot( state=self.state, - id=self.parse_id(d["_id"]), - owner_id=self.parse_id(d["owner"]), - token=d["token"], - public=d["public"], - analytics=d.get("analytics", False), - discoverable=d.get("discoverable", False), - interactions_url=d.get("interactions_url"), - terms_of_service_url=d.get("terms_of_service_url"), - privacy_policy_url=d.get("privacy_policy_url"), - flags=bots.BotFlags(d.get("flags") or 0), + id=d['_id'], + owner_id=d['owner'], + token=d['token'], + public=d['public'], + analytics=d.get('analytics', False), + discoverable=d.get('discoverable', False), + interactions_url=d.get('interactions_url'), + terms_of_service_url=d.get('terms_of_service_url'), + privacy_policy_url=d.get('privacy_policy_url'), + flags=BotFlags(d.get('flags', 0)), user=user, ) - def parse_bot(self, d: raw.Bot, user: raw.User) -> bots.Bot: + def parse_bot(self, d: raw.Bot, user: raw.User) -> Bot: return self._parse_bot(d, self.parse_user(user)) - def parse_bot_user_info(self, d: raw.BotInformation) -> users.BotUserInfo: - return users.BotUserInfo(owner_id=self.parse_id(d["owner"])) + def parse_bot_user_info(self, d: raw.BotInformation, /) -> BotUserInfo: + return BotUserInfo(owner_id=d['owner']) - def parse_bots(self, d: raw.OwnedBotsResponse) -> list[bots.Bot]: - bots = d["bots"] - users = d["users"] + def parse_bots(self, d: raw.OwnedBotsResponse) -> list[Bot]: + bots = d['bots'] + users = d['users'] if len(bots) != len(users): - raise RuntimeError(f"Expected {len(bots)} users but got {len(users)}") + raise RuntimeError(f'Expected {len(bots)} users but got {len(users)}') return [self.parse_bot(e, users[i]) for i, e in enumerate(bots)] def parse_bulk_message_delete_event( self, shard: Shard, d: raw.ClientBulkMessageDeleteEvent - ) -> events.BulkMessageDeleteEvent: - return events.BulkMessageDeleteEvent( + ) -> BulkMessageDeleteEvent: + return BulkMessageDeleteEvent( shard=shard, - channel_id=self.parse_id(d["channel"]), - message_ids=[self.parse_id(m) for m in d["ids"]], + channel_id=d['channel'], + message_ids=d['ids'], ) - def parse_category(self, d: raw.Category) -> servers.Category: - return servers.Category( - id=self.parse_id(d["id"]), - title=d["title"], - channels=d["channels"], # type: ignore + def parse_category(self, d: raw.Category, /) -> Category: + return Category( + id=d['id'], + title=d['title'], + channels=d['channels'], # type: ignore ) - def parse_channel_ack_event( - self, shard: Shard, d: raw.ClientChannelAckEvent - ) -> events.MessageAckEvent: - return events.MessageAckEvent( + def parse_channel_ack_event(self, shard: Shard, d: raw.ClientChannelAckEvent) -> MessageAckEvent: + return MessageAckEvent( shard=shard, - channel_id=self.parse_id(d["id"]), - message_id=self.parse_id(d["message_id"]), - user_id=self.parse_id(d["user"]), + channel_id=d['id'], + message_id=d['message_id'], + user_id=d['user'], ) - def parse_channel_create_event( - self, shard: Shard, d: raw.ClientChannelCreateEvent - ) -> events.ChannelCreateEvent: + def parse_channel_create_event(self, shard: Shard, d: raw.ClientChannelCreateEvent) -> ChannelCreateEvent: channel = self.parse_channel(d) if isinstance( channel, - (channels.SavedMessagesChannel, channels.DMChannel, channels.GroupChannel), + (SavedMessagesChannel, DMChannel, GroupChannel), ): - return events.PrivateChannelCreateEvent(shard=shard, channel=channel) + return PrivateChannelCreateEvent(shard=shard, channel=channel) else: - return events.ServerChannelCreateEvent(shard=shard, channel=channel) + return ServerChannelCreateEvent(shard=shard, channel=channel) - def parse_channel_delete_event( - self, shard: Shard, d: raw.ClientChannelDeleteEvent - ) -> events.ChannelDeleteEvent: - return events.ChannelDeleteEvent( + def parse_channel_delete_event(self, shard: Shard, d: raw.ClientChannelDeleteEvent) -> ChannelDeleteEvent: + return ChannelDeleteEvent( shard=shard, - channel_id=self.parse_id(d["id"]), + channel_id=d['id'], channel=None, ) def parse_channel_group_join_event( self, shard: Shard, d: raw.ClientChannelGroupJoinEvent - ) -> events.GroupRecipientAddEvent: - return events.GroupRecipientAddEvent( + ) -> GroupRecipientAddEvent: + return GroupRecipientAddEvent( shard=shard, - channel_id=self.parse_id(d["id"]), - user_id=self.parse_id(d["user"]), + channel_id=d['id'], + user_id=d['user'], group=None, ) def parse_channel_group_leave_event( self, shard: Shard, d: raw.ClientChannelGroupLeaveEvent - ) -> events.GroupRecipientRemoveEvent: - return events.GroupRecipientRemoveEvent( + ) -> GroupRecipientRemoveEvent: + return GroupRecipientRemoveEvent( shard=shard, - channel_id=self.parse_id(d["id"]), - user_id=self.parse_id(d["user"]), + channel_id=d['id'], + user_id=d['user'], group=None, ) def parse_channel_start_typing_event( self, shard: Shard, d: raw.ClientChannelStartTypingEvent - ) -> events.ChannelStartTypingEvent: - return events.ChannelStartTypingEvent( + ) -> ChannelStartTypingEvent: + return ChannelStartTypingEvent( shard=shard, - channel_id=self.parse_id(d["id"]), - user_id=self.parse_id(d["user"]), + channel_id=d['id'], + user_id=d['user'], ) def parse_channel_stop_typing_event( self, shard: Shard, d: raw.ClientChannelStopTypingEvent - ) -> events.ChannelStopTypingEvent: - return events.ChannelStopTypingEvent( + ) -> ChannelStopTypingEvent: + return ChannelStopTypingEvent( shard=shard, - channel_id=self.parse_id(d["id"]), - user_id=self.parse_id(d["user"]), + channel_id=d['id'], + user_id=d['user'], ) - def parse_channel_update_event( - self, shard: Shard, d: raw.ClientChannelUpdateEvent - ) -> events.ChannelUpdateEvent: - clear = d["clear"] - data = d["data"] + def parse_channel_update_event(self, shard: Shard, d: raw.ClientChannelUpdateEvent) -> ChannelUpdateEvent: + clear = d['clear'] + data = d['data'] - owner = data.get("owner") - icon = data.get("icon") - permissions = data.get("permissions") - role_permissions = data.get("role_permissions") - default_permissions = data.get("default_permissions") - last_message_id = data.get("last_message_id") + owner = data.get('owner') + icon = data.get('icon') + permissions = data.get('permissions') + role_permissions = data.get('role_permissions') + default_permissions = data.get('default_permissions') + last_message_id = data.get('last_message_id') - return events.ChannelUpdateEvent( + return ChannelUpdateEvent( shard=shard, - channel=channels.PartialChannel( + channel=PartialChannel( state=self.state, - id=self.parse_id(d["id"]), - name=data.get("name", core.UNDEFINED), - owner_id=self.parse_id(owner) if owner else core.UNDEFINED, - description=( - None - if "Description" in clear - else data.get("description", core.UNDEFINED) - ), - internal_icon=( - None - if "Icon" in clear - else self.parse_asset(icon) if icon else core.UNDEFINED - ), - nsfw=data.get("nsfw", core.UNDEFINED), - active=data.get("active", core.UNDEFINED), - permissions=( - permissions_.Permissions(permissions) - if permissions is not None - else core.UNDEFINED - ), + id=d['id'], + name=data.get('name', UNDEFINED), + owner_id=owner if owner else UNDEFINED, + description=(None if 'Description' in clear else data.get('description', UNDEFINED)), + internal_icon=(None if 'Icon' in clear else self.parse_asset(icon) if icon else UNDEFINED), + nsfw=data.get('nsfw', UNDEFINED), + active=data.get('active', UNDEFINED), + permissions=(Permissions(permissions) if permissions is not None else UNDEFINED), role_permissions=( - { - self.parse_id(k): self.parse_permission_override_field(v) - for k, v in role_permissions.items() - } + {k: self.parse_permission_override_field(v) for k, v in role_permissions.items()} if role_permissions is not None - else core.UNDEFINED + else UNDEFINED ), default_permissions=( self.parse_permission_override_field(default_permissions) if default_permissions is not None - else core.UNDEFINED - ), - last_message_id=( - self.parse_id(last_message_id) - if last_message_id - else core.UNDEFINED + else UNDEFINED ), + last_message_id=last_message_id or UNDEFINED, ), before=None, after=None, ) - def parse_channel(self, d: raw.Channel) -> channels.Channel: - return { - "SavedMessages": self.parse_saved_messages_channel, - "DirectMessage": self.parse_direct_message_channel, - "Group": self._parse_group_channel, - "TextChannel": self.parse_text_channel, - "VoiceChannel": self.parse_voice_channel, - }[d["channel_type"]](d) - - def parse_detached_emoji(self, d: raw.DetachedEmoji) -> emojis.DetachedEmoji: - animated = d.get("animated") - nsfw = d.get("nsfw") + def parse_channel(self, d: raw.Channel) -> Channel: + return self._channel_parsers[d['channel_type']](d) - return emojis.DetachedEmoji( + def parse_detached_emoji(self, d: raw.DetachedEmoji) -> DetachedEmoji: + return DetachedEmoji( state=self.state, - id=self.parse_id(d["_id"]), - creator_id=self.parse_id(d["creator_id"]), - name=d["name"], - animated=animated or False, - nsfw=nsfw or False, + id=d['_id'], + creator_id=d['creator_id'], + name=d['name'], + animated=d.get('animated', False), + nsfw=d.get('nsfw', False), ) - def parse_disabled_response_login( - self, d: raw.a.DisabledResponseLogin - ) -> auth.AccountDisabled: - return auth.AccountDisabled(user_id=core.resolve_ulid(d["user_id"])) + def parse_disabled_response_login(self, d: raw.a.DisabledResponseLogin) -> AccountDisabled: + return AccountDisabled(user_id=d['user_id']) - def parse_direct_message_channel( - self, d: raw.DirectMessageChannel - ) -> channels.DMChannel: - recipient_ids = d["recipients"] - last_message_id = d.get("last_message_id") + def parse_direct_message_channel(self, d: raw.DirectMessageChannel) -> DMChannel: + recipient_ids = d['recipients'] - return channels.DMChannel( + return DMChannel( state=self.state, - id=self.parse_id(d["_id"]), - active=d["active"], + id=d['_id'], + active=d['active'], recipient_ids=( - self.parse_id(recipient_ids[0]), - self.parse_id(recipient_ids[1]), + recipient_ids[0], + recipient_ids[1], ), - last_message_id=self.parse_id(last_message_id) if last_message_id else None, + last_message_id=d.get('last_message_id'), ) # Discovery def parse_discovery_bot(self, d: raw.DiscoveryBot) -> discovery.DiscoveryBot: - avatar = d.get("avatar") + avatar = d.get('avatar') return discovery.DiscoveryBot( state=self.state, - id=self.parse_id(d["_id"]), - username=d.get("username"), + id=d['_id'], + name=d['username'], internal_avatar=self.parse_asset(avatar) if avatar else None, - internal_profile=self.parse_user_profile(d["profile"]), - tags=d["tags"], - server_count=d["servers"], - usage=discovery.BotUsage(d["usage"]), + internal_profile=self.parse_user_profile(d['profile']), + tags=d['tags'], + server_count=d['servers'], + usage=BotUsage(d['usage']), ) - def parse_discovery_bot_search_result( - self, d: raw.DiscoveryBotSearchResult - ) -> discovery.BotSearchResult: + def parse_discovery_bot_search_result(self, d: raw.DiscoveryBotSearchResult) -> discovery.BotSearchResult: return discovery.BotSearchResult( - query=d["query"], - count=d["count"], - bots=[self.parse_discovery_bot(s) for s in d["bots"]], - related_tags=d["relatedTags"], + query=d['query'], + count=d['count'], + bots=[self.parse_discovery_bot(s) for s in d['bots']], + related_tags=d['relatedTags'], ) - def parse_discovery_bots_page( - self, d: raw.DiscoveryBotsPage - ) -> discovery.DiscoveryBotsPage: + def parse_discovery_bots_page(self, d: raw.DiscoveryBotsPage) -> discovery.DiscoveryBotsPage: return discovery.DiscoveryBotsPage( - bots=[self.parse_discovery_bot(b) for b in d["bots"]], - popular_tags=d["popularTags"], + bots=[self.parse_discovery_bot(b) for b in d['bots']], + popular_tags=d['popularTags'], ) - def parse_discovery_server( - self, d: raw.DiscoveryServer - ) -> discovery.DiscoveryServer: - icon = d.get("icon") - banner = d.get("banner") + def parse_discovery_server(self, d: raw.DiscoveryServer) -> discovery.DiscoveryServer: + icon = d.get('icon') + banner = d.get('banner') return discovery.DiscoveryServer( state=self.state, - id=self.parse_id(d["_id"]), - name=d["name"], - description=d.get("description"), + id=d['_id'], + name=d['name'], + description=d.get('description'), internal_icon=self.parse_asset(icon) if icon else None, internal_banner=self.parse_asset(banner) if banner else None, - flags=servers.ServerFlags(d.get("flags") or 0), - tags=d["tags"], - member_count=d["members"], - activity=discovery.ServerActivity(d["activity"]), + flags=ServerFlags(d.get('flags') or 0), + tags=d['tags'], + member_count=d['members'], + activity=ServerActivity(d['activity']), ) - def parse_discovery_servers_page( - self, d: raw.DiscoveryServersPage - ) -> discovery.DiscoveryServersPage: + def parse_discovery_servers_page(self, d: raw.DiscoveryServersPage) -> discovery.DiscoveryServersPage: return discovery.DiscoveryServersPage( - servers=[self.parse_discovery_server(s) for s in d["servers"]], - popular_tags=d["popularTags"], + servers=[self.parse_discovery_server(s) for s in d['servers']], + popular_tags=d['popularTags'], ) - def parse_discovery_server_search_result( - self, d: raw.DiscoveryServerSearchResult - ) -> discovery.ServerSearchResult: + def parse_discovery_server_search_result(self, d: raw.DiscoveryServerSearchResult) -> discovery.ServerSearchResult: return discovery.ServerSearchResult( - query=d["query"], - count=d["count"], - servers=[self.parse_discovery_server(s) for s in d["servers"]], - related_tags=d["relatedTags"], + query=d['query'], + count=d['count'], + servers=[self.parse_discovery_server(s) for s in d['servers']], + related_tags=d['relatedTags'], ) def parse_discovery_theme(self, d: raw.DiscoveryTheme) -> discovery.DiscoveryTheme: return discovery.DiscoveryTheme( state=self.state, - name=d["name"], - description=d["description"], - slug=d["slug"], - tags=d["tags"], - variables=d["variables"], - version=d["version"], - css=d.get("css"), - ) - - def parse_discovery_theme_search_result( - self, d: raw.DiscoveryThemeSearchResult - ) -> discovery.ThemeSearchResult: + name=d['name'], + description=d['description'], + creator=d['creator'], + slug=d['slug'], + tags=d['tags'], + overrides=d['variables'], + version=d['version'], + custom_css=d.get('css'), + ) + + def parse_discovery_theme_search_result(self, d: raw.DiscoveryThemeSearchResult) -> discovery.ThemeSearchResult: return discovery.ThemeSearchResult( - query=d["query"], - count=d["count"], - themes=[self.parse_discovery_theme(s) for s in d["themes"]], - related_tags=d["relatedTags"], + query=d['query'], + count=d['count'], + themes=[self.parse_discovery_theme(s) for s in d['themes']], + related_tags=d['relatedTags'], ) - def parse_discovery_themes_page( - self, d: raw.DiscoveryThemesPage - ) -> discovery.DiscoveryThemesPage: + def parse_discovery_themes_page(self, d: raw.DiscoveryThemesPage) -> discovery.DiscoveryThemesPage: return discovery.DiscoveryThemesPage( - themes=[self.parse_discovery_theme(b) for b in d["themes"]], - popular_tags=d["popularTags"], + themes=[self.parse_discovery_theme(b) for b in d['themes']], + popular_tags=d['popularTags'], ) - def parse_display_user(self, d: raw.BannedUser) -> users.DisplayUser: - avatar = d.get("avatar") + def parse_display_user(self, d: raw.BannedUser) -> DisplayUser: + avatar = d.get('avatar') - return users.DisplayUser( + return DisplayUser( state=self.state, - id=self.parse_id(d["_id"]), - name=d["username"], - discriminator=d["discriminator"], + id=d['_id'], + name=d['username'], + discriminator=d['discriminator'], internal_avatar=self.parse_asset(avatar) if avatar else None, ) - def parse_embed(self, d: raw.Embed) -> embeds.Embed: - return { - "Website": self.parse_website_embed, - "Image": self.parse_image_embed, - "Video": self.parse_video_embed, - "Text": self.parse_text_embed, - "None": self.parse_none_embed, - }[d["type"]](d) - - def parse_embed_special(self, d: raw.Special) -> embeds.EmbedSpecial: - return { - "None": self.parse_none_embed_special, - "GIF": self.parse_gif_embed_special, - "YouTube": self.parse_youtube_embed_special, - "Lightspeed": self.parse_lightspeed_embed_special, - "Twitch": self.parse_twitch_embed_special, - "Spotify": self.parse_spotify_embed_special, - "Soundcloud": self.parse_soundcloud_embed_special, - "Bandcamp": self.parse_bandcamp_embed_special, - "Streamable": self.parse_streamable_embed_special, - }[d["type"]](d) - - def parse_emoji(self, d: raw.Emoji) -> emojis.Emoji: - return { - "Server": self.parse_server_emoji, - "Detached": self.parse_detached_emoji, - }[d["parent"]["type"]](d) - - def parse_emoji_create_event( - self, shard: Shard, d: raw.ClientEmojiCreateEvent - ) -> events.ServerEmojiCreateEvent: - return events.ServerEmojiCreateEvent( + def parse_embed(self, d: raw.Embed) -> Embed: + return self._embed_parsers[d['type']](d) + + def parse_embed_special(self, d: raw.Special) -> EmbedSpecial: + return self._embed_special_parsers[d['type']](d) + + def parse_emoji(self, d: raw.Emoji) -> Emoji: + return self._emoji_parsers[d['parent']['type']](d) + + def parse_emoji_create_event(self, shard: Shard, d: raw.ClientEmojiCreateEvent) -> ServerEmojiCreateEvent: + return ServerEmojiCreateEvent( shard=shard, emoji=self.parse_server_emoji(d), ) - def parse_emoji_delete_event( - self, shard: Shard, d: raw.ClientEmojiDeleteEvent - ) -> events.ServerEmojiDeleteEvent: - return events.ServerEmojiDeleteEvent( + def parse_emoji_delete_event(self, shard: Shard, d: raw.ClientEmojiDeleteEvent) -> ServerEmojiDeleteEvent: + return ServerEmojiDeleteEvent( shard=shard, emoji=None, server_id=None, - emoji_id=self.parse_id(d["id"]), + emoji_id=d['id'], ) - def parse_gif_embed_special(self, _: raw.GIFSpecial) -> embeds.GifEmbedSpecial: - return embeds._GIF_EMBED_SPECIAL + def parse_gif_embed_special(self, _: raw.GIFSpecial) -> GifEmbedSpecial: + return _GIF_EMBED_SPECIAL def parse_group_channel( self, d: raw.GroupChannel, - recipients: ( - tuple[t.Literal[True], list[core.ULID]] - | tuple[t.Literal[False], list[users.User]] - ), - ) -> channels.GroupChannel: - icon = d.get("icon") - last_message_id = d.get("last_message_id") - permissions = d.get("permissions") - nsfw = d.get("nsfw") - - return channels.GroupChannel( + recipients: (tuple[typing.Literal[True], list[str]] | tuple[typing.Literal[False], list[User]]), + ) -> GroupChannel: + icon = d.get('icon') + permissions = d.get('permissions') + + return GroupChannel( state=self.state, - id=self.parse_id(d["_id"]), - name=d["name"], - owner_id=self.parse_id(d["owner"]), - description=d.get("description"), + id=d['_id'], + name=d['name'], + owner_id=d['owner'], + description=d.get('description'), internal_recipients=recipients, internal_icon=self.parse_asset(icon) if icon else None, - last_message_id=self.parse_id(last_message_id) if last_message_id else None, - permissions=( - None if permissions is None else permissions_.Permissions(permissions) - ), - nsfw=nsfw or False, + last_message_id=d.get('last_message_id'), + permissions=None if permissions is None else Permissions(permissions), + nsfw=d.get('nsfw', False), ) - def parse_group_invite(self, d: raw.GroupInvite) -> invites.GroupInvite: - return invites.GroupInvite( + def parse_group_invite(self, d: raw.GroupInvite) -> GroupInvite: + return GroupInvite( state=self.state, - code=d["_id"], - creator_id=self.parse_id(d["creator"]), - channel_id=self.parse_id(d["channel"]), + code=d['_id'], + creator_id=d['creator'], + channel_id=d['channel'], ) - def parse_group_public_invite( - self, d: raw.GroupInviteResponse - ) -> invites.GroupPublicInvite: - user_avatar = d.get("user_avatar") + def parse_group_public_invite(self, d: raw.GroupInviteResponse) -> GroupPublicInvite: + user_avatar = d.get('user_avatar') - return invites.GroupPublicInvite( + return GroupPublicInvite( state=self.state, - code=d["code"], - channel_id=self.parse_id(d["channel_id"]), - channel_name=d["channel_name"], - channel_description=d.get("channel_description", None), - user_name=d["user_name"], + code=d['code'], + channel_id=d['channel_id'], + channel_name=d['channel_name'], + channel_description=d.get('channel_description'), + user_name=d['user_name'], internal_user_avatar=self.parse_asset(user_avatar) if user_avatar else None, ) - def parse_image_embed(self, d: raw.Image) -> embeds.ImageEmbed: - return embeds.ImageEmbed( - url=d["url"], - width=d["width"], - height=d["height"], - size=embeds.ImageSize(d["size"]), + def parse_image_embed(self, d: raw.Image) -> ImageEmbed: + return ImageEmbed( + url=d['url'], + width=d['width'], + height=d['height'], + size=ImageSize(d['size']), ) - def parse_invite(self, d: raw.Invite) -> invites.Invite: - return { - "Server": self.parse_server_invite, - "Group": self.parse_group_invite, - }[ - d["type"] - ](d) + def parse_invite(self, d: raw.Invite) -> Invite: + return self._invite_parsers[d['type']](d) - def parse_lightspeed_embed_special( - self, d: raw.LightspeedSpecial - ) -> embeds.LightspeedEmbedSpecial: - return embeds.LightspeedEmbedSpecial( - content_type=embeds.LightspeedContentType(d["content_type"]), - id=d["id"], + def parse_lightspeed_embed_special(self, d: raw.LightspeedSpecial) -> LightspeedEmbedSpecial: + return LightspeedEmbedSpecial( + content_type=LightspeedContentType(d['content_type']), + id=d['id'], ) - def parse_logout_event( - self, shard: Shard, d: raw.ClientLogoutEvent - ) -> events.LogoutEvent: - return events.LogoutEvent(shard=shard) + def parse_logout_event(self, shard: Shard, d: raw.ClientLogoutEvent) -> LogoutEvent: + return LogoutEvent(shard=shard) def parse_member( self, d: raw.Member, + /, *, - user: users.User | None = None, - users: dict[core.ULID, users.User] | None = None, - ) -> servers.Member: + user: User | None = None, + users: dict[str, User] | None = None, + ) -> Member: if user and users: - raise ValueError("Cannot specify both user and users") + raise ValueError('Cannot specify both user and users') - id = d["_id"] - user_id = self.parse_id(id["user"]) + id = d['_id'] + user_id = id['user'] if user: - assert user.id == user_id, "IDs do not match" + assert user.id == user_id, 'IDs do not match' - avatar = d.get("avatar") - timeout = d.get("timeout") + avatar = d.get('avatar') + timeout = d.get('timeout') - return servers.Member( + return Member( state=self.state, _user=user or (users or _EMPTY_DICT).get(user_id) or user_id, - server_id=self.parse_id(id["server"]), - joined_at=datetime.fromisoformat(d["joined_at"]), - nick=d.get("nickname"), - internal_avatar=self.parse_asset(avatar) if avatar else None, - roles=[self.parse_id(role_id) for role_id in d.get("roles") or []], - timeout=datetime.fromisoformat(timeout) if timeout else None, + server_id=id['server'], + joined_at=datetime.fromisoformat(d['joined_at']), + nick=d.get('nickname'), + internal_server_avatar=self.parse_asset(avatar) if avatar else None, + roles=d.get('roles') or [], + timed_out_until=datetime.fromisoformat(timeout) if timeout else None, ) - def parse_member_list(self, d: raw.AllMemberResponse) -> servers.MemberList: - return servers.MemberList( - members=[self.parse_member(m) for m in d.get("members") or []], - users=[self.parse_user(u) for u in d.get("users") or []], + def parse_member_list(self, d: raw.AllMemberResponse) -> MemberList: + return MemberList( + members=[self.parse_member(m) for m in d['members']], + users=[self.parse_user(u) for u in d['users']], ) - def parse_members_with_users( - self, d: raw.AllMemberResponse - ) -> list[servers.Member]: - users = [self.parse_user(u) for u in d.get("users") or []] + def parse_members_with_users(self, d: raw.AllMemberResponse) -> list[Member]: + users = [self.parse_user(u) for u in d['users']] - return [self.parse_member(e, user=users[i]) for i, e in enumerate(d["members"])] + return [self.parse_member(e, user=users[i]) for i, e in enumerate(d['members'])] def parse_message( self, d: raw.Message, + /, *, - members: dict[core.ULID, servers.Member] = {}, - users: dict[core.ULID, users.User] = {}, - ) -> messages.Message: - author_id = self.parse_id(d["author"]) - webhook = d.get("webhook") - system = d.get("system") - edited_at = d.get("edited") - interactions = d.get("interactions") - masquerade = d.get("masquerade") - - member = d.get("member") - user = d.get("user") + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + ) -> Message: + author_id = d['author'] + webhook = d.get('webhook') + system = d.get('system') + edited_at = d.get('edited') + interactions = d.get('interactions') + masquerade = d.get('masquerade') + + member = d.get('member') + user = d.get('user') if member: if user: @@ -667,428 +803,471 @@ def parse_message( else: author = members.get(author_id) or users.get(author_id) or author_id - return messages.Message( + return Message( state=self.state, - id=self.parse_id(d["_id"]), - nonce=d.get("nonce"), - channel_id=self.parse_id(d["channel"]), + id=d['_id'], + nonce=d.get('nonce'), + channel_id=d['channel'], internal_author=author, webhook=self.parse_message_webhook(webhook) if webhook else None, - content=d.get("content") or "", - system_event=self.parse_message_system_event(system) if system else None, - internal_attachments=[ - self.parse_asset(a) for a in d.get("attachments") or [] - ], + content=d.get('content', ''), + internal_system_event=self.parse_message_system_event(system, members, users) if system else None, + internal_attachments=[self.parse_asset(a) for a in d.get('attachments', [])], edited_at=datetime.fromisoformat(edited_at) if edited_at else None, - internal_embeds=[self.parse_embed(e) for e in d.get("embeds") or []], - mention_ids=[self.parse_id(m) for m in d.get("mentions") or []], - replies=[self.parse_id(r) for r in d.get("replies") or []], - reactions={ - k: tuple(self.parse_id(u) for u in v) - for k, v in (d.get("reactions") or {}).items() - }, - interactions=( - self.parse_message_interactions(interactions) if interactions else None - ), - masquerade=( - self.parse_message_masquerade(masquerade) if masquerade else None - ), - pinned=d.get("pinned") or False, - flags=messages.MessageFlags(d.get("flags", 0)), + internal_embeds=[self.parse_embed(e) for e in d.get('embeds', [])], + mention_ids=d.get('mentions', []), + replies=d.get('replies', []), + reactions={k: tuple(v) for k, v in (d.get('reactions') or {}).items()}, + interactions=(self.parse_message_interactions(interactions) if interactions else None), + masquerade=(self.parse_message_masquerade(masquerade) if masquerade else None), + pinned=d.get('pinned', False), + flags=MessageFlags(d.get('flags', 0)), ) - def parse_message_append_event( - self, shard: Shard, d: raw.ClientMessageAppendEvent - ) -> events.MessageAppendEvent: - data = d["append"] - embeds = data.get("embeds") + def parse_message_append_event(self, shard: Shard, d: raw.ClientMessageAppendEvent) -> MessageAppendEvent: + data = d['append'] + embeds = data.get('embeds') - return events.MessageAppendEvent( + return MessageAppendEvent( shard=shard, - data=messages.MessageAppendData( + data=MessageAppendData( state=self.state, - id=self.parse_id(d["id"]), - channel_id=self.parse_id(d["channel"]), - internal_embeds=( - [self.parse_embed(e) for e in embeds] - if embeds is not None - else core.UNDEFINED - ), + id=d['id'], + channel_id=d['channel'], + internal_embeds=([self.parse_embed(e) for e in embeds] if embeds is not None else UNDEFINED), ), ) def parse_message_channel_description_changed_system_event( - self, d: raw.ChannelDescriptionChangedSystemMessage - ) -> messages.ChannelDescriptionChangedSystemEvent: - return messages.ChannelDescriptionChangedSystemEvent(by=self.parse_id(d["by"])) + self, + d: raw.ChannelDescriptionChangedSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessChannelDescriptionChangedSystemEvent: + by_id = d['by'] + + return StatelessChannelDescriptionChangedSystemEvent(internal_by=users.get(by_id, by_id)) def parse_message_channel_icon_changed_system_event( - self, d: raw.ChannelIconChangedSystemMessage - ) -> messages.ChannelIconChangedSystemEvent: - return messages.ChannelIconChangedSystemEvent(by=self.parse_id(d["by"])) + self, d: raw.ChannelIconChangedSystemMessage, members: dict[str, Member] = {}, users: dict[str, User] = {}, / + ) -> StatelessChannelIconChangedSystemEvent: + by_id = d['by'] + + return StatelessChannelIconChangedSystemEvent(internal_by=users.get(by_id, by_id)) def parse_message_channel_renamed_system_event( - self, d: raw.ChannelRenamedSystemMessage - ) -> messages.ChannelRenamedSystemEvent: - return messages.ChannelRenamedSystemEvent( - by=self.parse_id(d["by"]), + self, d: raw.ChannelRenamedSystemMessage, members: dict[str, Member] = {}, users: dict[str, User] = {}, / + ) -> StatelessChannelRenamedSystemEvent: + by_id = d['by'] + + return StatelessChannelRenamedSystemEvent( + name=d['name'], + internal_by=users.get(by_id, by_id), ) def parse_message_channel_ownership_changed_system_event( - self, d: raw.ChannelOwnershipChangedSystemMessage - ) -> messages.ChannelOwnershipChangedSystemEvent: - return messages.ChannelOwnershipChangedSystemEvent( - from_=self.parse_id(d["from"]), - to=self.parse_id(d["to"]), + self, + d: raw.ChannelOwnershipChangedSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessChannelOwnershipChangedSystemEvent: + from_id = d['from'] + to_id = d['to'] + + return StatelessChannelOwnershipChangedSystemEvent( + internal_from=users.get(from_id, from_id), + internal_to=users.get(to_id, to_id), ) - def parse_message_delete_event( - self, shard: Shard, d: raw.ClientMessageDeleteEvent - ) -> events.MessageDeleteEvent: - return events.MessageDeleteEvent( + def parse_message_delete_event(self, shard: Shard, d: raw.ClientMessageDeleteEvent) -> MessageDeleteEvent: + return MessageDeleteEvent( shard=shard, - channel_id=self.parse_id(d["channel"]), - message_id=self.parse_id(d["id"]), + channel_id=d['channel'], + message_id=d['id'], + ) + + def parse_message_event(self, shard: Shard, d: raw.ClientMessageEvent) -> MessageCreateEvent: + return MessageCreateEvent(shard=shard, message=self.parse_message(d)) + + def parse_message_interactions(self, d: raw.Interactions) -> Interactions: + return Interactions( + reactions=d.get('reactions', []), + restrict_reactions=d.get('restrict_reactions', False), ) - def parse_message_event( - self, shard: Shard, d: raw.ClientMessageEvent - ) -> events.MessageCreateEvent: - return events.MessageCreateEvent(shard=shard, message=self.parse_message(d)) + def parse_message_masquerade(self, d: raw.Masquerade) -> Masquerade: + return Masquerade(name=d.get('name'), avatar=d.get('avatar'), colour=d.get('colour')) - def parse_message_interactions(self, d: raw.Interactions) -> messages.Interactions: - return messages.Interactions( - reactions=d.get("reactions", []), - restrict_reactions=d.get("restrict_reactions", False), + def parse_message_message_pinned_system_event( + self, + d: raw.MessagePinnedSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessMessagePinnedSystemEvent: + pinned_message_id = d['id'] + by_id = d['by'] + + return StatelessMessagePinnedSystemEvent( + pinned_message_id=pinned_message_id, + internal_by=members.get(by_id, users.get(by_id, by_id)), ) - def parse_message_masquerade(self, d: raw.Masquerade) -> messages.Masquerade: - return messages.Masquerade( - name=d.get("name"), avatar=d.get("avatar"), colour=d.get("colour") + def parse_message_message_unpinned_system_event( + self, d: raw.MessageUnpinnedSystemMessage, members: dict[str, Member] = {}, users: dict[str, User] = {}, / + ) -> StatelessMessageUnpinnedSystemEvent: + unpinned_message_id = d['id'] + by_id = d['by'] + + return StatelessMessageUnpinnedSystemEvent( + unpinned_message_id=unpinned_message_id, + internal_by=members.get(by_id, users.get(by_id, by_id)), ) - def parse_message_react_event( - self, shard: Shard, d: raw.ClientMessageReactEvent - ) -> events.MessageReactEvent: - return events.MessageReactEvent( + def parse_message_react_event(self, shard: Shard, d: raw.ClientMessageReactEvent) -> MessageReactEvent: + return MessageReactEvent( shard=shard, - channel_id=self.parse_id(d["channel_id"]), - message_id=self.parse_id(d["id"]), - user_id=self.parse_id(d["user_id"]), - emoji=d["emoji_id"], + channel_id=d['channel_id'], + message_id=d['id'], + user_id=d['user_id'], + emoji=d['emoji_id'], ) def parse_message_remove_reaction_event( self, shard: Shard, d: raw.ClientMessageRemoveReactionEvent - ) -> events.MessageClearReactionEvent: - return events.MessageClearReactionEvent( + ) -> MessageClearReactionEvent: + return MessageClearReactionEvent( shard=shard, - channel_id=self.parse_id(d["channel_id"]), - message_id=self.parse_id(d["id"]), - emoji=d["emoji_id"], - ) - - def parse_message_system_event(self, d: raw.SystemMessage) -> messages.SystemEvent: - return { - "text": self.parse_message_text_system_event, - "user_added": self.parse_message_user_added_system_event, - "user_remove": self.parse_message_user_remove_system_event, - "user_joined": self.parse_message_user_joined_system_event, - "user_left": self.parse_message_user_left_system_event, - "user_kicked": self.parse_message_user_kicked_system_event, - "user_banned": self.parse_message_user_banned_system_event, - "channel_renamed": self.parse_message_channel_renamed_system_event, - "channel_description_changed": self.parse_message_channel_description_changed_system_event, - "channel_icon_changed": self.parse_message_channel_icon_changed_system_event, - "channel_ownership_changed": self.parse_message_channel_ownership_changed_system_event, - }[d["type"]](d) + channel_id=d['channel_id'], + message_id=d['id'], + emoji=d['emoji_id'], + ) + + def parse_message_system_event( + self, + d: raw.SystemMessage, + members: dict[str, Member], + users: dict[str, User], + /, + ) -> StatelessSystemEvent: + return self._message_system_event_parsers[d['type']](d, members, users) def parse_message_text_system_event( - self, d: raw.TextSystemMessage - ) -> messages.TextSystemEvent: - return messages.TextSystemEvent(content=d["content"]) - - def parse_message_unreact_event( - self, shard: Shard, d: raw.ClientMessageUnreactEvent - ) -> events.MessageUnreactEvent: - return events.MessageUnreactEvent( + self, + d: raw.TextSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + ) -> TextSystemEvent: + return TextSystemEvent(content=d['content']) + + def parse_message_unreact_event(self, shard: Shard, d: raw.ClientMessageUnreactEvent) -> MessageUnreactEvent: + return MessageUnreactEvent( shard=shard, - channel_id=self.parse_id(d["channel_id"]), - message_id=self.parse_id(d["id"]), - user_id=self.parse_id(d["user_id"]), - emoji=d["emoji_id"], + channel_id=d['channel_id'], + message_id=d['id'], + user_id=d['user_id'], + emoji=d['emoji_id'], ) - def parse_message_update_event( - self, shard: Shard, d: raw.ClientMessageUpdateEvent - ) -> events.MessageUpdateEvent: - data = d["data"] + def parse_message_update_event(self, shard: Shard, d: raw.ClientMessageUpdateEvent) -> MessageUpdateEvent: + data = d['data'] + clear = d.get('clear', []) - content = data.get("content") - edited_at = data.get("edited") - embeds = data.get("embeds") - reactions = data.get("reactions") + content = data.get('content') + edited_at = data.get('edited') + embeds = data.get('embeds') + reactions = data.get('reactions') - return events.MessageUpdateEvent( + return MessageUpdateEvent( shard=shard, - message=messages.PartialMessage( + message=PartialMessage( state=self.state, - id=self.parse_id(d["id"]), - channel_id=self.parse_id(d["channel"]), - content=content if content is not None else core.UNDEFINED, - edited_at=( - datetime.fromisoformat(edited_at) if edited_at else core.UNDEFINED - ), - internal_embeds=( - [self.parse_embed(e) for e in embeds] - if embeds is not None - else core.UNDEFINED - ), - reactions=( - { - k: tuple(self.parse_id(u) for u in v) - for k, v in reactions.items() - } - if reactions is not None - else core.UNDEFINED - ), + id=d['id'], + channel_id=d['channel'], + content=content if content is not None else UNDEFINED, + edited_at=datetime.fromisoformat(edited_at) if edited_at else UNDEFINED, + internal_embeds=[self.parse_embed(e) for e in embeds] if embeds is not None else UNDEFINED, + pinned=False if 'Pinned' in clear else data.get('pinned', UNDEFINED), + reactions={k: tuple(v) for k, v in reactions.items()} if reactions is not None else UNDEFINED, ), before=None, after=None, ) def parse_message_user_added_system_event( - self, d: raw.UserAddedSystemMessage - ) -> messages.UserAddedSystemEvent: - return messages.UserAddedSystemEvent( - id=self.parse_id(d["id"]), by=self.parse_id(d["by"]) + self, + d: raw.UserAddedSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessUserAddedSystemEvent: + user_id = d['id'] + by_id = d['by'] + + return StatelessUserAddedSystemEvent( + internal_user=members.get(user_id, users.get(user_id, user_id)), + internal_by=members.get(by_id, users.get(by_id, by_id)), ) def parse_message_user_banned_system_event( - self, d: raw.UserBannedSystemMessage - ) -> messages.UserBannedSystemEvent: - return messages.UserBannedSystemEvent(id=self.parse_id(d["id"])) + self, + d: raw.UserBannedSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessUserBannedSystemEvent: + user_id = d['id'] + + return StatelessUserBannedSystemEvent(internal_user=members.get(user_id, users.get(user_id, user_id))) def parse_message_user_joined_system_event( - self, d: raw.UserJoinedSystemMessage - ) -> messages.UserJoinedSystemEvent: - return messages.UserJoinedSystemEvent(id=self.parse_id(d["id"])) + self, + d: raw.UserJoinedSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessUserJoinedSystemEvent: + user_id = d['id'] + + return StatelessUserJoinedSystemEvent(internal_user=members.get(user_id, users.get(user_id, user_id))) def parse_message_user_kicked_system_event( - self, d: raw.UserKickedSystemMessage - ) -> messages.UserKickedSystemEvent: - return messages.UserKickedSystemEvent(id=self.parse_id(d["id"])) + self, + d: raw.UserKickedSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessUserKickedSystemEvent: + user_id = d['id'] + + return StatelessUserKickedSystemEvent(internal_user=members.get(user_id, users.get(user_id, user_id))) def parse_message_user_left_system_event( - self, d: raw.UserLeftSystemMessage - ) -> messages.UserLeftSystemEvent: - return messages.UserLeftSystemEvent(id=self.parse_id(d["id"])) + self, + d: raw.UserLeftSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + /, + ) -> StatelessUserLeftSystemEvent: + user_id = d['id'] + + return StatelessUserLeftSystemEvent(internal_user=members.get(user_id, users.get(user_id, user_id))) def parse_message_user_remove_system_event( - self, d: raw.UserRemoveSystemMessage - ) -> messages.UserRemovedSystemEvent: - return messages.UserRemovedSystemEvent( - id=self.parse_id(d["id"]), by=self.parse_id(d["by"]) + self, + d: raw.UserRemoveSystemMessage, + members: dict[str, Member] = {}, + users: dict[str, User] = {}, + ) -> StatelessUserRemovedSystemEvent: + user_id = d['id'] + by_id = d['by'] + + return StatelessUserRemovedSystemEvent( + internal_user=members.get(user_id, users.get(user_id, user_id)), + internal_by=members.get(by_id, users.get(by_id, by_id)), ) - def parse_message_webhook(self, d: raw.MessageWebhook) -> messages.MessageWebhook: - return messages.MessageWebhook( - name=d["name"], - avatar=d.get("avatar"), + def parse_message_webhook(self, d: raw.MessageWebhook) -> MessageWebhook: + return MessageWebhook( + name=d['name'], + avatar=d.get('avatar'), ) - def parse_messages(self, d: raw.BulkMessageResponse) -> list[messages.Message]: + def parse_messages(self, d: raw.BulkMessageResponse) -> list[Message]: if isinstance(d, list): return [self.parse_message(e) for e in d] elif isinstance(d, dict): - users = [self.parse_user(e) for e in d["users"]] + users = [self.parse_user(e) for e in d['users']] users_mapping = {u.id: u for u in users} - members = [ - self.parse_member(e, users=users_mapping) - for e in d.get("members") or {} - ] + members = [self.parse_member(e, users=users_mapping) for e in d.get('members') or {}] members_mapping = {m.id: m for m in members} - return [ - self.parse_message(e, members=members_mapping, users=users_mapping) - for e in d["messages"] - ] - raise RuntimeError("Unreachable") - - def parse_mfa_response_login( - self, d: raw.a.MFAResponseLogin, friendly_name: str | None - ) -> auth.MFARequired: - return auth.MFARequired( - ticket=d["ticket"], - allowed_methods=[auth.MFAMethod(m) for m in d["allowed_methods"]], - internal_friendly_name=friendly_name, + return [self.parse_message(e, members=members_mapping, users=users_mapping) for e in d['messages']] + raise RuntimeError('Unreachable') + + def parse_mfa_response_login(self, d: raw.a.MFAResponseLogin, friendly_name: str | None) -> MFARequired: + return MFARequired( + ticket=d['ticket'], + allowed_methods=[MFAMethod(m) for m in d['allowed_methods']], state=self.state, + internal_friendly_name=friendly_name, ) - def parse_mfa_ticket(self, d: raw.a.MFATicket) -> auth.MFATicket: - return auth.MFATicket( - id=self.parse_id(d["_id"]), - account_id=self.parse_id(d["account_id"]), - token=d["token"], - validated=d["validated"], - authorised=d["authorised"], - last_totp_code=d.get("last_totp_code"), + def parse_mfa_ticket(self, d: raw.a.MFATicket) -> MFATicket: + return MFATicket( + id=d['_id'], + account_id=d['account_id'], + token=d['token'], + validated=d['validated'], + authorised=d['authorised'], + last_totp_code=d.get('last_totp_code'), ) - def parse_multi_factor_status( - self, d: raw.a.MultiFactorStatus - ) -> auth.MultiFactorStatus: - return auth.MultiFactorStatus( - totp_mfa=d["totp_mfa"], - recovery_active=d["recovery_active"], + def parse_multi_factor_status(self, d: raw.a.MultiFactorStatus) -> MFAStatus: + return MFAStatus( + totp_mfa=d['totp_mfa'], + recovery_active=d['recovery_active'], ) - def parse_mutuals(self, d: raw.MutualResponse) -> users.Mutuals: - return users.Mutuals( - user_ids=[self.parse_id(e) for e in d.get("users", [])], - server_ids=[self.parse_id(e) for e in d.get("servers", [])], + def parse_mutuals(self, d: raw.MutualResponse) -> Mutuals: + return Mutuals( + user_ids=d['users'], + server_ids=d['servers'], ) - def parse_none_embed(self, _: raw.NoneEmbed) -> embeds.NoneEmbed: - return embeds._NONE_EMBED + def parse_none_embed(self, _: raw.NoneEmbed) -> NoneEmbed: + return _NONE_EMBED - def parse_none_embed_special(self, _: raw.NoneSpecial) -> embeds.NoneEmbedSpecial: - return embeds._NONE_EMBED_SPECIAL + def parse_none_embed_special(self, _: raw.NoneSpecial) -> NoneEmbedSpecial: + return _NONE_EMBED_SPECIAL - def parse_partial_account(self, d: raw.a.AccountInfo) -> auth.PartialAccount: - return auth.PartialAccount(id=self.parse_id(d["_id"]), email=d["email"]) + def parse_own_user(self, d: raw.User, /) -> OwnUser: + avatar = d.get('avatar') + status = d.get('status') + # profile = d.get("profile") + privileged = d.get('privileged') + bot = d.get('bot') - def parse_partial_session(self, d: raw.a.SessionInfo) -> auth.PartialSession: - return auth.PartialSession( - state=self.state, id=self.parse_id(d["_id"]), name=d["name"] + relations = [self.parse_relationship(r) for r in d.get('relations', [])] + + return OwnUser( + state=self.state, + id=d['_id'], + name=d['username'], + discriminator=d['discriminator'], + display_name=d.get('display_name'), + internal_avatar=self.parse_asset(avatar) if avatar else None, + relations={relation.id: relation for relation in relations}, + badges=UserBadges(d.get('badges', 0)), + status=self.parse_user_status(status) if status else None, + # internal_profile=self.parse_user_profile(profile) if profile else None, + flags=UserFlags(d.get('flags', 0)), + privileged=privileged or False, + bot=self.parse_bot_user_info(bot) if bot else None, + relationship=RelationshipStatus(d['relationship']), + online=d['online'], ) - def parse_partial_user_profile( - self, d: raw.UserProfile, clear: list[raw.FieldsUser] - ) -> users.PartialUserProfile: - background = d.get("background") + def parse_partial_account(self, d: raw.a.AccountInfo) -> PartialAccount: + return PartialAccount(id=d['_id'], email=d['email']) + + def parse_partial_session(self, d: raw.a.SessionInfo) -> PartialSession: + return PartialSession(state=self.state, id=d['_id'], name=d['name']) + + def parse_partial_user_profile(self, d: raw.UserProfile, clear: list[raw.FieldsUser]) -> PartialUserProfile: + background = d.get('background') - return users.PartialUserProfile( + return PartialUserProfile( state=self.state, - content=( - None - if "ProfileContent" in clear - else d.get("content") or core.UNDEFINED - ), + content=(None if 'ProfileContent' in clear else d.get('content') or UNDEFINED), internal_background=( - None - if "ProfileBackground" in clear - else self.parse_asset(background) if background else core.UNDEFINED + None if 'ProfileBackground' in clear else self.parse_asset(background) if background else UNDEFINED ), ) - def parse_permission_override( - self, d: raw.Override - ) -> permissions_.PermissionOverride: - return permissions_.PermissionOverride( - allow=permissions_.Permissions(d["allow"]), - deny=permissions_.Permissions(d["deny"]), + def parse_permission_override(self, d: raw.Override) -> PermissionOverride: + return PermissionOverride( + allow=Permissions(d['allow']), + deny=Permissions(d['deny']), ) - def parse_permission_override_field( - self, d: raw.OverrideField - ) -> permissions_.PermissionOverride: - return permissions_.PermissionOverride( - allow=permissions_.Permissions(d["a"]), - deny=permissions_.Permissions(d["d"]), + def parse_permission_override_field(self, d: raw.OverrideField) -> PermissionOverride: + return PermissionOverride( + allow=Permissions(d['a']), + deny=Permissions(d['d']), ) - def parse_public_bot(self, d: raw.PublicBot) -> bots.PublicBot: - return bots.PublicBot( + def parse_public_bot(self, d: raw.PublicBot) -> PublicBot: + return PublicBot( state=self.state, - id=self.parse_id(d["_id"]), - username=d["username"], - internal_avatar_id=d.get("avatar"), - description=d.get("description") or "", + id=d['_id'], + username=d['username'], + internal_avatar_id=d.get('avatar'), + description=d.get('description', ''), ) - def parse_public_invite(self, d: raw.InviteResponse) -> invites.BaseInvite: - return { - "Server": self.parse_server_public_invite, - "Group": self.parse_group_public_invite, - }.get(d["type"], self.parse_unknown_public_invite)(d) + def parse_public_invite(self, d: raw.InviteResponse) -> BaseInvite: + return self._public_invite_parsers.get(d['type'], self.parse_unknown_public_invite)(d) - def parse_read_state(self, d: raw.ChannelUnread) -> read_state.ReadState: - id = d["_id"] - last_id = d.get("last_id") + def parse_read_state(self, d: raw.ChannelUnread) -> ReadState: + id = d['_id'] + last_id = d.get('last_id') - return read_state.ReadState( + return ReadState( state=self.state, - channel_id=self.parse_id(id["channel"]), - user_id=self.parse_id(id["user"]), - last_message_id=self.parse_id(last_id) if last_id else None, - mentioned_in=[self.parse_id(m) for m in d.get("mentions") or []], + channel_id=id['channel'], + user_id=id['user'], + last_message_id=last_id if last_id else None, + mentioned_in=d.get('mentions') or [], + ) + + def parse_ready_event(self, shard: Shard, d: raw.ClientReadyEvent) -> ReadyEvent: + users = [self.parse_user(u) for u in d.get('users', [])] + servers = [self.parse_server(s, (True, s['channels'])) for s in d.get('servers', [])] + channels = [self.parse_channel(c) for c in d.get('channels', [])] + members = [self.parse_member(m) for m in d.get('members', [])] + emojis = [self.parse_server_emoji(e) for e in d.get('emojis', [])] + user_settings = self.parse_user_settings(d.get('user_settings', {}), False) + read_states = [self.parse_read_state(rs) for rs in d.get('channel_unreads', [])] + + me = users[-1] + if me.__class__ is not OwnUser or not isinstance(me, OwnUser): + for user in users: + if me.__class__ is not OwnUser or isinstance(me, OwnUser): + me = user + + if me.__class__ is not OwnUser or not isinstance(me, OwnUser): + raise TypeError('Unable to find own user') + + return ReadyEvent( + shard=shard, + users=users, + servers=servers, + channels=channels, + members=members, + emojis=emojis, + me=me, # type: ignore + user_settings=user_settings, + read_states=read_states, ) - def parse_ready_event( - self, shard: Shard, d: raw.ClientReadyEvent - ) -> events.ReadyEvent: - ready_users = [self.parse_user(u) for u in d["users"]] - - from .user import SelfUser - - me: SelfUser | None = None - - for user in ready_users: - if user.__class__ is SelfUser or isinstance(user, SelfUser): - me = user # type: ignore # pyright doesnt understand `user.__class__` + def parse_relationship(self, d: raw.Relationship) -> Relationship: + return Relationship( + id=d['_id'], + status=RelationshipStatus(d['status']), + ) - return events.ReadyEvent( - shard=shard, - old_me=self.state._me, - new_me=me, - users=ready_users, - servers=[ - self.parse_server(s, (True, [self.parse_id(c) for c in s["channels"]])) - for s in d["servers"] - ], - channels=[self.parse_channel(c) for c in d["channels"]], - members=[self.parse_member(m) for m in d["members"]], - emojis=[self.parse_server_emoji(e) for e in d["emojis"]], - ) - - def parse_relationship(self, d: raw.Relationship) -> users.Relationship: - return users.Relationship( - user_id=self.parse_id(d["_id"]), - status=users.RelationshipStatus(d["status"]), - ) - - def parse_response_login( - self, d: raw.a.ResponseLogin, friendly_name: str | None - ) -> auth.LoginResult: - if d["result"] == "Success": + def parse_response_login(self, d: raw.a.ResponseLogin, friendly_name: str | None) -> LoginResult: + if d['result'] == 'Success': return self.parse_session(d) - elif d["result"] == "MFA": + elif d['result'] == 'MFA': return self.parse_mfa_response_login(d, friendly_name) - elif d["result"] == "Disabled": + elif d['result'] == 'Disabled': return self.parse_disabled_response_login(d) else: raise NotImplementedError(d) - def parse_response_webhook(self, d: raw.ResponseWebhook) -> webhooks.Webhook: - avatar = d.get("avatar") - webhook_id = self.parse_id(d["id"]) + def parse_response_webhook(self, d: raw.ResponseWebhook) -> Webhook: + avatar = d.get('avatar') + webhook_id = d['id'] - return webhooks.Webhook( + return Webhook( state=self.state, id=webhook_id, - name=d["name"], + name=d['name'], internal_avatar=( - cdn.StatelessAsset( + StatelessAsset( id=avatar, - filename="", - metadata=cdn.AssetMetadata( - type=cdn.AssetMetadataType.IMAGE, + filename='', + metadata=AssetMetadata( + type=AssetMetadataType.image, width=None, height=None, ), - content_type="", + content_type='', size=0, deleted=False, reported=False, @@ -1100,748 +1279,593 @@ def parse_response_webhook(self, d: raw.ResponseWebhook) -> webhooks.Webhook: if avatar else None ), - channel_id=self.parse_id(d["channel_id"]), - permissions=permissions_.Permissions(d["permissions"]), + channel_id=d['channel_id'], + permissions=Permissions(d['permissions']), token=None, ) - def parse_role( - self, d: raw.Role, role_id: core.ULID, server_id: core.ULID - ) -> servers.Role: - return servers.Role( + def parse_role(self, d: raw.Role, role_id: str, server_id: str, /) -> Role: + return Role( state=self.state, id=role_id, - name=d["name"], - permissions=self.parse_permission_override_field(d["permissions"]), - colour=d.get("colour"), - hoist=d.get("hoist", False), - rank=d["rank"], + name=d['name'], + permissions=self.parse_permission_override_field(d['permissions']), + colour=d.get('colour'), + hoist=d.get('hoist', False), + rank=d['rank'], server_id=server_id, ) - def parse_saved_messages_channel( - self, d: raw.SavedMessagesChannel - ) -> channels.SavedMessagesChannel: - return channels.SavedMessagesChannel( + def parse_saved_messages_channel(self, d: raw.SavedMessagesChannel) -> SavedMessagesChannel: + return SavedMessagesChannel( state=self.state, - id=self.parse_id(d["_id"]), - user_id=self.parse_id(d["user"]), - ) - - def parse_self_user(self, d: raw.User) -> users.SelfUser: - avatar = d.get("avatar") - status = d.get("status") - profile = d.get("profile") - privileged = d.get("privileged") - bot = d.get("bot") - - return users.SelfUser( - state=self.state, - id=self.parse_id(d["_id"]), - name=d["username"], - discriminator=d["discriminator"], - display_name=d.get("display_name"), - internal_avatar=self.parse_asset(avatar) if avatar else None, - relations=[self.parse_relationship(r) for r in d.get("relations") or []], - badges=users.UserBadges(d.get("badges") or 0), - status=self.parse_user_status(status) if status else None, - # internal_profile=self.parse_user_profile(profile) if profile else None, - flags=users.UserFlags(d.get("flags") or 0), - privileged=privileged or False, - bot=self.parse_bot_user_info(bot) if bot else None, - relationship=users.RelationshipStatus(d["relationship"]), - online=d["online"], + id=d['_id'], + user_id=d['user'], ) def _parse_server( self, d: raw.Server, - channels: ( - tuple[t.Literal[True], list[core.ULID]] - | tuple[t.Literal[False], list[channels.ServerChannel]] - ), - ) -> servers.Server: - server_id = self.parse_id(d["_id"]) + channels: (tuple[typing.Literal[True], list[str]] | tuple[typing.Literal[False], list[ServerChannel]]), + /, + ) -> Server: + server_id = d['_id'] - categories = d.get("categories") or [] - system_messages = d.get("system_messages") + categories = d.get('categories', []) + system_messages = d.get('system_messages') roles = {} - for id, role_data in (d.get("roles") or {}).items(): - role_id = self.parse_id(id) + for id, role_data in d.get('roles', {}).items(): + role_id = id roles[role_id] = self.parse_role(role_data, role_id, server_id) - icon = d.get("icon") - banner = d.get("banner") - nsfw = d.get("nsfw") - analytics = d.get("analytics") - discoverable = d.get("discoverable") + icon = d.get('icon') + banner = d.get('banner') + + flags = _new_server_flags(ServerFlags) + flags.value = d.get('flags', 0) - return servers.Server( + return Server( state=self.state, id=server_id, - owner_id=self.parse_id(d["owner"]), - name=d["name"], - description=d.get("description"), + owner_id=d['owner'], + name=d['name'], + description=d.get('description'), internal_channels=channels, categories=[self.parse_category(e) for e in categories], - system_messages=( - self.parse_system_message_channels(system_messages) - if system_messages - else None - ), + system_messages=(self.parse_system_message_channels(system_messages) if system_messages else None), roles=roles, - default_permissions=permissions_.Permissions(d["default_permissions"]), + default_permissions=Permissions(d['default_permissions']), internal_icon=self.parse_asset(icon) if icon else None, internal_banner=self.parse_asset(banner) if banner else None, - flags=servers.ServerFlags(d.get("flags") or 0), - nsfw=nsfw or False, - analytics=analytics or False, - discoverable=discoverable or False, + flags=flags, + nsfw=d.get('nsfw', False), + analytics=d.get('analytics', False), + discoverable=d.get('discoverable', False), ) def parse_server( self, d: raw.Server, - channels: ( - tuple[t.Literal[True], list[str]] - | tuple[t.Literal[False], list[raw.Channel]] - ), - ) -> servers.Server: + channels: (tuple[typing.Literal[True], list[str]] | tuple[typing.Literal[False], list[raw.Channel]]), + /, + ) -> Server: internal_channels: ( - tuple[t.Literal[True], list[core.ULID]] - | tuple[t.Literal[False], list[channels.ServerChannel]] + tuple[typing.Literal[True], list[str]] | tuple[typing.Literal[False], list[ServerChannel]] ) = ( - (True, [core.ULID(i) for i in channels[1]]) + (True, [str(i) for i in channels[1]]) if channels[0] else (False, [self.parse_channel(c) for c in channels[1]]) # type: ignore ) return self._parse_server(d, internal_channels) def parse_server_create_event( - self, shard: Shard, d: raw.ClientServerCreateEvent - ) -> events.ServerCreateEvent: - return events.ServerCreateEvent( + self, shard: Shard, d: raw.ClientServerCreateEvent, joined_at: datetime, / + ) -> ServerCreateEvent: + return ServerCreateEvent( shard=shard, - server=self.parse_server(d["server"], (False, d["channels"])), - emojis=[self.parse_server_emoji(e) for e in d["emojis"]], + joined_at=joined_at, + server=self.parse_server(d['server'], (False, d['channels'])), + emojis=[self.parse_server_emoji(e) for e in d['emojis']], ) - def parse_server_delete_event( - self, shard: Shard, d: raw.ClientServerDeleteEvent - ) -> events.ServerDeleteEvent: - return events.ServerDeleteEvent( + def parse_server_delete_event(self, shard: Shard, d: raw.ClientServerDeleteEvent) -> ServerDeleteEvent: + return ServerDeleteEvent( shard=shard, - server_id=self.parse_id(d["id"]), + server_id=d['id'], server=None, ) - def parse_server_emoji(self, d: raw.ServerEmoji) -> emojis.ServerEmoji: - animated = d.get("animated") - nsfw = d.get("nsfw") - - return emojis.ServerEmoji( + def parse_server_emoji(self, d: raw.ServerEmoji) -> ServerEmoji: + return ServerEmoji( state=self.state, - id=self.parse_id(d["_id"]), - server_id=self.parse_id(d["parent"]["id"]), - creator_id=self.parse_id(d["creator_id"]), - name=d["name"], - animated=animated or False, - nsfw=nsfw or False, + id=d['_id'], + server_id=d['parent']['id'], + creator_id=d['creator_id'], + name=d['name'], + animated=d.get('animated', False), + nsfw=d.get('nsfw', False), ) - def parse_server_invite(self, d: raw.ServerInvite) -> invites.ServerInvite: - return invites.ServerInvite( + def parse_server_invite(self, d: raw.ServerInvite) -> ServerInvite: + return ServerInvite( state=self.state, - code=d["_id"], - creator_id=self.parse_id(d["creator"]), - server_id=self.parse_id(d["server"]), - channel_id=self.parse_id(d["channel"]), + code=d['_id'], + creator_id=d['creator'], + server_id=d['server'], + channel_id=d['channel'], ) def parse_server_member_join_event( self, shard: Shard, d: raw.ClientServerMemberJoinEvent, joined_at: datetime - ) -> events.ServerMemberJoinEvent: - return events.ServerMemberJoinEvent( + ) -> ServerMemberJoinEvent: + return ServerMemberJoinEvent( shard=shard, - member=servers.Member( + member=Member( state=self.state, - server_id=self.parse_id(d["id"]), - _user=self.parse_id(d["user"]), + server_id=d['id'], + _user=d['user'], joined_at=joined_at, nick=None, - internal_avatar=None, + internal_server_avatar=None, roles=[], - timeout=None, + timed_out_until=None, ), ) def parse_server_member_leave_event( self, shard: Shard, d: raw.ClientServerMemberLeaveEvent - ) -> events.ServerMemberLeaveEvent: - return events.ServerMemberLeaveEvent( + ) -> ServerMemberRemoveEvent: + return ServerMemberRemoveEvent( shard=shard, - server_id=self.parse_id(d["id"]), - user_id=self.parse_id(d["user"]), + server_id=d['id'], + user_id=d['user'], member=None, + reason=MemberRemovalIntention(d['reason']), ) def parse_server_member_update_event( self, shard: Shard, d: raw.ClientServerMemberUpdateEvent - ) -> events.ServerMemberUpdateEvent: - id = d["id"] - data = d["data"] - clear = d["clear"] + ) -> ServerMemberUpdateEvent: + id = d['id'] + data = d['data'] + clear = d['clear'] - avatar = data.get("avatar") - roles = data.get("roles") - timeout = data.get("timeout") + avatar = data.get('avatar') + roles = data.get('roles') + timeout = data.get('timeout') - return events.ServerMemberUpdateEvent( + return ServerMemberUpdateEvent( shard=shard, - member=servers.PartialMember( + member=PartialMember( state=self.state, - server_id=self.parse_id(id["server"]), - _user=self.parse_id(id["user"]), - nick=( - None - if "Nickname" in clear - else data.get("nickname") or core.UNDEFINED - ), - internal_avatar=( - None - if "Avatar" in clear - else self.parse_asset(avatar) if avatar else core.UNDEFINED - ), - roles=( - [] - if "Roles" in clear - else ( - [self.parse_id(role) for role in roles] - if roles is not None - else core.UNDEFINED - ) - ), - timeout=( - None - if "Timeout" in clear - else datetime.fromisoformat(timeout) if timeout else core.UNDEFINED + server_id=id['server'], + _user=id['user'], + nick=None if 'Nickname' in clear else data.get('nickname', UNDEFINED), + internal_server_avatar=None if 'Avatar' in clear else self.parse_asset(avatar) if avatar else UNDEFINED, + roles=[] if 'Roles' in clear else roles if roles is not None else UNDEFINED, + timed_out_until=( + None if 'Timeout' in clear else datetime.fromisoformat(timeout) if timeout else UNDEFINED ), ), before=None, # filled on dispatch after=None, # filled on dispatch ) - def parse_server_public_invite( - self, d: raw.ServerInviteResponse - ) -> invites.ServerPublicInvite: - server_icon = d.get("server_icon") - server_banner = d.get("server_banner") - user_avatar = d.get("user_avatar") + def parse_server_public_invite(self, d: raw.ServerInviteResponse) -> ServerPublicInvite: + server_icon = d.get('server_icon') + server_banner = d.get('server_banner') + user_avatar = d.get('user_avatar') - return invites.ServerPublicInvite( + return ServerPublicInvite( state=self.state, - code=d["code"], - server_id=self.parse_id(d["server_id"]), - server_name=d["server_name"], + code=d['code'], + server_id=d['server_id'], + server_name=d['server_name'], internal_server_icon=self.parse_asset(server_icon) if server_icon else None, - internal_server_banner=( - self.parse_asset(server_banner) if server_banner else None - ), - flags=servers.ServerFlags(d.get("server_flags") or 0), - channel_id=self.parse_id(d["channel_id"]), - channel_name=d["channel_name"], - channel_description=d.get("channel_description", None), - user_name=d["user_name"], + internal_server_banner=(self.parse_asset(server_banner) if server_banner else None), + flags=ServerFlags(d.get('server_flags', 0)), + channel_id=d['channel_id'], + channel_name=d['channel_name'], + channel_description=d.get('channel_description'), + user_name=d['user_name'], internal_user_avatar=self.parse_asset(user_avatar) if user_avatar else None, - members_count=d["member_count"], + members_count=d['member_count'], ) - def parse_server_role_delete_event( - self, shard: Shard, d: raw.ClientServerRoleDeleteEvent - ) -> events.ServerRoleDeleteEvent: - return events.ServerRoleDeleteEvent( + def parse_server_role_delete_event(self, shard: Shard, d: raw.ClientServerRoleDeleteEvent) -> ServerRoleDeleteEvent: + return ServerRoleDeleteEvent( shard=shard, - server_id=self.parse_id(d["id"]), - role_id=self.parse_id(d["role_id"]), + server_id=d['id'], + role_id=d['role_id'], server=None, role=None, ) def parse_server_role_update_event( self, shard: Shard, d: raw.ClientServerRoleUpdateEvent - ) -> events.RawServerRoleUpdateEvent: - data = d["data"] - clear = d["clear"] + ) -> RawServerRoleUpdateEvent: + data = d['data'] + clear = d['clear'] - permissions = data.get("permissions") - hoist = data.get("hoist") - rank = data.get("rank") + permissions = data.get('permissions') - return events.RawServerRoleUpdateEvent( + return RawServerRoleUpdateEvent( shard=shard, - role=servers.PartialRole( + role=PartialRole( state=self.state, - id=self.parse_id(d["role_id"]), - server_id=self.parse_id(d["id"]), - name=data.get("name") or core.UNDEFINED, - permissions=( - self.parse_permission_override_field(permissions) - if permissions - else core.UNDEFINED - ), - colour=( - None if "Colour" in clear else data.get("colour") or core.UNDEFINED - ), - hoist=hoist if hoist is not None else core.UNDEFINED, - rank=rank if rank is not None else core.UNDEFINED, + id=d['role_id'], + server_id=d['id'], + name=data.get('name') or UNDEFINED, + permissions=(self.parse_permission_override_field(permissions) if permissions else UNDEFINED), + colour=(None if 'Colour' in clear else data.get('colour', UNDEFINED)), + hoist=data.get('hoist', UNDEFINED), + rank=data.get('rank', UNDEFINED), ), old_role=None, new_role=None, server=None, ) - def parse_server_update_event( - self, shard: Shard, d: raw.ClientServerUpdateEvent - ) -> events.ServerUpdateEvent: - data = d["data"] - clear = d["clear"] - - owner_id = data.get("owner_id") - description = data.get("description") - channels = data.get("channels") - categories = data.get("categories") - system_messages = data.get("system_messages") - default_permissions = data.get("default_permissions") - icon = data.get("icon") - banner = data.get("banner") - flags = data.get("flags") - discoverable = data.get("discoverable") - analytics = data.get("analytics") - - return events.ServerUpdateEvent( + def parse_server_update_event(self, shard: Shard, d: raw.ClientServerUpdateEvent) -> ServerUpdateEvent: + data = d['data'] + clear = d['clear'] + + description = data.get('description') + categories = data.get('categories') + system_messages = data.get('system_messages') + default_permissions = data.get('default_permissions') + icon = data.get('icon') + banner = data.get('banner') + flags = data.get('flags') + + return ServerUpdateEvent( shard=shard, - server=servers.PartialServer( + server=PartialServer( state=self.state, - id=self.parse_id(d["id"]), - owner_id=( - self.parse_id(owner_id) if owner_id is not None else core.UNDEFINED - ), - name=data.get("name") or core.UNDEFINED, - description=( - None - if "Description" in clear - else description if description is not None else core.UNDEFINED - ), - channel_ids=( - [self.parse_id(c) for c in channels] - if channels is not None - else core.UNDEFINED - ), + id=d['id'], + owner_id=d.get('owner', UNDEFINED), + name=data.get('name', UNDEFINED), + description=(None if 'Description' in clear else description if description is not None else UNDEFINED), + channel_ids=data.get('channels', UNDEFINED), categories=( [] - if "Categories" in clear - else ( - [self.parse_category(c) for c in categories] - if categories is not None - else core.UNDEFINED - ) + if 'Categories' in clear + else ([self.parse_category(c) for c in categories] if categories is not None else UNDEFINED) ), system_messages=( None - if "SystemMessages" in clear + if 'SystemMessages' in clear else ( self.parse_system_message_channels(system_messages) if system_messages is not None - else core.UNDEFINED + else UNDEFINED ) ), default_permissions=( - permissions_.Permissions(default_permissions) - if default_permissions is not None - else core.UNDEFINED - ), - internal_icon=( - None - if "Icon" in clear - else self.parse_asset(icon) if icon else core.UNDEFINED - ), - internal_banner=( - None - if "Banner" in clear - else self.parse_asset(banner) if banner else core.UNDEFINED - ), - flags=( - servers.ServerFlags(flags) if flags is not None else core.UNDEFINED - ), - discoverable=( - discoverable if discoverable is not None else core.UNDEFINED + Permissions(default_permissions) if default_permissions is not None else UNDEFINED ), - analytics=analytics if analytics is not None else core.UNDEFINED, + internal_icon=(None if 'Icon' in clear else self.parse_asset(icon) if icon else UNDEFINED), + internal_banner=(None if 'Banner' in clear else self.parse_asset(banner) if banner else UNDEFINED), + flags=(ServerFlags(flags) if flags is not None else UNDEFINED), + discoverable=d.get('discoverable', UNDEFINED), + analytics=d.get('analytics', UNDEFINED), ), before=None, # filled on dispatch after=None, # filled on dispatch ) - def parse_session(self, d: raw.a.Session) -> auth.Session: - subscription = d.get("subscription") + def parse_session(self, d: raw.a.Session) -> Session: + subscription = d.get('subscription') - return auth.Session( + return Session( state=self.state, - id=self.parse_id(d["_id"]), - name=d["name"], - user_id=self.parse_id(d["user_id"]), - token=d["token"], - subscription=( - self.parse_webpush_subscription(subscription) if subscription else None - ), + id=d['_id'], + name=d['name'], + user_id=d['user_id'], + token=d['token'], + subscription=(self.parse_webpush_subscription(subscription) if subscription else None), ) - def parse_soundcloud_embed_special( - self, _: raw.SoundcloudSpecial - ) -> embeds.SoundcloudEmbedSpecial: - return embeds._SOUNDCLOUD_EMBED_SPECIAL + def parse_soundcloud_embed_special(self, _: raw.SoundcloudSpecial) -> SoundcloudEmbedSpecial: + return _SOUNDCLOUD_EMBED_SPECIAL - def parse_spotify_embed_special( - self, d: raw.SpotifySpecial - ) -> embeds.SpotifyEmbedSpecial: - return embeds.SpotifyEmbedSpecial( - content_type=d["content_type"], - id=d["id"], + def parse_spotify_embed_special(self, d: raw.SpotifySpecial) -> SpotifyEmbedSpecial: + return SpotifyEmbedSpecial( + content_type=d['content_type'], + id=d['id'], ) - def parse_streamable_embed_special( - self, d: raw.StreamableSpecial - ) -> embeds.StreamableEmbedSpecial: - return embeds.StreamableEmbedSpecial(id=d["id"]) + def parse_streamable_embed_special(self, d: raw.StreamableSpecial) -> StreamableEmbedSpecial: + return StreamableEmbedSpecial(id=d['id']) - def parse_system_message_channels( - self, - d: raw.SystemMessageChannels, - ) -> servers.SystemMessageChannels: - return servers.SystemMessageChannels( - user_joined=d.get("user_joined"), - user_left=d.get("user_left"), - user_kicked=d.get("user_kicked"), - user_banned=d.get("user_banned"), - ) - - def parse_text_channel(self, d: raw.TextChannel) -> channels.ServerTextChannel: - icon = d.get("icon") - last_message_id = d.get("last_message_id") - default_permissions = d.get("default_permissions") - role_permissions = d.get("role_permissions") or {} - nsfw = d.get("nsfw") - - return channels.ServerTextChannel( + def parse_system_message_channels(self, d: raw.SystemMessageChannels, /) -> SystemMessageChannels: + return SystemMessageChannels( + user_joined=d.get('user_joined'), + user_left=d.get('user_left'), + user_kicked=d.get('user_kicked'), + user_banned=d.get('user_banned'), + ) + + def parse_text_channel(self, d: raw.TextChannel, /) -> ServerTextChannel: + icon = d.get('icon') + default_permissions = d.get('default_permissions') + role_permissions = d.get('role_permissions', {}) + + try: + last_message_id = d['last_message_id'] # type: ignore # I really hope that Eric will allow that + except KeyError: + last_message_id = None + + return ServerTextChannel( state=self.state, - id=self.parse_id(d["_id"]), - server_id=self.parse_id(d["server"]), - name=d["name"], - description=d.get("description"), + id=d['_id'], + server_id=d['server'], + name=d['name'], + description=d.get('description'), internal_icon=self.parse_asset(icon) if icon else None, - last_message_id=self.parse_id(last_message_id) if last_message_id else None, + last_message_id=last_message_id, default_permissions=( - None - if default_permissions is None - else self.parse_permission_override_field(default_permissions) + None if default_permissions is None else self.parse_permission_override_field(default_permissions) ), - role_permissions={ - self.parse_id(k): self.parse_permission_override_field(v) - for k, v in role_permissions.items() - }, - nsfw=nsfw or False, + role_permissions={k: self.parse_permission_override_field(v) for k, v in role_permissions.items()}, + nsfw=d.get('nsfw', False), ) - def parse_text_embed(self, d: raw.TextEmbed) -> embeds.StatelessTextEmbed: - media = d.get("media") + def parse_text_embed(self, d: raw.TextEmbed) -> StatelessTextEmbed: + media = d.get('media') - return embeds.StatelessTextEmbed( - icon_url=d.get("icon_url"), - url=d.get("url"), - title=d.get("title"), - description=d.get("description"), + return StatelessTextEmbed( + icon_url=d.get('icon_url'), + url=d.get('url'), + title=d.get('title'), + description=d.get('description'), internal_media=self.parse_asset(media) if media else None, - colour=d.get("colour"), + colour=d.get('colour'), ) - def parse_twitch_embed_special( - self, d: raw.TwitchSpecial - ) -> embeds.TwitchEmbedSpecial: - return embeds.TwitchEmbedSpecial( - content_type=embeds.TwitchContentType(d["content_type"]), - id=d["id"], + def parse_twitch_embed_special(self, d: raw.TwitchSpecial) -> TwitchEmbedSpecial: + return TwitchEmbedSpecial( + content_type=TwitchContentType(d['content_type']), + id=d['id'], ) - def parse_unknown_public_invite( - self, d: dict[str, t.Any] - ) -> invites.UnknownPublicInvite: - return invites.UnknownPublicInvite(state=self.state, code=d["code"], d=d) + def parse_unknown_public_invite(self, d: dict[str, typing.Any]) -> UnknownPublicInvite: + return UnknownPublicInvite(state=self.state, code=d['code'], payload=d) + + def parse_user(self, d: raw.User, /) -> User | OwnUser: + if d['relationship'] == 'User': + return self.parse_own_user(d) - def parse_user(self, d: raw.User) -> users.User | users.SelfUser: - if d["relationship"] == "User": - return self.parse_self_user(d) + avatar = d.get('avatar') + status = d.get('status') - avatar = d.get("avatar") - status = d.get("status") - profile = d.get("profile") - privileged = d.get("privileged") - bot = d.get("bot") + badges = _new_user_badges(UserBadges) + badges.value = d.get('badges', 0) - return users.User( + flags = _new_user_flags(UserFlags) + flags.value = d.get('flags', 0) + + bot = d.get('bot') + + return User( state=self.state, - id=self.parse_id(d["_id"]), - name=d["username"], - discriminator=d["discriminator"], - display_name=d.get("display_name"), + id=d['_id'], + name=d['username'], + discriminator=d['discriminator'], + display_name=d.get('display_name'), internal_avatar=self.parse_asset(avatar) if avatar else None, - badges=users.UserBadges(d.get("badges") or 0), + badges=badges, status=self.parse_user_status(status) if status else None, # internal_profile=self.parse_user_profile(profile) if profile else None, - flags=users.UserFlags(d.get("flags") or 0), - privileged=privileged or False, + flags=flags, + privileged=d.get('privileged', False), bot=self.parse_bot_user_info(bot) if bot else None, - relationship=users.RelationshipStatus(d["relationship"]), - online=d.get("online") or False, + relationship=RelationshipStatus(d['relationship']), + online=d['online'], ) - def parse_user_platform_wipe_event( - self, shard: Shard, d: raw.ClientUserPlatformWipeEvent - ) -> events.UserPlatformWipeEvent: - return events.UserPlatformWipeEvent( + def parse_user_platform_wipe_event(self, shard: Shard, d: raw.ClientUserPlatformWipeEvent) -> UserPlatformWipeEvent: + return UserPlatformWipeEvent( shard=shard, - user_id=self.parse_id(d["user_id"]), - flags=users.UserFlags(d["flags"]), + user_id=d['user_id'], + flags=UserFlags(d['flags']), ) - def parse_user_profile(self, d: raw.UserProfile) -> users.StatelessUserProfile: - background = d.get("background") + def parse_user_profile(self, d: raw.UserProfile) -> StatelessUserProfile: + background = d.get('background') - return users.StatelessUserProfile( - content=d.get("content"), + return StatelessUserProfile( + content=d.get('content'), internal_background=self.parse_asset(background) if background else None, ) def parse_user_relationship_event( self, shard: Shard, d: raw.ClientUserRelationshipEvent - ) -> events.UserRelationshipUpdateEvent: - return events.UserRelationshipUpdateEvent( + ) -> UserRelationshipUpdateEvent: + return UserRelationshipUpdateEvent( shard=shard, - current_user_id=self.parse_id(d["id"]), + current_user_id=d['id'], old_user=None, - new_user=self.parse_user(d["user"]), + new_user=self.parse_user(d['user']), before=None, ) - def parse_user_settings(self, d: raw.UserSettings) -> user_settings.UserSettings: - return user_settings.UserSettings( - state=self.state, value={k: (s1, s2) for (k, (s1, s2)) in d.items()} + def parse_user_settings(self, d: raw.UserSettings, partial: bool) -> UserSettings: + return UserSettings( + data={k: (s1, s2) for (k, (s1, s2)) in d.items()}, + state=self.state, + mocked=False, + partial=partial, ) def parse_user_settings_update_event( self, shard: Shard, d: raw.ClientUserSettingsUpdateEvent - ) -> events.UserSettingsUpdateEvent: - return events.UserSettingsUpdateEvent( + ) -> UserSettingsUpdateEvent: + partial = self.parse_user_settings(d['update'], True) + + before = shard.state.settings + + if not before.mocked: + before = copy(before) + after = copy(before) + after._update(partial) + else: + after = before + + return UserSettingsUpdateEvent( shard=shard, - before=None, - current_user_id=self.parse_id(d["id"]), - after=self.parse_user_settings(d["update"]), + current_user_id=d['id'], + partial=partial, + before=before, + after=after, ) - def parse_user_status(self, d: raw.UserStatus) -> users.UserStatus: - presence = d.get("presence") + def parse_user_status(self, d: raw.UserStatus, /) -> UserStatus: + presence = d.get('presence') - return users.UserStatus( - text=d.get("text"), - presence=users.Presence(presence) if presence else None, + return UserStatus( + text=d.get('text'), + presence=Presence(presence) if presence else None, ) - def parse_user_status_edit( - self, d: raw.UserStatus, clear: list[raw.FieldsUser] - ) -> users.UserStatusEdit: - presence = d.get("presence") + def parse_user_status_edit(self, d: raw.UserStatus, clear: list[raw.FieldsUser]) -> UserStatusEdit: + presence = d.get('presence') - return users.UserStatusEdit( - text=None if "StatusText" in clear else d.get("text") or core.UNDEFINED, - presence=( - None - if "StatusPresence" in clear - else users.Presence(presence) if presence else core.UNDEFINED - ), + return UserStatusEdit( + text=None if 'StatusText' in clear else d.get('text', UNDEFINED), + presence=(None if 'StatusPresence' in clear else Presence(presence) if presence else UNDEFINED), ) - def parse_user_update_event( - self, shard: Shard, d: raw.ClientUserUpdateEvent - ) -> events.UserUpdateEvent: - user_id = self.parse_id(d["id"]) - data = d["data"] - clear = d["clear"] + def parse_user_update_event(self, shard: Shard, d: raw.ClientUserUpdateEvent) -> UserUpdateEvent: + user_id = d['id'] + data = d['data'] + clear = d['clear'] - avatar = data.get("avatar") - badges = data.get("badges") - status = data.get("status") - profile = data.get("profile") - flags = data.get("flags") - online = data.get("online") + avatar = data.get('avatar') + badges = data.get('badges') + status = data.get('status') + profile = data.get('profile') + flags = data.get('flags') - return events.UserUpdateEvent( + return UserUpdateEvent( shard=shard, - user=users.PartialUser( + user=PartialUser( state=self.state, id=user_id, - name=data.get("username") or core.UNDEFINED, - discriminator=data.get("discriminator") or core.UNDEFINED, - display_name=( - None - if "DisplayName" in clear - else data.get("display_name") or core.UNDEFINED - ), - internal_avatar=( - None - if "Avatar" in clear - else self.parse_asset(avatar) if avatar else core.UNDEFINED - ), - badges=( - users.UserBadges(badges) if badges is not None else core.UNDEFINED - ), - status=( - self.parse_user_status_edit(status, clear) - if status is not None - else core.UNDEFINED + name=data.get('username', UNDEFINED), + discriminator=data.get('discriminator', UNDEFINED), + display_name=(None if 'DisplayName' in clear else data.get('display_name') or UNDEFINED), + internal_avatar=(None if 'Avatar' in clear else self.parse_asset(avatar) if avatar else UNDEFINED), + badges=UserBadges(badges) if badges is not None else UNDEFINED, + status=(self.parse_user_status_edit(status, clear) if status is not None else UNDEFINED), + internal_profile=( + self.parse_partial_user_profile(profile, clear) if profile is not None else UNDEFINED ), - profile=( - self.parse_partial_user_profile(profile, clear) - if profile is not None - else core.UNDEFINED - ), - flags=users.UserFlags(flags) if flags is not None else core.UNDEFINED, - online=online if online is not None else core.UNDEFINED, + flags=UserFlags(flags) if flags is not None else UNDEFINED, + online=d.get('online', UNDEFINED), ), before=None, # filled on dispatch after=None, # filled on dispatch ) - def parse_video_embed(self, d: raw.Video) -> embeds.VideoEmbed: - return embeds.VideoEmbed( - url=d["url"], - width=d["width"], - height=d["height"], + def parse_video_embed(self, d: raw.Video) -> VideoEmbed: + return VideoEmbed( + url=d['url'], + width=d['width'], + height=d['height'], ) - def parse_voice_channel(self, d: raw.VoiceChannel) -> channels.VoiceChannel: - icon = d.get("icon") - default_permissions = d.get("default_permissions") - role_permissions = d.get("role_permissions") or {} - nsfw = d.get("nsfw") + def parse_voice_channel(self, d: raw.VoiceChannel) -> VoiceChannel: + icon = d.get('icon') + default_permissions = d.get('default_permissions') + role_permissions = d.get('role_permissions', {}) - return channels.VoiceChannel( + return VoiceChannel( state=self.state, - id=self.parse_id(d["_id"]), - server_id=self.parse_id(d["server"]), - name=d["name"], - description=d.get("description"), + id=d['_id'], + server_id=d['server'], + name=d['name'], + description=d.get('description'), internal_icon=self.parse_asset(icon) if icon else None, default_permissions=( - None - if default_permissions is None - else self.parse_permission_override_field(default_permissions) + None if default_permissions is None else self.parse_permission_override_field(default_permissions) ), - role_permissions={ - self.parse_id(k): self.parse_permission_override_field(v) - for k, v in role_permissions.items() - }, - nsfw=nsfw or False, + role_permissions={k: self.parse_permission_override_field(v) for k, v in role_permissions.items()}, + nsfw=d.get('nsfw', False), ) - def parse_webhook(self, d: raw.Webhook) -> webhooks.Webhook: - avatar = d.get("avatar") + def parse_webhook(self, d: raw.Webhook) -> Webhook: + avatar = d.get('avatar') - return webhooks.Webhook( + return Webhook( state=self.state, - id=self.parse_id(d["id"]), - name=d["name"], + id=d['id'], + name=d['name'], internal_avatar=self.parse_asset(avatar) if avatar else None, - channel_id=self.parse_id(d["channel_id"]), - permissions=permissions_.Permissions(d["permissions"]), - token=d.get("token"), + channel_id=d['channel_id'], + permissions=Permissions(d['permissions']), + token=d.get('token'), ) - def parse_webhook_create_event( - self, shard: Shard, d: raw.ClientWebhookCreateEvent - ) -> events.WebhookCreateEvent: - return events.WebhookCreateEvent( + def parse_webhook_create_event(self, shard: Shard, d: raw.ClientWebhookCreateEvent) -> WebhookCreateEvent: + return WebhookCreateEvent( shard=shard, webhook=self.parse_webhook(d), ) - def parse_webhook_update_event( - self, shard: Shard, d: raw.ClientWebhookUpdateEvent - ) -> events.WebhookUpdateEvent: - data = d["data"] - remove = d["remove"] + def parse_webhook_update_event(self, shard: Shard, d: raw.ClientWebhookUpdateEvent) -> WebhookUpdateEvent: + data = d['data'] + remove = d['remove'] - avatar = data.get("avatar") - permissions = data.get("permissions") + avatar = data.get('avatar') + permissions = data.get('permissions') - return events.WebhookUpdateEvent( + return WebhookUpdateEvent( shard=shard, - webhook=webhooks.PartialWebhook( + new_webhook=PartialWebhook( state=self.state, - id=self.parse_id(d["id"]), - name=data.get("name") or core.UNDEFINED, - internal_avatar=( - None - if "Avatar" in remove - else self.parse_asset(avatar) if avatar else core.UNDEFINED - ), - permissions=( - permissions_.Permissions(permissions) - if permissions is not None - else core.UNDEFINED - ), + id=d['id'], + name=data.get('name', UNDEFINED), + internal_avatar=(None if 'Avatar' in remove else self.parse_asset(avatar) if avatar else UNDEFINED), + permissions=(Permissions(permissions) if permissions is not None else UNDEFINED), ), ) - def parse_webhook_delete_event( - self, shard: Shard, d: raw.ClientWebhookDeleteEvent - ) -> events.WebhookDeleteEvent: - return events.WebhookDeleteEvent( + def parse_webhook_delete_event(self, shard: Shard, d: raw.ClientWebhookDeleteEvent) -> WebhookDeleteEvent: + return WebhookDeleteEvent( shard=shard, webhook=None, - webhook_id=self.parse_id(d["id"]), + webhook_id=d['id'], ) - def parse_webpush_subscription( - self, d: raw.a.WebPushSubscription - ) -> auth.WebPushSubscription: - return auth.WebPushSubscription( - endpoint=d["endpoint"], - p256dh=d["p256dh"], - auth=d["auth"], + def parse_webpush_subscription(self, d: raw.a.WebPushSubscription) -> WebPushSubscription: + return WebPushSubscription( + endpoint=d['endpoint'], + p256dh=d['p256dh'], + auth=d['auth'], ) - def parse_website_embed(self, d: raw.WebsiteEmbed) -> embeds.WebsiteEmbed: - special = d.get("special") - image = d.get("image") - video = d.get("video") + def parse_website_embed(self, d: raw.WebsiteEmbed) -> WebsiteEmbed: + special = d.get('special') + image = d.get('image') + video = d.get('video') - return embeds.WebsiteEmbed( - url=d.get("url"), - original_url=d.get("original_url"), + return WebsiteEmbed( + url=d.get('url'), + original_url=d.get('original_url'), special=self.parse_embed_special(special) if special else None, - title=d.get("title"), - description=d.get("description"), + title=d.get('title'), + description=d.get('description'), image=self.parse_image_embed(image) if image else None, video=self.parse_video_embed(video) if video else None, - site_name=d.get("site_name"), - icon_url=d.get("icon_url"), - colour=d.get("colour"), + site_name=d.get('site_name'), + icon_url=d.get('icon_url'), + colour=d.get('colour'), ) - def parse_youtube_embed_special( - self, d: raw.YouTubeSpecial - ) -> embeds.YouTubeEmbedSpecial: - return embeds.YouTubeEmbedSpecial(id=d["id"], timestamp=d.get("timestamp")) + def parse_youtube_embed_special(self, d: raw.YouTubeSpecial) -> YouTubeEmbedSpecial: + return YouTubeEmbedSpecial(id=d['id'], timestamp=d.get('timestamp')) -__all__ = ("Parser",) +__all__ = ('Parser',) diff --git a/pyvolt/permissions.py b/pyvolt/permissions.py index be572bb..3a191d7 100644 --- a/pyvolt/permissions.py +++ b/pyvolt/permissions.py @@ -1,190 +1,62 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations -from enum import IntFlag -import typing as t +from .flags import Permissions +import typing -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from . import raw -class Permissions(IntFlag): - NONE = 0 - - # * Generic premissions - - MANAGE_CHANNEL = 1 << 0 - """Manage the channel or channels on the server.""" - - MANAGE_SERVER = 1 << 1 - """Manage the server.""" - - MANAGE_PERMISSIONS = 1 << 2 - """Manage permissions on servers or channels.""" - - MANAGE_ROLE = 1 << 3 - """Manage roles on server.""" - - MANAGE_CUSTOMISATION = 1 << 4 - """Manage server customisation (includes emoji).""" - - # % 1 bit reserved - - # * Member permissions - KICK_MEMBERS = 1 << 6 - """Kick other members below their ranking.""" - - BAN_MEMBERS = 1 << 7 - """Ban other members below their ranking.""" - - TIMEOUT_MEMBERS = 1 << 8 - """Timeout other members below their ranking.""" - - ASSIGN_ROLES = 1 << 9 - """Assign roles to members below their ranking.""" - - CHANGE_NICKNAME = 1 << 10 - """Change own nickname.""" - - MANAGE_NICKNAMES = 1 << 11 - """Change or remove other's nicknames below their ranking.""" - - CHANGE_AVATAR = 1 << 12 - """Change own avatar.""" - - REMOVE_AVATARS = 1 << 13 - """Remove other's avatars below their ranking.""" - - # % 7 bits reserved - - # * Channel permissions - - VIEW_CHANNEL = 1 << 20 - """View a channel.""" - - READ_MESSAGE_HISTORY = 1 << 21 - """Read a channel's past message history.""" - - SEND_MESSAGE = 1 << 22 - """Send a message in a channel.""" - - MANAGE_MESSAGES = 1 << 23 - """Delete messages in a channel.""" - - MANAGE_WEBHOOKS = 1 << 24 - """Manage webhook entries on a channel.""" - - INVITE_OTHERS = 1 << 25 - """Create invites to this channel.""" - - SEND_EMBEDS = 1 << 26 - """Send embedded content in this channel.""" - - UPLOAD_FILES = 1 << 27 - """Send attachments and media in this channel.""" - - MASQUERADE = 1 << 28 - """Masquerade messages using custom nickname and avatar.""" - - REACT = 1 << 29 - """React to messages with emojis.""" - - # * Voice permissions - CONNECT = 1 << 30 - """Connect to a voice channel.""" - - SPEAK = 1 << 31 - """Speak in a voice call.""" - - VIDEO = 1 << 32 - """Share video in a voice call.""" - - MUTE_MEMBERS = 1 << 33 - """Mute other members with lower ranking in a voice call.""" - - DEAFEN_MEMBERS = 1 << 34 - """Deafen other members with lower ranking in a voice call.""" - - MOVE_MEMBERS = 1 << 35 - """Move members between voice channels.""" - - ALL = 0xFEFF03FDF - """All permissions.""" - - -# generating all permissions was done with: -# >>> bits = [0, 1, 2, 3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 20, 21, 22, 23, 24, 25, 26, 27, 29, 29, 30, 31, 32, 33, 34, 35] -# >>> all_permissions = reduce(lambda x, y: x|y, [1<>> print(hex(all_permissions)) -# 0xfeff03fdf - - class PermissionOverride: - """Represents a single permission override.""" - - allow: Permissions - """Allow bit flags.""" + """Represents a single permission override. - deny: Permissions - """Disallow bit flags.""" + Attributes + ---------- + allow: :class:`Permissions` + The permissions to allow. + deny: :class:`Permissions` + The permissions to deny. + """ - __slots__ = ("allow", "deny") + __slots__ = ('allow', 'deny') def __init__( self, - allow: Permissions = Permissions.NONE, - deny: Permissions = Permissions.NONE, + allow: Permissions = Permissions.none(), + deny: Permissions = Permissions.none(), ) -> None: self.allow = allow self.deny = deny - def build(self) -> "raw.Override": - return {"allow": int(self.allow), "deny": int(self.deny)} + def build(self) -> raw.Override: + return {'allow': self.allow.value, 'deny': self.deny.value} def __repr__(self) -> str: - return f"PermissionOverride(allow={self.allow!r}, deny={self.deny!r})" - - -class UserPermissions(IntFlag): - ACCESS = 1 << 0 - VIEW_PROFILE = 1 << 1 - SEND_MESSAGE = 1 << 2 - INVITE = 1 << 3 - - ALL = 15 - - -ALLOW_PERMISSIONS_IN_TIMEOUT = ( - Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY -) -VIEW_ONLY_PERMISSIONS = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY -DEFAULT_PERMISSIONS = ( - VIEW_ONLY_PERMISSIONS - | Permissions.SEND_MESSAGE - | Permissions.INVITE_OTHERS - | Permissions.SEND_EMBEDS - | Permissions.UPLOAD_FILES - | Permissions.CONNECT - | Permissions.SPEAK -) -DEFAULT_SAVED_MESSAGES_PERMISSIONS = Permissions.ALL -DEFAULT_DM_PERMISSIONS = ( - DEFAULT_PERMISSIONS | Permissions.MANAGE_CHANNEL | Permissions.REACT -) -DEFAULT_SERVER_PERMISSIONS = ( - DEFAULT_PERMISSIONS - | Permissions.REACT - | Permissions.CHANGE_NICKNAME - | Permissions.CHANGE_AVATAR -) - -__all__ = ( - "Permissions", - "PermissionOverride", - "UserPermissions", - "ALLOW_PERMISSIONS_IN_TIMEOUT", - "VIEW_ONLY_PERMISSIONS", - "DEFAULT_PERMISSIONS", - "DEFAULT_SAVED_MESSAGES_PERMISSIONS", - "DEFAULT_DM_PERMISSIONS", - "DEFAULT_SERVER_PERMISSIONS", -) + return f'' + + +__all__ = ('PermissionOverride',) diff --git a/pyvolt/raw/__init__.py b/pyvolt/raw/__init__.py index a061902..97ebae2 100644 --- a/pyvolt/raw/__init__.py +++ b/pyvolt/raw/__init__.py @@ -1,4 +1,5 @@ -from . import authifier as a +# This is actually used +from . import authifier as a # noqa: F401 from .basic import * from .bots import * from .channel_invites import * @@ -10,7 +11,9 @@ from .emojis import * from .files import * from .gateway import * +from .localization import * from .messages import * +from .misc import * from .permissions import * from .safety_reports import * from .server_bans import * diff --git a/pyvolt/raw/authifier.py b/pyvolt/raw/authifier.py index 97f0f3d..6c90b4b 100644 --- a/pyvolt/raw/authifier.py +++ b/pyvolt/raw/authifier.py @@ -1,26 +1,26 @@ from __future__ import annotations -import typing as t +import typing # >> /models/mfa/mod.rs -class MultiFactorAuthentication(t.TypedDict): - totp_token: t.NotRequired[Totp] - recovery_codes: t.NotRequired[list[str]] +class MultiFactorAuthentication(typing.TypedDict): + totp_token: typing.NotRequired[Totp] + recovery_codes: typing.NotRequired[list[str]] -MFAMethod = t.Literal["Password", "Recovery", "Totp"] +MFAMethod = typing.Literal['Password', 'Recovery', 'Totp'] -class PasswordMFAResponse(t.TypedDict): +class PasswordMFAResponse(typing.TypedDict): password: str -class RecoveryMFAResponse(t.TypedDict): +class RecoveryMFAResponse(typing.TypedDict): recovery_code: str -class TotpMFAResponse(t.TypedDict): +class TotpMFAResponse(typing.TypedDict): totp_code: str @@ -28,16 +28,16 @@ class TotpMFAResponse(t.TypedDict): # >> /models/mfa/totp.rs -class DisabledTotp(t.TypedDict): - status: t.Literal["Disabled"] +class DisabledTotp(typing.TypedDict): + status: typing.Literal['Disabled'] -class PendingTotp(t.TypedDict): - status: t.Literal["Pending"] +class PendingTotp(typing.TypedDict): + status: typing.Literal['Pending'] -class EnabledTotp(t.TypedDict): - status: t.Literal["Enabled"] +class EnabledTotp(typing.TypedDict): + status: typing.Literal['Enabled'] Totp = DisabledTotp | PendingTotp | EnabledTotp @@ -45,57 +45,53 @@ class EnabledTotp(t.TypedDict): # >> /models/account.rs -class VerifiedEmailVerification(t.TypedDict): - status: t.Literal["Verified"] +class VerifiedEmailVerification(typing.TypedDict): + status: typing.Literal['Verified'] -class PendingEmailVerification(t.TypedDict): - stauts: t.Literal["Pending"] +class PendingEmailVerification(typing.TypedDict): + stauts: typing.Literal['Pending'] -class MovingEmailVerification(t.TypedDict): - status: t.Literal["Moving"] +class MovingEmailVerification(typing.TypedDict): + status: typing.Literal['Moving'] new_email: str token: str expiry: str -EmailVerification = ( - VerifiedEmailVerification | PendingEmailVerification | MovingEmailVerification -) +EmailVerification = VerifiedEmailVerification | PendingEmailVerification | MovingEmailVerification -class PasswordReset(t.TypedDict): +class PasswordReset(typing.TypedDict): token: str expiry: str -class WaitingForVerificationDeletionInfo(t.TypedDict): - status: t.Literal["WaitingForVerification"] +class WaitingForVerificationDeletionInfo(typing.TypedDict): + status: typing.Literal['WaitingForVerification'] token: str expiry: str -class ScheduledDeletionInfo(t.TypedDict): - status: t.Literal["Scheduled"] +class ScheduledDeletionInfo(typing.TypedDict): + status: typing.Literal['Scheduled'] after: str -class DeletedDeletionInfo(t.TypedDict): - status: t.Literal["Deleted"] +class DeletedDeletionInfo(typing.TypedDict): + status: typing.Literal['Deleted'] -DeletionInfo = ( - WaitingForVerificationDeletionInfo | ScheduledDeletionInfo | DeletedDeletionInfo -) +DeletionInfo = WaitingForVerificationDeletionInfo | ScheduledDeletionInfo | DeletedDeletionInfo -class Lockout(t.TypedDict): +class Lockout(typing.TypedDict): attempts: int expiry: str | None -class Account(t.TypedDict): +class Account(typing.TypedDict): _id: str email: str email_normalised: str @@ -104,28 +100,28 @@ class Account(t.TypedDict): password_reset: PasswordReset | None deletion: DeletionInfo | None lockout: Lockout | None - mfa: "MultiFactorAuthentication" + mfa: MultiFactorAuthentication # >> /models/session.rs -class WebPushSubscription(t.TypedDict): +class WebPushSubscription(typing.TypedDict): endpoint: str p256dh: str auth: str -class Session(t.TypedDict): +class Session(typing.TypedDict): _id: str user_id: str token: str name: str - subscription: t.NotRequired[WebPushSubscription] + subscription: typing.NotRequired[WebPushSubscription] # >> /models/ticket.rs -class MFATicket(t.TypedDict): +class MFATicket(typing.TypedDict): _id: str account_id: str token: str @@ -135,24 +131,24 @@ class MFATicket(t.TypedDict): # >> /events/mod.rs -class AuthifierCreateAccountEvent(t.TypedDict): - event_type: t.Literal["CreateAccount"] +class AuthifierCreateAccountEvent(typing.TypedDict): + event_type: typing.Literal['CreateAccount'] account: Account -class AuthifierCreateSessionEvent(t.TypedDict): - event_type: t.Literal["CreateSession"] +class AuthifierCreateSessionEvent(typing.TypedDict): + event_type: typing.Literal['CreateSession'] session: Session -class AuthifierDeleteSessionEvent(t.TypedDict): - event_type: t.Literal["DeleteSession"] +class AuthifierDeleteSessionEvent(typing.TypedDict): + event_type: typing.Literal['DeleteSession'] user_id: str session_id: str -class AuthifierDeleteAllSessionsEvent(t.TypedDict): - event_type: t.Literal["DeleteAllSessions"] +class AuthifierDeleteAllSessionsEvent(typing.TypedDict): + event_type: typing.Literal['DeleteAllSessions'] user_id: str exclude_session_id: str | None @@ -167,60 +163,60 @@ class AuthifierDeleteAllSessionsEvent(t.TypedDict): # HTTP requests/responses -class DataChangeEmail(t.TypedDict): +class DataChangeEmail(typing.TypedDict): email: str current_password: str -class DataChangePassword(t.TypedDict): +class DataChangePassword(typing.TypedDict): password: str current_password: str -class DataAccountDeletion(t.TypedDict): +class DataAccountDeletion(typing.TypedDict): token: str -class DataCreateAccount(t.TypedDict): +class DataCreateAccount(typing.TypedDict): email: str password: str invite: str | None captcha: str | None -class AccountInfo(t.TypedDict): +class AccountInfo(typing.TypedDict): _id: str email: str -class DataPasswordReset(t.TypedDict): +class DataPasswordReset(typing.TypedDict): token: str password: str - remove_sessions: t.NotRequired[bool | None] + remove_sessions: typing.NotRequired[bool | None] -class DataResendVerification(t.TypedDict): +class DataResendVerification(typing.TypedDict): email: str captcha: str | None -class DataSendPasswordReset(t.TypedDict): +class DataSendPasswordReset(typing.TypedDict): email: str captcha: str | None -class NoTicketResponseVerify(t.TypedDict): +class NoTicketResponseVerify(typing.TypedDict): pass -class WithTicketResponseVerify(t.TypedDict): +class WithTicketResponseVerify(typing.TypedDict): ticket: MFATicket ResponseVerify = NoTicketResponseVerify | WithTicketResponseVerify -class MultiFactorStatus(t.TypedDict): +class MultiFactorStatus(typing.TypedDict): email_otp: bool trusted_handover: bool email_mfa: bool @@ -229,26 +225,26 @@ class MultiFactorStatus(t.TypedDict): recovery_active: bool -class ResponseTotpSecret(t.TypedDict): +class ResponseTotpSecret(typing.TypedDict): secret: str -class DataEditSession(t.TypedDict): +class DataEditSession(typing.TypedDict): friendly_name: str -class SessionInfo(t.TypedDict): +class SessionInfo(typing.TypedDict): _id: str name: str -class EmailDataLogin(t.TypedDict): +class EmailDataLogin(typing.TypedDict): email: str password: str friendly_name: str | None -class MFADataLogin(t.TypedDict): +class MFADataLogin(typing.TypedDict): mfa_ticket: str mfa_response: MFAResponse | None friendly_name: str | None @@ -258,72 +254,72 @@ class MFADataLogin(t.TypedDict): class SuccessResponseLogin(Session): - result: t.Literal["Success"] + result: typing.Literal['Success'] -class MFAResponseLogin(t.TypedDict): - result: t.Literal["MFA"] +class MFAResponseLogin(typing.TypedDict): + result: typing.Literal['MFA'] ticket: str allowed_methods: list[MFAMethod] -class DisabledResponseLogin(t.TypedDict): - result: t.Literal["Disabled"] +class DisabledResponseLogin(typing.TypedDict): + result: typing.Literal['Disabled'] user_id: str ResponseLogin = SuccessResponseLogin | MFAResponseLogin | DisabledResponseLogin __all__ = ( - "MultiFactorAuthentication", - "MFAMethod", - "PasswordMFAResponse", - "RecoveryMFAResponse", - "TotpMFAResponse", - "MFAResponse", - "DisabledTotp", - "PendingTotp", - "EnabledTotp", - "Totp", - "VerifiedEmailVerification", - "PendingEmailVerification", - "MovingEmailVerification", - "EmailVerification", - "PasswordReset", - "WaitingForVerificationDeletionInfo", - "ScheduledDeletionInfo", - "DeletedDeletionInfo", - "DeletionInfo", - "Lockout", - "Account", - "WebPushSubscription", - "Session", - "MFATicket", - "AuthifierCreateAccountEvent", - "AuthifierCreateSessionEvent", - "AuthifierDeleteSessionEvent", - "AuthifierDeleteAllSessionsEvent", - "AuthifierEvent", - "DataChangeEmail", - "DataChangePassword", - "DataAccountDeletion", - "DataCreateAccount", - "AccountInfo", - "DataPasswordReset", - "DataResendVerification", - "DataSendPasswordReset", - "NoTicketResponseVerify", - "WithTicketResponseVerify", - "ResponseVerify", - "MultiFactorStatus", - "ResponseTotpSecret", - "DataEditSession", - "SessionInfo", - "EmailDataLogin", - "MFADataLogin", - "DataLogin", - "SuccessResponseLogin", - "MFAResponseLogin", - "DisabledResponseLogin", - "ResponseLogin", + 'MultiFactorAuthentication', + 'MFAMethod', + 'PasswordMFAResponse', + 'RecoveryMFAResponse', + 'TotpMFAResponse', + 'MFAResponse', + 'DisabledTotp', + 'PendingTotp', + 'EnabledTotp', + 'Totp', + 'VerifiedEmailVerification', + 'PendingEmailVerification', + 'MovingEmailVerification', + 'EmailVerification', + 'PasswordReset', + 'WaitingForVerificationDeletionInfo', + 'ScheduledDeletionInfo', + 'DeletedDeletionInfo', + 'DeletionInfo', + 'Lockout', + 'Account', + 'WebPushSubscription', + 'Session', + 'MFATicket', + 'AuthifierCreateAccountEvent', + 'AuthifierCreateSessionEvent', + 'AuthifierDeleteSessionEvent', + 'AuthifierDeleteAllSessionsEvent', + 'AuthifierEvent', + 'DataChangeEmail', + 'DataChangePassword', + 'DataAccountDeletion', + 'DataCreateAccount', + 'AccountInfo', + 'DataPasswordReset', + 'DataResendVerification', + 'DataSendPasswordReset', + 'NoTicketResponseVerify', + 'WithTicketResponseVerify', + 'ResponseVerify', + 'MultiFactorStatus', + 'ResponseTotpSecret', + 'DataEditSession', + 'SessionInfo', + 'EmailDataLogin', + 'MFADataLogin', + 'DataLogin', + 'SuccessResponseLogin', + 'MFAResponseLogin', + 'DisabledResponseLogin', + 'ResponseLogin', ) diff --git a/pyvolt/raw/basic.py b/pyvolt/raw/basic.py index 994dec6..29082c7 100644 --- a/pyvolt/raw/basic.py +++ b/pyvolt/raw/basic.py @@ -1,5 +1,5 @@ -import typing as t +import typing -Bool = t.Literal["true", "false"] +Bool = typing.Literal['true', 'false'] -__all__ = ("Bool",) +__all__ = ('Bool',) diff --git a/pyvolt/raw/bots.py b/pyvolt/raw/bots.py index 99447bb..f3c213d 100644 --- a/pyvolt/raw/bots.py +++ b/pyvolt/raw/bots.py @@ -1,80 +1,80 @@ from __future__ import annotations -import typing as t +import typing -from . import users +from .users import User -class Bot(t.TypedDict): +class Bot(typing.TypedDict): _id: str owner: str token: str public: bool - analytics: t.NotRequired[bool] - discoverable: t.NotRequired[bool] - interactions_url: t.NotRequired[str] - terms_of_service_url: t.NotRequired[str] - privacy_policy_url: t.NotRequired[str] + analytics: typing.NotRequired[bool] + discoverable: typing.NotRequired[bool] + interactions_url: typing.NotRequired[str] + terms_of_service_url: typing.NotRequired[str] + privacy_policy_url: typing.NotRequired[str] flags: int -FieldsBot = t.Literal["Token", "InteractionsURL"] +FieldsBot = typing.Literal['Token', 'InteractionsURL'] -class PublicBot(t.TypedDict): +class PublicBot(typing.TypedDict): _id: str username: str - avatar: t.NotRequired[str] - description: t.NotRequired[str] + avatar: typing.NotRequired[str] + description: typing.NotRequired[str] -class FetchBotResponse(t.TypedDict): +class FetchBotResponse(typing.TypedDict): bot: Bot - user: users.User + user: User -class DataCreateBot(t.TypedDict): +class DataCreateBot(typing.TypedDict): name: str -class DataEditBot(t.TypedDict): - name: t.NotRequired[str] - public: t.NotRequired[bool] - analytics: t.NotRequired[bool] - interactions_url: t.NotRequired[str] - remove: t.NotRequired[list[FieldsBot]] +class DataEditBot(typing.TypedDict): + name: typing.NotRequired[str] + public: typing.NotRequired[bool] + analytics: typing.NotRequired[bool] + interactions_url: typing.NotRequired[str] + remove: typing.NotRequired[list[FieldsBot]] -class ServerInviteBotDestination(t.TypedDict): +class ServerInviteBotDestination(typing.TypedDict): server: str -class GroupInviteBotDestination(t.TypedDict): +class GroupInviteBotDestination(typing.TypedDict): group: str InviteBotDestination = ServerInviteBotDestination | GroupInviteBotDestination -class OwnedBotsResponse(t.TypedDict): +class OwnedBotsResponse(typing.TypedDict): bots: list[Bot] - users: list[users.User] + users: list[User] class BotWithUserResponse(Bot): - user: users.User + user: User __all__ = ( - "Bot", - "FieldsBot", - "PublicBot", - "FetchBotResponse", - "DataCreateBot", - "DataEditBot", - "ServerInviteBotDestination", - "GroupInviteBotDestination", - "InviteBotDestination", - "OwnedBotsResponse", - "BotWithUserResponse", + 'Bot', + 'FieldsBot', + 'PublicBot', + 'FetchBotResponse', + 'DataCreateBot', + 'DataEditBot', + 'ServerInviteBotDestination', + 'GroupInviteBotDestination', + 'InviteBotDestination', + 'OwnedBotsResponse', + 'BotWithUserResponse', ) diff --git a/pyvolt/raw/channel_invites.py b/pyvolt/raw/channel_invites.py index f797934..5b52207 100644 --- a/pyvolt/raw/channel_invites.py +++ b/pyvolt/raw/channel_invites.py @@ -1,20 +1,23 @@ from __future__ import annotations -import typing as t +import typing -from . import channels, files, servers, users +from .channels import GroupChannel, Channel +from .files import File +from .servers import Server +from .users import User -class ServerInvite(t.TypedDict): - type: t.Literal["Server"] +class ServerInvite(typing.TypedDict): + type: typing.Literal['Server'] _id: str server: str creator: str channel: str -class GroupInvite(t.TypedDict): - type: t.Literal["Group"] +class GroupInvite(typing.TypedDict): + type: typing.Literal['Group'] _id: str creator: str channel: str @@ -23,57 +26,57 @@ class GroupInvite(t.TypedDict): Invite = ServerInvite | GroupInvite -class ServerInviteResponse(t.TypedDict): - type: t.Literal["Server"] +class ServerInviteResponse(typing.TypedDict): + type: typing.Literal['Server'] code: str server_id: str server_name: str - server_icon: t.NotRequired[files.File] - server_banner: t.NotRequired[files.File] - server_flags: t.NotRequired[int] + server_icon: typing.NotRequired[File] + server_banner: typing.NotRequired[File] + server_flags: typing.NotRequired[int] channel_id: str channel_name: str - channel_description: t.NotRequired[str] + channel_description: typing.NotRequired[str] user_name: str - user_avatar: t.NotRequired[files.File] + user_avatar: typing.NotRequired[File] member_count: int -class GroupInviteResponse(t.TypedDict): - type: t.Literal["Group"] +class GroupInviteResponse(typing.TypedDict): + type: typing.Literal['Group'] code: str channel_id: str channel_name: str - channel_description: t.NotRequired[str] + channel_description: typing.NotRequired[str] user_name: str - user_avatar: t.NotRequired[files.File] + user_avatar: typing.NotRequired[File] InviteResponse = ServerInviteResponse | GroupInviteResponse -class ServerInviteJoinResponse(t.TypedDict): - type: t.Literal["Server"] - channels: list[channels.Channel] - server: servers.Server +class ServerInviteJoinResponse(typing.TypedDict): + type: typing.Literal['Server'] + channels: list[Channel] + server: Server -class GroupInviteJoinResponse(t.TypedDict): - type: t.Literal["Group"] - channel: channels.GroupChannel - users: list[users.User] +class GroupInviteJoinResponse(typing.TypedDict): + type: typing.Literal['Group'] + channel: GroupChannel + users: list[User] InviteJoinResponse = ServerInviteJoinResponse | GroupInviteJoinResponse __all__ = ( - "ServerInvite", - "GroupInvite", - "Invite", - "ServerInviteResponse", - "GroupInviteResponse", - "InviteResponse", - "ServerInviteJoinResponse", - "GroupInviteJoinResponse", - "InviteJoinResponse", + 'ServerInvite', + 'GroupInvite', + 'Invite', + 'ServerInviteResponse', + 'GroupInviteResponse', + 'InviteResponse', + 'ServerInviteJoinResponse', + 'GroupInviteJoinResponse', + 'InviteJoinResponse', ) diff --git a/pyvolt/raw/channel_unreads.py b/pyvolt/raw/channel_unreads.py index 8e5e39d..dd91137 100644 --- a/pyvolt/raw/channel_unreads.py +++ b/pyvolt/raw/channel_unreads.py @@ -1,17 +1,17 @@ from __future__ import annotations -import typing as t +import typing -class ChannelUnread(t.TypedDict): +class ChannelUnread(typing.TypedDict): _id: ChannelCompositeKey - last_id: t.NotRequired[str] - mentions: t.NotRequired[list[str]] + last_id: typing.NotRequired[str] + mentions: typing.NotRequired[list[str]] -class ChannelCompositeKey(t.TypedDict): +class ChannelCompositeKey(typing.TypedDict): channel: str user: str -__all__ = ("ChannelUnread", "ChannelCompositeKey") +__all__ = ('ChannelUnread', 'ChannelCompositeKey') diff --git a/pyvolt/raw/channel_webhooks.py b/pyvolt/raw/channel_webhooks.py index e0f8b9a..f243950 100644 --- a/pyvolt/raw/channel_webhooks.py +++ b/pyvolt/raw/channel_webhooks.py @@ -1,38 +1,38 @@ from __future__ import annotations -import typing as t +import typing -from . import files +from .files import File -class Webhook(t.TypedDict): +class Webhook(typing.TypedDict): id: str name: str - avatar: t.NotRequired[files.File] + avatar: typing.NotRequired[File] channel_id: str permissions: int token: str | None -class PartialWebhook(t.TypedDict): - name: t.NotRequired[str] - avatar: t.NotRequired[files.File] - permissions: t.NotRequired[int] +class PartialWebhook(typing.TypedDict): + name: typing.NotRequired[str] + avatar: typing.NotRequired[File] + permissions: typing.NotRequired[int] -class MessageWebhook(t.TypedDict): +class MessageWebhook(typing.TypedDict): name: str avatar: str | None -class DataEditWebhook(t.TypedDict): - name: t.NotRequired[str] - avatar: t.NotRequired[str] - permissions: t.NotRequired[int] - remove: t.NotRequired[list[FieldsWebhook]] +class DataEditWebhook(typing.TypedDict): + name: typing.NotRequired[str] + avatar: typing.NotRequired[str] + permissions: typing.NotRequired[int] + remove: typing.NotRequired[list[FieldsWebhook]] -class ResponseWebhook(t.TypedDict): +class ResponseWebhook(typing.TypedDict): id: str name: str avatar: str | None @@ -40,20 +40,20 @@ class ResponseWebhook(t.TypedDict): permissions: int -FieldsWebhook = t.Literal["Avatar"] +FieldsWebhook = typing.Literal['Avatar'] -class CreateWebhookBody(t.TypedDict): +class CreateWebhookBody(typing.TypedDict): name: str - avatar: t.NotRequired[str | None] + avatar: typing.NotRequired[str | None] __all__ = ( - "Webhook", - "PartialWebhook", - "MessageWebhook", - "DataEditWebhook", - "ResponseWebhook", - "FieldsWebhook", - "CreateWebhookBody", + 'Webhook', + 'PartialWebhook', + 'MessageWebhook', + 'DataEditWebhook', + 'ResponseWebhook', + 'FieldsWebhook', + 'CreateWebhookBody', ) diff --git a/pyvolt/raw/channels.py b/pyvolt/raw/channels.py index d540c6d..37a055b 100644 --- a/pyvolt/raw/channels.py +++ b/pyvolt/raw/channels.py @@ -1,146 +1,142 @@ from __future__ import annotations -import typing as t +import typing -from . import basic, files, permissions +from .basic import Bool +from .files import File +from .permissions import Override, OverrideField -class SavedMessagesChannel(t.TypedDict): - channel_type: t.Literal["SavedMessages"] +class SavedMessagesChannel(typing.TypedDict): + channel_type: typing.Literal['SavedMessages'] _id: str user: str -class DirectMessageChannel(t.TypedDict): - channel_type: t.Literal["DirectMessage"] +class DirectMessageChannel(typing.TypedDict): + channel_type: typing.Literal['DirectMessage'] _id: str active: bool recipients: list[str] - last_message_id: t.NotRequired[str] + last_message_id: typing.NotRequired[str] -class GroupChannel(t.TypedDict): - channel_type: t.Literal["Group"] +class GroupChannel(typing.TypedDict): + channel_type: typing.Literal['Group'] _id: str name: str owner: str - description: t.NotRequired[str] + description: typing.NotRequired[str] recipients: list[str] - icon: t.NotRequired[files.File] - last_message_id: t.NotRequired[str] - permissions: t.NotRequired[int] - nsfw: t.NotRequired[bool] + icon: typing.NotRequired[File] + last_message_id: typing.NotRequired[str] + permissions: typing.NotRequired[int] + nsfw: typing.NotRequired[bool] -class TextChannel(t.TypedDict): - channel_type: t.Literal["TextChannel"] +class TextChannel(typing.TypedDict): + channel_type: typing.Literal['TextChannel'] _id: str server: str name: str - description: t.NotRequired[str] - icon: t.NotRequired[files.File] - last_message_id: t.NotRequired[str] - default_permissions: t.NotRequired[permissions.OverrideField] - role_permissions: t.NotRequired[dict[str, permissions.OverrideField]] - nsfw: t.NotRequired[bool] + description: typing.NotRequired[str] + icon: typing.NotRequired[File] + last_message_id: typing.NotRequired[str] + default_permissions: typing.NotRequired[OverrideField] + role_permissions: typing.NotRequired[dict[str, OverrideField]] + nsfw: typing.NotRequired[bool] -class VoiceChannel(t.TypedDict): - channel_type: t.Literal["VoiceChannel"] +class VoiceChannel(typing.TypedDict): + channel_type: typing.Literal['VoiceChannel'] _id: str server: str name: str - description: t.NotRequired[str] - icon: t.NotRequired[files.File] - default_permissions: t.NotRequired[permissions.OverrideField] - role_permissions: t.NotRequired[dict[str, permissions.OverrideField]] - nsfw: t.NotRequired[bool] - - -Channel = ( - SavedMessagesChannel - | DirectMessageChannel - | GroupChannel - | TextChannel - | VoiceChannel -) + description: typing.NotRequired[str] + icon: typing.NotRequired[File] + default_permissions: typing.NotRequired[OverrideField] + role_permissions: typing.NotRequired[dict[str, OverrideField]] + nsfw: typing.NotRequired[bool] + + +Channel = SavedMessagesChannel | DirectMessageChannel | GroupChannel | TextChannel | VoiceChannel -class PartialChannel(t.TypedDict): - name: t.NotRequired[str] - owner: t.NotRequired[str] - description: t.NotRequired[str] - icon: t.NotRequired[files.File] - nsfw: t.NotRequired[bool] - active: t.NotRequired[bool] - permissions: t.NotRequired[int] - role_permissions: t.NotRequired[dict[str, permissions.OverrideField]] - default_permissions: t.NotRequired[permissions.OverrideField] - last_message_id: t.NotRequired[str] +class PartialChannel(typing.TypedDict): + name: typing.NotRequired[str] + owner: typing.NotRequired[str] + description: typing.NotRequired[str] + icon: typing.NotRequired[File] + nsfw: typing.NotRequired[bool] + active: typing.NotRequired[bool] + permissions: typing.NotRequired[int] + role_permissions: typing.NotRequired[dict[str, OverrideField]] + default_permissions: typing.NotRequired[OverrideField] + last_message_id: typing.NotRequired[str] -FieldsChannel = t.Literal["Description", "Icon", "DefaultPermissions"] +FieldsChannel = typing.Literal['Description', 'Icon', 'DefaultPermissions'] -class DataEditChannel(t.TypedDict): - name: t.NotRequired[str] - description: t.NotRequired[str] - owner: t.NotRequired[str] - icon: t.NotRequired[str] - nsfw: t.NotRequired[bool] - archived: t.NotRequired[bool] - remove: t.NotRequired[list[FieldsChannel]] +class DataEditChannel(typing.TypedDict): + name: typing.NotRequired[str] + description: typing.NotRequired[str] + owner: typing.NotRequired[str] + icon: typing.NotRequired[str] + nsfw: typing.NotRequired[bool] + archived: typing.NotRequired[bool] + remove: typing.NotRequired[list[FieldsChannel]] -class DataCreateGroup(t.TypedDict): +class DataCreateGroup(typing.TypedDict): name: str - description: t.NotRequired[str | None] - icon: t.NotRequired[str | None] - users: t.NotRequired[list[str]] - nsfw: t.NotRequired[bool | None] + description: typing.NotRequired[str | None] + icon: typing.NotRequired[str | None] + users: typing.NotRequired[list[str]] + nsfw: typing.NotRequired[bool | None] -LegacyServerChannelType = t.Literal["Text", "Voice"] +LegacyServerChannelType = typing.Literal['Text', 'Voice'] -class DataCreateServerChannel(t.TypedDict): - type: t.NotRequired[LegacyServerChannelType] +class DataCreateServerChannel(typing.TypedDict): + type: typing.NotRequired[LegacyServerChannelType] name: str - description: t.NotRequired[str | None] - nsfw: t.NotRequired[bool] + description: typing.NotRequired[str | None] + nsfw: typing.NotRequired[bool] -class DataDefaultChannelPermissions(t.TypedDict): - permissions: permissions.Override | int +class DataDefaultChannelPermissions(typing.TypedDict): + permissions: Override | int -class DataSetRolePermissions(t.TypedDict): - permissions: permissions.Override +class DataSetRolePermissions(typing.TypedDict): + permissions: Override -class OptionsChannelDelete(t.TypedDict): - leave_silently: t.NotRequired[basic.Bool] +class OptionsChannelDelete(typing.TypedDict): + leave_silently: typing.NotRequired[Bool] -class LegacyCreateVoiceUserResponse(t.TypedDict): +class LegacyCreateVoiceUserResponse(typing.TypedDict): token: str __all__ = ( - "SavedMessagesChannel", - "DirectMessageChannel", - "GroupChannel", - "TextChannel", - "VoiceChannel", - "Channel", - "PartialChannel", - "FieldsChannel", - "DataEditChannel", - "DataCreateGroup", - "LegacyServerChannelType", - "DataCreateServerChannel", - "DataDefaultChannelPermissions", - "DataSetRolePermissions", - "OptionsChannelDelete", - "LegacyCreateVoiceUserResponse", + 'SavedMessagesChannel', + 'DirectMessageChannel', + 'GroupChannel', + 'TextChannel', + 'VoiceChannel', + 'Channel', + 'PartialChannel', + 'FieldsChannel', + 'DataEditChannel', + 'DataCreateGroup', + 'LegacyServerChannelType', + 'DataCreateServerChannel', + 'DataDefaultChannelPermissions', + 'DataSetRolePermissions', + 'OptionsChannelDelete', + 'LegacyCreateVoiceUserResponse', ) diff --git a/pyvolt/raw/discovery.py b/pyvolt/raw/discovery.py index f5b3cab..db91467 100644 --- a/pyvolt/raw/discovery.py +++ b/pyvolt/raw/discovery.py @@ -2,110 +2,112 @@ from __future__ import annotations -import typing as t +import typing -from . import files, users +from .files import File +from .user_settings import ReviteThemeVariable +from .users import UserProfile # Servers -DiscoveryServerActivity = t.Literal["high", "medium", "low", "no"] +DiscoveryServerActivity = typing.Literal['high', 'medium', 'low', 'no'] -class DiscoveryServer(t.TypedDict): +class DiscoveryServer(typing.TypedDict): _id: str name: str description: str | None - icon: t.NotRequired[files.File] - banner: t.NotRequired[files.File] - flags: t.NotRequired[int] + icon: typing.NotRequired[File] + banner: typing.NotRequired[File] + flags: typing.NotRequired[int] tags: list[str] members: int activity: DiscoveryServerActivity -class DiscoveryServersPage(t.TypedDict): +class DiscoveryServersPage(typing.TypedDict): servers: list[DiscoveryServer] popularTags: list[str] -class DiscoveryServerSearchResult(t.TypedDict): +class DiscoveryServerSearchResult(typing.TypedDict): query: str count: int - type: t.Literal["servers"] + type: typing.Literal['servers'] servers: list[DiscoveryServer] relatedTags: list[str] # Bots -DiscoveryBotUsage = t.Literal["high", "medium", "low"] +DiscoveryBotUsage = typing.Literal['high', 'medium', 'low'] -class DiscoveryBot(t.TypedDict): +class DiscoveryBot(typing.TypedDict): _id: str username: str - avatar: t.NotRequired[files.File] - profile: users.UserProfile + avatar: typing.NotRequired[File] + profile: UserProfile tags: list[str] servers: int usage: DiscoveryBotUsage -class DiscoveryBotsPage(t.TypedDict): +class DiscoveryBotsPage(typing.TypedDict): bots: list[DiscoveryBot] popularTags: list[str] -class DiscoveryBotSearchResult(t.TypedDict): +class DiscoveryBotSearchResult(typing.TypedDict): query: str count: int - type: t.Literal["bots"] + type: typing.Literal['bots'] bots: list[DiscoveryBot] relatedTags: list[str] # Themes -class DiscoveryTheme(t.TypedDict): +class DiscoveryTheme(typing.TypedDict): name: str version: str slug: str creator: str description: str tags: list[str] - variables: dict[str, str] - css: t.NotRequired[str] + variables: dict[ReviteThemeVariable, str] + css: typing.NotRequired[str] -class DiscoveryThemesPage(t.TypedDict): +class DiscoveryThemesPage(typing.TypedDict): themes: list[DiscoveryTheme] popularTags: list[str] -class DiscoveryThemeSearchResult(t.TypedDict): +class DiscoveryThemeSearchResult(typing.TypedDict): query: str count: int - type: t.Literal["themes"] + type: typing.Literal['themes'] themes: list[DiscoveryTheme] relatedTags: list[str] -P = t.TypeVar("P") +P = typing.TypeVar('P') -class NextPage(t.Generic[P], t.TypedDict): +class NextPage(typing.Generic[P], typing.TypedDict): pageProps: P __N_SSP: bool __all__ = ( - "DiscoveryServerActivity", - "DiscoveryServer", - "DiscoveryServersPage", - "DiscoveryServerSearchResult", - "DiscoveryBotUsage", - "DiscoveryBot", - "DiscoveryBotsPage", - "DiscoveryBotSearchResult", - "DiscoveryTheme", - "DiscoveryThemesPage", - "DiscoveryThemeSearchResult", - "NextPage", + 'DiscoveryServerActivity', + 'DiscoveryServer', + 'DiscoveryServersPage', + 'DiscoveryServerSearchResult', + 'DiscoveryBotUsage', + 'DiscoveryBot', + 'DiscoveryBotsPage', + 'DiscoveryBotSearchResult', + 'DiscoveryTheme', + 'DiscoveryThemesPage', + 'DiscoveryThemeSearchResult', + 'NextPage', ) diff --git a/pyvolt/raw/embeds.py b/pyvolt/raw/embeds.py index 531e1ba..3706680 100644 --- a/pyvolt/raw/embeds.py +++ b/pyvolt/raw/embeds.py @@ -1,74 +1,80 @@ from __future__ import annotations -import typing as t +import typing -from . import files +from .files import File -ImageSize = t.Literal["Large", "Preview"] +ImageSize = typing.Literal['Large', 'Preview'] -class Image(t.TypedDict): +class Image(typing.TypedDict): url: str width: int height: int size: ImageSize -class Video(t.TypedDict): +class Video(typing.TypedDict): url: str width: int height: int -TwitchType = t.Literal["Channel", "Video", "Clip"] -LightspeedType = t.Literal["Channel"] -BandcampType = t.Literal["Album", "Track"] +TwitchType = typing.Literal['Channel', 'Video', 'Clip'] +LightspeedType = typing.Literal['Channel'] +BandcampType = typing.Literal['Album', 'Track'] -class NoneSpecial(t.TypedDict): - type: t.Literal["None"] +class NoneSpecial(typing.TypedDict): + type: typing.Literal['None'] -class GIFSpecial(t.TypedDict): - type: t.Literal["GIF"] +class GIFSpecial(typing.TypedDict): + type: typing.Literal['GIF'] -class YouTubeSpecial(t.TypedDict): - type: t.Literal["YouTube"] +class YouTubeSpecial(typing.TypedDict): + type: typing.Literal['YouTube'] id: str - timestamp: t.NotRequired[str] + timestamp: typing.NotRequired[str] -class LightspeedSpecial(t.TypedDict): - type: t.Literal["Lightspeed"] +class LightspeedSpecial(typing.TypedDict): + type: typing.Literal['Lightspeed'] content_type: LightspeedType id: str -class TwitchSpecial(t.TypedDict): - type: t.Literal["Twitch"] +class TwitchSpecial(typing.TypedDict): + type: typing.Literal['Twitch'] content_type: TwitchType id: str -class SpotifySpecial(t.TypedDict): - type: t.Literal["Spotify"] +class SpotifySpecial(typing.TypedDict): + type: typing.Literal['Spotify'] content_type: str id: str -class SoundcloudSpecial(t.TypedDict): - type: t.Literal["Soundcloud"] +class SoundcloudSpecial(typing.TypedDict): + type: typing.Literal['Soundcloud'] -class BandcampSpecial(t.TypedDict): - type: t.Literal["Bandcamp"] +class BandcampSpecial(typing.TypedDict): + type: typing.Literal['Bandcamp'] content_type: BandcampType id: str -class StreamableSpecial(t.TypedDict): - type: t.Literal["Streamable"] +class AppleMusicSpecial(typing.TypedDict): + type: typing.Literal['AppleMusic'] + album_id: str + track_id: typing.NotRequired[str] + + +class StreamableSpecial(typing.TypedDict): + type: typing.Literal['Streamable'] id: str @@ -80,77 +86,79 @@ class StreamableSpecial(t.TypedDict): | TwitchSpecial | SpotifySpecial | BandcampSpecial + | AppleMusicSpecial | StreamableSpecial ) -class WebsiteMetadata(t.TypedDict): - url: t.NotRequired[str] - original_url: t.NotRequired[str] - special: t.NotRequired[Special] - title: t.NotRequired[str] - description: t.NotRequired[str] - image: t.NotRequired[Image] - video: t.NotRequired[Video] - site_name: t.NotRequired[str] - icon_url: t.NotRequired[str] - colour: t.NotRequired[str] +class WebsiteMetadata(typing.TypedDict): + url: typing.NotRequired[str] + original_url: typing.NotRequired[str] + special: typing.NotRequired[Special] + title: typing.NotRequired[str] + description: typing.NotRequired[str] + image: typing.NotRequired[Image] + video: typing.NotRequired[Video] + site_name: typing.NotRequired[str] + icon_url: typing.NotRequired[str] + colour: typing.NotRequired[str] -class Text(t.TypedDict): - icon_url: t.NotRequired[str] - url: t.NotRequired[str] - title: t.NotRequired[str] - description: t.NotRequired[str] - media: t.NotRequired[files.File] - colour: t.NotRequired[str] +class Text(typing.TypedDict): + icon_url: typing.NotRequired[str] + url: typing.NotRequired[str] + title: typing.NotRequired[str] + description: typing.NotRequired[str] + media: typing.NotRequired[File] + colour: typing.NotRequired[str] class WebsiteEmbed(WebsiteMetadata): - type: t.Literal["Website"] + type: typing.Literal['Website'] class ImageEmbed(Image): - type: t.Literal["Image"] + type: typing.Literal['Image'] class VideoEmbed(Video): - type: t.Literal["Video"] + type: typing.Literal['Video'] class TextEmbed(Text): - type: t.Literal["Text"] + type: typing.Literal['Text'] -class NoneEmbed(t.TypedDict): - type: t.Literal["None"] +class NoneEmbed(typing.TypedDict): + type: typing.Literal['None'] Embed = WebsiteEmbed | ImageEmbed | VideoEmbed | TextEmbed | NoneEmbed __all__ = ( - "ImageSize", - "Image", - "Video", - "TwitchType", - "LightspeedType", - "BandcampType", - "NoneSpecial", - "GIFSpecial", - "YouTubeSpecial", - "LightspeedSpecial", - "TwitchSpecial", - "SpotifySpecial", - "SoundcloudSpecial", - "BandcampSpecial", - "StreamableSpecial", - "Special", - "WebsiteMetadata", - "Text", - "WebsiteEmbed", - "ImageEmbed", - "VideoEmbed", - "TextEmbed", - "NoneEmbed", - "Embed", + 'ImageSize', + 'Image', + 'Video', + 'TwitchType', + 'LightspeedType', + 'BandcampType', + 'NoneSpecial', + 'GIFSpecial', + 'YouTubeSpecial', + 'LightspeedSpecial', + 'TwitchSpecial', + 'SpotifySpecial', + 'SoundcloudSpecial', + 'BandcampSpecial', + 'AppleMusicSpecial', + 'StreamableSpecial', + 'Special', + 'WebsiteMetadata', + 'Text', + 'WebsiteEmbed', + 'ImageEmbed', + 'VideoEmbed', + 'TextEmbed', + 'NoneEmbed', + 'Embed', ) diff --git a/pyvolt/raw/emojis.py b/pyvolt/raw/emojis.py index 9e172bb..d8c79c0 100644 --- a/pyvolt/raw/emojis.py +++ b/pyvolt/raw/emojis.py @@ -1,47 +1,49 @@ -import typing as t +from __future__ import annotations -T = t.TypeVar("T") +import typing +T = typing.TypeVar('T') -class BaseEmoji(t.Generic[T], t.TypedDict): + +class BaseEmoji(typing.Generic[T], typing.TypedDict): _id: str parent: T creator_id: str name: str - animated: t.NotRequired[bool] - nsfw: t.NotRequired[bool] + animated: typing.NotRequired[bool] + nsfw: typing.NotRequired[bool] -ServerEmoji = BaseEmoji["ServerEmojiParent"] -DetachedEmoji = BaseEmoji["DetachedEmojiParent"] -Emoji = BaseEmoji["EmojiParent"] +ServerEmoji = BaseEmoji['ServerEmojiParent'] +DetachedEmoji = BaseEmoji['DetachedEmojiParent'] +Emoji = BaseEmoji['EmojiParent'] -class ServerEmojiParent(t.TypedDict): - type: t.Literal["Server"] +class ServerEmojiParent(typing.TypedDict): + type: typing.Literal['Server'] id: str -class DetachedEmojiParent(t.TypedDict): - type: t.Literal["Detached"] +class DetachedEmojiParent(typing.TypedDict): + type: typing.Literal['Detached'] EmojiParent = ServerEmojiParent | DetachedEmojiParent -class DataCreateEmoji(t.TypedDict): +class DataCreateEmoji(typing.TypedDict): name: str parent: EmojiParent - nsfw: t.NotRequired[bool] + nsfw: typing.NotRequired[bool] __all__ = ( - "BaseEmoji", - "ServerEmoji", - "DetachedEmoji", - "Emoji", - "ServerEmojiParent", - "DetachedEmojiParent", - "EmojiParent", - "DataCreateEmoji", + 'BaseEmoji', + 'ServerEmoji', + 'DetachedEmoji', + 'Emoji', + 'ServerEmojiParent', + 'DetachedEmojiParent', + 'EmojiParent', + 'DataCreateEmoji', ) diff --git a/pyvolt/raw/files.py b/pyvolt/raw/files.py index c0307ab..8d7d5b2 100644 --- a/pyvolt/raw/files.py +++ b/pyvolt/raw/files.py @@ -1,55 +1,55 @@ from __future__ import annotations -import typing as t +import typing -class File(t.TypedDict): +class File(typing.TypedDict): _id: str tag: str filename: str metadata: Metadata content_type: str size: int - deleted: t.NotRequired[bool] - reported: t.NotRequired[bool] - message_id: t.NotRequired[str] - user_id: t.NotRequired[str] - server_id: t.NotRequired[str] - object: t.NotRequired[str] + deleted: typing.NotRequired[bool] + reported: typing.NotRequired[bool] + message_id: typing.NotRequired[str] + user_id: typing.NotRequired[str] + server_id: typing.NotRequired[str] + object: typing.NotRequired[str] -class FileMetadata(t.TypedDict): - type: t.Literal["File"] +class FileMetadata(typing.TypedDict): + type: typing.Literal['File'] -class TextMetadata(t.TypedDict): - type: t.Literal["Text"] +class TextMetadata(typing.TypedDict): + type: typing.Literal['Text'] -class ImageMetadata(t.TypedDict): - type: t.Literal["Image"] +class ImageMetadata(typing.TypedDict): + type: typing.Literal['Image'] width: int height: int -class VideoMetadata(t.TypedDict): - type: t.Literal["Video"] +class VideoMetadata(typing.TypedDict): + type: typing.Literal['Video'] width: int height: int -class AudioMetadata(t.TypedDict): - type: t.Literal["Audio"] +class AudioMetadata(typing.TypedDict): + type: typing.Literal['Audio'] Metadata = FileMetadata | TextMetadata | ImageMetadata | VideoMetadata | AudioMetadata __all__ = ( - "File", - "FileMetadata", - "TextMetadata", - "ImageMetadata", - "VideoMetadata", - "AudioMetadata", - "Metadata", + 'File', + 'FileMetadata', + 'TextMetadata', + 'ImageMetadata', + 'VideoMetadata', + 'AudioMetadata', + 'Metadata', ) diff --git a/pyvolt/raw/gateway.py b/pyvolt/raw/gateway.py index b66e1cd..f5a4403 100644 --- a/pyvolt/raw/gateway.py +++ b/pyvolt/raw/gateway.py @@ -1,214 +1,238 @@ from __future__ import annotations -import typing as t - -from . import ( - authifier, - channel_webhooks, - channels, - emojis, - messages, - safety_reports, - server_members, - servers, - user_settings, - users, +import typing + +from .authifier import ( + AuthifierCreateSessionEvent, + AuthifierDeleteSessionEvent, + AuthifierDeleteAllSessionsEvent, +) +from .channel_unreads import ChannelUnread +from .channel_webhooks import Webhook, PartialWebhook, FieldsWebhook +from .channels import ( + SavedMessagesChannel, + DirectMessageChannel, + GroupChannel, + TextChannel, + VoiceChannel, + Channel, + PartialChannel, + FieldsChannel, ) +from .emojis import ServerEmoji +from .messages import Message, PartialMessage, AppendMessage, FieldsMessage +from .safety_reports import CreatedReport +from .server_members import ( + Member, + PartialMember, + MemberCompositeKey, + FieldsMember, + RemovalIntention, +) +from .servers import Server, PartialServer, PartialRole, FieldsServer, FieldsRole +from .user_settings import UserSettings +from .users import User, PartialUser, FieldsUser -class ClientBulkEvent(t.TypedDict): - type: t.Literal["Bulk"] +class ClientBulkEvent(typing.TypedDict): + type: typing.Literal['Bulk'] v: list[ClientEvent] -class ClientAuthenticatedEvent(t.TypedDict): - type: t.Literal["Authenticated"] +class ClientAuthenticatedEvent(typing.TypedDict): + type: typing.Literal['Authenticated'] -class ClientLogoutEvent(t.TypedDict): - type: t.Literal["Logout"] +class ClientLogoutEvent(typing.TypedDict): + type: typing.Literal['Logout'] -class ClientReadyEvent(t.TypedDict): - type: t.Literal["Ready"] - users: list[users.User] - servers: list[servers.Server] - channels: list[channels.Channel] - members: list[server_members.Member] - emojis: list[emojis.ServerEmoji] +class ClientReadyEvent(typing.TypedDict): + type: typing.Literal['Ready'] + users: typing.NotRequired[list[User]] + servers: typing.NotRequired[list[Server]] + channels: typing.NotRequired[list[Channel]] + members: typing.NotRequired[list[Member]] + emojis: typing.NotRequired[list[ServerEmoji]] + # Insert please.... + # me: User + user_settings: typing.NotRequired[UserSettings] + channel_unreads: typing.NotRequired[list[ChannelUnread]] Ping = list[int] | int -class ClientPongEvent(t.TypedDict): - type: t.Literal["Pong"] +class ClientPongEvent(typing.TypedDict): + type: typing.Literal['Pong'] data: Ping -class ClientMessageEvent(messages.Message): - type: t.Literal["Message"] +class ClientMessageEvent(Message): + type: typing.Literal['Message'] -class ClientMessageUpdateEvent(t.TypedDict): - type: t.Literal["MessageUpdate"] +class ClientMessageUpdateEvent(typing.TypedDict): + type: typing.Literal['MessageUpdate'] id: str channel: str - data: messages.PartialMessage + data: PartialMessage + clear: list[FieldsMessage] -class ClientMessageAppendEvent(t.TypedDict): - type: t.Literal["MessageAppend"] +class ClientMessageAppendEvent(typing.TypedDict): + type: typing.Literal['MessageAppend'] id: str channel: str - append: messages.AppendMessage + append: AppendMessage -class ClientMessageDeleteEvent(t.TypedDict): - type: t.Literal["MessageDelete"] +class ClientMessageDeleteEvent(typing.TypedDict): + type: typing.Literal['MessageDelete'] id: str channel: str -class ClientMessageReactEvent(t.TypedDict): - type: t.Literal["MessageReact"] +class ClientMessageReactEvent(typing.TypedDict): + type: typing.Literal['MessageReact'] id: str channel_id: str user_id: str emoji_id: str -class ClientMessageUnreactEvent(t.TypedDict): - type: t.Literal["MessageUnreact"] +class ClientMessageUnreactEvent(typing.TypedDict): + type: typing.Literal['MessageUnreact'] id: str channel_id: str user_id: str emoji_id: str -class ClientMessageRemoveReactionEvent(t.TypedDict): - type: t.Literal["MessageRemoveReaction"] +class ClientMessageRemoveReactionEvent(typing.TypedDict): + type: typing.Literal['MessageRemoveReaction'] id: str channel_id: str emoji_id: str -class ClientBulkMessageDeleteEvent(t.TypedDict): - type: t.Literal["BulkMessageDelete"] +class ClientBulkMessageDeleteEvent(typing.TypedDict): + type: typing.Literal['BulkMessageDelete'] channel: str ids: list[str] -class ClientServerCreateEvent(t.TypedDict): - type: t.Literal["ServerCreate"] +class ClientServerCreateEvent(typing.TypedDict): + type: typing.Literal['ServerCreate'] id: str - server: servers.Server - channels: list[channels.Channel] - emojis: list[emojis.ServerEmoji] + server: Server + channels: list[Channel] + emojis: list[ServerEmoji] -class ClientServerUpdateEvent(t.TypedDict): - type: t.Literal["ServerUpdate"] +class ClientServerUpdateEvent(typing.TypedDict): + type: typing.Literal['ServerUpdate'] id: str - data: servers.PartialServer - clear: list[servers.FieldsServer] + data: PartialServer + clear: list[FieldsServer] -class ClientServerDeleteEvent(t.TypedDict): - type: t.Literal["ServerDelete"] +class ClientServerDeleteEvent(typing.TypedDict): + type: typing.Literal['ServerDelete'] id: str -class ClientServerMemberUpdateEvent(t.TypedDict): - type: t.Literal["ServerMemberUpdate"] - id: server_members.MemberCompositeKey - data: server_members.PartialMember - clear: list[server_members.FieldsMember] +class ClientServerMemberUpdateEvent(typing.TypedDict): + type: typing.Literal['ServerMemberUpdate'] + id: MemberCompositeKey + data: PartialMember + clear: list[FieldsMember] -class ClientServerMemberJoinEvent(t.TypedDict): - type: t.Literal["ServerMemberJoin"] +class ClientServerMemberJoinEvent(typing.TypedDict): + type: typing.Literal['ServerMemberJoin'] id: str user: str -class ClientServerMemberLeaveEvent(t.TypedDict): - type: t.Literal["ServerMemberLeave"] +class ClientServerMemberLeaveEvent(typing.TypedDict): + type: typing.Literal['ServerMemberLeave'] id: str user: str + reason: RemovalIntention -class ClientServerRoleUpdateEvent(t.TypedDict): - type: t.Literal["ServerRoleUpdate"] +class ClientServerRoleUpdateEvent(typing.TypedDict): + type: typing.Literal['ServerRoleUpdate'] id: str role_id: str - data: servers.PartialRole - clear: list[servers.FieldsRole] + data: PartialRole + clear: list[FieldsRole] -class ClientServerRoleDeleteEvent(t.TypedDict): - type: t.Literal["ServerRoleDelete"] +class ClientServerRoleDeleteEvent(typing.TypedDict): + type: typing.Literal['ServerRoleDelete'] id: str role_id: str -class ClientUserUpdateEvent(t.TypedDict): - type: t.Literal["UserUpdate"] +class ClientUserUpdateEvent(typing.TypedDict): + type: typing.Literal['UserUpdate'] id: str - data: users.PartialUser - clear: list[users.FieldsUser] + data: PartialUser + clear: list[FieldsUser] event_id: str | None -class ClientUserRelationshipEvent(t.TypedDict): - type: t.Literal["UserRelationship"] +class ClientUserRelationshipEvent(typing.TypedDict): + type: typing.Literal['UserRelationship'] id: str - user: users.User + user: User -class ClientUserSettingsUpdateEvent(t.TypedDict): - type: t.Literal["UserSettingsUpdate"] +class ClientUserSettingsUpdateEvent(typing.TypedDict): + type: typing.Literal['UserSettingsUpdate'] id: str - update: user_settings.UserSettings + update: UserSettings -class ClientUserPlatformWipeEvent(t.TypedDict): - type: t.Literal["UserPlatformWipe"] +class ClientUserPlatformWipeEvent(typing.TypedDict): + type: typing.Literal['UserPlatformWipe'] user_id: str flags: int -class ClientEmojiCreateEvent(emojis.ServerEmoji): - type: t.Literal["EmojiCreate"] +class ClientEmojiCreateEvent(ServerEmoji): + type: typing.Literal['EmojiCreate'] -class ClientEmojiDeleteEvent(t.TypedDict): - type: t.Literal["EmojiDelete"] +class ClientEmojiDeleteEvent(typing.TypedDict): + type: typing.Literal['EmojiDelete'] id: str -class ClientReportCreateEvent(safety_reports.CreatedReport): - type: t.Literal["ReportCreate"] +class ClientReportCreateEvent(CreatedReport): + type: typing.Literal['ReportCreate'] -class ClientSavedMessagesChannelCreateEvent(channels.SavedMessagesChannel): - type: t.Literal["ChannelCreate"] +class ClientSavedMessagesChannelCreateEvent(SavedMessagesChannel): + type: typing.Literal['ChannelCreate'] -class ClientDirectMessageChannelCreateEvent(channels.DirectMessageChannel): - type: t.Literal["ChannelCreate"] +class ClientDirectMessageChannelCreateEvent(DirectMessageChannel): + type: typing.Literal['ChannelCreate'] -class ClientGroupChannelCreateEvent(channels.GroupChannel): - type: t.Literal["ChannelCreate"] +class ClientGroupChannelCreateEvent(GroupChannel): + type: typing.Literal['ChannelCreate'] -class ClientTextChannelCreateEvent(channels.TextChannel): - type: t.Literal["ChannelCreate"] +class ClientTextChannelCreateEvent(TextChannel): + type: typing.Literal['ChannelCreate'] -class ClientVoiceChannelCreateEvent(channels.VoiceChannel): - type: t.Literal["ChannelCreate"] +class ClientVoiceChannelCreateEvent(VoiceChannel): + type: typing.Literal['ChannelCreate'] ClientChannelCreateEvent = ( @@ -220,82 +244,78 @@ class ClientVoiceChannelCreateEvent(channels.VoiceChannel): ) -class ClientChannelUpdateEvent(t.TypedDict): - type: t.Literal["ChannelUpdate"] +class ClientChannelUpdateEvent(typing.TypedDict): + type: typing.Literal['ChannelUpdate'] id: str - data: channels.PartialChannel - clear: list[channels.FieldsChannel] + data: PartialChannel + clear: list[FieldsChannel] -class ClientChannelDeleteEvent(t.TypedDict): - type: t.Literal["ChannelDelete"] +class ClientChannelDeleteEvent(typing.TypedDict): + type: typing.Literal['ChannelDelete'] id: str -class ClientChannelGroupJoinEvent(t.TypedDict): - type: t.Literal["ChannelGroupJoin"] +class ClientChannelGroupJoinEvent(typing.TypedDict): + type: typing.Literal['ChannelGroupJoin'] id: str user: str -class ClientChannelGroupLeaveEvent(t.TypedDict): - type: t.Literal["ChannelGroupLeave"] +class ClientChannelGroupLeaveEvent(typing.TypedDict): + type: typing.Literal['ChannelGroupLeave'] id: str user: str -class ClientChannelStartTypingEvent(t.TypedDict): - type: t.Literal["ChannelStartTyping"] +class ClientChannelStartTypingEvent(typing.TypedDict): + type: typing.Literal['ChannelStartTyping'] id: str user: str -class ClientChannelStopTypingEvent(t.TypedDict): - type: t.Literal["ChannelStopTyping"] +class ClientChannelStopTypingEvent(typing.TypedDict): + type: typing.Literal['ChannelStopTyping'] id: str user: str -class ClientChannelAckEvent(t.TypedDict): - type: t.Literal["ChannelAck"] +class ClientChannelAckEvent(typing.TypedDict): + type: typing.Literal['ChannelAck'] id: str user: str message_id: str -class ClientWebhookCreateEvent(channel_webhooks.Webhook): - type: t.Literal["WebhookCreate"] +class ClientWebhookCreateEvent(Webhook): + type: typing.Literal['WebhookCreate'] -class ClientWebhookUpdateEvent(t.TypedDict): - type: t.Literal["WebhookUpdate"] +class ClientWebhookUpdateEvent(typing.TypedDict): + type: typing.Literal['WebhookUpdate'] id: str - data: channel_webhooks.PartialWebhook - remove: list[channel_webhooks.FieldsWebhook] + data: PartialWebhook + remove: list[FieldsWebhook] -class ClientWebhookDeleteEvent(t.TypedDict): - type: t.Literal["WebhookDelete"] +class ClientWebhookDeleteEvent(typing.TypedDict): + type: typing.Literal['WebhookDelete'] id: str -class ClientCreateSessionAuthEvent(authifier.AuthifierCreateSessionEvent): - type: t.Literal["Auth"] +class ClientCreateSessionAuthEvent(AuthifierCreateSessionEvent): + type: typing.Literal['Auth'] -class ClientDeleteSessionAuthEvent(authifier.AuthifierDeleteSessionEvent): - type: t.Literal["Auth"] +class ClientDeleteSessionAuthEvent(AuthifierDeleteSessionEvent): + type: typing.Literal['Auth'] -class ClientDeleteAllSessionsAuthEvent(authifier.AuthifierDeleteAllSessionsEvent): - type: t.Literal["Auth"] +class ClientDeleteAllSessionsAuthEvent(AuthifierDeleteAllSessionsEvent): + type: typing.Literal['Auth'] -ClientAuthEvent = ( - ClientCreateSessionAuthEvent - | ClientDeleteSessionAuthEvent - | ClientDeleteAllSessionsAuthEvent -) +ClientAuthEvent = ClientCreateSessionAuthEvent | ClientDeleteSessionAuthEvent | ClientDeleteAllSessionsAuthEvent ClientEvent = ( ClientBulkEvent @@ -341,35 +361,98 @@ class ClientDeleteAllSessionsAuthEvent(authifier.AuthifierDeleteAllSessionsEvent ) -class ServerAuthenticateEvent(t.TypedDict): - type: t.Literal["Authenticate"] +class ServerAuthenticateEvent(typing.TypedDict): + type: typing.Literal['Authenticate'] token: str -class ServerBeginTypingEvent(t.TypedDict): - type: t.Literal["BeginTyping"] +class ServerBeginTypingEvent(typing.TypedDict): + type: typing.Literal['BeginTyping'] channel: str -class ServerEndTypingEvent(t.TypedDict): - type: t.Literal["EndTyping"] +class ServerEndTypingEvent(typing.TypedDict): + type: typing.Literal['EndTyping'] channel: str -class ServerSubscribeEvent(t.TypedDict): - type: t.Literal["Subscribe"] +class ServerSubscribeEvent(typing.TypedDict): + type: typing.Literal['Subscribe'] server_id: str -class ServerPingEvent(t.TypedDict): - type: t.Literal["Ping"] +class ServerPingEvent(typing.TypedDict): + type: typing.Literal['Ping'] data: Ping ServerEvent = ( - ServerAuthenticateEvent - | ServerBeginTypingEvent - | ServerEndTypingEvent - | ServerSubscribeEvent - | ServerPingEvent + ServerAuthenticateEvent | ServerBeginTypingEvent | ServerEndTypingEvent | ServerSubscribeEvent | ServerPingEvent +) + + +class BonfireConnectionParameters(typing.TypedDict): + version: typing.Literal['1'] + format: typing.Literal['json', 'msgpack'] + token: typing.NotRequired[str] + __user_settings_keys: typing.NotRequired[str] + + +__all__ = ( + 'ClientBulkEvent', + 'ClientAuthenticatedEvent', + 'ClientLogoutEvent', + 'ClientReadyEvent', + 'Ping', + 'ClientPongEvent', + 'ClientMessageEvent', + 'ClientMessageUpdateEvent', + 'ClientMessageAppendEvent', + 'ClientMessageDeleteEvent', + 'ClientMessageReactEvent', + 'ClientMessageUnreactEvent', + 'ClientMessageRemoveReactionEvent', + 'ClientBulkMessageDeleteEvent', + 'ClientServerCreateEvent', + 'ClientServerUpdateEvent', + 'ClientServerDeleteEvent', + 'ClientServerMemberUpdateEvent', + 'ClientServerMemberJoinEvent', + 'ClientServerMemberLeaveEvent', + 'ClientServerRoleUpdateEvent', + 'ClientServerRoleDeleteEvent', + 'ClientUserUpdateEvent', + 'ClientUserRelationshipEvent', + 'ClientUserSettingsUpdateEvent', + 'ClientUserPlatformWipeEvent', + 'ClientEmojiCreateEvent', + 'ClientEmojiDeleteEvent', + 'ClientSavedMessagesChannelCreateEvent', + 'ClientDirectMessageChannelCreateEvent', + 'ClientGroupChannelCreateEvent', + 'ClientTextChannelCreateEvent', + 'ClientVoiceChannelCreateEvent', + 'ClientChannelCreateEvent', + 'ClientChannelUpdateEvent', + 'ClientChannelDeleteEvent', + 'ClientChannelGroupJoinEvent', + 'ClientChannelGroupLeaveEvent', + 'ClientChannelStartTypingEvent', + 'ClientChannelStopTypingEvent', + 'ClientChannelAckEvent', + 'ClientWebhookCreateEvent', + 'ClientWebhookUpdateEvent', + 'ClientWebhookDeleteEvent', + 'ClientCreateSessionAuthEvent', + 'ClientDeleteSessionAuthEvent', + 'ClientDeleteAllSessionsAuthEvent', + 'ClientAuthEvent', + 'ClientEvent', + 'ServerAuthenticateEvent', + 'ServerBeginTypingEvent', + 'ServerEndTypingEvent', + 'ServerSubscribeEvent', + 'ServerPingEvent', + 'ServerEvent', + 'BonfireConnectionParameters', ) diff --git a/pyvolt/raw/localization.py b/pyvolt/raw/localization.py new file mode 100644 index 0000000..5b86d29 --- /dev/null +++ b/pyvolt/raw/localization.py @@ -0,0 +1,78 @@ +import typing + +Language = typing.Literal[ + # english + 'en', + 'en_US', + # foreign languages + 'ar', + 'as', + 'az', + 'be', + 'bg', + 'bn', + 'br', + 'ca', + 'ceb', + 'ckb', + 'cs', + 'da', + 'de', + 'el', + 'es', + 'es_419', + 'et', + 'fi', + 'fil', + 'fr', + 'ga', + 'hi', + 'hr', + 'hu', + 'hy', + 'id', + 'is', + 'it', + 'ja', + 'ko', + 'lb', + 'lt', + 'mk', + 'ms', + 'nb_NO', + 'nl', + 'fa', + 'pl', + 'pt_BR', + 'pt_PT', + 'ro', + 'ru', + 'sk', + 'sl', + 'sq', + 'sr', + 'si', + 'sv', + 'ta', + 'th', + 'tr', + 'uk', + 'ur', + 'vec', + 'vi', + 'zh_Hans', + 'zh_Hant', + 'lv', + # constructed languages + 'tokipona', + 'esperanto', + # joke languages + 'owo', + 'pr', + 'bottom', + 'leet', + 'piglatin', + 'enchantment', +] + +__all__ = ('Language',) diff --git a/pyvolt/raw/messages.py b/pyvolt/raw/messages.py index d1a2eed..65d5a43 100644 --- a/pyvolt/raw/messages.py +++ b/pyvolt/raw/messages.py @@ -1,104 +1,125 @@ from __future__ import annotations -import typing as t +import typing -from . import basic, channel_webhooks, embeds, files, server_members, users +from .basic import Bool +from .channel_webhooks import MessageWebhook +from .embeds import Embed +from .files import File +from .server_members import Member +from .users import User -class Message(t.TypedDict): +class Message(typing.TypedDict): _id: str - nonce: t.NotRequired[str] + nonce: typing.NotRequired[str] channel: str author: str - user: t.NotRequired[users.User] - member: t.NotRequired[server_members.Member] - webhook: t.NotRequired[channel_webhooks.MessageWebhook] - content: t.NotRequired[str] - system: t.NotRequired[SystemMessage] - attachments: t.NotRequired[list[files.File]] - edited: t.NotRequired[str] - embeds: t.NotRequired[list[embeds.Embed]] - mentions: t.NotRequired[list[str]] - replies: t.NotRequired[list[str]] - reactions: t.NotRequired[dict[str, list[str]]] - interactions: t.NotRequired[Interactions] - masquerade: t.NotRequired[Masquerade] - flags: t.NotRequired[int] - - -class PartialMessage(t.TypedDict): - content: t.NotRequired[str] - edited: t.NotRequired[str] - embeds: t.NotRequired[list[embeds.Embed]] - reactions: t.NotRequired[dict[str, list[str]]] - - -class MessagesAndUsersBulkMessageResponse(t.TypedDict): + user: typing.NotRequired[User] + member: typing.NotRequired[Member] + webhook: typing.NotRequired[MessageWebhook] + content: typing.NotRequired[str] + system: typing.NotRequired[SystemMessage] + attachments: typing.NotRequired[list[File]] + edited: typing.NotRequired[str] + embeds: typing.NotRequired[list[Embed]] + mentions: typing.NotRequired[list[str]] + replies: typing.NotRequired[list[str]] + reactions: typing.NotRequired[dict[str, list[str]]] + interactions: typing.NotRequired[Interactions] + masquerade: typing.NotRequired[Masquerade] + pinned: typing.NotRequired[bool] + flags: typing.NotRequired[int] + + +class PartialMessage(typing.TypedDict): + content: typing.NotRequired[str] + edited: typing.NotRequired[str] + embeds: typing.NotRequired[list[Embed]] + pinned: typing.NotRequired[bool] + reactions: typing.NotRequired[dict[str, list[str]]] + + +class MessagesAndUsersBulkMessageResponse(typing.TypedDict): messages: list[Message] - users: list[users.User] - members: t.NotRequired[list[server_members.Member]] + users: list[User] + members: typing.NotRequired[list[Member]] BulkMessageResponse = list[Message] | MessagesAndUsersBulkMessageResponse -class TextSystemMessage(t.TypedDict): - type: t.Literal["text"] +class TextSystemMessage(typing.TypedDict): + type: typing.Literal['text'] content: str -class UserAddedSystemMessage(t.TypedDict): - type: t.Literal["user_added"] +class UserAddedSystemMessage(typing.TypedDict): + type: typing.Literal['user_added'] id: str by: str -class UserRemoveSystemMessage(t.TypedDict): - type: t.Literal["user_remove"] +class UserRemoveSystemMessage(typing.TypedDict): + type: typing.Literal['user_remove'] id: str by: str -class UserJoinedSystemMessage(t.TypedDict): - type: t.Literal["user_joined"] +class UserJoinedSystemMessage(typing.TypedDict): + type: typing.Literal['user_joined'] id: str -class UserLeftSystemMessage(t.TypedDict): - type: t.Literal["user_left"] +class UserLeftSystemMessage(typing.TypedDict): + type: typing.Literal['user_left'] id: str -class UserKickedSystemMessage(t.TypedDict): - type: t.Literal["user_kicked"] +class UserKickedSystemMessage(typing.TypedDict): + type: typing.Literal['user_kicked'] id: str -class UserBannedSystemMessage(t.TypedDict): - type: t.Literal["user_banned"] +class UserBannedSystemMessage(typing.TypedDict): + type: typing.Literal['user_banned'] id: str -class ChannelRenamedSystemMessage(t.TypedDict): - type: t.Literal["channel_renamed"] +class ChannelRenamedSystemMessage(typing.TypedDict): + type: typing.Literal['channel_renamed'] + name: str by: str -class ChannelDescriptionChangedSystemMessage(t.TypedDict): - type: t.Literal["channel_description_changed"] +class ChannelDescriptionChangedSystemMessage(typing.TypedDict): + type: typing.Literal['channel_description_changed'] by: str -class ChannelIconChangedSystemMessage(t.TypedDict): - type: t.Literal["channel_icon_changed"] +class ChannelIconChangedSystemMessage(typing.TypedDict): + type: typing.Literal['channel_icon_changed'] by: str -ChannelOwnershipChangedSystemMessage = t.TypedDict( - "ChannelOwnershipChangedSystemMessage", - {"type": t.Literal["channel_ownership_changed"], "from": str, "to": str}, +ChannelOwnershipChangedSystemMessage = typing.TypedDict( + 'ChannelOwnershipChangedSystemMessage', + {'type': typing.Literal['channel_ownership_changed'], 'from': str, 'to': str}, ) + +class MessagePinnedSystemMessage(typing.TypedDict): + type: typing.Literal['message_pinned'] + id: str + by: str + + +class MessageUnpinnedSystemMessage(typing.TypedDict): + type: typing.Literal['message_unpinned'] + id: str + by: str + + SystemMessage = ( TextSystemMessage | UserAddedSystemMessage @@ -111,110 +132,118 @@ class ChannelIconChangedSystemMessage(t.TypedDict): | ChannelDescriptionChangedSystemMessage | ChannelIconChangedSystemMessage | ChannelOwnershipChangedSystemMessage + | MessagePinnedSystemMessage + | MessageUnpinnedSystemMessage ) -class Masquerade(t.TypedDict): - name: t.NotRequired[str] - avatar: t.NotRequired[str] - colour: t.NotRequired[str] +class Masquerade(typing.TypedDict): + name: typing.NotRequired[str] + avatar: typing.NotRequired[str] + colour: typing.NotRequired[str] -class Interactions(t.TypedDict): - reactions: t.NotRequired[list[str]] - restrict_reactions: t.NotRequired[bool] +class Interactions(typing.TypedDict): + reactions: typing.NotRequired[list[str]] + restrict_reactions: typing.NotRequired[bool] -class AppendMessage(t.TypedDict): - embeds: t.NotRequired[list[embeds.Embed]] +class AppendMessage(typing.TypedDict): + embeds: typing.NotRequired[list[Embed]] -MessageSort = t.Literal["Relevance", "Latest", "Oldest"] +MessageSort = typing.Literal['Relevance', 'Latest', 'Oldest'] -class SendableEmbed(t.TypedDict): - icon_url: t.NotRequired[str] - url: t.NotRequired[str] - title: t.NotRequired[str] - description: t.NotRequired[str] - media: t.NotRequired[str] - colour: t.NotRequired[str] +class SendableEmbed(typing.TypedDict): + icon_url: typing.NotRequired[str] + url: typing.NotRequired[str] + title: typing.NotRequired[str] + description: typing.NotRequired[str] + media: typing.NotRequired[str] + colour: typing.NotRequired[str] -class ReplyIntent(t.TypedDict): +class ReplyIntent(typing.TypedDict): id: str mention: bool -class DataMessageSend(t.TypedDict): - content: t.NotRequired[str] - attachments: t.NotRequired[list[str]] - replies: t.NotRequired[list[ReplyIntent]] - embeds: t.NotRequired[list[SendableEmbed]] - masquerade: t.NotRequired[Masquerade] - interactions: t.NotRequired[Interactions] - flags: t.NotRequired[int] +class DataMessageSend(typing.TypedDict): + content: typing.NotRequired[str] + attachments: typing.NotRequired[list[str]] + replies: typing.NotRequired[list[ReplyIntent]] + embeds: typing.NotRequired[list[SendableEmbed]] + masquerade: typing.NotRequired[Masquerade] + interactions: typing.NotRequired[Interactions] + flags: typing.NotRequired[int] -class OptionsQueryMessages(t.TypedDict): - limit: t.NotRequired[int] - before: t.NotRequired[str] - after: t.NotRequired[str] - sort: t.NotRequired[MessageSort] - nearby: t.NotRequired[str] - include_users: t.NotRequired[basic.Bool] +class OptionsQueryMessages(typing.TypedDict): + limit: typing.NotRequired[int] + before: typing.NotRequired[str] + after: typing.NotRequired[str] + sort: typing.NotRequired[MessageSort] + nearby: typing.NotRequired[str] + include_users: typing.NotRequired[Bool] -class DataMessageSearch(t.TypedDict): - query: str - limit: t.NotRequired[int] - before: t.NotRequired[str] - after: t.NotRequired[str] - sort: t.NotRequired[MessageSort] - include_users: t.NotRequired[basic.Bool] +class DataMessageSearch(typing.TypedDict): + query: typing.NotRequired[str] + pinned: typing.NotRequired[bool] + limit: typing.NotRequired[int] + before: typing.NotRequired[str] + after: typing.NotRequired[str] + sort: typing.NotRequired[MessageSort] + include_users: typing.NotRequired[bool] -class DataEditMessage(t.TypedDict): - content: t.NotRequired[str | None] - embeds: t.NotRequired[list[SendableEmbed] | None] +class DataEditMessage(typing.TypedDict): + content: typing.NotRequired[str] + embeds: typing.NotRequired[list[SendableEmbed]] -class OptionsBulkDelete(t.TypedDict): +class OptionsBulkDelete(typing.TypedDict): ids: list[str] -class OptionsUnreact(t.TypedDict): - user_id: t.NotRequired[str] - remove_all: t.NotRequired[basic.Bool] +class OptionsUnreact(typing.TypedDict): + user_id: typing.NotRequired[str] + remove_all: typing.NotRequired[Bool] + +FieldsMessage = typing.Literal['Pinned'] __all__ = ( - "Message", - "PartialMessage", - "MessagesAndUsersBulkMessageResponse", - "BulkMessageResponse", - "TextSystemMessage", - "UserAddedSystemMessage", - "UserRemoveSystemMessage", - "UserJoinedSystemMessage", - "UserLeftSystemMessage", - "UserKickedSystemMessage", - "UserBannedSystemMessage", - "ChannelRenamedSystemMessage", - "ChannelDescriptionChangedSystemMessage", - "ChannelIconChangedSystemMessage", - "ChannelOwnershipChangedSystemMessage", - "SystemMessage", - "Masquerade", - "Interactions", - "AppendMessage", - "MessageSort", - "SendableEmbed", - "ReplyIntent", - "DataMessageSend", - "OptionsQueryMessages", - "DataMessageSearch", - "DataEditMessage", - "OptionsBulkDelete", - "OptionsUnreact", + 'Message', + 'PartialMessage', + 'MessagesAndUsersBulkMessageResponse', + 'BulkMessageResponse', + 'TextSystemMessage', + 'UserAddedSystemMessage', + 'UserRemoveSystemMessage', + 'UserJoinedSystemMessage', + 'UserLeftSystemMessage', + 'UserKickedSystemMessage', + 'UserBannedSystemMessage', + 'ChannelRenamedSystemMessage', + 'ChannelDescriptionChangedSystemMessage', + 'ChannelIconChangedSystemMessage', + 'ChannelOwnershipChangedSystemMessage', + 'MessagePinnedSystemMessage', + 'MessageUnpinnedSystemMessage', + 'SystemMessage', + 'Masquerade', + 'Interactions', + 'AppendMessage', + 'MessageSort', + 'SendableEmbed', + 'ReplyIntent', + 'DataMessageSend', + 'OptionsQueryMessages', + 'DataMessageSearch', + 'DataEditMessage', + 'OptionsBulkDelete', + 'OptionsUnreact', + 'FieldsMessage', ) diff --git a/pyvolt/raw/misc.py b/pyvolt/raw/misc.py new file mode 100644 index 0000000..29b6e8b --- /dev/null +++ b/pyvolt/raw/misc.py @@ -0,0 +1,46 @@ +import typing + + +class CaptchaFeature(typing.TypedDict): + enabled: bool + key: str + + +class Feature(typing.TypedDict): + enabled: bool + url: str + + +class VoiceFeature(typing.TypedDict): + enabled: bool + url: str + ws: str + + +class RevoltFeatures(typing.TypedDict): + captcha: CaptchaFeature + email: bool + invite_only: bool + autumn: Feature + january: Feature + voso: VoiceFeature + + +class BuildInformation(typing.TypedDict): + commit_sha: str + commit_timestamp: str + semver: str + origin_url: str + timestamp: str + + +class RevoltConfig(typing.TypedDict): + revolt: str + features: RevoltFeatures + ws: str + app: str + vapid: str + build: BuildInformation + + +__all__ = ('CaptchaFeature', 'Feature', 'VoiceFeature', 'RevoltFeatures', 'BuildInformation', 'RevoltConfig') diff --git a/pyvolt/raw/permissions.py b/pyvolt/raw/permissions.py index e29b325..bfe72e5 100644 --- a/pyvolt/raw/permissions.py +++ b/pyvolt/raw/permissions.py @@ -1,16 +1,33 @@ from __future__ import annotations -import typing as t +import typing -class Override(t.TypedDict): +class Override(typing.TypedDict): allow: int deny: int -class OverrideField(t.TypedDict): +class DataPermissionsField(typing.TypedDict): + permissions: Override + + +class DataPermissionsValue(typing.TypedDict): + permissions: int + + +DataPermissionPoly = DataPermissionsValue | DataPermissionsField + + +class OverrideField(typing.TypedDict): a: int d: int -__all__ = ("Override", "OverrideField") +__all__ = ( + 'Override', + 'DataPermissionsField', + 'DataPermissionsValue', + 'DataPermissionPoly', + 'OverrideField', +) diff --git a/pyvolt/raw/safety_reports.py b/pyvolt/raw/safety_reports.py index 3ba0a79..9853ec6 100644 --- a/pyvolt/raw/safety_reports.py +++ b/pyvolt/raw/safety_reports.py @@ -1,9 +1,9 @@ from __future__ import annotations -import typing as t +import typing -class BaseReport(t.TypedDict): +class BaseReport(typing.TypedDict): _id: str author_id: str content: ReportedContent @@ -11,90 +11,90 @@ class BaseReport(t.TypedDict): notes: str -ContentReportReason = t.Literal[ - "NoneSpecified", - "Illegal", - "IllegalGoods", - "IllegalExtortion", - "IllegalPornography", - "IllegalHacking", - "ExtremeViolence", - "PromotesHarm", - "UnsolicitedSpam", - "Raid", - "SpamAbuse", - "ScamsFraud", - "Malware", - "Harassment", +ContentReportReason = typing.Literal[ + 'NoneSpecified', + 'Illegal', + 'IllegalGoods', + 'IllegalExtortion', + 'IllegalPornography', + 'IllegalHacking', + 'ExtremeViolence', + 'PromotesHarm', + 'UnsolicitedSpam', + 'Raid', + 'SpamAbuse', + 'ScamsFraud', + 'Malware', + 'Harassment', ] -UserReportReason = t.Literal[ - "NoneSpecified", - "UnsolicitedSpam", - "SpamAbuse", - "InappropriateProfile", - "Impersonation", - "BanEvasion", - "Underage", +UserReportReason = typing.Literal[ + 'NoneSpecified', + 'UnsolicitedSpam', + 'SpamAbuse', + 'InappropriateProfile', + 'Impersonation', + 'BanEvasion', + 'Underage', ] -class MessageReportedContent(t.TypedDict): - type: t.Literal["Message"] +class MessageReportedContent(typing.TypedDict): + type: typing.Literal['Message'] id: str report_reason: ContentReportReason -class ServerReportedContent(t.TypedDict): - type: t.Literal["Server"] +class ServerReportedContent(typing.TypedDict): + type: typing.Literal['Server'] id: str report_reason: ContentReportReason -class UserReportedContent(t.TypedDict): - type: t.Literal["User"] +class UserReportedContent(typing.TypedDict): + type: typing.Literal['User'] id: str report_reason: UserReportReason - message_id: t.NotRequired[str] + message_id: typing.NotRequired[str] ReportedContent = MessageReportedContent | ServerReportedContent | UserReportedContent class CreatedReport(BaseReport): - status: t.Literal["Created"] + status: typing.Literal['Created'] class RejectedReport(BaseReport): - status: t.Literal["Rejected"] + status: typing.Literal['Rejected'] rejection_reason: str closed_at: str | None class ResolvedReport(BaseReport): - status: t.Literal["Resolved"] + status: typing.Literal['Resolved'] closed_at: str | None Report = CreatedReport | RejectedReport | ResolvedReport -class DataReportContent(t.TypedDict): +class DataReportContent(typing.TypedDict): content: ReportedContent - additional_context: t.NotRequired[str] + additional_context: typing.NotRequired[str] __all__ = ( - "BaseReport", - "ContentReportReason", - "UserReportReason", - "MessageReportedContent", - "ServerReportedContent", - "UserReportedContent", - "ReportedContent", - "CreatedReport", - "RejectedReport", - "ResolvedReport", - "Report", - "DataReportContent", + 'BaseReport', + 'ContentReportReason', + 'UserReportReason', + 'MessageReportedContent', + 'ServerReportedContent', + 'UserReportedContent', + 'ReportedContent', + 'CreatedReport', + 'RejectedReport', + 'ResolvedReport', + 'Report', + 'DataReportContent', ) diff --git a/pyvolt/raw/server_bans.py b/pyvolt/raw/server_bans.py index bb37c85..246d88f 100644 --- a/pyvolt/raw/server_bans.py +++ b/pyvolt/raw/server_bans.py @@ -1,27 +1,28 @@ -import typing as t +import typing -from . import files, server_members +from .files import File +from .server_members import MemberCompositeKey -class ServerBan(t.TypedDict): - _id: server_members.MemberCompositeKey +class ServerBan(typing.TypedDict): + _id: MemberCompositeKey reason: str | None -class DataBanCreate(t.TypedDict): - reason: t.NotRequired[str | None] +class DataBanCreate(typing.TypedDict): + reason: typing.NotRequired[str | None] -class BannedUser(t.TypedDict): +class BannedUser(typing.TypedDict): _id: str username: str discriminator: str - avatar: files.File | None + avatar: File | None -class BanListResult(t.TypedDict): +class BanListResult(typing.TypedDict): users: list[BannedUser] bans: list[ServerBan] -__all__ = ("ServerBan", "DataBanCreate", "BannedUser", "BanListResult") +__all__ = ('ServerBan', 'DataBanCreate', 'BannedUser', 'BanListResult') diff --git a/pyvolt/raw/server_members.py b/pyvolt/raw/server_members.py index a26aa80..fa685f9 100644 --- a/pyvolt/raw/server_members.py +++ b/pyvolt/raw/server_members.py @@ -1,57 +1,61 @@ from __future__ import annotations -import typing as t +import typing -from . import basic, files, users +from .basic import Bool +from .files import File +from .users import User -class Member(t.TypedDict): +class Member(typing.TypedDict): _id: MemberCompositeKey joined_at: str - nickname: t.NotRequired[str] - avatar: t.NotRequired[files.File] - roles: t.NotRequired[list[str]] - timeout: t.NotRequired[str] + nickname: typing.NotRequired[str] + avatar: typing.NotRequired[File] + roles: typing.NotRequired[list[str]] + timeout: typing.NotRequired[str] -class PartialMember(t.TypedDict): - nickname: t.NotRequired[str] - avatar: t.NotRequired[files.File] - roles: t.NotRequired[list[str]] - timeout: t.NotRequired[str] +class PartialMember(typing.TypedDict): + nickname: typing.NotRequired[str] + avatar: typing.NotRequired[File] + roles: typing.NotRequired[list[str]] + timeout: typing.NotRequired[str] -class MemberCompositeKey(t.TypedDict): +class MemberCompositeKey(typing.TypedDict): server: str user: str -FieldsMember = t.Literal["Nickname", "Avatar", "Roles", "Timeout"] +FieldsMember = typing.Literal['Nickname', 'Avatar', 'Roles', 'Timeout'] +RemovalIntention = typing.Literal['Leave', 'Kick', 'Ban'] -class OptionsFetchAllMembers(t.TypedDict): - exclude_offline: t.NotRequired[basic.Bool] +class OptionsFetchAllMembers(typing.TypedDict): + exclude_offline: typing.NotRequired[Bool] -class AllMemberResponse(t.TypedDict): +class AllMemberResponse(typing.TypedDict): members: list[Member] - users: list[users.User] + users: list[User] -class DataMemberEdit(t.TypedDict): - nickname: t.NotRequired[str] - avatar: t.NotRequired[str] - roles: t.NotRequired[list[str]] - timeout: t.NotRequired[str] - remove: t.NotRequired[list[FieldsMember]] +class DataMemberEdit(typing.TypedDict): + nickname: typing.NotRequired[str] + avatar: typing.NotRequired[str] + roles: typing.NotRequired[list[str]] + timeout: typing.NotRequired[str] + remove: typing.NotRequired[list[FieldsMember]] __all__ = ( - "Member", - "PartialMember", - "MemberCompositeKey", - "FieldsMember", - "OptionsFetchAllMembers", - "AllMemberResponse", - "DataMemberEdit", + 'Member', + 'PartialMember', + 'MemberCompositeKey', + 'FieldsMember', + 'RemovalIntention', + 'OptionsFetchAllMembers', + 'AllMemberResponse', + 'DataMemberEdit', ) diff --git a/pyvolt/raw/servers.py b/pyvolt/raw/servers.py index 2f75a8c..f4a7a4b 100644 --- a/pyvolt/raw/servers.py +++ b/pyvolt/raw/servers.py @@ -1,163 +1,164 @@ from __future__ import annotations -import typing as t +import typing -from . import basic, channels, files, permissions +from .basic import Bool +from .channels import Channel +from .files import File +from .permissions import Override, OverrideField -# class BaseServer(t.Generic[C], t.TypedDict): -class BaseServer(t.TypedDict): +# class BaseServer(t.Generic[C], typing.TypedDict): +class BaseServer(typing.TypedDict): _id: str owner: str name: str - description: t.NotRequired[str] + description: typing.NotRequired[str] # channels: list[C] - categories: t.NotRequired[list[Category]] - system_messages: t.NotRequired[SystemMessageChannels] - roles: t.NotRequired[dict[str, Role]] + categories: typing.NotRequired[list[Category]] + system_messages: typing.NotRequired[SystemMessageChannels] + roles: typing.NotRequired[dict[str, Role]] default_permissions: int - icon: t.NotRequired[files.File] - banner: t.NotRequired[files.File] - flags: t.NotRequired[int] - nsfw: t.NotRequired[bool] - analytics: t.NotRequired[bool] - discoverable: t.NotRequired[bool] + icon: typing.NotRequired[File] + banner: typing.NotRequired[File] + flags: typing.NotRequired[int] + nsfw: typing.NotRequired[bool] + analytics: typing.NotRequired[bool] + discoverable: typing.NotRequired[bool] class Server(BaseServer): channels: list[str] -class PartialServer(t.TypedDict): - owner: t.NotRequired[str] - name: t.NotRequired[str] - description: t.NotRequired[str] - channels: t.NotRequired[list[str]] - categories: t.NotRequired[list[Category]] - system_messages: t.NotRequired["SystemMessageChannels"] - default_permissions: t.NotRequired[int] - icon: t.NotRequired[files.File] - banner: t.NotRequired[files.File] - flags: t.NotRequired[int] - discoverable: t.NotRequired[bool] - analytics: t.NotRequired[bool] +class PartialServer(typing.TypedDict): + owner: typing.NotRequired[str] + name: typing.NotRequired[str] + description: typing.NotRequired[str] + channels: typing.NotRequired[list[str]] + categories: typing.NotRequired[list[Category]] + system_messages: typing.NotRequired[SystemMessageChannels] + default_permissions: typing.NotRequired[int] + icon: typing.NotRequired[File] + banner: typing.NotRequired[File] + flags: typing.NotRequired[int] + discoverable: typing.NotRequired[bool] + analytics: typing.NotRequired[bool] -class Role(t.TypedDict): +class Role(typing.TypedDict): name: str - permissions: permissions.OverrideField - colour: t.NotRequired[str] - hoist: t.NotRequired[bool] + permissions: OverrideField + colour: typing.NotRequired[str] + hoist: typing.NotRequired[bool] rank: int -class PartialRole(t.TypedDict): - name: t.NotRequired[str] - permissions: t.NotRequired[permissions.OverrideField] - colour: t.NotRequired[str] - hoist: t.NotRequired[bool] - rank: t.NotRequired[int] +class PartialRole(typing.TypedDict): + name: typing.NotRequired[str] + permissions: typing.NotRequired[OverrideField] + colour: typing.NotRequired[str] + hoist: typing.NotRequired[bool] + rank: typing.NotRequired[int] -FieldsServer = t.Literal[ - "Description", "Categories", "SystemMessages", "Icon", "Banner" -] -FieldsRole = t.Literal["Colour"] +FieldsServer = typing.Literal['Description', 'Categories', 'SystemMessages', 'Icon', 'Banner'] +FieldsRole = typing.Literal['Colour'] -class Category(t.TypedDict): +class Category(typing.TypedDict): id: str title: str channels: list[str] -class SystemMessageChannels(t.TypedDict): - user_joined: t.NotRequired[str] - user_left: t.NotRequired[str] - user_kicked: t.NotRequired[str] - user_banned: t.NotRequired[str] +class SystemMessageChannels(typing.TypedDict): + user_joined: typing.NotRequired[str] + user_left: typing.NotRequired[str] + user_kicked: typing.NotRequired[str] + user_banned: typing.NotRequired[str] -class DataCreateServer(t.TypedDict): +class DataCreateServer(typing.TypedDict): name: str - description: t.NotRequired[str | None] - nsfw: t.NotRequired[bool] + description: typing.NotRequired[str | None] + nsfw: typing.NotRequired[bool] -class DataCreateRole(t.TypedDict): +class DataCreateRole(typing.TypedDict): name: str - rank: t.NotRequired[int | None] + rank: typing.NotRequired[int | None] -class NewRoleResponse(t.TypedDict): +class NewRoleResponse(typing.TypedDict): id: str role: Role -class CreateServerLegacyResponse(t.TypedDict): +class CreateServerLegacyResponse(typing.TypedDict): server: Server - channels: list[channels.Channel] + channels: list[Channel] -class OptionsFetchServer(t.TypedDict): - include_channels: t.NotRequired[basic.Bool] +class OptionsFetchServer(typing.TypedDict): + include_channels: typing.NotRequired[Bool] class ServerWithChannels(BaseServer): - channels: list[channels.Channel] + channels: list[Channel] FetchServerResponse = Server | ServerWithChannels -class DataEditServer(t.TypedDict): - name: t.NotRequired[str] - description: t.NotRequired[str] - icon: t.NotRequired[str] - banner: t.NotRequired[str] - categories: t.NotRequired[list[Category]] - system_messages: t.NotRequired[SystemMessageChannels] - flags: t.NotRequired[int] - discoverable: t.NotRequired[bool] - analytics: t.NotRequired[bool] - remove: t.NotRequired[list[FieldsServer]] +class DataEditServer(typing.TypedDict): + name: typing.NotRequired[str] + description: typing.NotRequired[str] + icon: typing.NotRequired[str] + banner: typing.NotRequired[str] + categories: typing.NotRequired[list[Category]] + system_messages: typing.NotRequired[SystemMessageChannels] + flags: typing.NotRequired[int] + discoverable: typing.NotRequired[bool] + analytics: typing.NotRequired[bool] + remove: typing.NotRequired[list[FieldsServer]] -class DataEditRole(t.TypedDict): - name: t.NotRequired[str] - colour: t.NotRequired[str] - hoist: t.NotRequired[bool] - rank: t.NotRequired[int] - remove: t.NotRequired[list[FieldsRole]] +class DataEditRole(typing.TypedDict): + name: typing.NotRequired[str] + colour: typing.NotRequired[str] + hoist: typing.NotRequired[bool] + rank: typing.NotRequired[int] + remove: typing.NotRequired[list[FieldsRole]] -class DataSetServerRolePermission(t.TypedDict): - permissions: permissions.Override +class DataSetServerRolePermission(typing.TypedDict): + permissions: Override -class OptionsServerDelete(t.TypedDict): - leave_silently: t.NotRequired[basic.Bool] +class OptionsServerDelete(typing.TypedDict): + leave_silently: typing.NotRequired[Bool] __all__ = ( - "BaseServer", - "Server", - "PartialServer", - "Role", - "PartialRole", - "FieldsServer", - "FieldsRole", - "Category", - "SystemMessageChannels", - "DataCreateServer", - "DataCreateRole", - "NewRoleResponse", - "CreateServerLegacyResponse", - "OptionsFetchServer", - "ServerWithChannels", - "FetchServerResponse", - "DataEditServer", - "DataEditRole", - "DataSetServerRolePermission", - "OptionsServerDelete", + 'BaseServer', + 'Server', + 'PartialServer', + 'Role', + 'PartialRole', + 'FieldsServer', + 'FieldsRole', + 'Category', + 'SystemMessageChannels', + 'DataCreateServer', + 'DataCreateRole', + 'NewRoleResponse', + 'CreateServerLegacyResponse', + 'OptionsFetchServer', + 'ServerWithChannels', + 'FetchServerResponse', + 'DataEditServer', + 'DataEditRole', + 'DataSetServerRolePermission', + 'OptionsServerDelete', ) diff --git a/pyvolt/raw/user_settings.py b/pyvolt/raw/user_settings.py index c0c32cd..6763e98 100644 --- a/pyvolt/raw/user_settings.py +++ b/pyvolt/raw/user_settings.py @@ -1,23 +1,176 @@ from __future__ import annotations -import typing as t +import typing + +from .localization import Language UserSettings = dict[str, tuple[int, str]] -class OptionsFetchSettings(t.TypedDict): +class OptionsFetchSettings(typing.TypedDict): keys: list[str] -class OptionsSetSettings(t.TypedDict): - timestamp: t.NotRequired[int] +class OptionsSetSettings(typing.TypedDict): + timestamp: typing.NotRequired[int] DataSetSettings = dict[str, str] + +# Android User Settings +AndroidTheme = typing.Literal['Revolt', 'Light', 'Amoled', 'None', 'M3Dynamic'] +AndroidProfilePictureShape = int +AndroidMessageReplyStyle = typing.Literal['None', 'SwipeFromEnd', 'DoubleTap'] + + +class AndroidUserSettings(typing.TypedDict): + theme: typing.NotRequired[AndroidTheme] + colourOverrides: typing.NotRequired[dict[str, int]] + # If not provided, defaults to SwipeFromEnd + messageReplyStyle: typing.NotRequired[AndroidMessageReplyStyle] + # If not provided, defaults to 50 + avatarRadius: typing.NotRequired[AndroidProfilePictureShape] + + +# Revite User Settings +ReviteChangelogEntryID = typing.Literal[1, 2, 3] | int + + +class ReviteChangelog(typing.TypedDict): + viewed: ReviteChangelogEntryID + + +class ReviteLocaleOptions(typing.TypedDict): + lang: Language + + +ReviteNotificationState = typing.Literal['all', 'mention', 'none', 'muted'] + + +class ReviteNotificationOptions(typing.TypedDict): + server: dict[str, ReviteNotificationState] + channel: dict[str, ReviteNotificationState] + + +class ReviteOrdering(typing.TypedDict): + servers: list[str] + + +ReviteAppearanceEmojiPack = typing.Literal['mutant', 'twemoji', 'openmoji', 'noto'] +ReviteAppearanceSettings = typing.TypedDict( + 'ReviteAppearanceSettings', + { + 'appearance:emoji': typing.NotRequired[ReviteAppearanceEmojiPack], + 'appearance:seasonal': typing.NotRequired[bool], + 'appearance:transparency': typing.NotRequired[bool], + }, +) + +ReviteAppearanceTheme = typing.Literal['dark', 'light'] +ReviteAppearanceFont = typing.Literal[ + 'Open Sans', + 'OpenDyslexic', + 'Inter', + 'Atkinson Hyperlegible', + 'Roboto', + 'Noto Sans', + 'Lato', + 'Bitter', + 'Montserrat', + 'Poppins', + 'Raleway', + 'Ubuntu', + 'Comic Neue', + 'Lexend', +] +ReviteAppearanceMonoFont = typing.Literal[ + 'Fira Code', + 'Roboto Mono', + 'Source Code Pro', + 'Space Mono', + 'Ubuntu Mono', + 'JetBrains Mono', +] +ReviteThemeVariable = typing.Literal[ + 'accent', + 'background', + 'foreground', + 'block', + 'message-box', + 'mention', + 'success', + 'warning', + 'error', + 'hover', + 'scrollbar-thumb', + 'scrollbar-track', + 'primary-background', + 'primary-header', + 'secondary-background', + 'secondary-foreground', + 'secondary-header', + 'tertiary-background', + 'tertiary-foreground', + 'tooltip', + 'status-online', + 'status-away', + 'status-focus', + 'status-busy', + 'status-streaming', + 'status-invisible', +] +ReviteThemeSettings = typing.TypedDict( + 'ReviteThemeSettings', + { + 'appearance:ligatures': typing.NotRequired[bool], + 'appearance:theme:base': typing.NotRequired[ReviteAppearanceTheme], + 'appearance:theme:css': typing.NotRequired[str], + 'appearance:theme:font': typing.NotRequired[ReviteAppearanceFont], + # Deprecated by `base` + # 'appearance:theme:light': typing.NotRequired[bool], + 'appearance:theme:monoFont': typing.NotRequired[ReviteAppearanceMonoFont], + 'appearance:theme:overrides': typing.NotRequired[dict[ReviteThemeVariable, str]], + }, +) + + +class ReviteUserSettingsPayload(typing.TypedDict): + # changelog: typing.NotRequired[ReviteChangelog] + # locale: typing.NotRequired[ReviteLocaleOptions] + # notifications: typing.NotRequired[ReviteNotificationOptions] + # ordering: typing.NotRequired[ReviteOrdering] + # appearance: typing.NotRequired[ReviteAppearanceSettings] + # theme: typing.NotRequired[ReviteThemeSettings] + changelog: typing.NotRequired[str] + locale: typing.NotRequired[str] + notifications: typing.NotRequired[str] + ordering: typing.NotRequired[str] + appearance: typing.NotRequired[str] + theme: typing.NotRequired[str] + + __all__ = ( - "UserSettings", - "OptionsFetchSettings", - "OptionsSetSettings", - "DataSetSettings", + 'UserSettings', + 'OptionsFetchSettings', + 'OptionsSetSettings', + 'DataSetSettings', + 'AndroidTheme', + 'AndroidProfilePictureShape', + 'AndroidMessageReplyStyle', + 'AndroidUserSettings', + 'ReviteChangelogEntryID', + 'ReviteChangelog', + 'ReviteLocaleOptions', + 'ReviteNotificationState', + 'ReviteNotificationOptions', + 'ReviteOrdering', + 'ReviteAppearanceEmojiPack', + 'ReviteAppearanceSettings', + 'ReviteAppearanceTheme', + 'ReviteAppearanceFont', + 'ReviteAppearanceMonoFont', + 'ReviteThemeVariable', + 'ReviteThemeSettings', + 'ReviteUserSettingsPayload', ) diff --git a/pyvolt/raw/users.py b/pyvolt/raw/users.py index 7d2ba82..008793e 100644 --- a/pyvolt/raw/users.py +++ b/pyvolt/raw/users.py @@ -1,134 +1,132 @@ from __future__ import annotations -import typing as t +import typing -from . import files +from .files import File -class User(t.TypedDict): +class User(typing.TypedDict): _id: str username: str discriminator: str - display_name: t.NotRequired[str] - avatar: t.NotRequired[files.File] - relations: t.NotRequired[list[Relationship]] - badges: t.NotRequired[int] - status: t.NotRequired[UserStatus] - profile: t.NotRequired[UserProfile] - flags: t.NotRequired[int] - privileged: t.NotRequired[bool] - bot: t.NotRequired[BotInformation] + display_name: typing.NotRequired[str] + avatar: typing.NotRequired[File] + relations: typing.NotRequired[list[Relationship]] + badges: typing.NotRequired[int] + status: typing.NotRequired[UserStatus] + profile: typing.NotRequired[UserProfile] + flags: typing.NotRequired[int] + privileged: typing.NotRequired[bool] + bot: typing.NotRequired[BotInformation] relationship: RelationshipStatus online: bool -class PartialUser(t.TypedDict): - username: t.NotRequired[str] - discriminator: t.NotRequired[str] - display_name: t.NotRequired[str] - avatar: t.NotRequired[files.File] - badges: t.NotRequired[int] - status: t.NotRequired[UserStatus] - # profile: t.NotRequired["UserProfile"] - flags: t.NotRequired[int] - online: t.NotRequired[bool] - - -FieldsUser = t.Literal[ - "Avatar", - "StatusText", - "StatusPresence", - "ProfileContent", - "ProfileBackground", - "DisplayName", -] -RelationshipStatus = t.Literal[ - "None", "User", "Friend", "Outgoing", "Incoming", "Blocked", "BlockedOther" +class PartialUser(typing.TypedDict): + username: typing.NotRequired[str] + discriminator: typing.NotRequired[str] + display_name: typing.NotRequired[str] + avatar: typing.NotRequired[File] + badges: typing.NotRequired[int] + status: typing.NotRequired[UserStatus] + # profile: typing.NotRequired[UserProfile] + flags: typing.NotRequired[int] + online: typing.NotRequired[bool] + + +FieldsUser = typing.Literal[ + 'Avatar', + 'StatusText', + 'StatusPresence', + 'ProfileContent', + 'ProfileBackground', + 'DisplayName', ] +RelationshipStatus = typing.Literal['None', 'User', 'Friend', 'Outgoing', 'Incoming', 'Blocked', 'BlockedOther'] -class Relationship(t.TypedDict): +class Relationship(typing.TypedDict): _id: str status: RelationshipStatus -Presence = t.Literal["Online", "Idle", "Focus", "Busy", "Invisible"] +Presence = typing.Literal['Online', 'Idle', 'Focus', 'Busy', 'Invisible'] -class UserStatus(t.TypedDict): - text: t.NotRequired[str] - presence: t.NotRequired[Presence] +class UserStatus(typing.TypedDict): + text: typing.NotRequired[str] + presence: typing.NotRequired[Presence] -class UserProfile(t.TypedDict): - content: t.NotRequired[str] - background: t.NotRequired[files.File] +class UserProfile(typing.TypedDict): + content: typing.NotRequired[str] + background: typing.NotRequired[File] -class DataUserProfile(t.TypedDict): - content: t.NotRequired[str] - background: t.NotRequired[str] +class DataUserProfile(typing.TypedDict): + content: typing.NotRequired[str] + background: typing.NotRequired[str] -class DataEditUser(t.TypedDict): - display_name: t.NotRequired[str] - avatar: t.NotRequired[str] - status: t.NotRequired[UserStatus] - profile: t.NotRequired[DataUserProfile] - badges: t.NotRequired[int] - flags: t.NotRequired[int] - remove: t.NotRequired[list[FieldsUser]] +class DataEditUser(typing.TypedDict): + display_name: typing.NotRequired[str] + avatar: typing.NotRequired[str] + status: typing.NotRequired[UserStatus] + profile: typing.NotRequired[DataUserProfile] + badges: typing.NotRequired[int] + flags: typing.NotRequired[int] + remove: typing.NotRequired[list[FieldsUser]] -class FlagResponse(t.TypedDict): +class FlagResponse(typing.TypedDict): flags: int -class MutualResponse(t.TypedDict): +class MutualResponse(typing.TypedDict): users: list[str] servers: list[str] -class BotInformation(t.TypedDict): +class BotInformation(typing.TypedDict): owner: str -class DataSendFriendRequest(t.TypedDict): +class DataSendFriendRequest(typing.TypedDict): username: str # delta types -class DataChangeUsername(t.TypedDict): +class DataChangeUsername(typing.TypedDict): username: str password: str -class DataHello(t.TypedDict): +class DataHello(typing.TypedDict): onboarding: bool -class DataOnboard(t.TypedDict): +class DataOnboard(typing.TypedDict): username: str __all__ = ( - "User", - "PartialUser", - "FieldsUser", - "RelationshipStatus", - "Relationship", - "Presence", - "UserStatus", - "UserProfile", - "DataUserProfile", - "DataEditUser", - "FlagResponse", - "MutualResponse", - "BotInformation", - "DataSendFriendRequest", - "DataChangeUsername", - "DataHello", - "DataOnboard", + 'User', + 'PartialUser', + 'FieldsUser', + 'RelationshipStatus', + 'Relationship', + 'Presence', + 'UserStatus', + 'UserProfile', + 'DataUserProfile', + 'DataEditUser', + 'FlagResponse', + 'MutualResponse', + 'BotInformation', + 'DataSendFriendRequest', + 'DataChangeUsername', + 'DataHello', + 'DataOnboard', ) diff --git a/pyvolt/read_state.py b/pyvolt/read_state.py index 50d5228..b070a96 100644 --- a/pyvolt/read_state.py +++ b/pyvolt/read_state.py @@ -1,11 +1,33 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field -import typing as t - -from . import core +import typing -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from .state import State @@ -13,21 +35,30 @@ class ReadState: """The channel read state.""" - state: "State" = field(repr=False, hash=False, kw_only=True, eq=False) + state: State = field(repr=False, hash=False, kw_only=True, eq=False) - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, hash=True, kw_only=True, eq=True) """The state channel ID.""" - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + user_id: str = field(repr=True, hash=True, kw_only=True, eq=True) """The current user ID.""" - last_message_id: core.ULID | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + last_message_id: str | None = field(repr=True, hash=True, kw_only=True, eq=True) """The ID of the last message read in this channel by current user.""" - mentioned_in: list[core.ULID] = field(repr=True, hash=True, kw_only=True, eq=True) + mentioned_in: list[str] = field(repr=True, hash=True, kw_only=True, eq=True) """The list of message IDs that mention the user.""" + def __hash__(self) -> int: + return hash((self.channel_id, self.user_id)) + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, ReadState) + and self.channel_id == other.channel_id + and self.user_id == self.user_id + ) + -__all__ = ("ReadState",) +__all__ = ('ReadState',) diff --git a/pyvolt/routes.py b/pyvolt/routes.py index f0caed3..f17b92a 100644 --- a/pyvolt/routes.py +++ b/pyvolt/routes.py @@ -1,391 +1,354 @@ -from typing import Any, Final, Literal +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + +import typing from urllib.parse import quote -Method = Literal["GET", "POST", "PATCH", "DELETE", "PUT"] +Method = typing.Literal['GET', 'POST', 'PATCH', 'DELETE', 'PUT'] class CompiledRoute: """Represents compiled Revolt API route.""" - route: "Route" - args: dict[str, Any] + route: Route + args: dict[str, typing.Any] - __slots__ = ("route", "args") + __slots__ = ('route', 'args') - def __init__(self, route: "Route", **args: Any) -> None: + def __init__(self, route: Route, **args: typing.Any) -> None: self.route = route self.args = args def __repr__(self) -> str: - return f"" + return f'' def __str__(self) -> str: - return f"CompiledRoute(route={self.route}, args={self.args})" + return f'CompiledRoute({self.route}, **{self.args!r})' def build(self) -> str: - return self.route.path.format( - **dict(map(lambda p: (p[0], quote(str(p[1]))), self.args.items())) - ) + return self.route.path.format(**dict(map(lambda p: (p[0], quote(str(p[1]))), self.args.items()))) class Route: """Represents Revolt API route.""" - method: Method - path: str - - __slots__ = ("method", "path") + __slots__ = ('method', 'path') def __init__(self, method: Method, path: str, /) -> None: - self.method = method - self.path = path + self.method: Method = method + self.path: str = path def __repr__(self) -> str: - return f"" + return f'' def __str__(self) -> str: - return f"Route({self.method!r}, {self.path!r})" + return f'{self.method} {self.path}' - def compile(self, **args: Any) -> CompiledRoute: + def compile(self, **args: typing.Any) -> CompiledRoute: """Compiles route.""" return CompiledRoute(self, **args) -ROOT: Final[Route] = Route("GET", "/") +ROOT: typing.Final[Route] = Route('GET', '/') -BOTS_CREATE: Final[Route] = Route("POST", "/bots/create") -BOTS_DELETE: Final[Route] = Route("DELETE", "/bots/{bot_id}") -BOTS_EDIT: Final[Route] = Route("PATCH", "/bots/{bot_id}") -BOTS_FETCH: Final[Route] = Route("GET", "/bots/{bot_id}") -BOTS_FETCH_OWNED: Final[Route] = Route("GET", "/bots/@me") -BOTS_FETCH_PUBLIC: Final[Route] = Route("GET", "/bots/{bot_id}/invite") -BOTS_INVITE: Final[Route] = Route("POST", "/bots/{bot_id}/invite") +BOTS_CREATE: typing.Final[Route] = Route('POST', '/bots/create') +BOTS_DELETE: typing.Final[Route] = Route('DELETE', '/bots/{bot_id}') +BOTS_EDIT: typing.Final[Route] = Route('PATCH', '/bots/{bot_id}') +BOTS_FETCH: typing.Final[Route] = Route('GET', '/bots/{bot_id}') +BOTS_FETCH_OWNED: typing.Final[Route] = Route('GET', '/bots/@me') +BOTS_FETCH_PUBLIC: typing.Final[Route] = Route('GET', '/bots/{bot_id}/invite') +BOTS_INVITE: typing.Final[Route] = Route('POST', '/bots/{bot_id}/invite') # Channels control -CHANNELS_CHANNEL_ACK: Final[Route] = Route( - "PUT", "/channels/{channel_id}/ack/{message_id}" -) -CHANNELS_CHANNEL_DELETE: Final[Route] = Route("DELETE", "/channels/{channel_id}") -CHANNELS_CHANNEL_EDIT: Final[Route] = Route("PATCH", "/channels/{channel_id}") -CHANNELS_CHANNEL_FETCH: Final[Route] = Route("GET", "/channels/{channel_id}") -CHANNELS_CHANNEL_PINS: Final[Route] = Route("GET", "/channels/{channel_id}/pins") -CHANNELS_GROUP_ADD_MEMBER: Final[Route] = Route( - "PUT", "/channels/{channel_id}/recipients/{user_id}" -) -CHANNELS_GROUP_CREATE: Final[Route] = Route("POST", "/channels/create") -CHANNELS_GROUP_REMOVE_MEMBER: Final[Route] = Route( - "DELETE", "/channels/{channel_id}/recipients/{member_id}" -) -CHANNELS_INVITE_CREATE: Final[Route] = Route("POST", "/channels/{channel_id}/invites") -CHANNELS_MEMBERS_FETCH: Final[Route] = Route("GET", "/channels/{channel_id}/members") -CHANNELS_MESSAGE_BULK_DELETE: Final[Route] = Route( - "DELETE", "/channels/{channel_id}/messages/bulk" -) -CHANNELS_MESSAGE_CLEAR_REACTIONS: Final[Route] = Route( - "DELETE", "/channels/{channel_id}/messages/{message_id}/reactions" -) -CHANNELS_MESSAGE_DELETE: Final[Route] = Route( - "DELETE", "/channels/{channel_id}/messages/{message_id}" -) -CHANNELS_MESSAGE_EDIT: Final[Route] = Route( - "PATCH", "/channels/{channel_id}/messages/{message_id}" -) -CHANNELS_MESSAGE_FETCH: Final[Route] = Route( - "GET", "/channels/{channel_id}/messages/{message_id}" -) -CHANNELS_MESSAGE_QUERY: Final[Route] = Route("GET", "/channels/{channel_id}/messages") -CHANNELS_MESSAGE_REACT: Final[Route] = Route( - "PUT", "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}" -) -CHANNELS_MESSAGE_PIN: Final[Route] = Route( - "POST", "/channels/{channel_id}/messages/{message_id}/pin" -) -CHANNELS_MESSAGE_SEND: Final[Route] = Route("POST", "/channels/{channel_id}/messages") -CHANNELS_MESSAGE_UNPIN: Final[Route] = Route( - "POST", "/channels/{channel_id}/messages/{message_id}/unpin" -) -CHANNELS_MESSAGE_SEARCH: Final[Route] = Route("POST", "/channels/{channel_id}/search") -CHANNELS_MESSAGE_UNREACT: Final[Route] = Route( - "DELETE", "/channels/{channel_id}/messages/{message_id}/reactions/{emoji}" -) -CHANNELS_PERMISSIONS_SET: Final[Route] = Route( - "PUT", "/channels/{channel_id}/permissions/{role_id}" -) -CHANNELS_PERMISSIONS_SET_DEFAULT: Final[Route] = Route( - "PUT", "/channels/{channel_id}/permissions/default" -) -CHANNELS_VOICE_JOIN: Final[Route] = Route("POST", "/channels/{channel_id}/join_call") -CHANNELS_WEBHOOK_CREATE: Final[Route] = Route("POST", "/channels/{channel_id}/webhooks") -CHANNELS_WEBHOOK_FETCH_ALL: Final[Route] = Route( - "GET", "/channels/{channel_id}/webhooks" -) +CHANNELS_CHANNEL_ACK: typing.Final[Route] = Route('PUT', '/channels/{channel_id}/ack/{message_id}') +CHANNELS_CHANNEL_DELETE: typing.Final[Route] = Route('DELETE', '/channels/{channel_id}') +CHANNELS_CHANNEL_EDIT: typing.Final[Route] = Route('PATCH', '/channels/{channel_id}') +CHANNELS_CHANNEL_FETCH: typing.Final[Route] = Route('GET', '/channels/{channel_id}') +CHANNELS_CHANNEL_PINS: typing.Final[Route] = Route('GET', '/channels/{channel_id}/pins') +CHANNELS_GROUP_ADD_MEMBER: typing.Final[Route] = Route('PUT', '/channels/{channel_id}/recipients/{user_id}') +CHANNELS_GROUP_CREATE: typing.Final[Route] = Route('POST', '/channels/create') +CHANNELS_GROUP_REMOVE_MEMBER: typing.Final[Route] = Route('DELETE', '/channels/{channel_id}/recipients/{user_id}') +CHANNELS_INVITE_CREATE: typing.Final[Route] = Route('POST', '/channels/{channel_id}/invites') +CHANNELS_MEMBERS_FETCH: typing.Final[Route] = Route('GET', '/channels/{channel_id}/members') +CHANNELS_MESSAGE_BULK_DELETE: typing.Final[Route] = Route('DELETE', '/channels/{channel_id}/messages/bulk') +CHANNELS_MESSAGE_CLEAR_REACTIONS: typing.Final[Route] = Route( + 'DELETE', '/channels/{channel_id}/messages/{message_id}/reactions' +) +CHANNELS_MESSAGE_DELETE: typing.Final[Route] = Route('DELETE', '/channels/{channel_id}/messages/{message_id}') +CHANNELS_MESSAGE_EDIT: typing.Final[Route] = Route('PATCH', '/channels/{channel_id}/messages/{message_id}') +CHANNELS_MESSAGE_FETCH: typing.Final[Route] = Route('GET', '/channels/{channel_id}/messages/{message_id}') +CHANNELS_MESSAGE_QUERY: typing.Final[Route] = Route('GET', '/channels/{channel_id}/messages') +CHANNELS_MESSAGE_REACT: typing.Final[Route] = Route( + 'PUT', '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}' +) +CHANNELS_MESSAGE_PIN: typing.Final[Route] = Route('POST', '/channels/{channel_id}/messages/{message_id}/pin') +CHANNELS_MESSAGE_SEND: typing.Final[Route] = Route('POST', '/channels/{channel_id}/messages') +CHANNELS_MESSAGE_UNPIN: typing.Final[Route] = Route('DELETE', '/channels/{channel_id}/messages/{message_id}/pin') +CHANNELS_MESSAGE_SEARCH: typing.Final[Route] = Route('POST', '/channels/{channel_id}/search') +CHANNELS_MESSAGE_UNREACT: typing.Final[Route] = Route( + 'DELETE', '/channels/{channel_id}/messages/{message_id}/reactions/{emoji}' +) +CHANNELS_PERMISSIONS_SET: typing.Final[Route] = Route('PUT', '/channels/{channel_id}/permissions/{role_id}') +CHANNELS_PERMISSIONS_SET_DEFAULT: typing.Final[Route] = Route('PUT', '/channels/{channel_id}/permissions/default') +CHANNELS_VOICE_JOIN: typing.Final[Route] = Route('POST', '/channels/{channel_id}/join_call') +CHANNELS_WEBHOOK_CREATE: typing.Final[Route] = Route('POST', '/channels/{channel_id}/webhooks') +CHANNELS_WEBHOOK_FETCH_ALL: typing.Final[Route] = Route('GET', '/channels/{channel_id}/webhooks') # Customization control (emojis) -CUSTOMISATION_EMOJI_CREATE: Final[Route] = Route("PUT", "/custom/emoji/{attachment_id}") -CUSTOMISATION_EMOJI_DELETE: Final[Route] = Route("DELETE", "/custom/emoji/{emoji_id}") -CUSTOMISATION_EMOJI_FETCH: Final[Route] = Route("GET", "/custom/emoji/{emoji_id}") +CUSTOMISATION_EMOJI_CREATE: typing.Final[Route] = Route('PUT', '/custom/emoji/{attachment_id}') +CUSTOMISATION_EMOJI_DELETE: typing.Final[Route] = Route('DELETE', '/custom/emoji/{emoji_id}') +CUSTOMISATION_EMOJI_FETCH: typing.Final[Route] = Route('GET', '/custom/emoji/{emoji_id}') # Invites control -INVITES_INVITE_DELETE: Final[Route] = Route("DELETE", "/invites/{invite_code}") -INVITES_INVITE_FETCH: Final[Route] = Route("GET", "/invites/{invite_code}") -INVITES_INVITE_JOIN: Final[Route] = Route("POST", "/invites/{invite_code}") +INVITES_INVITE_DELETE: typing.Final[Route] = Route('DELETE', '/invites/{invite_code}') +INVITES_INVITE_FETCH: typing.Final[Route] = Route('GET', '/invites/{invite_code}') +INVITES_INVITE_JOIN: typing.Final[Route] = Route('POST', '/invites/{invite_code}') # Onboarding control -ONBOARD_COMPLETE: Final[Route] = Route("POST", "/onboard/complete") -ONBOARD_HELLO: Final[Route] = Route("GET", "/onboard/hello") +ONBOARD_COMPLETE: typing.Final[Route] = Route('POST', '/onboard/complete') +ONBOARD_HELLO: typing.Final[Route] = Route('GET', '/onboard/hello') # Web Push subscription control -PUSH_SUBSCRIBE: Final[Route] = Route("POST", "/push/subscribe") -PUSH_UNSUBSCRIBE: Final[Route] = Route("POST", "/push/unsubscribe") +PUSH_SUBSCRIBE: typing.Final[Route] = Route('POST', '/push/subscribe') +PUSH_UNSUBSCRIBE: typing.Final[Route] = Route('POST', '/push/unsubscribe') # Safety control -SAFETY_REPORT_CONTENT: Final[Route] = Route("POST", "/safety/report") +SAFETY_REPORT_CONTENT: typing.Final[Route] = Route('POST', '/safety/report') # Servers control -SERVERS_BAN_CREATE: Final[Route] = Route("PUT", "/servers/{server_id}/bans/{user_id}") -SERVERS_BAN_LIST: Final[Route] = Route("GET", "/servers/{server_id}/bans") -SERVERS_BAN_REMOVE: Final[Route] = Route( - "DELETE", "/servers/{server_id}/bans/{user_id}" -) -SERVERS_CHANNEL_CREATE: Final[Route] = Route("POST", "/servers/{server_id}/channels") -SERVERS_EMOJI_LIST: Final[Route] = Route("GET", "/servers/{server_id}/emojis") -SERVERS_INVITES_FETCH: Final[Route] = Route("GET", "/servers/{server_id}/invites") -SERVERS_MEMBER_EDIT: Final[Route] = Route( - "PATCH", "/servers/{server_id}/members/{member_id}" -) -SERVERS_MEMBER_EXPERIMENTAL_QUERY: Final[Route] = Route( - "GET", "/servers/{server_id}/members_experimental_query" -) -SERVERS_MEMBER_FETCH: Final[Route] = Route( - "GET", "/servers/{server_id}/members/{member_id}" -) -SERVERS_MEMBER_FETCH_ALL: Final[Route] = Route("GET", "/servers/{server_id}/members") -SERVERS_MEMBER_REMOVE: Final[Route] = Route( - "DELETE", "/servers/{server_id}/members/{member_id}" -) -SERVERS_PERMISSIONS_SET: Final[Route] = Route( - "PUT", "/servers/{server_id}/permissions/{role_id}" -) -SERVERS_PERMISSIONS_SET_DEFAULT: Final[Route] = Route( - "PUT", "/servers/{server_id}/permissions/default" -) -SERVERS_ROLES_CREATE: Final[Route] = Route("POST", "/servers/{server_id}/roles") -SERVERS_ROLES_DELETE: Final[Route] = Route( - "DELETE", "/servers/{server_id}/roles/{role_id}" -) -SERVERS_ROLES_EDIT: Final[Route] = Route( - "PATCH", "/servers/{server_id}/roles/{role_id}" -) -SERVERS_ROLES_FETCH: Final[Route] = Route( - "GET", "/servers/{channel_id}/roles/{role_id}" -) -SERVERS_SERVER_ACK: Final[Route] = Route("PUT", "/servers/{server_id}/ack") -SERVERS_SERVER_CREATE: Final[Route] = Route("POST", "/servers/create") -SERVERS_SERVER_DELETE: Final[Route] = Route("DELETE", "/servers/{server_id}") -SERVERS_SERVER_EDIT: Final[Route] = Route("PATCH", "/servers/{server_id}") -SERVERS_SERVER_FETCH: Final[Route] = Route("GET", "/servers/{server_id}") +SERVERS_BAN_CREATE: typing.Final[Route] = Route('PUT', '/servers/{server_id}/bans/{user_id}') +SERVERS_BAN_LIST: typing.Final[Route] = Route('GET', '/servers/{server_id}/bans') +SERVERS_BAN_REMOVE: typing.Final[Route] = Route('DELETE', '/servers/{server_id}/bans/{user_id}') +SERVERS_CHANNEL_CREATE: typing.Final[Route] = Route('POST', '/servers/{server_id}/channels') +SERVERS_EMOJI_LIST: typing.Final[Route] = Route('GET', '/servers/{server_id}/emojis') +SERVERS_INVITES_FETCH: typing.Final[Route] = Route('GET', '/servers/{server_id}/invites') +SERVERS_MEMBER_EDIT: typing.Final[Route] = Route('PATCH', '/servers/{server_id}/members/{member_id}') +SERVERS_MEMBER_EXPERIMENTAL_QUERY: typing.Final[Route] = Route('GET', '/servers/{server_id}/members_experimental_query') +SERVERS_MEMBER_FETCH: typing.Final[Route] = Route('GET', '/servers/{server_id}/members/{member_id}') +SERVERS_MEMBER_FETCH_ALL: typing.Final[Route] = Route('GET', '/servers/{server_id}/members') +SERVERS_MEMBER_REMOVE: typing.Final[Route] = Route('DELETE', '/servers/{server_id}/members/{member_id}') +SERVERS_PERMISSIONS_SET: typing.Final[Route] = Route('PUT', '/servers/{server_id}/permissions/{role_id}') +SERVERS_PERMISSIONS_SET_DEFAULT: typing.Final[Route] = Route('PUT', '/servers/{server_id}/permissions/default') +SERVERS_ROLES_CREATE: typing.Final[Route] = Route('POST', '/servers/{server_id}/roles') +SERVERS_ROLES_DELETE: typing.Final[Route] = Route('DELETE', '/servers/{server_id}/roles/{role_id}') +SERVERS_ROLES_EDIT: typing.Final[Route] = Route('PATCH', '/servers/{server_id}/roles/{role_id}') +SERVERS_ROLES_FETCH: typing.Final[Route] = Route('GET', '/servers/{channel_id}/roles/{role_id}') +SERVERS_SERVER_ACK: typing.Final[Route] = Route('PUT', '/servers/{server_id}/ack') +SERVERS_SERVER_CREATE: typing.Final[Route] = Route('POST', '/servers/create') +SERVERS_SERVER_DELETE: typing.Final[Route] = Route('DELETE', '/servers/{server_id}') +SERVERS_SERVER_EDIT: typing.Final[Route] = Route('PATCH', '/servers/{server_id}') +SERVERS_SERVER_FETCH: typing.Final[Route] = Route('GET', '/servers/{server_id}') # Sync control -SYNC_GET_SETTINGS: Final[Route] = Route("POST", "/sync/settings/fetch") -SYNC_GET_UNREADS: Final[Route] = Route("GET", "/sync/unreads") -SYNC_SET_SETTINGS: Final[Route] = Route("POST", "/sync/settings/set") +SYNC_GET_SETTINGS: typing.Final[Route] = Route('POST', '/sync/settings/fetch') +SYNC_GET_UNREADS: typing.Final[Route] = Route('GET', '/sync/unreads') +SYNC_SET_SETTINGS: typing.Final[Route] = Route('POST', '/sync/settings/set') # Users control -USERS_ADD_FRIEND: Final[Route] = Route("PUT", "/users/{user_id}/friend") -USERS_BLOCK_USER: Final[Route] = Route("PUT", "/users/{user_id}/block") -USERS_CHANGE_USERNAME: Final[Route] = Route("PATCH", "/users/@me/username") -USERS_EDIT_SELF_USER: Final[Route] = Route("PATCH", "/users/@me") -USERS_EDIT_USER: Final[Route] = Route("PATCH", "/users/{user_id}") -USERS_FETCH_DMS: Final[Route] = Route("GET", "/users/dms") -USERS_FETCH_PROFILE: Final[Route] = Route("GET", "/users/{user_id}/profile") -USERS_FETCH_SELF: Final[Route] = Route("GET", "/users/@me") -USERS_FETCH_USER: Final[Route] = Route("GET", "/users/{user_id}") -USERS_FETCH_USER_FLAGS: Final[Route] = Route("GET", "/users/{user_id}/flags") -USERS_FIND_MUTUAL: Final[Route] = Route("GET", "/users/{user_id}/mutual") -USERS_GET_DEFAULT_AVATAR: Final[Route] = Route("GET", "/users/{user_id}/default_avatar") -USERS_OPEN_DM: Final[Route] = Route("GET", "/users/{user_id}/dm") -USERS_REMOVE_FRIEND: Final[Route] = Route("DELETE", "/users/{user_id}/friend") -USERS_SEND_FRIEND_REQUEST: Final[Route] = Route("POST", "/users/friend") -USERS_UNBLOCK_USER: Final[Route] = Route("DELETE", "/users/{user_id}/block") +USERS_ADD_FRIEND: typing.Final[Route] = Route('PUT', '/users/{user_id}/friend') +USERS_BLOCK_USER: typing.Final[Route] = Route('PUT', '/users/{user_id}/block') +USERS_CHANGE_USERNAME: typing.Final[Route] = Route('PATCH', '/users/@me/username') +USERS_EDIT_SELF_USER: typing.Final[Route] = Route('PATCH', '/users/@me') +USERS_EDIT_USER: typing.Final[Route] = Route('PATCH', '/users/{user_id}') +USERS_FETCH_DMS: typing.Final[Route] = Route('GET', '/users/dms') +USERS_FETCH_PROFILE: typing.Final[Route] = Route('GET', '/users/{user_id}/profile') +USERS_FETCH_SELF: typing.Final[Route] = Route('GET', '/users/@me') +USERS_FETCH_USER: typing.Final[Route] = Route('GET', '/users/{user_id}') +USERS_FETCH_USER_FLAGS: typing.Final[Route] = Route('GET', '/users/{user_id}/flags') +USERS_FIND_MUTUAL: typing.Final[Route] = Route('GET', '/users/{user_id}/mutual') +USERS_GET_DEFAULT_AVATAR: typing.Final[Route] = Route('GET', '/users/{user_id}/default_avatar') +USERS_OPEN_DM: typing.Final[Route] = Route('GET', '/users/{user_id}/dm') +USERS_REMOVE_FRIEND: typing.Final[Route] = Route('DELETE', '/users/{user_id}/friend') +USERS_SEND_FRIEND_REQUEST: typing.Final[Route] = Route('POST', '/users/friend') +USERS_UNBLOCK_USER: typing.Final[Route] = Route('DELETE', '/users/{user_id}/block') # Webhooks control -WEBHOOKS_WEBHOOK_DELETE: Final[Route] = Route("DELETE", "/webhooks/{webhook_id}") -WEBHOOKS_WEBHOOK_DELETE_TOKEN: Final[Route] = Route( - "DELETE", "/webhooks/{webhook_id}/{webhook_token}" -) -WEBHOOKS_WEBHOOK_EDIT: Final[Route] = Route("PATCH", "/webhooks/{webhook_id}") -WEBHOOKS_WEBHOOK_EDIT_TOKEN: Final[Route] = Route( - "PATCH", "/webhooks/{webhook_id}/{webhook_token}" -) -WEBHOOKS_WEBHOOK_EXECUTE: Final[Route] = Route( - "POST", "/webhooks/{webhook_id}/{webhook_token}" -) -WEBHOOKS_WEBHOOK_FETCH: Final[Route] = Route("GET", "/webhooks/{webhook_id}") -WEBHOOKS_WEBHOOK_FETCH_TOKEN: Final[Route] = Route( - "GET", "/webhooks/{webhook_id}/{webhook_token}" -) +WEBHOOKS_WEBHOOK_DELETE: typing.Final[Route] = Route('DELETE', '/webhooks/{webhook_id}') +WEBHOOKS_WEBHOOK_DELETE_TOKEN: typing.Final[Route] = Route('DELETE', '/webhooks/{webhook_id}/{webhook_token}') +WEBHOOKS_WEBHOOK_EDIT: typing.Final[Route] = Route('PATCH', '/webhooks/{webhook_id}') +WEBHOOKS_WEBHOOK_EDIT_TOKEN: typing.Final[Route] = Route('PATCH', '/webhooks/{webhook_id}/{webhook_token}') +WEBHOOKS_WEBHOOK_EXECUTE: typing.Final[Route] = Route('POST', '/webhooks/{webhook_id}/{webhook_token}') +WEBHOOKS_WEBHOOK_FETCH: typing.Final[Route] = Route('GET', '/webhooks/{webhook_id}') +WEBHOOKS_WEBHOOK_FETCH_TOKEN: typing.Final[Route] = Route('GET', '/webhooks/{webhook_id}/{webhook_token}') # Account Authentication -AUTH_ACCOUNT_CHANGE_EMAIL: Final[Route] = Route("PATCH", "/auth/account/change/email") -AUTH_ACCOUNT_CHANGE_PASSWORD: Final[Route] = Route( - "PATCH", "/auth/account/change/password" -) -AUTH_ACCOUNT_CONFIRM_DELETION: Final[Route] = Route("PUT", "/auth/account/delete") -AUTH_ACCOUNT_CREATE_ACCOUNT: Final[Route] = Route("POST", "/auth/account/create") -AUTH_ACCOUNT_DELETE_ACCOUNT: Final[Route] = Route("POST", "/auth/account/delete") -AUTH_ACCOUNT_DISABLE_ACCOUNT: Final[Route] = Route("POST", "/auth/account/disable") -AUTH_ACCOUNT_FETCH_ACCOUNT: Final[Route] = Route("GET", "/auth/account/") -AUTH_ACCOUNT_PASSWORD_RESET: Final[Route] = Route( - "PATCH", "/auth/account/reset_password" -) -AUTH_ACCOUNT_RESEND_VERIFICATION: Final[Route] = Route("POST", "/auth/account/reverify") -AUTH_ACCOUNT_SEND_PASSWORD_RESET: Final[Route] = Route( - "POST", "/auth/account/reset_password" -) -AUTH_ACCOUNT_VERIFY_EMAIL: Final[Route] = Route("POST", "/auth/account/verify/{code}") +AUTH_ACCOUNT_CHANGE_EMAIL: typing.Final[Route] = Route('PATCH', '/auth/account/change/email') +AUTH_ACCOUNT_CHANGE_PASSWORD: typing.Final[Route] = Route('PATCH', '/auth/account/change/password') +AUTH_ACCOUNT_CONFIRM_DELETION: typing.Final[Route] = Route('PUT', '/auth/account/delete') +AUTH_ACCOUNT_CREATE_ACCOUNT: typing.Final[Route] = Route('POST', '/auth/account/create') +AUTH_ACCOUNT_DELETE_ACCOUNT: typing.Final[Route] = Route('POST', '/auth/account/delete') +AUTH_ACCOUNT_DISABLE_ACCOUNT: typing.Final[Route] = Route('POST', '/auth/account/disable') +AUTH_ACCOUNT_FETCH_ACCOUNT: typing.Final[Route] = Route('GET', '/auth/account/') +AUTH_ACCOUNT_PASSWORD_RESET: typing.Final[Route] = Route('PATCH', '/auth/account/reset_password') +AUTH_ACCOUNT_RESEND_VERIFICATION: typing.Final[Route] = Route('POST', '/auth/account/reverify') +AUTH_ACCOUNT_SEND_PASSWORD_RESET: typing.Final[Route] = Route('POST', '/auth/account/reset_password') +AUTH_ACCOUNT_VERIFY_EMAIL: typing.Final[Route] = Route('POST', '/auth/account/verify/{code}') # MFA Authentication -AUTH_MFA_CREATE_TICKET: Final[Route] = Route("PUT", "/auth/mfa/ticket") -AUTH_MFA_FETCH_RECOVERY: Final[Route] = Route("POST", "/auth/mfa/recovery") -AUTH_MFA_FETCH_STATUS: Final[Route] = Route("GET", "/auth/mfa/") -AUTH_MFA_GENERATE_RECOVERY: Final[Route] = Route("PATCH", "/auth/mfa/recovery") -AUTH_MFA_GET_MFA_METHODS: Final[Route] = Route("GET", "/auth/mfa/methods") -AUTH_MFA_TOTP_DISABLE: Final[Route] = Route("DELETE", "/auth/mfa/totp") -AUTH_MFA_TOTP_ENABLE: Final[Route] = Route("PUT", "/auth/mfa/totp") -AUTH_MFA_TOTP_GENERATE_SECRET: Final[Route] = Route("POST", "/auth/mfa/totp") +AUTH_MFA_CREATE_TICKET: typing.Final[Route] = Route('PUT', '/auth/mfa/ticket') +AUTH_MFA_FETCH_RECOVERY: typing.Final[Route] = Route('POST', '/auth/mfa/recovery') +AUTH_MFA_FETCH_STATUS: typing.Final[Route] = Route('GET', '/auth/mfa/') +AUTH_MFA_GENERATE_RECOVERY: typing.Final[Route] = Route('PATCH', '/auth/mfa/recovery') +AUTH_MFA_GET_MFA_METHODS: typing.Final[Route] = Route('GET', '/auth/mfa/methods') +AUTH_MFA_TOTP_DISABLE: typing.Final[Route] = Route('DELETE', '/auth/mfa/totp') +AUTH_MFA_TOTP_ENABLE: typing.Final[Route] = Route('PUT', '/auth/mfa/totp') +AUTH_MFA_TOTP_GENERATE_SECRET: typing.Final[Route] = Route('POST', '/auth/mfa/totp') # Session Authentication -AUTH_SESSION_EDIT: Final[Route] = Route("PATCH", "/auth/session/{session_id}") -AUTH_SESSION_FETCH_ALL: Final[Route] = Route("GET", "/auth/session/all") -AUTH_SESSION_LOGIN: Final[Route] = Route("POST", "/auth/session/login") -AUTH_SESSION_LOGOUT: Final[Route] = Route("POST", "/auth/session/logout") -AUTH_SESSION_REVOKE: Final[Route] = Route("DELETE", "/auth/session/{session_id}") -AUTH_SESSION_REVOKE_ALL: Final[Route] = Route("DELETE", "/auth/session/all") +AUTH_SESSION_EDIT: typing.Final[Route] = Route('PATCH', '/auth/session/{session_id}') +AUTH_SESSION_FETCH_ALL: typing.Final[Route] = Route('GET', '/auth/session/all') +AUTH_SESSION_LOGIN: typing.Final[Route] = Route('POST', '/auth/session/login') +AUTH_SESSION_LOGOUT: typing.Final[Route] = Route('POST', '/auth/session/logout') +AUTH_SESSION_REVOKE: typing.Final[Route] = Route('DELETE', '/auth/session/{session_id}') +AUTH_SESSION_REVOKE_ALL: typing.Final[Route] = Route('DELETE', '/auth/session/all') __all__ = ( - "Method", - "CompiledRoute", - "Route", - "ROOT", - "BOTS_CREATE", - "BOTS_DELETE", - "BOTS_EDIT", - "BOTS_FETCH", - "BOTS_FETCH_OWNED", - "BOTS_FETCH_PUBLIC", - "BOTS_INVITE", - "CHANNELS_CHANNEL_ACK", - "CHANNELS_CHANNEL_DELETE", - "CHANNELS_CHANNEL_EDIT", - "CHANNELS_CHANNEL_FETCH", - "CHANNELS_CHANNEL_PINS", - "CHANNELS_GROUP_ADD_MEMBER", - "CHANNELS_GROUP_CREATE", - "CHANNELS_GROUP_REMOVE_MEMBER", - "CHANNELS_INVITE_CREATE", - "CHANNELS_MEMBERS_FETCH", - "CHANNELS_MESSAGE_BULK_DELETE", - "CHANNELS_MESSAGE_CLEAR_REACTIONS", - "CHANNELS_MESSAGE_DELETE", - "CHANNELS_MESSAGE_EDIT", - "CHANNELS_MESSAGE_FETCH", - "CHANNELS_MESSAGE_QUERY", - "CHANNELS_MESSAGE_REACT", - "CHANNELS_MESSAGE_PIN", - "CHANNELS_MESSAGE_SEND", - "CHANNELS_MESSAGE_UNPIN", - "CHANNELS_MESSAGE_SEARCH", - "CHANNELS_MESSAGE_UNREACT", - "CHANNELS_PERMISSIONS_SET", - "CHANNELS_PERMISSIONS_SET_DEFAULT", - "CHANNELS_VOICE_JOIN", - "CHANNELS_WEBHOOK_CREATE", - "CHANNELS_WEBHOOK_FETCH_ALL", - "CUSTOMISATION_EMOJI_CREATE", - "CUSTOMISATION_EMOJI_DELETE", - "CUSTOMISATION_EMOJI_FETCH", - "INVITES_INVITE_DELETE", - "INVITES_INVITE_FETCH", - "INVITES_INVITE_JOIN", - "ONBOARD_COMPLETE", - "ONBOARD_HELLO", - "PUSH_SUBSCRIBE", - "PUSH_UNSUBSCRIBE", - "SAFETY_REPORT_CONTENT", - "SERVERS_BAN_CREATE", - "SERVERS_BAN_LIST", - "SERVERS_BAN_REMOVE", - "SERVERS_CHANNEL_CREATE", - "SERVERS_EMOJI_LIST", - "SERVERS_INVITES_FETCH", - "SERVERS_MEMBER_EDIT", - "SERVERS_MEMBER_EXPERIMENTAL_QUERY", - "SERVERS_MEMBER_FETCH", - "SERVERS_MEMBER_FETCH_ALL", - "SERVERS_MEMBER_REMOVE", - "SERVERS_PERMISSIONS_SET", - "SERVERS_PERMISSIONS_SET_DEFAULT", - "SERVERS_ROLES_CREATE", - "SERVERS_ROLES_DELETE", - "SERVERS_ROLES_EDIT", - "SERVERS_ROLES_FETCH", - "SERVERS_SERVER_ACK", - "SERVERS_SERVER_CREATE", - "SERVERS_SERVER_DELETE", - "SERVERS_SERVER_EDIT", - "SERVERS_SERVER_FETCH", - "SYNC_GET_SETTINGS", - "SYNC_GET_UNREADS", - "SYNC_SET_SETTINGS", - "USERS_ADD_FRIEND", - "USERS_BLOCK_USER", - "USERS_CHANGE_USERNAME", - "USERS_EDIT_SELF_USER", - "USERS_EDIT_USER", - "USERS_FETCH_DMS", - "USERS_FETCH_PROFILE", - "USERS_FETCH_SELF", - "USERS_FETCH_USER", - "USERS_FETCH_USER_FLAGS", - "USERS_FIND_MUTUAL", - "USERS_GET_DEFAULT_AVATAR", - "USERS_OPEN_DM", - "USERS_REMOVE_FRIEND", - "USERS_SEND_FRIEND_REQUEST", - "USERS_UNBLOCK_USER", - "WEBHOOKS_WEBHOOK_DELETE", - "WEBHOOKS_WEBHOOK_DELETE_TOKEN", - "WEBHOOKS_WEBHOOK_EDIT", - "WEBHOOKS_WEBHOOK_EDIT_TOKEN", - "WEBHOOKS_WEBHOOK_EXECUTE", - "WEBHOOKS_WEBHOOK_FETCH", - "WEBHOOKS_WEBHOOK_FETCH_TOKEN", - "AUTH_ACCOUNT_CHANGE_EMAIL", - "AUTH_ACCOUNT_CHANGE_PASSWORD", - "AUTH_ACCOUNT_CONFIRM_DELETION", - "AUTH_ACCOUNT_CREATE_ACCOUNT", - "AUTH_ACCOUNT_DELETE_ACCOUNT", - "AUTH_ACCOUNT_DISABLE_ACCOUNT", - "AUTH_ACCOUNT_FETCH_ACCOUNT", - "AUTH_ACCOUNT_PASSWORD_RESET", - "AUTH_ACCOUNT_RESEND_VERIFICATION", - "AUTH_ACCOUNT_SEND_PASSWORD_RESET", - "AUTH_ACCOUNT_VERIFY_EMAIL", - "AUTH_MFA_CREATE_TICKET", - "AUTH_MFA_FETCH_RECOVERY", - "AUTH_MFA_FETCH_STATUS", - "AUTH_MFA_GENERATE_RECOVERY", - "AUTH_MFA_GET_MFA_METHODS", - "AUTH_MFA_TOTP_DISABLE", - "AUTH_MFA_TOTP_ENABLE", - "AUTH_MFA_TOTP_GENERATE_SECRET", - "AUTH_SESSION_EDIT", - "AUTH_SESSION_FETCH_ALL", - "AUTH_SESSION_LOGIN", - "AUTH_SESSION_LOGOUT", - "AUTH_SESSION_REVOKE", - "AUTH_SESSION_REVOKE_ALL", + 'Method', + 'CompiledRoute', + 'Route', + 'ROOT', + 'BOTS_CREATE', + 'BOTS_DELETE', + 'BOTS_EDIT', + 'BOTS_FETCH', + 'BOTS_FETCH_OWNED', + 'BOTS_FETCH_PUBLIC', + 'BOTS_INVITE', + 'CHANNELS_CHANNEL_ACK', + 'CHANNELS_CHANNEL_DELETE', + 'CHANNELS_CHANNEL_EDIT', + 'CHANNELS_CHANNEL_FETCH', + 'CHANNELS_CHANNEL_PINS', + 'CHANNELS_GROUP_ADD_MEMBER', + 'CHANNELS_GROUP_CREATE', + 'CHANNELS_GROUP_REMOVE_MEMBER', + 'CHANNELS_INVITE_CREATE', + 'CHANNELS_MEMBERS_FETCH', + 'CHANNELS_MESSAGE_BULK_DELETE', + 'CHANNELS_MESSAGE_CLEAR_REACTIONS', + 'CHANNELS_MESSAGE_DELETE', + 'CHANNELS_MESSAGE_EDIT', + 'CHANNELS_MESSAGE_FETCH', + 'CHANNELS_MESSAGE_QUERY', + 'CHANNELS_MESSAGE_REACT', + 'CHANNELS_MESSAGE_PIN', + 'CHANNELS_MESSAGE_SEND', + 'CHANNELS_MESSAGE_UNPIN', + 'CHANNELS_MESSAGE_SEARCH', + 'CHANNELS_MESSAGE_UNREACT', + 'CHANNELS_PERMISSIONS_SET', + 'CHANNELS_PERMISSIONS_SET_DEFAULT', + 'CHANNELS_VOICE_JOIN', + 'CHANNELS_WEBHOOK_CREATE', + 'CHANNELS_WEBHOOK_FETCH_ALL', + 'CUSTOMISATION_EMOJI_CREATE', + 'CUSTOMISATION_EMOJI_DELETE', + 'CUSTOMISATION_EMOJI_FETCH', + 'INVITES_INVITE_DELETE', + 'INVITES_INVITE_FETCH', + 'INVITES_INVITE_JOIN', + 'ONBOARD_COMPLETE', + 'ONBOARD_HELLO', + 'PUSH_SUBSCRIBE', + 'PUSH_UNSUBSCRIBE', + 'SAFETY_REPORT_CONTENT', + 'SERVERS_BAN_CREATE', + 'SERVERS_BAN_LIST', + 'SERVERS_BAN_REMOVE', + 'SERVERS_CHANNEL_CREATE', + 'SERVERS_EMOJI_LIST', + 'SERVERS_INVITES_FETCH', + 'SERVERS_MEMBER_EDIT', + 'SERVERS_MEMBER_EXPERIMENTAL_QUERY', + 'SERVERS_MEMBER_FETCH', + 'SERVERS_MEMBER_FETCH_ALL', + 'SERVERS_MEMBER_REMOVE', + 'SERVERS_PERMISSIONS_SET', + 'SERVERS_PERMISSIONS_SET_DEFAULT', + 'SERVERS_ROLES_CREATE', + 'SERVERS_ROLES_DELETE', + 'SERVERS_ROLES_EDIT', + 'SERVERS_ROLES_FETCH', + 'SERVERS_SERVER_ACK', + 'SERVERS_SERVER_CREATE', + 'SERVERS_SERVER_DELETE', + 'SERVERS_SERVER_EDIT', + 'SERVERS_SERVER_FETCH', + 'SYNC_GET_SETTINGS', + 'SYNC_GET_UNREADS', + 'SYNC_SET_SETTINGS', + 'USERS_ADD_FRIEND', + 'USERS_BLOCK_USER', + 'USERS_CHANGE_USERNAME', + 'USERS_EDIT_SELF_USER', + 'USERS_EDIT_USER', + 'USERS_FETCH_DMS', + 'USERS_FETCH_PROFILE', + 'USERS_FETCH_SELF', + 'USERS_FETCH_USER', + 'USERS_FETCH_USER_FLAGS', + 'USERS_FIND_MUTUAL', + 'USERS_GET_DEFAULT_AVATAR', + 'USERS_OPEN_DM', + 'USERS_REMOVE_FRIEND', + 'USERS_SEND_FRIEND_REQUEST', + 'USERS_UNBLOCK_USER', + 'WEBHOOKS_WEBHOOK_DELETE', + 'WEBHOOKS_WEBHOOK_DELETE_TOKEN', + 'WEBHOOKS_WEBHOOK_EDIT', + 'WEBHOOKS_WEBHOOK_EDIT_TOKEN', + 'WEBHOOKS_WEBHOOK_EXECUTE', + 'WEBHOOKS_WEBHOOK_FETCH', + 'WEBHOOKS_WEBHOOK_FETCH_TOKEN', + 'AUTH_ACCOUNT_CHANGE_EMAIL', + 'AUTH_ACCOUNT_CHANGE_PASSWORD', + 'AUTH_ACCOUNT_CONFIRM_DELETION', + 'AUTH_ACCOUNT_CREATE_ACCOUNT', + 'AUTH_ACCOUNT_DELETE_ACCOUNT', + 'AUTH_ACCOUNT_DISABLE_ACCOUNT', + 'AUTH_ACCOUNT_FETCH_ACCOUNT', + 'AUTH_ACCOUNT_PASSWORD_RESET', + 'AUTH_ACCOUNT_RESEND_VERIFICATION', + 'AUTH_ACCOUNT_SEND_PASSWORD_RESET', + 'AUTH_ACCOUNT_VERIFY_EMAIL', + 'AUTH_MFA_CREATE_TICKET', + 'AUTH_MFA_FETCH_RECOVERY', + 'AUTH_MFA_FETCH_STATUS', + 'AUTH_MFA_GENERATE_RECOVERY', + 'AUTH_MFA_GET_MFA_METHODS', + 'AUTH_MFA_TOTP_DISABLE', + 'AUTH_MFA_TOTP_ENABLE', + 'AUTH_MFA_TOTP_GENERATE_SECRET', + 'AUTH_SESSION_EDIT', + 'AUTH_SESSION_FETCH_ALL', + 'AUTH_SESSION_LOGIN', + 'AUTH_SESSION_LOGOUT', + 'AUTH_SESSION_REVOKE', + 'AUTH_SESSION_REVOKE_ALL', ) diff --git a/pyvolt/safety_reports.py b/pyvolt/safety_reports.py deleted file mode 100644 index 35aa422..0000000 --- a/pyvolt/safety_reports.py +++ /dev/null @@ -1,75 +0,0 @@ -from enum import StrEnum - - -class ContentReportReason(StrEnum): - """The reason for reporting content (message or server).""" - - NONE = "NoneSpecified" - """No reason has been specified.""" - - ILLEGAL = "Illegal" - """Illegal content catch-all reason.""" - - ILLEGAL_GOODS = "IllegalGoods" - """Selling or facilitating use of drugs or other illegal goods.""" - - ILLEGAL_EXTORTION = "IllegalExtortion" - """Extortion or blackmail.""" - - ILLEGAL_PORNOGRAPHY = "IllegalPornography" - """Revenge or child pornography.""" - - ILLEGAL_HACKING = "IllegalHacking" - """Illegal hacking activity.""" - - EXTREME_VIOLENCE = "ExtremeViolence" - """Extreme violence, gore, or animal cruelty. With exception to violence potrayed in media / creative arts.""" - - PROMOTES_HARM = "PromotesHarm" - """Content that promotes harm to others / self.""" - - UNSOLICITED_SPAM = "UnsolicitedSpam" - """Unsolicited advertisements.""" - - RAID = "Raid" - """This is a raid.""" - - SPAM_ABUSE = "SpamAbuse" - """Spam or platform abuse.""" - - SCAMS_FRAUD = "ScamsFraud" - """Scams or fraud.""" - - MALWARE = "Malware" - """Distribution of malware or malicious links.""" - - HARASSMENT = "Harassment" - """Harassment or abuse targeted at another user.""" - - -class UserReportReason(StrEnum): - """Reason for reporting a user.""" - - NONE = "NoneSpecified" - """No reason has been specified.""" - - UNSOLICITED_SPAM = "UnsolicitedSpam" - """Unsolicited advertisements.""" - - SPAM_ABUSE = "SpamAbuse" - """User is sending spam or otherwise abusing the platform.""" - - INAPPROPRIATE_PROFILE = "InappropriateProfile" - """User's profile contains inappropriate content for a general audience.""" - - IMPERSONATION = "Impersonation" - """User is impersonating another user.""" - - BAN_EVASION = "BanEvasion" - """User is evading a ban.""" - - UNDERAGE = "Underage" - """User is not of minimum age to use the platform.""" - - -__all__ = ("ContentReportReason", "UserReportReason") diff --git a/pyvolt/server.py b/pyvolt/server.py index edca0d5..506a684 100644 --- a/pyvolt/server.py +++ b/pyvolt/server.py @@ -1,137 +1,190 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field -from datetime import datetime -from enum import IntFlag -import typing as t +from collections.abc import Mapping +from datetime import datetime, timedelta +import typing from . import ( - base, cache as caching, - cdn, - core, - errors, - permissions as permissions_, - safety_reports, - state, - user as users, utils, ) +from .base import Base +from .bot import BaseBot +from .cdn import StatelessAsset, Asset, ResolvableResource +from .core import ( + UNDEFINED, + UndefinedOr, + ULIDOr, + resolve_id, +) +from .emoji import ServerEmoji +from .enums import ChannelType, ContentReportReason, RelationshipStatus +from .errors import NoData +from .flags import Permissions, ServerFlags, UserBadges, UserFlags +from .permissions import Permissions, PermissionOverride +from .state import State +from .user import ( + UserStatus, + BaseUser, + DisplayUser, + BotUserInfo, + User, +) -if t.TYPE_CHECKING: - from . import ( - channel as channels, - raw, - ) - - -class ServerFlags(IntFlag): - NONE = 0 - - VERIFIED = 1 << 0 - """Whether the server is verified.""" - OFFICIAL = 1 << 1 - """Whether the server is ran by Revolt team.""" +if typing.TYPE_CHECKING: + from . import raw + from .channel import ( + TextChannel, + ServerTextChannel, + VoiceChannel, + ServerChannel, + ) class Category: - """Representation of channel category on Revolt server.""" - - id: core.ULID - """Unique ID for this category.""" + """Represents a category containing channels in Revolt server. - title: str - """Title for this category.""" + Attributes + ---------- + id: :class:`str` + The category's ID. + title: :class:`str` + The category's title. + channels: List[:class:`str`] + The IDs of channels in this category. + """ - channels: list[core.ULID] - """Channel in this category.""" - - __slots__ = ("id", "title", "channels") + __slots__ = ('id', 'title', 'channels') def __init__( self, - id: core.ResolvableULID, + id: ULIDOr[Category], title: str, - channels: list[core.ResolvableULID], + channels: list[ULIDOr[ServerChannel]], ) -> None: - self.id = core.resolve_ulid(id) + self.id = resolve_id(id) self.title = title - self.channels = [core.resolve_ulid(channel) for channel in channels] + self.channels = [resolve_id(channel) for channel in channels] + + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, Category) and self.id == other.id def build(self) -> raw.Category: return { - "id": self.id, - "title": self.title, - "channels": t.cast("list[str]", self.channels), + 'id': self.id, + 'title': self.title, + 'channels': self.channels, } class SystemMessageChannels: - """System message channel assignments.""" - - user_joined: core.ULID | None - """ID of channel to send user join messages in.""" - - user_left: core.ULID | None - """ID of channel to send user left messages in.""" - - user_kicked: core.ULID | None - """ID of channel to send user kicked messages in.""" - - user_banned: core.ULID | None - """ID of channel to send user banned messages in.""" - - __slots__ = ("user_joined", "user_left", "user_kicked", "user_banned") + """Represents system message channel assignments in a Revolt server. + + Attributes + ---------- + user_joined: Optional[:class:`str`] + The ID of channel to send user join messages in. + user_left: Optional[:class:`str`] + The ID of channel to send user left messages in. + user_kicked: Optional[:class:`str`] + The ID of channel to send user kicked messages in. + user_banned: Optional[:class:`str`] + The ID of channel to send user banned messages in. + """ + + __slots__ = ('user_joined', 'user_left', 'user_kicked', 'user_banned') def __init__( self, *, - user_joined: core.ResolvableULID | None = None, - user_left: core.ResolvableULID | None = None, - user_kicked: core.ResolvableULID | None = None, - user_banned: core.ResolvableULID | None = None, + user_joined: ULIDOr[TextChannel] | None = None, + user_left: ULIDOr[TextChannel] | None = None, + user_kicked: ULIDOr[TextChannel] | None = None, + user_banned: ULIDOr[TextChannel] | None = None, ) -> None: - self.user_joined = ( - None if user_joined is None else core.resolve_ulid(user_joined) - ) - self.user_left = None if user_left is None else core.resolve_ulid(user_left) - self.user_kicked = ( - None if user_kicked is None else core.resolve_ulid(user_kicked) - ) - self.user_banned = ( - None if user_banned is None else core.resolve_ulid(user_banned) + self.user_joined = None if user_joined is None else resolve_id(user_joined) + self.user_left = None if user_left is None else resolve_id(user_left) + self.user_kicked = None if user_kicked is None else resolve_id(user_kicked) + self.user_banned = None if user_banned is None else resolve_id(user_banned) + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, SystemMessageChannels) + and ( + self.user_joined == other.user_joined + and self.user_left == other.user_left + and self.user_kicked == other.user_kicked + and self.user_banned == other.user_banned + ) ) def build(self) -> raw.SystemMessageChannels: - d: raw.SystemMessageChannels = {} + payload: raw.SystemMessageChannels = {} if self.user_joined is not None: - d["user_joined"] = self.user_joined + payload['user_joined'] = self.user_joined if self.user_left is not None: - d["user_left"] = self.user_left + payload['user_left'] = self.user_left if self.user_kicked is not None: - d["user_kicked"] = self.user_kicked + payload['user_kicked'] = self.user_kicked if self.user_banned is not None: - d["user_banned"] = self.user_banned - return d + payload['user_banned'] = self.user_banned + return payload -@define() -class BaseRole(base.Base): +@define(slots=True) +class BaseRole(Base): """Base representation of a server role.""" - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) + + def __hash__(self) -> int: + return hash((self.server_id, self.id)) + + def __eq__(self, other: object) -> bool: + return ( + self is other or isinstance(other, BaseRole) and (self.id == other.id and self.server_id == other.server_id) + ) async def delete(self) -> None: """|coro| - Delete a server role. + Deletes the role. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete the role. - :class:`APIError` + HTTPException Deleting the role failed. """ return await self.state.http.delete_role(self.server_id, self.id) @@ -139,42 +192,52 @@ async def delete(self) -> None: async def edit( self, *, - name: core.UndefinedOr[str] = core.UNDEFINED, - colour: core.UndefinedOr[str | None] = core.UNDEFINED, - hoist: core.UndefinedOr[bool] = core.UNDEFINED, - rank: core.UndefinedOr[int] = core.UNDEFINED, + name: UndefinedOr[str] = UNDEFINED, + colour: UndefinedOr[str | None] = UNDEFINED, + hoist: UndefinedOr[bool] = UNDEFINED, + rank: UndefinedOr[int] = UNDEFINED, ) -> Role: """|coro| - Edit a role. + Edits the role. Parameters ---------- name: :class:`UndefinedOr`[:class:`str`] New role name. Should be between 1 and 32 chars long. - colour: :class:`UndefinedOr`[:class:`str` | `None`] - New role colour. + colour: :class:`UndefinedOr`[Optional[:class:`str`]] + New role colour. This should be valid CSS colour. hoist: :class:`UndefinedOr`[:class:`bool`] Whether this role should be displayed separately. rank: :class:`UndefinedOr`[:class:`int`] - Ranking position. Smaller values take priority. + The new ranking position. Smaller values take priority. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the role. - :class:`APIError` + HTTPException Editing the role failed. + + Returns + ------- + :class:`Role` + The newly updated role. """ return await self.state.http.edit_role( - self.server_id, self.id, name=name, colour=colour, hoist=hoist, rank=rank + self.server_id, + self.id, + name=name, + colour=colour, + hoist=hoist, + rank=rank, ) async def set_permissions( self, *, - allow: permissions_.Permissions = permissions_.Permissions.NONE, - deny: permissions_.Permissions = permissions_.Permissions.NONE, + allow: Permissions = Permissions.NONE, + deny: Permissions = Permissions.NONE, ) -> Server: """|coro| @@ -182,55 +245,49 @@ async def set_permissions( Parameters ---------- - allow: :class:`permissions_.Permissions` + allow: :class:`Permissions` New allow bit flags. - deny: :class:`permissions_.Permissions` + deny: :class:`Permissions` New disallow bit flags. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set role permissions on the server. - :class:`APIError` + HTTPException Setting permissions failed. """ - return await self.state.http.set_role_server_permissions( - self.server_id, self.id, allow=allow, deny=deny - ) + return await self.state.http.set_server_permissions_for_role(self.server_id, self.id, allow=allow, deny=deny) @define(slots=True) class PartialRole(BaseRole): """Partial representation of a server role.""" - name: core.UndefinedOr[str] = field(repr=True, hash=True, kw_only=True, eq=True) + name: UndefinedOr[str] = field(repr=True, kw_only=True) """The new role name.""" - permissions: core.UndefinedOr[permissions_.PermissionOverride] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + permissions: UndefinedOr[PermissionOverride] = field(repr=True, kw_only=True) """The permissions available to this role.""" - colour: core.UndefinedOr[str | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + colour: UndefinedOr[str | None] = field(repr=True, kw_only=True) """New colour used for this. This can be any valid CSS colour.""" - hoist: core.UndefinedOr[bool] = field(repr=True, hash=True, kw_only=True, eq=True) + hoist: UndefinedOr[bool] = field(repr=True, kw_only=True) """Whether this role should be shown separately on the member sidebar.""" - rank: core.UndefinedOr[int] = field(repr=True, hash=True, kw_only=True, eq=True) + rank: UndefinedOr[int] = field(repr=True, kw_only=True) """New ranking of this role.""" def into_full(self) -> Role | None: - """Tries transform this partial role into full object. This is useful when caching role.""" + """Optional[:class:`Role`]: Tries transform this partial role into full object. This is useful when caching role.""" if ( - core.is_defined(self.name) - and core.is_defined(self.permissions) - and core.is_defined(self.hoist) - and core.is_defined(self.rank) + self.name is not UNDEFINED + and self.permissions is not UNDEFINED + and self.hoist is not UNDEFINED + and self.rank is not UNDEFINED ): - colour = None if not core.is_defined(self.colour) else self.colour + colour = None if not self.colour is not UNDEFINED else self.colour return Role( state=self.state, id=self.id, @@ -247,43 +304,95 @@ def into_full(self) -> Role | None: class Role(BaseRole): """Representation of a server role.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """Role name.""" - permissions: permissions_.PermissionOverride = field( - repr=True, hash=True, kw_only=True, eq=True - ) + permissions: PermissionOverride = field(repr=True, kw_only=True) """Permissions available to this role.""" - colour: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + colour: str | None = field(repr=True, kw_only=True) """Colour used for this. This can be any valid CSS colour.""" - hoist: bool = field(repr=True, hash=True, kw_only=True, eq=True) + hoist: bool = field(repr=True, kw_only=True) """Whether this role should be shown separately on the member sidebar.""" - rank: int = field(repr=True, hash=True, kw_only=True, eq=True) + rank: int = field(repr=True, kw_only=True) """Ranking of this role.""" def _update(self, data: PartialRole) -> None: - if core.is_defined(data.name): + if data.name is not UNDEFINED: self.name = data.name - if core.is_defined(data.permissions): + if data.permissions is not UNDEFINED: self.permissions = data.permissions - if core.is_defined(data.colour): + if data.colour is not UNDEFINED: self.colour = data.colour - if core.is_defined(data.hoist): + if data.hoist is not UNDEFINED: self.hoist = data.hoist - if core.is_defined(data.rank): + if data.rank is not UNDEFINED: self.rank = data.rank @define(slots=True) -class BaseServer(base.Base): +class BaseServer(Base): """Base representation of a server on Revolt.""" + @property + def emojis(self) -> Mapping[str, ServerEmoji]: + """Mapping[:class:`str`, :class:`ServerEmoji`]: Returns all emojis of this server.""" + cache = self.state.cache + if cache: + return cache.get_server_emojis_mapping_of(self.id, caching._USER_REQUEST) or {} + return {} + + @property + def members(self) -> Mapping[str, Member]: + """Mapping[:class:`str`, :class:`Member`]: Returns all members of this server.""" + cache = self.state.cache + if cache: + return cache.get_server_members_mapping_of(self.id, caching._USER_REQUEST) or {} + return {} + + def get_emoji(self, emoji_id: str, /) -> ServerEmoji | None: + """Retrieves a server emoji from cache. + + Parameters + ---------- + emoji_id: :class:`str` + The emoji ID. + + Returns + ------- + Optional[:class:`ServerEmoji`] + The emoji or ``None`` if not found. + """ + cache = self.state.cache + if not cache: + return + emoji = cache.get_emoji(emoji_id, caching._USER_REQUEST) + if emoji and isinstance(emoji, ServerEmoji) and emoji.server_id == self.id: + return emoji + + def get_member(self, user_id: str, /) -> Member | None: + """Retrieves a server member from cache. + + Parameters + ---------- + user_id: :class:`str` + The user ID. + + Returns + ------- + Optional[:class:`Member`] + The member or ``None`` if not found. + """ + cache = self.state.cache + if not cache: + return + return cache.get_server_member(self.id, user_id, caching._USER_REQUEST) + async def add_bot( self, - bot: core.ResolvableULID, + bot: ULIDOr[BaseBot | BaseUser], ) -> None: """|coro| @@ -291,66 +400,83 @@ async def add_bot( """ return await self.state.http.invite_bot(bot, server=self.id) + async def ban(self, user: str | BaseUser | BaseMember, *, reason: str | None = None) -> Ban: + """|coro| + + Ban a user. + + Parameters + ---------- + user: Union[:class:`str`, :class:`BaseUser`, :class:`BaseMember`] + The user to ban. + reason: Optional[:class:`str`] + The ban reason. Should be between 1 and 1024 chars long. + + Raises + ------ + Forbidden + You do not have permissions to ban the user. + HTTPException + Banning the user failed. + """ + return await self.state.http.ban(self.id, user, reason=reason) + async def create_channel( self, *, - type: channels.ChannelType | None = None, + type: ChannelType | None = None, name: str, description: str | None = None, nsfw: bool | None = None, - ) -> channels.ServerChannel: + ) -> ServerChannel: """|coro| Create a new text or voice channel within this server. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create the channel. - :class:`APIError` + HTTPException Creating the channel failed. """ - return await self.state.http.create_channel( + return await self.state.http.create_server_channel( self.id, type=type, name=name, description=description, nsfw=nsfw ) async def create_text_channel( self, name: str, *, description: str | None = None, nsfw: bool | None = None - ) -> channels.ServerTextChannel: + ) -> ServerTextChannel: """|coro| Create a new text channel within this server. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create the channel. - :class:`APIError` + HTTPException Creating the channel failed. """ - from .channel import ChannelType - - type = ChannelType.TEXT - return await self.create_channel(type=type, name=name, description=description, nsfw=nsfw) # type: ignore + channel = await self.create_channel(type=ChannelType.text, name=name, description=description, nsfw=nsfw) + return channel # type: ignore async def create_voice_channel( self, name: str, *, description: str | None = None, nsfw: bool | None = None - ) -> channels.VoiceChannel: + ) -> VoiceChannel: """|coro| Create a new voice channel within this server. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create the channel. - :class:`APIError` + HTTPException Creating the channel failed. """ - from .channel import ChannelType - - type = ChannelType.VOICE - return await self.create_channel(type=type, name=name, description=description, nsfw=nsfw) # type: ignore + channel = await self.create_channel(type=ChannelType.voice, name=name, description=description, nsfw=nsfw) + return channel # type: ignore async def create_role(self, *, name: str, rank: int | None = None) -> Role: """|coro| @@ -359,16 +485,16 @@ async def create_role(self, *, name: str, rank: int | None = None) -> Role: Parameters ---------- - name: :class:`str` | `None` - Role name. Should be between 1 and 32 chars long. - rank: :class:`int` | `None` - Ranking position. Smaller values take priority. + name: :class:`str` + The role name. Should be between 1 and 32 chars long. + rank: Optional[:class:`int`] + The ranking position. Smaller values take priority. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to create the role. - :class:`APIError` + HTTPException Creating the role failed. """ return await self.state.http.create_role(self.id, name=name, rank=rank) @@ -376,45 +502,43 @@ async def create_role(self, *, name: str, rank: int | None = None) -> Role: async def delete(self) -> None: """|coro| - Deletes a server if owner otherwise leaves. + Deletes the server if owner otherwise leaves. """ return await self.state.http.delete_server(self.id) async def edit( self, *, - name: core.UndefinedOr[str] = core.UNDEFINED, - description: core.UndefinedOr[str | None] = core.UNDEFINED, - icon: core.UndefinedOr[str | None] = core.UNDEFINED, - banner: core.UndefinedOr[str | None] = core.UNDEFINED, - categories: core.UndefinedOr[list[Category] | None] = core.UNDEFINED, - system_messages: core.UndefinedOr[ - SystemMessageChannels | None - ] = core.UNDEFINED, - flags: core.UndefinedOr[ServerFlags] = core.UNDEFINED, - discoverable: core.UndefinedOr[bool] = core.UNDEFINED, - analytics: core.UndefinedOr[bool] = core.UNDEFINED, + name: UndefinedOr[str] = UNDEFINED, + description: UndefinedOr[str | None] = UNDEFINED, + icon: UndefinedOr[str | None] = UNDEFINED, + banner: UndefinedOr[str | None] = UNDEFINED, + categories: UndefinedOr[list[Category] | None] = UNDEFINED, + system_messages: UndefinedOr[SystemMessageChannels | None] = UNDEFINED, + flags: UndefinedOr[ServerFlags] = UNDEFINED, + discoverable: UndefinedOr[bool] = UNDEFINED, + analytics: UndefinedOr[bool] = UNDEFINED, ) -> Server: """|coro| - Edit a server. + Edits the server. Parameters ---------- name: :class:`UndefinedOr`[:class:`str`] New server name. Should be between 1 and 32 chars long. - description: :class:`UndefinedOr`[:class:`str` | `None`] + description: :class:`UndefinedOr`[Optional[:class:`str`]] New server description. Can be 1024 chars maximum long. - icon: :class:`UndefinedOr`[:class:`str` | `None`] - New server icon. Pass attachment ID given by Autumn. - banner: :class:`UndefinedOr`[:class:`str` | `None`] - New server banner. Pass attachment ID given by Autumn. - categories: :class:`UndefinedOr`[:class:`list`[:class:`Category`] | `None`] + icon: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + New server icon. + banner: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + New server banner. + categories: :class:`UndefinedOr`[Optional[List[:class:`Category`]]] New category structure for this server. - system_messsages: :class:`UndefinedOr`[:class:`SystemMessageChannels` | `None`] + system_messsages: :class:`UndefinedOr`[Optional[:class:`SystemMessageChannels`]] New system message channels configuration. flags: :class:`UndefinedOr`[:class:`ServerFlags`] - Bitfield of server flags. Can be passed only if you're privileged user. + The new server flags. Can be passed only if you're privileged user. discoverable: :class:`UndefinedOr`[:class:`bool`] Whether this server is public and should show up on [Revolt Discover](https://rvlt.gg). Can be passed only if you're privileged user. analytics: :class:`UndefinedOr`[:class:`bool`] @@ -422,10 +546,15 @@ async def edit( Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the server. - :class:`APIError` + HTTPException Editing the server failed. + + Returns + ------- + :class:`Server` + The newly updated server. """ return await self.state.http.edit_server( self.id, @@ -440,19 +569,34 @@ async def edit( analytics=analytics, ) - async def leave(self, *, leave_silently: bool | None = None) -> None: + async def join(self) -> Server: + """|coro| + + Joins the server. + + Raises + ------ + Forbidden + You're banned. + NotFound + Either server is not discoverable, or it does not exist at all. + HTTPException + Accepting the invite failed. + """ + server = await self.state.http.accept_invite(self.id) + return server # type: ignore + + async def leave(self, *, silent: bool | None = None) -> None: """|coro| - Deletes a server if owner otherwise leaves. + Leaves a server if not owner otherwise deletes it. Parameters ---------- - leave_silently: :class:`bool` + silent: :class:`bool` Whether to not send a leave message. """ - return await self.state.http.leave_server( - self.id, leave_silently=leave_silently - ) + return await self.state.http.leave_server(self.id, silent=silent) async def mark_server_as_read(self) -> None: """|coro| @@ -463,7 +607,7 @@ async def mark_server_as_read(self) -> None: async def report( self, - reason: safety_reports.ContentReportReason, + reason: ContentReportReason, *, additional_context: str | None = None, ) -> None: @@ -476,19 +620,17 @@ async def report( Raises ------ - :class:`APIError` + HTTPException You're trying to self-report, or reporting the server failed. """ - return await self.state.http.report_server( - self.id, reason, additional_context=additional_context - ) + return await self.state.http.report_server(self.id, reason, additional_context=additional_context) async def set_role_permissions( self, - role: core.ResolvableULID, + role: ULIDOr[BaseRole], *, - allow: permissions_.Permissions = permissions_.Permissions.NONE, - deny: permissions_.Permissions = permissions_.Permissions.NONE, + allow: Permissions = Permissions.NONE, + deny: Permissions = Permissions.NONE, ) -> Server: """|coro| @@ -496,37 +638,33 @@ async def set_role_permissions( Parameters ---------- - allow: :class:`permissions_.Permissions` + allow: :class:`Permissions` New allow bit flags. - deny: :class:`permissions_.Permissions` + deny: :class:`Permissions` New deny bit flags. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set role permissions on the server. - :class:`APIError` + HTTPException Setting permissions failed. """ - return await self.state.http.set_role_server_permissions( - self.id, role, allow=allow, deny=deny - ) + return await self.state.http.set_server_permissions_for_role(self.id, role, allow=allow, deny=deny) - async def set_default_permissions( - self, permissions: permissions_.Permissions | permissions_.PermissionOverride, / - ) -> Server: + async def set_default_permissions(self, permissions: Permissions, /) -> Server: """|coro| Sets permissions for the default role in this server. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to set default permissions on the server. - :class:`APIError` + HTTPException Setting permissions failed. """ - return await self.state.http.set_default_role_permissions(self.id, permissions) + return await self.state.http.set_default_server_permissions(self.id, permissions) async def subscribe(self) -> None: """|coro| @@ -535,63 +673,60 @@ async def subscribe(self) -> None: """ await self.state.shard.subscribe_to(self.id) + async def unban(self, user: ULIDOr[BaseUser]) -> None: + """|coro| + + Unbans a user from the server. + + Parameters + ---------- + user: :class:`ULIDOr`[:class:`BaseUser`] + The user to unban from the server. + + Raises + ------ + Forbidden + You do not have permissions to unban the user. + HTTPException + Unbanning the user failed. + """ + return await self.state.http.unban(self.id, user) + @define(slots=True) class PartialServer(BaseServer): """Partial representation of a server on Revolt.""" - name: core.UndefinedOr[str] = field(repr=True, hash=True, kw_only=True, eq=True) - owner_id: core.UndefinedOr[core.ULID] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - description: core.UndefinedOr[str | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - channel_ids: core.UndefinedOr[list[core.ULID]] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - categories: core.UndefinedOr[list[Category] | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - system_messages: core.UndefinedOr[SystemMessageChannels | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - default_permissions: core.UndefinedOr[permissions_.Permissions] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - internal_icon: core.UndefinedOr[cdn.StatelessAsset | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - internal_banner: core.UndefinedOr[cdn.StatelessAsset | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - flags: core.UndefinedOr[ServerFlags] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - discoverable: core.UndefinedOr[bool] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - analytics: core.UndefinedOr[bool] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + name: UndefinedOr[str] = field(repr=True, kw_only=True) + owner_id: UndefinedOr[str] = field(repr=True, kw_only=True) + description: UndefinedOr[str | None] = field(repr=True, kw_only=True) + channel_ids: UndefinedOr[list[str]] = field(repr=True, kw_only=True) + categories: UndefinedOr[list[Category] | None] = field(repr=True, kw_only=True) + system_messages: UndefinedOr[SystemMessageChannels | None] = field(repr=True, kw_only=True) + default_permissions: UndefinedOr[Permissions] = field(repr=True, kw_only=True) + internal_icon: UndefinedOr[StatelessAsset | None] = field(repr=True, kw_only=True) + internal_banner: UndefinedOr[StatelessAsset | None] = field(repr=True, kw_only=True) + flags: UndefinedOr[ServerFlags] = field(repr=True, kw_only=True) + discoverable: UndefinedOr[bool] = field(repr=True, kw_only=True) + analytics: UndefinedOr[bool] = field(repr=True, kw_only=True) @property - def icon(self) -> core.UndefinedOr[cdn.Asset | None]: - return self.internal_icon and self.internal_icon._stateful(self.state, "icons") + def icon(self) -> UndefinedOr[Asset | None]: + """:class:`UndefinedOr`[Optional[:class:`Asset`]]: The stateful server icon.""" + return self.internal_icon and self.internal_icon._stateful(self.state, 'icons') @property - def banner(self) -> core.UndefinedOr[cdn.Asset | None]: - return self.internal_banner and self.internal_banner._stateful( - self.state, "banners" - ) + def banner(self) -> UndefinedOr[Asset | None]: + """:class:`UndefinedOr`[Optional[:class:`Asset`]]: The stateful server banner.""" + return self.internal_banner and self.internal_banner._stateful(self.state, 'banners') def _sort_roles( - target_roles: list[core.ULID], + target_roles: list[str], /, *, safe: bool, - server_roles: dict[core.ULID, Role], + server_roles: dict[str, Role], ) -> list[Role]: if not safe: return sorted( @@ -606,109 +741,134 @@ def _sort_roles( reverse=True, ) except KeyError as ke: - raise errors.NoData(ke.args[0], "role") + raise NoData(ke.args[0], 'role') def _calculate_server_permissions( roles: list[Role], target_timeout: datetime | None, *, - default_permissions: permissions_.Permissions, -) -> permissions_.Permissions: - result = default_permissions.value + default_permissions: Permissions, +) -> Permissions: + result = default_permissions.copy() for role in roles: - result |= role.permissions.allow.value - result &= ~role.permissions.deny.value + result |= role.permissions.allow + result &= ~role.permissions.deny if target_timeout is not None and target_timeout > utils.utcnow(): - result &= ~permissions_.Permissions.SEND_MESSAGE.value + result.send_messages = True - return permissions_.Permissions(result) + return result @define(slots=True) class Server(BaseServer): """Representation of a server on Revolt.""" - owner_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + owner_id: str = field(repr=True, kw_only=True) """The user ID of the owner.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """The name of the server.""" - description: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + description: str | None = field(repr=True, kw_only=True) """The description for the server.""" - internal_channels: ( - tuple[t.Literal[True], list[core.ULID]] - | tuple[t.Literal[False], list[channels.ServerChannel]] - ) = field(repr=True, hash=True, kw_only=True, eq=True) - - categories: list[Category] | None = field( - repr=True, hash=True, kw_only=True, eq=True + internal_channels: tuple[typing.Literal[True], list[str]] | tuple[typing.Literal[False], list[ServerChannel]] = ( + field(repr=True, kw_only=True) ) + + categories: list[Category] | None = field(repr=True, kw_only=True) """The categories for this server.""" - system_messages: SystemMessageChannels | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + system_messages: SystemMessageChannels | None = field(repr=True, kw_only=True) """The configuration for sending system event messages.""" - roles: dict[core.ULID, Role] = field(repr=True, hash=True, kw_only=True, eq=True) + roles: dict[str, Role] = field(repr=True, kw_only=True) """The roles for this server.""" - default_permissions: permissions_.Permissions = field( - repr=True, hash=True, kw_only=True, eq=True - ) + default_permissions: Permissions = field(repr=True, kw_only=True) """The default set of server and channel permissions.""" - internal_icon: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_icon: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless server icon.""" - internal_banner: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_banner: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless server banner.""" - flags: ServerFlags = field(repr=True, hash=True, kw_only=True, eq=True) + flags: ServerFlags = field(repr=True, kw_only=True) """The server flags.""" - nsfw: bool = field(repr=True, hash=True, kw_only=True, eq=True) + nsfw: bool = field(repr=True, kw_only=True) """Whether this server is flagged as not safe for work.""" - analytics: bool = field(repr=True, hash=True, kw_only=True, eq=True) + analytics: bool = field(repr=True, kw_only=True) """Whether to enable analytics.""" - discoverable: bool = field(repr=True, hash=True, kw_only=True, eq=True) + discoverable: bool = field(repr=True, kw_only=True) """Whether this server should be publicly discoverable.""" + def get_channel(self, channel_id: str, /) -> ServerChannel | None: + """Retrieves a server channel from cache. + + Parameters + ---------- + channel_id: :class:`str` + The channel ID. + + Returns + ------- + Optional[:class:`ServerChannel`] + The channel or ``None`` if not found. + """ + cache = self.state.cache + if not cache: + return + + from .channel import ServerChannel + + channel = cache.get_channel(channel_id, caching._USER_REQUEST) + if channel and isinstance(channel, ServerChannel) and (channel.server_id == self.id or channel.server_id == ''): + return channel + + if not self.internal_channels[0]: + for ch in self.internal_channels[1]: + t: ServerChannel = ch # type: ignore + if t.id == channel_id: + return t + + def _prepare_cached(self) -> list[ServerChannel]: + if not self.internal_channels[0]: + channels = self.internal_channels[1] + self.internal_channels = (True, self.channel_ids) + return channels # type: ignore + return [] + def _update(self, data: PartialServer) -> None: - if core.is_defined(data.owner_id): + if data.owner_id is not UNDEFINED: self.owner_id = data.owner_id - if core.is_defined(data.name): + if data.name is not UNDEFINED: self.name = data.name - if core.is_defined(data.description): + if data.description is not UNDEFINED: self.description = data.description - if core.is_defined(data.channel_ids): + if data.channel_ids is not UNDEFINED: self.internal_channels = (True, data.channel_ids) - if core.is_defined(data.categories): + if data.categories is not UNDEFINED: self.categories = data.categories or [] - if core.is_defined(data.system_messages): + if data.system_messages is not UNDEFINED: self.system_messages = data.system_messages - if core.is_defined(data.default_permissions): + if data.default_permissions is not UNDEFINED: self.default_permissions = data.default_permissions - if core.is_defined(data.internal_icon): + if data.internal_icon is not UNDEFINED: self.internal_icon = data.internal_icon - if core.is_defined(data.internal_banner): + if data.internal_banner is not UNDEFINED: self.internal_banner = data.internal_banner - if core.is_defined(data.flags): + if data.flags is not UNDEFINED: self.flags = data.flags - if core.is_defined(data.discoverable): + if data.discoverable is not UNDEFINED: self.discoverable = data.discoverable - if core.is_defined(data.analytics): + if data.analytics is not UNDEFINED: self.analytics = data.analytics def _role_update_full(self, data: PartialRole | Role) -> None: @@ -728,59 +888,55 @@ def _role_update(self, data: PartialRole) -> None: role._update(data) @property - def icon(self) -> cdn.Asset | None: - """The server icon.""" - return self.internal_icon and self.internal_icon._stateful(self.state, "icons") + def icon(self) -> Asset | None: + """Optional[:class:`Asset`]: The server icon.""" + return self.internal_icon and self.internal_icon._stateful(self.state, 'icons') @property - def banner(self) -> cdn.Asset | None: - """The server banner.""" - return self.internal_banner and self.internal_banner._stateful( - self.state, "banners" - ) + def banner(self) -> Asset | None: + """Optional[:class:`Asset`]: The server banner.""" + return self.internal_banner and self.internal_banner._stateful(self.state, 'banners') @property - def channel_ids(self) -> list[core.ULID]: - """IDs of channels within this server.""" + def channel_ids(self) -> list[str]: + """List[:class:`str`]: The IDs of channels within this server.""" if self.internal_channels[0]: return self.internal_channels[1] # type: ignore else: return [channel.id for channel in self.internal_channels[1]] # type: ignore @property - def channels(self) -> list[channels.ServerChannel]: - """The channels within this server.""" + def channels(self) -> list[ServerChannel]: + """List[:class:`ServerChannel`]: The channels within this server.""" if not self.internal_channels[0]: - return t.cast("list[channels.ServerChannel]", self.internal_channels[1]) + return self.internal_channels[1] # type: ignore cache = self.state.cache if not cache: return [] + from .channel import ServerTextChannel, VoiceChannel + channels = [] for channel_id in self.internal_channels[1]: - channel = t.cast( - "channels.ServerChannel | None", - cache.get_channel(t.cast(core.ULID, channel_id), caching._USER_REQUEST), - ) + id: str = channel_id # type: ignore + channel = cache.get_channel(id, caching._USER_REQUEST) + if channel: + if channel.__class__ not in ( + ServerTextChannel, + VoiceChannel, + ) or not isinstance(channel, (ServerTextChannel, VoiceChannel)): + raise TypeError(f'Cache have given us incorrect channel type: {channel.__class__!r}') channels.append(channel) return channels - @property - def members(self) -> list[Member]: - """The members list of this server.""" - cache = self.state.cache - if not cache: - return [] - return cache.get_all_server_members_of(self.id, caching._USER_REQUEST) or [] - def is_verified(self) -> bool: - """Whether the server is verified.""" - return ServerFlags.VERIFIED in self.flags + """:class:`bool`: Whether the server is verified.""" + return self.flags.verified def is_official(self) -> bool: - """Whether the server is ran by Revolt team.""" - return ServerFlags.OFFICIAL in self.flags + """:class:`bool`: Whether the server is ran by Revolt team.""" + return self.flags.official def permissions_for( self, @@ -788,122 +944,171 @@ def permissions_for( /, *, safe: bool = True, - ) -> permissions_.Permissions: - """Calculate permissions for given member.""" + with_ownership: bool = True, + include_timeout: bool = True, + ) -> Permissions: + """Calculate permissions for given member. - if member.id == self.owner_id: - return permissions_.Permissions.ALL + Parameters + ---------- + member: :class:`Member` + The member to calculate permissions for. + safe: :class:`bool` + Whether to raise exception or not if role is missing in cache. + with_ownership: :class:`bool` + Whether to account for ownership. + include_timeout: :class:`bool` + Whether to account for timeout. + + Returns + ------- + :class:`Permissions` + The calculated permissions. + """ + + if with_ownership and member.id == self.owner_id: + return Permissions.ALL return _calculate_server_permissions( _sort_roles(member.roles, safe=safe, server_roles=self.roles), - member.timeout, + member.timed_out_until if include_timeout else None, default_permissions=self.default_permissions, ) - def _ensure_cached(self) -> None: - if not self.internal_channels[0]: - self.internal_channels = (True, self.channel_ids) - @define(slots=True) class Ban: """Representation of a server ban on Revolt.""" - server_id: core.ULID = field(repr=False, hash=False, kw_only=True, eq=False) + server_id: str = field(repr=False, kw_only=True) """The server ID.""" - user_id: core.ULID = field(repr=False, hash=False, kw_only=True, eq=False) + user_id: str = field(repr=False, kw_only=True) """The user ID that was banned.""" - reason: str | None = field(repr=False, hash=False, kw_only=True, eq=False) + reason: str | None = field(repr=False, kw_only=True) """Reason for ban creation.""" - user: users.DisplayUser | None = field( - repr=False, hash=False, kw_only=True, eq=False - ) + user: DisplayUser | None = field(repr=False, kw_only=True) """The user that was banned.""" + def __hash__(self) -> int: + return hash((self.server_id, self.user_id)) + + def __eq__(self, other: object) -> bool: + return ( + self is other + or isinstance(other, Ban) + and (self.server_id == other.server_id and self.user_id == other.user_id) + ) + @define(slots=True) class BaseMember: """Base representation of a member of a server on Revolt.""" - state: state.State = field(repr=False, hash=False, kw_only=True, eq=False) + state: State = field(repr=False, kw_only=True) """State that controls this member.""" - server_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + server_id: str = field(repr=True, kw_only=True) """ID of the server that member is on.""" - _user: users.User | core.ULID = field( - repr=True, hash=True, kw_only=True, eq=True, alias="_user" - ) + _user: User | str = field(repr=True, kw_only=True, alias='_user') - @property - def id(self) -> core.ULID: - """The member's user ID.""" - return self._user.id if isinstance(self._user, users.User) else self._user + def get_user(self) -> User | None: + """Optional[:class:`User`]: Grabs the user from cache.""" + if isinstance(self._user, User): + return self._user + cache = self.state.cache + if not cache: + return None + return cache.get_user(self._user, caching._USER_REQUEST) + def __eq__(self, other: object) -> bool: + return ( + self is other or isinstance(other, BaseMember) and self.id == other.id and self.server_id == other.server_id + ) -@define(slots=True) -class PartialMember(BaseMember): - """Partial representation of a member of a server on Revolt.""" + def __hash__(self) -> int: + return hash((self.server_id, self.id)) - nick: core.UndefinedOr[str | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - internal_avatar: core.UndefinedOr[cdn.StatelessAsset | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - roles: core.UndefinedOr[list[core.ULID]] = field( - repr=True, hash=True, kw_only=True, eq=True - ) - timeout: core.UndefinedOr[datetime | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + def __str__(self) -> str: + user = self.get_user() + return str(user) if user else '' - def avatar(self) -> core.UndefinedOr[cdn.Asset | None]: - return self.internal_avatar and self.internal_avatar._stateful( - self.state, "avatars" - ) + @property + def id(self) -> str: + """The member's user ID.""" + return self._user.id if isinstance(self._user, User) else self._user + @property + def user(self) -> User: + """:class:`User`: The member user.""" + user = self.get_user() + if not user: + raise NoData(self.id, 'member user') + return user -@define(slots=True) -class Member(BaseMember): - """Representation of a member of a server on Revolt.""" + @property + def display_name(self) -> str | None: + """Optional[:class:`str`]: The user display name.""" + user = self.get_user() + if user: + return user.display_name - joined_at: datetime = field(repr=True, hash=True, kw_only=True, eq=True) - """Time at which this user joined the server.""" + @property + def badges(self) -> UserBadges: + """:class:`UserBadges`: The user badges.""" + user = self.get_user() + if user: + return user.badges + return UserBadges.NONE - nick: str | None = field(repr=True, hash=True, kw_only=True, eq=True) - """The member's nick.""" + @property + def status(self) -> UserStatus | None: + """Optional[:class:`UserStatus`]: The current user's status.""" + user = self.get_user() + if user: + return user.status - internal_avatar: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) - """The member's avatar on server.""" + @property + def user_flags(self) -> UserFlags: + """Optional[:class:`UserFlags`]: The user flags.""" + user = self.get_user() + if user: + return user.flags + return UserFlags.NONE - roles: list[core.ULID] = field(repr=True, hash=True, kw_only=True, eq=True) - """The member's roles.""" + @property + def privileged(self) -> bool: + """:class:`bool`: Whether this user is privileged.""" + user = self.get_user() + if user: + return user.privileged + return False - timeout: datetime | None = field(repr=True, hash=True, kw_only=True, eq=True) - """The timestamp this member is timed out until.""" + @property + def bot(self) -> BotUserInfo | None: + """Optional[:class:`BotUserInfo`]: The information about the bot.""" + user = self.get_user() + if user: + return user.bot - def _update(self, data: PartialMember) -> None: - if core.is_defined(data.nick): - self.nick = data.nick - if core.is_defined(data.internal_avatar): - self.internal_avatar = data.internal_avatar - if core.is_defined(data.roles): - self.roles = data.roles or [] - if core.is_defined(data.timeout): - self.timeout = data.timeout + @property + def relationship(self) -> RelationshipStatus: + """:class:`RelationshipStatus`: The current session user's relationship with this user.""" + user = self.get_user() + if user: + return user.relationship + return RelationshipStatus.none @property - def avatar(self) -> cdn.Asset | None: - """The member's avatar on server.""" - return self.internal_avatar and self.internal_avatar._stateful( - self.state, "avatars" - ) + def online(self) -> bool: + """:class:`bool`: Whether this user is currently online.""" + user = self.get_user() + if user: + return user.online + return False async def ban(self, *, reason: str | None = None) -> Ban: """|coro| @@ -912,17 +1117,55 @@ async def ban(self, *, reason: str | None = None) -> Ban: Parameters ---------- - reason: :class:`str` | `None` - Ban reason. Should be between 1 and 1024 chars long. + reason: Optional[:class:`str`] + The ban reason. Should be between 1 and 1024 chars long. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to ban the user. - :class:`APIError` + HTTPException Banning the user failed. """ - return await self.state.http.ban_user(self.server_id, self.id, reason=reason) + return await self.state.http.ban(self.server_id, self.id, reason=reason) + + async def edit( + self, + *, + nick: UndefinedOr[str | None] = UNDEFINED, + avatar: UndefinedOr[ResolvableResource | None] = UNDEFINED, + roles: UndefinedOr[list[ULIDOr[BaseRole]] | None] = UNDEFINED, + timeout: UndefinedOr[datetime | timedelta | float | int | None] = UNDEFINED, + ) -> Member: + """|coro| + + Edits the member. + + Parameters + ---------- + nick: :class:`UndefinedOr`[Optional[:class:`str`]] + The member's new nick. Use ``None`` to remove the nickname. + avatar: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + The member's new avatar. Use ``None`` to remove the avatar. You can only change your own server avatar. + roles: :class:`UndefinedOr`[Optional[List[:class:`BaseRole`]]] + The member's new list of roles. This *replaces* the roles. + timeout: :class:`UndefinedOr`[Optional[Union[:class:`datetime`, :class:`timedelta`, :class:`float`, :class:`int`]]] + The duration/date the member's timeout should expire, or ``None`` to remove the timeout. + This must be a timezone-aware datetime object. Consider using :func:`utils.utcnow()`. + + Returns + ------- + :class:`Member` + The newly updated member. + """ + return await self.state.http.edit_member( + self.server_id, + self.id, + nick=nick, + avatar=avatar, + roles=roles, + timeout=timeout, + ) async def kick(self) -> None: """|coro| @@ -931,36 +1174,85 @@ async def kick(self) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to kick the member. - :class:`APIError` + HTTPException Kicking the member failed. """ return await self.state.http.kick_member(self.server_id, self.id) +@define(slots=True) +class PartialMember(BaseMember): + """Partial representation of a member of a server on Revolt.""" + + nick: UndefinedOr[str | None] = field(repr=True, kw_only=True) + internal_server_avatar: UndefinedOr[StatelessAsset | None] = field(repr=True, kw_only=True) + roles: UndefinedOr[list[str]] = field(repr=True, kw_only=True) + timed_out_until: UndefinedOr[datetime | None] = field(repr=True, kw_only=True) + + def server_avatar(self) -> UndefinedOr[Asset | None]: + """:class:`UndefinedOr`[Optional[:class:`Asset`]]: The member's avatar on server.""" + return self.internal_server_avatar and self.internal_server_avatar._stateful(self.state, 'avatars') + + +@define(slots=True) +class Member(BaseMember): + """Representation of a member of a server on Revolt.""" + + joined_at: datetime = field(repr=True, kw_only=True) + """Time at which this user joined the server.""" + + nick: str | None = field(repr=True, kw_only=True) + """The member's nick.""" + + internal_server_avatar: StatelessAsset | None = field(repr=True, kw_only=True) + """The member's avatar on server.""" + + roles: list[str] = field(repr=True, kw_only=True) + """The member's roles.""" + + timed_out_until: datetime | None = field(repr=True, kw_only=True) + """The timestamp this member is timed out until.""" + + def _update(self, data: PartialMember) -> None: + if data.nick is not UNDEFINED: + self.nick = data.nick + if data.internal_server_avatar is not UNDEFINED: + self.internal_server_avatar = data.internal_server_avatar + if data.roles is not UNDEFINED: + self.roles = data.roles or [] + if data.timed_out_until is not UNDEFINED: + self.timed_out_until = data.timed_out_until + + @property + def server_avatar(self) -> Asset | None: + """Optional[:class:`Asset`]: The member's avatar on server.""" + return self.internal_server_avatar and self.internal_server_avatar._stateful(self.state, 'avatars') + + @define(slots=True) class MemberList: """A member list of a server.""" - members: list[Member] = field(repr=True, hash=True, kw_only=True, eq=True) - users: list[users.User] = field(repr=True, hash=True, kw_only=True, eq=True) + members: list[Member] = field(repr=True, kw_only=True) + users: list[User] = field(repr=True, kw_only=True) __all__ = ( - "ServerFlags", - "Category", - "SystemMessageChannels", - "BaseRole", - "PartialRole", - "Role", - "BaseServer", - "PartialServer", - "_sort_roles", - "_calculate_server_permissions", - "Server", - "Ban", - "BaseMember", - "PartialMember", - "Member", + 'Category', + 'SystemMessageChannels', + 'BaseRole', + 'PartialRole', + 'Role', + 'BaseServer', + 'PartialServer', + '_sort_roles', + '_calculate_server_permissions', + 'Server', + 'Ban', + 'BaseMember', + 'PartialMember', + 'Member', + 'MemberList', ) diff --git a/pyvolt/shard.py b/pyvolt/shard.py index c214298..e8ce392 100644 --- a/pyvolt/shard.py +++ b/pyvolt/shard.py @@ -1,60 +1,100 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations import abc import aiohttp import asyncio import logging -import typing as t +import typing -from . import core, errors, utils +from . import utils +from .core import ULIDOr, resolve_id, __version__ as version +from .enums import ShardFormat +from .errors import PyvoltError, ShardError, AuthenticationError, ConnectError -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: from . import raw + from .channel import TextChannel + from .server import BaseServer from .state import State +try: + import msgpack +except ImportError: + _HAS_MSGPACK = False +else: + _HAS_MSGPACK = True + _L = logging.getLogger(__name__) -class Close(BaseException): - pass +class Close(Exception): + __slots__ = () -class Reconnect(BaseException): - pass +class Reconnect(Exception): + __slots__ = () class EventHandler(abc.ABC): @abc.abstractmethod - async def handle_raw(self, shard: Shard, d: raw.ClientEvent) -> None: ... + async def handle_raw(self, shard: Shard, payload: raw.ClientEvent, /) -> None: ... -DEFAULT_SHARD_USER_AGENT = ( - f"pyvolt Shard client (https://github.com/MCausc78/pyvolt, {core.__version__})" -) +DEFAULT_SHARD_USER_AGENT = f'pyvolt Shard client (https://github.com/MCausc78/pyvolt, {version})' class Shard: + """Implementation of Revolt WebSocket client.""" + _closing_future: asyncio.Future[None] | None _ws: aiohttp.ClientWebSocketResponse | None __slots__ = ( - "_closed", - "_closing_future", - "_heartbeat_sequence", - "_last_close_code", - "_sequence", - "_ws", - "base", - "bot", - "connect_delay", - "handler", - "logged_out", - "reconnect_on_timeout", - "retries", - "_session", - "state", - "token", - "user_agent", + '_closed', + '_closing_future', + '_heartbeat_sequence', + '_last_close_code', + '_sequence', + '_session', + '_ws', + 'base', + 'bot', + 'connect_delay', + 'format', + 'handler', + 'logged_out', + 'reconnect_on_timeout', + 'request_user_settings', + 'retries', + 'state', + 'token', + 'user_agent', + 'recv', + 'send', ) def __init__( @@ -64,41 +104,48 @@ def __init__( base: str | None = None, bot: bool = True, connect_delay: int | float | None = 2, + format: ShardFormat = ShardFormat.json, handler: EventHandler | None = None, reconnect_on_timeout: bool = True, + request_user_settings: list[str] | None = None, retries: int | None = None, - session: ( - utils.MaybeAwaitableFunc[[Shard], aiohttp.ClientSession] - | aiohttp.ClientSession - ), + session: utils.MaybeAwaitableFunc[[Shard], aiohttp.ClientSession] | aiohttp.ClientSession, state: State, user_agent: str | None = None, ) -> None: + if format is ShardFormat.msgpack and not _HAS_MSGPACK: + raise TypeError('Cannot use msgpack format without dependency') + self._closed = False self._closing_future = None self._heartbeat_sequence = 1 self._last_close_code = None self._sequence = 0 self._ws = None - self.base = base or "wss://ws.revolt.chat/" + self.base = base or 'wss://ws.revolt.chat/' self.bot = bot self.connect_delay = connect_delay + self.format = format self.handler = handler self.logged_out = False self.reconnect_on_timeout = reconnect_on_timeout + self.request_user_settings = request_user_settings self.retries = retries or 150 self._session = session self.state = state self.token = token self.user_agent = user_agent or DEFAULT_SHARD_USER_AGENT + self.recv = self._recv_json if format is ShardFormat.json else self._recv_msgpack + self.send = self._send_json if format is ShardFormat.json else self._send_msgpack + def is_closed(self) -> bool: return self._closed and not self._ws async def close(self) -> None: if self._ws: if self._closed: - raise errors.ShardError("Already closed") + raise ShardError('Already closed') self._closing_future = None self._closed = True await self._ws.close(code=1000) @@ -106,23 +153,96 @@ async def close(self) -> None: @property def ws(self) -> aiohttp.ClientWebSocketResponse: if self._ws is None: - raise TypeError("No websocket") + raise TypeError('No websocket') return self._ws - async def begin_typing(self, channel: core.ResolvableULID) -> None: - await self.send({"type": "BeginTyping", "channel": core.resolve_ulid(channel)}) + def with_credentials(self, token: str, *, bot: bool = True) -> None: + """Modifies HTTP client credentials. + + Parameters + ---------- + token: :class:`str` + The authentication token. + bot: :class:`bool` + Whether the token belongs to bot account or not. + """ + self.token = token + self.bot = bot + + async def authenticate(self) -> None: + """|coro| + + Authenticates the currently connected WebSocket. This is called right after successful WebSocket handshake. + """ + payload: raw.ServerAuthenticateEvent = { + 'type': 'Authenticate', + 'token': self.token, + } + await self.send(payload) + + async def ping(self) -> None: + """|coro| + + Pings the WebSocket. + """ + self._heartbeat_sequence += 1 + payload: raw.ServerPingEvent = { + 'type': 'Ping', + 'data': self._heartbeat_sequence, + } + await self.send(payload) + + async def begin_typing(self, channel: ULIDOr[TextChannel]) -> None: + """|coro| + + Begins typing in a channel. + + Parameters + ---------- + channel: :class:`ULIDOr`[:class:`TextChannel`] + The channel to begin typing in. + """ + payload: raw.ServerBeginTypingEvent = {'type': 'BeginTyping', 'channel': resolve_id(channel)} + await self.send(payload) + + async def end_typing(self, channel: ULIDOr[TextChannel]) -> None: + """|coro| + + Ends typing in a channel. + + Parameters + ---------- + channel: :class:`ULIDOr`[:class:`TextChannel`] + The channel to end typing in. + """ + payload: raw.ServerEndTypingEvent = {'type': 'EndTyping', 'channel': resolve_id(channel)} + await self.send(payload) + + async def subscribe_to(self, server: ULIDOr[BaseServer]) -> None: + """|coro| - async def end_typing(self, channel: core.ResolvableULID) -> None: - await self.send({"type": "EndTyping", "channel": core.resolve_ulid(channel)}) + Subscribes to a server. After calling this method, you'll receive :class:`UserUpdateEvent`'s. - async def subscribe_to(self, server: core.ResolvableULID) -> None: - await self.send({"type": "Subscribe", "server_id": core.resolve_ulid(server)}) + Parameters + ---------- + server: :class:`ULIDOr`[:class:`BaseServer`] + The server to subscribe to. + """ + payload: raw.ServerSubscribeEvent = {'type': 'Subscribe', 'server_id': resolve_id(server)} + await self.send(payload) - async def send(self, d: raw.ServerEvent) -> None: - _L.debug("sending %s", d) + async def _send_json(self, d: raw.ServerEvent, /) -> None: + _L.debug('sending %s', d) await self.ws.send_str(utils.to_json(d)) - async def recv(self) -> raw.ClientEvent | None: + async def _send_msgpack(self, d: raw.ServerEvent, /) -> None: + _L.debug('sending %s', d) + + # Will never none according to stubs: https://github.com/sbdchd/msgpack-types/blob/a9ab1c861933fa11aff706b21c303ee52a2ee359/msgpack-stubs/__init__.pyi#L40-L49 + payload: bytes = msgpack.packb(d) # type: ignore + await self.ws.send_bytes(payload) + + async def _recv_json(self) -> raw.ClientEvent: try: message = await self.ws.receive() except (KeyboardInterrupt, asyncio.CancelledError): @@ -134,32 +254,66 @@ async def recv(self) -> raw.ClientEvent | None: ): data = message.data self._last_close_code = data - _L.debug("websocket closed: %s", data) + _L.debug('Websocket closed: %s', data) if self._closed: raise Close else: - await asyncio.sleep(1) + await asyncio.sleep(0.5) raise Reconnect + if message.type is aiohttp.WSMsgType.ERROR: - _L.debug("received invalid websocket payload, reconnecting") + _L.debug('Received invalid websocket payload. Reconnecting.') raise Reconnect + if message.type is not aiohttp.WSMsgType.TEXT: - return None + _L.debug( + 'Received unknown message type: %s (expected TEXT). Reconnecting.', + message.type, + ) + raise Reconnect + k = utils.from_json(message.data) - if k["type"] != "Ready": - _L.debug("received %s", k) + if k['type'] != 'Ready': + _L.debug('Received %s', k) return k - async def ping(self) -> None: - self._heartbeat_sequence += 1 - d: raw.ServerPingEvent = { - "type": "Ping", - "data": self._heartbeat_sequence, - } - await self.send(d) + async def _recv_msgpack(self) -> raw.ClientEvent: + try: + message = await self.ws.receive() + except (KeyboardInterrupt, asyncio.CancelledError): + raise Close + if message.type in ( + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSING, + ): + data = message.data + self._last_close_code = data + _L.debug('Websocket closed: %s', data) + if self._closed: + raise Close + else: + await asyncio.sleep(0.5) + raise Reconnect + if message.type is aiohttp.WSMsgType.ERROR: + _L.debug('Received invalid websocket payload, reconnecting') + raise Reconnect + + if message.type is not aiohttp.WSMsgType.BINARY: + _L.debug( + 'Received unknown message type: %s (expected BINARY). Reconnecting.', + message.type, + ) + raise Reconnect + + # `msgpack` wont be unbound here + k: raw.ClientEvent = msgpack.unpackb(message.data, use_list=True) # type: ignore + if k['type'] != 'Ready': + _L.debug('received %s', k) + return k def _headers(self) -> dict[str, str]: - return {"user-agent": self.user_agent} + return {'user-agent': self.user_agent} async def _heartbeat(self) -> None: while True: @@ -175,45 +329,53 @@ async def _ws_connect(self) -> aiohttp.ClientWebSocketResponse: session = await utils._maybe_coroutine(session, self) # detect recursion if callable(session): - raise TypeError( - f"Expected aiohttp.ClientSession, not {type(session)!r}" - ) + raise TypeError(f'Expected aiohttp.ClientSession, not {type(session)!r}') # Do not call factory on future requests self._session = session - es = [] + params: raw.BonfireConnectionParameters = { + 'version': '1', + 'format': self.format.value, + } + if self.request_user_settings is not None: + params['__user_settings_keys'] = ','.join(self.request_user_settings) + + errors = [] for i in range(self.retries): try: - _L.debug("connecting to %s, format=json, try=%i", self.base, i) + _L.debug('Connecting to %s, format=%s, try=%i', self.base, self.format, i) return await session.ws_connect( self.base, headers=self._headers(), - params={ - "version": "1", - "format": "json", - }, + params=params, # type: ignore # Not true ) + except OSError as exc: + # TODO: Handle 10053? + if exc.errno in (54, 10054): # Connection reset by peer + await asyncio.sleep(1.5) + continue + errors.append(exc) except Exception as exc: - es.append(exc) - _L.debug("connection failed on try=%i: %s", i, exc) + errors.append(exc) + _L.debug('connection failed on try=%i: %s', i, exc) if self.connect_delay is not None: await asyncio.sleep(self.connect_delay) - raise errors.ConnectError(self.retries, es) + raise ConnectError(self.retries, errors) async def _connect(self) -> None: if self._ws: - raise errors.PyvoltError("The connection is already open.") + raise PyvoltError('The connection is already open.') self._closing_future = asyncio.Future() self._last_close_code = None while not self._closed: ws = await self._ws_connect() self._ws = ws - await self.send({"type": "Authenticate", "token": self.token}) + await self.authenticate() message = await self.recv() - if message is None or message["type"] != "Authenticated": - raise errors.AuthenticationError(message) + if message is None or message['type'] != 'Authenticated': + raise AuthenticationError(message) self.logged_out = False await self._handle(message) message = None @@ -226,13 +388,10 @@ async def _connect(self) -> None: tmp = exc exc = None raise tmp - while True: - try: - message = await self.recv() - if message: - break - except (asyncio.CancelledError, KeyboardInterrupt): - raise Close + try: + message = await self.recv() + except (asyncio.CancelledError, KeyboardInterrupt): + raise Close except Close: heartbeat_task.cancel() await ws.close() @@ -244,7 +403,7 @@ async def _connect(self) -> None: self._ws = None try: await _ws.close() - except: + except Exception: pass break else: @@ -252,7 +411,7 @@ async def _connect(self) -> None: if self.logged_out: try: await ws.close() - except: # Ignore close + except Exception: # Ignore close error pass return exc = Reconnect() @@ -261,47 +420,41 @@ async def _connect(self) -> None: try: await ws.close() except Exception as exc: - _L.warning("failed to close websocket", exc_info=exc) + _L.warning('failed to close websocket', exc_info=exc) if self._closing_future: self._closing_future.set_result(None) self._last_close_code = None - async def _handle(self, d: raw.ClientEvent) -> bool: + async def _handle(self, payload: raw.ClientEvent, /) -> bool: authenticated = True - if d["type"] == "Pong": - nonce = d["data"] + if payload['type'] == 'Pong': + nonce = payload['data'] if nonce != self._heartbeat_sequence: - extra = "" + extra = '' if isinstance(nonce, int) and nonce < self._heartbeat_sequence: - extra = ( - f"nonce is behind of {self._heartbeat_sequence - nonce} beats" - ) + extra = f'nonce is behind of {self._heartbeat_sequence - nonce} beats' if self.reconnect_on_timeout: _L.error( - "missed Pong, expected %s, got %s (%s)", + 'missed Pong, expected %s, got %s (%s)', self._heartbeat_sequence, nonce, extra, ) else: _L.warn( - "missed Pong, expected %s, got %s (%s)", + 'missed Pong, expected %s, got %s (%s)', self._heartbeat_sequence, nonce, extra, ) return not self.reconnect_on_timeout - elif d["type"] == "Logout": + elif payload['type'] == 'Logout': authenticated = False if self.handler is not None: - # asyncio.create_task(self.handler.handle_raw(self, d), name=f"pyvolt-eht-{self._sequence}") - try: - await self.handler.handle_raw(self, d) - except Exception as exc: - _L.exception("error occured on seq=%s", self._sequence, exc_info=exc) + await self.handler.handle_raw(self, payload) self._sequence += 1 return authenticated -__all__ = ("Close", "Reconnect", "EventHandler", "DEFAULT_SHARD_USER_AGENT", "Shard") +__all__ = ('Close', 'Reconnect', 'EventHandler', 'DEFAULT_SHARD_USER_AGENT', 'Shard') diff --git a/pyvolt/state.py b/pyvolt/state.py index 1a7af3a..305c78b 100644 --- a/pyvolt/state.py +++ b/pyvolt/state.py @@ -1,44 +1,103 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations -import typing as t +import typing -if t.TYPE_CHECKING: - from .cache import Cache +from .core import ZID +from .user_settings import UserSettings +from .user import UserBadges, UserFlags, RelationshipStatus, User + +if typing.TYPE_CHECKING: + from .cache import ProvideCacheContextIn, Cache from .cdn import CDNClient from .channel import SavedMessagesChannel from .http import HTTPClient from .parser import Parser from .shard import Shard - from .user import SelfUser + from .user import OwnUser class State: + """Represents a manager for all pyvolt objects. + + Attributes + ---------- + provide_cache_context_in: List[:class:`ProvideCacheContextIn`] + The methods/properties that do provide cache context. + system: :class:`User` + The Revolt#0000 sentinel user. + """ + __slots__ = ( - "_cache", - "_cdn_client", - "_http", - "_parser", - "_shard", - "_me", - "_saved_notes", + '_cache', + 'provide_cache_context_in', + '_cdn_client', + '_http', + '_parser', + '_shard', + '_me', + '_saved_notes', + '_settings', + 'system', ) def __init__( self, *, cache: Cache | None = None, + provide_cache_context_in: list[ProvideCacheContextIn] | None = None, cdn_client: CDNClient | None = None, http: HTTPClient | None = None, parser: Parser | None = None, shard: Shard | None = None, ) -> None: self._cache = cache + self.provide_cache_context_in: list[ProvideCacheContextIn] = provide_cache_context_in or [] self._cdn_client = cdn_client self._http = http self._parser = parser self._shard = shard - self._me: SelfUser | None = None + self._me: OwnUser | None = None self._saved_notes: SavedMessagesChannel | None = None + self._settings: UserSettings | None = None + self.system = User( + state=self, + id=ZID, + name='Revolt', + discriminator='0000', + internal_avatar=None, + display_name=None, + badges=UserBadges.NONE, + status=None, + flags=UserFlags.NONE, + privileged=True, + bot=None, + relationship=RelationshipStatus.none, + online=True, + ) def setup( self, @@ -67,34 +126,42 @@ def cache(self) -> Cache | None: @property def cdn_client(self) -> CDNClient: - assert self._cdn_client, "State has no CDN client attached" + assert self._cdn_client, 'State has no CDN client attached' return self._cdn_client @property def http(self) -> HTTPClient: - assert self._http, "State has no HTTP client attached" + assert self._http, 'State has no HTTP client attached' return self._http @property def parser(self) -> Parser: - assert self._parser, "State has no parser attached" + assert self._parser, 'State has no parser attached' return self._parser @property def shard(self) -> Shard: - assert self._shard, "State has no shard attached" + assert self._shard, 'State has no shard attached' return self._shard @property - def me(self) -> SelfUser | None: - """:class:`SelfUser` | None: The currently logged in user.""" + def me(self) -> OwnUser | None: + """Optional[:class:`OwnUser`]: The currently logged in user.""" # assert self._me, "State has no current user attached" return self._me @property def saved_notes(self) -> SavedMessagesChannel | None: - """:class:`SavedMessagesChannel` | None: The Saved Notes channel.""" + """Optional[:class:`SavedMessagesChannel`]: The Saved Notes channel.""" return self._saved_notes + @property + def settings(self) -> UserSettings: + """:class:`UserSettings`: The current user settings.""" + if self._settings: + return self._settings + self._settings = UserSettings(data={}, state=self, mocked=True, partial=True) + return self._settings + -__all__ = ("State",) +__all__ = ('State',) diff --git a/pyvolt/ulid.py b/pyvolt/ulid.py new file mode 100644 index 0000000..91ecc9f --- /dev/null +++ b/pyvolt/ulid.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +# https://github.com/mdomke/python-ulid/blob/main/ulid/__init__.py +# https://github.com/mdomke/python-ulid/blob/main/ulid/base32.py + +_LUT = [ + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x00, + 0x01, + 0x02, + 0x03, + 0x04, + 0x05, + 0x06, + 0x07, + 0x08, + 0x09, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x0A, + 0x0B, + 0x0C, + 0x0D, + 0x0E, + 0x0F, + 0x10, + 0x11, + 0xFF, + 0x12, + 0x13, + 0xFF, + 0x14, + 0x15, + 0xFF, + 0x16, + 0x17, + 0x18, + 0x19, + 0x1A, + 0xFF, + 0x1B, + 0x1C, + 0x1D, + 0x1E, + 0x1F, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0x0A, + 0x0B, + 0x0C, + 0x0D, + 0x0E, + 0x0F, + 0x10, + 0x11, + 0xFF, + 0x12, + 0x13, + 0xFF, + 0x14, + 0x15, + 0xFF, + 0x16, + 0x17, + 0x18, + 0x19, + 0x1A, + 0xFF, + 0x1B, + 0x1C, + 0x1D, + 0x1E, + 0x1F, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, + 0xFF, +] + + +def _ulid_timestamp(v: bytes) -> float: + return int.from_bytes(_ulid_decode_timestamp(v)[:6], byteorder='big') / 1000 + + +def _ulid_decode_timestamp(v: bytes) -> bytes: + return bytes( + [ + ((_LUT[v[0]] << 5) | _LUT[v[1]]) & 0xFF, + ((_LUT[v[2]] << 3) | (_LUT[v[3]] >> 2)) & 0xFF, + ((_LUT[v[3]] << 6) | (_LUT[v[4]] << 1) | (_LUT[v[5]] >> 4)) & 0xFF, + ((_LUT[v[5]] << 4) | (_LUT[v[6]] >> 1)) & 0xFF, + ((_LUT[v[6]] << 7) | (_LUT[v[7]] << 2) | (_LUT[v[8]] >> 3)) & 0xFF, + ((_LUT[v[8]] << 5) | (_LUT[v[9]])) & 0xFF, + ] + ) + + +__all__ = ('_LUT', '_ulid_timestamp', '_ulid_decode_timestamp') diff --git a/pyvolt/user.py b/pyvolt/user.py index 14e286f..ccab62f 100644 --- a/pyvolt/user.py +++ b/pyvolt/user.py @@ -1,111 +1,119 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field -from enum import IntFlag, StrEnum -import typing as t - -from . import ( - base, - cdn, - core, - permissions as permissions_, - safety_reports, +import typing + +from . import routes +from .base import Base +from .cdn import StatelessAsset, Asset, ResolvableResource, resolve_resource +from .core import ( + UNDEFINED, + UndefinedOr, + ULIDOr, ) +from .flags import UserPermissions, UserBadges, UserFlags +from .enums import UserReportReason, Presence, RelationshipStatus -if t.TYPE_CHECKING: - from . import ( - channel as channels, - raw, - ) +if typing.TYPE_CHECKING: + from . import raw + from .channel import SavedMessagesChannel, DMChannel + from .message import BaseMessage from .state import State -class Presence(StrEnum): - ONLINE = "Online" - """User is online.""" - - IDLE = "Idle" - """User is not currently available.""" - - FOCUS = "Focus" - """User is focusing / will only receive mentions.""" - - BUSY = "Busy" - """User is busy / will not receive any notifications.""" - - INVISIBLE = "Invisible" - """User appears to be offline.""" - - @define(slots=True) class UserStatus: - """User's active status.""" + """Represents user's active status.""" - text: str | None = field(repr=True, hash=True, kw_only=True, eq=True) - """Custom status text.""" + text: str | None = field(repr=True, kw_only=True) + """The custom status text.""" - presence: Presence | None = field(repr=True, hash=True, kw_only=True, eq=True) - """Current presence option.""" + presence: Presence | None = field(repr=True, kw_only=True) + """The current presence option.""" def _update(self, data: UserStatusEdit) -> None: - if core.is_defined(data.text): + if data.text is not UNDEFINED: self.text = data.text - if core.is_defined(data.presence): + if data.presence is not UNDEFINED: self.presence = data.presence class UserStatusEdit: - """Patrial user's status.""" + """Represents partial user's status. - text: core.UndefinedOr[str | None] - """Custom status text.""" + Attributes + ---------- + text: :class:`UndefinedOr`[Optional[:class:`str`]] + The new custom status text. + presence: :class:`UndefinedOr`[Optional[:class:`Presence`]] + The presence to use. + """ - presence: core.UndefinedOr[Presence | None] - """Current presence option.""" - - __slots__ = ("text", "presence") + __slots__ = ('text', 'presence') def __init__( self, *, - text: core.UndefinedOr[str | None] = core.UNDEFINED, - presence: core.UndefinedOr[Presence | None] = core.UNDEFINED, + text: UndefinedOr[str | None] = UNDEFINED, + presence: UndefinedOr[Presence | None] = UNDEFINED, ) -> None: self.text = text self.presence = presence @property def remove(self) -> list[raw.FieldsUser]: - r = [] + remove: list[raw.FieldsUser] = [] if self.text is None: - r.append("StatusText") + remove.append('StatusText') if self.presence is None: - r.append("StatusPresence") - return r + remove.append('StatusPresence') + return remove def build(self) -> raw.UserStatus: - j: raw.UserStatus = {} - if self.text is not None and core.is_defined(self.text): - j["text"] = self.text - if self.presence is not None and core.is_defined(self.presence): - j["presence"] = self.presence.value - return j + payload: raw.UserStatus = {} + if self.text not in (None, UNDEFINED): + payload['text'] = self.text + if self.presence not in (None, UNDEFINED): + payload['presence'] = self.presence.value + return payload @define(slots=True) class StatelessUserProfile: - """Stateless user's profile.""" + """The stateless user's profile.""" - content: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + content: str | None = field(repr=True, kw_only=True) """The user's profile content.""" - internal_background: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_background: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless background visible on user's profile.""" - def _stateful(self, state: State, user_id: core.ULID) -> UserProfile: + def _stateful(self, state: State, user_id: str) -> UserProfile: return UserProfile( content=self.content, internal_background=self.internal_background, @@ -118,237 +126,197 @@ def _stateful(self, state: State, user_id: core.ULID) -> UserProfile: class UserProfile(StatelessUserProfile): """User's profile.""" - state: State = field(repr=False, hash=False, kw_only=True, eq=False) - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + state: State = field(repr=False, kw_only=True) + user_id: str = field(repr=True, kw_only=True) @property - def background(self) -> cdn.Asset | None: + def background(self) -> Asset | None: """Background visible on user's profile.""" - return self.internal_background and self.internal_background._stateful( - self.state, "backgrounds" - ) + return self.internal_background and self.internal_background._stateful(self.state, 'backgrounds') @define(slots=True) class PartialUserProfile: """The user's profile.""" - state: State = field(repr=False, hash=False, kw_only=True, eq=False) + state: State = field(repr=False, kw_only=True) """The state.""" - content: core.UndefinedOr[str | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + content: UndefinedOr[str | None] = field(repr=True, kw_only=True) """The user's profile content.""" - internal_background: core.UndefinedOr[cdn.StatelessAsset | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_background: UndefinedOr[StatelessAsset | None] = field(repr=True, kw_only=True) """The stateless background visible on user's profile.""" @property - def background(self) -> core.UndefinedOr[cdn.Asset | None]: - """Background visible on user's profile.""" - return self.internal_background and self.internal_background._stateful( - self.state, "backgrounds" - ) + def background(self) -> UndefinedOr[Asset | None]: + """:class:`UndefinedOr`[Optional[:class:`Asset`]]: The background visible on user's profile.""" + return self.internal_background and self.internal_background._stateful(self.state, 'backgrounds') class UserProfileEdit: - """Partial user's profile.""" + """Partially represents user's profile. - content: core.UndefinedOr[str | None] - """Text to set as user profile description.""" + Attributes + ---------- + content: :class:`UndefinedOr`[Optional[:class:`str`]] + The text to use in user profile description. + background: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + The background to use on user's profile. + """ - background: core.UndefinedOr[cdn.ResolvableResource | None] - """New background visible on user's profile.""" - - __slots__ = ("content", "background") + __slots__ = ('content', 'background') def __init__( self, - content: core.UndefinedOr[str | None] = core.UNDEFINED, - background: core.UndefinedOr[cdn.ResolvableResource | None] = core.UNDEFINED, + content: UndefinedOr[str | None] = UNDEFINED, + *, + background: UndefinedOr[ResolvableResource | None] = UNDEFINED, ) -> None: self.content = content self.background = background @property def remove(self) -> list[raw.FieldsUser]: - r = [] + remove: list[raw.FieldsUser] = [] if self.content is None: - r.append("ProfileContent") + remove.append('ProfileContent') if self.background is None: - r.append("ProfileBackground") - return r + remove.append('ProfileBackground') + return remove async def build(self, state: State) -> raw.DataUserProfile: - j: raw.DataUserProfile = {} + payload: raw.DataUserProfile = {} if self.content: - j["content"] = self.content + payload['content'] = self.content if self.background: - j["background"] = await cdn.resolve_resource( - state, self.background, tag="backgrounds" - ) - return j - - -class UserBadges(IntFlag): - """User badges bitfield.""" - - NONE = 0 - """Zero badges bitfield.""" - - DEVELOPER = 1 << 0 - """Revolt developer.""" - - TRANSLATOR = 1 << 1 - """Helped translate Revolt.""" - - SUPPORTER = 1 << 2 - """Monetarily supported Revolt.""" - - RESPONSIBLE_DISCLOSURE = 1 << 3 - """Responsibly disclosed a security issue.""" - - FOUNDER = 1 << 4 - """Revolt founder.""" - - PLATFORM_MODERATION = 1 << 5 - """Platform moderator.""" - - ACTIVE_SUPPORTER = 1 << 6 - """Active monetary supporter.""" - - PAW = 1 << 7 - """🦊🦝""" - - EARLY_ADOPTER = 1 << 8 - """Joined as one of the first 1000 users in 2021.""" - - RESERVED_RELEVANT_JOKE_BADGE_1 = 1 << 9 - """Amogus.""" - - RESERVED_RELEVANT_JOKE_BADGE_2 = 1 << 10 - """Low resolution troll face.""" - - -class UserFlags(IntFlag): - """User flags bitfield.""" - - NONE = 0 - """Zero badges bitfield.""" - - SUSPENDED = 1 << 0 - """User has been suspended from the platform.""" - - DELETED = 1 << 1 - """User has deleted their account.""" - - BANNED = 1 << 2 - """User was banned off the platform.""" - - SPAM = 1 << 3 - """User was marked as spam and removed from platform.""" - - -class RelationshipStatus(StrEnum): - """User's relationship with another user (or themselves).""" - - NONE = "None" - """No relationship with other user.""" - - USER = "User" - """Other user is us.""" - - FRIEND = "Friend" - """Friends with the other user.""" - - OUTGOING = "Outgoing" - """Pending friend request to user.""" - - INCOMING = "Incoming" - """Incoming friend request from user.""" - - BLOCKED = "Blocked" - """Blocked this user.""" - - BLOCKED_OTHER = "BlockedOther" - """Blocked by this user.""" + payload['background'] = await resolve_resource(state, self.background, tag='backgrounds') + return payload @define(slots=True) class Relationship: """Represents a relationship entry indicating current status with other user.""" - user_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + id: str = field(repr=True, kw_only=True) """Other user's ID.""" - status: RelationshipStatus = field(repr=True, hash=True, kw_only=True, eq=True) + status: RelationshipStatus = field(repr=True, kw_only=True) """Relationship status with them.""" + def __hash__(self) -> int: + return hash(self.id) + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, Relationship) and self.id == other.id and self.status == other.status + @define(slots=True) class Mutuals: """Mutual friends and servers response.""" - user_ids: list[core.ULID] = field(repr=True, hash=True, kw_only=True, eq=True) + user_ids: list[str] = field(repr=True, kw_only=True) """Array of mutual user IDs that both users are friends with.""" - server_ids: list[core.ULID] = field(repr=True, hash=True, kw_only=True, eq=True) + server_ids: list[str] = field(repr=True, kw_only=True) """Array of mutual server IDs that both users are in.""" -class BaseUser(base.Base): +class BaseUser(Base): """Represents a user on Revolt.""" + def is_sentinel(self) -> bool: + """:class:`bool`: Returns whether the user is sentinel (Revolt#0000).""" + return self is self.state.system + @property def mention(self) -> str: - """The user mention.""" - return f"<@{self.id}>" + """:class:`str`: The user mention.""" + return f'<@{self.id}>' + + @property + def default_avatar_url(self) -> str: + """:class:`str`: The URL to user's default avatar.""" + return self.state.http.url_for(routes.USERS_GET_DEFAULT_AVATAR.compile(user_id=self.id)) + + @property + def dm_channel_id(self) -> str | None: + """Optional[:class:`str`]: The ID of the private channel with this user.""" + cache = self.state.cache + if cache: + from .cache import _USER_REQUEST as USER_REQUEST + + return cache.get_private_channel_by_user(self.id, USER_REQUEST) + + pm_id = dm_channel_id + + @property + def dm_channel(self) -> DMChannel | None: + """Optional[:class:`DMChannel`]: The private channel with this user.""" + dm_channel_id = self.dm_channel_id + + cache = self.state.cache + if cache and dm_channel_id: + from .cache import _USER_REQUEST as USER_REQUEST + + channel = cache.get_channel(dm_channel_id, USER_REQUEST) + if isinstance(channel, DMChannel): + return channel + + pm = dm_channel async def accept_friend_request(self) -> User: """|coro| - Accept another user's friend request. + Accepts the incoming friend request. """ return await self.state.http.accept_friend_request(self.id) async def block(self) -> User: """|coro| - Block this user. + Blocks the user. """ return await self.state.http.block_user(self.id) async def edit( self, *, - display_name: core.UndefinedOr[str] = core.UNDEFINED, - avatar: core.UndefinedOr[str | None] = core.UNDEFINED, - status: core.UndefinedOr[UserStatusEdit] = core.UNDEFINED, - profile: core.UndefinedOr[UserProfileEdit] = core.UNDEFINED, - badges: core.UndefinedOr[UserBadges] = core.UNDEFINED, - flags: core.UndefinedOr[UserFlags] = core.UNDEFINED, + display_name: UndefinedOr[str] = UNDEFINED, + avatar: UndefinedOr[str | None] = UNDEFINED, + status: UndefinedOr[UserStatusEdit] = UNDEFINED, + profile: UndefinedOr[UserProfileEdit] = UNDEFINED, + badges: UndefinedOr[UserBadges] = UNDEFINED, + flags: UndefinedOr[UserFlags] = UNDEFINED, ) -> User: """|coro| - Edit this user. + Edits the user. Parameters ---------- - display_name: :class:`UndefinedOr`[:class:`str`] | `None` - New display name. Set `None` to remove it. - avatar: :class:`UndefinedOr`[:class:`str`] | `None` - New avatar. Must be attachment ID. Set `None` to remove it. + display_name: :class:`UndefinedOr`[Optional[:class:`str`]] + New display name. Pass ``None`` to remove it. + avatar: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + New avatar. Pass ``None`` to remove it. status: :class:`UndefinedOr`[:class:`UserStatusEdit`] New user status. profile: :class:`UndefinedOr`[:class:`UserProfileEdit`] New user profile data. This is applied as a partial. badges: :class:`UndefinedOr`[:class:`UserBadges`] - New user badges. + The new user badges. flags: :class:`UndefinedOr`[:class:`UserFlags`] - New user flags. + The new user flags. + + Raises + ------ + HTTPException + Editing the user failed. + + Returns + ------- + :class:`User` + The newly updated user. """ return await self.state.http.edit_user( self.id, @@ -367,20 +335,20 @@ async def deny_friend_request(self) -> User: """ return await self.state.http.deny_friend_request(self.id) - async def mutual_friend_ids(self) -> list[core.ULID]: + async def mutual_friend_ids(self) -> list[str]: """|coro| - Retrieve a list of mutual friends with this user. + Retrieves a list of mutual friends with this user. """ - mutuals = await self.state.http.get_mutual_friends_and_servers(self.id) + mutuals = await self.state.http.get_mutuals_with(self.id) return mutuals.user_ids - async def mutual_server_ids(self) -> list[core.ULID]: + async def mutual_server_ids(self) -> list[str]: """|coro| - Retrieve a list of mutual servers with this user. + Retrieves a list of mutual servers with this user. """ - mutuals = await self.state.http.get_mutual_friends_and_servers(self.id) + mutuals = await self.state.http.get_mutuals_with(self.id) return mutuals.server_ids async def mutuals(self) -> Mutuals: @@ -388,28 +356,48 @@ async def mutuals(self) -> Mutuals: Retrieve a list of mutual friends and servers with this user. """ - return await self.state.http.get_mutual_friends_and_servers(self.id) + return await self.state.http.get_mutuals_with(self.id) - async def open_dm(self) -> channels.SavedMessagesChannel | channels.DMChannel: + async def open_dm(self) -> SavedMessagesChannel | DMChannel: """|coro| Open a DM with another user. If the target is oneself, a saved messages channel is returned. """ return await self.state.http.open_dm(self.id) + async def fetch_profile(self) -> UserProfile: + """|coro| + + Retrives user profile. + + Returns + ------- + :class`UserProfile` + The user's profile page. + """ + return await self.state.http.get_user_profile(self.id) + async def remove_friend(self) -> User: """|coro| - Denies this user's friend request or removes an existing friend. + Removes the user as a friend. + + .. note:: + This can only be used by non-bot accounts. + + Raises + ------ + HTTPException + Removing the user as a friend failed. """ return await self.state.http.remove_friend(self.id) async def report( self, - reason: safety_reports.UserReportReason, + reason: UserReportReason, *, additional_context: str | None = None, - message_context: core.ResolvableULID, + message_context: ULIDOr[BaseMessage], ) -> None: """|coro| @@ -420,7 +408,7 @@ async def report( Raises ------ - :class:`APIError` + HTTPException You're trying to self-report, or reporting the user failed. """ return await self.state.http.report_user( @@ -433,7 +421,7 @@ async def report( async def unblock(self) -> User: """|coro| - Unblock this user. + Unblocks the user. """ return await self.state.http.unblock_user(self.id) @@ -442,76 +430,56 @@ async def unblock(self) -> User: class PartialUser(BaseUser): """Partially represents a user on Revolt.""" - name: core.UndefinedOr[str] = field(repr=True, hash=True, kw_only=True, eq=True) + name: UndefinedOr[str] = field(repr=True, kw_only=True) """New username of the user.""" - discriminator: core.UndefinedOr[str] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + discriminator: UndefinedOr[str] = field(repr=True, kw_only=True) """New discriminator of the user.""" - display_name: core.UndefinedOr[str | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + display_name: UndefinedOr[str | None] = field(repr=True, kw_only=True) """New display name of the user.""" - internal_avatar: core.UndefinedOr[cdn.StatelessAsset | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_avatar: UndefinedOr[StatelessAsset | None] = field(repr=True, kw_only=True) """New stateless avatar of the user.""" - badges: core.UndefinedOr[UserBadges] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + badges: UndefinedOr[UserBadges] = field(repr=True, kw_only=True) """New user badges.""" - status: core.UndefinedOr[UserStatusEdit] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + status: UndefinedOr[UserStatusEdit] = field(repr=True, kw_only=True) """New user's status.""" - profile: core.UndefinedOr[PartialUserProfile] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_profile: UndefinedOr[PartialUserProfile] = field(repr=True, kw_only=True) """New user's profile page.""" - flags: core.UndefinedOr[UserFlags] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + flags: UndefinedOr[UserFlags] = field(repr=True, kw_only=True) """The user flags.""" - online: core.UndefinedOr[bool] = field(repr=True, hash=True, kw_only=True, eq=True) + online: UndefinedOr[bool] = field(repr=True, kw_only=True) """Whether this user came online.""" @property - def avatar(self) -> core.UndefinedOr[cdn.Asset | None]: + def avatar(self) -> UndefinedOr[Asset | None]: """The avatar of the user.""" - return self.internal_avatar and self.internal_avatar._stateful( - self.state, "avatars" - ) + return self.internal_avatar and self.internal_avatar._stateful(self.state, 'avatars') @define(slots=True) class DisplayUser(BaseUser): """Represents a user on Revolt that can be easily displayed in UI.""" - name: str = field(repr=True, hash=True, kw_only=True, eq=True) + name: str = field(repr=True, kw_only=True) """The username of the user.""" - discriminator: str = field(repr=True, hash=True, kw_only=True, eq=True) + discriminator: str = field(repr=True, kw_only=True) """The discriminator of the user.""" - internal_avatar: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_avatar: StatelessAsset | None = field(repr=True, kw_only=True) """The stateless avatar of the user.""" @property - def avatar(self) -> cdn.Asset | None: - """The avatar of the user.""" - return self.internal_avatar and self.internal_avatar._stateful( - self.state, "avatars" - ) + def avatar(self) -> Asset | None: + """Optional[:class:`Asset`]: The avatar of the user.""" + return self.internal_avatar and self.internal_avatar._stateful(self.state, 'avatars') async def send_friend_request(self) -> User: """|coro| @@ -523,130 +491,186 @@ async def send_friend_request(self) -> User: @define(slots=True) class BotUserInfo: - owner_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) - """ID of the owner of this bot.""" + owner_id: str = field(repr=True, kw_only=True) + """The ID of the owner of this bot.""" + + def __eq__(self, other: object) -> bool: + return self is other or isinstance(other, BotUserInfo) and self.owner_id == other.owner_id def _calculate_user_permissions( - user_id: core.ULID, + user_id: str, user_privileged: bool, user_relationship: RelationshipStatus, user_bot: BotUserInfo | None, *, - perspective_id: core.ULID, + perspective_id: str, perspective_bot: BotUserInfo | None, -) -> permissions_.UserPermissions: - if ( - user_privileged - or user_id == perspective_id - or user_relationship == RelationshipStatus.FRIEND - ): - return permissions_.UserPermissions.ALL +) -> UserPermissions: + if user_privileged or user_id == perspective_id or user_relationship is RelationshipStatus.friend: + return UserPermissions.ALL if user_relationship in ( - RelationshipStatus.BLOCKED, - RelationshipStatus.BLOCKED_OTHER, + RelationshipStatus.blocked, + RelationshipStatus.blocked_other, ): - return permissions_.UserPermissions.ACCESS + return UserPermissions(access=True) - result = 0 - if user_relationship in (RelationshipStatus.INCOMING, RelationshipStatus.OUTGOING): - result |= permissions_.UserPermissions.ACCESS.value + result = UserPermissions() + if user_relationship in (RelationshipStatus.incoming, RelationshipStatus.outgoing): + result.access = True if user_bot or perspective_bot: - result |= permissions_.UserPermissions.SEND_MESSAGE.value + result.send_messages = True - return permissions_.UserPermissions(result) + return result @define(slots=True) class User(DisplayUser): """Represents a user on Revolt.""" - display_name: str | None = field(repr=True, hash=True, kw_only=True, eq=True) + display_name: str | None = field(repr=True, kw_only=True) """The user display name.""" - badges: UserBadges = field(repr=True, hash=True, kw_only=True, eq=True) + badges: UserBadges = field(repr=True, kw_only=True) """The user badges.""" - status: UserStatus | None = field(repr=True, hash=True, kw_only=True, eq=True) + status: UserStatus | None = field(repr=True, kw_only=True) """The current user's status.""" - flags: UserFlags = field(repr=True, hash=True, kw_only=True, eq=True) + flags: UserFlags = field(repr=True, kw_only=True) """The user flags.""" - privileged: bool = field(repr=True, hash=True, kw_only=True, eq=True) + privileged: bool = field(repr=True, kw_only=True) """Whether this user is privileged.""" - bot: BotUserInfo | None = field(repr=True, hash=True, kw_only=True, eq=True) + bot: BotUserInfo | None = field(repr=True, kw_only=True) """The information about the bot.""" - relationship: RelationshipStatus = field( - repr=True, hash=True, kw_only=True, eq=True - ) + relationship: RelationshipStatus = field(repr=True, kw_only=True) """The current session user's relationship with this user.""" - online: bool = field(repr=True, hash=True, kw_only=True, eq=True) + online: bool = field(repr=True, kw_only=True) """Whether this user is currently online.""" + def __str__(self) -> str: + return self.name + + @property + def tag(self) -> str: + """:class:`str`: The tag of the user. + Assuming that :attr:`User.name` is ``'vlf'`` and :attr:`User.discriminator` is ``'3510'``, example output would be ``'vlf#3510'``.""" + return f'{self.name}#{self.discriminator}' + def _update(self, data: PartialUser) -> None: - if core.is_defined(data.name): + if data.name is not UNDEFINED: self.name = data.name - if core.is_defined(data.discriminator): + if data.discriminator is not UNDEFINED: self.discriminator = data.discriminator - if core.is_defined(data.display_name): + if data.display_name is not UNDEFINED: self.display_name = data.display_name - if core.is_defined(data.internal_avatar): + if data.internal_avatar is not UNDEFINED: self.internal_avatar = data.internal_avatar - if core.is_defined(data.badges): + if data.badges is not UNDEFINED: self.badges = data.badges - if core.is_defined(data.status): + if data.status is not UNDEFINED: status = data.status - if core.is_defined(status.text) and core.is_defined(status.presence): + if status.text is not UNDEFINED and status.presence is not UNDEFINED: self.status = UserStatus( text=status.text, presence=status.presence, ) elif self.status: self.status._update(status) - if core.is_defined(data.flags): + if data.flags is not UNDEFINED: self.flags = data.flags - if core.is_defined(data.online): + if data.online is not UNDEFINED: self.online = data.online - async def profile(self) -> UserProfile: - """|coro| + # flags + def is_suspended(self) -> bool: + """:class:`bool`: Whether this user has been suspended from the platform.""" + return self.flags.suspended - Retrives user profile. + def is_deleted(self) -> bool: + """:class:`bool`: Whether this user is deleted his account.""" + return self.flags.deleted - Returns - ------- - :class`UserProfile` - The user's profile page. - """ - return await self.state.http.get_user_profile(self.id) + def is_banned(self) -> bool: + """:class:`bool`: Whether this user is banned off the platform.""" + return self.flags.banned + + def is_spammer(self) -> bool: + """:class:`bool`: Whether this user was marked as spam and removed from platform.""" + return self.flags.spam + + # badges + def is_developer(self) -> bool: + """:class:`bool`: Whether this user is Revolt developer.""" + return self.badges.developer + + def is_translator(self) -> bool: + """:class:`bool`: Whether this user helped translate Revolt.""" + return self.badges.translator + + def is_supporter(self) -> bool: + """:class:`bool`: Whether this user monetarily supported Revolt.""" + return self.badges.supporter + + def is_responsible_disclosure(self) -> bool: + """:class:`bool`: Whether this user responsibly disclosed a security issue.""" + return self.badges.responsible_disclosure + + def is_founder(self) -> bool: + """:class:`bool`: Whether this user is Revolt founder.""" + return self.badges.founder + + def is_platform_moderator(self) -> bool: + """:class:`bool`: Whether this user is platform moderator.""" + return self.badges.platform_moderation + + def is_active_supporter(self) -> bool: + """:class:`bool`: Whether this user is active monetary supporter.""" + return self.badges.active_supporter + + def is_paw(self) -> bool: + """:class:`bool`: Whether this user likes fox/raccoon (🦊🦝).""" + return self.badges.paw + + def is_early_adopter(self) -> bool: + """:class:`bool`: Whether this user have joined Revolt as one of the first 1000 users in 2021.""" + return self.badges.early_adopter + + def is_relevant_joke_1(self) -> bool: + """:class:`bool`: Whether this user have given funny joke (Called "sus", displayed as Amogus in Revite).""" + return self.badges.reserved_relevant_joke_badge_1 + + def is_relevant_joke_2(self) -> bool: + """:class:`bool`: Whether this user have given other funny joke (Called as "It's Morbin Time" in Revite).""" + return self.badges.reserved_relevant_joke_badge_2 @define(slots=True) -class SelfUser(User): - """Representation of a user on Revolt.""" +class OwnUser(User): + """Representation of a current user on Revolt.""" - relations: list[Relationship] = field(repr=True, hash=True, kw_only=True, eq=True) - """Relationships with other users.""" + relations: dict[str, Relationship] = field(repr=True, kw_only=True) + """The dictionary of relationships with other users.""" async def edit( self, *, - display_name: core.UndefinedOr[str] = core.UNDEFINED, - avatar: core.UndefinedOr[str | None] = core.UNDEFINED, - status: core.UndefinedOr[UserStatusEdit] = core.UNDEFINED, - profile: core.UndefinedOr[UserProfileEdit] = core.UNDEFINED, - badges: core.UndefinedOr[UserBadges] = core.UNDEFINED, - flags: core.UndefinedOr[UserFlags] = core.UNDEFINED, + display_name: UndefinedOr[str] = UNDEFINED, + avatar: UndefinedOr[str | None] = UNDEFINED, + status: UndefinedOr[UserStatusEdit] = UNDEFINED, + profile: UndefinedOr[UserProfileEdit] = UNDEFINED, + badges: UndefinedOr[UserBadges] = UNDEFINED, + flags: UndefinedOr[UserFlags] = UNDEFINED, ) -> User: """|coro| - Edit user. + Edits the user. Parameters ---------- @@ -663,7 +687,7 @@ async def edit( flags: :class:`UndefinedOr`[:class:`UserFlags`] Bitfield of new user flags. """ - return await self.state.http.edit_self_user( + return await self.state.http.edit_my_user( display_name=display_name, avatar=avatar, status=status, @@ -674,22 +698,19 @@ async def edit( __all__ = ( - "Presence", - "UserStatus", - "UserStatusEdit", - "StatelessUserProfile", - "UserProfile", - "UserProfileEdit", - "UserBadges", - "UserFlags", - "RelationshipStatus", - "Relationship", - "Mutuals", - "BaseUser", - "PartialUser", - "DisplayUser", - "BotUserInfo", - "_calculate_user_permissions", - "User", - "SelfUser", + 'UserStatus', + 'UserStatusEdit', + 'StatelessUserProfile', + 'UserProfile', + 'PartialUserProfile', + 'UserProfileEdit', + 'Relationship', + 'Mutuals', + 'BaseUser', + 'PartialUser', + 'DisplayUser', + 'BotUserInfo', + '_calculate_user_permissions', + 'User', + 'OwnUser', ) diff --git a/pyvolt/user_settings.py b/pyvolt/user_settings.py index 8a6c46b..f23a887 100644 --- a/pyvolt/user_settings.py +++ b/pyvolt/user_settings.py @@ -1,44 +1,168 @@ -from attrs import define, field +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations + from datetime import datetime -import typing as t +import typing + +from . import utils +from .core import UNDEFINED, UndefinedOr, ULIDOr, resolve_id +from .enums import ( + Language, + AndroidTheme, + AndroidMessageReplyStyle, + AndroidProfilePictureShape, + ReviteNotificationState, + ReviteEmojiPack, + ReviteBaseTheme, + ReviteFont, + ReviteMonoFont, + ReviteChangelogEntry, +) -if t.TYPE_CHECKING: +if typing.TYPE_CHECKING: + from . import raw + from .channel import Channel + from .server import BaseServer from .state import State -@define(slots=True) class UserSettings: - """The user settings.""" + """Represents Revolt user settings. - state: "State" = field(repr=False, hash=False, kw_only=True, eq=False) - """The state that manages current user settings.""" + Attributes + ---------- + state: :class:`State` + The state that manages current user settings. + data: Dict[:class:`str`, Tuple[:class:`int`, :class:`str`]] + The mapping of ``{key: (timestamp, value)}``. + mocked: :class:`bool` + Whether user settings are mocked. Mocked user settings are created by library itself, if not logged in, or in HTTP-only mode. + partial: :class:`bool` + Whether user settings are partial. This is set to ``True`` when used via :attr:`UserSettingsUpdateEvent.partial`. + """ - value: dict[str, tuple[int, str]] = field( - repr=True, hash=True, kw_only=True, eq=True + __slots__ = ( + 'state', + 'data', + 'mocked', + 'partial', + '_android', + '_revite', ) - """The {user_setting_key: (timestamp, value)} mapping.""" + + def __init__( + self, + *, + data: dict[str, tuple[int, str]], + state: State, + mocked: bool, + partial: bool, + ) -> None: + self.state: State = state + self.data: dict[str, tuple[int, str]] = data + self.mocked: bool = mocked + self.partial: bool = partial + self._parse(partial=None) + + def as_dict(self) -> dict[str, str]: + """Dict[:class:`str`, :class:`str`]: The dictionary of `{key -> value}`.""" + return {k: v for k, (_, v) in self.data.items()} + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} data={self.data!r} mocked={self.mocked!r} partial={self.partial!r}>' + + def _parse(self, *, partial: UserSettings | None) -> None: + if partial: + if isinstance(self._android, AndroidUserSettings) and 'android' in partial.data: + android_payload: raw.AndroidUserSettings = utils.from_json(partial['android']) + self._android._update(android_payload) + if isinstance(self._revite, ReviteUserSettings): + self._revite._update(partial, full=False) + else: + try: + self._android: AndroidUserSettings | Exception = AndroidUserSettings(self) + except Exception as exc: + self._android = exc + + try: + self._revite: ReviteUserSettings | Exception = ReviteUserSettings(self) + except Exception as exc: + self._revite = exc + + def _update(self, partial: UserSettings) -> None: + self.data.update(partial.data) + self._parse(partial=partial) + + @property + def android(self) -> AndroidUserSettings: + """:class:`AndroidUserSettings`: The Android user settings. + + Raises + ------ + Exception + If ``android`` user setting JSON string is corrupted. + """ + if isinstance(self._android, Exception): + raise self._android from None + return self._android + + @property + def revite(self) -> ReviteUserSettings: + """:class:`ReviteUserSettings`: The Revite user settings. + + Raises + ------ + Exception + If user settings are corrupted. + """ + if isinstance(self._revite, Exception): + raise self._revite from None + return self._revite def __getitem__(self, key: str) -> str: - return self.value[key][1] + return self.data[key][1] - @t.overload + @typing.overload def get(self, key: str) -> str | None: ... - @t.overload + @typing.overload def get(self, key: str, default: str, /) -> str: ... def get(self, key: str, *default: str) -> str | None: """Get a user setting.""" - if key in self.value: - return self.value[key][1] + if key in self.data: + return self.data[key][1] return default[0] if default else None async def edit( self, - timestamp: datetime | int | None = None, dict_settings: dict[str, str] = {}, + edited_at: datetime | int | None = None, /, - **kw_settings: str, + **kwargs: str, ) -> None: """|coro| @@ -47,9 +171,838 @@ async def edit( .. note:: This can only be used by non-bot accounts. """ - return await self.state.http.set_user_settings( - timestamp, dict_settings, **kw_settings + return await self.state.http.edit_user_settings(dict_settings, edited_at, **kwargs) + + +class AndroidUserSettings: + """Represents Android user settings. + + Attributes + ---------- + parent: :class:`UserSettings` + The parent. + """ + + __slots__ = ( + 'parent', + '_payload', + '_theme', + '_colour_overrides', + '_reply_style', + '_avatar_radius', + ) + + def __init__(self, parent: UserSettings) -> None: + self.parent: UserSettings = parent + payload: raw.AndroidUserSettings = utils.from_json(parent.get('android', '{}')) + self._payload: raw.AndroidUserSettings = payload + + self._update(payload) + + def _update(self, payload: raw.AndroidUserSettings) -> None: + theme = payload.get('theme') + + if theme: + self._theme: AndroidTheme | None = AndroidTheme(theme) + else: + self._theme = None + + self._colour_overrides: dict[str, int] | None = payload.get('colourOverrides') + reply_style = payload.get('messageReplyStyle') + + if reply_style: + self._reply_style: AndroidMessageReplyStyle | None = AndroidMessageReplyStyle(reply_style) + else: + self._reply_style = None + + self._avatar_radius: int | None = payload.get('avatarRadius') + + @property + def theme(self) -> AndroidTheme: + """:class:`AndroidTheme`: The current theme.""" + return self._theme or AndroidTheme.system + + @property + def colour_overrides(self) -> dict[str, int]: + """Dict[:class:`str`, :class:`int`]: The current theme colour overrides.""" + return self._colour_overrides or {} + + @property + def reply_style(self) -> AndroidMessageReplyStyle: + """:class:`AndroidMessageReplyStyle`: The current theme.""" + return self._reply_style or AndroidMessageReplyStyle.swipe_to_reply + + @property + def profile_picture_shape(self) -> int: + """:class:`int`: The current profile picture shape.""" + return self._avatar_radius or 50 + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} theme={self.theme!r} colour_overrides={self.colour_overrides!r} reply_style={self.reply_style!r} profile_picture_shape={self.profile_picture_shape!r}>' + + def payload_for( + self, + *, + initial_payload: UndefinedOr[raw.AndroidUserSettings] = UNDEFINED, + theme: UndefinedOr[AndroidTheme | None] = UNDEFINED, + colour_overrides: UndefinedOr[dict[str, int] | None] = UNDEFINED, + reply_style: UndefinedOr[AndroidMessageReplyStyle | None] = UNDEFINED, + avatar_radius: UndefinedOr[AndroidProfilePictureShape | int | None] = UNDEFINED, + ) -> raw.AndroidUserSettings: + """Builds a payload for Android user settings. You must pass it as JSON string to :meth:`HTTPClient.edit_user_settings`, like so: + + .. code-block:: python3 + + payload = settings.payload_for(theme=AndroidTheme.material_you) + await http.edit_user_settings(android=json.loads(payload)) + + Parameters + ---------- + initial_payload: :class:`UndefinedOr`[raw.AndroidUserSettings] + The initial payload. + theme: :class:`UndefinedOr`[Optional[:class:`AndroidTheme`]] + The new theme. Passing ``None`` denotes ``theme`` removal in internal object. + colour_overrides: :class:`UndefinedOr`[Optional[Dict[:class:`str`, :class:`int`]]] + The new colour overrides. Passing ``None`` denotes ``colourOverrides`` removal in internal object. + reply_style: :class:`UndefinedOr`[Optional[:class:`AndroidMessageReplyStyle`]] + The new message reply style. Passing ``None`` denotes ``messageReplyStyle`` removal in internal object. + avatar_radius: :class:`UndefinedOr`[Optional[Union[:class:`AndroidProfilePictureShape`, :class:`int`]]] + The new avatar radius. Passing ``None`` denotes ``avatarRadius`` removal in internal object. + """ + + if initial_payload is not UNDEFINED: + payload = initial_payload | {} + else: + payload = self._payload | {} + + if theme is not UNDEFINED: + if theme is None: + payload.pop('theme', None) + else: + payload['theme'] = theme.value + + if colour_overrides is not UNDEFINED: + if colour_overrides is None: + payload.pop('colourOverrides', None) + else: + payload['colourOverrides'] = colour_overrides + + if reply_style is not UNDEFINED: + if reply_style is None: + payload.pop('messageReplyStyle', None) + else: + payload['messageReplyStyle'] = reply_style.value + + if avatar_radius is not UNDEFINED: + if avatar_radius is None: + payload.pop('avatarRadius', None) + elif isinstance(avatar_radius, AndroidProfilePictureShape): + payload['avatarRadius'] = avatar_radius.value + else: + payload['avatarRadius'] = avatar_radius + + return payload + + async def edit( + self, + *, + edited_at: datetime | int | None = None, + theme: UndefinedOr[AndroidTheme | None] = UNDEFINED, + colour_overrides: UndefinedOr[dict[str, int] | None] = UNDEFINED, + reply_style: UndefinedOr[AndroidMessageReplyStyle | None] = UNDEFINED, + avatar_radius: UndefinedOr[AndroidProfilePictureShape | int | None] = UNDEFINED, + ) -> None: + """|coro| + + Edits the Android user settings. + + Parameters + ---------- + edited_at: Optional[Union[:class:`datetime`, :class:`int`]] + External parameter to pass in :meth:`HTTPClient.edit_user_settings`. + theme: :class:`UndefinedOr`[Optional[:class:`AndroidTheme`]] + The new theme. Passing ``None`` denotes ``theme`` removal in internal object. + colour_overrides: :class:`UndefinedOr`[Optional[Dict[:class:`str`, :class:`int`]]] + The new colour overrides. Passing ``None`` denotes ``colourOverrides`` removal in internal object. + reply_style: :class:`UndefinedOr`[Optional[:class:`AndroidMessageReplyStyle`]] + The new message reply style. Passing ``None`` denotes ``messageReplyStyle`` removal in internal object. + avatar_radius: :class:`UndefinedOr`[Optional[Union[:class:`AndroidProfilePictureShape`, :class:`int`]]] + The new avatar radius. Passing ``None`` denotes ``avatarRadius`` removal in internal object. + """ + payload = self.payload_for( + theme=theme, + colour_overrides=colour_overrides, + reply_style=reply_style, + avatar_radius=avatar_radius, + ) + await self.parent.edit({'android': utils.to_json(payload)}, edited_at) + + +class ReviteNotificationOptions: + """Represents Revite notification options. + + Attributes + ---------- + servers: Dict[:class:`str`, :class:`ReviteNotificationState`] + The servers. + channels: Dict[:class:`str`, :class:`ReviteNotificationState`] + The channels. + """ + + __slots__ = ('servers', 'channels') + + def __init__(self, data: raw.ReviteNotificationOptions) -> None: + self.servers: dict[str, ReviteNotificationState] = { + server_id: ReviteNotificationState(state) for server_id, state in data['server'].items() + } + self.channels: dict[str, ReviteNotificationState] = { + channel_id: ReviteNotificationState(state) for channel_id, state in data['channel'].items() + } + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} servers={self.servers!r} channels={self.channels!r}>' + + +ReviteThemeVariable: typing.TypeAlias = 'raw.ReviteThemeVariable' + + +class ReviteUserSettings: + """Represents Revite user settings. + + Attributes + ---------- + parent: :class:`UserSettings` + The raw user settings. + last_viewed_changelog_entry: Optional[:class:`ReviteChangelogEntry`] + The last viewed changelog entry. + seasonal: Optional[:class:`bool`] + Whether to display effects in the home tab during holiday seasons or not. + transparent: Optional[:class:`bool`] + Whether to enable transparency effects throughout the app or not. + ligatures: Optional[:class:`bool`] + Whether to combine characters together or not. + For example, ``->`` turns into an arrow if this property is ``True``. + Applicable only for supported fonts (such as :attr:`ReviteFont.inter`). + """ + + __slots__ = ( + 'parent', + '_changelog_payload', + 'last_viewed_changelog_entry', + '_locale_payload', + '_language', + '_notifications_payload', + '_notification_options', + '_ordering_payload', + '_ordering', + '_appearance_payload', + '_appearance_emoji_pack', + 'seasonal', + 'transparent', + 'ligatures', + '_theme_payload', + '_appearance_theme_base', + '_appearance_theme_css', + '_appearance_theme_font', + # "_appearance_theme_light", + '_appearance_theme_monofont', + '_appearance_theme_overrides', + ) + + def __init__(self, parent: UserSettings) -> None: + self.parent: UserSettings = parent + self._update(parent, full=True) + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} last_viewed_changelog_entry={self.last_viewed_changelog_entry!r} language={self.language!r} notification_options={self._notification_options!r} ordering={self.ordering!r} emoji_pack={self.emoji_pack!r} seasonal={self.seasonal!r} transparent={self.transparent!r} ligatures={self.ligatures!r} base_theme={self.base_theme!r} custom_css={self.custom_css!r} font={self.font!r} monofont={self.monofont!r} theme_overrides={self.theme_overrides!r}>' + + def get_language(self) -> Language | None: + """Optional[:class:`Language`]: The current language.""" + return self._language + + @property + def language(self) -> Language: + """:class:`Language`: The current language. Defaults to :attr:`Language.english_simplified` if language is undefined.""" + return self._language or Language.english + + def get_notification_options(self) -> ReviteNotificationOptions | None: + """Optional[:class:`ReviteNotificationOptions`]: The notification options.""" + return self._notification_options + + @property + def notification_options(self) -> ReviteNotificationOptions: + """:class:`ReviteNotificationOptions`: The notification options.""" + return self._notification_options or ReviteNotificationOptions({'server': {}, 'channel': {}}) + + @property + def ordering(self) -> list[str]: + """List[:class:`str`]: The server ordering.""" + return self._ordering or [] + + def get_emoji_pack(self) -> ReviteEmojiPack | None: + """Optional[:class:`ReviteEmojiPack`]: Gets the current emoji pack.""" + return self._appearance_emoji_pack + + @property + def emoji_pack(self) -> ReviteEmojiPack: + """:class:`ReviteEmojiPack`: The current emoji pack.""" + return self._appearance_emoji_pack or ReviteEmojiPack.mutant_remix + + def is_seasonal(self) -> bool: + """:class:`bool`: Whether to display effects in the home tab during holiday seasons or not.""" + return True if self.seasonal is None else self.seasonal + + def is_transparent(self) -> bool: + """Whether to enable transparency effects throughout the app or not.""" + return True if self.transparent is None else self.transparent + + def is_ligatures_enabled(self) -> bool: + """:class:`bool`: Whether to combine characters together or not. + For example, ``->`` turns into an arrow if this property is ``True``. + + Applicable only for supported fonts (such as :attr:`ReviteFont.inter`). + """ + return True if self.ligatures is None else self.ligatures + + def get_base_theme(self) -> ReviteBaseTheme | None: + """Optional[:class:`ReviteBaseTheme`]: The current base theme.""" + return self._appearance_theme_base + + @property + def base_theme(self) -> ReviteBaseTheme: + """:class:`ReviteBaseTheme`: The current base theme. Defaults to :attr:`ReviteBaseTheme.dark` if base theme is undefined.""" + return self._appearance_theme_base or ReviteBaseTheme.dark + + @property + def custom_css(self) -> str | None: + """Optional[:class:`str`]: The custom CSS string.""" + return self._appearance_theme_css + + def get_font(self) -> ReviteFont | None: + """Optional[:class:`ReviteFont`]: The current Revite font.""" + return self._appearance_theme_font + + @property + def font(self) -> ReviteFont: + """:class:`ReviteFont`: The current Revite font. Defaults to :attr:`ReviteFont.open_sans` if font is undefined.""" + return self._appearance_theme_font or ReviteFont.open_sans + + def get_monofont(self) -> ReviteMonoFont | None: + """Optional[:class:`ReviteMonoFont`]: The current Revite monospace font.""" + return self._appearance_theme_monofont + + @property + def monofont(self) -> ReviteMonoFont: + """:class:`ReviteMonoFont`: The current Revite monospace font. Defaults to :attr:`ReviteMonoFont.fira_code` if monofont is undefined.""" + return self._appearance_theme_monofont or ReviteMonoFont.fira_code + + def get_theme_overrides(self) -> dict[ReviteThemeVariable, str] | None: + """Optional[Dict[:class:`ReviteThemeVariable`, :class:`str`]]: The theme overrides.""" + return self._appearance_theme_overrides + + @property + def theme_overrides(self) -> dict[ReviteThemeVariable, str]: + """Dict[:class:`ReviteThemeVariable`, :class:`str`]: The theme overrides.""" + return self._appearance_theme_overrides or {} + + def _on_changelog(self, changelog: raw.ReviteChangelog) -> None: + self._changelog_payload: raw.ReviteChangelog | None = changelog + self.last_viewed_changelog_entry: ReviteChangelogEntry | None = ReviteChangelogEntry(changelog['viewed']) + + def _on_locale(self, locale: raw.ReviteLocaleOptions) -> None: + self._locale_payload: raw.ReviteLocaleOptions | None = locale + self._language: Language | None = Language(locale['lang']) + + def _on_notifications(self, notifications: raw.ReviteNotificationOptions) -> None: + self._notifications_payload: raw.ReviteNotificationOptions | None = notifications + self._notification_options: ReviteNotificationOptions | None = ReviteNotificationOptions(notifications) + + def _on_ordering(self, ordering: raw.ReviteOrdering) -> None: + self._ordering_payload: raw.ReviteOrdering | None = ordering + self._ordering: list[str] | None = ordering['servers'] + + def _on_appearance(self, appearance: raw.ReviteAppearanceSettings) -> None: + self._appearance_payload: raw.ReviteAppearanceSettings | None = appearance + + appearance_emoji_pack = appearance.get('appearance:emoji') + if appearance_emoji_pack: + self._appearance_emoji_pack: ReviteEmojiPack | None = ReviteEmojiPack(appearance_emoji_pack) + else: + self._appearance_emoji_pack = None + + self.seasonal: bool | None = appearance.get('appearance:seasonal') + self.transparent: bool | None = appearance.get('appearance:transparency') + + def _on_theme(self, theme: raw.ReviteThemeSettings) -> None: + self._theme_payload: raw.ReviteThemeSettings | None = None + + self.ligatures: bool | None = theme.get('appearance:ligatures') + + base_theme = theme.get('appearance:theme:base') + + if base_theme: + self._appearance_theme_base = ReviteBaseTheme(base_theme) + else: + self._appearance_theme_base = None + self._appearance_theme_css = theme.get('appearance:theme:css') + + font = theme.get('appearance:theme:font') + if font: + self._appearance_theme_font: ReviteFont | None = ReviteFont(font) + else: + self._appearance_theme_font = None + + # Deprecated by base theme + # self._appearance_theme_light: bool | None = theme.get('appearance:theme:light') + + monofont = theme.get('appearance:theme:monoFont') + if monofont: + self._appearance_theme_monofont: ReviteMonoFont | None = ReviteMonoFont(monofont) + else: + self._appearance_theme_monofont = None + self._appearance_theme_overrides: dict[ReviteThemeVariable, str] | None = theme.get( + 'appearance:theme:overrides' + ) + + def _update(self, payload: UserSettings, *, full: bool) -> None: + changelog_json = payload.get('changelog') + if changelog_json: + changelog: raw.ReviteChangelog = utils.from_json(changelog_json) + self._on_changelog(changelog) + elif full: + self._changelog_payload = None + self.last_viewed_changelog_entry = None + + locale_json = payload.get('locale') + if locale_json: + locale: raw.ReviteLocaleOptions = utils.from_json(locale_json) + self._on_locale(locale) + elif full: + self._locale_payload = None + self._language = None + + notifications_json = payload.get('notifications') + if notifications_json: + notifications: raw.ReviteNotificationOptions = utils.from_json(notifications_json) + self._on_notifications(notifications) + elif full: + self._notifications_payload = None + self._notification_options = None + + ordering_json = payload.get('ordering') + if ordering_json: + ordering: raw.ReviteOrdering = utils.from_json(ordering_json) + self._on_ordering(ordering) + elif full: + self._ordering_payload = None + self._ordering = None + + appearance_json = payload.get('appearance') + if appearance_json: + appearance: raw.ReviteAppearanceSettings = utils.from_json(appearance_json) + self._on_appearance(appearance) + elif full: + self._appearance_payload = None + self._appearance_emoji_pack = None + self.seasonal = None + self.transparent = None + + theme_json = payload.get('theme') + if theme_json: + theme: raw.ReviteThemeSettings = utils.from_json(theme_json) + self._on_theme(theme) + elif full: + self._theme_payload = None + self.ligatures = None + self._appearance_theme_base = None + self._appearance_theme_css = None + self._appearance_theme_font = None + # self._appearance_theme_light = None + self._appearance_theme_monofont = None + self._appearance_theme_overrides = None + + def payload_for( + self, + *, + initial_changelog_payload: UndefinedOr[raw.ReviteChangelog] = UNDEFINED, + last_viewed_changelog_entry: UndefinedOr[ReviteChangelogEntry | int] = UNDEFINED, + initial_locale_payload: UndefinedOr[raw.ReviteLocaleOptions] = UNDEFINED, + language: UndefinedOr[Language] = UNDEFINED, + initial_notifications_payload: UndefinedOr[raw.ReviteNotificationOptions] = UNDEFINED, + server_notifications: UndefinedOr[dict[ULIDOr[BaseServer], ReviteNotificationState]] = UNDEFINED, + merge_server_notifications: bool = True, + channel_notifications: UndefinedOr[dict[ULIDOr[Channel], ReviteNotificationState]] = UNDEFINED, + merge_channel_notifications: bool = True, + initial_ordering_payload: UndefinedOr[raw.ReviteOrdering] = UNDEFINED, + ordering: UndefinedOr[list[ULIDOr[BaseServer]]] = UNDEFINED, + initial_appearance_payload: UndefinedOr[raw.ReviteAppearanceSettings] = UNDEFINED, + emoji_pack: UndefinedOr[ReviteEmojiPack | None] = UNDEFINED, + seasonal: UndefinedOr[bool | None] = UNDEFINED, + transparent: UndefinedOr[bool | None] = UNDEFINED, + initial_theme_payload: UndefinedOr[raw.ReviteThemeSettings] = UNDEFINED, + ligatures: UndefinedOr[bool | None] = UNDEFINED, + base_theme: UndefinedOr[ReviteBaseTheme | None] = UNDEFINED, + custom_css: UndefinedOr[str | None] = UNDEFINED, + font: UndefinedOr[ReviteFont | None] = UNDEFINED, + monofont: UndefinedOr[ReviteMonoFont | None] = UNDEFINED, + overrides: UndefinedOr[dict[ReviteThemeVariable, str] | None] = UNDEFINED, + ) -> raw.ReviteUserSettingsPayload: + """Builds a payload for Revite user settings. You must pass it as first argument to :meth:`HTTPClient.edit_user_settings`, like so: + + .. code-block:: python3 + + payload = settings.payload_for(language=Language.russian) + await http.edit_user_settings(payload) + + Parameters + ---------- + initial_changelog_payload: :class:`UndefinedOr`[raw.ReviteChangelog] + The initial ``changelog`` payload. + last_viewed_changelog_entry: :class:`UndefinedOr`[Union[:class:`ReviteChangelogEntry`, :class:`int`]] + The last viewed changelog entry. + initial_locale_payload: :class:`UndefinedOr`[raw.ReviteLocaleOptions] + The initial ``locale`` payload. + language: :class:`UndefinedOr`[:class:`Language`] + The language. + initial_notifications_payload: :class:`UndefinedOr`[raw.ReviteNotificationOptions] + The initial ``notifications`` payload. + server_notifications: :class:`UndefinedOr`[Dict[:class:`ULIDOr`[:class:`BaseServer`], :class:`ReviteNotificationState`]] + The notification options for servers. + merge_server_notifications: :class:`bool` + Whether to merge new servers notifications options into existing ones. Defaults to ``True``. + channel_notifications: :class:`UndefinedOr`[Dict[:class:`ULIDOr`[:class:`Channel`], :class:`ReviteNotificationState`]] + The notification options for channels. + merge_channel_notifications: :class:`bool` + Whether to merge new channels notifications options into existing ones. Defaults to ``True``. + initial_ordering_payload: :class:`UndefinedOr`[raw.ReviteOrdering] + The initial ``ordering`` payload. + ordering: :class:`UndefinedOr`[List[:class:`ULIDOr`[:class:`BaseServer`]]] + The servers tab order. + initial_appearance_payload: :class:`UndefinedOr`[raw.ReviteAppearanceSettings] + The initial ``appearance`` payload. + emoji_pack: :class:`UndefinedOr`[Optional[:class:`ReviteEmojiPack`]] + The new emoji pack to use. Passing ``None`` denotes ``appearance.appearance:emoji`` removal in internal object. + seasonal: :class:`UndefinedOr`[Optional[:class:`bool`]] + To display effects in the home tab during holiday seasons or not. + Passing ``None`` denotes ``appearance.appearance:seasonal`` removal in internal object. + transparent: :class:`UndefinedOr`[Optional[:class:`bool`]] + To enable transparency effects throughout the app or not. + Passing ``None`` denotes ``appearance.appearance:transparency`` removal in internal object. + initial_theme_payload: :class:`UndefinedOr`[raw.ReviteThemeSettings] + The initial ``theme`` payload. + ligatures: :class:`UndefinedOr`[Optional[:class:`bool`]] + To combine characters together or not. More details in :attr:`ReviteUserSettings.ligatures`. + Passing ``None`` denotes ``theme.appearance:ligatures`` removal in internal object. + base_theme: :class:`UndefinedOr`[Optional[:class:`ReviteBaseTheme`]] + The base theme to use. + Passing ``None`` denotes ``theme.appearance:theme:base`` removal in internal object. + custom_css: :class:`UndefinedOr`[Optional[:class:`str`]] + The CSS string. + Passing ``None`` denotes ``theme.appearance:theme:css`` removal in internal object. + font: :class:`UndefinedOr`[Optional[:class:`ReviteFont`]] + The font to use across the app. + Passing ``None`` denotes ``theme.appearance:theme:font`` removal in internal object. + monofont: :class:`UndefinedOr`[Optional[:class:`ReviteMonoFont`]] + The monospace font to use in codeblocks. + Passing ``None`` denotes ``theme.appearance:theme:monoFont`` removal in internal object. + overrides: :class:`UndefinedOr`[Optional[Dict[:class:`ReviteThemeVariable`, :class:`str`]]] + The theme overrides. + Passing ``None`` denotes ``theme.appearance:theme:overrides`` removal in internal object. + + Returns + ------- + Dict[:class:`str`, Any] + The payload that must to be passed in :meth:`HTTPClient.edit_user_settings`. + """ + payload: raw.ReviteUserSettingsPayload = {} + + if last_viewed_changelog_entry is not UNDEFINED: + changelog_payload = ( + self._changelog_payload if initial_changelog_payload is UNDEFINED else initial_changelog_payload + ) + + viewed = ( + last_viewed_changelog_entry.value + if isinstance(last_viewed_changelog_entry, ReviteChangelogEntry) + else last_viewed_changelog_entry + ) + + if changelog_payload is not None: + changelog: raw.ReviteChangelog = changelog_payload | {'viewed': viewed} + else: + changelog = {'viewed': viewed} + + payload['changelog'] = utils.to_json(changelog) + + if language is not UNDEFINED: + locale_payload = self._locale_payload if initial_locale_payload is UNDEFINED else initial_locale_payload + + if locale_payload is not None: + locale: raw.ReviteLocaleOptions = locale_payload | {'lang': language.value} + else: + locale = {'lang': language.value} + payload['locale'] = utils.to_json(locale) + + if server_notifications is not UNDEFINED or channel_notifications is not UNDEFINED: + notifications_payload = ( + self._notifications_payload + if initial_notifications_payload is UNDEFINED + else initial_notifications_payload + ) + + options = self._notification_options + if options: + if merge_server_notifications: + servers: dict[str, raw.ReviteNotificationState] = { + server_id: state.value for server_id, state in options.servers.items() + } + else: + servers = {} + + if merge_channel_notifications: + channels: dict[str, raw.ReviteNotificationState] = { + channel_id: state.value for channel_id, state in options.channels.items() + } + else: + channels = {} + else: + servers = {} + channels = {} + + if notifications_payload is not None: + notifications: raw.ReviteNotificationOptions = notifications_payload | { + 'server': servers, + 'channel': channels, + } + else: + notifications: raw.ReviteNotificationOptions = { + 'server': servers, + 'channel': channels, + } + + if server_notifications is not UNDEFINED: + server_payload = notifications['server'] | { + resolve_id(server): state.value for server, state in server_notifications.items() + } + # I literally don't know why it errors... + notifications['server'] = server_payload # type: ignore + if channel_notifications is not UNDEFINED: + channel_payload = notifications['channel'] | { + resolve_id(channel): state.value for channel, state in channel_notifications.items() + } + notifications['channel'] = channel_payload # type: ignore + payload['notifications'] = utils.to_json(notifications) + + if ordering is not UNDEFINED: + existing_ordering_payload = ( + self._ordering_payload if initial_ordering_payload is UNDEFINED else initial_ordering_payload + ) + + if existing_ordering_payload is not None: + ordering_payload: raw.ReviteOrdering = existing_ordering_payload | { + 'servers': [resolve_id(server_id) for server_id in ordering] + } + else: + ordering_payload: raw.ReviteOrdering = {'servers': [resolve_id(server_id) for server_id in ordering]} + payload['ordering'] = utils.to_json(ordering_payload) + + if emoji_pack is not UNDEFINED or seasonal is not UNDEFINED or transparent is not UNDEFINED: + if initial_appearance_payload is UNDEFINED: + if self._appearance_payload is not None: + appearance_payload: raw.ReviteAppearanceSettings = self._appearance_payload + else: + appearance_payload = {} + else: + appearance_payload = initial_appearance_payload + + if emoji_pack is None: + appearance_payload.pop('appearance:emoji', None) + elif emoji_pack is not UNDEFINED: + appearance_payload['appearance:emoji'] = emoji_pack.value + + if seasonal is None: + appearance_payload.pop('appearance:seasonal', None) + elif seasonal is not UNDEFINED: + appearance_payload['appearance:seasonal'] = seasonal + + if transparent is None: + appearance_payload.pop('appearance:transparency', None) + elif transparent is not UNDEFINED: + appearance_payload['appearance:transparency'] = transparent + + payload['appearance'] = utils.to_json(appearance_payload) + + if ( + ligatures is not UNDEFINED + or base_theme is not UNDEFINED + or custom_css is not UNDEFINED + or font is not UNDEFINED + or monofont is not UNDEFINED + or overrides is not UNDEFINED + ): + if initial_theme_payload is UNDEFINED: + if self._theme_payload is not None: + theme_payload: raw.ReviteThemeSettings = self._theme_payload + else: + theme_payload = {} + else: + theme_payload = initial_theme_payload + + if ligatures is None: + theme_payload.pop('appearance:ligatures', None) + elif ligatures is not UNDEFINED: + theme_payload['appearance:ligatures'] = ligatures + + if base_theme is None: + theme_payload.pop('appearance:theme:base', None) + elif base_theme is not UNDEFINED: + theme_payload['appearance:theme:base'] = base_theme.value + + if custom_css is None: + theme_payload.pop('appearance:theme:css', None) + elif custom_css is not UNDEFINED: + theme_payload['appearance:theme:css'] = custom_css + + if font is None: + theme_payload.pop('appearance:theme:font', None) + elif font is not UNDEFINED: + theme_payload['appearance:theme:font'] = font.value + + if monofont is None: + theme_payload.pop('appearance:theme:monoFont', None) + elif monofont is not UNDEFINED: + theme_payload['appearance:theme:monoFont'] = monofont.value + + if overrides is None: + theme_payload.pop('appearance:theme:overrides', None) + elif overrides is not UNDEFINED: + theme_payload['appearance:theme:overrides'] = overrides + + payload['theme'] = utils.to_json(theme_payload) + + return payload + + async def edit( + self, + *, + edited_at: datetime | int | None = None, + initial_changelog_payload: UndefinedOr[raw.ReviteChangelog] = UNDEFINED, + last_viewed_changelog_entry: UndefinedOr[ReviteChangelogEntry | int] = UNDEFINED, + initial_locale_payload: UndefinedOr[raw.ReviteLocaleOptions] = UNDEFINED, + language: UndefinedOr[Language] = UNDEFINED, + initial_notifications_payload: UndefinedOr[raw.ReviteNotificationOptions] = UNDEFINED, + server_notifications: UndefinedOr[dict[ULIDOr[BaseServer], ReviteNotificationState]] = UNDEFINED, + merge_server_notifications: bool = True, + channel_notifications: UndefinedOr[dict[ULIDOr[Channel], ReviteNotificationState]] = UNDEFINED, + merge_channel_notifications: bool = True, + initial_ordering_payload: UndefinedOr[raw.ReviteOrdering] = UNDEFINED, + ordering: UndefinedOr[list[ULIDOr[BaseServer]]] = UNDEFINED, + initial_appearance_payload: UndefinedOr[raw.ReviteAppearanceSettings] = UNDEFINED, + emoji_pack: UndefinedOr[ReviteEmojiPack | None] = UNDEFINED, + seasonal: UndefinedOr[bool | None] = UNDEFINED, + transparent: UndefinedOr[bool | None] = UNDEFINED, + initial_theme_payload: UndefinedOr[raw.ReviteThemeSettings] = UNDEFINED, + ligatures: UndefinedOr[bool | None] = UNDEFINED, + base_theme: UndefinedOr[ReviteBaseTheme | None] = UNDEFINED, + custom_css: UndefinedOr[str | None] = UNDEFINED, + font: UndefinedOr[ReviteFont | None] = UNDEFINED, + monofont: UndefinedOr[ReviteMonoFont | None] = UNDEFINED, + overrides: UndefinedOr[dict[ReviteThemeVariable, str] | None] = UNDEFINED, + ) -> None: + """|coro| + + Edits the Revite user settings. + + Parameters + ---------- + edited_at: Optional[Union[:class:`datetime`, :class:`int`]] + External parameter to pass in :meth:`HTTPClient.edit_user_settings`. + initial_changelog_payload: :class:`UndefinedOr`[raw.ReviteChangelog] + The initial ``changelog`` payload. + last_viewed_changelog_entry: :class:`UndefinedOr`[Union[:class:`ReviteChangelogEntry`, :class:`int`]] + The last viewed changelog entry. + initial_locale_payload: :class:`UndefinedOr`[raw.ReviteLocaleOptions] + The initial ``locale`` payload. + language: :class:`UndefinedOr`[:class:`Language`] + The language. + initial_notifications_payload: :class:`UndefinedOr`[raw.ReviteNotificationOptions] + The initial ``notifications`` payload. + server_notifications: :class:`UndefinedOr`[Dict[:class:`ULIDOr`[:class:`BaseServer`], :class:`ReviteNotificationState`]] + The notification options for servers. + merge_server_notifications: :class:`bool` + Whether to merge new servers notifications options into existing ones. Defaults to ``True``. + channel_notifications: :class:`UndefinedOr`[Dict[:class:`ULIDOr`[:class:`Channel`], :class:`ReviteNotificationState`]] + The notification options for channels. + merge_channel_notifications: :class:`bool` + Whether to merge new channels notifications options into existing ones. Defaults to ``True``. + initial_ordering_payload: :class:`UndefinedOr`[raw.ReviteOrdering] + The initial ``ordering`` payload. + ordering: :class:`UndefinedOr`[List[:class:`ULIDOr`[:class:`BaseServer`]]] + The servers tab order. + initial_appearance_payload: :class:`UndefinedOr`[raw.ReviteAppearanceSettings] + The initial ``appearance`` payload. + emoji_pack: :class:`UndefinedOr`[Optional[:class:`ReviteEmojiPack`]] + The new emoji pack to use. Passing ``None`` denotes ``appearance.appearance:emoji`` removal in internal object. + seasonal: :class:`UndefinedOr`[Optional[:class:`bool`]] + To display effects in the home tab during holiday seasons or not. + Passing ``None`` denotes ``appearance.appearance:seasonal`` removal in internal object. + transparent: :class:`UndefinedOr`[Optional[:class:`bool`]] + To enable transparency effects throughout the app or not. + Passing ``None`` denotes ``appearance.appearance:transparency`` removal in internal object. + initial_theme_payload: :class:`UndefinedOr`[raw.ReviteThemeSettings] + The initial ``theme`` payload. + ligatures: :class:`UndefinedOr`[Optional[:class:`bool`]] + To combine characters together or not. More details in :attr:`ReviteUserSettings.ligatures`. + Passing ``None`` denotes ``theme.appearance:ligatures`` removal in internal object. + base_theme: :class:`UndefinedOr`[Optional[:class:`ReviteBaseTheme`]] + The base theme to use. + Passing ``None`` denotes ``theme.appearance:theme:base`` removal in internal object. + custom_css: :class:`UndefinedOr`[Optional[:class:`str`]] + The CSS string. + Passing ``None`` denotes ``theme.appearance:theme:css`` removal in internal object. + font: :class:`UndefinedOr`[Optional[:class:`ReviteFont`]] + The font to use across the app. + Passing ``None`` denotes ``theme.appearance:theme:font`` removal in internal object. + monofont: :class:`UndefinedOr`[Optional[:class:`ReviteMonoFont`]] + The monospace font to use in codeblocks. + Passing ``None`` denotes ``theme.appearance:theme:monoFont`` removal in internal object. + overrides: :class:`UndefinedOr`[Optional[Dict[:class:`ReviteThemeVariable`, :class:`str`]]] + The theme overrides. + Passing ``None`` denotes ``theme.appearance:theme:overrides`` removal in internal object. + """ + + payload: raw.ReviteUserSettingsPayload = self.payload_for( + initial_changelog_payload=initial_changelog_payload, + last_viewed_changelog_entry=last_viewed_changelog_entry, + initial_locale_payload=initial_locale_payload, + language=language, + initial_notifications_payload=initial_notifications_payload, + server_notifications=server_notifications, + merge_server_notifications=merge_server_notifications, + channel_notifications=channel_notifications, + merge_channel_notifications=merge_channel_notifications, + initial_ordering_payload=initial_ordering_payload, + ordering=ordering, + initial_appearance_payload=initial_appearance_payload, + emoji_pack=emoji_pack, + seasonal=seasonal, + transparent=transparent, + initial_theme_payload=initial_theme_payload, + ligatures=ligatures, + base_theme=base_theme, + custom_css=custom_css, + font=font, + monofont=monofont, + overrides=overrides, ) + # It works tho + await self.parent.edit({}, edited_at, **payload) -__all__ = ("UserSettings",) +__all__ = ( + 'UserSettings', + 'AndroidUserSettings', + 'ReviteNotificationOptions', + 'ReviteThemeVariable', + 'ReviteUserSettings', +) diff --git a/pyvolt/utils.py b/pyvolt/utils.py index 9e19e8b..144381d 100644 --- a/pyvolt/utils.py +++ b/pyvolt/utils.py @@ -1,6 +1,31 @@ -# Thanks Rapptz: +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +# Thanks Danny: # - https://github.com/Rapptz/discord.py/blob/041abf8b487038c2935da668405ba8b0686ff2f8/discord/utils.py#L77-L82 # - https://github.com/Rapptz/discord.py/blob/041abf8b487038c2935da668405ba8b0686ff2f8/discord/utils.py#L641-L653 +# - https://github.com/Rapptz/discord.py/blob/ff638d393d0f5a83639ccc087bec9bf588b59a22/discord/utils.py#L1253-L1374 from __future__ import annotations @@ -9,7 +34,9 @@ import inspect import json import logging -import typing as t +import os +import sys +import typing try: import orjson @@ -18,39 +45,37 @@ else: HAS_ORJSON = True -if t.TYPE_CHECKING: - from collections import abc as ca +if typing.TYPE_CHECKING: + from collections.abc import Awaitable, Callable - from . import raw + P = typing.ParamSpec('P') + T = typing.TypeVar('T') - P = t.ParamSpec("P") - T = t.TypeVar("T") + MaybeAwaitable = T | Awaitable[T] + MaybeAwaitableFunc = Callable[P, MaybeAwaitable[T]] - MaybeAwaitable = T | ca.Awaitable[T] - MaybeAwaitableFunc = ca.Callable[P, MaybeAwaitable[T]] +from .core import UNDEFINED, UndefinedOr _L = logging.getLogger(__name__) if HAS_ORJSON: - def to_json(obj: t.Any) -> str: - return orjson.dumps(obj).decode("utf-8") # type: ignore + def to_json(obj: typing.Any) -> str: + return orjson.dumps(obj).decode('utf-8') # type: ignore from_json = orjson.loads # type: ignore else: - def to_json(obj: t.Any) -> str: - return json.dumps(obj, separators=(",", ":"), ensure_ascii=True) + def to_json(obj: typing.Any) -> str: + return json.dumps(obj, separators=(',', ':'), ensure_ascii=True) from_json = json.loads -async def _maybe_coroutine( - f: MaybeAwaitableFunc[P, T], *args: P.args, **kwargs: P.kwargs -) -> T: +async def _maybe_coroutine(f: MaybeAwaitableFunc[P, T], *args: P.args, **kwargs: P.kwargs) -> T: value = f(*args, **kwargs) if inspect.isawaitable(value): return await value @@ -58,20 +83,18 @@ async def _maybe_coroutine( return value -_TRUE: t.Literal["true"] = "true" -_FALSE: t.Literal["false"] = "false" +_TRUE: typing.Literal['true'] = 'true' +_FALSE: typing.Literal['false'] = 'false' -def _bool(b: bool) -> "raw.Bool": +def _bool(b: bool) -> typing.Literal['true', 'false']: return _TRUE if b else _FALSE -async def _json_or_text(response: aiohttp.ClientResponse) -> t.Any: - text = await response.text(encoding="utf-8") +async def _json_or_text(response: aiohttp.ClientResponse) -> typing.Any: + text = await response.text(encoding='utf-8') try: - if response.headers["content-type"] == "application/json": - if len(text) < 2000: - _L.debug("Decoding %s", text) + if response.headers['content-type'].startswith('application/json'): return from_json(text) except KeyError: # Thanks Cloudflare @@ -84,11 +107,135 @@ def utcnow() -> datetime.datetime: return datetime.datetime.now(datetime.timezone.utc) +def is_docker() -> bool: + path = '/proc/self/cgroup' + return os.path.exists('/.dockerenv') or (os.path.isfile(path) and any('docker' in line for line in open(path))) + + +def stream_supports_colour(stream: typing.Any) -> bool: + is_a_tty = hasattr(stream, 'isatty') and stream.isatty() + + # Pycharm and VSCode support colour in their inbuilt editors + if 'PYCHARM_HOSTED' in os.environ or os.environ.get('TERM_PROGRAM') == 'vscode': + return is_a_tty + + if sys.platform != 'win32': + # Docker does not consistently have a tty attached to it + return is_a_tty or is_docker() + + # ANSICON checks for things like ConEmu + # WT_SESSION checks if this is Windows Terminal + return is_a_tty and ('ANSICON' in os.environ or 'WT_SESSION' in os.environ) + + +class _ColourFormatter(logging.Formatter): + # ANSI codes are a bit weird to decipher if you're unfamiliar with them, so here's a refresher + # It starts off with a format like \x1b[XXXm where XXX is a semicolon separated list of commands + # The important ones here relate to colour. + # 30-37 are black, red, green, yellow, blue, magenta, cyan and white in that order + # 40-47 are the same except for the background + # 90-97 are the same but "bright" foreground + # 100-107 are the same as the bright ones but for the background. + # 1 means bold, 2 means dim, 0 means reset, and 4 means underline. + + LEVEL_COLOURS = [ + (logging.DEBUG, '\x1b[40;1m'), + (logging.INFO, '\x1b[34;1m'), + (logging.WARNING, '\x1b[33;1m'), + (logging.ERROR, '\x1b[31m'), + (logging.CRITICAL, '\x1b[41m'), + ] + + FORMATS = { + level: logging.Formatter( + f'\x1b[30;1m%(asctime)s\x1b[0m {colour}%(levelname)-8s\x1b[0m \x1b[35m%(name)s\x1b[0m %(message)s', + '%Y-%m-%d %H:%M:%S', + ) + for level, colour in LEVEL_COLOURS + } + + def format(self, record): + formatter = self.FORMATS.get(record.levelno) + if formatter is None: + formatter = self.FORMATS[logging.DEBUG] + + # Override the traceback to always print in red + if record.exc_info: + text = formatter.formatException(record.exc_info) + record.exc_text = f'\x1b[31m{text}\x1b[0m' + + output = formatter.format(record) + + # Remove the cache layer + record.exc_text = None + return output + + +def setup_logging( + *, + handler: UndefinedOr[logging.Handler] = UNDEFINED, + formatter: UndefinedOr[logging.Formatter] = UNDEFINED, + level: UndefinedOr[int] = UNDEFINED, + root: bool = True, +) -> None: + """A helper function to setup logging. + + This is superficially similar to :func:`logging.basicConfig` but + uses different defaults and a colour formatter if the stream can + display colour. + + This is used by the :class:`~pyvolt.Client` to set up logging + if ``log_handler`` is not ``None``. + + Parameters + ----------- + handler: :class:`logging.Handler` + The log handler to use for the library's logger. + + The default log handler if not provided is :class:`logging.StreamHandler`. + formatter: :class:`logging.Formatter` + The formatter to use with the given log handler. If not provided then it + defaults to a colour based logging formatter (if available). If colour + is not available then a simple logging formatter is provided. + level: :class:`int` + The default log level for the library's logger. Defaults to ``logging.INFO``. + root: :class:`bool` + Whether to set up the root logger rather than the library logger. + Unlike the default for :class:`~pyvolt.Client`, this defaults to ``True``. + """ + + if level is UNDEFINED: + level = logging.INFO + + if handler is UNDEFINED: + handler = logging.StreamHandler() + + if formatter is UNDEFINED: + if isinstance(handler, logging.StreamHandler) and stream_supports_colour(handler.stream): + formatter = _ColourFormatter() + else: + dt_fmt = '%Y-%m-%d %H:%M:%S' + formatter = logging.Formatter('[{asctime}] [{levelname:<8}] {name}: {message}', dt_fmt, style='{') + + if root: + logger = logging.getLogger() + else: + library, _, _ = __name__.partition('.') + logger = logging.getLogger(library) + + handler.setFormatter(formatter) + logger.setLevel(level) + logger.addHandler(handler) + + __all__ = ( - "to_json", - "from_json", - "_maybe_coroutine", - "_bool", - "_json_or_text", - "utcnow", + 'to_json', + 'from_json', + '_maybe_coroutine', + '_bool', + '_json_or_text', + 'utcnow', + 'is_docker', + 'stream_supports_colour', + 'setup_logging', ) diff --git a/pyvolt/webhook.py b/pyvolt/webhook.py index 5c66c1f..076102d 100644 --- a/pyvolt/webhook.py +++ b/pyvolt/webhook.py @@ -1,18 +1,51 @@ +""" +The MIT License (MIT) + +Copyright (c) 2024-present MCausc78 + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + from __future__ import annotations from attrs import define, field -from . import ( - base, - cdn, - core, - message as messages, - permissions as permissions_, +from .base import Base +from .cdn import StatelessAsset, Asset, ResolvableResource +from .core import ( + UNDEFINED, + UndefinedOr, + ULIDOr, +) +from .message import ( + Reply, + Interactions, + Masquerade, + SendableEmbed, + BaseMessage, + Message, ) +from .permissions import Permissions @define(slots=True) -class BaseWebhook(base.Base): +class BaseWebhook(Base): """Representation of Revolt webhook.""" def _token(self) -> str | None: @@ -25,9 +58,9 @@ async def delete(self, *, by_token: bool = False) -> None: Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to delete the webhook. - :class:`APIError` + HTTPException Deleting the webhook failed. """ @@ -41,31 +74,37 @@ async def edit( self, *, by_token: bool = False, - name: core.UndefinedOr[str] = core.UNDEFINED, - avatar: core.UndefinedOr[str | None] = core.UNDEFINED, - permissions: core.UndefinedOr[permissions_.Permissions] = core.UNDEFINED, + name: UndefinedOr[str] = UNDEFINED, + avatar: UndefinedOr[str | None] = UNDEFINED, + permissions: UndefinedOr[Permissions] = UNDEFINED, ) -> Webhook: """|coro| - Edits a webhook. If webhook token wasn't given, the library will attempt edit webhook with bot/user token. + Edits a webhook. If webhook token wasn't given, the library will attempt edit webhook with current bot/user token. Parameters ---------- - name: :class:`UndefinedOr`[:class:`str` | `None`] + token: Optional[:class:`str`] + The webhook token. + name: :class:`UndefinedOr`[:class:`str`] New webhook name. Should be between 1 and 32 chars long. - avatar: :class:`UndefinedOr`[:class:`str` | `None`] - New webhook avatar. Pass attachment ID given by Autumn. - permissions: :class:`UndefinedOr`[:class:`permission.Permissions`] + avatar: :class:`UndefinedOr`[Optional[:class:`ResolvableResource`]] + New webhook avatar. + permissions: :class:`UndefinedOr`[:class:`Permissions`] New webhook permissions. Raises ------ - :class:`Forbidden` + Forbidden You do not have permissions to edit the webhook. - :class:`APIError` + HTTPException Editing the webhook failed. - """ + Returns + ------- + :class:`Webhook` + The newly updated webhook. + """ if by_token: token = self._token() @@ -77,21 +116,19 @@ async def edit( permissions=permissions, ) else: - return await self.state.http.edit_webhook( - self.id, name=name, avatar=avatar, permissions=permissions - ) + return await self.state.http.edit_webhook(self.id, name=name, avatar=avatar, permissions=permissions) async def execute( self, content: str | None = None, *, nonce: str | None = None, - attachments: list[cdn.ResolvableResource] | None = None, - replies: list[messages.Reply | core.ResolvableULID] | None = None, - embeds: list[messages.SendableEmbed] | None = None, - masquerade: messages.Masquerade | None = None, - interactions: messages.Interactions | None = None, - ) -> messages.Message: + attachments: list[ResolvableResource] | None = None, + replies: list[Reply | ULIDOr[BaseMessage]] | None = None, + embeds: list[SendableEmbed] | None = None, + masquerade: Masquerade | None = None, + interactions: Interactions | None = None, + ) -> Message: """|coro| Executes a webhook and returns a message. @@ -102,7 +139,7 @@ async def execute( The message sent. """ token = self._token() - assert token, "No token" + assert token, 'No token' return await self.state.http.execute_webhook( self.id, token, @@ -118,25 +155,19 @@ async def execute( @define(slots=True) class PartialWebhook(BaseWebhook): - name: core.UndefinedOr[str] = field(repr=True, hash=True, kw_only=True, eq=True) + name: UndefinedOr[str] = field(repr=True, hash=True, kw_only=True, eq=True) """The new name of the webhook.""" - internal_avatar: core.UndefinedOr[cdn.StatelessAsset | None] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_avatar: UndefinedOr[StatelessAsset | None] = field(repr=True, hash=True, kw_only=True, eq=True) """The new stateless avatar of the webhook.""" - permissions: core.UndefinedOr[permissions_.Permissions] = field( - repr=True, hash=True, kw_only=True, eq=True - ) + permissions: UndefinedOr[Permissions] = field(repr=True, hash=True, kw_only=True, eq=True) """The new permissions for the webhook.""" @property - def avatar(self) -> core.UndefinedOr[cdn.Asset | None]: + def avatar(self) -> UndefinedOr[Asset | None]: """The new avatar of the webhook.""" - return self.internal_avatar and self.internal_avatar._stateful( - self.state, "avatars" - ) + return self.internal_avatar and self.internal_avatar._stateful(self.state, 'avatars') @define(slots=True) @@ -144,39 +175,34 @@ class Webhook(BaseWebhook): name: str = field(repr=True, hash=True, kw_only=True, eq=True) """The name of the webhook.""" - internal_avatar: cdn.StatelessAsset | None = field( - repr=True, hash=True, kw_only=True, eq=True - ) + internal_avatar: StatelessAsset | None = field(repr=True, hash=True, kw_only=True, eq=True) """The stateless avatar of the webhook.""" - channel_id: core.ULID = field(repr=True, hash=True, kw_only=True, eq=True) + channel_id: str = field(repr=True, hash=True, kw_only=True, eq=True) """The channel this webhook belongs to.""" - permissions: permissions_.Permissions = field( - repr=True, hash=True, kw_only=True, eq=True - ) + permissions: Permissions = field(repr=True, hash=True, kw_only=True, eq=True) """The permissions for the webhook.""" token: str | None = field(repr=True, hash=True, kw_only=True, eq=True) """The private token for the webhook.""" def _update(self, data: PartialWebhook) -> None: - if core.is_defined(data.name): + if data.name is not UNDEFINED: self.name = data.name - if core.is_defined(data.internal_avatar): + if data.internal_avatar is not UNDEFINED: self.internal_avatar = data.internal_avatar - if core.is_defined(data.permissions): + if data.permissions is not UNDEFINED: self.permissions = data.permissions @property - def avatar(self) -> cdn.Asset | None: - """The avatar of the webhook.""" - return self.internal_avatar and self.internal_avatar._stateful( - self.state, "avatars" - ) + def avatar(self) -> Asset | None: + """Optional[:class:`Asset`]: The avatar of the webhook.""" + return self.internal_avatar and self.internal_avatar._stateful(self.state, 'avatars') __all__ = ( - "BaseWebhook", - "Webhook", + 'BaseWebhook', + 'PartialWebhook', + 'Webhook', ) diff --git a/requirements.txt b/requirements.txt index 82aed1c..85a2ad7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ aiohttp>=3.9.5,<4 -ulid-py>=1.1.0,<2 diff --git a/test/data/discovery/bots.json b/test/data/discovery/bots.json deleted file mode 100644 index f223eba..0000000 --- a/test/data/discovery/bots.json +++ /dev/null @@ -1,665 +0,0 @@ -{ - "pageProps": { - "bots": [ - { - "_id": "01GA9PC742D72BM6CNDNXS3X7D", - "username": "Reddit", - "profile": { - "content": "Automatically send new posts from subreddits and redditors.\n\nCommands work on both Redditors and Subreddits.\nRedditor: `u/spez`\nSubreddit: `r/askreddit`\n[Support Server](https://rvlt.gg/SPMxwwC8)" - }, - "tags": [], - "servers": 318, - "usage": "high" - }, - { - "_id": "01FHGJ3NPP7XANQQH8C2BE44ZY", - "username": "AutoMod", - "avatar": { - "_id": "pYjK-QyMv92hy8GUM-b4IK1DMzYILys9s114khzzKY", - "tag": "avatars", - "filename": "cut-automod-gay.png", - "metadata": { - "type": "Image", - "width": 512, - "height": 512 - }, - "content_type": "image/png", - "size": 29618 - }, - "profile": { - "content": "[Source Code](https://github.com/DeclanChidlow/AutoMod)\n\nRevolt's premier moderation bot.\n\nThanks to [@Amy.](https://miruku.cafe/@amy) for designing the logo and banner!\n\nTo enable automated spam prevention use `/botctl spam_detection on`. Some automatic moderation features are enabled by default - [More info](https://github.com/janderedev/automod/wiki/Global-Blacklist).\n\nFor help join [the AutoMod server](https://rvlt.gg/automod).", - "background": { - "_id": "AoRvVsvP1Y8X_A4cEqJ_R7B29wDZI5lNrbiu0A5pJI", - "tag": "backgrounds", - "filename": "banner.png", - "metadata": { - "type": "Image", - "width": 1366, - "height": 768 - }, - "content_type": "image/png", - "size": 151802 - } - }, - "tags": [], - "servers": 3287, - "usage": "high" - }, - { - "_id": "01G1Y9M6G254VWBF41W3N5DQY5", - "username": "bolt", - "avatar": { - "_id": "UI23uI7QEbVtwnj6hQRBe5VSrqfZK8dzAn7xBj71q8", - "tag": "avatars", - "filename": "BoltLogoWhiteOnBlack.png", - "metadata": { - "type": "Image", - "width": 1000, - "height": 1000 - }, - "content_type": "image/png", - "size": 635772 - }, - "profile": { - "content": "Bolt is a cross-platform chat app connecting communities, wherever your people might be.\nhttps://williamhorning.dev/bolt\n\nbridge revolt", - "background": { - "_id": "QZqM7LmRdOAgX-C5lf-yPMyDn6zuDVbMpOc-B-ak6l", - "tag": "backgrounds", - "filename": "BoltWordmarkColorOnBlack.png", - "metadata": { - "type": "Image", - "width": 2000, - "height": 1000 - }, - "content_type": "image/png", - "size": 420325 - } - }, - "tags": [ - "bridge", - "revolt" - ], - "servers": 435, - "usage": "high" - }, - { - "_id": "01H1DNQN9JGV40W0TJXWV26SGZ", - "username": "GlobalAdvertising", - "avatar": { - "_id": "YwvIYujaK29YCJ4NvuFHur35ZcRXiWrEEcsiOMjSe4", - "tag": "avatars", - "filename": "8cb449e049d2e5d1dd21eb67f91dd16a.png", - "metadata": { - "type": "Image", - "width": 128, - "height": 128 - }, - "content_type": "image/png", - "size": 11585 - }, - "profile": { - "content": "Global Ads uses a `global chat` style system to allow users to easily send any ad in any server by simply sending your ad as a message.\n\nType `g!help` to view all commands.\n\n[Support](https://rvlt.gg/pQZXYcRk) | [Website](https://frontier.cx) | [Discord Equivalent](https://global-advertising.frontier.cx/invite)", - "background": { - "_id": "LX-h5uZDQLGVAEsegt-kiG7E2vVtsVFpAnh6SRGZ9y", - "tag": "backgrounds", - "filename": "global.png", - "metadata": { - "type": "Image", - "width": 900, - "height": 300 - }, - "content_type": "image/png", - "size": 54115 - } - }, - "tags": [], - "servers": 67, - "usage": "high" - }, - { - "_id": "01H3N2N26KYV4626V4ZEZYKHT8", - "username": "Masquerade", - "profile": { - "content": "Send messages with a different Name, Avatar and Colour.\nUsage:\n`@Masquerade` Send help message\n`@Masquerade create {name} {Display Name}` Create a masquerade\n`name;Text you want to send` Send a message as {name}\n[Support Server](https://rvlt.gg/SPMxwwC8)\n[Source Code](https://github.com/TheBobBobs/masquerade-bot)" - }, - "tags": [], - "servers": 282, - "usage": "high" - }, - { - "_id": "01FGEJB5YQHRE55TWQG9RZMZFJ", - "username": "Booru", - "profile": { - "content": "29,933,846 Images - 3,444,325 Tags\n!d help\nGet a random image from the top 5 boorus or upload your own.\nUse tags to narrow your search. (!d maid cat_ears)\n\n[danbooru](https://danbooru.donmai.us), [e621](https://e621.net), [gelbooru](https://gelbooru.com), [rule34](https://rule34.xxx), [realbooru](https://realbooru.com/)\n[Support Server](https://rvlt.gg/SPMxwwC8)\n\nNSFW\n" - }, - "tags": [ - "nsfw" - ], - "servers": 396, - "usage": "high" - }, - { - "_id": "01HBQ5YTZ17GX9PAH87Q8SBNYE", - "username": "Auttaja", - "avatar": { - "_id": "PdEyPLzLUX1oJpJ0MhGs3FG_508LkzyC9qHHS57nim", - "tag": "avatars", - "filename": "Auttaja.png", - "metadata": { - "type": "Image", - "width": 1024, - "height": 1024 - }, - "content_type": "image/png", - "size": 49386 - }, - "profile": { - "content": "I am a multipurpose bot for Revolt featuring a counting system, moderation, and more.\n‎\nWe have mod & message logs system, logs when a message is edited or deleted, also logs bans, kicks, warns, etc.\n \n(All a work In progress!)‎\nRevolt Bot List - https://revoltbots.org/bots/Auttaja\nmoderation mod logging multipurpose utility", - "background": { - "_id": "zPqPKU8sXZ7ZRaLyZD63_MsBZJEo2PwZ9bWLbgr_58", - "tag": "backgrounds", - "filename": "Auttaja.png", - "metadata": { - "type": "Image", - "width": 480, - "height": 218 - }, - "content_type": "image/png", - "size": 41721 - } - }, - "tags": [ - "moderation", - "mod", - "logging", - "multipurpose", - "utility" - ], - "servers": 103, - "usage": "high" - }, - { - "_id": "01G6ECYVSP18H3MV09X3VX2R0G", - "username": "PopCord", - "profile": { - "content": "Add the Revolt bot PopCord to set up moderation, fun, utils, social, and NSFW for your Revolt server.\n\n[support server](https://app.revolt.chat/invite/kGZ1D6y6)\n[invite me](https://app.revolt.chat/bot/01G6ECYVSP18H3MV09X3VX2R0G)\n\n[website](https://popcord.github.io/)\n[API](https://api-popcord.vercel.app/)\n\nnsfw adult lewd 18 social anime", - "background": { - "_id": "53d42Jtf_u7qOaJbr7fZqJgC60QhAzooHOf_3R5mbw", - "tag": "backgrounds", - "filename": "image_2023-07-15_165759562.png", - "metadata": { - "type": "Image", - "width": 1200, - "height": 300 - }, - "content_type": "image/png", - "size": 380078 - } - }, - "avatar": { - "_id": "YAi3D8lhWCbNhpuxG0SxdfoDaFbPPPHtw8XpJOMR2N", - "tag": "avatars", - "filename": "a_b0f0c3aab1aa5024dee9be10f68a4641.gif", - "metadata": { - "type": "Image", - "width": 269, - "height": 269 - }, - "content_type": "image/gif", - "size": 1654485 - }, - "tags": [ - "nsfw", - "adult", - "lewd", - "18", - "social", - "anime" - ], - "servers": 278, - "usage": "medium" - }, - { - "_id": "01FVB28WQ9JHMWK8K7RD0F0VCW", - "username": "Remix", - "avatar": { - "_id": "763s_Jbrgw0VHeW6vOet-bBe5yB-sVW-IBBqbNRaY4", - "tag": "avatars", - "filename": "Remix.png", - "metadata": { - "type": "Image", - "width": 1440, - "height": 1440 - }, - "content_type": "image/png", - "size": 263926 - }, - "profile": { - "content": "Remix is the first open-source music bot for Revolt! if you need help or have any suggestions or feedback, join our support server. Please expect bugs from time to time as some features are under constant development.\n‎\n[Add the bot here]() • [Support Server]() • [Sponsor]() • [Web]()\n\n- You can find Remix's source code [here]().\n- [Remix Discord Version]().\n‎\nFor commands run `%help`\n- Command prefix might vary from server to server. Run <@​​​​01FVB28WQ9JHMWK8K7RD0F0VCW> to get the prefix in the current server.\n‎\nCheck for current downtime incidents: [Status Page](http://status.remix.fairuse.org)\nVote for Remix on [revoltbots.org](https://revoltbots.org/bots/remix)\n\n- `Made by ShadowLp174 and NoLogicAlan`​​​​\n\nmusic musicbot bot watchtogether dashboard rythm", - "background": { - "_id": "hsSRljmK34pgASEXRz28rnBBEbO5iC_C6Z1VWZJ0zK", - "tag": "backgrounds", - "filename": "D4_hBq5D2DObGDeYuo6agc7-5kjWNcefnrqMgHAkL2.gif", - "metadata": { - "type": "Image", - "width": 600, - "height": 360 - }, - "content_type": "image/gif", - "size": 1739994 - } - }, - "tags": [ - "music", - "musicbot", - "bot", - "watchtogether", - "dashboard", - "rythm" - ], - "servers": 2286, - "usage": "medium" - }, - { - "_id": "01HV5TBBZZQQ38N500NER52TRQ", - "username": "yagprb", - "avatar": { - "_id": "YJctin4WI7EpAbaPIDcmvLePJ4JtAUorJgcn-h_CxW", - "tag": "avatars", - "filename": "lizard.gif", - "metadata": { - "type": "Image", - "width": 413, - "height": 413 - }, - "content_type": "image/gif", - "size": 3770242 - }, - "profile": { - "content": "### Hello, I am YAGPRB. A general purpose revolt bot made to help you make your server a better place!\n\nSome of me features are:\n- A customizable levelling system with level rewards!\n- The ability to make custom join/leave messages\n- A highlights system that notifies you whenever a message keyword is sent in chat\n\nAnd even more. [Add me to your server](https://app.revolt.chat/bot/01HV5TBBZZQQ38N500NER52TRQ) now and explore all my features!\n\nutility minigames levelling roles moderation general" - }, - "tags": [ - "utility", - "minigames", - "levelling", - "roles", - "moderation", - "general" - ], - "servers": 24, - "usage": "medium" - }, - { - "_id": "01FEYFMPWJSJZ0671REAQMP6TY", - "username": "Ahni", - "avatar": { - "_id": "9Em8JLSKOwVoHnqdLaeUj5Z0eZf9UwdC16cdn7LMFW", - "tag": "avatars", - "filename": "racoon-raccoon.gif", - "metadata": { - "type": "Image", - "width": 498, - "height": 498 - }, - "content_type": "image/gif", - "size": 2574460 - }, - "profile": { - "content": "I am a fun and hot NSFW bot.\n:rainbow: LGBTQ+ Friendly :rainbow:\nSupport: https://rvlt.gg/tskJVrtk\nUsing a safe-to-use API, I am here to please. :smile_cat:\nAdd me to your server and try out my commands with `=help`!\n[My Source Code](https://github.com/Akrasio/Ahni-Revolt)\n\nnsfw lgbt adult lewd 18 anime images\n", - "background": { - "_id": "TCLfhz7L_zxENtWctj9_ucVTF_9dbKLQLCxN9omNWG", - "tag": "backgrounds", - "filename": "wp6371955.jpg", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 231985 - } - }, - "tags": [ - "nsfw", - "lgbt", - "adult", - "lewd", - "18", - "anime", - "images" - ], - "servers": 886, - "usage": "low" - }, - { - "_id": "01G9XW2NR0QBH5SD3RMDX7VWDB", - "username": "Roles", - "profile": { - "content": "Create messages that will give roles when reacted to.\n[Support Server](https://rvlt.gg/SPMxwwC8)\n[Source Code](https://github.com/TheBobBobs/roles-bot)" - }, - "tags": [], - "servers": 751, - "usage": "low" - }, - { - "_id": "01GTEXXYPYZQZFQ92AZB7JVR1G", - "username": "Bobcat2", - "avatar": { - "_id": "Ec85usBR7_2mHVz0MI9rwXPLcaxIrFKy5pM6Y25dzM", - "tag": "avatars", - "filename": "1685936841305_bobcat-_detailed_tongue_and_teeth", - "metadata": { - "type": "Image", - "width": 512, - "height": 512 - }, - "content_type": "image/png", - "size": 488845 - }, - "profile": { - "content": "Clone of BOBCAt, the best (quality is relative) moderation and utilities bot. I did not write it, the source code was made my crispy cat, he wants nothing to do with revolt tho.\nI can dm the source code, if you ask for it...\n\nPrefix is t$" - }, - "tags": [], - "servers": 37, - "usage": "low" - }, - { - "_id": "01GEJRAVCNV8VZNESKYKZG7ZNK", - "username": "PHLASH", - "avatar": { - "_id": "OSt8882bNBAl7WSKjou1ge3ZRE95urx_Ke655z2_vv", - "tag": "avatars", - "filename": "PHLASH.png", - "metadata": { - "type": "Image", - "width": 256, - "height": 256 - }, - "content_type": "image/png", - "size": 53622 - }, - "profile": { - "content": "A bot to help you get things done in a PHLASH!\nMostly just music right now. Supports queueing songs from youtube/soundcloud, playing livestreams or even radio, and setting filters for individual songs. (speed/reverb/bassboost)\n\nI'm [open source](https://github.com/itzTheMeow/revolt-phlash)!\n\nmusic bot musicbot", - "background": { - "_id": "-PMXovI4tcPjS2RBIuycpRb9H_jThdJGBZn1viOGQS", - "tag": "backgrounds", - "filename": "Shapes Background Large.png", - "metadata": { - "type": "Image", - "width": 800, - "height": 450 - }, - "content_type": "image/png", - "size": 7266 - } - }, - "tags": [ - "music", - "bot", - "musicbot" - ], - "servers": 394, - "usage": "low" - }, - { - "_id": "01H302WEK18E6F06NXW3ZF1JQ4", - "username": "Logger", - "avatar": { - "_id": "feCCSrJ_v-GbUZglZZXWOFn24Chy39fNtZ21HXFo9o", - "tag": "avatars", - "filename": "Revolt_Logger_Bot.png", - "metadata": { - "type": "Image", - "width": 712, - "height": 712 - }, - "content_type": "image/png", - "size": 23166 - }, - "profile": { - "content": "## Join the Support Server, [Bot Central](https://rvlt.gg/KDsGjPTW)!\nThe ultimate logging Revolt bot! Stay informed with comprehensive logging for message updates, and message deletes, as well as member joins and leaves. Keep a complete record of your server's activity and changes with Logger, your reliable logging companion.", - "background": { - "_id": "yuWwoG6j44x8uZ09e4hicIzxmLpIY5YxRvoo3Ghvyh", - "tag": "backgrounds", - "filename": "3885A408-B4CD-447D-A153-F0324C46407B.png", - "metadata": { - "type": "Image", - "width": 750, - "height": 499 - }, - "content_type": "image/png", - "size": 12642 - } - }, - "tags": [], - "servers": 173, - "usage": "low" - }, - { - "_id": "01FEMAA7ZDBD7KXBNRQT3PX8G6", - "username": "Waifu", - "profile": { - "content": "Made with love by Builderb <3 | Updated May 2023!\nA multi-purpose bot with utility and image commands coming soon.\n[Invite Bot](https://app.revolt.chat/bot/01FEMAA7ZDBD7KXBNRQT3PX8G6) | [Revolt Server](https://app.revolt.chat/invite/J5Ras1J3) | [Website](https://fluxpoint.dev) | [Discord Server](https://discord.gg/fluxpoint)\n\nanime imagegen memes utility", - "background": { - "_id": "oVQu_OOPng5YtUx6ej42QLlLYftLNgLCMJhE1M8ZYV", - "tag": "backgrounds", - "filename": "png", - "metadata": { - "type": "Image", - "width": 1500, - "height": 968 - }, - "content_type": "image/png", - "size": 2811567 - } - }, - "avatar": { - "_id": "-GBne-bBuNmJyg5ABWmc1e1Q6uqLOQqb39dl2S0aRB", - "tag": "avatars", - "filename": "waifu_new_avatar.png", - "metadata": { - "type": "Image", - "width": 2148, - "height": 2148 - }, - "content_type": "image/png", - "size": 2463533 - }, - "tags": [ - "anime", - "imagegen", - "memes", - "utility" - ], - "servers": 503, - "usage": "low" - }, - { - "_id": "01HS541Y8H5TQMWVEQD2VK213M", - "username": "Roulette", - "profile": { - "content": "This is the ultimate way to pass the time.\n======================================\nPlay: \".r\" or \".g\"\nLet's match the numbers\n======================================\n[Revolt Bots](https://revoltbots.org/bots/01HS541Y8H5TQMWVEQD2VK213M/)|[GitHub](https://github.com/ztttas1/revolt_render_ru-)|[Web Site](https://revolt-render-ru.onrender.com/)|[Sub machine](https://app.revolt.chat/bot/01J0QDZK8MWZRRX0K1C0NET8K3)\ngame english Revolt", - "background": { - "_id": "SAPWm2EtopAXp04_lR_F3FIB4e5TuyRnIzeO6Y37XO", - "tag": "backgrounds", - "filename": "22696631.jpg", - "metadata": { - "type": "Image", - "width": 1600, - "height": 1200 - }, - "content_type": "image/jpeg", - "size": 61899 - } - }, - "avatar": { - "_id": "zCE1XRZCHpEsJiy3Tx2wPYHdEnIU2RoEsFE17wsgM4", - "tag": "avatars", - "filename": "ダウンロード.png", - "metadata": { - "type": "Image", - "width": 186, - "height": 186 - }, - "content_type": "image/png", - "size": 7315 - }, - "tags": [ - "game", - "english", - "revolt" - ], - "servers": 14, - "usage": "low" - }, - { - "_id": "01GQ6RADXPSXPF6NW4CBXXE0VV", - "username": "MeowbahhBot", - "avatar": { - "_id": "zXsqmEzZb95w4pikLJpYQgx1Lz0cffY_ecnhhKMXKv", - "tag": "avatars", - "filename": "Meowbahh 2.jpg", - "metadata": { - "type": "Image", - "width": 593, - "height": 593 - }, - "content_type": "image/jpeg", - "size": 57953 - }, - "profile": { - "content": "HI. I am Meowbahh the Brainless trash bin. Please make me Trash. You can ask me questions with ++askmeow to get dumb answers, see Bad User hate arts with ++art, and more.\nPrefix: ++\n[Add the Bot](https://app.revolt.chat/bot/01GQ6RADXPSXPF6NW4CBXXE0VV) | [Support Server](https://rvlt.gg/sRTVE2sV) | [Website](https://idiotcreaturehater.glitch.me) | [Source Code](https://github.com/BadUserHater/MeowbahhBot/tree/main/revoltbot) | [Status Page](https://idiotcreaturehater.instatus.com)\n\nchatbot idiotcreaturehater fun meme", - "background": { - "_id": "fJUIh2wETjuaDpj9di-YJddh3lKm64CvuNyWhdr_R3", - "tag": "backgrounds", - "filename": "Idiotic Brainless Barbie Bin in Stores.png", - "metadata": { - "type": "Image", - "width": 1272, - "height": 734 - }, - "content_type": "image/png", - "size": 100147 - } - }, - "tags": [ - "chatbot", - "idiotcreaturehater", - "fun", - "meme" - ], - "servers": 49, - "usage": "low" - }, - { - "_id": "01HX0519791BX5BFWZ98MNS27H", - "username": "Translator", - "avatar": { - "_id": "VkyX0a0QnZGW1LKlc6EigRkhBpRf83D2vDQjI8Tcq3", - "tag": "avatars", - "filename": "translator.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 29249 - }, - "profile": { - "content": "**1st Revolt translation bot !**\n\n> Invite the bot\n>https://app.revolt.chat/bot/01HX0519791BX5BFWZ98MNS27H\n\n> Support server\n>https://rvlt.gg/pfkGX8P9\n\n100% free, no premium features, no limitations. ", - "background": { - "_id": "9H2naWpVOriI4JncxJWoDKA9AuhYb6WTH7NuMHSiQ8", - "tag": "backgrounds", - "filename": "translator_bannière_2.png.png", - "metadata": { - "type": "Image", - "width": 1200, - "height": 480 - }, - "content_type": "image/png", - "size": 31782 - } - }, - "tags": [], - "servers": 16, - "usage": "low" - }, - { - "_id": "01HPAFAVQM01BE2PN2A7RDTQXJ", - "username": "Anna", - "profile": { - "content": "I am a bot which is made for AI conversations! If you want to just chat, ping me with a message.\n\nDisclaimer:\nI use an AI Chat service which could be biased or offensive... tread carefully!\n\nAnd yes... I do take some time to respond and provide decent responses! Please be patient!\n\nAI automated chatbot chat fun\n\n- https://revoltbots.org/bots/ai\n- https://github.com/Akrasio/AI\n- [Home Server](https://rvlt.gg/01G5DNGB7K9JN685WABZKTYQ0X)", - "background": { - "_id": "NM_YFL1kYro75TbgI6BMl22l7CFEPpaByOJ6vWUeFK", - "tag": "backgrounds", - "filename": "giphy.gif", - "metadata": { - "type": "Image", - "width": 500, - "height": 280 - }, - "content_type": "image/gif", - "size": 644907 - } - }, - "avatar": { - "_id": "VAL7R3NT3xPeOrHRnAiiys2VpRXVYe_rMB7sIoL3V8", - "tag": "avatars", - "filename": "gify.gif", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/gif", - "size": 2263418 - }, - "tags": [ - "ai", - "automated", - "chatbot", - "chat", - "fun" - ], - "servers": 7, - "usage": "low" - }, - { - "_id": "01H3JFKWCDET5YTMP0J92SXBC9", - "username": "BaldiBot", - "avatar": { - "_id": "msom_qCb-C7yXTs7zksS6Kx6q8UGloOAd8hNhQS5Ct", - "tag": "avatars", - "filename": "Baldi Bot Icon.png", - "metadata": { - "type": "Image", - "width": 400, - "height": 425 - }, - "content_type": "image/png", - "size": 140564 - }, - "profile": { - "content": "Hello, I am Baldi. I can send people to detention, Find high quality Baldi's Basics mods, Baldi's Basics Roleplay commands, and more.\n\nTo get Started with me, use bb!help\n[Support Server](https://rvlt.gg/fSfKknAw) | [Website](https://baldibot.nl) | [Invite Discord Bot (verified by Discord)](https://discord.com/oauth2/authorize?client_id=821513607916814376&permissions=0&integration_type=0&scope=bot+applications.commands)\n\nDISCLAIMER: This bot is not made by or affilirated with mystman12/Basically Games and the creators of Baldi Basics and Baldi Basics Plus. This is a fanmade bot. This bot is intended to be more of a funny bot to provide fun for Baldi Basics fans." - }, - "tags": [], - "servers": 21, - "usage": "low" - } - ], - "popularTags": [ - "nsfw", - "utility", - "moderation", - "revolt", - "bridge", - "mod", - "logging", - "multipurpose", - "anime", - "18" - ] - }, - "__N_SSP": true -} \ No newline at end of file diff --git a/test/data/discovery/servers.json b/test/data/discovery/servers.json deleted file mode 100644 index f4d203d..0000000 --- a/test/data/discovery/servers.json +++ /dev/null @@ -1,3676 +0,0 @@ -{ - "pageProps": { - "servers": [ - { - "_id": "01HZJX93WB0W6QMFFC2E5VNG23", - "name": "peeps", - "description": "peeps is an active chatting server with memes and more", - "icon": { - "_id": "JTnWjuOrDZw6-A0ey94dvfVtDIvydD7bisr_aU82v_", - "tag": "icons", - "filename": "GSKz91QUnRHWnO92E697pRpDl7BBkHnBCVvQVHTppK.jpeg", - "metadata": { - "type": "Image", - "width": 768, - "height": 768 - }, - "content_type": "image/jpeg", - "size": 96701 - }, - "banner": { - "_id": "ZDeq632SkOHK26Arq7j4uX0V_bbyD10g8NWd8l1eH_", - "tag": "banners", - "filename": "il_fullxfull.2923896440_644n.jpg", - "metadata": { - "type": "Image", - "width": 2750, - "height": 2125 - }, - "content_type": "image/jpeg", - "size": 453049 - }, - "tags": [], - "members": 71, - "activity": "high" - }, - { - "_id": "01F80118K1F2EYD9XAMCPQ0BCT", - "name": "Catgirl Dungeon", - "description": "The gayest corner of the internet :3\n\nNon-catgirls welcome too ❤️", - "icon": { - "_id": "XIwQosw_3USL_XIvzZDyIKSi9LlMryrOPJNKsTrqts", - "tag": "icons", - "filename": "gaysex.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 17439 - }, - "banner": { - "_id": "ZTPUKiZ6OP1Yqox2PNOBVx_q1U3u9hXBbn84tLJXzK", - "tag": "banners", - "filename": "20230626_221308-2.jpg", - "metadata": { - "type": "Image", - "width": 5120, - "height": 2880 - }, - "content_type": "image/jpeg", - "size": 1321952 - }, - "flags": 2, - "tags": [], - "members": 6804, - "activity": "high" - }, - { - "_id": "01HVKQBBQ3DQVVNK3M8DHXV30D", - "name": "Femboy Kingdom", - "icon": { - "_id": "1tTV6RqTik3qntjw2tFXg-HfCW_Dtmp4cYBA385BzO", - "tag": "icons", - "filename": "femdom logo small.png", - "metadata": { - "type": "Image", - "width": 2500, - "height": 2500 - }, - "content_type": "image/png", - "size": 2082228 - }, - "banner": { - "_id": "LoBAfzfxv-0bObWR4pYpByG2joAQ_o1LeqZByOrLSY", - "tag": "banners", - "filename": "Hyrule_Castlesmallimagesize.png", - "metadata": { - "type": "Image", - "width": 3840, - "height": 2160 - }, - "content_type": "image/png", - "size": 7096268 - }, - "description": "! **18+ ONLY** !\n\na comfy server for femboys, but also home to girllikers, boykissers, silly gays of all kinds, and everyone else, too!!", - "tags": [], - "members": 225, - "activity": "high" - }, - { - "_id": "01F7ZSBSFHQ8TA81725KQCSDDP", - "name": "Revolt", - "icon": { - "_id": "gtc0gJE2S3RvuDhrl2-JeakvgbqEGr2acvBnRTTh6k", - "tag": "icons", - "filename": "logo_round.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 7558 - }, - "banner": { - "_id": "G_Q-6Y8KiGFVBNY2qVtDS25bX9Dh14CK2Py3TmLN_P", - "tag": "banners", - "filename": "Lounge_Banner_Old.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 138982 - }, - "description": "Official server run by the team behind Revolt.\nGeneral conversation and support server.\n \nAppeals and reports: lounge@revolt.chat", - "flags": 1, - "tags": [ - "revolt" - ], - "members": 39737, - "activity": "medium" - }, - { - "_id": "01HH6WE8A9RR6N9YVZNZAJRG92", - "name": "Revolt過密サーバー!", - "description": "ja japan 7/3現在 discover5位!", - "icon": { - "_id": "-Drsvy3FxRt4A-ENypzmhM3puuC9Efyb2oz8S_c94E", - "tag": "icons", - "filename": "image.png", - "metadata": { - "type": "Image", - "width": 150, - "height": 147 - }, - "content_type": "image/png", - "size": 43303 - }, - "banner": { - "_id": "wkXnd3a9mlzHF4ChPrS4VUXC3NXazKMKj72DVnHV_m", - "tag": "banners", - "filename": "名称未設定のデザイン.png", - "metadata": { - "type": "Image", - "width": 1280, - "height": 720 - }, - "content_type": "image/png", - "size": 108794 - }, - "tags": [ - "ja", - "japan" - ], - "members": 352, - "activity": "medium" - }, - { - "_id": "01GDS83RMZW89AV0BZG24NEXYC", - "name": "Furgatory", - "icon": { - "_id": "Q93qhBFrwVQE43rumFZFdRalq6UYR0JyHxssXOitTA", - "tag": "icons", - "filename": "Furgatory.webp", - "metadata": { - "type": "Image", - "width": 1000, - "height": 1000 - }, - "content_type": "image/webp", - "size": 65770 - }, - "description": "**Furgatory** is a simple server, meant to be fun, chaotic, and with lenient rules!\nYou either love it, or absolutely **HATE** it.\n\n*PS: This server is bridged with Discord.*\n\nuhh yes\n\nfurry chill chaotic fun", - "banner": { - "_id": "54hCHvbcG0P2j1DlomchOxR4ITkco4gGUWoSpYL_fK", - "tag": "banners", - "filename": "ujyhtgj.png", - "metadata": { - "type": "Image", - "width": 7111, - "height": 4000 - }, - "content_type": "image/jpeg", - "size": 2231838 - }, - "flags": 2, - "tags": [ - "furry", - "chill", - "chaotic", - "fun" - ], - "members": 392, - "activity": "medium" - }, - { - "_id": "01HPW0178JYC2HV7AB3Y9H2GEA", - "name": "Terraria", - "icon": { - "_id": "QNTsRnYMjuanz9-SFyY1YdQSy5O3sK62ycdCwo3FBv", - "tag": "icons", - "filename": "image(1).png", - "metadata": { - "type": "Image", - "width": 970, - "height": 970 - }, - "content_type": "image/png", - "size": 27348 - }, - "description": "The largest community-owned server for all Terrarians to chat about the beloved game itself, Terraria!", - "banner": { - "_id": "qXUVCBGhdgJVWwqORLCN9cS5B2xtuL8xSCsnYrLfcl", - "tag": "banners", - "filename": "steamuserimages-a.akamaihd.jpeg", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 528776 - }, - "tags": [], - "members": 223, - "activity": "medium" - }, - { - "_id": "01G5DNGB7K9JN685WABZKTYQ0X", - "name": "Social Haven", - "icon": { - "_id": "3mNkqRCBw88PveUOYoshTfOi8KNzHtWzWQs2uHRW_l", - "tag": "icons", - "filename": "SocialHaven.gif", - "metadata": { - "type": "Image", - "width": 421, - "height": 421 - }, - "content_type": "image/gif", - "size": 1806072 - }, - "banner": { - "_id": "gNOForvh8ourH7EyT05Xbc402pszXvxFDx48keTMd1", - "tag": "banners", - "filename": "SPANN.png", - "metadata": { - "type": "Image", - "width": 960, - "height": 540 - }, - "content_type": "image/png", - "size": 503321 - }, - "description": "For anyone who wants to discuss things from development, anime, tv shows... Or even to get help on using Ahni (Bot).", - "tags": [], - "members": 516, - "activity": "medium" - }, - { - "_id": "01HC4Y9963EZBZD40W28V0SWD2", - "name": "Snek Cult", - "description": "LGBTQA+ safe space, friendly, chill server!\n\n~~The cult part is a joke, we are just chaotic~~", - "icon": { - "_id": "pYTYnO9T0lYkNDfFvlWNPVYvSCWpZbP2s1MZMO_JYa", - "tag": "icons", - "filename": "SnakeLarge.jpg", - "metadata": { - "type": "Image", - "width": 900, - "height": 542 - }, - "content_type": "image/jpeg", - "size": 55605 - }, - "banner": { - "_id": "nCU-3RhI8u4jYi3FCgQ8ERD7N5OSMADHDXgT2BVwlt", - "tag": "banners", - "filename": "Torhola_cave_entrance_Aug2008.jpg", - "metadata": { - "type": "Image", - "width": 800, - "height": 600 - }, - "content_type": "image/jpeg", - "size": 189459 - }, - "tags": [], - "members": 1261, - "activity": "medium" - }, - { - "_id": "01HBKJD949CPKM31A5WS3DJF4F", - "name": "𝘓𝘦𝘧𝘵𝘪𝘴𝘵𝘴 𝘝𝘰𝘪𝘤𝘦", - "icon": { - "_id": "_PeslCCAi33E97fxwQyITqtZIeJZpASYbmMJYxDN02", - "tag": "icons", - "filename": "Screenshot_2024-04-02-20-01-12-50_99c04817c0de5652397fc8b56c3b3817.png", - "metadata": { - "type": "Image", - "width": 750, - "height": 750 - }, - "content_type": "image/png", - "size": 431448 - }, - "banner": { - "_id": "XCSbcx3CbaKIh6RrR7QFLaZD_iiZ5W44bXnSelpyKG", - "tag": "banners", - "filename": "Sans titre (3) (12).png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 2353977 - }, - "description": "🔴| A server for generally left adjacent and liberatory leftists people to hang out. |⚫\n‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎‎ ‎ ‎ ‎‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ \nleftismpoliticalpoliticsantifascistsantifascismsocialistssocialismsocialsanarchistsanarchismcommunistscommunismancomsbolcheviksbolchevismdemsocssocdemssyndicalistssyndicalismmarxistsmarxismleninistsleninismmaoistsmaoismmlmtrotskyiststrotskyismproletariatsproletarianredflagsfeministsfeminismecologyecologicallgbt+lgbtqa+lgbtqia+2slgbtq+transgenderstransfemstransmascsnonbinaryqueersblacklivesmatterblmpalestinianspalestineelections", - "tags": [ - "left", - "leftists", - "leftism", - "political", - "politics", - "antifascists", - "antifascism", - "socialists", - "socialism", - "socials" - ], - "members": 202, - "activity": "medium" - }, - { - "_id": "01G26VQTAV4T8FDTXGKAEN6WW3", - "name": "Revolt Türkiye 🇹🇷", - "icon": { - "_id": "YYNYH5lJD9Qrvbxc7kH_2zHuf2wV__J80EXh0J49gV", - "tag": "icons", - "filename": "Revolt.jpg", - "metadata": { - "type": "Image", - "width": 178, - "height": 180 - }, - "content_type": "image/jpeg", - "size": 5049 - }, - "description": null, - "banner": { - "_id": "NLLL9LapfIFVBkoOZnv6bt4yKuJlhW9ijd46hJF2dy", - "tag": "banners", - "filename": "revoltbg.png", - "metadata": { - "type": "Image", - "width": 1200, - "height": 720 - }, - "content_type": "image/png", - "size": 113114 - }, - "tags": [], - "members": 246, - "activity": "medium" - }, - { - "_id": "01HJRBQKJN8DPDDF8EWC5NP0Y3", - "name": "Vimjoyer's server", - "banner": { - "_id": "29k_SQ4_gTLMUGmZY0xmtQUl-pq9z8FA7_PYUwxool", - "tag": "banners", - "filename": "1674902814_top-fon-com-p-fon-dlya-prezentatsii-krestovie-pokhodi-95.jpg", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 355837 - }, - "icon": { - "_id": "sLZPNq_baVjDEGw71lwdg-Vel7C5z97NThJ_qJ888G", - "tag": "icons", - "filename": "da9197e4c321bde3cbbd82b6b2ede81d.png", - "metadata": { - "type": "Image", - "width": 460, - "height": 460 - }, - "content_type": "image/png", - "size": 347738 - }, - "description": "Vimjoyer is a laid-back technical community focused primarily on [NixOS](https://nixos.org/) and the associated [YouTube channel](https://www.youtube.com/@vimjoyer).\n\nDiscord Server: https://discord.gg/vimjoyer-s-server-1133342557494067232", - "tags": [], - "members": 35, - "activity": "medium" - }, - { - "_id": "01FPH1PAT0RRZRRRHX05S5PAHF", - "name": "Metal Music", - "banner": { - "_id": "isp_efybah3RPr8tWUqmDKQAjB56oJaZEOn7VX3Jev", - "tag": "banners", - "filename": "thyrfi8.jpg", - "metadata": { - "type": "Image", - "width": 997, - "height": 496 - }, - "content_type": "image/jpeg", - "size": 113020 - }, - "icon": { - "_id": "npIBCmZWEcSS9uZ3S4xKWSywdKY_1bZAIromqFtPIJ", - "tag": "icons", - "filename": "Metal Horns.jpg", - "metadata": { - "type": "Image", - "width": 238, - "height": 250 - }, - "content_type": "image/jpeg", - "size": 7765 - }, - "description": "Metal Music to share and discover \n\nMetal, MetalMusic, metal, metalmusic, Musikk, #\\M/ #\\m/, Bands", - "tags": [ - "metal", - "metalmusic", - "musikk", - "bands" - ], - "members": 454, - "activity": "medium" - }, - { - "_id": "01HMHX9YTP6JDMSMF2V2YK5XSC", - "name": "líf's tea house", - "icon": { - "_id": "Bhv0oJdHSis42wVpKHzG2ocee9FjcwjhGaRksftf5B", - "tag": "icons", - "filename": "clipart1343880.png", - "metadata": { - "type": "Image", - "width": 949, - "height": 797 - }, - "content_type": "image/png", - "size": 51880 - }, - "banner": { - "_id": "5ITVvJsJH4oZDAuIzEOdV-Y-aOkhuyJXLsyLs7xH0o", - "tag": "banners", - "filename": "deers_den_japanese_inn_by_mizakilightsword_dgejrsf-fullview.jpg", - "metadata": { - "type": "Image", - "width": 1280, - "height": 718 - }, - "content_type": "image/jpeg", - "size": 247313 - }, - "description": "hey there, hope you like tea and a cosy atmosphere! let's vibe and spill the tea!\n\n(please don't spill any actual tea...)\n\ncome on in! c:\n\ntea friendly chat chatting games gaming cosy cozy art music lgbtq+", - "tags": [ - "tea", - "friendly", - "chat", - "chatting", - "games", - "gaming", - "cosy", - "cozy", - "art", - "music" - ], - "members": 180, - "activity": "medium" - }, - { - "_id": "01HY3ZH08W97DNEBEAJENYBVQW", - "name": "Hazbin Hotel", - "icon": { - "_id": "aGAHw5a2wc9-iyiXYvTi2HFQk-rE1VfphFqc2R2fqM", - "tag": "icons", - "filename": "Screenshot_20240517-151315.png", - "metadata": { - "type": "Image", - "width": 941, - "height": 939 - }, - "content_type": "image/png", - "size": 1313620 - }, - "description": "A server open to everyone. (But I do suggest watching Hazbin Hotel.)", - "banner": { - "_id": "5sgnDL1rt4H89FuynB7vBSvPKE0E6u6E6ezMUKQDym", - "tag": "banners", - "filename": "Screenshot_20240516-150906.png", - "metadata": { - "type": "Image", - "width": 939, - "height": 937 - }, - "content_type": "image/png", - "size": 959759 - }, - "tags": [], - "members": 72, - "activity": "medium" - }, - { - "_id": "01GMSVV3HESTM2QNV6FSRR66FK", - "name": "World Three (WWW)", - "icon": { - "_id": "7jJUkr0_FXGJynrco8HsVAq4wJxXPIziTEkAPYOqZ6", - "tag": "icons", - "filename": "Logo2.png", - "metadata": { - "type": "Image", - "width": 825, - "height": 825 - }, - "content_type": "image/png", - "size": 635311 - }, - "banner": { - "_id": "3jIMyKk7LSJF1Dt0xtlFz1FS0_fFpTJ74zneC6WNV8", - "tag": "banners", - "filename": "bg_main(5).png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1920 - }, - "content_type": "image/png", - "size": 3462831 - }, - "description": "A Revolt Version of World Three\n[Guilded](https://guilded.gg/www)\n[Discord](https://discord.gg/R4KDE9NK6J)\n", - "tags": [], - "members": 73, - "activity": "medium" - }, - { - "_id": "01GKCGQT0M5YNR6Y0JD4B7MD2K", - "name": "Linux Gang", - "banner": { - "_id": "FtDdlGoFMtqsv8USM7xsZo7c6DWLsohAeTFcrlliVo", - "tag": "banners", - "filename": "image.psd(2).png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1040 - }, - "content_type": "image/png", - "size": 302367 - }, - "icon": { - "_id": "Sj0oL7W9_xECKdxRg5y9aGka4Af4yK1mFakVoi3FzO", - "tag": "icons", - "filename": "10eb37bf4d557a7e0a4d883c9b3f80c3.jpg", - "metadata": { - "type": "Image", - "width": 453, - "height": 453 - }, - "content_type": "image/jpeg", - "size": 26199 - }, - "description": "Epic Linux Revolt server (BRIDGED FROM DISCORD SERVER https://discord.gg/linuxgang)\nThis is official Revolt variant of Linux Gang, currently used as a backup. linux programming gang memes meme fun", - "tags": [ - "linux", - "programming", - "gang", - "memes", - "meme", - "fun" - ], - "members": 621, - "activity": "medium" - }, - { - "_id": "01HQ8K0RMZ9A52F8BDPAR0JKRE", - "name": "Starlight City | Anime+", - "banner": { - "_id": "hjQOtyar483kLcmJb6DOPKPuSf-RaiKi89g3C7SVPt", - "tag": "banners", - "filename": "Starlight City.png", - "metadata": { - "type": "Image", - "width": 529, - "height": 164 - }, - "content_type": "image/png", - "size": 155878 - }, - "description": "ANIME+ Server. \nEveryone Welcome (16+)\nEnglish Only!!", - "icon": { - "_id": "odP3IrTBbBhBoWrKGXYdw4s3242GtxEerPTu2vElI7", - "tag": "icons", - "filename": "img-yW0jCZHPou5zNsQesUZIf.jpeg", - "metadata": { - "type": "Image", - "width": 768, - "height": 768 - }, - "content_type": "image/jpeg", - "size": 130899 - }, - "tags": [], - "members": 101, - "activity": "low" - }, - { - "_id": "01H0M660HG4MQV4Q7SZ79EX6JF", - "name": "ReVoYo", - "description": "The Revolt server for all Hoyoverse fans out there!\n\nTags: Genshin GenshinImpact Honkai StarRail HSR ZZZ", - "banner": { - "_id": "2ZtqTBG6DrMHxpz1hqzIgTQkuVVQKen3DpTa7Gw22F", - "tag": "banners", - "filename": "moby-kokomi.jpg", - "metadata": { - "type": "Image", - "width": 2150, - "height": 1518 - }, - "content_type": "image/jpeg", - "size": 493196 - }, - "icon": { - "_id": "wq9PL5UqwbnAIP2B_LtXytzVHTZ0001U2aEvZt0ipc", - "tag": "icons", - "filename": "firefy.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 55193 - }, - "tags": [ - "genshin", - "genshinimpact", - "honkai", - "starrail", - "hsr", - "zzz" - ], - "members": 615, - "activity": "low" - }, - { - "_id": "01GT8N19H8Y8XRA6TMZ9JGX3CZ", - "name": "Programmer's Lounge", - "icon": { - "_id": "N_qoHg4BjHDYyp0FQFZ8SgbwH_cIGK4mHmputuqoA0", - "tag": "icons", - "filename": "Sequence-4-_1_.webp", - "metadata": { - "type": "Image", - "width": 190, - "height": 190 - }, - "content_type": "image/webp", - "size": 1815636 - }, - "banner": { - "_id": "cui2dtXLcihgWSoX1zGxZ0xonLRpLs0VsJtPaVm4l5", - "tag": "banners", - "filename": "ezgif.com-resize-_7_.webp", - "metadata": { - "type": "Image", - "width": 300, - "height": 225 - }, - "content_type": "image/webp", - "size": 1487472 - }, - "description": "👨‍💻[NEW SERVER] - The old server has been raided and we had to start again here.\n 💿\nLounge for programmers 😎 - non-programmers are welcome as well!\n\nrevolt programming Linux memelords", - "flags": 2, - "tags": [ - "revolt", - "programming", - "linux", - "memelords" - ], - "members": 2098, - "activity": "low" - }, - { - "_id": "01HXH7XSA0SSGTS9YQNR28GRWS", - "name": "El Chat", - "icon": { - "_id": "XW8qDDiG3gk7B6RoOY_92CEhCnoPcZJVF4O4tAv2--", - "tag": "icons", - "filename": "Picture1.png", - "metadata": { - "type": "Image", - "width": 670, - "height": 595 - }, - "content_type": "image/png", - "size": 365714 - }, - "description": "This server is for all things chat worthy (or to just hang out)\n\n(Btw this is not a Spanish server, we just used 'el' bc 'the' is boring) \ngeneral chill\n", - "banner": { - "_id": "Cs8v3PLc-9Z0Bze1_6m96n0muyG3TlEw2heXGx3O5N", - "tag": "banners", - "filename": "Tumblr_l_610160573231594.jpg", - "metadata": { - "type": "Image", - "width": 1200, - "height": 817 - }, - "content_type": "image/jpeg", - "size": 261713 - }, - "tags": [ - "general", - "chill" - ], - "members": 62, - "activity": "low" - }, - { - "_id": "01FYW7ZZMBA66K1ZEVN0JTD51Y", - "name": "Artists Guild", - "description": "The Artist Guild, welcome all artist whether you area a traditional 2D, 3D, Mixed Media, Animator, Comic, Sculptor or anything else. This is a place for fun, sharing, critiques if wanted, and networking.\n\nArt, art, Painting, painting, 2D, 2d, 3D, 3d, Animation, animation,", - "banner": { - "_id": "AagHiHfldnhf7jHinrMJBx5cHr_cX8Ywn2gUBt0k8k", - "tag": "banners", - "filename": "new crossed swords on shield.png", - "metadata": { - "type": "Image", - "width": 1787, - "height": 1621 - }, - "content_type": "image/png", - "size": 4384651 - }, - "icon": { - "_id": "UfoWGkddrARrsw1TG7y4YLSUsCbC5VYJS7VtnsboU2", - "tag": "icons", - "filename": "images.jpg", - "metadata": { - "type": "Image", - "width": 227, - "height": 222 - }, - "content_type": "image/jpeg", - "size": 28005 - }, - "tags": [ - "art", - "painting", - "2d", - "3d", - "animation" - ], - "members": 229, - "activity": "low" - }, - { - "_id": "01H3A6J2B2SR014NB46Z9T92KH", - "name": "Homestuck", - "icon": { - "_id": "L8QTOHkHolX_1IBsCa1mfijyp9iqNYwDUUD9KiHHt5", - "tag": "icons", - "filename": "homestuck server logo.png", - "metadata": { - "type": "Image", - "width": 800, - "height": 800 - }, - "content_type": "image/png", - "size": 17398 - }, - "description": "An unofficial server for all things Homestuck! The current banner is [\"The Furthest Ring\"](https://www.deviantart.com/ursca/art/The-Furthest-Ring-201864211) by [Ursca on Deviantart](https://www.deviantart.com/ursca/gallery)!", - "banner": { - "_id": "fYVf1dcwNnHr84cc46cOmgF3AQgLtnMwmaf9joHE18", - "tag": "banners", - "filename": "the outer ring.jpg", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 163349 - }, - "tags": [], - "members": 261, - "activity": "low" - }, - { - "_id": "01HXY6ND7F2CJJWV02H6M8TXPB", - "name": "Neo_Tokyo", - "icon": { - "_id": "lFtUK_BNe0S4vm1orj-RIfsRJ_10Y_-83ud-XsT-cp", - "tag": "icons", - "filename": "download.jpg", - "metadata": { - "type": "Image", - "width": 225, - "height": 225 - }, - "content_type": "image/jpeg", - "size": 17617 - }, - "banner": { - "_id": "FIdodQ4cYGLgU22w8ptHMxZ4qMPqd65HYx_4sL6GyE", - "tag": "banners", - "filename": "images.jpg", - "metadata": { - "type": "Image", - "width": 290, - "height": 174 - }, - "content_type": "image/jpeg", - "size": 29636 - }, - "description": "A place to connect users and share hobbies to make a good community.", - "tags": [], - "members": 21, - "activity": "low" - }, - { - "_id": "01HAXF5YV13S4J1C9YDSY9GRXC", - "name": "✴☽ _🗡 - MYSTICA - 🪄 _ ☾✴", - "icon": { - "_id": "meXiFrbnAz4ndNHVnzi0tHKPij0jkNDwCK9SfyJsw2", - "tag": "icons", - "filename": "Mystica_Logo-1-01 (1).jpg", - "metadata": { - "type": "Image", - "width": 1080, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 114826 - }, - "banner": { - "_id": "Dp2OVVpNOWMyCXHdmTFRhbZQTC1fLwW6lsD3aPgGfQ", - "tag": "banners", - "filename": "fnf-6666666666666-min.gif", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/gif", - "size": 3559343 - }, - "description": "🗡️𝐌𝐘𝐒𝐓𝐈𝐂𝐀🪄Communauté occulte de la maison d'édition #ésotérique MYSTICA\n\n𝐌𝐘𝐒𝐓𝐈𝐂𝐀, un sanctuaire secret dédié aux praticiens et chercheurs de l' occultisme ésotérique. Entre sorcières, alchimistes, francs-maçons et rose-croix, plongez dans un monde envoûtant où les rituels mystiques et les enseignements ésotériques se rejoignent. Êtes-vous prêt à évoluer spirituellement en explorant les profondeurs cachées de la connaissance secrète ? 🗝️🌌\n\n🌙 Un Univers de Connaissances Cachées :\nTous se réunissent pour partager leurs connaissances ésotériques. Vous découvrirez des secrets anciens, des grimoires sacrés et des pratiques magiques qui transcendent les limites de la réalité. 📚🧙\n\n🌌 Des Convents, Cercles de Cérémonies et Rituels :\nRejoignez nos convents disponibles dans chaque région et à l'étranger, où les étoiles guident nos rituels ésotériques. De la magie cérémonielle à l'astrologie ésotérique, devenez un de nos praticiens éclairés...", - "tags": [ - "occulte", - "occultisme", - "sorci", - "alchimistes", - "francs", - "convents", - "rituels" - ], - "members": 19, - "activity": "low" - }, - { - "_id": "01FFJEZKYD3JVWBW3DHREE6EQH", - "name": "Revoltijo en español", - "description": "¡Primer servidor hispanohablante de Revolt!\nCreado en septiembre de 2021.\n\nspanish español gaming linux english inglés", - "icon": { - "_id": "75cUFEfe2maMQ85ssZSBcvjibQ5d1VSdDQ8DIHZOPu", - "tag": "icons", - "filename": "revolt español icon.png", - "metadata": { - "type": "Image", - "width": 1360, - "height": 1310 - }, - "content_type": "image/png", - "size": 1183870 - }, - "banner": { - "_id": "XGRlSt_TbEsA95Uw_53pqSYUvpTFrwyXPVaHKNweZn", - "tag": "banners", - "filename": "revolt español banner.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 1961079 - }, - "tags": [ - "spanish", - "espa", - "gaming", - "linux", - "english", - "ingl" - ], - "members": 1055, - "activity": "low" - }, - { - "_id": "01HXGAAE0G57M1VW3Z12CTNE79", - "name": "Mospixel", - "icon": { - "_id": "GCKM1r8KJqTzXvfqBHj0ZybJGyFlTJzeGFdQTDhSO8", - "tag": "icons", - "filename": "20230321_222813.png", - "metadata": { - "type": "Image", - "width": 3264, - "height": 3264 - }, - "content_type": "image/png", - "size": 1601740 - }, - "description": "A minecraft server that allows cheating. No bans. No punishment.\nThere's only one gamemode, skywars.\nYou can also test the anticheat!", - "banner": { - "_id": "iFFYVWPk_UdSAK48qzt7zYOPhnC9G5NEJn9bKdoTYI", - "tag": "banners", - "filename": "2024-06-13_22.37.38_4K.png", - "metadata": { - "type": "Image", - "width": 3840, - "height": 2160 - }, - "content_type": "image/png", - "size": 6044170 - }, - "tags": [], - "members": 17, - "activity": "low" - }, - { - "_id": "01H7ZPK9TSYX12YRTRJ8PGBWBS", - "name": "ADSCRAFT Season X (PorcupineNet)", - "icon": { - "_id": "uuzuC1teebda4VXZgagOZ1HzOj2QcfU2lF9LN7ptEK", - "tag": "icons", - "filename": "Copy of server-icon.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 21647 - }, - "banner": { - "_id": "wCQ7jDFXKM_2svlUOx0s0D816VP2mUzj4Sy10cLWff", - "tag": "banners", - "filename": "Untitled design (1).png", - "metadata": { - "type": "Image", - "width": 2245, - "height": 1587 - }, - "content_type": "image/png", - "size": 285907 - }, - "description": "The Main chat for ADSCRAFT Season 10 and the Porcupine Network!", - "tags": [], - "members": 42, - "activity": "low" - }, - { - "_id": "01GXPNCVM4QTPCGP2DJ5CZ0M74", - "name": "🇫🇷 | La Révolte | 🇫🇷", - "description": "🇫🇷 | Bienvenue dans la Révolte! C'est LE serveur communautaire pour tous les francophones et tous les français sur Revolt! | 🇫🇷\n‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎\n‎ ‎\nfrancaisfrancophonecommunautairefrenchfrenchspeakerscommunityfrance", - "banner": { - "_id": "HR_4NyvawzKsPDf0ydBK-liM0qL6oYD01X5Bz-eG6a", - "tag": "banners", - "filename": "proxy-image (28).jpeg", - "metadata": { - "type": "Image", - "width": 1600, - "height": 1200 - }, - "content_type": "image/jpeg", - "size": 336751 - }, - "icon": { - "_id": "xWlzD8Be81y0rmPK0M3uZztChhqE-S4JebL-_YOwsa", - "tag": "icons", - "filename": "IMG_20231102_033937.png", - "metadata": { - "type": "Image", - "width": 425, - "height": 427 - }, - "content_type": "image/png", - "size": 181144 - }, - "tags": [ - "francais", - "francophone", - "communautaire", - "french", - "frenchspeakers", - "community", - "france" - ], - "members": 296, - "activity": "low" - }, - { - "_id": "01H9RVA6E180ZN1X6E025BMHQ8", - "name": "🌸 Kokoro | こころ Revolt 🌸", - "icon": { - "_id": "ITrnY3ZLHPgOnqK7LtWhkpNmue8X4UPUaKHepKiZCX", - "tag": "icons", - "filename": "image (1) (15) (2).jpeg", - "metadata": { - "type": "Image", - "width": 394, - "height": 382 - }, - "content_type": "image/jpeg", - "size": 44161 - }, - "banner": { - "_id": "3BvSztWyLAOfpzAtKfiW1eYKTjRSTlZ68AYp8XG31r", - "tag": "banners", - "filename": "cherry-blossom.gif", - "metadata": { - "type": "Image", - "width": 640, - "height": 360 - }, - "content_type": "image/gif", - "size": 4351267 - }, - "description": "🌸🧡Revolt Version of Guilded's Big Server Kokoro, Discord Bot Fully Out! Enter the Magic House!🧡🌸", - "tags": [], - "members": 115, - "activity": "low" - }, - { - "_id": "01HTK55PQTM9JC1TNMBA056H5C", - "name": "Termux", - "icon": { - "_id": "ANklm_EU-jT6RuxOoAdxstqhpxBtDk4K1PB80JrBeZ", - "tag": "icons", - "filename": "tewmux.gif", - "metadata": { - "type": "Image", - "width": 600, - "height": 600 - }, - "content_type": "image/gif", - "size": 43994 - }, - "banner": { - "_id": "BRIKuy6CdJM96JsB0T3H0hyPnXJZ66boS9G_DlIljH", - "tag": "banners", - "filename": "termux-banner.png", - "metadata": { - "type": "Image", - "width": 1440, - "height": 812 - }, - "content_type": "image/png", - "size": 76956 - }, - "description": "Termux is an Android terminal emulator and Linux environment app that works directly with no rooting or setup required.", - "tags": [], - "members": 42, - "activity": "low" - }, - { - "_id": "01GQFCFF0MAT571TESKPW96CZ9", - "name": "AI Enthusiasts", - "description": "A server for those that are excited for the advancements in AI. And for those that like AI art.\nWe have a stable diffusion bot to generate images.\n\n\nAI Stable_diffusion art chatgpt gpt machine_learning #", - "icon": { - "_id": "Ru4C0CS1iZQ8WuQjAjol1zPF9QQC8S3dHBjHoLqfUW", - "tag": "icons", - "filename": "downloa2d.jpg", - "metadata": { - "type": "Image", - "width": 768, - "height": 768 - }, - "content_type": "image/jpeg", - "size": 128370 - }, - "banner": { - "_id": "QAp-4VLQWq6LjOjO2gqVn2_RbYFO3lQXjkM6HLVh0_", - "tag": "banners", - "filename": "robot painting.png", - "metadata": { - "type": "Image", - "width": 1042, - "height": 512 - }, - "content_type": "image/png", - "size": 1073895 - }, - "tags": [ - "ai", - "stable_diffusion", - "art", - "chatgpt", - "gpt", - "machine_learning" - ], - "members": 694, - "activity": "low" - }, - { - "_id": "01GK7CWZJ6RF7PXBW8DW0D6AT3", - "name": "The Pitlane: EURO 2024 Edition", - "description": "**Current Event:** 🇪🇺 UEFA EURO 2024. Hello and welcome to ***the Pitlane*** where it's mainly but not exclusively about sports, racing and motorsports.", - "icon": { - "_id": "lCgK3haewxs6-Sfr7RDgF9hAWGHWpE0yegUyEtisaH", - "tag": "icons", - "filename": "EURO Icon.png", - "metadata": { - "type": "Image", - "width": 1000, - "height": 1000 - }, - "content_type": "image/png", - "size": 773812 - }, - "banner": { - "_id": "fZiBW5mq6UpewZB109eyMLDCu6SIFy0S8HGgzw9mFt", - "tag": "banners", - "filename": "euro.png", - "metadata": { - "type": "Image", - "width": 912, - "height": 513 - }, - "content_type": "image/png", - "size": 246574 - }, - "tags": [ - "uefa", - "euro", - "sports", - "racing", - "motorsports" - ], - "members": 34, - "activity": "low" - }, - { - "_id": "01FE6SE1J3GQPGC7W8APGZ6T4M", - "name": "Nittama", - "description": "Para quienes buscan un lugar ordenado y agradable donde habitar. ¡Bienvenidos al templo en el que la calidad vence a la cantidad! Tenemos por objetivo ser y hacer amigos: aquí nadie es un simple número.\nsocial juegos arte anime ocio pasatiempos bots windows sociedad humanidades waifus husbandos nittama español\nServidor creado el 28 de agosto de 2021.", - "banner": { - "_id": "ZWX57CqFkA7QD7_x0eEg7Jk79cbf9Xf72GPhfQG96_", - "tag": "banners", - "filename": "Nittama Estandarte v2.png", - "metadata": { - "type": "Image", - "width": 800, - "height": 432 - }, - "content_type": "image/png", - "size": 584683 - }, - "icon": { - "_id": "Bat4jOksLMWgxHKLZtJlkFRV2peIzNzqTNl7BdW1sI", - "tag": "icons", - "filename": "tama.gif", - "metadata": { - "type": "Image", - "width": 192, - "height": 192 - }, - "content_type": "image/gif", - "size": 1147973 - }, - "tags": [ - "social", - "juegos", - "arte", - "anime", - "ocio", - "pasatiempos", - "bots", - "windows", - "sociedad", - "humanidades" - ], - "members": 22, - "activity": "low" - }, - { - "_id": "01HZB918XT1A42YMEEARQ21J5S", - "name": "Olympus RP Hub", - "icon": { - "_id": "AhT3OM8nIpKjJN9XCCL5HXDx-teRgV6XQCCw7vMYCt", - "tag": "icons", - "filename": "fasd (16).png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 401050 - }, - "banner": { - "_id": "-WiXYYieKxhmGKQxtT6-HPvSYEc3SwRh9h7iy8x-9b", - "tag": "banners", - "filename": "Nikki Marie (5).png", - "metadata": { - "type": "Image", - "width": 680, - "height": 240 - }, - "content_type": "image/png", - "size": 258200 - }, - "description": "Olympus RP Hub is a generalized Roleplay Server where users can chat about the hobby, get and give advice, find RP partners, and connect with other writers should they decide to create group games of their own. Roleplayers from all walks of the hobby are invited to join, hang out, and help make this community into more of what they would like to see from the hobby as a whole. \n\nRP roleplay roleplaying ttrp tbrp writing creativewriting literaterp", - "tags": [ - "rp", - "roleplay", - "roleplaying", - "ttrp", - "tbrp", - "writing", - "creativewriting", - "literaterp" - ], - "members": 34, - "activity": "low" - }, - { - "_id": "01HT96GXFPQCP100NF9NE0BAS8", - "name": "Saiko's Refuge", - "description": "A chat room simlar to a web chat room. Have fun. no racism, no bullshit. We also have lore now so theorise and read it.", - "icon": { - "_id": "PDwmCcSa-WIPwVqTwixmZFO3KB8269X5UlBNVoMRaq", - "tag": "icons", - "filename": "cartoon-lofi-young-manga-style-girl-while-listening-to-music-in-the-rain-ai-generative-photo.jpg", - "metadata": { - "type": "Image", - "width": 714, - "height": 400 - }, - "content_type": "image/jpeg", - "size": 59195 - }, - "banner": { - "_id": "UQoVVPO-PAW5HhlROaRJ_9armJlKRtxKixNJPeGEgj", - "tag": "banners", - "filename": "cartoon-lofi-young-manga-style-girl-while-listening-to-music-in-the-rain-ai-generative-photo.jpg", - "metadata": { - "type": "Image", - "width": 714, - "height": 400 - }, - "content_type": "image/jpeg", - "size": 59195 - }, - "tags": [], - "members": 37, - "activity": "low" - }, - { - "_id": "01H2V5VGM6GKX2BEMJHSSXY20J", - "name": "TXZ Official Server", - "description": "This is the TXZ Official Revolt Server, originally made in January 19, 2020 on Discord by A TXZ Project. A lot of us play Geometry Dash, Osu! and Minecraft, a chill community where you can get easily involved here! Currently, we are under construction, but hope you feel great here :)\ngeometrydash minecraft mc osu! gdps gaming spanish english español games discord anime chill", - "icon": { - "_id": "ZohqTf0D4x_7COIcX-2uDV6ElT-vfjmiDgO6qrHHf2", - "tag": "icons", - "filename": "1000007419.jpg", - "metadata": { - "type": "Image", - "width": 577, - "height": 577 - }, - "content_type": "image/jpeg", - "size": 25352 - }, - "banner": { - "_id": "qvT-8yglTwvvdLkE3EXadU48JmID1S-nWrxnBp7Dua", - "tag": "banners", - "filename": "TXZNewBanner.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 643139 - }, - "tags": [ - "geometrydash", - "minecraft", - "mc", - "osu", - "gdps", - "gaming", - "spanish", - "english", - "espa", - "games" - ], - "members": 15, - "activity": "low" - }, - { - "_id": "01GZYC59WCH8SB16Z9V06K57GH", - "name": "Pokémon: Revolt", - "banner": { - "_id": "V6aUnHPT89ix72S3noylO5Re25u5OjchbGYko3eZ45", - "tag": "banners", - "filename": "sky.png", - "metadata": { - "type": "Image", - "width": 2000, - "height": 1230 - }, - "content_type": "image/png", - "size": 978748 - }, - "description": "Welcome to the Pokémon Revolt Community Server, for Art, News and all kinds of discussions regarding the Games and Anime, as well as Tech in general. \nWe won't bite :3\n\n\npokemon gaming art nintendo lucario Pokemon digimon # Digimon", - "icon": { - "_id": "6jX4C17c4pLTOAZ54rnfaptPhPUlERyDrACo2hW9DQ", - "tag": "icons", - "filename": "TR_i_pkp1.png", - "metadata": { - "type": "Image", - "width": 138, - "height": 137 - }, - "content_type": "image/png", - "size": 23034 - }, - "tags": [ - "pokemon", - "gaming", - "art", - "nintendo", - "lucario", - "digimon" - ], - "members": 448, - "activity": "low" - }, - { - "_id": "01H3QFBM82MEAZK4HT8FXJ8PD6", - "name": "CYBΞRLabs", - "icon": { - "_id": "m613XMTBnec1nFyl5Ge3wwePPj8w6urHOxlPbVthv0", - "tag": "icons", - "filename": "Discord_logo_for_some_CYBERLABS_thing.png", - "metadata": { - "type": "Image", - "width": 1207, - "height": 1207 - }, - "content_type": "image/png", - "size": 44185 - }, - "description": "The Support server for **Revoltanator** A fun/multipurpose bot!\nAlso the home of MPPBridge!", - "banner": { - "_id": "VyPDylApVm4OHA5C4kQZFoTF2MF3eyEJOcHOQx8WVC", - "tag": "banners", - "filename": "ComfyUI_00011_.png", - "metadata": { - "type": "Image", - "width": 1248, - "height": 824 - }, - "content_type": "image/png", - "size": 1666670 - }, - "tags": [], - "members": 28, - "activity": "low" - }, - { - "_id": "01GG4G9PSPQH338SZQQB7YXCYQ", - "name": "Femboy Lounge", - "description": "A comfy place for all your femboy needs! ✨ :3\n\nWhether you identify as a femboy or are simply curious, feel free to join! ❤️️", - "icon": { - "_id": "ukwPS8XCWZ2Etd8trwaBn906f8q-CQHCFMTblgfgdo", - "tag": "icons", - "filename": "Ev9eA6vVcAEs-hs.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 116044 - }, - "banner": { - "_id": "5ND7kH5wiPPhgDjGz4qvXcpfC1c6IKN4C-5nVVBR-L", - "tag": "banners", - "filename": "ddmxnoa-0d31447a-ac6b-4c2a-8dcd-3bf0164c2512.png", - "metadata": { - "type": "Image", - "width": 2000, - "height": 1162 - }, - "content_type": "image/png", - "size": 1560906 - }, - "tags": [ - "comfy", - "femboy" - ], - "members": 2701, - "activity": "low" - }, - { - "_id": "01H4NBR374CYXKPRX4K9ECHZR3", - "name": "~ p l a c e h o l d e r ~", - "icon": { - "_id": "mWUdAgLRrItV20qbOJw9G0yghLiTu4qvA7qBZWzviU", - "tag": "icons", - "filename": "bridgetpinkcrop.png", - "metadata": { - "type": "Image", - "width": 675, - "height": 675 - }, - "content_type": "image/png", - "size": 483328 - }, - "banner": { - "_id": "yfdBGb9hZ8jo9fcloFfta3smDvDbcXGimgoyJpcDZe", - "tag": "banners", - "filename": "cmhJ4PMid4S8aPFN150LAt6QBhElBz_Hsp51qQtKgspink.png.png", - "metadata": { - "type": "Image", - "width": 480, - "height": 270 - }, - "content_type": "image/png", - "size": 49728 - }, - "description": "A silly gay server for silly gay goobers :3\n\nDo not join if you are below 18 years old", - "tags": [], - "members": 879, - "activity": "low" - }, - { - "_id": "01HKG62HFBA7HNEGZRB1GB5FW9", - "name": "restress/", - "description": "Русский сервер для общения с нотками видеоигры Doki Doki Literature Club(в частности по персонажу по имени Моника). Имеется мост с сервером stresscord(дискорд)\n\nTags/Тэги: russian ddlc nsfw monika bridged", - "banner": { - "_id": "5va9e4Rw6KP1V1UUgR3H4TZsuk-snejcZLMygjFJlu", - "tag": "banners", - "filename": "txt_1699709623248.jpg", - "metadata": { - "type": "Image", - "width": 4554, - "height": 2560 - }, - "content_type": "image/jpeg", - "size": 506312 - }, - "icon": { - "_id": "7Yyz7GakUjqmQfLtHlB9mmA7vilTkL8zHc0rQ7ejK0", - "tag": "icons", - "filename": "IMG_20240210_185350759_processed.jpg", - "metadata": { - "type": "Image", - "width": 2280, - "height": 2280 - }, - "content_type": "image/jpeg", - "size": 826092 - }, - "tags": [], - "members": 161, - "activity": "low" - }, - { - "_id": "01G45V2YS1TVTHCBDQ15R51WNJ", - "name": "Infinity", - "icon": { - "_id": "6q4OfKD585rP1GAeAiYo77GgrL3-4P1Vuo_rQBLg3x", - "tag": "icons", - "filename": "NITW9.png", - "metadata": { - "type": "Image", - "width": 194, - "height": 189 - }, - "content_type": "image/png", - "size": 65247 - }, - "banner": { - "_id": "jmypp6MkET6IEY1S3foftMX8Ah__483pmbDRFzRJnx", - "tag": "banners", - "filename": "NITW10.jpg", - "metadata": { - "type": "Image", - "width": 1024, - "height": 551 - }, - "content_type": "image/jpeg", - "size": 104871 - }, - "description": "🏳‍🌈We are a friendly welcoming community!\nMake friends, participate in events, and have fun!\nLots of NITW and Chicory emotes! \nGaming Community Chill LGBTQ Furry General NITW NightInTheWoods Night_in_the_Woods Chicory Chicory_A_Colorful_Tale", - "tags": [ - "gaming", - "community", - "chill", - "lgbtq", - "furry", - "general", - "nitw", - "nightinthewoods", - "night_in_the_woods", - "chicory" - ], - "members": 122, - "activity": "low" - }, - { - "_id": "01GVB8HXQCWQDCB63E24H5BZA3", - "name": "Bureaucracy: The Server", - "icon": { - "_id": "E9Gk-TgP1cQ9IYTnuirl5hCwf265vcr6NRHfCLOu5t", - "tag": "icons", - "filename": "monopoly man.jpg", - "metadata": { - "type": "Image", - "width": 172, - "height": 196 - }, - "content_type": "image/jpeg", - "size": 8200 - }, - "description": "Defending free speech and expression, a task that Revolt is making difficult. Regardless, our work must continue. \n", - "banner": { - "_id": "jiSDBMPQUGIFX6xGiyjHcVLgUKfXxrAUOuIIi1La5o", - "tag": "banners", - "filename": "claptrap robolution.png", - "metadata": { - "type": "Image", - "width": 474, - "height": 336 - }, - "content_type": "image/png", - "size": 390741 - }, - "tags": [], - "members": 88, - "activity": "low" - }, - { - "_id": "01HZJVJZRCVKRHKQY47PCMD6MZ", - "name": "UK Vote 2024 General Election", - "icon": { - "_id": "XS71mCR_0m85gyzMUWY1v0Ji2bg2yDc1-u2AJTn1HT", - "tag": "icons", - "filename": "OIP (7).jfif", - "metadata": { - "type": "Image", - "width": 315, - "height": 198 - }, - "content_type": "image/jpeg", - "size": 23434 - }, - "banner": { - "_id": "zO0LAhRmv_blp5ib_XHBaW_qYbwCUJcnveT1pAGosl", - "tag": "banners", - "filename": "d34cb1f60a9045598b7e37150dbdd242_18.jpg", - "metadata": { - "type": "Image", - "width": 1000, - "height": 562 - }, - "content_type": "image/jpeg", - "size": 126527 - }, - "description": "UK Focused Revolt and Discord server for the UK Vote 2024 General Election\n\npolitics left right election uk dxr", - "tags": [ - "politics", - "left", - "right", - "election", - "uk", - "dxr" - ], - "members": 16, - "activity": "low" - }, - { - "_id": "01FE6HQ77JYN0ERD5F5GTP5XMN", - "name": "Fluxpoint Development", - "icon": { - "_id": "EFeT0YxtMGjh_vs6YK447RQOvwDYAK-zWAkhW54p8b", - "tag": "icons", - "filename": "fluxpoint.png", - "metadata": { - "type": "Image", - "width": 2048, - "height": 2048 - }, - "content_type": "image/png", - "size": 150210 - }, - "description": "A community focused server for developers, gamers and anime fans that enjoy doing what we love.", - "banner": { - "_id": "NFB2C3DN4_TI73Lgh62umMfUkvRL1lGfyTmcV3es1G", - "tag": "banners", - "filename": "Webp.net-resizeimage(3).png", - "metadata": { - "type": "Image", - "width": 320, - "height": 320 - }, - "content_type": "image/png", - "size": 196632 - }, - "tags": [], - "members": 351, - "activity": "low" - }, - { - "_id": "01HTXX6G6JTTMBTT5B2Q0KV73B", - "name": "Lanterna dos Revoltados 🌽", - "description": "Servidor descontraído com intuito de reunir usuários brasileiros ou interessados em conhecer o país.\n\nbrazil general portuguese chatting chill brasil geral português conversar BR Pt-BR", - "banner": { - "_id": "ZAmOCmQBEcVvQUrXvMOGXsEqTrCoouIJDwqf0un9Fj", - "tag": "banners", - "filename": "istockphoto-1396194786-612x612.png", - "metadata": { - "type": "Image", - "width": 612, - "height": 253 - }, - "content_type": "image/png", - "size": 117954 - }, - "icon": { - "_id": "tHsUyl31Zeb4h6dwm4LbjulcHi_5b1wl__OvQ4RNWk", - "tag": "icons", - "filename": "1446882ad46daafb5ada89eb162a967b17ea2a73ba7f9b1cff2f85cb2239f6fb_1_cropped.png", - "metadata": { - "type": "Image", - "width": 291, - "height": 291 - }, - "content_type": "image/png", - "size": 146993 - }, - "tags": [ - "brazil", - "general", - "portuguese", - "chatting", - "chill", - "brasil", - "geral", - "portugu", - "conversar", - "br" - ], - "members": 38, - "activity": "low" - }, - { - "_id": "01FZB38TYPX73VSWFMMJTZE8C5", - "name": "Freakyville", - "icon": { - "_id": "tBqQ2eh6GZdZs3xnnO0llkd1Mde1gIOu-cXOCqSwpp", - "tag": "icons", - "filename": "logo.png", - "metadata": { - "type": "Image", - "width": 226, - "height": 235 - }, - "content_type": "image/png", - "size": 21591 - }, - "description": "This is a pretty chill server, if you're confused on how you joined this, this used to be the Mecha server. Annnnyyyyyy ways just join this server for a cookie and also to keep up to date on my latest projects, memes, inside jokes, and other stuff. Also linked with my personal discord server\n\nvoltage bot bots developer developing python py revoltbot community programming programmer support mechasupport supportserver mechabot mecha chill coding coder", - "banner": { - "_id": "3zTbBjh_RCVGTSN3GtK1FyAAa6AIaG6CvozT8NFnRQ", - "tag": "banners", - "filename": "20240129200003_1.jpg", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 436094 - }, - "tags": [ - "voltage", - "bot", - "bots", - "developer", - "developing", - "python", - "py", - "revoltbot", - "community", - "programming" - ], - "members": 130, - "activity": "low" - }, - { - "_id": "01GV54XVZBV12SYCC7CD8223K4", - "name": "Weeb's Cafe", - "description": "Hi. We like anime, manga, gaming and even films!\n", - "icon": { - "_id": "rnx_fuk7gp5C4bJ9GH9nWeZtTwWyzaQwir2m8pvAu5", - "tag": "icons", - "filename": "tumblr_c3f21628338e10f4a2c22c00b0171e13_30f2da15_1280.jpg", - "metadata": { - "type": "Image", - "width": 1179, - "height": 801 - }, - "content_type": "image/jpeg", - "size": 118416 - }, - "banner": { - "_id": "rbuiMpRFbgQ6wtUlhBwk_509mRA1wA1Nf5IUQ_HqZo", - "tag": "banners", - "filename": "anime-asuka.gif", - "metadata": { - "type": "Image", - "width": 498, - "height": 372 - }, - "content_type": "image/gif", - "size": 3144389 - }, - "tags": [], - "members": 547, - "activity": "low" - }, - { - "_id": "01GVQJVVFM233EHBV03K7B3HF5", - "name": "SuperTuxKart", - "icon": { - "_id": "-rzoEJPeWD8rAJJWNEx_6KfcYUPd-8C1zn53YUe6MP", - "tag": "icons", - "filename": "supertuxkart_256.png", - "metadata": { - "type": "Image", - "width": 256, - "height": 256 - }, - "content_type": "image/png", - "size": 52492 - }, - "description": "The (un)official SuperTuxKart Revolt server! SuperTuxKart is a 3D open source kart racing game with a variety of tracks, characters, and modes to play.", - "banner": { - "_id": "x3HyHSvMTc8nJpaRCPc5AP_SKHMin-kFQpayQjY5fg", - "tag": "banners", - "filename": "Screenshot from 2023-07-01 13-37-53.png", - "metadata": { - "type": "Image", - "width": 1361, - "height": 695 - }, - "content_type": "image/png", - "size": 1531368 - }, - "tags": [ - "supertuxkart" - ], - "members": 254, - "activity": "low" - }, - { - "_id": "01GKFQ23DBDBK1D978F5GTSNZ7", - "name": "Blobfox", - "banner": { - "_id": "7m8AVQv2CgBSmw1BBcyGN3zwPMiSt3EObAkDlEXWHh", - "tag": "banners", - "filename": "blobfox-banner3.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 1030870 - }, - "icon": { - "_id": "YcVEbFfn06htn2-zBSNq9GI-jdsPxOhBwjsoNOJHJU", - "tag": "icons", - "filename": "blobfoxbox.png", - "metadata": { - "type": "Image", - "width": 128, - "height": 128 - }, - "content_type": "image/png", - "size": 4272 - }, - "description": "A chat for those who really like foxxos :D \n\nAlso contains blobfox emojis.", - "tags": [], - "members": 505, - "activity": "low" - }, - { - "_id": "01H3K7D3N3X9AQNAKWYWT916WY", - "name": "Splatlands", - "icon": { - "_id": "5HyUDPiEmbtY77onG6aE4f1KCm-guIH2AMjad6fU1v", - "tag": "icons", - "filename": "InklingUsingSplatNet_cropped.png", - "metadata": { - "type": "Image", - "width": 679, - "height": 679 - }, - "content_type": "image/png", - "size": 259218 - }, - "banner": { - "_id": "-fQu2XCKU7qXcl5EktAD7VS9BA3SnUHIzbEeDE4QoV", - "tag": "banners", - "filename": "ec39a30dec6162a8855a7be5717d5e0d-744733100 (1).gif", - "metadata": { - "type": "Image", - "width": 480, - "height": 320 - }, - "content_type": "image/gif", - "size": 138684 - }, - "description": "400+ members & active! 🦑 A vibrant cross-platform community for battles, art, tips, & ink-redible fun! - see splatlands.com/revolt-metrics for why it says \"low activity\" - splatoon , splatoon2 , splatoon3 , nintendo , gaming, switch , wiiu", - "flags": 2, - "tags": [ - "splatoon", - "splatoon2", - "splatoon3", - "nintendo", - "gaming", - "switch", - "wiiu" - ], - "members": 110, - "activity": "low" - }, - { - "_id": "01G6JJDXXTC87T4VK2Y1A67M0C", - "name": "PopCord Support", - "icon": { - "_id": "HdZhrklZqHW8NmQnxa3Yo6BWdfgsjTiTudfI-6eg8R", - "tag": "icons", - "filename": "image_2023-07-06_015057428.png", - "metadata": { - "type": "Image", - "width": 636, - "height": 636 - }, - "content_type": "image/png", - "size": 413371 - }, - "banner": { - "_id": "CJ0uNsmy4rJmuitfiIgCuKvtLmXuktX0x93msEg6EY", - "tag": "banners", - "filename": "image_2023-09-30_222236459.png", - "metadata": { - "type": "Image", - "width": 1200, - "height": 300 - }, - "content_type": "image/png", - "size": 380078 - }, - "description": "the official support server of PopCord\n\ntags : nsfw support lewd anime hentai game adult social admin", - "tags": [ - "nsfw", - "support", - "lewd", - "anime", - "hentai", - "game", - "adult", - "social", - "admin" - ], - "members": 444, - "activity": "low" - }, - { - "_id": "01J019XD6STD9F76CDBM41MXHJ", - "name": "Evelyn's Sillies | Revolt 🌸", - "description": "Welcome to Evelyn's server! We hope you enjoy your stay here.", - "icon": { - "_id": "nrI-8zRH1kWZ67c-Fuj5QzZ5yjMVs6ru0DE_oLBNwY", - "tag": "icons", - "filename": "f0297c7462b4ec4e8b33914201afe304.jpg", - "metadata": { - "type": "Image", - "width": 474, - "height": 470 - }, - "content_type": "image/jpeg", - "size": 39173 - }, - "banner": { - "_id": "6WU6lKuPryw3vZ3y8R_kq2nQqPoRQD_0fa_G4IoBR2", - "tag": "banners", - "filename": "886ddaf1d23845256d097a340b08fc30.jpg", - "metadata": { - "type": "Image", - "width": 736, - "height": 414 - }, - "content_type": "image/jpeg", - "size": 21965 - }, - "tags": [], - "members": 16, - "activity": "low" - }, - { - "_id": "01HZ7C8RCF989ZKE783ADDWZNV", - "name": "Chlove's Cabin", - "icon": { - "_id": "rA03w4ObPzWIccWM5POlS8ewgHbLt0usbdkYml_hpi", - "tag": "icons", - "filename": "f42c89c57a58181a6b8152d50c55c952-Large.png", - "metadata": { - "type": "Image", - "width": 450, - "height": 450 - }, - "content_type": "image/png", - "size": 457804 - }, - "banner": { - "_id": "Kf30G7xjTOBm8X6T4rDktm2p6nIB9bJFBgLiSMCYIv", - "tag": "banners", - "filename": "1717083426.jpg", - "metadata": { - "type": "Image", - "width": 1200, - "height": 400 - }, - "content_type": "image/jpeg", - "size": 56053 - }, - "description": "Welcome to my cozy little Revolt server, everyone is welcome!", - "tags": [], - "members": 25, - "activity": "low" - }, - { - "_id": "01HZWWK052VKHKYK77RXWG7E0T", - "name": "Wales Teens", - "banner": { - "_id": "_znlbdlgHu2-oyafsekbdyyHXq2M1VTLt7vUhOLSXA", - "tag": "banners", - "filename": "0_yes-Cymru-Rally.webp", - "metadata": { - "type": "Image", - "width": 1841, - "height": 1227 - }, - "content_type": "image/webp", - "size": 259216 - }, - "icon": { - "_id": "UhkX6V7WzJQT_MdBA6ppywDjZRQmz0Bl3dANYH7lLV", - "tag": "icons", - "filename": "Screenshot_20240608_234337_Color-removebg-preview.png", - "metadata": { - "type": "Image", - "width": 499, - "height": 500 - }, - "content_type": "image/png", - "size": 119191 - }, - "description": "just Revolt x Discord server for any teens who are Welsh or live in Wales.\n\nplease consider joining as most of the other Welsh community servers are dead :)\n\nwe're still a small server but we do have a fairly active community\n\nWales Celtic Welsh Politics 14+ Community Friends politics dxr", - "tags": [ - "wales", - "celtic", - "welsh", - "politics", - "14", - "community", - "friends", - "dxr" - ], - "members": 7, - "activity": "low" - }, - { - "_id": "01HDYRVKEA83PFHY1NZH8C00C6", - "name": "⟳ .. Venting", - "banner": { - "_id": "DTjK7SiZCs08eMahEk7Mptik2WUyuhGo-MPXTO2tdr", - "tag": "banners", - "filename": "23a1724d1094c0a03bebb8072db7eca5.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 282678 - }, - "icon": { - "_id": "-GPafG3-JkiP_tGcfs72DEeQjGkm3JsXCwMS4SXSwf", - "tag": "icons", - "filename": "f68ed1d48ebc16f05ee3a4b22619afe9.jpg", - "metadata": { - "type": "Image", - "width": 236, - "height": 236 - }, - "content_type": "image/jpeg", - "size": 13357 - }, - "description": "> started on 10.29.23 .. this server's theme is: Echo by Crusher-P\n\n> **Welcome .. This is a comfort chat for ones who need help. We can also hangout.**", - "tags": [], - "members": 79, - "activity": "low" - }, - { - "_id": "01HYXTJ5T5ZNF82BM6ZNWVW47M", - "name": "Trace's Chill Corner トレースのチルコーナー", - "banner": { - "_id": "t2oIqupetequV1tA71x2evRa6eHJypR5AM0TsXKylt", - "tag": "banners", - "filename": "abstract-purple-moon-wallpaper-2560x1600.jpg", - "metadata": { - "type": "Image", - "width": 2560, - "height": 1548 - }, - "content_type": "image/jpeg", - "size": 670876 - }, - "icon": { - "_id": "seIYj_RNuz3TvxgJg4BkS0eetCdUSF7D72WbGEiyPo", - "tag": "icons", - "filename": "Trace's Chill Cornerトレースのチルコーナー", - "metadata": { - "type": "Image", - "width": 431, - "height": 431 - }, - "content_type": "image/jpeg", - "size": 34376 - }, - "description": "This server is a small friendly and wholesome community, where we can have a nice chat, share moments and experiences together, chill, vibe to some music, play games and have fun.", - "tags": [], - "members": 18, - "activity": "low" - }, - { - "_id": "01GA6VT1QGYPBBSQJG824664N5", - "name": "Capp n' Ccino", - "icon": { - "_id": "3gaTmL5spsKRyxDTDLVd1ffCyepOJRGS38SFadsK82", - "tag": "icons", - "filename": "capp.png", - "metadata": { - "type": "Image", - "width": 700, - "height": 700 - }, - "content_type": "image/png", - "size": 24751 - }, - "banner": { - "_id": "thvkrmF41c_sFbuDHFd0PvUvpcIZT-oMa3QXf_WtPS", - "tag": "banners", - "filename": "banner-cappu.png", - "metadata": { - "type": "Image", - "width": 1280, - "height": 720 - }, - "content_type": "image/png", - "size": 180687 - }, - "description": "🟢⚪Capp n' Ccino⚪🟢・📶 Servidor inovador・💬 Chats temáticos e diversos・🏆 Eventos・🐟 Peixe Alves (Deus) ・🇵🇹🇧🇷 Servidor português (PT/BR - Portugal e Brasil)", - "tags": [], - "members": 150, - "activity": "low" - }, - { - "_id": "01H89E5HFQHYZ68FSR7QCM4J0D", - "name": "⭐ | AutumnWoods", - "description": "Comunidade de ponte ao servidor com o mesmo nome no discord e atualmente o maior servidor furry do brasil no revolt.", - "icon": { - "_id": "Uq-FuS9fQla4fE3Fo16ShK81TwS-UIIt4lwIrLUwQg", - "tag": "icons", - "filename": "e87ce6de2fb6297e085b91c9c33173ce.png", - "metadata": { - "type": "Image", - "width": 469, - "height": 469 - }, - "content_type": "image/png", - "size": 105297 - }, - "banner": { - "_id": "wmSad7pB81OAtOMJJEOY9BoHC7XRjM0cQEzoyQDITt", - "tag": "banners", - "filename": "fda3f0ea0fbff59f8f2d442f7c0ccf05.png", - "metadata": { - "type": "Image", - "width": 1200, - "height": 821 - }, - "content_type": "image/png", - "size": 1690641 - }, - "tags": [ - "brasil" - ], - "members": 43, - "activity": "low" - }, - { - "_id": "01GQ14WC58C8AXCWNJQBFDZNT3", - "name": "RevoltBots", - "icon": { - "_id": "ep-ZfhyRjEbl3O1uLuLUOP8bJYqixm27KRhm9VXllz", - "tag": "icons", - "filename": "Pride.png", - "metadata": { - "type": "Image", - "width": 186, - "height": 186 - }, - "content_type": "image/png", - "size": 10369 - }, - "description": "The community and support server for the very first open-sourced Revolt Bot List.\nMade for an easier way to find bots and servers. \n\ncommunity support botlist open-sourced programming", - "banner": { - "_id": "KxuVkC7Q0_yBtUQCZm5FyUUsE8w0Mh26wBw-lOXEDH", - "tag": "banners", - "filename": "revolt-bot-list-banner-by-axorax.png", - "metadata": { - "type": "Image", - "width": 6912, - "height": 3456 - }, - "content_type": "image/png", - "size": 1634583 - }, - "tags": [ - "community", - "support", - "botlist", - "open", - "programming" - ], - "members": 583, - "activity": "low" - }, - { - "_id": "01GZXEJSR4YGRZ0780FB0TVQWR", - "name": "👊Unis Game Clube🎮", - "icon": { - "_id": "QwCNtMfe94cRHhbKCImh4AuNG04DebXC4zKnXGHK14", - "tag": "icons", - "filename": "UGC GAMES.png", - "metadata": { - "type": "Image", - "width": 628, - "height": 628 - }, - "content_type": "image/png", - "size": 125897 - }, - "banner": { - "_id": "2IBeL7cC1Z4mg3VhxHOYUbMvSU8lVigiXO1D1WMT6B", - "tag": "banners", - "filename": "Cyber_Punk.gif", - "metadata": { - "type": "Image", - "width": 498, - "height": 149 - }, - "content_type": "image/gif", - "size": 607446 - }, - "description": "Grupo Criado Especialmente Para o Publico >GAMER< Em Geral.\n\nbrasil revolt gaming community chat", - "tags": [ - "brasil", - "revolt", - "gaming", - "community", - "chat" - ], - "members": 29, - "activity": "low" - }, - { - "_id": "01H2NVSP015K3GQGEPGD09WZ4H", - "name": "Lucario's Hangout", - "icon": { - "_id": "aOO2Mk2eOpUjc7bQu38SfbXrMnR_fWkIuyjcI_cft7", - "tag": "icons", - "filename": "lucaROI again04a622a1316109f4a52b0a2a0b9714.jpg", - "metadata": { - "type": "Image", - "width": 564, - "height": 795 - }, - "content_type": "image/jpeg", - "size": 88966 - }, - "banner": { - "_id": "B34TbbuaN_qzI3QMyam7WA4_1XgnPOPjesQ1SYcPst", - "tag": "banners", - "filename": "lucariobannerQ8aHU8AASURK.jpg", - "metadata": { - "type": "Image", - "width": 1200, - "height": 400 - }, - "content_type": "image/jpeg", - "size": 112935 - }, - "description": "Welcome to Lucario's server! Here, you will find a lot of nice people and fun bots, such as level bots! This server is for everyone, no matter what. There are advertising channels too, if you'd like to grow your server, so feel free to join! o-o\n\n\nlucario pokemon revolt chill discord", - "tags": [ - "lucario", - "pokemon", - "revolt", - "chill", - "discord" - ], - "members": 83, - "activity": "low" - }, - { - "_id": "01FR5JKX0N7CAMETQF2KWPCQDY", - "name": "Minecraft", - "icon": { - "_id": "BcraeSn8FndarGQHF26U22y8OXZrqnuBnMG3XDwTrB", - "tag": "icons", - "filename": "revolt_mc_icon.png", - "metadata": { - "type": "Image", - "width": 1079, - "height": 1079 - }, - "content_type": "image/png", - "size": 3974 - }, - "banner": { - "_id": "YKEaIq2CK2hU02yQS0MBwRVWsH21J-FkenRr4ooXRj", - "tag": "banners", - "filename": "revolt_mc_banner.png", - "metadata": { - "type": "Image", - "width": 3072, - "height": 1024 - }, - "content_type": "image/png", - "size": 2151 - }, - "description": "Revolt Minecraft community run by Minecraft enthusiasts for players across the globe! Minecraft Gaming MC Gamer Memes Fun", - "tags": [ - "minecraft", - "gaming", - "mc", - "gamer", - "memes", - "fun" - ], - "members": 1201, - "activity": "low" - }, - { - "_id": "01H5XHVJ8FKHN8WXHWCV4MV4H4", - "name": "Gaming and more", - "icon": { - "_id": "jfkSm4KZdpfh3LmkiieloLo9LgZtmYZJhKHYYCRtm0", - "tag": "icons", - "filename": "images.jpeg", - "metadata": { - "type": "Image", - "width": 310, - "height": 163 - }, - "content_type": "image/jpeg", - "size": 15417 - }, - "description": "to discuss gaming and more", - "tags": [], - "members": 16, - "activity": "low" - }, - { - "_id": "01H2WTG24FHPC0E2GKB4EDS1ER", - "name": "Watame Wonkers 🐏", - "icon": { - "_id": "FCJxSXbXodD8tOR0ThMLWfXjUwWd1H9j_x7M-DHLey", - "tag": "icons", - "filename": "watamebigface.jpg", - "metadata": { - "type": "Image", - "width": 900, - "height": 900 - }, - "content_type": "image/jpeg", - "size": 105730 - }, - "banner": { - "_id": "boAv9G3YQnCUC-xPVAZml8pdk2ikw52T3TM9ZAjyxe", - "tag": "banners", - "filename": "watamehappy.jpg", - "metadata": { - "type": "Image", - "width": 1920, - "height": 811 - }, - "content_type": "image/jpeg", - "size": 144562 - }, - "description": "Konbandododoo! A server for all things Hololive and Vtuber related. This server also likes other groups like Nijisanji and Vshojo so don't be afraid! If you like the best sheep Watame, do join!", - "tags": [ - "hololive", - "vtuber", - "nijisanji", - "vshojo", - "sheep", - "watame" - ], - "members": 103, - "activity": "low" - }, - { - "_id": "01HXJD88FQMSKNZBNA23GWZ77D", - "name": "FREE PALESTINE", - "icon": { - "_id": "IlkN8LZoGgvPdcdDyfH95yfebUcW55y3ExM3UEPrn0", - "tag": "icons", - "filename": "depositphotos_44276519-stock-photo-waving-palestine-flag(1).png", - "metadata": { - "type": "Image", - "width": 600, - "height": 400 - }, - "content_type": "image/png", - "size": 132612 - }, - "banner": { - "_id": "TokKs-xc_amWrNscKqlouBezrWPuoQR1igzpjeSAcQ", - "tag": "banners", - "filename": "GettyImages-1958208313-scaled.jpg", - "metadata": { - "type": "Image", - "width": 1200, - "height": 630 - }, - "content_type": "image/jpeg", - "size": 118950 - }, - "description": "\"From the land to the sea, Palestine shall be free!\" This is a server dedicated to the support of Palestine and the wish for its freedom and worldwide recognition.", - "tags": [], - "members": 26, - "activity": "low" - }, - { - "_id": "01HHK2X45XDHXKAGBFF09TDP8F", - "name": "Minecraft BR", - "icon": { - "_id": "nzgeCRbFUC2IzHh_hn61lgcr3asVh93cQ4VWpk5ZKq", - "tag": "icons", - "filename": "mcforu10.png", - "metadata": { - "type": "Image", - "width": 512, - "height": 512 - }, - "content_type": "image/png", - "size": 166105 - }, - "banner": { - "_id": "F1Pn9H9O9pDmKBXZbkgI9i7D2egun3M4XSP8gIlJvZ", - "tag": "banners", - "filename": "minecraft-logo-v0-ak9w918zi5r81.webp", - "metadata": { - "type": "Image", - "width": 400, - "height": 400 - }, - "content_type": "image/webp", - "size": 10258 - }, - "description": "Servidor da comunidade de minecraft br <3", - "tags": [], - "members": 22, - "activity": "low" - }, - { - "_id": "01GVHCXDM9DMDDR6CWBCVQTXST", - "name": "Sonic The Hedgehog | 85+ Emojis", - "description": "The Number 1 Sonic The Hedgehog fandom server for Revolt.chat, and we got emojis, lots of em.\nTags: #sonic #fandom #emojis #gaming #SonicTheHedgehog #emoji #emotes ", - "banner": { - "_id": "WoWxFMMruS-AWFQ_3spCP2i7jf2T899sKLwLsqHGfB", - "tag": "banners", - "filename": "Untitled101_20240206164205.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 381 - }, - "content_type": "image/png", - "size": 251838 - }, - "icon": { - "_id": "3hob4UPtsEWefgOHHH6BDkgzbJI9hvYbtcoFi-hvM5", - "tag": "icons", - "filename": "Untitled90_20240105200635.png", - "metadata": { - "type": "Image", - "width": 1136, - "height": 1040 - }, - "content_type": "image/png", - "size": 104539 - }, - "tags": [ - "sonic", - "fandom", - "emojis", - "gaming", - "sonicthehedgehog", - "emoji", - "emotes" - ], - "members": 234, - "activity": "low" - }, - { - "_id": "01HGWHASQ5E2GK735SKJ96NDFQ", - "name": "::::Server Not Found::::", - "banner": { - "_id": "lTLc3rgIRSrvbs6Ndw2zCRV5vCxzEUgbO7Bo4pOoRn", - "tag": "banners", - "filename": "IMG_4975.png", - "metadata": { - "type": "Image", - "width": 1560, - "height": 624 - }, - "content_type": "image/png", - "size": 33769 - }, - "description": "This is sui-han-ki proxy server!!!(Server changed)\nOf course, English OK! Japanese OK!\nLet’s talk with us. Everyone is welcome.\n### [My server](https://rvlt.gg/3kNMTVTw) ([Discord ver.](https://discord.gg/3mzgvWQuc8)) Please join!\nsui-han-ki proxy japanese ", - "icon": { - "_id": "jlioGUKSUqRRW59t15XgY2DjdVMlzOqLCKJCezD4v4", - "tag": "icons", - "filename": "IMG_4436.jpeg", - "metadata": { - "type": "Image", - "width": 1056, - "height": 1064 - }, - "content_type": "image/jpeg", - "size": 51278 - }, - "tags": [ - "sui", - "proxy", - "japanese" - ], - "members": 194, - "activity": "low" - }, - { - "_id": "01GTPEPDFFRVWEKPXNTNY9S69H", - "name": "Programming Space", - "description": "A general-purpose SFW programming server for all developers/programmers and more.\n\nprogramming coding revolt linux", - "icon": { - "_id": "Ct4Pidnhx0Y7OMHcsFIcB2BiLijTl1xHzHsvyKujtw", - "tag": "icons", - "filename": "PFPNewest.png", - "metadata": { - "type": "Image", - "width": 550, - "height": 550 - }, - "content_type": "image/png", - "size": 286150 - }, - "banner": { - "_id": "ji24WY1PQ4MqWlA_2HmiOWhMPJ-3bOoBlV1rtpk0Js", - "tag": "banners", - "filename": "Banner2.png", - "metadata": { - "type": "Image", - "width": 3456, - "height": 900 - }, - "content_type": "image/png", - "size": 2485277 - }, - "tags": [ - "programming", - "coding", - "revolt", - "linux" - ], - "members": 243, - "activity": "low" - }, - { - "_id": "01GTWDKEMTKWKM5M1BW630YTV3", - "name": "Russian Shitpost", - "description": "Сервер для русскоязычной части Револьта\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nrussian russia programming tech алокакэтитегиубрать", - "banner": { - "_id": "qEXE4f9bXuRJnXVnHDkRF9_j6Y9J_hz6855Sef_e5a", - "tag": "banners", - "filename": "furry_pornushka.gif", - "metadata": { - "type": "Image", - "width": 600, - "height": 421 - }, - "content_type": "image/gif", - "size": 3541037 - }, - "icon": { - "_id": "XwFsjo4hQ_Gf7_7U2Oo0-B1kN5rSDX-nak4EH0mJ02", - "tag": "icons", - "filename": "rslogo.png", - "metadata": { - "type": "Image", - "width": 64, - "height": 64 - }, - "content_type": "image/png", - "size": 9911 - }, - "tags": [ - "russian", - "russia", - "programming", - "tech" - ], - "members": 449, - "activity": "low" - }, - { - "_id": "01H7WNWGEX0GCMV7FC0BR6PEEH", - "name": "jenvolt.", - "icon": { - "_id": "P0ObxzAOW3dvRvcO5bvfmgzkRmXjK4PHQnK66nyntp", - "tag": "icons", - "filename": "-LWDWjundDgaq3SSQUYcCm8a6zNR3G5zmaL1lcDFpK (4).png", - "metadata": { - "type": "Image", - "width": 715, - "height": 715 - }, - "content_type": "image/png", - "size": 222016 - }, - "banner": { - "_id": "X5lL3bygullQL3kSt7q94fcP3MYcCN_KMjVIbckSYv", - "tag": "banners", - "filename": "v5GDGIf0iHAzsKg2dpLSArNKG2d4-B2V2x1G96c4Gc.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 1041645 - }, - "description": "welcome to el servidor de jennifer\nwe also do the official revolt android app 🥶🥶🥶🥶🥶\n", - "flags": 2, - "tags": [], - "members": 430, - "activity": "low" - }, - { - "_id": "01HZR76BXQHHQ2ZPSS4W3NPXKX", - "name": "Revplace ", - "description": "Comunidade brasileira dedicada a trazer atualizações e novidades sobre as mudanças e inovações da plataforma Revolt.\n\nBrazil", - "banner": { - "_id": "7te8h0oZdywWacVfyz2SrP176XGZFeRtnbaziRIUyY", - "tag": "banners", - "filename": "IMG_5961.jpeg", - "metadata": { - "type": "Image", - "width": 1000, - "height": 548 - }, - "content_type": "image/jpeg", - "size": 72732 - }, - "icon": { - "_id": "U53GfG0VHT7MZ-CVnKZHf-L1bTId0-_X-URirVSxO9", - "tag": "icons", - "filename": "IMG_5962.png", - "metadata": { - "type": "Image", - "width": 512, - "height": 512 - }, - "content_type": "image/png", - "size": 24275 - }, - "tags": [ - "brazil" - ], - "members": 8, - "activity": "low" - }, - { - "_id": "01HSJPNS9H2MPPMZTJNW8T5MRD", - "name": "AutoMod", - "description": "The official AutoMod server.\n\nBot AutoMod Moderation", - "icon": { - "_id": "u1UnHIUee-DWKXO6AN6ZzOVoznde1TOaC4rv4lK_kj", - "tag": "icons", - "filename": "pYjK-QyMv92hy8GUM-b4IK1DMzYILys9s114khzzKY.png", - "metadata": { - "type": "Image", - "width": 512, - "height": 512 - }, - "content_type": "image/png", - "size": 29618 - }, - "banner": { - "_id": "vLwPp9Hf_AdMboqBnJrypI24cE8zRH2EHGOY7C35M1", - "tag": "banners", - "filename": "AoRvVsvP1Y8X_A4cEqJ_R7B29wDZI5lNrbiu0A5pJI.webp", - "metadata": { - "type": "Image", - "width": 1000, - "height": 562 - }, - "content_type": "image/webp", - "size": 44628 - }, - "flags": 2, - "tags": [ - "bot", - "automod", - "moderation" - ], - "members": 123, - "activity": "low" - }, - { - "_id": "01G3PKD1YJ2H484MDX6KP9WRBN", - "name": "Revolt API", - "icon": { - "_id": "vKzb5xTbhtAvXcFcY6PITtOuHDhjie4849gq6pWO5n", - "tag": "icons", - "filename": "BujbQ_t27M9iQ-ZKVQuv03LI-IndUYxq3sBHpx6XsM.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 12696 - }, - "description": "Official server for discussing the Revolt API. Get help, discuss improvements and hear about the latest changes here!\n\nrevolt api official ", - "flags": 1, - "tags": [ - "revolt", - "api", - "official" - ], - "members": 512, - "activity": "no" - }, - { - "_id": "01J0242M5KBX0QDNVQBVJGQ7SM", - "name": "Portugal", - "description": "O servidor Portugal trata-se de uma comunidade portuguesa, onde todos são muito bem vindos, espero que gostem do servidor!\n", - "icon": { - "_id": "iUNTQOoScm1skpVOa1x_3pR-EoUebARGhVTt0ZYOax", - "tag": "icons", - "filename": "Screenshot from 2024-06-16 22-11-50.png", - "metadata": { - "type": "Image", - "width": 829, - "height": 605 - }, - "content_type": "image/png", - "size": 580529 - }, - "banner": { - "_id": "WwRBopQryQv-WuGxBUAAE4JgnsIWo7ktHXUb2IPMaX", - "tag": "banners", - "filename": "portugal2.jpg", - "metadata": { - "type": "Image", - "width": 1500, - "height": 1000 - }, - "content_type": "image/jpeg", - "size": 286484 - }, - "tags": [], - "members": 10, - "activity": "no" - }, - { - "_id": "01H6WQGNWVEKH3BH7FS14NS7XH", - "name": "The Splatoon Lounge", - "icon": { - "_id": "iZtGw9smfIqvBpXFJKroLt6f2Xfbx1YtN2iJ0IeJsc", - "tag": "icons", - "filename": "IMG_3221.jpeg", - "metadata": { - "type": "Image", - "width": 416, - "height": 351 - }, - "content_type": "image/jpeg", - "size": 55007 - }, - "banner": { - "_id": "NFfaCjj69zHWSxKjrY-n1FLSCo3c_Z8VPmDHLQBcwc", - "tag": "banners", - "filename": "IMG_3220.jpeg", - "metadata": { - "type": "Image", - "width": 700, - "height": 200 - }, - "content_type": "image/jpeg", - "size": 28391 - }, - "description": "A server you can chat about Splatoon. Please make sure to read the rules before going into conversation. Enjoy your stay here! ", - "tags": [], - "members": 87, - "activity": "no" - }, - { - "_id": "01FEPATGXB3TGTKSA3ZVA7RSTM", - "name": "yui's comfy place", - "description": "comfy server for everyone!", - "banner": { - "_id": "lhq_rDDPfY3J3zUw1EaYQMCmptoatIqrkU0u9g4qgB", - "tag": "banners", - "filename": "7e0accaa2b2b902724863fffaa319f94.png", - "metadata": { - "type": "Image", - "width": 1024, - "height": 361 - }, - "content_type": "image/png", - "size": 432331 - }, - "icon": { - "_id": "1tU3Gn6rosEoZ0sbgfgZkHMp0dlAPOrge1CUxzKW--", - "tag": "icons", - "filename": "sssdwqqwd.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 226803 - }, - "tags": [], - "members": 786, - "activity": "no" - }, - { - "_id": "01H28NWQBPXA2FMZD7G0YVKZ61", - "name": "Language Learning", - "description": "The server for language learning and all things language. For both natlangs and conlangs! Also a very active chess channel...", - "icon": { - "_id": "X33zErm-vNBR0ObiRz90haDQp15nbXNYD4Wkt3dhoy", - "tag": "icons", - "filename": "planet-earth-on-blue-background-vector-3295462538(1).jpg", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/jpeg", - "size": 24115 - }, - "banner": { - "_id": "pjvGhN93qaSIRmMi-7nJpKNLS4huqHI3Lu91IQxoLm", - "tag": "banners", - "filename": "Untitled347_20230611115010.png", - "metadata": { - "type": "Image", - "width": 800, - "height": 450 - }, - "content_type": "image/png", - "size": 255952 - }, - "tags": [ - "language", - "learning", - "natlangs", - "conlangs", - "chess" - ], - "members": 783, - "activity": "no" - }, - { - "_id": "01HB2D9WWFMVHS4PH3KXT6HZTM", - "name": "blendOS", - "icon": { - "_id": "YDXDLRy9cFEGfiojdLIvLmif3EvIAXafvga1dFGsgc", - "tag": "icons", - "filename": "logo-1.png", - "metadata": { - "type": "Image", - "width": 1024, - "height": 1024 - }, - "content_type": "image/png", - "size": 634962 - }, - "description": "[blendOS](https://blendos.co) is an Arch based Linux Distribution, focused around allowing the user to use packages from different distributions, Android, and PWAs.", - "banner": { - "_id": "08JnC5g_2wnIvH9NlLMDB-wrkipLLZJmGF4RjltZDZ", - "tag": "banners", - "filename": "rn_image_picker_lib_temp_3b8c1800-a197-4563-aa8b-4b777d10255c.jpg", - "metadata": { - "type": "Image", - "width": 1080, - "height": 360 - }, - "content_type": "image/jpeg", - "size": 35160 - }, - "tags": [], - "members": 40, - "activity": "no" - }, - { - "_id": "01FZ62C8WFS3HBEN5QTN8RZRQG", - "name": "Remix HQ", - "icon": { - "_id": "abVmV8YgPfdZalAs0vqy2YaOgs3lNRV8oIj8wL8tAY", - "tag": "icons", - "filename": "Remix.jpg", - "metadata": { - "type": "Image", - "width": 1440, - "height": 1440 - }, - "content_type": "image/jpeg", - "size": 104575 - }, - "description": "The official community for the Remix music bot. \nremix remixbot firstmusicbot bot bots music musicbot revolt revoltbot support community cool epic", - "flags": 2, - "banner": { - "_id": "wj2r7z8c_dJt2UDw6NQOXM4sU8Vt-CNAAFjNURY4vC", - "tag": "banners", - "filename": "D4_hBq5D2DObGDeYuo6agc7-5kjWNcefnrqMgHAkL2.gif", - "metadata": { - "type": "Image", - "width": 600, - "height": 360 - }, - "content_type": "image/gif", - "size": 1739994 - }, - "tags": [ - "remix", - "remixbot", - "firstmusicbot", - "bot", - "bots", - "music", - "musicbot", - "revolt", - "revoltbot", - "support" - ], - "members": 514, - "activity": "no" - }, - { - "_id": "01GVKKSEJAPK8V9CXG9FNYW0DG", - "name": "Roblox Lounge", - "icon": { - "_id": "jb0YFUKg3v118tU3hIDVKblX5KFiG31tYIuHS4adyj", - "tag": "icons", - "filename": "Revolt Icon.png", - "metadata": { - "type": "Image", - "width": 512, - "height": 512 - }, - "content_type": "image/png", - "size": 6332 - }, - "banner": { - "_id": "2aIsCm0ZI6tNuJh9ohOmA2Xr365j0FcIjG0nKcj_eZ", - "tag": "banners", - "filename": "Revolt Banner.png", - "metadata": { - "type": "Image", - "width": 1222, - "height": 489 - }, - "content_type": "image/png", - "size": 11518 - }, - "description": "Fan Server about the game named Roblox! Share whatever you like here, find new people in our channels :)\n\nNot official neither affilitated with the Roblox Company.", - "tags": [], - "members": 281, - "activity": "no" - }, - { - "_id": "01HC0370ZP1VKKMXP4KB1666G0", - "name": "The Star Lounge ⭐", - "description": "> **Welcome to The Star Lounge ★ !!**\n*Relax, chat, and make lots of friends here ♡ !*\n> **Have fun, and enjoy your time 🎔 !!**\n``We celebrate with 40+ members and 13 spot in Discovery, join the fun !``\n", - "icon": { - "_id": "WGAMHgoQ4_hZWnOQ8Zx96L0GJE1vNpXwtfuuhL-aTx", - "tag": "icons", - "filename": "pixai-1671089269300275634-0.png", - "metadata": { - "type": "Image", - "width": 440, - "height": 378 - }, - "content_type": "image/png", - "size": 255760 - }, - "banner": { - "_id": "yK0QaOuRwkjTOrrDxtyrFXWMis4rhVloBhrRY7oSBx", - "tag": "banners", - "filename": "pixai-1671090919939491631-2.png", - "metadata": { - "type": "Image", - "width": 512, - "height": 357 - }, - "content_type": "image/png", - "size": 319166 - }, - "tags": [ - "13" - ], - "members": 115, - "activity": "no" - }, - { - "_id": "01HS75CA336AEZQCR3CZCW5TYF", - "name": "I like boars", - "description": "For anyone who interested in my games, software and other projects.\n\nAlso available is general Godot and game development discussion + guidance.\nOur procrastination is programming.\n\ngamedev godot selfhosting programming", - "icon": { - "_id": "pUjeTLgdRGfzLlYBdlhX34FdcVbVg2LrK7dByBew0P", - "tag": "icons", - "filename": "cecc2b48f468f6e8f313343e78ef3eb5.jpg", - "metadata": { - "type": "Image", - "width": 750, - "height": 750 - }, - "content_type": "image/jpeg", - "size": 101405 - }, - "banner": { - "_id": "pU9PoZNYrr07WtLj7xARbxAulZEZ8DA4xKEhuuBlra", - "tag": "banners", - "filename": "letton-ditch-768x1024-panorama-1ccb4f1d628031af4b38c3709211dc6c-58bc9620e17d7.jpg", - "metadata": { - "type": "Image", - "width": 768, - "height": 384 - }, - "content_type": "image/jpeg", - "size": 116377 - }, - "tags": [ - "gamedev", - "godot", - "selfhosting", - "programming" - ], - "members": 32, - "activity": "no" - }, - { - "_id": "01HYVDCETD8056A9R3S37569H2", - "name": "Wireframe: A 3D modelling server", - "description": "A server for 3D modellers to get together and share knowledge, creations and also improve their skills\n\n3d modelling blender art", - "icon": { - "_id": "Jth2gxEHBaFwtTwZeK98K1bQlaPLLMK2a2f2M41Nzr", - "tag": "icons", - "filename": "icon.gif", - "metadata": { - "type": "Image", - "width": 256, - "height": 256 - }, - "content_type": "image/gif", - "size": 2001855 - }, - "banner": { - "_id": "OLaoi-y3Vb7GaCSfNsd0NbozEj0sQWN17ACvO-YDEg", - "tag": "banners", - "filename": "banner.png", - "metadata": { - "type": "Image", - "width": 1024, - "height": 512 - }, - "content_type": "image/png", - "size": 652817 - }, - "tags": [ - "3d", - "modelling", - "blender", - "art" - ], - "members": 9, - "activity": "no" - }, - { - "_id": "01HZ94FSKNRP4XWWZ4XQ0BHYQ2", - "name": "🎊 | Solix’s World", - "description": null, - "banner": { - "_id": "tMpKyE2cCFCvjpP7NGKAmf6LwMgFH5o5ZL3af5jTJ3", - "tag": "banners", - "filename": "officialbanner.png", - "metadata": { - "type": "Image", - "width": 2560, - "height": 1440 - }, - "content_type": "image/png", - "size": 1915119 - }, - "tags": [], - "members": 10, - "activity": "no" - }, - { - "_id": "01FNYD7FYZF1YA4QGEY9PHSK2S", - "name": "Voltage", - "description": "Voltage is a python API wrapper for the revolt API.\nHere you can chill, talk about Voltage and stuff *i guess*.\n\nThis server is also bridged with Eludris so you'll find a lot of shitposting, programming talk and dumb shit regularly :^)\n\nRepo: https://github.com/EnokiUN/voltage\n\nTags: bots programming anime shitposting amogus ", - "icon": { - "_id": "wx2pNtOIszoFshiQH_vcxtjBzKVq1YcIBRIvdVvE5t", - "tag": "icons", - "filename": "Untitled40_20220314234237.png", - "metadata": { - "type": "Image", - "width": 514, - "height": 512 - }, - "content_type": "image/png", - "size": 25926 - }, - "banner": { - "_id": "t5djH5K6ElpuM0n9uZVXUxkLFYj1QlsfMMiuuQlkSg", - "tag": "banners", - "filename": "20220324_194432.jpg", - "metadata": { - "type": "Image", - "width": 1280, - "height": 718 - }, - "content_type": "image/jpeg", - "size": 46484 - }, - "tags": [ - "bots", - "programming", - "anime", - "shitposting", - "amogus" - ], - "members": 612, - "activity": "no" - }, - { - "_id": "01GCSWMPKQ8HRKR4AVB4C850CV", - "name": "Privacy Lounge", - "description": "Engage with users in conversations about privacy, Linux, and Fediverse, while also having the freedom to discuss a wide range of other topics.", - "icon": { - "_id": "5cTVZ_8TLEFRaKsGakcSZvgHnZCmQfyLxAor5XM1ss", - "tag": "icons", - "filename": "PLC1_UAPIG_RevoltLogo2.png", - "metadata": { - "type": "Image", - "width": 1080, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 46285 - }, - "banner": { - "_id": "CpgHPi9Kcm-6JiVR6ISKCTB1SHOyKI1wEIWzdX6xAw", - "tag": "banners", - "filename": "PLC1_UAPIG_RevoltBanner2-1.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/jpeg", - "size": 73561 - }, - "tags": [ - "privacy", - "linux", - "fediverse" - ], - "members": 538, - "activity": "no" - }, - { - "_id": "01H1H8JQ2J2HGE6109MVBSW4KH", - "name": "Celeste Hearts", - "description": "Cute Celeste-themed pride emotes for all your identity needs. Feel free to suggest additional flags!", - "icon": { - "_id": "pQgrTgBY5RulGqPe_oJne5lgM2z9jytuyaJh6BGNsg", - "tag": "icons", - "filename": "image", - "metadata": { - "type": "Image", - "width": 72, - "height": 68 - }, - "content_type": "image/png", - "size": 1057 - }, - "banner": { - "_id": "Gfzyt0iWKJGMxxDrVBQASGHbycl660VURVn2rxjbHR", - "tag": "banners", - "filename": "Celeste Hearts Banner.png", - "metadata": { - "type": "Image", - "width": 3000, - "height": 1500 - }, - "content_type": "image/png", - "size": 435383 - }, - "tags": [], - "members": 164, - "activity": "no" - }, - { - "_id": "01FDA8EPHMNWGF26NRDGSGZ3ND", - "name": "NitroMojis", - "icon": { - "_id": "B0xQlGTPH8GMqOOFedd2Xu5EIryHm1mgkQ2wvX2XeJ", - "tag": "icons", - "filename": "a_0893876ce40862ad9d83c27f6145909a (1).png", - "metadata": { - "type": "Image", - "width": 128, - "height": 128 - }, - "content_type": "image/png", - "size": 21577 - }, - "banner": { - "_id": "EIKeaHLERSMb6ENUYbwqkNfW9qkxC20ut4gCGC3u7Y", - "tag": "banners", - "filename": "NitroMojis Banner.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 757960 - }, - "description": "NitroMojis is a server that aims to provide Revolt users with the finest emojis to use all across Revolt. Emoji, Emote and Emotes.", - "flags": 2, - "tags": [], - "members": 1309, - "activity": "no" - }, - { - "_id": "01GVKNE144E7Y219RNATVX72QX", - "name": "idk what should i call this", - "description": "idk join if you want", - "banner": { - "_id": "KZBmJEjbW0tOa-gkgsU25jvh86ZEGxI2nT-C_K73Dt", - "tag": "banners", - "filename": "srhtdjfy.jpg", - "metadata": { - "type": "Image", - "width": 400, - "height": 382 - }, - "content_type": "image/jpeg", - "size": 30062 - }, - "icon": { - "_id": "EI0mcQDjnNq4OmF-Ho4IDpvPcHFYq2aNKChF5-lgVY", - "tag": "icons", - "filename": "RANMYFAVORITE.jpg", - "metadata": { - "type": "Image", - "width": 222, - "height": 177 - }, - "content_type": "image/jpeg", - "size": 14140 - }, - "tags": [], - "members": 52, - "activity": "no" - }, - { - "_id": "01H2RDRXT4VN0HQ5M4QAM5XC92", - "name": "Polandia✨", - "icon": { - "_id": "vLCUIf6mNebE4SaY_n5qBAOWtCc6hJ2vNV4k2hc5Lg", - "tag": "icons", - "filename": "Polandia.jpg", - "metadata": { - "type": "Image", - "width": 400, - "height": 400 - }, - "content_type": "image/jpeg", - "size": 32989 - }, - "banner": { - "_id": "VDOKLXl205tQ29267z-jI098Tf_DRijgPErkNFpyXI", - "tag": "banners", - "filename": "polandiatemplate.jpg", - "metadata": { - "type": "Image", - "width": 2000, - "height": 1000 - }, - "content_type": "image/jpeg", - "size": 472010 - }, - "description": "Do you like geography, history or countryballs? Than join the first geography server ever made: This one!\n\n", - "tags": [], - "members": 22, - "activity": "no" - }, - { - "_id": "01HD41EJ793JPHYS3Z64BACKBM", - "name": "Division Server!tintindaisuki", - "icon": { - "_id": "pP6qtvoODmmeNQABCkaYiS_P5klasMO1ag5dARH50m", - "tag": "icons", - "filename": "IMG_0502.jpeg", - "metadata": { - "type": "Image", - "width": 225, - "height": 225 - }, - "content_type": "image/jpeg", - "size": 7193 - }, - "description": "ばか?\n\n\nchat japan ja division revolt chating ", - "banner": { - "_id": "FKnd9SQx21ZTwUZRiai6NvCZ9SqfZTf8iVW9drR67D", - "tag": "banners", - "filename": "IMG_0559.jpeg", - "metadata": { - "type": "Image", - "width": 460, - "height": 380 - }, - "content_type": "image/jpeg", - "size": 58393 - }, - "tags": [ - "chat", - "japan", - "ja", - "division", - "revolt", - "chating" - ], - "members": 89, - "activity": "no" - }, - { - "_id": "01H6BZ13N71P5MJG1JFFHHRJHC", - "name": "Astroid", - "icon": { - "_id": "_CSrGoqeQCqNPdmAOzSQ54dcHaWCWLY2KpTvVI0XKT", - "tag": "icons", - "filename": "Astroid Logo.png", - "metadata": { - "type": "Image", - "width": 927, - "height": 927 - }, - "content_type": "image/jpeg", - "size": 54410 - }, - "description": "Astroid is a easy to use multi-plattform bridge including Discord, Guilded and Revolt!\n", - "banner": { - "_id": "42E70bk5H3DNnOmkGKbYQrBxLTYeCb8uV8dxetQODC", - "tag": "banners", - "filename": "Astroid-banner.png", - "metadata": { - "type": "Image", - "width": 1920, - "height": 1080 - }, - "content_type": "image/png", - "size": 204815 - }, - "tags": [ - "bridge", - "discord", - "guilded", - "revolt" - ], - "members": 44, - "activity": "no" - }, - { - "_id": "01G2RNRDXXEZP3WEHQZEY4GE79", - "name": "Revolt C#", - "icon": { - "_id": "IdMMWfYfiLLTXduMxer2qbHg7ZKIAbAXV6j7EgdxdS", - "tag": "icons", - "filename": "c-img2.png", - "metadata": { - "type": "Image", - "width": 231, - "height": 231 - }, - "content_type": "image/png", - "size": 5370 - }, - "description": "Revolt c# development server!\n\n\nThis is for our library RevoltSharp which you can use to interact with the revolt api and create your own bots.", - "tags": [], - "members": 114, - "activity": "no" - }, - { - "_id": "01H14M0JCRWT52VW7BR1XH5Y5W", - "name": "Revolt Translators", - "description": "Official server for discussing, expanding and improving Revolt's translations. revolt translations translators internationalisation internationalization i18n ", - "flags": 1, - "icon": { - "_id": "N0Oi3so_bu-X_M0xsXENUEl5oQuw4AkpwJ7y07Thlz", - "tag": "icons", - "filename": "Group_3.png", - "metadata": { - "type": "Image", - "width": 266, - "height": 266 - }, - "content_type": "image/png", - "size": 6672 - }, - "tags": [ - "revolt", - "translations", - "translators", - "internationalisation", - "internationalization", - "i18n" - ], - "members": 487, - "activity": "no" - }, - { - "_id": "01J01ZZQ9V749SK2MW1VJM3BQJ", - "name": "Socialize, Hangout & Chat!", - "description": "Join our community of free thinkers in a Revolt server dedicated to the principles of **freedom of speech**, **privacy** and ~**anime**~ **open discussion**. Our server is a safe space for individuals from all walks of life to come together, share their thoughts, and engage in respectful conversations. \n\n**What to expect:**\n1. A relaxed and welcoming atmosphere, perfect for socializing and making new friends.\n\n2. A platform for open and honest discussion on various topics, from politics and social issues to hobbies and interests.", - "icon": { - "_id": "yjDkbFq6OpPJFIZC8FUtbZ5O6JImkXfn6QDcfjzO0D", - "tag": "icons", - "filename": "Untitled-tCfr5M9Pu-transformed.jpeg", - "metadata": { - "type": "Image", - "width": 2048, - "height": 2048 - }, - "content_type": "image/jpeg", - "size": 362881 - }, - "banner": { - "_id": "j-OacdPeNJSL2MsppElviruqp0lGfVuZ3dvfAVlITu", - "tag": "banners", - "filename": "Untitled2-transformed.jpeg", - "metadata": { - "type": "Image", - "width": 3072, - "height": 2048 - }, - "content_type": "image/jpeg", - "size": 561340 - }, - "tags": [], - "members": 13, - "activity": "no" - }, - { - "_id": "01GPB36KBEVXZK77YHSA6SM2CW", - "name": "Workbench Team", - "icon": { - "_id": "GRD3bK68MZwvUxn2tQdcItj9QRLTO4pp-dzytIOvdc", - "tag": "icons", - "filename": "a_ce4d2d1b08978e4443958ecbc4827245(1).gif", - "metadata": { - "type": "Image", - "width": 320, - "height": 320 - }, - "content_type": "image/gif", - "size": 2164048 - }, - "banner": { - "_id": "VUC03mQMU9P_O-yOwz5ACxK53Yj48Ik2Nr4DnmRAH4", - "tag": "banners", - "filename": "943c3dbc098523861d34e9d3c5e676b1.png", - "metadata": { - "type": "Image", - "width": 1024, - "height": 576 - }, - "content_type": "image/png", - "size": 448438 - }, - "description": "🇺🇸 We are the team of game content creators and servers\n\n🇷🇺 Мы команда создателей игрового контента и серверов", - "tags": [], - "members": 276, - "activity": "no" - }, - { - "_id": "01H2JG3X52790F328P7X20GCM1", - "name": "Revolt Car Club", - "icon": { - "_id": "UeW1vFIr_8P5taoKA9LR247dNT5RrvOT5FBd4DMOT9", - "tag": "icons", - "filename": "TE37.png", - "metadata": { - "type": "Image", - "width": 960, - "height": 960 - }, - "content_type": "image/png", - "size": 1223704 - }, - "banner": { - "_id": "5nTKigMUv-m5BMNGq8UK2pI7lFmHOi20vHwoa8VVgA", - "tag": "banners", - "filename": "0_Car drifting through a mountain road curve, lots o_esrgan-v1-x2plus.png", - "metadata": { - "type": "Image", - "width": 1792, - "height": 1024 - }, - "content_type": "image/png", - "size": 2666535 - }, - "description": "Hello! Do you like cars, or things that move on wheels? Yes? Join us and let's build a community of car lovers and alike together!", - "tags": [], - "members": 15, - "activity": "no" - }, - { - "_id": "01HYDZPQFE9S86PGV1T24DSGA7", - "name": "むめーproxy鯖!", - "description": "scratch民が多いと思うけど仲良くしていこう!", - "banner": { - "_id": "Q2yzKB71NNza6aTRCHSGxXFCbPvcsYahSmGNly04M_", - "tag": "banners", - "filename": "あ.jpeg", - "metadata": { - "type": "Image", - "width": 457, - "height": 340 - }, - "content_type": "image/jpeg", - "size": 24946 - }, - "icon": { - "_id": "mQsTdm521JI-L6S3nF9aJQ7v491HdoT8dY1BqOuLsN", - "tag": "icons", - "filename": "images.jpeg", - "metadata": { - "type": "Image", - "width": 225, - "height": 225 - }, - "content_type": "image/jpeg", - "size": 11700 - }, - "tags": [], - "members": 19, - "activity": "no" - }, - { - "_id": "01J17WDGM1R7Q3HWE0FTT1R28A", - "name": "Rainbow Advertising", - "description": "Advertise for free here! Part of the Rainbow Network across Discord & Guilded.\n\nadvertise | advertising | promo | promote | promotion | self promo", - "icon": { - "_id": "kXpvtI-QWU4Zt_Lm5r4J9Ur5r8SiEhAAX7wdj_YbaJ", - "tag": "icons", - "filename": "Screenshot 2024-06-25 at 10.28.51 AM.png", - "metadata": { - "type": "Image", - "width": 144, - "height": 150 - }, - "content_type": "image/png", - "size": 35512 - }, - "tags": [ - "advertise", - "advertising", - "promo", - "promote", - "promotion" - ], - "members": 8, - "activity": "no" - }, - { - "_id": "01HE70NW72PF4HGCYGB9M8EH37", - "name": "The Legend of Zelda", - "icon": { - "_id": "W4gKFeKWc1P1edq651ipXtC7UW-o-TWJ25f2-WbOhZ", - "tag": "icons", - "filename": "zonai-charge-materials-zelda-tears-of-the-kingdom-wiki-guide-200px.png", - "metadata": { - "type": "Image", - "width": 512, - "height": 512 - }, - "content_type": "image/png", - "size": 116934 - }, - "description": "Unofficial server for all things Legend Of Zelda related.\n\n\nzelda totk link nintendo gaming", - "banner": { - "_id": "ij84WA7LHN0fKwCPTryblBKE0imoy28ccJhMBRs25r", - "tag": "banners", - "filename": "Screenshot 2024-02-20 12.34.26.png", - "metadata": { - "type": "Image", - "width": 461, - "height": 153 - }, - "content_type": "image/png", - "size": 136520 - }, - "tags": [ - "zelda", - "totk", - "link", - "nintendo", - "gaming" - ], - "members": 36, - "activity": "no" - }, - { - "_id": "01HX2MP6ANR0R83BCG9V66XCDD", - "name": "🌐 Translator | Bot", - "icon": { - "_id": "7DrjzKyrSfBAoPWf-uMsfi16l7McmOEbpQs7KELT4N", - "tag": "icons", - "filename": "translator.png", - "metadata": { - "type": "Image", - "width": 500, - "height": 500 - }, - "content_type": "image/png", - "size": 29249 - }, - "banner": { - "_id": "CJgHaUXKCN2tpg26TNBiGqbynicfH_xbIqVisc6LLT", - "tag": "banners", - "filename": "translator_bannière_2.png.png", - "metadata": { - "type": "Image", - "width": 1200, - "height": 480 - }, - "content_type": "image/png", - "size": 31782 - }, - "description": "😍 1st Revolt translation bot !\n👀 100% free, no premium features, no limitations.\n⭐ Try it now !\n\nTranslator", - "tags": [ - "translation", - "bot", - "free", - "translator" - ], - "members": 15, - "activity": "no" - }, - { - "_id": "01J163G9TCNM63DHHHHF9PVT0G", - "name": "Amici In Game", - "icon": { - "_id": "1pLkEMaF17yi_0FGvIeDNVSPEty6xwIPlViHPY_d8K", - "tag": "icons", - "filename": "Logo BOT Minecraft.png", - "metadata": { - "type": "Image", - "width": 1024, - "height": 1024 - }, - "content_type": "image/png", - "size": 736160 - }, - "banner": { - "_id": "WGFQyTn3908BB8XADhK7arbk-6W7SKmzH1c0bX1vYH", - "tag": "banners", - "filename": "Sfondo Ritagliato.jpg", - "metadata": { - "type": "Image", - "width": 999, - "height": 499 - }, - "content_type": "image/jpeg", - "size": 104187 - }, - "description": "Server Ufficiale su Revolt della Community di Amici In Game\nSeguici in Live su Twitch.Tv/Amici_In_Game\n\nTwitch Live Gaming Chill Music", - "tags": [ - "twitch", - "live", - "gaming", - "chill", - "music" - ], - "members": 5, - "activity": "no" - } - ], - "popularTags": [ - "gaming", - "revolt", - "programming", - "fun", - "chill", - "art", - "linux", - "politics", - "chat", - "music" - ] - }, - "__N_SSP": true -} \ No newline at end of file diff --git a/test/data/discovery/themes.json b/test/data/discovery/themes.json deleted file mode 100644 index 12f1ada..0000000 --- a/test/data/discovery/themes.json +++ /dev/null @@ -1,2349 +0,0 @@ -{ - "pageProps": { - "themes": [ - { - "version": "1.0.1", - "slug": "amethyst", - "name": "Amethyst", - "creator": "Meow", - "description": "Purple... based on spinfish's amethyst betterdiscord theme.", - "tags": [ - "dark", - "purple" - ], - "variables": { - "accent": "#ff9100", - "background": "#230431", - "foreground": "#ffffff", - "block": "#1c0028", - "mention": "#310c44", - "message-box": "#2d0f3d", - "success": "#4dd75e", - "warning": "#FAA352", - "error": "#ed3f43", - "hover": "rgba(0, 0, 0, 0.2)", - "tooltip": "#000000", - "scrollbar-thumb": "#361741", - "scrollbar-track": "transparent", - "primary-background": "#230431", - "primary-header": "#150020", - "secondary-background": "#1c0028", - "secondary-foreground": "#d2d2d2", - "secondary-header": "#1c0028", - "tertiary-background": "#8b8b8b", - "tertiary-foreground": "#8b8b8b", - "status-online": "#3ba55d", - "status-away": "#faa81a", - "status-busy": "#ed4245", - "status-invisible": "#747f8d" - } - }, - { - "version": "0.0.1", - "slug": "amoled", - "name": "AMOLED", - "creator": "insert", - "description": "Pure black, perfect for mobile.", - "tags": [ - "oled", - "dark" - ], - "variables": { - "accent": "#FD6671", - "background": "#000000", - "foreground": "#FFFFFF", - "block": "#1D1D1D", - "message-box": "#000000", - "mention": "rgba(251, 255, 0, 0.06)", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#000000", - "primary-header": "#000000", - "secondary-background": "#000000", - "secondary-foreground": "#DDDDDD", - "secondary-header": "#1A1A1A", - "tertiary-background": "#000000", - "tertiary-foreground": "#AAAAAA", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "0.0.1", - "slug": "dark", - "name": "Ayu Dark", - "creator": "Zomatree", - "description": "A Theme using the Ayu colour pallete.", - "variables": { - "accent": "#e6b450", - "background": "#090e15", - "foreground": "#b3b1ad", - "block": "#0c1017", - "message-box": "#090e15", - "mention": "#0c1017", - "success": "#91b362", - "warning": "#FAA352", - "error": "#ff3333", - "hover": "#0c1017", - "scrollbar-thumb": "#e6b450", - "scrollbar-track": "transparent", - "primary-background": "#090e15", - "primary-header": "#090e15", - "secondary-background": "#090e15", - "secondary-foreground": "#4d5566", - "secondary-header": "#0c1017", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#848484", - "status-online": "#91b362", - "status-away": "#F39F00", - "status-busy": "#ff3333", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "tags": [ - "dark" - ] - }, - { - "version": "1.0.0", - "slug": "blobfox-theme", - "name": "Blobfox theme", - "creator": "Houl", - "description": "A dark theme with blobfox orange.", - "tags": [ - "dark", - "blobfox", - "orange" - ], - "variables": { - "accent": "#FF8702", - "background": "#101010", - "foreground": "#F6F6F6", - "block": "#202020", - "message-box": "#202020", - "mention": "rgba(255, 135, 2, 0.2)", - "success": "#57F287", - "warning": "#FE925C", - "error": "#ED4245", - "hover": "rgba(75, 75, 75, 0.1)", - "scrollbar-thumb": "#FF8702", - "scrollbar-track": "transparent", - "primary-background": "#101010", - "primary-header": "#151515", - "secondary-background": "#151515", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#181818", - "tertiary-background": "#505050", - "tertiary-foreground": "#848484", - "status-online": "#57F287", - "status-away": "#FE925C", - "status-busy": "#ED4245", - "status-streaming": "#AE45EB", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "1.0.0", - "slug": "blossom", - "name": "Blossom", - "creator": "Axorax", - "description": "A visually enchanting theme inspired by cherry blossoms.", - "tags": [ - "light", - "blossom", - "cherry" - ], - "variables": { - "accent": "#FF85A0", - "background": "#F6F0F7", - "foreground": "#2C2C2C", - "block": "#c7a9c7", - "message-box": "#F1EAF2", - "mention": "#FFC2D6", - "success": "#70D84C", - "warning": "#FFC93C", - "error": "#FF504E", - "hover": "#ECE7EC", - "scrollbar-thumb": "#D8D1D8", - "scrollbar-track": "#EDE8ED", - "primary-background": "#F1EAF2", - "primary-header": "#F1EAF2", - "secondary-background": "#EDE8ED", - "secondary-foreground": "#5D5D5D", - "secondary-header": "#F4EFF4", - "tertiary-background": "#D4CDD4", - "tertiary-foreground": "#787878", - "status-online": "#70D84C", - "status-away": "#FFC93C", - "status-busy": "#FF504E", - "status-streaming": "#CFA0D0", - "status-invisible": "#BDB7BD" - } - }, - { - "version": "0.0.1", - "slug": "blue-dark", - "name": "Blue Dark", - "creator": "SP46", - "description": "A modern and unique style for Revolt.", - "variables": { - "accent": "#e91e63", - "background": "#101823", - "foreground": "#f6f6f6", - "block": "#262f3d", - "message-box": "#212a38", - "mention": "rgba(251, 255, 0, 0.06)", - "success": "#31bf7e", - "warning": "#fae352", - "error": "#e91e63", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#ba184f", - "scrollbar-track": "transparent", - "primary-background": "#151e2b", - "primary-header": "#1a2433", - "secondary-background": "#1a2433", - "secondary-foreground": "#c8c8c8", - "secondary-header": "#212a38", - "tertiary-background": "#212a38", - "tertiary-foreground": "#848484", - "status-online": "#31bf7e", - "status-away": "#fae352", - "status-busy": "#e91e63", - "status-streaming": "#977eff", - "status-invisible": "#a5a5a5" - }, - "tags": [ - "dark" - ] - }, - { - "version": "0.0.1", - "slug": "bree", - "name": "bree's theme", - "creator": "bree", - "description": "this is bree's purple dark theme.", - "variables": { - "accent": "#FD6671", - "background": "#292937", - "foreground": "#F6F6F6", - "block": "rgb(25 25 30)", - "message-box": "rgb(55 55 66)", - "mention": "rgba(251, 255, 0, 0.06)", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgb(0 0 0 / 0.1)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "rgb(33 33 44)", - "primary-header": "rgb(44 44 55)", - "secondary-background": "rgb(28 28 36)", - "secondary-foreground": "#C8C8C8", - "secondary-header": "rgb(37 37 48)", - "tertiary-background": "rgb(77 77 88)", - "tertiary-foreground": "rgb(133 133 144)", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "/* todo: use a consistently named, stable css class */\n/* The chat input area */\n.tinfKpom71H-wBY-85CJ7 {\n background-color: var(--hover);\n}\n", - "tags": [ - "dark" - ] - }, - { - "version": "1.0.0", - "slug": "catppuccin-frappe", - "name": "Catppuccin Frappé", - "creator": "Catppuccin Org", - "description": "Soothing pastel theme for Revolt.", - "tags": [ - "pastel", - "soothing", - "dark" - ], - "variables": { - "accent": "#f2d5cf", - "background": "#303446", - "foreground": "#babbf1", - "block": "#232634", - "message-box": "#232634", - "mention": "rgba(30, 29, 47, .1)", - "success": "#a6d189", - "warning": "#ef9f76", - "tooltip": "#000000", - "error": "#e78284", - "hover": "rgba(35, 38, 52, 0.75)", - "scrollbar-thumb": "#babbf1", - "scrollbar-track": "transparent", - "primary-background": "#303446", - "primary-header": "#292c3c", - "secondary-background": "#292c3c", - "secondary-foreground": "#737994", - "secondary-header": "#292c3c", - "tertiary-background": "#232634", - "tertiary-foreground": "#626880", - "status-online": "#a6d189", - "status-away": "#eebebe", - "status-busy": "#ea999c", - "status-streaming": "#ca9ee6", - "status-invisible": "#626880" - }, - "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "catppuccin-latte", - "name": "Catppuccin Latte", - "creator": "Catppuccin Org", - "description": "Soothing pastel theme for Revolt.", - "tags": [ - "pastel", - "soothing", - "light" - ], - "variables": { - "accent": "#dc8a78", - "background": "#eff1f5", - "foreground": "#7287fd", - "block": "#dce0e8", - "message-box": "#dce0e8", - "mention": "rgba(30, 29, 47, .1)", - "success": "#40a02b", - "warning": "#fe640b", - "tooltip": "#000000", - "error": "#d20f39", - "hover": "rgba(220, 224, 232, 0.75)", - "scrollbar-thumb": "#7287fd", - "scrollbar-track": "transparent", - "primary-background": "#eff1f5", - "primary-header": "#e6e9ef", - "secondary-background": "#e6e9ef", - "secondary-foreground": "#9ca0b0", - "secondary-header": "#e6e9ef", - "tertiary-background": "#dce0e8", - "tertiary-foreground": "#acb0be", - "status-online": "#40a02b", - "status-away": "#dd7878", - "status-busy": "#e64553", - "status-streaming": "#8839ef", - "status-invisible": "#acb0be" - }, - "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "catppuccin-macchiato", - "name": "Catppuccin Macchiato", - "creator": "Catppuccin Org", - "description": "Soothing pastel theme for Revolt.", - "tags": [ - "pastel", - "soothing", - "dark" - ], - "variables": { - "accent": "#f4dbd6", - "background": "#24273a", - "foreground": "#b7bdf8", - "block": "#181926", - "message-box": "#181926", - "mention": "rgba(30, 29, 47, .1)", - "success": "#a6da95", - "warning": "#f5a97f", - "tooltip": "#000000", - "error": "#ed8796", - "hover": "rgba(24, 25, 38, 0.75)", - "scrollbar-thumb": "#b7bdf8", - "scrollbar-track": "transparent", - "primary-background": "#24273a", - "primary-header": "#1e2030", - "secondary-background": "#1e2030", - "secondary-foreground": "#6e738d", - "secondary-header": "#1e2030", - "tertiary-background": "#181926", - "tertiary-foreground": "#5b6078", - "status-online": "#a6da95", - "status-away": "#f0c6c6", - "status-busy": "#ee99a0", - "status-streaming": "#c6a0f6", - "status-invisible": "#5b6078" - }, - "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "catppuccin-mocha", - "name": "Catppuccin Mocha", - "creator": "Catppuccin Org", - "description": "Soothing pastel theme for Revolt.", - "tags": [ - "pastel", - "soothing", - "dark" - ], - "variables": { - "accent": "#f5e0dc", - "background": "#1e1e2e", - "foreground": "#b4befe", - "block": "#11111b", - "message-box": "#11111b", - "mention": "rgba(30, 29, 47, .1)", - "success": "#a6e3a1", - "warning": "#fab387", - "tooltip": "#000000", - "error": "#f38ba8", - "hover": "rgba(17, 17, 27, 0.75)", - "scrollbar-thumb": "#b4befe", - "scrollbar-track": "transparent", - "primary-background": "#1e1e2e", - "primary-header": "#181825", - "secondary-background": "#181825", - "secondary-foreground": "#6c7086", - "secondary-header": "#181825", - "tertiary-background": "#11111b", - "tertiary-foreground": "#585b70", - "status-online": "#a6e3a1", - "status-away": "#f2cdcd", - "status-busy": "#eba0ac", - "status-streaming": "#cba6f7", - "status-invisible": "#585b70" - }, - "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" - }, - { - "version": "0.0.1", - "slug": "cats", - "name": "Cats", - "creator": "@LazyCat", - "description": "There are cats on bg and more blur.", - "tags": [ - "cats", - "blur", - "dark" - ], - "variables": { - "accent": "#FD6671", - "background": "transparent", - "foreground": "#FFF", - "block": "#2D2D2D69", - "message-box": "#000000aa", - "mention": "#ffff0015", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgb(0, 0, 0, 0.1)", - "tooltip": "#000000", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#000000a0", - "primary-header": "#00000069", - "secondary-background": "#00000069", - "secondary-foreground": "#C8C8C869", - "secondary-header": "#00000069", - "tertiary-background": "#00000069", - "tertiary-foreground": "#ffffff69", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "body { \nbackground-image: url('https://autumn.revolt.chat/attachments/PGJcmqTljyBeHQAsqq8aaJjJ6bQBZbrfCfWkoIjhgC/Hauskatze_langhaar.jpg');\nbackground-size: cover;\nbackground-position: center;\n}\n\n.EmbedInvite__EmbedInviteBase-sc-154n8c6-0.bTyHZC,\n._attachment_197qa_1._file_197qa_25,\n.MessageBox__Base-sc-jul4fa-0.jBEnry,\n._embed_1912h_1._website_1912h_12,\n.Theme__ThemeInfo-sc-12nukt1-0.jPrjw,\n.Sidebar__Base-sc-1uh6eub-0.cRCSBm,\n._content_sso5v_77, \n._details_605sm_31,\n._actions_ag1so_25 > *,\n.Modal__ModalBase-sc-1ppg3sd-0.bDDYwS,\n._settings_t7t23_33,\n._actions_iu4oa_1,\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh,\n.VoiceHeader__VoiceBase-sc-mvbohh-0.coVeIl,\n.Tip__TipBase-sc-1b2fe6b-1.bVXzUI,\n.ComboBox-sc-djv074-0.ddkErI,\n.FilePreview__Container-sc-znkm6h-0.ewmXHm,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-1qkt4w1-0.kyMBmJ,\n.Titlebar__TitlebarBase-sc-1ykozpe-0.hwmymO,\n.BottomNavigation__Base-sc-15obpw5-0.fmxapA,\n._invite_605sm_1, .Header-sc-39hpd7-0.fUpUzg,\n.ErrorBoundary__CrashContainer-sc-1s8h6qo-0.cHHQBt,\n._pending_sd6wo_95,\n._attachment_197qa_1._audio_197qa_13,\n.RequiresOnline__Base-sc-qthzj2-0.hwlBoN,\n._attachment_197qa_1._text_197qa_33,\n.RevoltApp__StatusBar-sc-1613ew5-1.iOZZrd,\n.Button-sc-1gxkzq3-0.iWNKPN,\n.Tile__Icon-sc-1szgf75-3.xlMfH,\nimg._image_197qa_75,\n.Base-sc-1d91xkm-0.kVCswW,\n._embed_dxa3n_1._website_dxa3n_12,\n.sc-iBPTik.bnZuoo,\n.Details-sc-3j94yd-0.jKeKvP,\n.InviteBot__BotInfo-sc-eg29o6-0.fNvYYN,\n.ComboBox-sc-f94r78-0.jfwTbF,\n\ninput, textarea, button, .entry, blockquote, .spoiler,\npre.code, .context-menu, audio, table, .mobile, pre\n{\n backdrop-filter: blur(25px) !important;\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry > * ,\n.MessageBox__Base-sc-jul4fa-0.jBEnry, \n.ServerListSidebar__ServerEntry-sc-10bk066-2.jFgSXl > span > svg > path\n{\n background-color: transparent !important;\n fill: #000000 !important;\n}\n\n.InviteBot__BotInfo-sc-eg29o6-0.fNvYYN {\n border-radius: 10px;\n}\n\noption, audio {\n background-color: #000000;\n color: #fff;\n}\n\noption:hover {\n background-color: var(--accent);\n}\n\nblockquote < pre.code {\n backdrop-filter: none;\n}\n\nblockquote > blockquote {\n backdrop-filter: none;\n backgound-color: #000000 !important;\n}\n\n*::selection {\n background-color: var(--accent);\n color: #fff;\n}\n\n.RevoltApp__StatusBar-sc-1613ew5-1.iOZZrd {\n color: var(--accent) !important;\n}\n\n.context-menu {\n transition: all 0.3s;\n background-color: #000000a0 !important;\n}\n\n.context-menu > *:hover {\n color: var(--accent);\n background-color: #00000015 !important;\n backdrop-filter: none;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Button-sc-1gxkzq3-0.iWNKPN, \ntable, .Details-sc-1qkt4w1-0.kyMBmJ, time\n{\n background-color: #00000069;\n}\n\n.Titlebar__TitlebarBase-sc-1ykozpe-0.hwmymO {\n background-color: #000000f0 !important;\n}\n\ntextarea, time {\n background-color: transparent !important;\n}\n\n::root {\n --sidebar-active: #000000;\n}\n\nimg._image_197qa_75 {\n background-color: #00000069 !important;\n}\n\n._container_197qa_64 {\n min-width: 20%;\n}\n\n.spoiler > img {\n display: none;\n}\n\n.Modal__ModalBase-sc-1ppg3sd-0.bDDYwS {\n background: rgba(0, 0, 0, 0.5);\n}\n" - }, - { - "version": "1.0.0", - "slug": "classic-grey", - "name": "Classic Grey", - "creator": "Sandwich247", - "description": "A classic grey theme with orange accents.", - "tags": [ - "light", - "grey", - "orange" - ], - "variables": { - "accent": "#F39F00", - "background": "#787878", - "foreground": "#ffffff", - "block": "#2e2e2e", - "message-box": "#3d3d3d", - "mention": "#637eb6", - "success": "#669a4d", - "warning": "#F39F00", - "tooltip": "#000000", - "error": "#420000", - "hover": "#7d7d7d", - "scrollbar-thumb": "#F39F00", - "scrollbar-track": "#3d3d3d", - "primary-background": "#707070", - "primary-header": "#3d3d3d", - "secondary-background": "#5e5e5e", - "secondary-foreground": "#d9d9d9", - "secondary-header": "#4f4f4f", - "tertiary-background": "#4f4f4f", - "tertiary-foreground": "#cccccc", - "status-online": "#669a4d", - "status-away": "#F39F00", - "status-focus": "#4799F0", - "status-busy": "ff0000", - "status-streaming": "#977EFF", - "status-invisible": "#383838" - } - }, - { - "version": "1.0.0", - "slug": "classic-xp", - "name": "Classic XP", - "creator": "Neptuner", - "description": "Throwback theme to the Windows XP era", - "tags": [ - "windows", - "windows-xp", - "xp", - "throwback", - "light" - ], - "variables": { - "accent": "#0855dd", - "background": "#ece9d8", - "foreground": "#000000", - "block": "#ece9d8", - "message-box": "#e2deca", - "mention": "rgba(252,207,3, 0.3)", - "success": "#81C046", - "warning": "#FAA352", - "error": "#DE482B", - "hover": "rgba(0, 0, 0, 0.2)", - "scrollbar-thumb": "#1255be", - "scrollbar-track": "transparent", - "primary-background": "#e2deca", - "primary-header": "#e2deca", - "secondary-background": "#ece9d8", - "secondary-foreground": "#1f1f1f", - "secondary-header": "#e2deca", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#3a3a3a", - "status-online": "#81C046", - "status-away": "#F39F00", - "status-busy": "#DE482B", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "0.0.1", - "slug": "comfy-blue", - "name": "Comfy Blue", - "creator": "Qky", - "description": "Soft and cute looking light blue theme.", - "tags": [ - "blue", - "light", - "pastel" - ], - "variables": { - "accent": "#165EC9", - "background": "#FFFFFF", - "foreground": "#071D40", - "block": "rgb(43, 118, 231, 0.9)", - "message-box": "rgba(89, 148, 236, 0.7)", - "mention": "rgba(236, 89, 221, 0.3)", - "success": "#2BE79C", - "warning": "#E79C2B", - "tooltip": "#5994EC", - "error": "#EC6859", - "hover": "#c8dbf9", - "scrollbar-thumb": "rgba(177, 89, 236, 0.6)", - "scrollbar-track": "transparent", - "primary-background": "rgba(181, 207, 246, 0.31)", - "primary-header": "#6296E3", - "secondary-background": "#87B2F1", - "secondary-foreground": "#0C336E", - "secondary-header": "#6C98DA", - "tertiary-background": "#5994EC", - "tertiary-foreground": "#11489C", - "status-online": "#59EC68", - "status-away": "#ece75b", - "status-busy": "#EC6859", - "status-streaming": "#9C2BE7", - "status-invisible": "#747f8d", - "border-color": "#d0d7de" - } - }, - { - "version": "1.0.0", - "slug": "dark", - "name": "Dark Mode", - "creator": "Revolt", - "description": "This is the default dark theme for Revolt.", - "tags": [ - "revolt", - "dark" - ], - "variables": { - "accent": "#FD6671", - "background": "#191919", - "foreground": "#F6F6F6", - "block": "#2D2D2D", - "message-box": "#363636", - "mention": "rgba(251, 255, 0, 0.06)", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#242424", - "primary-header": "#363636", - "secondary-background": "#1E1E1E", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#2D2D2D", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#848484", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "v1.0.0", - "name": "Darkmode Pink", - "slug": "darkmode-pink", - "description": "A pleasant dark theme with pink highlights.", - "creator": "maloriemeows", - "variables": { - "accent": "#e8a6aa", - "background": "#101010", - "foreground": "#faebeb", - "block": "#2D2D2D", - "message-box": "#282828", - "mention": "#564848", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "tooltip": "#000000", - "scrollbar-track": "transparent", - "scrollbar-thumb": "#ffb3b8", - "primary-header": "#665c5c", - "primary-background": "#0d0d0d", - "secondary-header": "#2D2D2D", - "secondary-background": "#131313", - "secondary-foreground": "#ccadad", - "tertiary-background": "#323232", - "tertiary-foreground": "#848484", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-focus": "#4799F0", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "tags": [ - "dark" - ] - }, - { - "version": "0.0.1", - "slug": "darkmode-purple", - "name": "Darkmode Purple", - "creator": "gurblygirl", - "description": "A snazzy, purple, and black theme", - "tags": [ - "purple", - "dark", - "darkmode" - ], - "variables": { - "accent": "#ad90a9", - "background": "#101010", - "foreground": "#c8c8c8", - "block": "#1e1e1e", - "message-box": "#0f0f0f", - "mention": "#171717", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "tooltip": "#000000", - "scrollbar-track": "transparent", - "scrollbar-thumb": "#191919", - "primary-header": "#1d1b1d", - "primary-background": "#0a0a0a", - "secondary-header": "#1e1e1e", - "secondary-background": "#0d0d0d", - "secondary-foreground": "#998593", - "tertiary-background": "#262224", - "tertiary-foreground": "#969696", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-focus": "#4799F0", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "1.0.0", - "slug": "deepocean", - "name": "Deep Ocean", - "creator": "hofq", - "description": "Deep oceanic blue inspired by Hyperocean", - "tags": [ - "ocean", - "space", - "blue", - "dark" - ], - "variables": { - "accent": "#00b0ff", - "background": "#0f111a", - "foreground": "#FFFFFF", - "block": "rgba(144, 164, 255, 0.1)", - "message-box": "#1c2031", - "mention": "rgba(242, 242, 122, 0.3)", - "success": "#14b37d", - "warning": "#f2f27a", - "error": "#ff5f56", - "hover": "rgba(144, 164, 255, 0.03)", - "scrollbar-thumb": "#ff5f56", - "scrollbar-track": "transparent", - "primary-background": "#0b0c13", - "primary-header": "#0f111a", - "secondary-background": "#0f111a", - "secondary-foreground": "#bcc6d6", - "secondary-header": "#1c2031", - "tertiary-background": "#1c2031", - "tertiary-foreground": "#9ba3b0", - "status-online": "#14b37d", - "status-away": "#f2f27a", - "status-busy": "#ff5f56", - "status-streaming": "#703faf", - "status-invisible": "#8f93a2" - } - }, - { - "version": "1.0.0", - "slug": "discord", - "name": "Discord", - "creator": "Axorax", - "description": "Theme based on Discord dark mode.", - "tags": [ - "dark", - "discord" - ], - "variables": { - "accent": "#5865F2", - "background": "#1E1F22", - "foreground": "#fff", - "block": "#2B2D31", - "message-box": "#313338", - "mention": "#444037", - "success": "#23A55A", - "warning": "#F0B132", - "error": "#DA373C", - "hover": "#2E3035", - "scrollbar-thumb": "#1A1B1E", - "scrollbar-track": "#2B2D31", - "primary-background": "#313338", - "primary-header": "#313338", - "secondary-background": "#2B2D31", - "secondary-foreground": "#dbdee1", - "secondary-header": "#3F4147", - "tertiary-background": "#4f4f4f", - "tertiary-foreground": "#8e8f90", - "status-online": "#23A55A", - "status-away": "#F0B232", - "status-busy": "#F23F43", - "status-streaming": "#593695", - "status-invisible": "#80848E" - } - }, - { - "version": "0.0.3", - "slug": "discord-colors", - "name": "Discord Colors", - "creator": "Eleven", - "description": "Discord colors for Revolt.", - "tags": [ - "discord", - "blurple", - "dark" - ], - "variables": { - "accent": "#5865F2", - "background": "#202225", - "foreground": "#ffffff", - "block": "#202225", - "message-box": "#40444b", - "mention": "#4b443b", - "success": "#57F287", - "warning": "#FEE75C", - "error": "#ED4245", - "hover": "rgba(0, 0, 0, 0.1)", - "sidebar-active": "#2f3136", - "scrollbar-thumb": "#202225", - "scrollbar-track": "transparent", - "primary-background": "#36393f", - "primary-header": "#2f3136", - "secondary-background": "#2f3136", - "secondary-foreground": "#b9bbbe", - "secondary-header": "#2a2c31", - "tertiary-background": "#202225", - "tertiary-foreground": "#b9bbbe", - "status-online": "#3ba55d", - "status-away": "#faa81a", - "status-busy": "#ed4245", - "status-streaming": "#593695", - "status-invisible": "#747f8d" - }, - "css": ":root {\n --sidebar-active: #2f3136;\n}\n\na,\na:hover {\n color: #13abe8;\n}\n\n/* Apply margin to server scroller */\n[class*=\"SidebarBase\"]:first-child > [class^=\"Base-sc\"] {\n margin-bottom: 10px;\n margin-left: 10px;\n margin-right: 10px;\n}\n\n[data-test-id^=\"virtuoso-item-list\"]{\n\tmargin-top: 10px !important;\n} \n\n[class*=\"SidebarBase\"]\n > [class^=\"Base-sc\"]\n > [data-test-id*=\"virtuoso-scroller\"] {\n width: 70px;\n} \n\n[class*=\"MessageBox__Base\"] {\n margin-left: 20px;\n margin-right: 20px;\n margin-bottom: 26px;\n border-radius: 10px;\n}\n\n/* Padding at the end of the chat. */\n[class*=\"MessageArea__Area\"] > div {\n padding-bottom: 0px;\n}\n\n[class*=\"RevoltApp__Routes\"] > [class*=\"Header\"] {\n background: #36393f;\n box-shadow: 1px 1px 1px #27292e;\n border-bottom: solid #363636 1px;\n}\n\n[class^=\"MessageBase__MessageInfo\"].csvICB {\n width: 90px;\n}\n\n[class*=\"MessageBase__MessageInfo\"] > [class*=\"avatar\"] {\n height: 44px !important;\n width: 44px;\n}\n\n[class*=\"MessageArea__Area\"] {\n --scrollbar-track: #2e3338;\n --scrollbar-thickness: 10px;\n}\n\n::-webkit-scrollbar-thumb {\n display: inline-block;\n background: #202225;\n border-radius: 15px;\n\n background-clip: border-box !important;\n height: 5px !important;\n background-size: 5px 5px !important;\n background-repeat: no-repeat;\n\n max-height: 5px !important;\n}\n\n::-webkit-scrollbar-track {\n border-radius: 30px;\n}\n\n:root {\n --foreground: #d3d3d3;\n}\n\n._item_1avxi_1[data-alert=\"true\"] {\n color: #ffffff !important;\n}\n\n._item_1avxi_1[data-active=\"true\"] {\n color: #ffffff !important;\n}\n\n:root {\n --hover: #3c3f45;\n}\n\n@media (hover: hover) {\n [class*=\"ServerSidebar__ServerList\"]:not(:hover) {\n overflow: hidden;\n }\n\n [data-test-id*=\"virtuoso-scroller\"]:not(:hover) {\n overflow-y: hidden !important;\n overflow: hidden;\n }\n}\n\n[class*=\"FilePreview__Container\"] {\n margin: 10px;\n}\n\n[class*=\"MessageBox__Blocked\"] {\n margin-left: 20px;\n}\n\n[class*=\"MessageBox__Action\"] {\n margin-right: 10px;\n}\n\n[class*=\"MessageArea__Area\"] > div > [class*=\"Base-sc\"]:has(> time) {\n border-top: solid 1px #42464d;\n justify-content: center;\n}\n\n[class*=\"MessageArea__Area\"] > div > [class*=\"Base-sc\"]:has(> time) > time {\n color: #b9bbbe;\n}\n\n[class*=\"MessageBase\"]:hover {\n background: #32353b;\n}\n\n[class*=\"ServerSidebar__ServerBase\"] {\n margin: 0 !important;\n}\n\nblockquote {\n background: none !important;\n border-radius: 2px !important;\n border-inline-start-color: #4f545c !important;\n}\n\n@media (hover: none), (pointer: coarse) {\n [class^=\"MessageBase__MessageInfo\"].csvICB { \n width: 60px;\n margin-right: 5px;\n }\n\n [class*=\"MessageBox__Action\"] {\n margin-right: 0px;\n }\n\n textarea[class*=\"TextArea\"] {\n width: 115%;\n }\n\n /* New observation, above and this can be deleted probably */\n textarea[class*=\"TextArea\"] {\n width: 100%;\n }\n}\n\n[class*=\"MessageArea__Area\"] {\n margin-bottom: -40px;\n}\n\n[class*=\"MessageArea__Area\"] > div {\n padding-bottom: 60px;\n}\n\n/* Messages scrollbar overflow fix */\n::-webkit-scrollbar-track {\n margin-bottom: 65px;\n margin-top: 53px;\n}\n\n::-webkit-scrollbar-thumb {\n min-height: 0px !important;\n}\n\na,\na:link,\na:visited,\na:hover {\n color: #15afe3;\n}\n\n[class^=\"SidebarBase\"] [class^=\"ParentBase-sc\"] {\n height: 48px;\n width: 48px;\n}\n\n[class^=\"SwooshWrapper\"] {\n top: -58px;\n left: -6px;\n}\n\n[class^=\"SwooshWrapper\"] > svg {\n width: 66px;\n height: 163px;\n}\n\n[class^=\"MessageBase__MessageInfo\"] {\n max-height: 38px;\n}\n\n[class^=\"MessageBase-sc\"] {\n padding: 0.225rem;\n}\n\n[class^=\"MessageBase__MessageContent-sc\"] > [class^=\"detail\"] {\n margin-bottom: 5px;\n}\n\n@media (hover: none), (pointer: coarse) {\n [class^=\"MessageArea__Area-sc\"]::-webkit-scrollbar {\n width: 0px;\n }\n}\n\n[class^=\"Search__SearchBase-sc\"] > input[class^=\"InputBox-sc\"] {\n background: #24262a;\n}\n\n\n@media (pointer: coarse) {\n [class^=\"ServerSidebar__ServerList-sc\"] [class^=\"_item\"] {\n height: 46px;\n }\n}\n\n@media (pointer: coarse) {\n [class^=\"MessageEditor__EditorBase-sc\"] {\n position: relative;\n left: -6%;\n }\n}\n\n@media (pointer: coarse) {\n [class^=\"MessageBox__FloatingLayer-sc\"] > [class^=\"Column-sc\"] {\n z-index: 6;\n }\n\n [class^=\"Header-sc\"] {\n z-index: 1;\n }\n}\n\n[class^=\"MessageBox__FileAction-sc\"] > [class^=\"IconButton-sc\"] {\n width: 56px;\n justify-content: flex-end;\n padding-right: 10px;\n}\n[class^=\"MessageBox__FileAction-sc\"] {\n padding-left: 4px;\n}\n\n\n/* Make scrollbar alike Discord for Desktop*/\n@media (pointer: fine) {\n\t[class^=\"Channel__ChannelContent-sc\"]{\n\t\tmargin-right: 5px;\n\t }\n\t[class^=\"MessageArea__Area-sc\"]{\n\t\tpadding-right: 4px;\n\t}\n}\n\n[class^=\"MessageBase__MessageContent\"] > [class^=\"_embed\"] {\n\tmargin: 0.3em 0;\n}\n\n \n/* Disable highlights on hover of server channels */\n[class^=\"ServerSidebar__ServerList-sc\"] [class^=\"_item_\"]:hover{\n\tcolor: var(--tertiary-foreground);\n} \n\n/* Animations for Server List & Chat Containers */\n\n@keyframes scaleanimation{\n 0% { transform: scale(0.9); } \n 100% { transform: scale(1.0); } \n} \n\n[class^=ParentBase-sc]:active, [class^=SwooshWrapper-sc] { \n animation: scaleanimation 2s forwards;\n} \n\n@keyframes customfadein{\n 0% { opacity: 0; } \n 100% { opacity: 1; } \n} \n\n[class^=SwooshWrapper-sc], [class^=Channel__ChannelContent-sc] > * {\n animation: customfadein 1.0s forwards; \n} \n\n/* Quick fix for the emoji picker due to recent changes in Revolt's emoji picker behavior. */\n[data-test-id=\"virtuoso-item-list\"] > [class^=RowContainer-sc] {\n flex-wrap: nowrap;\n}\n" - }, - { - "version": "0.1.0", - "slug": "discord-colours-layout", - "name": "Discord Colours & Layout", - "creator": "Derpius", - "description": "Original Discord colours as well as some custom CSS to replicate the feel", - "tags": [ - "discord", - "blurple", - "dark" - ], - "commit": "70bda88", - "variables": { - "accent": "#7289DA", - "background": "#202225", - "foreground": "#dcddde", - "block": "#2f3136", - "message-box": "#34363c", - "mention": "hsla(38,95.7%,54.1%,0.1)", - "success": "hsl(139,calc(47.3%),43.9%);", - "warning": "#FAA352", - "error": "hsl(359,66.7%,54.1%);", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#202225", - "scrollbar-track": "#2f3136", - "primary-background": "#36393f", - "primary-header": "#2d2f34", - "secondary-background": "#2f3136", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#2f3136", - "tertiary-background": "#4f545c", - "tertiary-foreground": "#a3a6aa", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "/*\n\tFonts\n*/\n:root {\n\t--text-size: 14px;\n}\nbody {\n\tfont-weight: 400;\n}\n[class^=\"_item_\"] > div[class^=\"_name_\"], [class^=\"Category-sc-\"], [class^=\"MessageReply__ReplyBase\"] .user {\n\tfont-weight: 500;\n}\n/* Revite actually uses important on the author, so we need to do a higher specificity important */\nspan.detail span.author {\n\tfont-weight: 500 !important;\n}\n\n/*\n\tLayout\n*/\n[class^=\"_settings_\"] {\n\tposition: relative !important;\n}\n\n[class^=\"MessageBase__MessageInfo\"] [class^=\"IconBase\"] {\n\twidth: 40px;\n\theight: 40px;\n}\n\n[class^=\"MessageBase\"], .detail {\n\tmin-height: 1.375em;\n\tpadding: 0.125em;\n}\n[class^=\"MessageBase__DetailBase\"],\n[class^=\"MessageBase\"] > time,\n[class^=\"MessageBase\"] > .edited {\n\tmin-height: 1em;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n}\n\n[class^=\"UserShort__BotBadge-sc-\"] {\n\theight: 1.3em;\n}\n\npre {\n\tpadding: 0.5em !important;\n\tmargin: 0;\n}\n\n/*\n\tRecolours\n*/\n::selection {\n\tbackground-color: var(--accent);\n\tcolor: var(--foreground);\n}\n\n[class^=\"MessageReply__ReplyBase\"]::before {\n\tborder-inline-start: 2px solid var(--tertiary-background);\n border-top: 2px solid var(--tertiary-background);\n}\n\npre {\n\tborder: 1px solid var(--background);\n}\n\n/*\n\tCustom Scrollbar\n*/\n:not(\n\t[data-test-id=\"virtuoso-scroller\"],\n\t[class^=\"Groups-sc\"],\n\t[class*=\"Sidebar\"]\n)::-webkit-scrollbar {\n\twidth: 16px;\n}\n\n::-webkit-scrollbar-track, ::-webkit-scrollbar-thumb {\n\tborder-radius: 8px;\n\tborder: solid 4px transparent !important;\n\tbackground-clip: padding-box;\n}\n\n::-webkit-scrollbar-thumb {\n\tmin-height: 40px;\n}\n\n::-webkit-scrollbar-track {\n\tmargin-bottom: 4px;\n\tmargin-top: 4px;\n}\n\n[data-scroll-offset]::-webkit-scrollbar-track {\n\tmargin-top: calc(var(--header-height) + 4px);\n}\n" - }, - { - "version": "0.0.1", - "slug": "dracula", - "name": "Dracula", - "creator": "Wild", - "description": "Dracula theme for Revolt. https://draculatheme.com/", - "variables": { - "accent": "#ff79c6", - "background": "#282a36", - "foreground": "#f8f8f2", - "block": "#1C1D26", - "message-box": "#343646", - "mention": "rgb(139, 233, 253, 0.05)", - "success": "#50fa7b", - "warning": "#ffb86c", - "error": "#ff5555", - "hover": "#282a36", - "scrollbar-thumb": "#bd93f9", - "scrollbar-track": "transparent", - "primary-background": "#282a36", - "primary-header": "#343646", - "secondary-background": "#1C1D26", - "secondary-foreground": "#f8f8f2", - "secondary-header": "#343646", - "tertiary-background": "#282a36", - "tertiary-foreground": "#AEAEA9", - "status-online": "#50fa7b", - "status-away": "#ffb86c", - "status-busy": "#ff5555", - "status-streaming": "#bd93f9", - "status-invisible": "#6272a4" - }, - "tags": [ - "dark" - ] - }, - { - "version": "1.0.0", - "slug": "forgejo-dark", - "name": "Forgejo Dark", - "creator": "Freeplay", - "description": "Forgejo's Dark Colors", - "tags": [ - "dark", - "forgejo" - ], - "variables": { - "accent": "#e5873a", - "background": "#10161d", - "foreground": "#D2E0F0", - "block": "#171e26", - "message-box": "#1d262f", - "mention": "#37435160", - "success": "#0be881", - "warning": "#ed8936", - "error": "#f66151", - "hover": "rgba(34, 45, 55, 0.5)", - "scrollbar-thumb": "#e5873a", - "scrollbar-track": "transparent", - "primary-background": "#171e26", - "primary-header": "#242d38", - "secondary-background": "#131a21", - "secondary-foreground": "#D2E0F0CC", - "secondary-header": "#1d262f", - "tertiary-background": "#171e26", - "tertiary-foreground": "#D2E0F099", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "1.0.0", - "slug": "github-dark", - "name": "Github Dark", - "creator": "CodeWhiteOfficial", - "description": "Github's Dark theme from the official color palette", - "tags": [ - "dark", - "github" - ], - "variables": { - "accent": "#6C63FF", - "background": "#1B1F23", - "foreground": "#CCCCCC", - "block": "#1B1F23", - "message-box": "#40444b", - "mention": "#4b443b", - "success": "#2D974D", - "warning": "#FEE75C", - "error": "#ED4245", - "hover": "rgba(0, 0, 0, 0.1)", - "tooltip": "#000000", - "scrollbar-thumb": "#1B1F23", - "scrollbar-track": "transparent", - "primary-background": "#1B1F23", - "primary-header": "#181A1F", - "secondary-background": "#181A1F", - "secondary-foreground": "#b9bbbe", - "secondary-header": "#1B1F23", - "tertiary-background": "#1B1F23", - "tertiary-foreground": "#b9bbbe", - "status-online": "#2D974D", - "status-away": "#FEE75C", - "status-focus": "#6C63FF", - "status-busy": "#ED4245", - "status-streaming": "#593695", - "status-invisible": "#747f8d" - } - }, - { - "version": "1.0.0", - "slug": "github-dark-dimmed", - "name": "Github Dark Dimmed", - "creator": "CodeWhiteOfficial", - "description": "Github's Dark Dimmed theme from the official color palette", - "tags": [ - "dark", - "dark-dimmed", - "github" - ], - "variables": { - "accent": "#0366d6", - "background": "#1B1B1B", - "foreground": "#C5C8C6", - "block": "#262626", - "mention": "rgba(3, 102, 214, 0.06)", - "message-box": "#2E2E2E", - "success": "#28A745", - "warning": "#F5A623", - "error": "#CB2431", - "hover": "rgba(0, 0, 0, 0.1)", - "tooltip": "#000000", - "scrollbar-thumb": "#3E3E3E", - "scrollbar-track": "transparent", - "primary-background": "#282828", - "primary-header": "#3C3C3C", - "secondary-background": "#252525", - "secondary-foreground": "#C5C8C6", - "secondary-header": "#2E2E2E", - "tertiary-background": "#383838", - "tertiary-foreground": "#848484", - "status-online": "#28A745", - "status-away": "#F5A623", - "status-focus": "#0366d6", - "status-busy": "#CB2431", - "status-streaming": "#3E3E3E", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "1.0.0", - "slug": "glass", - "name": "Glass", - "creator": "Axorax", - "description": "Modified nucleon theme with glass effect.", - "tags": [ - "dark", - "transparent", - "glass", - "blur", - "nucleon-glass" - ], - "variables": { - "background": "#11111192", - "mention": "rgba(251, 255, 0, 0.1)", - "hover": "rgba(0, 0, 0, 0.3)", - "primary-background": "#11111192", - "secondary-background": "#11111192", - "secondary-foreground": "#c9c9c9" - }, - "css": "._11Ivv {\n background: url(\"https://autumn.revolt.chat/attachments/FQkCgdyHs4z9OXbdGfbF396uhPahc3seYRFMdEMZqp/axo-theme-bg.jpg\");\n background-repeat: no-repeat;\n background-size: cover;\n}\n\n.PreloaderBase-sc-1xcl5li-0.ddQdoB {\n background: #11111192;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.gILwrq > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n margin-top: 2rem;\n border-radius: 6px;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div,\n.TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n backdrop-filter: blur(2px);\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div button.active {\n background: #111111c9;\n border: 1px solid #3d3d3d;\n}\n\n.MessageBase__DetailBase-sc-1s9ehlg-3.kyoVXf time {\n color: #c9c9c9;\n}\n\ntextarea#message::placeholder {\n color: #ffffff8e;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 1rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 0.5rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\npre.sc-bkzYnD.cjWexI {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\n.Base-sc-1kllx7r-0.iIKZoW {\n border-top: thin solid #5eb0c3;\n}\n\n.Base-sc-yb16g4-0.iDeTmr {\n margin: 1rem;\n background: #11111192;\n border-radius: 6px;\n}\n\n.Shadow-sc-yb16g4-1.bSmCBu {\n display: none;\n}\n\n.MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n margin: 5rem 1rem 1rem 1rem;\n background: #11111192;\n border-radius: 6px;\n backdrop-filter: blur(2.8px);\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n background: #11111192;\n border-radius: 6px;\n margin: 0 1rem 1rem 1rem;\n backdrop-filter: blur(3px);\n}\n\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n background: #11111192;\n margin: 5rem 1rem 0 0;\n height: calc(100vh - 6rem);\n border-radius: 6px;\n padding: 0.5rem;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX, .Header-sc-ujh2d9-0.fEKFaQ, .Header-sc-ujh2d9-0.dzRjrU, .Header-sc-ujh2d9-0.gXyCoD {\n margin: 1rem;\n border-radius: 6px;\n width: calc(100vw - 22rem);\n backdrop-filter: blur(0px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.gXyCoD {\n width: calc(100vw - 7.4rem) !important; \n}\n\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 6.4rem) !important;\n margin-left: -15.5rem !important;\n}\n\n._list_sd6wo_13.with-padding {\n background: #11111192;\n margin-right: 0.8rem;\n margin-top: 5rem;\n border-radius: 6px;\n height: calc(100vh - 6rem);\n}\n\n.Header-sc-ujh2d9-0.dzRjrU {\n width: calc(100vw - 7.4rem) !important;\n margin-left: 1rem !important;\n}\n\n.ItemContainer-sc-176t3v5-0.iRFMLa {\n overflow: hidden;\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n background: #11111192;\n border-radius: 6px;\n margin: 1rem 0;\n height: calc(100vh - 2rem);\n}\n\n.ThemeBaseSelector__List-sc-udzwdl-0.fTtexK > div h4 {\n margin-top: .5rem;\n color: #c9c9c9;\n}\n\n._contentcontainer_t7t23_62 h4 {\n color: #c9c9c9 !important;\n}\n\npre.sc-bkzYnD.cjWexI code,\n.MemberList__ListCategory-sc-1bwlier-0.fWMCga,\n.MemberList__ListCategory-sc-1bwlier-0.jOPNWX,\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Channel__ChannelMain-sc-k5myot-0.DmIgS,\n.RevoltApp__Routes-sc-1613ew5-2.hpNYLK,\n.Channel__ChannelContent-sc-k5myot-1.hfkRtg,\n.RevoltApp__Routes-sc-1613ew5-2.jAWgqa,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-3j94yd-0.jKeKvP summary {\n background: transparent !important;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq,\na.Base-sc-j9ukbi-0.bQqZNf,\n.TipBase-sc-upm053-0.jzUIcb {\n background: #11111192;\n border: 1px solid #3d3d3d;\n}\n\niframe.Discover__Frame-sc-6nwhb3-1.kcZkuw {\n margin: 1rem;\n border-radius: 10px;\n}\n\ndiv#Menu,\n.MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: blur(9px);\n background: #11111192;\n}\n\n._contentcontainer_t7t23_62::after {\n content: \"⠀\";\n}\n\n._embed_dxa3n_1._website_dxa3n_12 {\n backdrop-filter: blur(8px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX svg {\n width: 22px;\n aspect-ratio: 1/1;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw {\n background: transparent;\n backdrop-filter: blur(0);\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort {\n flex-direction: column !important;\n gap: .5rem !important;\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort button {\n padding: .5rem 1rem !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn {\n background: #0c0c0c;\n border: 1px solid #3d3d3d;\n border-radius: 6px;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:hover {\n outline: 1px dashed #5eb0c3 !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:focus {\n outline: none !important;\n}\n\n._item_1avxi_1._user_1avxi_23 {\n opacity: .7;\n}\n\n._item_1avxi_1._user_1avxi_23, ._item_1avxi_1 div._name_1avxi_60 ._subText_1avxi_67 {\n color: #e7e7e7bd;\n}" - }, - { - "version": "0.0.1", - "slug": "gruvbox-dark", - "name": "Gruvbox Dark", - "creator": "to268", - "description": "This is the gruvbox dark hard theme", - "variables": { - "accent": "#FB4934", - "background": "#282828", - "foreground": "#F6F6F6", - "block": "#282828", - "message-box": "#3C3836", - "mention": "#D3869B", - "success": "#B8BB26", - "warning": "#FABD2F", - "error": "#FE8019", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#FF424F", - "scrollbar-track": "transparent", - "primary-background": "#242424", - "primary-header": "#3C3836", - "secondary-background": "#3C3836", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#504945", - "tertiary-background": "#928374", - "tertiary-foreground": "#BDAE93", - "status-online": "#8EC07C", - "status-away": "#D65D0E", - "status-busy": "#FB4934", - "status-streaming": "#977ED5", - "status-invisible": "#FBF1C7" - }, - "tags": [ - "dark" - ] - }, - { - "version": "1.0.0", - "slug": "horizon", - "name": "Horizon", - "creator": "Demperor", - "description": "The VSCode Horizon theme ported to Revolt.", - "variables": { - "accent": "#6C6F93", - "background": "#16161C", - "foreground": "#8B8D8F", - "block": "#4B4C535A", - "message-box": "#1D1F27", - "mention": "#2E303E9F", - "success": "#09F7A0", - "warning": "#FAB28E", - "tooltip": "#000000", - "error": "#F43E5C", - "hover": "#083C5A9f", - "sidebar-sidebar-active": "#202225", - "scrollbar-thumb": "#8a1046", - "scrollbar-track": "#21252E2A", - "primary-background": "#1D1F27", - "primary-header": "#16161C", - "secondary-background": "#1D1F27", - "secondary-foreground": "#8B8D8F", - "secondary-header": "#1c2031", - "tertiary-background": "#1c2031", - "tertiary-foreground": "#9ba3b0", - "status-online": "#27D796", - "status-away": "#FAC29A", - "status-focus": "#4799F0", - "status-busy": "#F09383", - "status-streaming": "#21BFC2", - "status-invisible": "#c8ccd4" - }, - "tags": [ - "dark" - ] - }, - { - "version": "0.0.1", - "slug": "ice-shard", - "name": "Ice Shard", - "creator": "Cara", - "commit": "3cf8c64", - "description": "Very dark backgrounds and light blue details. Making use of custom css to give the app a sharp, cold feel and add a number of simple ux improvements.", - "variables": { - "accent": "#00fff5", - "background": "#0a0a0a", - "foreground": "#e2ffff", - "block": "#262630", - "message-box": "#1d1d1d", - "mention": "rgba(0, 0, 86, 0.40)", - "success": "#5bffbc", - "warning": "#ffb44a", - "error": "#e72a4f", - "hover": "rgba(37, 37, 48, 0.2)", - "scrollbar-thumb": "#6399a1", - "scrollbar-track": "transparent", - "primary-background": "#181818", - "primary-header": "#1e1e1e", - "secondary-background": "#141414", - "secondary-foreground": "#acf8ff", - "secondary-header": "#1e1e1e", - "tertiary-background": "#292929", - "tertiary-foreground": "#dbffff", - "status-online": "#4cfaff", - "status-away": "#cfe40b", - "status-busy": "#fe0e4d", - "status-streaming": "#b961ff", - "status-invisible": "#cdcdcd" - }, - "css": "/*** Custom css for the ice shard theme ***/\n\n/* Removes all round edges on everything */\n:root {\n --border-radius: 0;\n --border-radius-half: 0;\n}\n\n/* Adds a light blue line between the server and channel list */\n.dGrZZ {\n border-right: 2px solid #72F5F3;\n}\n\n/* Makes spoilers a lighter color, to make them better visible on the dark background */\n:root .spoiler {\n background: #313152;\n}\n\n/* Hides the wave used as a chosen server indicator by default */\n.QgupM span, .kGANuc span {\n display: none;\n}\n\n/* Adds a light blue border around the active server's icon */\n.QgupM svg, .kGANuc svg {\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 2px;\n}\n\n/* Colored outline and name of picked channel */\n._compact_12e90_19[data-active=true],\n._normal_12e90_16[data-active=true],\n._user_12e90_23[data-active=true] {\n color: #37FFF8;\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 2px;\n}\n\n/* Mention message colored + bold text */\ndiv.CaijV > div.jgcjMZ > ._markdown_8b8eo_2,\ndiv.cCwoWr > div.jgcjMZ > ._markdown_8b8eo_2 {\n color: #4CFAFF;\n font-weight: bold;\n}\n\n/* Nice home screen */\n._home_1khrk_1 {\n height: 100%;\n background-image: url(\"https://autumn.revolt.chat/attachments/hqI1nk7NBECh8n7GP3eqt-PXv-5tXGWGnbn9qJWZUO\");\n background-repeat: no-repeat;\n background-attachment: fixed;\n background-size: 100% 100%;\n}\n/* Outlining buttons on home screen for better readability */\ndiv._actions_1khrk_12 > a > div,\ndiv._actions_1khrk_12 > div > a > div {\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 1px;\n}\n\n/* Nicer link hovers */\np>a:hover,span>a:hover,li>a:hover,strong>a:hover,th>a:hover,td>a:hover,\nh1>a:hover,h2>a:hover,h3>a:hover,h4>a:hover,h5>a:hover,h6>a:hover{\n color: magenta;\n background: cyan;\n text-decoration: none !important;\n}\n\n/* Nicer name hovers */\n.author:hover,.user>span:hover{\n background: cyan !important;\n text-decoration: none !important;\n color: #000056;\n}\n\n/* Readable buttons on pop-up menus and bot badges */\n.ZAptB, .iYfESC, .kqSFkj {\n color: #000056;\n font-weight: bold;\n}\n\n/* Theme consistent colors on message search */\n.kRnBUt {\n color: #000056;\n font-weight: bold;\n background: #00FFF5;\n}\n.kRnBUt:hover {\n background: #4CFAFF;\n}\n.jqHpRY {\n color: #00FFF5;\n}\n\n/* buttons and checkboxes */\n.check, .dvKiPb {\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 1px;\n}\n\n/***** END *****/\n", - "tags": [ - "dark" - ] - }, - { - "version": "1.2.0", - "slug": "iceshrimp-light", - "name": "IceShrimp Light", - "creator": "theycallhermax", - "description": "The light theme from the Misskey fork bringing you no-nonsense fixes, features and improvements you actually want", - "tags": [ - "light", - "blue", - "iceshrimp" - ], - "variables": { - "accent": "#9A92FF", - "background": "#f1f5ff", - "foreground": "#3B364C", - "block": "#414141", - "message-box": "#f1f5ff", - "mention": "rgb(229, 200, 144, 0.40)", - "success": "rgb(134, 179, 0)", - "warning": "rgb(255, 240, 219)", - "error": "rgb(236, 65, 55)", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#9A92FF", - "scrollbar-track": "transparent", - "primary-background": "#E7EDFF", - "primary-header": "#f1f5ff", - "secondary-background": "#f1f5ff", - "secondary-foreground": "#3B364C", - "secondary-header": "#c6d0f5", - "tertiary-background": "#9A92FF", - "tertiary-foreground": "#3B364C", - "status-online": "#40a02b", - "status-away": "#df8e1d", - "status-busy": "#e64553", - "status-streaming": "#7287fd", - "status-invisible": "#c6d0f5" - }, - "css": "/*\n This is the default CSS for the IceShrimp Light theme.\n\n This applies small fixes that makes Revolt more in place with the\n theme, it is entirely optional to have this CSS as-is, but\n things may feel slightly off.\n*/\n\n:root {\n --border-radius-user-icon: 8px;\n \n font-feature-settings: \"liga\" 1, \"calt\" 1;\n}\n\na[href^=\"/server/\"] svg > circle {\n fill: #303446 !important;\n}\n\n[class^=\"UserShort__Name\"] {\n filter: contrast(70%);\n}\n\n[class^=\"MessageReply__ReplyBase-sc-\"]::before {\n border-inline-start: 2px solid #a5adce;\n border-top: 2px solid #a5adce;\n}\n\n[class^=\"TypingIndicator__Base-sc-\"] > div > [class^=\"avatars\"] > img {\n border-radius: var(--border-radius-user-icon);\n}\n\n[data-item-index=\"0\"] [class^=\"ItemContainer-sc-176t3v5-0\"] foreignObject :is(div, img) {\n border-radius: var(--border-radius-user-icon) !important;\n}\n\n[class^=\"ServerHeader__ServerBanner-sc-\"] {\n margin: 0.5rem;\n border-radius: var(--border-radius-user-icon);\n}\n" - }, - { - "version": "0.0.1", - "slug": "irc-chat", - "name": "IRC Chat", - "creator": "ERROR_404_NULL_NOT_FOUND", - "description": "A pure CSS theme looking to emulate the look and feel of an IRC chat. Colors are amoled. Credits are in Custom.css", - "tags": [ - "amoled", - "irc", - "IRC", - "compact", - "no-pfps", - "dark" - ], - "variables": { - "accent": "#FD6671", - "background": "#000000", - "foreground": "#FFFFFF", - "block": "#1D1D1D", - "message-box": "#000000", - "mention": "rgba(251, 255, 0, 0.06)", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#000000", - "primary-header": "#000000", - "secondary-background": "#000000", - "secondary-foreground": "#DDDDDD", - "secondary-header": "#1A1A1A", - "tertiary-background": "#000000", - "tertiary-foreground": "#AAAAAA", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "/*Original theme: <@01FE43S0TRRR73MEP5Y63Z9MCB>(@Davari)\nEdits to make more usable made by <@01FTN0QWKA45VG5CPCVJH7H399>(@MYT)\n*/\n* {\n border-color: var(--border-color) !important;\n}\n@font-face {\n font-family: Blank;\n src: url(\"https://raw.githack.com/adobe-fonts/adobe-blank/master/AdobeBlank.ttf.woff?raw=true\");\n unicode-range: U+61-7a,U+41-5a,U+20\n}\n:root {\n font-variant-ligatures:normal!important;\n --font: var(--monospace-font) !important;\n --irc-chat-width: 260px;\n}\n[class^=MessageBase-] {\n position: relative;\n margin-top: 0;\n}\n[class^=MessageBase__MessageInfo] {\n width: var(--irc-chat-width);\n margin-right: 10px;\n}\n[class^=MessageBase__MessageInfo]>svg, .user>svg {\n display: none;\n}\n[class^=Channel__ChannelContent] [class^=MessageBase__MessageContent] {\n position: unset;\n}\n[class^=MessageBase__MessageContent]>.detail {\n position: absolute;\n left: 0;\n top: 0;\n width: var(--irc-chat-width);\n display: flex;\n flex-direction: row-reverse;\n}\n[class^=MessageBase__MessageContent]>.detail>.author {\n word-break: break-all;\n}\n[class^=MessageBase__MessageInfo]>time {\n opacity: 1 !important;\n flex-grow: 1; \n}\n[class^=MessageReply__ReplyBase] {\n margin-left: calc(var(--irc-chat-width) + 15px);\n margin-top: 0;\n}\n[class^=MessageReply__ReplyBase]::before {\n content: '>';\n border: none;\n align-self: center;\n width: auto;\n height: 100%;\n}\n[class^=MessageBase__DetailBase] {\n margin-right: auto;\n}\n[class^=MessageBase__DetailBase]>time {\n margin-left: 2px;\n font-family: Blank, var(--font);\n}\n[class^=MessageBase__]>time::before {\n content: '['\n}\n[class^=MessageBase__]>time::after {\n content: ']'\n}\n[class^=MessageBase__MessageContent]>.detail>.author::before, .user>span::before {\n content: '<'\n}\n[class^=MessageBase__MessageContent]>.detail>.author::after, .user>span::after {\n content: '>'\n}\n[class^=DateDivider__Base] {\n margin: 3px -6px;\n padding: 1px 4px;\n position: sticky;\n top: 0;\n border: unset;\n background: var(--secondary-header);\n height: auto;\n z-index: 1;\n}\n[class^=DateDivider__Base]>time {\n font-size: 10px;\n background: unset;\n}\n[class^=MessageArea__Area] {\n background: linear-gradient(to right, var(--secondary-background) calc(var(--irc-chat-width) + 7px), #0000 calc(var(--irc-chat-width) + 7px))\n}\n/*[class^=MessageArea__Area]>div {\n padding-bottom: 0;\n}\nto reactivate this, you need to hide the * is typing bar*/\n[class^=DateDivider__Unread] {\n padding: unset;\n border-radius: unset;\n background: unset;\n color: var(--accent);\n font-size: 10px;\n margin: 0 5px;\n}\n[class^=DateDivider__Unread]::after {\n content: '';\n position: absolute;\n width: 100%;\n background: var(--accent);\n height: 1px;\n top: 7px;\n margin-left: 5px;\n}\n[class^=Channel__ChannelContent] [class^=MessageBase__MessageInfo]>.copyTime {\n position: relative;\n}\n" - }, - { - "version": "0.0.1", - "slug": "kyli0x", - "name": "kyli0x's Theme", - "creator": "kyli0x", - "description": "Cyan and gray based theme for Revolt", - "versions": "1.0.0", - "tags": [ - "dark", - "cyan", - "gray", - "orange" - ], - "variables": { - "accent": "#1AABA0", - "background": "#444444", - "foreground": "#FFFFFF", - "block": "#333333", - "message-box": "#333333", - "mention": "AB581A", - "success": "#ff7d00", - "warning": "#aba01a", - "tooltip": "#5994EC", - "error": "#AB1A25", - "hover": "#222222", - "scrollbar-thumb": "#FF7D00", - "scrollbar-track": "transparent", - "primary-background": "#222222", - "primary-header": "#222222", - "secondary-background": "#222222", - "secondary-foreground": "BCC6D6", - "secondary-header": "#444444", - "tertiary-background": "#FF7D00", - "tertiary-foreground": "#9BA3B0", - "status-online": "#30B6A0", - "status-away": "#ABA01A", - "status-focus": "#9146FF", - "status-busy": "#AB1A25", - "status-streaming": "#A01AAB", - "status-invisible": "#8F93A2" - } - }, - { - "version": "1.0.0", - "slug": "light", - "name": "Light Mode", - "creator": "Revolt", - "description": "This is the default light theme for Revolt.", - "tags": [ - "revolt", - "light" - ], - "variables": { - "accent": "#FD6671", - "background": "#F6F6F6", - "foreground": "#101010", - "block": "#414141", - "message-box": "#F1F1F1", - "mention": "rgba(251, 255, 0, 0.40)", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.2)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#FFFFFF", - "primary-header": "#F1F1F1", - "secondary-background": "#F1F1F1", - "secondary-foreground": "#888888", - "secondary-header": "#F1F1F1", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#646464", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "v0.0.1", - "name": "Lucifer's Fall", - "slug": "lucifers-fall", - "creator": "The Humanoid", - "tags": [ - "amoled", - "purple", - "dark" - ], - "description": "AMOLED black theme based around Lucifer's Fall with a purple accent.", - "variables": { - "accent": "#662d73", - "background": "#000000", - "foreground": "#F6F6F6", - "block": "#000000", - "message-box": "#000000", - "mention": "#662d73", - "success": "#65E572", - "warning": "#FAA352", - "error": "#ED4245", - "hover": "rgba(0, 0, 0, 0.1)", - "tooltip": "#000000", - "scrollbar-track": "transparent", - "scrollbar-thumb": "#662d73", - "primary-header": "#000000", - "primary-background": "#000000", - "secondary-header": "#000000", - "secondary-background": "#000000", - "secondary-foreground": "#C8C8C8", - "tertiary-background": "#662d73", - "tertiary-foreground": "#C8C8C8", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-focus": "#4799F0", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": ":root {\n --border-radius-user-icon: 12px;\n --primary-background: rgba(var(--primary-background-rgb), 0.70);\n --secondary-background: rgba(var(--secondary-background-rgb), 0.35);\n --message-box: rgba(var(--secondary-background-rgb), 0.70);\n --block: rgba(var(--primary-background-rgb), 0.70);\n}\n\n[class^=\"RevoltApp__AppContainer-sc-\"] {\n z-index: -999;\n background: url('https://autumn.revolt.chat/attachments/KHu7zDNg6LcIvIYq22cICwDL8FzqRfjCpFiTW7UvG8/Untitled281_20221101085844.png');\n background-position: center;\n background-repeat: no-repeat;\n background-attachment: fixed;\n background-size: cover;\n position: absolute;\n display: block;\n inset: 0;\n top: 0;\n}\n\n[class^=\"MessageReply__ReplyBase\"]::before {\nborder-inline-start: 2px solid var(--tertiary-background);\nborder-top: 2px solid var(--tertiary-background);}\n" - }, - { - "version": "0.0.1", - "slug": "mint-glow", - "name": "Mint Glow", - "creator": "Potato#9983", - "description": "Dark theme with pastel green highlights.", - "variables": { - "accent": "#B8F684", - "background": "#141414", - "foreground": "#F5F3E7", - "block": "#414141", - "message-box": "#EEC3C3", - "mention": "#FBFF00", - "success": "#AAE95A", - "warning": "#F7D145", - "error": "#EA2A2A", - "hover": "#000000", - "scrollbar-thumb": "#B8F684", - "scrollbar-track": "transparent", - "primary-background": "#1E1E1E", - "primary-header": "#30313B", - "secondary-background": "#252526", - "secondary-foreground": "#BFBFBF", - "secondary-header": "#1A1A1A", - "tertiary-background": "#252526", - "tertiary-foreground": "#D8E4DA", - "status-online": "#3BBF51", - "status-away": "#F39F00", - "status-busy": "#E24050", - "status-streaming": "#9A81FD", - "status-invisible": "#A5A5A5", - "status-tags-0": "pastel", - "status-tags-1": "green", - "status-version": "0.0.1" - }, - "tags": [ - "dark" - ] - }, - { - "version": "0.0.1", - "slug": "neon-blue", - "name": "Neon Blue", - "creator": "WlodekM", - "commit": "782a9b5", - "description": "Neon theme inspired by Opera GX.", - "tags": [ - "neon", - "blue", - "dark" - ], - "variables": { - "accent": "#0084ff", - "background": "#11111192", - "foreground": "#dcddde", - "block": "#2f3136", - "message-box": "#34363c", - "mention": "rgba(251, 255, 0, 0.1)", - "success": "#3ABF7E", - "warning": "#FAA352", - "error": "hsl(359,66.7%,54.1%);", - "hover": "#0084ff50", - "scrollbar-thumb": "#0084ff", - "scrollbar-track": "#2f3136", - "primary-background": "#11111192", - "primary-header": "#2d2f34", - "secondary-background": "#11111192", - "secondary-foreground": "#c9c9c9", - "secondary-header": "#2f3136", - "tertiary-background": "#4f545c", - "tertiary-foreground": "#a3a6aa", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "/* Main BG */\n._11Ivv {\n background: #1a1b26;\n background-repeat: no-repeat;\n background-size: cover;\n z-index: 1;\n border: 1px var(--accent) solid;\n}\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n height: calc(100% - 3rem);\n}\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 58px) !important;\n}\n/* settings button */ \n\n.gafLvO svg {--accent: white !important}\n.FallbackBase-sc-3bwws8-1.gafLvO {\n border-radius: 2px;\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n --cp-size: 5px;\n background: var(--accent);\n}\n\n\n/* stop settings from overlapping border and topbar on desktop */\n._settings_t7t23_33.undefined {\n margin-inline: 1px;\n height: calc(100% - var(--titlebar-height) - 2px);\n width: calc(100% - 2px);\n margin-top: calc(var(--titlebar-height) + 1px) !important;\n}\n\n/* Topbar */\n.hwmymO .actions div:hover {\n background: #252631;\n cursor: pointer;\n}\n\n.drag {\n border-top: 1px var(--accent) solid;\n margin: 0 !important;\n z-index: 999999;\n}\n\n.hwmymO .actions, .hwmymO .title, .hwmymO .drag {\n background: #151621;\n}\n\n.hwmymO .actions {\n border-top: 1px var(--accent) solid;\n border-right: 1px var(--accent) solid;\n margin: 0 !important;\n padding-left: 6px;\n height: 100%;\n}\n\n.hwmymO .title {\n border-top: 1px var(--accent) solid;\n border-left: 1px var(--accent) solid;\n margin: 0 !important;\n padding-left: 10px;\n padding-top: 10px;\n height: 100%;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div, .TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #0002;\n backdrop-filter: blur(5px);\n border-radius: 0;\n border-top: 1px var(--accent) solid;\n}\n\n._tabs_kup3j_69>div:hover:not([data-active=true]) {\n border-bottom: 2px solid #09701a;\n}\n\n._tabs_kup3j_69>div[data-active=true] {\n border-bottom: 2px solid var(--accent);\n}\n\n.Content-sc-1d91xkm-3.bVQPpK {\n border: var(--accent) 1px solid;\n}\n\n._item_1avxi_1._user_1avxi_23:hover svg foreignObject img {\n border-radius: 25%;\n}\n\n._item_1avxi_1._user_1avxi_23 svg foreignObject img {\n transition: .1s ease-in-out all;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.gILwrq > div {\n background: #11111192;\n border-radius: 0;\n border-bottom: var(--accent) 1px solid;\n backdrop-filter: none;\n}\n\n._item_1avxi_1._compact_1avxi_19:hover ._avatar_1avxi_56 img.IconBase__ImageIconBase-sc-igncj4-1.hpNCHs, ._item_1avxi_1._compact_1avxi_19[data-active=\"true\"] ._avatar_1avxi_56 img.IconBase__ImageIconBase-sc-igncj4-1.hpNCHs {\n border-radius: calc(var(--border-radius-half) / 3);\n}\n\nimg.IconBase__ImageIconBase-sc-igncj4-1.hpNCHs {\n border-radius: calc(var(--border-radius-half) / 1.5);\n}\n\n._list_sd6wo_13.with-padding {\n margin-top: 3em;\n height: 100vh !important;\n}\n\n.iDeTmr {\n background: var(--secondary-background);\n border-right: 1px var(--accent) solid;\n}\n\n.Channel__ChannelMain-sc-k5myot-0.DmIgS .SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n border-right: 0;\n border-left: 1px var(--accent) solid;\n}\n\n\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n border-right: 1px var(--accent) solid;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq, a.Base-sc-j9ukbi-0.bQqZNf, .TipBase-sc-upm053-0.jzUIcb {\n --cp-size: 15px;\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n border: none !important;\n}\n\nselect {\n --cp-size: 15px;\n}\n\n.checkmark{\n --cp-size: 7px;\n border: none !important\n}\n\n.EmojiSelector__Container-sc-1b2o6ki-0 div div .button {\n --cp-size: 25px;\n border: none !important\n}\n\n.button, .checkmark, select {\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n}\n\n._item_1avxi_1._compact_1avxi_19 {\n --cp-size: 7.5px;\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n border-radius: 3px !important;\n}\n\n.ThemeOverrides__Container-sc-1brryxj-0 .entry {\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n --cp-size: 15px;\n}\n\n._attachment_197qa_1._text_197qa_33 {\n border-radius: 0;\n}\n\ndiv._text_197qa_33 div._actions_iu4oa_1 {\n border-top: none !important;\n}\n\n._actions_iu4oa_1 {\n border-radius: 0 !important;\n backdrop-filter: none !important;\n background: #111 !important;\n border: 1px var(--accent) solid !important;\n}\n\n.Header-sc-ujh2d9-0.dzRjrU {\n border-radius: 0;\n border-bottom: 1px var(--accent) solid;\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n border-top: var(--accent) 1px solid;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n width: initial !important;\n margin: 0 !important;\n margin-left: 0 !important;\n border-top: 1px var(--accent) solid;\n}\n\ndiv#Menu, .MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: none;\n background: #141414;\n border-radius: 0;\n border: 1px var(--accent) solid;\n}\n\nbody {\n border: 1px var(--accent) solid\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n border-radius: 0;\n border-right: 1px var(--accent) solid\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n margin: 0 !important;\n border-top: 1px var(--accent) solid\n}\n\n.Header-sc-ujh2d9-0.fPzJRX {\n border-bottom: 1px var(--accent) solid;\n}\n\n.dUCGFk, .MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n margin-top: 3rem !important;\n}\n\n.Details-sc-3j94yd-0 summary div {\n width: 100%;\n}\n\n._item_1avxi_1[data-active=true] {\n cursor: default;\n background: var(--accent);\n}\n\n.Category-sc-tvyi45-0.iuNJGz {\n padding-top: 1.2em;\n border-top: var(--accent) 1px solid;\n width: 100%;\n}\n\n._sidebar_t7t23_39 {\n border-right: var(--accent) 1px solid;\n}\n\n/* cyrillic-ext */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtEacLLTQ.woff2) format('woff2');\n unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n/* cyrillic */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtNacLLTQ.woff2) format('woff2');\n unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n/* greek */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtKacLLTQ.woff2) format('woff2');\n unicode-range: U+0370-03FF;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtGacLLTQ.woff2) format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtHacLLTQ.woff2) format('woff2');\n unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtJacI.woff2) format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n:root {\n --font: Tektur !important;\n}\n\n.PreloaderBase-sc-1xcl5li-0.ddQdoB {\n background: #11111192;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div,\n.TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #11111192;\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n margin-top: -1rem;\n backdrop-filter: blur(2px);\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div button.active {\n background: #111111c9;\n border: 1px solid #3d3d3d;\n}\n\n.MessageBase__DetailBase-sc-1s9ehlg-3.kyoVXf time {\n color: #c9c9c9;\n}\n\ntextarea#message::placeholder {\n color: #ffffff8e;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 1rem 0;\n margin-left: 1rem;\n backdrop-filter: blur(9px);\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\npre.sc-bkzYnD.cjWexI {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\n.Base-sc-1kllx7r-0.iIKZoW {\n border-top: thin solid #5eb0c3;\n}\n\n.Shadow-sc-yb16g4-1.bSmCBu {\n display: none;\n}\n\n.MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n background: #11111192;\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n background: #11111192;\n margin: 0 1rem 1rem 1rem;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX, .Header-sc-ujh2d9-0.fEKFaQ, .Header-sc-ujh2d9-0.dzRjrU, .Header-sc-ujh2d9-0.gXyCoD {\n backdrop-filter: blur(0px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.gXyCoD {\n width: calc(100vw - 7.4rem) !important; \n}\n\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 58px) !important;\n margin-left: -14.5rem !important;\n border-bottom: var(--accent) 1px solid;\n border-radius: 0 !important;\n}\n\n._list_sd6wo_13.with-padding {\n background: #11111192;\n}\n\n.ItemContainer-sc-176t3v5-0.iRFMLa {\n overflow: hidden;\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n background: #11111192;\n}\n\n.ThemeBaseSelector__List-sc-udzwdl-0.fTtexK > div h4 {\n margin-top: .5rem;\n color: #c9c9c9;\n}\n\n._contentcontainer_t7t23_62 h4 {\n color: #c9c9c9 !important;\n}\n\npre.sc-bkzYnD.cjWexI code,\n.MemberList__ListCategory-sc-1bwlier-0.fWMCga,\n.MemberList__ListCategory-sc-1bwlier-0.jOPNWX,\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Channel__ChannelMain-sc-k5myot-0.DmIgS,\n.RevoltApp__Routes-sc-1613ew5-2.hpNYLK,\n.Channel__ChannelContent-sc-k5myot-1.hfkRtg,\n.RevoltApp__Routes-sc-1613ew5-2.jAWgqa,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-3j94yd-0.jKeKvP summary {\n background: transparent !important;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq,\na.Base-sc-j9ukbi-0.bQqZNf,\n.TipBase-sc-upm053-0.jzUIcb {\n background: #11111192;\n border: 1px solid #3d3d3d;\n}\n\ndiv#Menu,\n.MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: blur(9px);\n background: #11111192;\n}\n\n._contentcontainer_t7t23_62::after {\n content: \"⠀\";\n}\n\n._embed_dxa3n_1._website_dxa3n_12 {\n backdrop-filter: blur(8px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX svg {\n width: 22px;\n aspect-ratio: 1/1;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw {\n background: transparent;\n backdrop-filter: blur(0);\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort {\n flex-direction: column !important;\n gap: .5rem !important;\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort button {\n padding: .5rem 1rem !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn {\n background: #0c0c0c;\n border: 1px solid #3d3d3d;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:hover {\n outline: 1px dashed #5eb0c3 !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:focus {\n outline: none !important;\n}\n\n._item_1avxi_1._user_1avxi_23 {\n opacity: .7;\n}\n\n._item_1avxi_1._user_1avxi_23, ._item_1avxi_1 div._name_1avxi_60 ._subText_1avxi_67 {\n color: #e7e7e7bd;\n}\n" - }, - { - "version": "0.0.1", - "slug": "nocturnal", - "name": "Nocturnal", - "creator": "aeris", - "description": "For when default revolt just isn't enough, a theme for those who live a night.", - "variables": { - "accent": "#2275C9", - "background": "#1E2731", - "foreground": "#F6F6F6", - "block": "#1E2731", - "message-box": "#1E2731", - "mention": "#0F3827", - "success": "#35E680", - "warning": "#0F3827", - "error": "#ED4245", - "hover": "#1D242B", - "scrollbar-thumb": "#124A81", - "scrollbar-track": "transparent", - "primary-background": "#1E2731", - "primary-header": "#12171D", - "secondary-background": "#12171D", - "secondary-foreground": "#2ECC71", - "secondary-header": "#12171D", - "tertiary-background": "#1E2731", - "tertiary-foreground": "#8E9297", - "status-online": "#43B581", - "status-away": "#FAA61A", - "status-busy": "#F04747", - "status-streaming": "#643DA7", - "status-invisible": "#747F8D" - }, - "tags": [ - "dark" - ] - }, - { - "version": "1.0.0", - "slug": "nord-dark", - "name": "Nord (Dark)", - "creator": "o69mar", - "description": "Nordic dark theme for revolt.chat", - "tags": [ - "dark", - "nord" - ], - "variables": { - "accent": "#81a1c1", - "background": "#434c5e", - "foreground": "#f6f6f6", - "block": "#2e3440", - "message-box": "#3b4252", - "mention": "#434c5e", - "success": "#a3be8c", - "warning": "#ebcb8b", - "error": "#bf616a", - "hover": "#4c556a", - "scrollbar-thumb": "#81a1c1", - "scrollbar-track": "transparent", - "primary-background": "#2e3440", - "primary-header": "#3b4252", - "secondary-background": "#3b4252", - "secondary-foreground": "#d8dee9", - "secondary-header": "#3b4252", - "tertiary-background": "#3b4252", - "tertiary-foreground": "#d8dee9", - "status-online": "#a3be8c", - "status-away": "#ebcb8b", - "status-busy": "#bf616a", - "status-streaming": "#81a1c1", - "status-invisible": "#a5a5a5" - } - }, - { - "version": "1.0.0", - "slug": "nucleon", - "name": "Nucleon", - "creator": "Axorax", - "description": "Transparent background dark theme.", - "tags": [ - "dark", - "nucleon", - "background", - "transparent", - "glass", - "blur" - ], - "variables": { - "background": "#11111192", - "mention": "rgba(251, 255, 0, 0.1)", - "hover": "rgba(0, 0, 0, 0.3)", - "primary-background": "#11111192", - "secondary-background": "#11111192", - "secondary-foreground": "#c9c9c9" - }, - "css": "._11Ivv {\n background: url(\"https://autumn.revolt.chat/attachments/FQkCgdyHs4z9OXbdGfbF396uhPahc3seYRFMdEMZqp/axo-theme-bg.jpg\");\n background-repeat: no-repeat;\n background-size: cover;\n}\n\n.PreloaderBase-sc-1xcl5li-0.ddQdoB {\n background: #11111192;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.gILwrq > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n margin-top: 2rem;\n border-radius: 6px;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div,\n.TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n backdrop-filter: blur(2px);\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div button.active {\n background: #111111c9;\n border: 1px solid #3d3d3d;\n}\n\n.MessageBase__DetailBase-sc-1s9ehlg-3.kyoVXf time {\n color: #c9c9c9;\n}\n\ntextarea#message::placeholder {\n color: #ffffff8e;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 1rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 0.5rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\npre.sc-bkzYnD.cjWexI {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\n.Base-sc-1kllx7r-0.iIKZoW {\n border-top: thin solid #5eb0c3;\n}\n\n.Base-sc-yb16g4-0.iDeTmr {\n margin: 1rem;\n background: #11111192;\n border-radius: 6px;\n}\n\n.Shadow-sc-yb16g4-1.bSmCBu {\n display: none;\n}\n\n.MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n margin: 5rem 1rem 1rem 1rem;\n background: #11111192;\n border-radius: 6px;\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n background: #11111192;\n border-radius: 6px;\n margin: 0 1rem 1rem 1rem;\n}\n\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n background: #11111192;\n margin: 5rem 1rem 0 0;\n height: calc(100vh - 6rem);\n border-radius: 6px;\n padding: 0.5rem;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX, .Header-sc-ujh2d9-0.fEKFaQ, .Header-sc-ujh2d9-0.dzRjrU, .Header-sc-ujh2d9-0.gXyCoD {\n margin: 1rem;\n border-radius: 6px;\n width: calc(100vw - 22rem);\n backdrop-filter: blur(0px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.gXyCoD {\n width: calc(100vw - 7.4rem) !important; \n}\n\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 6.4rem) !important;\n margin-left: -15.5rem !important;\n}\n\n._list_sd6wo_13.with-padding {\n background: #11111192;\n margin-right: 0.8rem;\n margin-top: 5rem;\n border-radius: 6px;\n height: calc(100vh - 6rem);\n}\n\n.Header-sc-ujh2d9-0.dzRjrU {\n width: calc(100vw - 7.4rem) !important;\n margin-left: 1rem !important;\n}\n\n.ItemContainer-sc-176t3v5-0.iRFMLa {\n overflow: hidden;\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n background: #11111192;\n border-radius: 6px;\n margin: 1rem 0;\n height: calc(100vh - 2rem);\n}\n\n.ThemeBaseSelector__List-sc-udzwdl-0.fTtexK > div h4 {\n margin-top: .5rem;\n color: #c9c9c9;\n}\n\n._contentcontainer_t7t23_62 h4 {\n color: #c9c9c9 !important;\n}\n\npre.sc-bkzYnD.cjWexI code,\n.MemberList__ListCategory-sc-1bwlier-0.fWMCga,\n.MemberList__ListCategory-sc-1bwlier-0.jOPNWX,\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Channel__ChannelMain-sc-k5myot-0.DmIgS,\n.RevoltApp__Routes-sc-1613ew5-2.hpNYLK,\n.Channel__ChannelContent-sc-k5myot-1.hfkRtg,\n.RevoltApp__Routes-sc-1613ew5-2.jAWgqa,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-3j94yd-0.jKeKvP summary {\n background: transparent !important;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq,\na.Base-sc-j9ukbi-0.bQqZNf,\n.TipBase-sc-upm053-0.jzUIcb {\n background: #11111192;\n border: 1px solid #3d3d3d;\n}\n\niframe.Discover__Frame-sc-6nwhb3-1.kcZkuw {\n margin: 1rem;\n border-radius: 10px;\n}\n\ndiv#Menu,\n.MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: blur(9px);\n background: #11111192;\n}\n\n._contentcontainer_t7t23_62::after {\n content: \"⠀\";\n}\n\n._embed_dxa3n_1._website_dxa3n_12 {\n backdrop-filter: blur(8px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX svg {\n width: 22px;\n aspect-ratio: 1/1;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw {\n background: transparent;\n backdrop-filter: blur(0);\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort {\n flex-direction: column !important;\n gap: .5rem !important;\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort button {\n padding: .5rem 1rem !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn {\n background: #0c0c0c;\n border: 1px solid #3d3d3d;\n border-radius: 6px;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:hover {\n outline: 1px dashed #5eb0c3 !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:focus {\n outline: none !important;\n}\n\n._item_1avxi_1._user_1avxi_23 {\n opacity: .7;\n}\n\n._item_1avxi_1._user_1avxi_23, ._item_1avxi_1 div._name_1avxi_60 ._subText_1avxi_67 {\n color: #e7e7e7bd;\n}" - }, - { - "version": "0.0.1", - "slug": "one-dark", - "name": "One Dark", - "creator": "Odyssey346", - "description": "A theme based on Mahmoud Ali's Atom One Dark VSCode theme.", - "variables": { - "accent": "#4D78CC", - "background": "#21252B", - "foreground": "#D7DAE0", - "block": "#082730", - "message-box": "#21252B", - "mention": "rgba(251, 255, 0, 0.06)", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#CA525A", - "scrollbar-track": "transparent", - "primary-background": "#333842", - "primary-header": "#21252B", - "secondary-background": "#21252B", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#21252B", - "tertiary-background": "#21252B", - "tertiary-foreground": "#D7DAE0", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5", - "sidebar-sidebar-active": "#202225" - }, - "tags": [ - "dark" - ] - }, - { - "version": "0.0.1", - "slug": "pink", - "name": "Pink Theme", - "creator": "IanSkinner1982", - "description": "This is pink colored theme for Revolt.", - "variables": { - "accent": "#FD6671", - "background": "#ce5f5f", - "foreground": "#6c1414", - "block": "#414141", - "message-box": "#efc3c3", - "mention": "#00dbd8", - "success": "#65E572", - "warning": "#FAA352", - "error": "#ED4245", - "hover": "#ffadad", - "scrollbar-thumb": "#ca525a", - "scrollbar-track": "transparent", - "primary-background": "#fdcece", - "primary-header": "#ffa8a8", - "secondary-background": "#e99696", - "secondary-foreground": "#711414", - "secondary-header": "#fdb4b4", - "tertiary-background": "#ffffff", - "tertiary-foreground": "#813131", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#9161cc", - "status-invisible": "#A5A5A5" - }, - "tags": [ - "light" - ] - }, - { - "version": "1.0.0", - "slug": "pleasant-purple", - "name": "Pleasant Purple", - "creator": "Ridglef", - "description": "A pleasant looking purple theme", - "tags": [ - "purple", - "dark-purple", - "dark" - ], - "variables": { - "accent": "#1ABC9C", - "background": "#464664", - "foreground": "#ffffff", - "block": "#303030", - "message-box": "#363636", - "mention": "#7d5f9b", - "success": "#65E572", - "warning": "#FAA352", - "tooltip": "#000000", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#15967d", - "scrollbar-track": "transparent", - "primary-background": "#5a4674", - "primary-header": "#363636", - "secondary-background": "#59456e", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#2D2D2D", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#dedede", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-focus": "#4799F0", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "@font-face {\n font-family: 'Comfortaa';\n src: url('https://fonts.gstatic.com/s/comfortaa/v40/1Pt_g8LJRfWJmhDAuUsSQamb1W0lwk4S4WjMDrMfIA.woff2') format('woff2');\n}\n\np {\n font-family: 'Comfortaa', sans-serif;\n}\n" - }, - { - "version": "1.0.0", - "slug": "primer-dark", - "name": "Primer Dark", - "tags": [ - "dark" - ], - "creator": "bree", - "description": "Dark theme using primer color primatives.", - "variables": { - "accent": "#58a6ff", - "background": "#0d1117", - "foreground": "#c9d1d9", - "block": "#0d1117", - "message-box": "#0d1117", - "mention": "#db61a2", - "success": "#3fb950", - "warning": "#d29922", - "error": "#f85149", - "hover": "rgba(110,118,129,0.1)", - "border-color": "#30363d", - "scrollbar-thumb": "#f85149", - "scrollbar-track": "transparent", - "primary-background": "#0d1117", - "primary-foreground": "#c9d1d9", - "primary-header": "#161b22", - "secondary-background": "#161b22", - "secondary-foreground": "#8b949e", - "secondary-header": "#161b22", - "tertiary-background": "#161b22", - "tertiary-foreground": "#8b949e", - "status-online": "#3fb950", - "status-away": "#d29922", - "status-busy": "#f85149", - "status-streaming": "#a371f7", - "status-invisible": "#6e7681" - }, - "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "primer-dark-colorblind", - "name": "Primer Dark colorblind", - "tags": [ - "colorblind", - "dark" - ], - "creator": "bree", - "description": "Dark colorblind theme using primer color primatives.", - "variables": { - "accent": "#58a6ff", - "background": "#0d1117", - "foreground": "#c9d1d9", - "block": "#0d1117", - "message-box": "#0d1117", - "mention": "#db61a2", - "success": "#42a0ff", - "warning": "#d29922", - "error": "#c38000", - "hover": "rgba(110,118,129,0.1)", - "border-color": "#30363d", - "scrollbar-thumb": "#c38000", - "scrollbar-track": "transparent", - "primary-background": "#0d1117", - "primary-foreground": "#c9d1d9", - "primary-header": "#161b22", - "secondary-background": "#161b22", - "secondary-foreground": "#8b949e", - "secondary-header": "#161b22", - "tertiary-background": "#161b22", - "tertiary-foreground": "#8b949e", - "status-online": "#42a0ff", - "status-away": "#d29922", - "status-busy": "#c38000", - "status-streaming": "#a371f7", - "status-invisible": "#6e7681" - }, - "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "primer-dark-dimmed", - "name": "Primer Dark dimmed", - "tags": [ - "dark" - ], - "creator": "bree", - "description": "Dark dimmed theme using primer color primatives.", - "variables": { - "accent": "#539bf5", - "background": "#22272e", - "foreground": "#adbac7", - "block": "#22272e", - "message-box": "#22272e", - "mention": "#c96198", - "success": "#57ab5a", - "warning": "#c69026", - "error": "#e5534b", - "hover": "rgba(99,110,123,0.1)", - "border-color": "#444c56", - "scrollbar-thumb": "#e5534b", - "scrollbar-track": "transparent", - "primary-background": "#22272e", - "primary-foreground": "#adbac7", - "primary-header": "#2d333b", - "secondary-background": "#2d333b", - "secondary-foreground": "#768390", - "secondary-header": "#2d333b", - "tertiary-background": "#2d333b", - "tertiary-foreground": "#768390", - "status-online": "#57ab5a", - "status-away": "#c69026", - "status-busy": "#e5534b", - "status-streaming": "#986ee2", - "status-invisible": "#636e7b" - }, - "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "primer-dark-high-contrast", - "name": "Primer Dark high contrast", - "tags": [ - "high-contrast", - "dark" - ], - "creator": "bree", - "description": "Dark high contrast theme using primer color primatives.", - "variables": { - "accent": "#71b7ff", - "background": "#0a0c10", - "foreground": "#f0f3f6", - "block": "#0a0c10", - "message-box": "#0a0c10", - "mention": "#ef6eb1", - "success": "#26cd4d", - "warning": "#f0b72f", - "error": "#ff6a69", - "hover": "rgba(158,167,179,0.1)", - "border-color": "#7a828e", - "scrollbar-thumb": "#ff6a69", - "scrollbar-track": "transparent", - "primary-background": "#0a0c10", - "primary-foreground": "#f0f3f6", - "primary-header": "#272b33", - "secondary-background": "#272b33", - "secondary-foreground": "#f0f3f6", - "secondary-header": "#272b33", - "tertiary-background": "#272b33", - "tertiary-foreground": "#f0f3f6", - "status-online": "#26cd4d", - "status-away": "#f0b72f", - "status-busy": "#ff6a69", - "status-streaming": "#b780ff", - "status-invisible": "#9ea7b3" - }, - "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "primer-light", - "name": "Primer Light", - "tags": [ - "light" - ], - "creator": "bree", - "description": "Light theme using primer color primatives.", - "variables": { - "accent": "#0969da", - "background": "#ffffff", - "foreground": "#24292f", - "block": "#24292f", - "message-box": "#ffffff", - "mention": "#bf3989", - "success": "#1a7f37", - "warning": "#9a6700", - "error": "#cf222e", - "hover": "rgba(175,184,193,0.2)", - "border-color": "#d0d7de", - "scrollbar-thumb": "#fa4549", - "scrollbar-track": "transparent", - "primary-background": "#ffffff", - "primary-foreground": "#24292f", - "primary-header": "#f6f8fa", - "secondary-background": "#f6f8fa", - "secondary-foreground": "#57606a", - "secondary-header": "#f6f8fa", - "tertiary-background": "#f6f8fa", - "tertiary-foreground": "#57606a", - "status-online": "#1a7f37", - "status-away": "#9a6700", - "status-busy": "#cf222e", - "status-streaming": "#8250df", - "status-invisible": "#6e7781" - }, - "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "primer-light-colorblind", - "name": "Primer Light colorblind", - "tags": [ - "colorblind", - "light" - ], - "creator": "bree", - "description": "Light colorblind theme using primer color primatives.", - "variables": { - "accent": "#0969da", - "background": "#ffffff", - "foreground": "#24292f", - "block": "#24292f", - "message-box": "#ffffff", - "mention": "#bf3989", - "success": "#0566d5", - "warning": "#9a6700", - "error": "#ac5e00", - "hover": "rgba(175,184,193,0.2)", - "border-color": "#d0d7de", - "scrollbar-thumb": "#d08002", - "scrollbar-track": "transparent", - "primary-background": "#ffffff", - "primary-foreground": "#24292f", - "primary-header": "#f6f8fa", - "secondary-background": "#f6f8fa", - "secondary-foreground": "#57606a", - "secondary-header": "#f6f8fa", - "tertiary-background": "#f6f8fa", - "tertiary-foreground": "#57606a", - "status-online": "#0566d5", - "status-away": "#9a6700", - "status-busy": "#ac5e00", - "status-streaming": "#8250df", - "status-invisible": "#6e7781" - }, - "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" - }, - { - "version": "1.0.0", - "slug": "primer-light-high-contrast", - "name": "Primer Light high contrast", - "tags": [ - "high-contrast", - "light" - ], - "creator": "bree", - "description": "Light high contrast theme using primer color primatives.", - "variables": { - "accent": "#0349b4", - "background": "#ffffff", - "foreground": "#0E1116", - "block": "#0E1116", - "message-box": "#ffffff", - "mention": "#971368", - "success": "#055d20", - "warning": "#744500", - "error": "#a0111f", - "hover": "rgba(172,182,192,0.2)", - "border-color": "#20252C", - "scrollbar-thumb": "#d5232c", - "scrollbar-track": "transparent", - "primary-background": "#ffffff", - "primary-foreground": "#0E1116", - "primary-header": "#ffffff", - "secondary-background": "#ffffff", - "secondary-foreground": "#0E1116", - "secondary-header": "#ffffff", - "tertiary-background": "#ffffff", - "tertiary-foreground": "#0E1116", - "status-online": "#055d20", - "status-away": "#744500", - "status-busy": "#a0111f", - "status-streaming": "#622cbc", - "status-invisible": "#66707B" - }, - "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" - }, - { - "version": "v1.0.0", - "slug": "purpel", - "name": "Purpel", - "creator": "Lea", - "description": "Sleek purple theme", - "tags": [ - "purple", - "dark" - ], - "variables": { - "accent": "#b18fe9", - "background": "#150d1f", - "foreground": "#d3b5d9", - "block": "var(--secondary-background)", - "message-box": "var(--secondary-background)", - "mention": "rgba(251, 255, 0, 0.06)", - "success": "#a6d189", - "warning": "#ef9f76", - "error": "#e78284", - "hover": "#b18fe920", - "scrollbar-thumb": "#b261bd", - "scrollbar-track": "transparent", - "primary-background": "#150d1f", - "primary-header": "var(--secondary-background)", - "secondary-background": "#21182c", - "secondary-foreground": "#b88fbf", - "secondary-header": "var(--secondary-background)", - "tertiary-background": "#96619f", - "tertiary-foreground": "#a57bad", - "status-online": "#a6d189", - "status-away": "#eebebe", - "status-busy": "#ea999c", - "status-streaming": "#8260e0", - "status-invisible": "#88748b" - }, - "css": "@import url(\"https://cdn.githubraw.com/sussycatgirl/d45b96f7af1632c9268e16bfee0e8fea/raw/7892d51888d3ee341868ddd4b23f040a06be8c5d/rounded.css\");\n\n[class^=\"UserShort__BotBadge\"] {\n background-color: var(--tertiary-background);\n}\n" - }, - { - "version": "1.0.0", - "slug": "rexo-turquoise", - "name": "Rexo's Turquoise Theme", - "creator": "Rexogamer", - "description": "A dark turquoise theme for Revolt.", - "tags": [ - "turquoise", - "seagreen", - "dark" - ], - "variables": { - "accent": "#00b7c3", - "background": "#05191f", - "foreground": "#F6F6F6", - "block": "#082730", - "message-box": "#0c3b46", - "mention": "rgba(251, 255, 90, 0.06)", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#00929c", - "scrollbar-track": "transparent", - "primary-background": "#041e25", - "primary-header": "#082d35", - "secondary-background": "#06252d", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#0d363f", - "tertiary-background": "#082c35", - "tertiary-foreground": "#848484", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5", - "sidebar-sidebar-active": "#202225" - } - }, - { - "version": "0.0.1", - "slug": "rosepine", - "name": "Rosé Pine", - "creator": "ThatOneCalculator", - "description": "soho vibes for revolt. https://rosepinetheme.com/", - "variables": { - "accent": "#eb6f92", - "background": "#191724", - "foreground": "#e0def4", - "block": "#26233a", - "message-box": "#26233a", - "mention": "#f6c17720", - "success": "#9ccfd8", - "warning": "#f6c177", - "error": "#ea9a97", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#eb6f92", - "scrollbar-track": "transparent", - "primary-background": "#1f1d2e", - "primary-header": "#26233a", - "secondary-background": "#26233a", - "secondary-foreground": "#e0def4", - "secondary-header": "#555169", - "tertiary-background": "#6e6a86", - "tertiary-foreground": "#6e6a86", - "status-online": "#9ccfd8", - "status-away": "#f6c177", - "status-busy": "#eb6f92", - "status-streaming": "#c4a7e7", - "status-invisible": "#c4a7e7" - }, - "tags": [ - "dark" - ] - }, - { - "version": "0.0.1", - "slug": "rosepine-dawn", - "name": "Rosé Pine Dawn", - "creator": "ThatOneCalculator", - "description": "soho vibes for revolt, dawn edition. https://rosepinetheme.com/", - "variables": { - "accent": "#b4637a", - "background": "#faf4ed", - "foreground": "#575279", - "block": "#f2e9de", - "message-box": "#f2e9de", - "mention": "#ea9d3420", - "success": "#56949f", - "warning": "#ea9d34", - "error": "#d7827e", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#b4637a", - "scrollbar-track": "transparent", - "primary-background": "#fffaf3", - "primary-header": "#f2e9de", - "secondary-background": "#f2e9de", - "secondary-foreground": "#6e6a86", - "secondary-header": "#9893a5", - "tertiary-background": "#6e6a86", - "tertiary-foreground": "#6e6a86", - "status-online": "#56949f", - "status-away": "#ea9d34", - "status-busy": "#b4637a", - "status-streaming": "#907aa9", - "status-invisible": "#907aa9" - }, - "tags": [ - "light" - ] - }, - { - "version": "0.0.1", - "slug": "rosepine-moon", - "name": "Rosé Pine Moon", - "creator": "ThatOneCalculator", - "description": "soho vibes for revolt, moon edition. https://rosepinetheme.com/", - "variables": { - "accent": "#eb6f92", - "background": "#232136", - "foreground": "#e0def4", - "block": "#393552", - "message-box": "#393552", - "mention": "#f6c17720", - "success": "#9ccfd8", - "warning": "#f6c177", - "error": "#ea9a97", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#eb6f92", - "scrollbar-track": "transparent", - "primary-background": "#1f1d2e", - "primary-header": "#26233a", - "secondary-background": "#26233a", - "secondary-foreground": "#e0def4", - "secondary-header": "#59546d", - "tertiary-background": "#6e6a86", - "tertiary-foreground": "#817c9c", - "status-online": "#9ccfd8", - "status-away": "#f6c177", - "status-busy": "#eb6f92", - "status-streaming": "#c4a7e7", - "status-invisible": "#c4a7e7" - }, - "tags": [ - "dark" - ] - }, - { - "version": "1.2.0", - "slug": "skeuovolt", - "name": "Skeuovolt", - "creator": "Septicity", - "commit": "9d83d7c", - "description": "A semi-skeuomorphic theme, based on/sourced from Marda33's Skeuocord", - "tags": [ - "skeuomorphism", - "skeuomorphic", - "gradient", - "dark", - "skeuocord", - "grey", - "black" - ], - "variables": { - "accent": "#FD6671", - "background": "#191919", - "foreground": "#F6F6F6", - "block": "#2D2D2D", - "message-box": "#c1c1e0", - "mention": "rgba(251, 255, 0, 0.2)", - "success": "#65E572", - "warning": "#DB7F29", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#A8A8A8", - "scrollbar-track": "#212121", - "primary-background": "#242424", - "primary-header": "#363636", - "secondary-background": "#050505", - "secondary-foreground": "#C8C8C8", - "secondary-header": "#323232", - "tertiary-background": "#5E5E5E", - "tertiary-foreground": "#848484", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "/* Avatar and Server Borders */\n\n:root {\n --border-radius-user-icon: 50%;\n --border-radius-server-icon: 50%;\n \n --border-radius-user-icon-active: 50%;\n --border-radius-server-icon-active: 6px;\n \n /* To disable this, replace \"--accent\" with \"--tertiary-background\" */\n --border-active-color: var(--accent);\n}\n\n:root{--border-radius-channel-icon:var(--border-radius-user-icon);--message-box-padding:10px;--scrollbar-thickness:6px;--primary-header-gradient:linear-gradient(to top,var(--background),var(--tertiary-background) 110%);--secondary-header-gradient:linear-gradient(to top,var(--secondary-background)10%,var(--secondary-header));--textbox-gradient:linear-gradient(to top,var(--message-box-accent),var(--secondary-background) 120%);--button-gradient:linear-gradient(transparent,#000A);--sidebar-gradient:linear-gradient(to top,var(--secondary-background)20%,var(--tertiary-background));--chat-gradient:linear-gradient(to top,var(--background),var(--message-box-accent));--shadow-color:#0009;--message-box-accent:rgba(var(--message-box-rgb),0.17);--common-border:1px solid var(--tertiary-background);--common-shadow:inset 0 0 2px 2px var(--shadow-color);--light-shadow:inset 0 0 3px 0 var(--shadow-color);--thin-shadow:inset 0 0 0 1px var(--shadow-color)}::-webkit-scrollbar-thumb{min-height:100px;color:var(--background);border-radius:3px;outline:var(--common-border)}::-webkit-scrollbar-thumb:vertical{background:linear-gradient(to top,var(--background) -60%,var(--scrollbar-thumb),var(--background) 160%)}::-webkit-scrollbar-thumb:horizontal{background:linear-gradient(to left,var(--background) -60%,var(--scrollbar-thumb),var(--background) 160%)}[data-scroll-offset]::-webkit-scrollbar-thumb{outline:var(--common-border)}::-webkit-scrollbar-track{box-shadow:var(--light-shadow)}::-webkit-scrollbar-corner{background:linear-gradient(to bottom right,var(--scrollbar-thumb),var(--background));outline:var(--common-border)}.Header-sc-ujh2d9-0{border-bottom:var(--common-border);border-right:var(--common-border);box-shadow:var(--light-shadow),5px 2px 5px 5px rgba(0,0,0,0.4);background:var(--primary-header-gradient);border-radius:0;text-shadow:1px 2px 2px #000}.Channel__ChannelContent-sc-k5myot-1{background-image:var(--chat-gradient)}.SidebarBase-sc-1dma6vq-0 .SidebarBase__GenericSidebarBase-sc-1dma6vq-1{background-image:var(--textbox-gradient);overflow:auto}.Channel__ChannelMain-sc-k5myot-0.DmIgS .SidebarBase-sc-1dma6vq-0{border-right:var(--common-border);border-left:var(--common-border)}.MemberList__ListCategory-sc-1bwlier-0.fWMCga{height:39px;padding-top:16px}.UserShort__Name-sc-1sbe9n1-1{text-shadow:none;filter:drop-shadow(1px 2px 1px black)}.HomeSidebar__Navbar-sc-1n8qxkj-0{border-bottom:var(--common-border);box-shadow:inset 0 0 0 1px rgba(6,6,6,0.5),0 0 5px 5px rgba(0,0,0,0.4);background:var(--secondary-header-gradient);border-right:3px double var(--tertiary-background);text-shadow:1px 2px 2px #000}.Base-sc-yb16g4-0,.Shadow-sc-yb16g4-1 + .ItemContainer-sc-176t3v5-0{border-right:3px double var(--tertiary-background)}.SidebarBase__GenericSidebarList-sc-1dma6vq-2{border-radius:0;background:var(--textbox-gradient);border-right:3px double var(--tertiary-background);overflow:auto}.ServerSidebar__ServerBase-sc-1lca5oe-0,.Sidebar__Base-sc-1uh6eub-0{background:var(--sidebar-gradient);border-right:3px double var(--tertiary-background);border-radius:0}.ServerSidebar__ServerList-sc-1lca5oe-1{box-shadow:var(--common-shadow);border-top:3px double var(--tertiary-background);overflow:auto}.ServerHeader__ServerBanner-sc-15ursl9-0{box-shadow:var(--common-shadow)}.Shadow-sc-yb16g4-1 + .ItemContainer-sc-176t3v5-0{height:55.88px;padding-bottom:5.6px;padding-top:5.6px}._settings_t7t23_33 ._sidebar_t7t23_39{background:linear-gradient(to top left,transparent 15%,var(--message-box-accent));background-color:var(--secondary-background);border:var(--common-border)}._content_t7t23_40{background:linear-gradient(to bottom right,transparent 15%,var(--message-box-accent));background-color:var(--secondary-background);border-top:var(--common-border);border-right:var(--common-border);border-bottom:var(--common-border)}._user_fwdgs_1 ._preview_fwdgs_107{background:transparent;width:90%}._settings_t7t23_33 ._action_t7t23_224 ._closeButton_t7t23_240{background:var(--secondary-header)}.MessageBox__Base-sc-jul4fa-0,.Shadow-sc-yb16g4-1 + .ItemContainer-sc-176t3v5-0,.FilePreview__Container-sc-znkm6h-0{border-top:var(--common-border);background-image:var(--button-gradient);background-color:var(--secondary-header);box-shadow:inset 0 1px 0 0 var(--shadow-color)}.MessageBox__FileAction-sc-jul4fa-3,.MessageBox__Action-sc-jul4fa-2{margin:3.7px 0 0}.TextAreaAutoSize__Container-sc-1ebfmuf-0,.ckpHTH{border:var(--common-border);background:var(--textbox-gradient);border-radius:var(--border-radius);box-shadow:var(--common-shadow);padding:0 10px;margin:6px 0 7px}.InputBox-sc-13s1218-0{border:var(--common-border);background:var(--textbox-gradient);border-radius:var(--border-radius);box-shadow:inset 0 0 3px 2px var(--shadow-color)}.InputBox-sc-13s1218-0:hover{background:var(--textbox-gradient)}.TextArea-sc-1kh63xd-0{background:transparent;padding:10px 0}.Column-sc-mmjmg6-0.Base-sc-1c6g74-0{border:var(--common-border);margin:0 10px 0 0}.MessageEditor__EditorBase-sc-133d9pc-0 .TextAreaAutoSize__Container-sc-1ebfmuf-0,._settings_t7t23_33 .TextAreaAutoSize__Container-sc-1ebfmuf-0{padding:0}.MessageEditor__EditorBase-sc-133d9pc-0 .TextArea-sc-1kh63xd-0,._settings_t7t23_33 .TextArea-sc-1kh63xd-0{background:transparent;padding:8px}.ReplyBar__Base-sc-d2l7w7-0,.TypingIndicator__Base-sc-1jama4m-0{background:var(--secondary-header-gradient);border-top:var(--common-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);padding:0}.jtuvHh .actions{margin-right:8px}.TypingIndicator__Base-sc-1jama4m-0 > div,.ggHcXn > div,.hitXXs > div{background-image:var(--button-gradient);background-color:var(--primary-background);border-top:var(--common-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);width:100%}.gILwrq > div,.NPPEn > div{background-image:var(--button-gradient);border-bottom:var(--common-border);border-bottom-left-radius:var(--border-radius);border-bottom-right-radius:var(--border-radius)}.ReplyBar__Base-sc-d2l7w7-0 .MessageReply__ReplyBase-sc-utzx7-0{height:100%;margin:0 8px}.MessageReply__ReplyBase-sc-utzx7-0 .content{align-self:center;height:100%;padding:0}.MessageReply__ReplyBase-sc-utzx7-0 .message{border:var(--common-border);border-right:var(--common-border);border-radius:3px;background:rgba(var(--foreground-rgb),0.075);font-size:.8em;padding:3px}.cZEhIO,.fhGxEk,.jRMJXP,.sc-dIUeWJ.kizbFb{border:1px solid var(--mention);border-radius:var(--border-radius);background:linear-gradient(var(--mention),transparent 130%)}.cZEhIO:hover,.fhGxEk:hover,.jRMJXP:hover,.sc-dIUeWJ.kizbFb:hover{border:1px solid var(--mention);border-radius:var(--border-radius);background:linear-gradient(var(--mention) -100%,transparent 130%)}.VoiceHeader__VoiceBase-sc-mvbohh-0{background:var(--secondary-header-gradient);border-bottom:var(--common-border);border-bottom-left-radius:var(--border-radius)}.Base-sc-j9ukbi-0,.TipBase-sc-upm053-0,.VoiceHeader__VoiceBase-sc-mvbohh-0 .status,.ComboBox-sc-f94r78-0,.jfwTbF,.bCPWex ._entry_fwdgs_300,._title_dxa3n_54,.jMjzeh,.jbwUqj,._friend_sd6wo_33,.EmbedInvite__EmbedInviteBase-sc-154n8c6-0,._user_fwdgs_1 .Button-sc-wdolxy-0,.Button-sc-wdolxy-0.bQkbYV,.Button-sc-wdolxy-0,.preact-context-menu .context-menu,._entry_kup3j_140,.TipBase-sc-upm053-0,._languages_fwdgs_383 ._list_fwdgs_383 ._entry_fwdgs_300,.Button-sc-wdolxy-0{background-image:var(--button-gradient)!important;border:var(--common-border);border-radius:15px;padding:8px;box-shadow:var(--thin-shadow);text-shadow:1px 1px 2px #000}.Base-sc-j9ukbi-0:hover{background-color:var(--background)}.sc-jSgvzq{background-image:var(--button-gradient);background-color:var(--secondary-header);box-shadow:var(--thin-shadow)}.sc-jSgvzq.cTcKPP{border:var(--common-border)}._item_1avxi_1,._item_1avxi_1:hover,._item_1avxi_1[data-active=\"true\"]{border-radius:var(--border-radius);background-image:linear-gradient(var(--foreground) -100%,transparent 50%);background-size:1px 208%;transition:250ms}._item_1avxi_1{background-position:50% 99%;border-left:1px solid transparent;border-right:1px solid transparent;text-shadow:1px 1px 2px #000}._item_1avxi_1:hover{background-position:50% 40%;border-left:var(--common-border);border-right:var(--common-border)}._item_1avxi_1[data-active=\"true\"]{background-position:top;border-left:var(--common-border);border-right:var(--common-border);box-shadow:var(--light-shadow)}.BottomNavigation__Base-sc-15obpw5-0{border-top:3px double var(--tertiary-background)}.BottomNavigation__Button-sc-15obpw5-2{border-radius:4px;box-shadow:var(--light-shadow);background:var(--button-gradient)}.BottomNavigation__Button-sc-15obpw5-2.brHoXv{border-left:var(--common-border);border-right:var(--common-border);background-color:#666}.BottomNavigation__Button-sc-15obpw5-2.bkKKgS{border-left:1px solid #1117;border-right:1px solid #1117;background-color:var(--primary-header)}.BottomNavigation__Button-sc-15obpw5-2:hover:active{background-color:#222}.Column-sc-mmjmg6-0.Controls-sc-1c6g74-1{border-bottom:var(--common-border);box-shadow:inset 0 0 2px 1px var(--shadow-color);background:var(--primary-header-gradient)}.Base-sc-1m0owfd-0{border-top:var(--common-border);box-shadow:inset 0 0 2px 1px var(--shadow-color);background:var(--textbox-gradient)}.Groups-sc-1c6g74-3{background:var(--secondary-header-gradient);border-radius:0;border-left:var(--common-border);overflow-x:hidden}.CategoryBar-sc-1c6g74-6,.MemberList__ListCategory-sc-1bwlier-0{border-top:var(--common-border);border-bottom:var(--common-border);background:var(--button-gradient)}._header_kup3j_8,.Settings__AccountHeader-sc-1gunfj7-0 .account{border-left:1px solid var(--foreground);border-top:1px solid var(--foreground);border-right:1px solid var(--foreground);border-bottom:var(--common-border);box-shadow:inset 0 0 15px 2px var(--shadow-color);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius)}._content_kup3j_90{border-left:1px solid var(--foreground);border-bottom:1px solid var(--foreground);border-right:1px solid var(--foreground);box-shadow:inset 0 0 15px 2px var(--shadow-color)}.Settings__AccountHeader-sc-1gunfj7-0 .statusChanger{border-left:1px solid var(--foreground);border-right:1px solid var(--foreground);border-bottom:1px solid var(--foreground);border-bottom-left-radius:var(--border-radius);border-bottom-right-radius:var(--border-radius);background:var(--textbox-gradient)}.Container-sc-1d91xkm-1,.create_group{border:var(--common-border);background:var(--background);background-image:var(--chat-gradient)}.Container-sc-1d91xkm-1.bKfOaB{background:none;border:none}.sc-jrAFXE.eiGSNL{border-radius:var(--border-radius);border:var(--common-border)}.Title-sc-1d91xkm-2{border-bottom:var(--common-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);box-shadow:var(--thin-shadow);background:var(--primary-header-gradient)}.Content-sc-1d91xkm-3{padding:1rem;background:var(--background)}.Actions-sc-1d91xkm-4{background:var(--secondary-header-gradient);border-top:var(--common-border)}.Container-sc-1d91xkm-1[type=\"user_profile\"],.jKeKvP summary,.Content-sc-1d91xkm-3{background:transparent;border:unset}.Content-sc-1d91xkm-3[type=\"user_picker\"]{padding:0;background:var(--background)}.sc-kstqJO.iDLnDV{padding:6px}.bCPWex{padding:4px 10px}.preact-context-menu .context-menu > span:not([data-disabled=true]){border-left:1px solid transparent;border-right:1px solid transparent}.preact-context-menu .context-menu > span:not([data-disabled=true]):hover{background:#0003;border-left:var(--common-border);border-right:var(--common-border)}.preact-context-menu .context-menu > span > span:hover{background:none}._attachment_197qa_1._text_197qa_33,._container_197qa_64{padding:0;border:var(--common-border);box-shadow:0 0 4px 1px #000}._attachment_197qa_1._text_197qa_33 ._textContent_197qa_39{border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);box-shadow:var(--common-shadow)}._attachment_197qa_1._audio_197qa_13{background-image:var(--button-gradient);background-color:var(--primary-header);border:var(--common-border);border-radius:15px;padding:8px;box-shadow:var(--thin-shadow),0 0 4px 1px #000}._embed_dxa3n_1._website_dxa3n_12{border-top:var(--common-border);border-bottom:var(--common-border);border-right:var(--common-border);box-shadow:var(--thin-shadow),0 0 4px 1px #000}._embed_dxa3n_1._website_dxa3n_12 iframe{border:var(--common-border);box-shadow:0 0 4px 1px #000}.sc-bkzYnD{box-shadow:var(--common-shadow),0 0 4px 1px #000;border:var(--common-border);overflow:auto}._actions_iu4oa_1,._embed_dxa3n_1._website_dxa3n_12{background-image:var(--button-gradient);background-color:var(--primary-header);text-shadow:1px 1px 2px #000}._attachment_197qa_1._text_197qa_33 ._actions_iu4oa_1,._actions_iu4oa_1._imageAction_iu4oa_1{border-top:var(--common-border);border-bottom-left-radius:var(--border-radius);border-bottom-right-radius:var(--border-radius);box-shadow:var(--thin-shadow)}._container_197qa_64 ._actions_iu4oa_1{border-bottom:var(--common-border);box-shadow:var(--thin-shadow);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius)}._attachment_197qa_1._audio_197qa_13 ._actions_iu4oa_1{border:none;background:transparent}._attachment_197qa_1._file_197qa_25 ._actions_iu4oa_1{border:var(--common-border);box-shadow:var(--thin-shadow)}.UserIcon__VoiceIndicator-sc-y109su-0{background-image:var(--button-gradient);box-shadow:inset 0 0 0 .6px #000C}a:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0,.ItemContainer-sc-176t3v5-0:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0,.SidebarBase__GenericSidebarList-sc-1dma6vq-2 ._item_1avxi_1[data-active=\"true\"] img{border-color:var(--border-active-color)}._item_1avxi_1:hover img,._item_1avxi_1[data-active=\"true\"] img,.IconBase-sc-igncj4-0.bTjVNf.avatar img:hover{border-radius:var(--border-radius-user-icon-active);transition:100ms;transition-delay:70ms}.sc-iBPTik,.sc-iBPTik:hover{border:var(--common-border);background:linear-gradient(to bottom,var(--background) 10%,var(--secondary-header));box-shadow:var(--thin-shadow);box-shadow:0 0 5px 0 var(--shadow-color)}.sc-fubCzh:hover,.preact-context-menu .context-menu span:hover{background:#0004}.FallbackBase-sc-3bwws8-1,._friend_sd6wo_33 .IconButton-sc-166lqkp-0,.EmbedInvite__EmbedInviteBase-sc-154n8c6-0 .IconBase__ImageIconBase-sc-igncj4-1,._settings_t7t23_33 ._action_t7t23_224 ._closeButton_t7t23_240,._image_1m2vo_5,._content_kup3j_90 img:not(.emoji){border:1px solid var(--tertiary-foreground);padding:1px;border-radius:50%;box-shadow:var(--thin-shadow);background-image:var(--button-gradient);background-color:var(--secondary-header)}.SidebarBase__GenericSidebarList-sc-1dma6vq-2 ._item_1avxi_1 img,._profile_kup3j_21 img,.bTjVNf img,.Image-sc-3bwws8-0{border:1px solid var(--tertiary-foreground);padding:1px;box-shadow:var(--thin-shadow);background-image:var(--button-gradient);background-color:var(--secondary-header);transition:100ms}.kPeClm{border-radius:var(--border-radius-server-icon)}.SwooshWrapper-sc-176t3v5-1{height:54px;width:56px;border-top:var(--common-border);border-bottom:var(--common-border);border-left:var(--common-border);background-image:var(--button-gradient);background-color:var(--primary-header);box-shadow:var(--thin-shadow);border-top-left-radius:25%;border-bottom-left-radius:25%;margin:26px 0 0;transition:position 150ms;filter:drop-shadow(2px 2px 2px #000B)}.FilePreview__EmptyEntry-sc-znkm6h-5.icon,.FilePreview__PreviewBox-sc-znkm6h-6 .icon{border-radius:var(--border-radius)}.FilePreview__EmptyEntry-sc-znkm6h-5{border:var(--common-border);border-radius:50%;box-shadow:var(--thin-shadow);background:var(--button-gradient)}.LineDivider-sc-135498f-0{background:linear-gradient(to left,transparent,rgba(var(--foreground-rgb),0.7),transparent)}.sc-pGacB{background:linear-gradient(transparent,rgba(var(--foreground-rgb),0.7),transparent)}.Base-sc-1kllx7r-0{padding:10px}.StyledIconBase-ea9ulj-0 path,.StyledIconBase-ea9ulj-0 circle{fill:var(--foreground)}._friend_sd6wo_33{margin:3px}.Base-sc-hcp9jm-0{cursor:pointer}._sessions_fwdgs_285 ._entry_fwdgs_300,._sessions_fwdgs_285 ._entry_fwdgs_300[data-active=true]{background-image:linear-gradient(transparent,#0008);border:var(--common-border);box-shadow:var(--thin-shadow)}.checkmark{border:2px solid var(--foreground);box-shadow:inset 0 0 0 2px var(--shadow-color);background:var(--background)}.tippy-box{background-image:var(--button-gradient);background-color:var(--primary-header);opacity:.95;border:var(--common-border);box-shadow:var(--thin-shadow),0 0 4px 1px #000;left:-6px}.checkmark.gfBZnu{background-image:linear-gradient(transparent -20%,var(--accent))}:active > svg,:active > ._avatar_1avxi_56,._item_1avxi_1:active,._content_kup3j_90 a:active img{transform:translateY(2px);filter:brightness(0.85) drop-shadow(2px 2px 2px #000B)}._item_1avxi_1 ._avatar_1avxi_56,._item_1avxi_1 svg{transform:none}.hwmymO .actions{margin-right:7px}.hwmymO .actions div{border:var(--common-border);border-radius:3px;background-image:var(--button-gradient);background-color:var(--primary-header);margin:10px 3px 10px 0;height:calc(var(--titlebar-height) - 8px)}.hwmymO .actions div:hover,.hwmymO .actions div.error:hover{background-image:var(--button-gradient)}.RevoltApp__AppContainer-sc-1613ew5-0{background:var(--textbox-gradient);border:var(--common-border)}::-webkit-scrollbar-track:vertical,::-webkit-scrollbar-track:horizontal{outline:1px solid rgba(var(--tertiary-background-rgb),0.7)}.SidebarBase-sc-1dma6vq-0,.sc-hBEYId .Base-sc-1c2l8gq-0,.FilePreview__PreviewBox-sc-znkm6h-6,.RevoltApp__Routes-sc-1613ew5-2{background:transparent}.Base-sc-yb16g4-0 .list,.FilePreview__Carousel-sc-znkm6h-1{overflow-x:hidden}.EmbedInvite__EmbedInviteBase-sc-154n8c6-0,._entry_kup3j_140{background-color:var(--primary-header)}.eiGSNL img,.Titlebar__TitlebarBase-sc-1ykozpe-0.hwmymO{border-bottom:var(--common-border);background:var(--secondary-header-gradient)}.sc-bkzYnD code,.iIKZoW time{background:unset}.Image-sc-3bwws8-0,.FallbackBase-sc-3bwws8-1,._item_1avxi_1 div{transition:100ms}.Image-sc-3bwws8-0:hover,.FallbackBase-sc-3bwws8-1:hover,a:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0,.ItemContainer-sc-176t3v5-0:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0{border-radius:var(--border-radius-server-icon-active);transition:100ms;transition-delay:70ms}.SwooshWrapper-sc-176t3v5-1 svg,.tippy-arrow{display:none}svg,._content_kup3j_90 a img,._avatar_1avxi_56{filter:drop-shadow(2px 2px 2px #000B);transition:100ms}svg foreignObject svg,:active > svg foreignObject svg,._session_fwdgs_285 svg,.sc-dIUeWJ.kizbFb svg{filter:none;transform:none}\n" - }, - { - "version": "v1.0.1", - "slug": "social-classic", - "name": "Social Classic", - "creator": "DragShot", - "description": "A mixture of light, dark and a primary color of your preference, inspired by social websites from the 2010s.", - "tags": [ - "social", - "classic", - "light", - "2010" - ], - "variables": { - "accent": "#3B5998", - "background": "#F6F6F6", - "foreground": "#101010", - "block": "#414141", - "message-box": "#E2E6F0", - "mention": "#FFF5CE", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.2)", - "scrollbar-thumb": "#2F477A", - "scrollbar-track": "transparent", - "primary-background": "#FFFFFF", - "primary-header": "#3B5998", - "secondary-background": "#F1F1F1", - "secondary-foreground": "#1f1f1f", - "secondary-header": "#F1F1F1", - "tertiary-background": "#4D4D4D", - "tertiary-foreground": "#3A3A3A", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - }, - "css": "/* Constants */\r\n\r\n:root{\r\n /* --background: #F6F6F6;\r\n --foreground: #101010; */\r\n --primary-header: #F1F1F1;\r\n --serverbar-background: #303440;\r\n --serverbar-foreground: #F7F7F7;\r\n --serverbar-border: #1C202B;\r\n --serverbar-highlight: #505460;\r\n --scrollbar-color: rgba(0, 0, 0, 0.25);\r\n --accent-10: rgba(var(--accent-rgb), 0.10);\r\n --accent-15: rgba(var(--accent-rgb), 0.15);\r\n --accent-25: rgba(var(--accent-rgb), 0.25);\r\n --accent-50: rgba(var(--accent-rgb), 0.50);\r\n --accent-60: rgba(var(--accent-rgb), 0.60);\r\n --accent-75: rgba(var(--accent-rgb), 0.75);\r\n --form-border: #CCC;\r\n --button-success-border: #3B6E22;\r\n --button-success-background: #67A54B;\r\n --button-success-foreground: #F7F7F7;\r\n --white-pixel: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=);\r\n --error-darker: #CD2225;\r\n --mention: #FFF5CE;\r\n --mention-darker: #F6ECC2;\r\n --border-radius-user-icon: 6px;\r\n}\r\n\r\n/* Server Sidebar */\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] {\r\n background: var(--serverbar-background);\r\n border: solid 1px var(--serverbar-border);\r\n width: 58px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] [class^=\"ItemContainer-sc\"] div[class^=\"LineDivider-sc\"] {\r\n background: var(--serverbar-border);\r\n border-top: solid 1px #282B38;\r\n border-bottom: solid 1px #393D48;\r\n margin: 5px auto;\r\n width: 100%;\r\n height: 3px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] > [class^=\"Shadow-sc\"] > div {\r\n background: linear-gradient(to bottom, transparent, var(--serverbar-background));\r\n margin-top: -24px;\r\n height: 24px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] [class^=\"ItemContainer-sc\"] [class^=\"FallbackBase-sc\"] {\r\n background: var(--serverbar-border);\r\n color: var(--serverbar-foreground);\r\n /*padding-right: 8px;*/\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] [class^=\"ItemContainer-sc\"] [class^=\"FallbackBase-sc\"] > svg > * {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[class^=\"SwooshWrapper-sc\"] {\r\n --sidebar-active: var(--serverbar-highlight);\r\n top: -8px;\r\n overflow-y: hidden;\r\n /* z-index: 0; */\r\n}\r\n\r\n[class^=\"SwooshWrapper-sc-\"] > svg {\r\n margin-top: -24px;\r\n}\r\n\r\n[class^=\"SwooshWrapper-sc-\"] > svg > path:not(:first-child), \r\n[class^=\"SwooshWrapper-sc-\"] > svg > rect {\r\n display: none;\r\n}\r\n\r\n[class^=\"SwooshWrapper-sc-\"] > svg > path:first-child {\r\n fill: var(--accent);\r\n /* Fix uneven bubble around the server icon - by lo-kiss@github.com */\r\n transform: translateX(.8px) scale(0.95);\r\n transform-origin: center;\r\n}\r\n\r\ndiv[class^=\"ItemContainer-sc\"] > div:nth-child(1),\r\ndiv[class^=\"ItemContainer-sc\"] > a > div:nth-child(1) {\r\n position: relative;\r\n z-index: 0;\r\n}\r\n\r\ndiv[class^=\"ItemContainer-sc\"] > div:nth-child(2),\r\ndiv[class^=\"ItemContainer-sc\"] > a > div:nth-child(2) {\r\n position: relative;\r\n z-index: 1;\r\n}\r\n\r\n/* Tooltips */\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box {\r\n background: var(--serverbar-border);\r\n color: var(--serverbar-foreground);\r\n border-radius: 6px;\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box * {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box > .tippy-content {\r\n background: var(--serverbar-border);\r\n color: var(--serverbar-foreground);\r\n border-radius: 6px;\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='top'] > .tippy-arrow::before {\r\n border-top-color: var(--serverbar-border);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {\r\n border-bottom-color: var(--serverbar-border);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='left'] > .tippy-arrow::before {\r\n border-left-color: var(--serverbar-border);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='right'] > .tippy-arrow::before {\r\n border-right-color: var(--serverbar-border);\r\n}\r\n\r\n/* Server Header */\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase-sc\"] {\r\n border-radius: 0px;\r\n}\r\n\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"],\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > div > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"] {\r\n margin-top: calc(var(--header-height) + 24px);\r\n}\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"] > [class^=\"HomeSidebar__Navbar-sc\"],\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > div > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"] > [class^=\"HomeSidebar__Navbar-sc\"] {\r\n background-image: url(\"/assets/wide.svg\");\r\n margin-top: -40px;\r\n padding-top: 40px;\r\n height: 88px;\r\n background-position-x: 12px;\r\n background-repeat: no-repeat;\r\n background-size: 50%;\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase-sc\"] > [class^=\"ServerHeader__ServerBanner-sc\"] {\r\n background-color: var(--accent);\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"].dKFhRZ > .container {\r\n\tbackground: linear-gradient(0deg, var(--serverbar-background), transparent);\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"].jXEHHp > .container {\r\n\tmargin-top: 24px;\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"] > .container > a.title,\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"] > .container a[class^=\"IconButton-sc\"] {\r\n color: var(--serverbar-foreground);\r\n /* text-shadow: -1px 0px black, 0px 1px black,\r\n 1px 0px black, 0px -1px black; */\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase-sc\"] > div[class^=\"ServerSidebar__ServerList-sc\"] {\r\n border-top: solid 5px var(--accent-60);\r\n}\r\n\r\n/* Server Channels */\r\n\r\n[class^=\"ServerSidebar__ServerList\"] {\r\n padding-right: 0px;\r\n -ms-overflow-style: none; /* Internet Explorer 10+ */\r\n scrollbar-width: none; /* Firefox */\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"]::-webkit-scrollbar {\r\n display: none; /* Safari and Chrome */\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"],\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"] * {\r\n transition: none;\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"] > [class^=\"_name\"] {\r\n font-weight: normal;\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-alert=\"true\"]:not(:hover) > div {\r\n color: var(--accent);\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-alert=\"true\"] > [class^=\"_button\"] > div {\r\n background: var(--accent);\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-alert=\"true\"]:hover > [class^=\"_button\"] > div {\r\n background: var(--serverbar-foreground);\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"]:hover,\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-active=\"true\"] {\r\n background: var(--accent-75);\r\n color: var(--serverbar-foreground);\r\n border-top-right-radius: 0px;\r\n border-bottom-right-radius: 0px;\r\n}\r\n\r\n/* Content Header */\r\n\r\ndiv[class^=\"RevoltApp__Routes-sc\"],\r\ndiv[class^=\"Header-sc\"] {\r\n border-radius: 0px;\r\n}\r\n\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"],\r\ndiv[class^=\"RevoltApp__Routes-sc\"] [class^=\"Home__Overlay-sc\"] [class^=\"Header-sc\"] {\r\n background: var(--accent);\r\n}\r\n\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"] > div,\r\ndiv[class^=\"RevoltApp__Routes-sc\"] [class^=\"Home__Overlay-sc\"] [class^=\"Header-sc\"],\r\ndiv[class^=\"RevoltApp__Routes-sc\"] [class^=\"Home__Overlay-sc\"] [class^=\"Header-sc\"] > div,\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"] > [class^=\"HeaderActions__Container\"] > a,\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"] a[class^=\"IconButton-sc\"],\r\ndiv[class^=\"ChannelHeader__Info\"] > .desc {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[class^=\"ChannelHeader__Info\"] > .desc a {\r\n color: var(--serverbar-foreground);\r\n text-decoration: underline;\r\n}\r\n\r\ndiv[class^=\"ChannelHeader__Info\"] > .divider {\r\n background: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[class^=\"Home__Overlay-sc\"] > .content h3 > img {\r\n filter: brightness(0%);\r\n}\r\n\r\n/* Chat Area */\r\n\r\n[class^=\"MessageArea__Area-sc\"]/*,\r\n[class^=\"_home_\"] .content > [class^=\"_homeScreen_\"],\r\n[class^=\"RevoltApp__Routes\"] > div[data-scroll-offset=\"true\"] > [class^=\"_list_\"]*/ {\r\n border: solid 1px rgba(0, 0, 0, 0.5);\r\n padding-top: 0px;\r\n margin-top: var(--header-height);\r\n}\r\n\r\n[class^=\"MessageBase-sc\"]:hover {\r\n background: var(--accent-10);\r\n}\r\n\r\n[class^=\"TypingIndicator__Base\"] {\r\n left: 1px;\r\n}\r\n\r\n[class^=\"MessageBox__Blocked-sc\"] > [class^=\"MessageBox__Action-sc\"],\r\n[class^=\"MessageBox__Blocked-sc\"] > .text {\r\n margin-left: 12px;\r\n}\r\n\r\n[class^=\"MessageArea__Area-sc\"] div[class^=\"Base-sc\"] { /* Day/NEW message separators */\r\n height: auto;\r\n margin: 8px 0px -4px 2px;\r\n border-top: solid 1px var(--accent);\r\n background: var(--accent-15);\r\n padding: 4px 4px 6px 4px;\r\n}\r\n\r\n[class^=\"MessageArea__Area-sc\"] div[class^=\"Base-sc\"] > time {\r\n margin: 0px;\r\n background: unset;\r\n color: var(--accent);\r\n display: block;\r\n}\r\n\r\n[class^=\"MessageArea__Area-sc\"] div[class^=\"Base-sc\"] > [class^=\"Unread-sc\"] {\r\n padding: 4px 6px;\r\n border-radius: 0px;\r\n margin: -5px 4px -6px -4px;\r\n}\r\n\r\n.cYBcvy {\r\n --emoji-size: 3.428em; /* 48px */\r\n}\r\n\r\n.fDfxAc { /* Reactions */\r\n background: var(--accent-10);\r\n}\r\n\r\n.fDfxAc:hover {\r\n filter: none;\r\n background: var(--accent-25)\r\n}\r\n\r\n.cZEhIO:not(:hover), .fhGxEk:not(:hover) { /* Mentions */\r\n /* border-bottom: solid 1px var(--mention-darker);*/\r\n box-shadow: inset 0px -1px 0px 0px var(--mention-darker);\r\n}\r\n\r\n/* Embeds */\r\n\r\n[class^=\"AutoComplete__Base-sc\"] > div {\r\n border: solid 1px rgba(0, 0, 0, 0.5);\r\n border-bottom: none;\r\n}\r\n\r\n[class^=\"ReplyBar__Base-sc\"],\r\n[class^=\"MessageBox__Base-sc\"] {\r\n border-left: solid 1px rgba(0, 0, 0, 0.5);\r\n border-right: solid 1px rgba(0, 0, 0, 0.5);\r\n}\r\n\r\n[class^=\"ReplyBar__Base-sc\"] {\r\n background: var(--accent-15);\r\n}\r\n\r\n[class^=\"MessageBox__Base-sc\"] {\r\n background: linear-gradient(to right, var(--accent-15), var(--accent-15)), var(--white-pixel);\r\n}\r\n\r\n/* Unread notification */\r\n\r\n[class^=\"JumpToBottom__Bar-sc\"] > div,\r\n[class^=\"JumpToBottom__Bar-sc\"] > div:hover {\r\n background: linear-gradient(to right, var(--accent-15), var(--accent-15)), var(--white-pixel);\r\n border: solid 1px var(--accent);\r\n color: var(--accent);\r\n}\r\n\r\n/* Right bar */\r\n\r\ndiv[class^=\"SidebarBase-sc\"] {\r\n background: var(--accent);\r\n padding-right: 0px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase__GenericSidebarBase-sc\"] {\r\n border-top: solid 5px var(--accent-25);\r\n padding-top: 0px;\r\n margin-top: var(--header-height);\r\n margin-right: 0px;\r\n}\r\n\r\n/* BottonNav */\r\n\r\ndiv[class^=\"BottomNavigation__Base-sc\"] {\r\n /*background: var(--background);*/\r\n background: linear-gradient(to right, var(--accent-25), var(--accent-25)), var(--white-pixel);\r\n border-top: solid 2px var(--accent);\r\n}\r\n\r\n/*div[class^=\"BottomNavigation__Base-sc\"] > div[class^=\"BottomNavigation__Navbar-sc\"] {\r\n background: var(--accent-25);\r\n}*/\r\n\r\n/*div[class^=\"BottomNavigation__Base-sc\"] div[class^=\"BottomNavigation__Button-sc\"] > div[class^=\"IconButton-sc\"] {*/\r\n.bkKKgS * {\r\n color: var(--accent) !important;\r\n}\r\n\r\n.bkKKgS > a > a > svg {\r\n text-shadow: 0px 0px 2px var(--serverbar-foreground);\r\n}\r\n\r\n.brHoXv {\r\n background: var(--accent);\r\n}\r\n\r\n.brHoXv * {\r\n color: var(--serverbar-foreground) !important;\r\n}\r\n\r\n/* Floating action bar */\r\n\r\n[class^=\"MessageBase__MessageContent-sc\"] > .sc-iBPTik {\r\n background: var(--accent);\r\n border: solid 1px var(--scrollbar-thumb);\r\n}\r\n\r\n[class^=\"MessageBase__MessageContent-sc\"] > .sc-iBPTik .sc-fubCzh:hover {\r\n background: var(--scrollbar-thumb);\r\n}\r\n\r\n[class^=\"MessageBase__MessageContent-sc\"] > .sc-iBPTik div {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\n/* Context menu */\r\n\r\n.preact-context-menu > .context-menu > span:not([data-disabled=\"true\"]):hover,\r\n.preact-context-menu > .context-menu > span:not([data-disabled=\"true\"]):hover > .tip {\r\n background-color: var(--accent);\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\n.preact-context-menu > .context-menu > span:not([data-disabled=\"true\"]):hover > span[style^=\"color:var(--error)\"] {\r\n color: var(--serverbar-foreground) !important;\r\n background: var(--error);\r\n width: 100%;\r\n margin: -6px -8px;\r\n padding: 6px 8px;\r\n border-radius: calc(var(--border-radius) / 2);\r\n box-sizing: content-box;\r\n}\r\n\r\n/* Users/DMs sidebar */\r\n\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[data-test-id=\"virtuoso-item-list\"] div[class^=\"_item_\"]:hover,\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"]:hover,\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] {\r\n background: var(--accent-15);\r\n}\r\n\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[data-test-id=\"virtuoso-item-list\"] div[class^=\"_item_\"]:hover > div[class^=\"_name_\"] [class^=\"UserShort__Name-sc\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"]:hover > div[class^=\"_content_\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] > div[class^=\"_content_\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"]:hover > div[class^=\"_name_\"] [class^=\"UserShort__Name-sc\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] > div[class^=\"_name_\"] [class^=\"UserShort__Name-sc\"] {\r\n color: var(--accent);\r\n}\r\n\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] {\r\n border-bottom: solid 1px var(--accent);\r\n}\r\n\r\n/* User Profile */\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] {\r\n background: var(--accent);\r\n padding-bottom: 0px !important;\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] * {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_profile_\"] + div:not([class]) {\r\n background-color: rgba(0, 0, 0, 0.5) !important;\r\n margin-top: -5px;\r\n margin-bottom: 15px;\r\n /*box-sizing: border-box;\r\n display: block;*/\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_tabs_\"] > div,\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_tabs_\"] > div:hover {\r\n border-bottom: 0px solid transparent;\r\n text-align: center;\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_tabs_\"] > div[data-active=\"true\"] {\r\n background: linear-gradient(to right, var(--accent-25), var(--accent-25)), var(--white-pixel);\r\n color: var(--accent);\r\n border: 1px solid var(--accent);\r\n border-bottom: 0px solid transparent;\r\n border-top-left-radius: 6px;\r\n border-top-right-radius: 6px;\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_content_\"] [class^=\"_entry_\"]:hover {\r\n background: var(--accent-15);\r\n color: var(--accent);\r\n}\r\n\r\n/* Settings */\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] {\r\n background: var(--accent);\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"_sidebar_\"] [class^=\"_container_\"] {\r\n background: var(--accent-15);\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Header-sc\"],\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Header-sc\"] > a {\r\n background: var(--accent);\r\n color: white;\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Settings__AccountHeader-sc\"] {\r\n border: solid 1px rgba(0, 0, 0, 0.25);\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Settings__AccountHeader-sc\"] > .statusChanger {\r\n --secondary-foreground: var(--accent);\r\n color: var(--accent);\r\n background: white;\r\n border-top: solid 1px rgba(0, 0, 0, 0.25);\r\n}\r\n\r\n[class^=\"_settings_\"]:not([data-mobile=\"true\"]) [class^=\"_sidebar_\"] * {\r\n color: white;\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"],\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"] * {\r\n transition: none;\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"]:hover,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][data-active=\"true\"] {\r\n background: linear-gradient(to right, var(--accent-25), var(--accent-25)), var(--white-pixel);\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"]:hover *,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][data-active=\"true\"] * {\r\n color: var(--accent);\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_donate_\"]:hover,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_donate_\"]:hover * {\r\n background: #daa520;\r\n color: white;\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_logOut_\"]:hover,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_logOut_\"]:hover * {\r\n background: var(--error);\r\n color: white;\r\n}\r\n\r\n/* Text fields */\r\n\r\ndiv[class^=\"MessageBox__Base-sc\"] div[class^=\"TextAreaAutoSize__Container-sc\"] > textarea {\r\n background: white;\r\n border: solid 1px var(--accent);\r\n border-radius: 4px;\r\n /*;padding: 11px 7px 11px 7px;\r\n margin: 3px 7px 3px 0px;*/\r\n padding: 12px 7px 12px 7px;\r\n margin: 0px 7px 0px 0px;\r\n max-height: 120px;\r\n}\r\n\r\ndiv[class^=\"MessageBox__Base-sc\"] div[class^=\"TextAreaAutoSize__Container-sc\"] > textarea:not(:focus):placeholder-shown {\r\n border: solid 1px var(--form-border);\r\n /*border-bottom: solid 2px var(--accent);*/\r\n padding: 7px 7px 7px 7px;\r\n margin: 5px 7px 5px 0px;\r\n height: 38px !important;\r\n}\r\n\r\ninput[class^=\"InputBox-sc\"],\r\nselect[class^=\"ComboBox-sc\"] {\r\n background: white;\r\n border: solid 1px var(--form-border);\r\n border-radius: 4px;\r\n}\r\n\r\ndiv[class^=\"MessageBox__Base-sc\"] div[class^=\"TextAreaAutoSize__Container-sc\"] > textarea:not(:focus):placeholder-shown,\r\ninput[class^=\"InputBox-sc\"]:focus,\r\nselect[class^=\"ComboBox-sc\"]:focus {\r\n border-bottom: solid 1px var(--accent);\r\n -webkit-box-shadow: inset 0px -1px 0px 0px var(--accent);\r\n -moz-box-shadow: inset 0px -1px 0px 0px var(--accent);\r\n box-shadow: inset 0px -1px 0px 0px var(--accent);\r\n}\r\n\r\ninput[class^=\"InputBox-sc\"]:hover,\r\nselect[class^=\"ComboBox-sc\"]:hover {\r\n background: white;\r\n}\r\n\r\n/* Buttons */\r\n\r\nbutton[class^=\"Button-sc\"]:not(.jMjzeh) { /* Default */\r\n border: solid 1px var(--form-border);\r\n box-shadow: inset 0px 1px 0px 0px hsla(0deg, 0%, 100%, 0.25);\r\n padding: 1px 11px 1px 11px !important;\r\n border-radius: 4px;\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].uZBqX { /* Primary */\r\n border-color: var(--scrollbar-thumb);\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].claKep {\r\n --foreground: white;\r\n background: var(--accent);\r\n border-color: var(--scrollbar-thumb);\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].claKep:hover {\r\n filter: brightness(1.2);\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].hWskOV { /* Danger */\r\n border-color: var(--error-darker);\r\n}\r\n\r\n/* Invite links */\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] {\r\n background: var(--accent-15);\r\n border: solid 1px var(--accent-25);\r\n}\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"EmbedInvite__EmbedInviteName-sc\"] {\r\n color: var(--scrollbar-thumb);\r\n}\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"EmbedInvite__EmbedInviteDetails-sc\"] {}\r\n\r\n@media(min-width: 1024px) {\r\n /*[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"Button-sc\"].jbwUqj*/\r\n [class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"EmbedInvite__EmbedInviteDetails-sc\"] {\r\n /*margin-left: 6px;*/\r\n padding-right: 6px;\r\n }\r\n}\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"Button-sc\"].jbwUqj { /* Invite */\r\n border-color: var(--button-success-border);\r\n background: var(--button-success-background);\r\n color: var(--button-success-foreground);\r\n padding-top: 0px !important;\r\n z-index: auto;\r\n}\r\n\r\n/* Scrollbar */\r\n\r\n::-webkit-scrollbar {\r\n width: var(--scrollbar-thickness);\r\n height: var(--scrollbar-thickness);\r\n}\r\n\r\n::-webkit-scrollbar-track {\r\n background: var(--scrollbar-track);\r\n}\r\n\r\n::-webkit-scrollbar-thumb {\r\n min-width: 30px;\r\n min-height: 30px;\r\n background-clip: content-box;\r\n background: var(--scrollbar-color);\r\n border-radius: 15px;\r\n}\r\n\r\nhtml,\r\nbody {\r\n scrollbar-color: var(--scrollbar-color) var(--scrollbar-track);\r\n}\r\n\r\n* {\r\n scrollbar-width: var(--scrollbar-thickness-ff);\r\n}" - }, - { - "version": "0.0.1", - "slug": "striking-stardust", - "name": "Striking Stardust", - "creator": "giantspacemonster", - "description": "a vibrant purple theme", - "tags": [ - "purple", - "dark" - ], - "variables": { - "accent": "#00b46c", - "background": "#160a33", - "foreground": "#9dffec", - "block": "#2e1948", - "message-box": "#461e59", - "mention": "11393c", - "success": "#65E572", - "warning": "#FAA352", - "error": "#F06464", - "hover": "rgba(0, 0, 0, 0.1)", - "scrollbar-thumb": "#00b46c", - "scrollbar-track": "transparent", - "primary-background": "#241440", - "primary-header": "#461e59", - "secondary-background": "#2e1948", - "secondary-foreground": "#51dff4", - "secondary-header": "#3c1c55", - "tertiary-background": "#3c1c55", - "tertiary-foreground": "#44bcfa", - "status-online": "#3ABF7E", - "status-away": "#F39F00", - "status-busy": "#F84848", - "status-streaming": "#977EFF", - "status-invisible": "#A5A5A5" - } - }, - { - "version": "0.0.1", - "slug": "tokyo-night", - "name": "Tokyo Night", - "creator": "hecker", - "description": "A Clean Revolt Theme.", - "variables": { - "accent": "#4651c2", - "background": "#12131b", - "foreground": "#F6F6F6", - "block": "#171722", - "message-box": "#171722", - "mention": "#4b443b", - "success": "#57F287", - "warning": "#FEE75C", - "error": "#ED4245", - "hover": "#181822", - "scrollbar-thumb": "#4651c2", - "scrollbar-track": "transparent", - "primary-background": "#1b1a26", - "primary-header": "#17161f", - "secondary-background": "#171722", - "secondary-foreground": "#6e728e", - "secondary-header": "#171722", - "tertiary-background": "#171722", - "tertiary-foreground": "#848484", - "status-online": "#57F287", - "status-away": "#FEE75C", - "status-busy": "#ED4245", - "status-streaming": "#7100fd", - "status-invisible": "#747f8d" - }, - "tags": [ - "dark" - ] - }, - { - "version": "1.0.0", - "slug": "twitch-theme", - "name": "Twitch Theme", - "creator": "NoLogicAlan", - "description": "Theme inspired by Twitch's dark theme.", - "tags": [ - "Twitch", - "dark", - "purple", - "streaming" - ], - "variables": { - "accent": "#9146FF", - "background": "#0F0F13", - "foreground": "#FFFFFF", - "block": "rgba(255, 255, 255, 0.1)", - "message-box": "rgba(255, 255, 255, 0.05)", - "mention": "rgba(236, 89, 221, 0.3)", - "success": "#2BE79C", - "warning": "#E79C2B", - "tooltip": "#9146FF", - "error": "#EC6859", - "hover": "#191919", - "scrollbar-thumb": "rgba(145, 70, 255, 0.6)", - "scrollbar-track": "transparent", - "primary-background": "#191919", - "primary-header": "#9146FF", - "secondary-background": "#0F0F13", - "secondary-foreground": "#FFFFFF", - "secondary-header": "#9146FF", - "tertiary-background": "#383838", - "tertiary-foreground": "#FFFFFF", - "status-online": "#4BBD4B", - "status-away": "#FFAF30", - "status-focus": "#9146FF", - "status-busy": "#FF453A", - "status-streaming": "#9146FF", - "status-invisible": "#747f8d", - "status-border-color": "#1F1F24", - "status-font": "Roboto", - "status-monospaceFont": "Fira Code" - } - }, - { - "version": "1.0.0", - "slug": "vintage-terminal", - "name": "Vintage Terminal", - "creator": "Axorax", - "description": "Vintage looking terminal theme for Revolt.", - "tags": [ - "dark", - "terminal", - "vintage" - ], - "variables": { - "accent": "#ffff33", - "background": "#000000", - "foreground": "#00ff00", - "block": "#333333", - "message-box": "#000000", - "mention": "#121212", - "success": "\t#00ff00", - "warning": "#ffff33", - "error": "#FF0000", - "hover": "#00ff001A", - "scrollbar-thumb": "#ffffff", - "scrollbar-track": "transparent", - "primary-background": "#000000", - "primary-header": "#333333", - "secondary-background": "#000000", - "secondary-foreground": "#00ff00", - "secondary-header": "#333333", - "tertiary-background": "#000000", - "tertiary-foreground": "#ffffff", - "status-online": "#00ff00", - "status-away": "#ffff33", - "status-busy": "#FF0000", - "status-streaming": "#A020F0", - "status-invisible": "#80848E" - } - } - ], - "popularTags": [ - "dark", - "light", - "purple", - "pastel", - "soothing", - "blue", - "orange", - "blur", - "discord", - "grey" - ] - }, - "__N_SSP": true -} \ No newline at end of file diff --git a/test/data/channels/dm.json b/tests/data/channels/dm.json similarity index 100% rename from test/data/channels/dm.json rename to tests/data/channels/dm.json diff --git a/test/data/channels/group.json b/tests/data/channels/group.json similarity index 100% rename from test/data/channels/group.json rename to tests/data/channels/group.json diff --git a/test/data/channels/messages/ayana.json b/tests/data/channels/messages/ayana.json similarity index 100% rename from test/data/channels/messages/ayana.json rename to tests/data/channels/messages/ayana.json diff --git a/test/data/channels/messages/embeds/revolt_website.json b/tests/data/channels/messages/embeds/revolt_website.json similarity index 100% rename from test/data/channels/messages/embeds/revolt_website.json rename to tests/data/channels/messages/embeds/revolt_website.json diff --git a/test/data/channels/messages/embeds/text.json b/tests/data/channels/messages/embeds/text.json similarity index 100% rename from test/data/channels/messages/embeds/text.json rename to tests/data/channels/messages/embeds/text.json diff --git a/test/data/channels/messages/embeds/youtube_website.json b/tests/data/channels/messages/embeds/youtube_website.json similarity index 100% rename from test/data/channels/messages/embeds/youtube_website.json rename to tests/data/channels/messages/embeds/youtube_website.json diff --git a/test/data/channels/messages/rules.json b/tests/data/channels/messages/rules.json similarity index 100% rename from test/data/channels/messages/rules.json rename to tests/data/channels/messages/rules.json diff --git a/tests/data/channels/rules_channel.json b/tests/data/channels/rules_channel.json new file mode 100644 index 0000000..47ff6f5 --- /dev/null +++ b/tests/data/channels/rules_channel.json @@ -0,0 +1,21 @@ +{ + "channel_type": "TextChannel", + "_id": "01H0TPNRZ2CJK97Z33J45CF04Y", + "server": "01F7ZSBSFHQ8TA81725KQCSDDP", + "name": "Rules", + "last_message_id": "01H2TQ2VD2Y0CVDNXB3RKBX2PK", + "default_permissions": { + "a": 0, + "d": 4194304 + }, + "role_permissions": { + "01F9HFTSBWTNA2F4TMSV7VM3FG": { + "a": 4194304, + "d": 0 + }, + "01GDE9VX4AD3T3KBPYZE7932P9": { + "a": 4194304, + "d": 0 + } + } +} \ No newline at end of file diff --git a/test/data/channels/saved_messages.json b/tests/data/channels/saved_messages.json similarity index 100% rename from test/data/channels/saved_messages.json rename to tests/data/channels/saved_messages.json diff --git a/test/data/channels/voice.json b/tests/data/channels/voice.json similarity index 100% rename from test/data/channels/voice.json rename to tests/data/channels/voice.json diff --git a/tests/data/discovery/bots.json b/tests/data/discovery/bots.json new file mode 100644 index 0000000..7c51cc2 --- /dev/null +++ b/tests/data/discovery/bots.json @@ -0,0 +1,662 @@ +{ + "bots": [ + { + "_id": "01GA9PC742D72BM6CNDNXS3X7D", + "username": "Reddit", + "profile": { + "content": "Automatically send new posts from subreddits and redditors.\n\nCommands work on both Redditors and Subreddits.\nRedditor: `u/spez`\nSubreddit: `r/askreddit`\n[Support Server](https://rvlt.gg/SPMxwwC8)" + }, + "tags": [], + "servers": 318, + "usage": "high" + }, + { + "_id": "01FHGJ3NPP7XANQQH8C2BE44ZY", + "username": "AutoMod", + "avatar": { + "_id": "pYjK-QyMv92hy8GUM-b4IK1DMzYILys9s114khzzKY", + "tag": "avatars", + "filename": "cut-automod-gay.png", + "metadata": { + "type": "Image", + "width": 512, + "height": 512 + }, + "content_type": "image/png", + "size": 29618 + }, + "profile": { + "content": "[Source Code](https://github.com/DeclanChidlow/AutoMod)\n\nRevolt's premier moderation bot.\n\nThanks to [@Amy.](https://miruku.cafe/@amy) for designing the logo and banner!\n\nTo enable automated spam prevention use `/botctl spam_detection on`. Some automatic moderation features are enabled by default - [More info](https://github.com/janderedev/automod/wiki/Global-Blacklist).\n\nFor help join [the AutoMod server](https://rvlt.gg/automod).", + "background": { + "_id": "AoRvVsvP1Y8X_A4cEqJ_R7B29wDZI5lNrbiu0A5pJI", + "tag": "backgrounds", + "filename": "banner.png", + "metadata": { + "type": "Image", + "width": 1366, + "height": 768 + }, + "content_type": "image/png", + "size": 151802 + } + }, + "tags": [], + "servers": 3287, + "usage": "high" + }, + { + "_id": "01G1Y9M6G254VWBF41W3N5DQY5", + "username": "bolt", + "avatar": { + "_id": "UI23uI7QEbVtwnj6hQRBe5VSrqfZK8dzAn7xBj71q8", + "tag": "avatars", + "filename": "BoltLogoWhiteOnBlack.png", + "metadata": { + "type": "Image", + "width": 1000, + "height": 1000 + }, + "content_type": "image/png", + "size": 635772 + }, + "profile": { + "content": "Bolt is a cross-platform chat app connecting communities, wherever your people might be.\nhttps://williamhorning.dev/bolt\n\nbridge revolt", + "background": { + "_id": "QZqM7LmRdOAgX-C5lf-yPMyDn6zuDVbMpOc-B-ak6l", + "tag": "backgrounds", + "filename": "BoltWordmarkColorOnBlack.png", + "metadata": { + "type": "Image", + "width": 2000, + "height": 1000 + }, + "content_type": "image/png", + "size": 420325 + } + }, + "tags": [ + "bridge", + "revolt" + ], + "servers": 435, + "usage": "high" + }, + { + "_id": "01H1DNQN9JGV40W0TJXWV26SGZ", + "username": "GlobalAdvertising", + "avatar": { + "_id": "YwvIYujaK29YCJ4NvuFHur35ZcRXiWrEEcsiOMjSe4", + "tag": "avatars", + "filename": "8cb449e049d2e5d1dd21eb67f91dd16a.png", + "metadata": { + "type": "Image", + "width": 128, + "height": 128 + }, + "content_type": "image/png", + "size": 11585 + }, + "profile": { + "content": "Global Ads uses a `global chat` style system to allow users to easily send any ad in any server by simply sending your ad as a message.\n\nType `g!help` to view all commands.\n\n[Support](https://rvlt.gg/pQZXYcRk) | [Website](https://frontier.cx) | [Discord Equivalent](https://global-advertising.frontier.cx/invite)", + "background": { + "_id": "LX-h5uZDQLGVAEsegt-kiG7E2vVtsVFpAnh6SRGZ9y", + "tag": "backgrounds", + "filename": "global.png", + "metadata": { + "type": "Image", + "width": 900, + "height": 300 + }, + "content_type": "image/png", + "size": 54115 + } + }, + "tags": [], + "servers": 67, + "usage": "high" + }, + { + "_id": "01H3N2N26KYV4626V4ZEZYKHT8", + "username": "Masquerade", + "profile": { + "content": "Send messages with a different Name, Avatar and Colour.\nUsage:\n`@Masquerade` Send help message\n`@Masquerade create {name} {Display Name}` Create a masquerade\n`name;Text you want to send` Send a message as {name}\n[Support Server](https://rvlt.gg/SPMxwwC8)\n[Source Code](https://github.com/TheBobBobs/masquerade-bot)" + }, + "tags": [], + "servers": 282, + "usage": "high" + }, + { + "_id": "01FGEJB5YQHRE55TWQG9RZMZFJ", + "username": "Booru", + "profile": { + "content": "29,933,846 Images - 3,444,325 Tags\n!d help\nGet a random image from the top 5 boorus or upload your own.\nUse tags to narrow your search. (!d maid cat_ears)\n\n[danbooru](https://danbooru.donmai.us), [e621](https://e621.net), [gelbooru](https://gelbooru.com), [rule34](https://rule34.xxx), [realbooru](https://realbooru.com/)\n[Support Server](https://rvlt.gg/SPMxwwC8)\n\nNSFW\n" + }, + "tags": [ + "nsfw" + ], + "servers": 396, + "usage": "high" + }, + { + "_id": "01HBQ5YTZ17GX9PAH87Q8SBNYE", + "username": "Auttaja", + "avatar": { + "_id": "PdEyPLzLUX1oJpJ0MhGs3FG_508LkzyC9qHHS57nim", + "tag": "avatars", + "filename": "Auttaja.png", + "metadata": { + "type": "Image", + "width": 1024, + "height": 1024 + }, + "content_type": "image/png", + "size": 49386 + }, + "profile": { + "content": "I am a multipurpose bot for Revolt featuring a counting system, moderation, and more.\n‎\nWe have mod & message logs system, logs when a message is edited or deleted, also logs bans, kicks, warns, etc.\n \n(All a work In progress!)‎\nRevolt Bot List - https://revoltbots.org/bots/Auttaja\nmoderation mod logging multipurpose utility", + "background": { + "_id": "zPqPKU8sXZ7ZRaLyZD63_MsBZJEo2PwZ9bWLbgr_58", + "tag": "backgrounds", + "filename": "Auttaja.png", + "metadata": { + "type": "Image", + "width": 480, + "height": 218 + }, + "content_type": "image/png", + "size": 41721 + } + }, + "tags": [ + "moderation", + "mod", + "logging", + "multipurpose", + "utility" + ], + "servers": 103, + "usage": "high" + }, + { + "_id": "01G6ECYVSP18H3MV09X3VX2R0G", + "username": "PopCord", + "profile": { + "content": "Add the Revolt bot PopCord to set up moderation, fun, utils, social, and NSFW for your Revolt server.\n\n[support server](https://app.revolt.chat/invite/kGZ1D6y6)\n[invite me](https://app.revolt.chat/bot/01G6ECYVSP18H3MV09X3VX2R0G)\n\n[website](https://popcord.github.io/)\n[API](https://api-popcord.vercel.app/)\n\nnsfw adult lewd 18 social anime", + "background": { + "_id": "53d42Jtf_u7qOaJbr7fZqJgC60QhAzooHOf_3R5mbw", + "tag": "backgrounds", + "filename": "image_2023-07-15_165759562.png", + "metadata": { + "type": "Image", + "width": 1200, + "height": 300 + }, + "content_type": "image/png", + "size": 380078 + } + }, + "avatar": { + "_id": "YAi3D8lhWCbNhpuxG0SxdfoDaFbPPPHtw8XpJOMR2N", + "tag": "avatars", + "filename": "a_b0f0c3aab1aa5024dee9be10f68a4641.gif", + "metadata": { + "type": "Image", + "width": 269, + "height": 269 + }, + "content_type": "image/gif", + "size": 1654485 + }, + "tags": [ + "nsfw", + "adult", + "lewd", + "18", + "social", + "anime" + ], + "servers": 278, + "usage": "medium" + }, + { + "_id": "01FVB28WQ9JHMWK8K7RD0F0VCW", + "username": "Remix", + "avatar": { + "_id": "763s_Jbrgw0VHeW6vOet-bBe5yB-sVW-IBBqbNRaY4", + "tag": "avatars", + "filename": "Remix.png", + "metadata": { + "type": "Image", + "width": 1440, + "height": 1440 + }, + "content_type": "image/png", + "size": 263926 + }, + "profile": { + "content": "Remix is the first open-source music bot for Revolt! if you need help or have any suggestions or feedback, join our support server. Please expect bugs from time to time as some features are under constant development.\n‎\n[Add the bot here]() • [Support Server]() • [Sponsor]() • [Web]()\n\n- You can find Remix's source code [here]().\n- [Remix Discord Version]().\n‎\nFor commands run `%help`\n- Command prefix might vary from server to server. Run <@​​​​01FVB28WQ9JHMWK8K7RD0F0VCW> to get the prefix in the current server.\n‎\nCheck for current downtime incidents: [Status Page](http://status.remix.fairuse.org)\nVote for Remix on [revoltbots.org](https://revoltbots.org/bots/remix)\n\n- `Made by ShadowLp174 and NoLogicAlan`​​​​\n\nmusic musicbot bot watchtogether dashboard rythm", + "background": { + "_id": "hsSRljmK34pgASEXRz28rnBBEbO5iC_C6Z1VWZJ0zK", + "tag": "backgrounds", + "filename": "D4_hBq5D2DObGDeYuo6agc7-5kjWNcefnrqMgHAkL2.gif", + "metadata": { + "type": "Image", + "width": 600, + "height": 360 + }, + "content_type": "image/gif", + "size": 1739994 + } + }, + "tags": [ + "music", + "musicbot", + "bot", + "watchtogether", + "dashboard", + "rythm" + ], + "servers": 2286, + "usage": "medium" + }, + { + "_id": "01HV5TBBZZQQ38N500NER52TRQ", + "username": "yagprb", + "avatar": { + "_id": "YJctin4WI7EpAbaPIDcmvLePJ4JtAUorJgcn-h_CxW", + "tag": "avatars", + "filename": "lizard.gif", + "metadata": { + "type": "Image", + "width": 413, + "height": 413 + }, + "content_type": "image/gif", + "size": 3770242 + }, + "profile": { + "content": "### Hello, I am YAGPRB. A general purpose revolt bot made to help you make your server a better place!\n\nSome of me features are:\n- A customizable levelling system with level rewards!\n- The ability to make custom join/leave messages\n- A highlights system that notifies you whenever a message keyword is sent in chat\n\nAnd even more. [Add me to your server](https://app.revolt.chat/bot/01HV5TBBZZQQ38N500NER52TRQ) now and explore all my features!\n\nutility minigames levelling roles moderation general" + }, + "tags": [ + "utility", + "minigames", + "levelling", + "roles", + "moderation", + "general" + ], + "servers": 24, + "usage": "medium" + }, + { + "_id": "01FEYFMPWJSJZ0671REAQMP6TY", + "username": "Ahni", + "avatar": { + "_id": "9Em8JLSKOwVoHnqdLaeUj5Z0eZf9UwdC16cdn7LMFW", + "tag": "avatars", + "filename": "racoon-raccoon.gif", + "metadata": { + "type": "Image", + "width": 498, + "height": 498 + }, + "content_type": "image/gif", + "size": 2574460 + }, + "profile": { + "content": "I am a fun and hot NSFW bot.\n:rainbow: LGBTQ+ Friendly :rainbow:\nSupport: https://rvlt.gg/tskJVrtk\nUsing a safe-to-use API, I am here to please. :smile_cat:\nAdd me to your server and try out my commands with `=help`!\n[My Source Code](https://github.com/Akrasio/Ahni-Revolt)\n\nnsfw lgbt adult lewd 18 anime images\n", + "background": { + "_id": "TCLfhz7L_zxENtWctj9_ucVTF_9dbKLQLCxN9omNWG", + "tag": "backgrounds", + "filename": "wp6371955.jpg", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 231985 + } + }, + "tags": [ + "nsfw", + "lgbt", + "adult", + "lewd", + "18", + "anime", + "images" + ], + "servers": 886, + "usage": "low" + }, + { + "_id": "01G9XW2NR0QBH5SD3RMDX7VWDB", + "username": "Roles", + "profile": { + "content": "Create messages that will give roles when reacted to.\n[Support Server](https://rvlt.gg/SPMxwwC8)\n[Source Code](https://github.com/TheBobBobs/roles-bot)" + }, + "tags": [], + "servers": 751, + "usage": "low" + }, + { + "_id": "01GTEXXYPYZQZFQ92AZB7JVR1G", + "username": "Bobcat2", + "avatar": { + "_id": "Ec85usBR7_2mHVz0MI9rwXPLcaxIrFKy5pM6Y25dzM", + "tag": "avatars", + "filename": "1685936841305_bobcat-_detailed_tongue_and_teeth", + "metadata": { + "type": "Image", + "width": 512, + "height": 512 + }, + "content_type": "image/png", + "size": 488845 + }, + "profile": { + "content": "Clone of BOBCAt, the best (quality is relative) moderation and utilities bot. I did not write it, the source code was made my crispy cat, he wants nothing to do with revolt tho.\nI can dm the source code, if you ask for it...\n\nPrefix is t$" + }, + "tags": [], + "servers": 37, + "usage": "low" + }, + { + "_id": "01GEJRAVCNV8VZNESKYKZG7ZNK", + "username": "PHLASH", + "avatar": { + "_id": "OSt8882bNBAl7WSKjou1ge3ZRE95urx_Ke655z2_vv", + "tag": "avatars", + "filename": "PHLASH.png", + "metadata": { + "type": "Image", + "width": 256, + "height": 256 + }, + "content_type": "image/png", + "size": 53622 + }, + "profile": { + "content": "A bot to help you get things done in a PHLASH!\nMostly just music right now. Supports queueing songs from youtube/soundcloud, playing livestreams or even radio, and setting filters for individual songs. (speed/reverb/bassboost)\n\nI'm [open source](https://github.com/itzTheMeow/revolt-phlash)!\n\nmusic bot musicbot", + "background": { + "_id": "-PMXovI4tcPjS2RBIuycpRb9H_jThdJGBZn1viOGQS", + "tag": "backgrounds", + "filename": "Shapes Background Large.png", + "metadata": { + "type": "Image", + "width": 800, + "height": 450 + }, + "content_type": "image/png", + "size": 7266 + } + }, + "tags": [ + "music", + "bot", + "musicbot" + ], + "servers": 394, + "usage": "low" + }, + { + "_id": "01H302WEK18E6F06NXW3ZF1JQ4", + "username": "Logger", + "avatar": { + "_id": "feCCSrJ_v-GbUZglZZXWOFn24Chy39fNtZ21HXFo9o", + "tag": "avatars", + "filename": "Revolt_Logger_Bot.png", + "metadata": { + "type": "Image", + "width": 712, + "height": 712 + }, + "content_type": "image/png", + "size": 23166 + }, + "profile": { + "content": "## Join the Support Server, [Bot Central](https://rvlt.gg/KDsGjPTW)!\nThe ultimate logging Revolt bot! Stay informed with comprehensive logging for message updates, and message deletes, as well as member joins and leaves. Keep a complete record of your server's activity and changes with Logger, your reliable logging companion.", + "background": { + "_id": "yuWwoG6j44x8uZ09e4hicIzxmLpIY5YxRvoo3Ghvyh", + "tag": "backgrounds", + "filename": "3885A408-B4CD-447D-A153-F0324C46407B.png", + "metadata": { + "type": "Image", + "width": 750, + "height": 499 + }, + "content_type": "image/png", + "size": 12642 + } + }, + "tags": [], + "servers": 173, + "usage": "low" + }, + { + "_id": "01FEMAA7ZDBD7KXBNRQT3PX8G6", + "username": "Waifu", + "profile": { + "content": "Made with love by Builderb <3 | Updated May 2023!\nA multi-purpose bot with utility and image commands coming soon.\n[Invite Bot](https://app.revolt.chat/bot/01FEMAA7ZDBD7KXBNRQT3PX8G6) | [Revolt Server](https://app.revolt.chat/invite/J5Ras1J3) | [Website](https://fluxpoint.dev) | [Discord Server](https://discord.gg/fluxpoint)\n\nanime imagegen memes utility", + "background": { + "_id": "oVQu_OOPng5YtUx6ej42QLlLYftLNgLCMJhE1M8ZYV", + "tag": "backgrounds", + "filename": "png", + "metadata": { + "type": "Image", + "width": 1500, + "height": 968 + }, + "content_type": "image/png", + "size": 2811567 + } + }, + "avatar": { + "_id": "-GBne-bBuNmJyg5ABWmc1e1Q6uqLOQqb39dl2S0aRB", + "tag": "avatars", + "filename": "waifu_new_avatar.png", + "metadata": { + "type": "Image", + "width": 2148, + "height": 2148 + }, + "content_type": "image/png", + "size": 2463533 + }, + "tags": [ + "anime", + "imagegen", + "memes", + "utility" + ], + "servers": 503, + "usage": "low" + }, + { + "_id": "01HS541Y8H5TQMWVEQD2VK213M", + "username": "Roulette", + "profile": { + "content": "This is the ultimate way to pass the time.\n======================================\nPlay: \".r\" or \".g\"\nLet's match the numbers\n======================================\n[Revolt Bots](https://revoltbots.org/bots/01HS541Y8H5TQMWVEQD2VK213M/)|[GitHub](https://github.com/ztttas1/revolt_render_ru-)|[Web Site](https://revolt-render-ru.onrender.com/)|[Sub machine](https://app.revolt.chat/bot/01J0QDZK8MWZRRX0K1C0NET8K3)\ngame english Revolt", + "background": { + "_id": "SAPWm2EtopAXp04_lR_F3FIB4e5TuyRnIzeO6Y37XO", + "tag": "backgrounds", + "filename": "22696631.jpg", + "metadata": { + "type": "Image", + "width": 1600, + "height": 1200 + }, + "content_type": "image/jpeg", + "size": 61899 + } + }, + "avatar": { + "_id": "zCE1XRZCHpEsJiy3Tx2wPYHdEnIU2RoEsFE17wsgM4", + "tag": "avatars", + "filename": "ダウンロード.png", + "metadata": { + "type": "Image", + "width": 186, + "height": 186 + }, + "content_type": "image/png", + "size": 7315 + }, + "tags": [ + "game", + "english", + "revolt" + ], + "servers": 14, + "usage": "low" + }, + { + "_id": "01GQ6RADXPSXPF6NW4CBXXE0VV", + "username": "MeowbahhBot", + "avatar": { + "_id": "zXsqmEzZb95w4pikLJpYQgx1Lz0cffY_ecnhhKMXKv", + "tag": "avatars", + "filename": "Meowbahh 2.jpg", + "metadata": { + "type": "Image", + "width": 593, + "height": 593 + }, + "content_type": "image/jpeg", + "size": 57953 + }, + "profile": { + "content": "HI. I am Meowbahh the Brainless trash bin. Please make me Trash. You can ask me questions with ++askmeow to get dumb answers, see Bad User hate arts with ++art, and more.\nPrefix: ++\n[Add the Bot](https://app.revolt.chat/bot/01GQ6RADXPSXPF6NW4CBXXE0VV) | [Support Server](https://rvlt.gg/sRTVE2sV) | [Website](https://idiotcreaturehater.glitch.me) | [Source Code](https://github.com/BadUserHater/MeowbahhBot/tree/main/revoltbot) | [Status Page](https://idiotcreaturehater.instatus.com)\n\nchatbot idiotcreaturehater fun meme", + "background": { + "_id": "fJUIh2wETjuaDpj9di-YJddh3lKm64CvuNyWhdr_R3", + "tag": "backgrounds", + "filename": "Idiotic Brainless Barbie Bin in Stores.png", + "metadata": { + "type": "Image", + "width": 1272, + "height": 734 + }, + "content_type": "image/png", + "size": 100147 + } + }, + "tags": [ + "chatbot", + "idiotcreaturehater", + "fun", + "meme" + ], + "servers": 49, + "usage": "low" + }, + { + "_id": "01HX0519791BX5BFWZ98MNS27H", + "username": "Translator", + "avatar": { + "_id": "VkyX0a0QnZGW1LKlc6EigRkhBpRf83D2vDQjI8Tcq3", + "tag": "avatars", + "filename": "translator.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 29249 + }, + "profile": { + "content": "**1st Revolt translation bot !**\n\n> Invite the bot\n>https://app.revolt.chat/bot/01HX0519791BX5BFWZ98MNS27H\n\n> Support server\n>https://rvlt.gg/pfkGX8P9\n\n100% free, no premium features, no limitations. ", + "background": { + "_id": "9H2naWpVOriI4JncxJWoDKA9AuhYb6WTH7NuMHSiQ8", + "tag": "backgrounds", + "filename": "translator_bannière_2.png.png", + "metadata": { + "type": "Image", + "width": 1200, + "height": 480 + }, + "content_type": "image/png", + "size": 31782 + } + }, + "tags": [], + "servers": 16, + "usage": "low" + }, + { + "_id": "01HPAFAVQM01BE2PN2A7RDTQXJ", + "username": "Anna", + "profile": { + "content": "I am a bot which is made for AI conversations! If you want to just chat, ping me with a message.\n\nDisclaimer:\nI use an AI Chat service which could be biased or offensive... tread carefully!\n\nAnd yes... I do take some time to respond and provide decent responses! Please be patient!\n\nAI automated chatbot chat fun\n\n- https://revoltbots.org/bots/ai\n- https://github.com/Akrasio/AI\n- [Home Server](https://rvlt.gg/01G5DNGB7K9JN685WABZKTYQ0X)", + "background": { + "_id": "NM_YFL1kYro75TbgI6BMl22l7CFEPpaByOJ6vWUeFK", + "tag": "backgrounds", + "filename": "giphy.gif", + "metadata": { + "type": "Image", + "width": 500, + "height": 280 + }, + "content_type": "image/gif", + "size": 644907 + } + }, + "avatar": { + "_id": "VAL7R3NT3xPeOrHRnAiiys2VpRXVYe_rMB7sIoL3V8", + "tag": "avatars", + "filename": "gify.gif", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/gif", + "size": 2263418 + }, + "tags": [ + "ai", + "automated", + "chatbot", + "chat", + "fun" + ], + "servers": 7, + "usage": "low" + }, + { + "_id": "01H3JFKWCDET5YTMP0J92SXBC9", + "username": "BaldiBot", + "avatar": { + "_id": "msom_qCb-C7yXTs7zksS6Kx6q8UGloOAd8hNhQS5Ct", + "tag": "avatars", + "filename": "Baldi Bot Icon.png", + "metadata": { + "type": "Image", + "width": 400, + "height": 425 + }, + "content_type": "image/png", + "size": 140564 + }, + "profile": { + "content": "Hello, I am Baldi. I can send people to detention, Find high quality Baldi's Basics mods, Baldi's Basics Roleplay commands, and more.\n\nTo get Started with me, use bb!help\n[Support Server](https://rvlt.gg/fSfKknAw) | [Website](https://baldibot.nl) | [Invite Discord Bot (verified by Discord)](https://discord.com/oauth2/authorize?client_id=821513607916814376&permissions=0&integration_type=0&scope=bot+applications.commands)\n\nDISCLAIMER: This bot is not made by or affilirated with mystman12/Basically Games and the creators of Baldi Basics and Baldi Basics Plus. This is a fanmade bot. This bot is intended to be more of a funny bot to provide fun for Baldi Basics fans." + }, + "tags": [], + "servers": 21, + "usage": "low" + } + ], + "popularTags": [ + "nsfw", + "utility", + "moderation", + "revolt", + "bridge", + "mod", + "logging", + "multipurpose", + "anime", + "18" + ] +} \ No newline at end of file diff --git a/tests/data/discovery/servers.json b/tests/data/discovery/servers.json new file mode 100644 index 0000000..579bfae --- /dev/null +++ b/tests/data/discovery/servers.json @@ -0,0 +1,3673 @@ +{ + "servers": [ + { + "_id": "01HZJX93WB0W6QMFFC2E5VNG23", + "name": "peeps", + "description": "peeps is an active chatting server with memes and more", + "icon": { + "_id": "JTnWjuOrDZw6-A0ey94dvfVtDIvydD7bisr_aU82v_", + "tag": "icons", + "filename": "GSKz91QUnRHWnO92E697pRpDl7BBkHnBCVvQVHTppK.jpeg", + "metadata": { + "type": "Image", + "width": 768, + "height": 768 + }, + "content_type": "image/jpeg", + "size": 96701 + }, + "banner": { + "_id": "ZDeq632SkOHK26Arq7j4uX0V_bbyD10g8NWd8l1eH_", + "tag": "banners", + "filename": "il_fullxfull.2923896440_644n.jpg", + "metadata": { + "type": "Image", + "width": 2750, + "height": 2125 + }, + "content_type": "image/jpeg", + "size": 453049 + }, + "tags": [], + "members": 71, + "activity": "high" + }, + { + "_id": "01F80118K1F2EYD9XAMCPQ0BCT", + "name": "Catgirl Dungeon", + "description": "The gayest corner of the internet :3\n\nNon-catgirls welcome too ❤️", + "icon": { + "_id": "XIwQosw_3USL_XIvzZDyIKSi9LlMryrOPJNKsTrqts", + "tag": "icons", + "filename": "gaysex.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 17439 + }, + "banner": { + "_id": "ZTPUKiZ6OP1Yqox2PNOBVx_q1U3u9hXBbn84tLJXzK", + "tag": "banners", + "filename": "20230626_221308-2.jpg", + "metadata": { + "type": "Image", + "width": 5120, + "height": 2880 + }, + "content_type": "image/jpeg", + "size": 1321952 + }, + "flags": 2, + "tags": [], + "members": 6804, + "activity": "high" + }, + { + "_id": "01HVKQBBQ3DQVVNK3M8DHXV30D", + "name": "Femboy Kingdom", + "icon": { + "_id": "1tTV6RqTik3qntjw2tFXg-HfCW_Dtmp4cYBA385BzO", + "tag": "icons", + "filename": "femdom logo small.png", + "metadata": { + "type": "Image", + "width": 2500, + "height": 2500 + }, + "content_type": "image/png", + "size": 2082228 + }, + "banner": { + "_id": "LoBAfzfxv-0bObWR4pYpByG2joAQ_o1LeqZByOrLSY", + "tag": "banners", + "filename": "Hyrule_Castlesmallimagesize.png", + "metadata": { + "type": "Image", + "width": 3840, + "height": 2160 + }, + "content_type": "image/png", + "size": 7096268 + }, + "description": "! **18+ ONLY** !\n\na comfy server for femboys, but also home to girllikers, boykissers, silly gays of all kinds, and everyone else, too!!", + "tags": [], + "members": 225, + "activity": "high" + }, + { + "_id": "01F7ZSBSFHQ8TA81725KQCSDDP", + "name": "Revolt", + "icon": { + "_id": "gtc0gJE2S3RvuDhrl2-JeakvgbqEGr2acvBnRTTh6k", + "tag": "icons", + "filename": "logo_round.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 7558 + }, + "banner": { + "_id": "G_Q-6Y8KiGFVBNY2qVtDS25bX9Dh14CK2Py3TmLN_P", + "tag": "banners", + "filename": "Lounge_Banner_Old.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 138982 + }, + "description": "Official server run by the team behind Revolt.\nGeneral conversation and support server.\n \nAppeals and reports: lounge@revolt.chat", + "flags": 1, + "tags": [ + "revolt" + ], + "members": 39737, + "activity": "medium" + }, + { + "_id": "01HH6WE8A9RR6N9YVZNZAJRG92", + "name": "Revolt過密サーバー!", + "description": "ja japan 7/3現在 discover5位!", + "icon": { + "_id": "-Drsvy3FxRt4A-ENypzmhM3puuC9Efyb2oz8S_c94E", + "tag": "icons", + "filename": "image.png", + "metadata": { + "type": "Image", + "width": 150, + "height": 147 + }, + "content_type": "image/png", + "size": 43303 + }, + "banner": { + "_id": "wkXnd3a9mlzHF4ChPrS4VUXC3NXazKMKj72DVnHV_m", + "tag": "banners", + "filename": "名称未設定のデザイン.png", + "metadata": { + "type": "Image", + "width": 1280, + "height": 720 + }, + "content_type": "image/png", + "size": 108794 + }, + "tags": [ + "ja", + "japan" + ], + "members": 352, + "activity": "medium" + }, + { + "_id": "01GDS83RMZW89AV0BZG24NEXYC", + "name": "Furgatory", + "icon": { + "_id": "Q93qhBFrwVQE43rumFZFdRalq6UYR0JyHxssXOitTA", + "tag": "icons", + "filename": "Furgatory.webp", + "metadata": { + "type": "Image", + "width": 1000, + "height": 1000 + }, + "content_type": "image/webp", + "size": 65770 + }, + "description": "**Furgatory** is a simple server, meant to be fun, chaotic, and with lenient rules!\nYou either love it, or absolutely **HATE** it.\n\n*PS: This server is bridged with Discord.*\n\nuhh yes\n\nfurry chill chaotic fun", + "banner": { + "_id": "54hCHvbcG0P2j1DlomchOxR4ITkco4gGUWoSpYL_fK", + "tag": "banners", + "filename": "ujyhtgj.png", + "metadata": { + "type": "Image", + "width": 7111, + "height": 4000 + }, + "content_type": "image/jpeg", + "size": 2231838 + }, + "flags": 2, + "tags": [ + "furry", + "chill", + "chaotic", + "fun" + ], + "members": 392, + "activity": "medium" + }, + { + "_id": "01HPW0178JYC2HV7AB3Y9H2GEA", + "name": "Terraria", + "icon": { + "_id": "QNTsRnYMjuanz9-SFyY1YdQSy5O3sK62ycdCwo3FBv", + "tag": "icons", + "filename": "image(1).png", + "metadata": { + "type": "Image", + "width": 970, + "height": 970 + }, + "content_type": "image/png", + "size": 27348 + }, + "description": "The largest community-owned server for all Terrarians to chat about the beloved game itself, Terraria!", + "banner": { + "_id": "qXUVCBGhdgJVWwqORLCN9cS5B2xtuL8xSCsnYrLfcl", + "tag": "banners", + "filename": "steamuserimages-a.akamaihd.jpeg", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 528776 + }, + "tags": [], + "members": 223, + "activity": "medium" + }, + { + "_id": "01G5DNGB7K9JN685WABZKTYQ0X", + "name": "Social Haven", + "icon": { + "_id": "3mNkqRCBw88PveUOYoshTfOi8KNzHtWzWQs2uHRW_l", + "tag": "icons", + "filename": "SocialHaven.gif", + "metadata": { + "type": "Image", + "width": 421, + "height": 421 + }, + "content_type": "image/gif", + "size": 1806072 + }, + "banner": { + "_id": "gNOForvh8ourH7EyT05Xbc402pszXvxFDx48keTMd1", + "tag": "banners", + "filename": "SPANN.png", + "metadata": { + "type": "Image", + "width": 960, + "height": 540 + }, + "content_type": "image/png", + "size": 503321 + }, + "description": "For anyone who wants to discuss things from development, anime, tv shows... Or even to get help on using Ahni (Bot).", + "tags": [], + "members": 516, + "activity": "medium" + }, + { + "_id": "01HC4Y9963EZBZD40W28V0SWD2", + "name": "Snek Cult", + "description": "LGBTQA+ safe space, friendly, chill server!\n\n~~The cult part is a joke, we are just chaotic~~", + "icon": { + "_id": "pYTYnO9T0lYkNDfFvlWNPVYvSCWpZbP2s1MZMO_JYa", + "tag": "icons", + "filename": "SnakeLarge.jpg", + "metadata": { + "type": "Image", + "width": 900, + "height": 542 + }, + "content_type": "image/jpeg", + "size": 55605 + }, + "banner": { + "_id": "nCU-3RhI8u4jYi3FCgQ8ERD7N5OSMADHDXgT2BVwlt", + "tag": "banners", + "filename": "Torhola_cave_entrance_Aug2008.jpg", + "metadata": { + "type": "Image", + "width": 800, + "height": 600 + }, + "content_type": "image/jpeg", + "size": 189459 + }, + "tags": [], + "members": 1261, + "activity": "medium" + }, + { + "_id": "01HBKJD949CPKM31A5WS3DJF4F", + "name": "𝘓𝘦𝘧𝘵𝘪𝘴𝘵𝘴 𝘝𝘰𝘪𝘤𝘦", + "icon": { + "_id": "_PeslCCAi33E97fxwQyITqtZIeJZpASYbmMJYxDN02", + "tag": "icons", + "filename": "Screenshot_2024-04-02-20-01-12-50_99c04817c0de5652397fc8b56c3b3817.png", + "metadata": { + "type": "Image", + "width": 750, + "height": 750 + }, + "content_type": "image/png", + "size": 431448 + }, + "banner": { + "_id": "XCSbcx3CbaKIh6RrR7QFLaZD_iiZ5W44bXnSelpyKG", + "tag": "banners", + "filename": "Sans titre (3) (12).png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 2353977 + }, + "description": "🔴| A server for generally left adjacent and liberatory leftists people to hang out. |⚫\n‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎‎ ‎ ‎ ‎‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ \nleftismpoliticalpoliticsantifascistsantifascismsocialistssocialismsocialsanarchistsanarchismcommunistscommunismancomsbolcheviksbolchevismdemsocssocdemssyndicalistssyndicalismmarxistsmarxismleninistsleninismmaoistsmaoismmlmtrotskyiststrotskyismproletariatsproletarianredflagsfeministsfeminismecologyecologicallgbt+lgbtqa+lgbtqia+2slgbtq+transgenderstransfemstransmascsnonbinaryqueersblacklivesmatterblmpalestinianspalestineelections", + "tags": [ + "left", + "leftists", + "leftism", + "political", + "politics", + "antifascists", + "antifascism", + "socialists", + "socialism", + "socials" + ], + "members": 202, + "activity": "medium" + }, + { + "_id": "01G26VQTAV4T8FDTXGKAEN6WW3", + "name": "Revolt Türkiye 🇹🇷", + "icon": { + "_id": "YYNYH5lJD9Qrvbxc7kH_2zHuf2wV__J80EXh0J49gV", + "tag": "icons", + "filename": "Revolt.jpg", + "metadata": { + "type": "Image", + "width": 178, + "height": 180 + }, + "content_type": "image/jpeg", + "size": 5049 + }, + "description": null, + "banner": { + "_id": "NLLL9LapfIFVBkoOZnv6bt4yKuJlhW9ijd46hJF2dy", + "tag": "banners", + "filename": "revoltbg.png", + "metadata": { + "type": "Image", + "width": 1200, + "height": 720 + }, + "content_type": "image/png", + "size": 113114 + }, + "tags": [], + "members": 246, + "activity": "medium" + }, + { + "_id": "01HJRBQKJN8DPDDF8EWC5NP0Y3", + "name": "Vimjoyer's server", + "banner": { + "_id": "29k_SQ4_gTLMUGmZY0xmtQUl-pq9z8FA7_PYUwxool", + "tag": "banners", + "filename": "1674902814_top-fon-com-p-fon-dlya-prezentatsii-krestovie-pokhodi-95.jpg", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 355837 + }, + "icon": { + "_id": "sLZPNq_baVjDEGw71lwdg-Vel7C5z97NThJ_qJ888G", + "tag": "icons", + "filename": "da9197e4c321bde3cbbd82b6b2ede81d.png", + "metadata": { + "type": "Image", + "width": 460, + "height": 460 + }, + "content_type": "image/png", + "size": 347738 + }, + "description": "Vimjoyer is a laid-back technical community focused primarily on [NixOS](https://nixos.org/) and the associated [YouTube channel](https://www.youtube.com/@vimjoyer).\n\nDiscord Server: https://discord.gg/vimjoyer-s-server-1133342557494067232", + "tags": [], + "members": 35, + "activity": "medium" + }, + { + "_id": "01FPH1PAT0RRZRRRHX05S5PAHF", + "name": "Metal Music", + "banner": { + "_id": "isp_efybah3RPr8tWUqmDKQAjB56oJaZEOn7VX3Jev", + "tag": "banners", + "filename": "thyrfi8.jpg", + "metadata": { + "type": "Image", + "width": 997, + "height": 496 + }, + "content_type": "image/jpeg", + "size": 113020 + }, + "icon": { + "_id": "npIBCmZWEcSS9uZ3S4xKWSywdKY_1bZAIromqFtPIJ", + "tag": "icons", + "filename": "Metal Horns.jpg", + "metadata": { + "type": "Image", + "width": 238, + "height": 250 + }, + "content_type": "image/jpeg", + "size": 7765 + }, + "description": "Metal Music to share and discover \n\nMetal, MetalMusic, metal, metalmusic, Musikk, #\\M/ #\\m/, Bands", + "tags": [ + "metal", + "metalmusic", + "musikk", + "bands" + ], + "members": 454, + "activity": "medium" + }, + { + "_id": "01HMHX9YTP6JDMSMF2V2YK5XSC", + "name": "líf's tea house", + "icon": { + "_id": "Bhv0oJdHSis42wVpKHzG2ocee9FjcwjhGaRksftf5B", + "tag": "icons", + "filename": "clipart1343880.png", + "metadata": { + "type": "Image", + "width": 949, + "height": 797 + }, + "content_type": "image/png", + "size": 51880 + }, + "banner": { + "_id": "5ITVvJsJH4oZDAuIzEOdV-Y-aOkhuyJXLsyLs7xH0o", + "tag": "banners", + "filename": "deers_den_japanese_inn_by_mizakilightsword_dgejrsf-fullview.jpg", + "metadata": { + "type": "Image", + "width": 1280, + "height": 718 + }, + "content_type": "image/jpeg", + "size": 247313 + }, + "description": "hey there, hope you like tea and a cosy atmosphere! let's vibe and spill the tea!\n\n(please don't spill any actual tea...)\n\ncome on in! c:\n\ntea friendly chat chatting games gaming cosy cozy art music lgbtq+", + "tags": [ + "tea", + "friendly", + "chat", + "chatting", + "games", + "gaming", + "cosy", + "cozy", + "art", + "music" + ], + "members": 180, + "activity": "medium" + }, + { + "_id": "01HY3ZH08W97DNEBEAJENYBVQW", + "name": "Hazbin Hotel", + "icon": { + "_id": "aGAHw5a2wc9-iyiXYvTi2HFQk-rE1VfphFqc2R2fqM", + "tag": "icons", + "filename": "Screenshot_20240517-151315.png", + "metadata": { + "type": "Image", + "width": 941, + "height": 939 + }, + "content_type": "image/png", + "size": 1313620 + }, + "description": "A server open to everyone. (But I do suggest watching Hazbin Hotel.)", + "banner": { + "_id": "5sgnDL1rt4H89FuynB7vBSvPKE0E6u6E6ezMUKQDym", + "tag": "banners", + "filename": "Screenshot_20240516-150906.png", + "metadata": { + "type": "Image", + "width": 939, + "height": 937 + }, + "content_type": "image/png", + "size": 959759 + }, + "tags": [], + "members": 72, + "activity": "medium" + }, + { + "_id": "01GMSVV3HESTM2QNV6FSRR66FK", + "name": "World Three (WWW)", + "icon": { + "_id": "7jJUkr0_FXGJynrco8HsVAq4wJxXPIziTEkAPYOqZ6", + "tag": "icons", + "filename": "Logo2.png", + "metadata": { + "type": "Image", + "width": 825, + "height": 825 + }, + "content_type": "image/png", + "size": 635311 + }, + "banner": { + "_id": "3jIMyKk7LSJF1Dt0xtlFz1FS0_fFpTJ74zneC6WNV8", + "tag": "banners", + "filename": "bg_main(5).png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1920 + }, + "content_type": "image/png", + "size": 3462831 + }, + "description": "A Revolt Version of World Three\n[Guilded](https://guilded.gg/www)\n[Discord](https://discord.gg/R4KDE9NK6J)\n", + "tags": [], + "members": 73, + "activity": "medium" + }, + { + "_id": "01GKCGQT0M5YNR6Y0JD4B7MD2K", + "name": "Linux Gang", + "banner": { + "_id": "FtDdlGoFMtqsv8USM7xsZo7c6DWLsohAeTFcrlliVo", + "tag": "banners", + "filename": "image.psd(2).png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1040 + }, + "content_type": "image/png", + "size": 302367 + }, + "icon": { + "_id": "Sj0oL7W9_xECKdxRg5y9aGka4Af4yK1mFakVoi3FzO", + "tag": "icons", + "filename": "10eb37bf4d557a7e0a4d883c9b3f80c3.jpg", + "metadata": { + "type": "Image", + "width": 453, + "height": 453 + }, + "content_type": "image/jpeg", + "size": 26199 + }, + "description": "Epic Linux Revolt server (BRIDGED FROM DISCORD SERVER https://discord.gg/linuxgang)\nThis is official Revolt variant of Linux Gang, currently used as a backup. linux programming gang memes meme fun", + "tags": [ + "linux", + "programming", + "gang", + "memes", + "meme", + "fun" + ], + "members": 621, + "activity": "medium" + }, + { + "_id": "01HQ8K0RMZ9A52F8BDPAR0JKRE", + "name": "Starlight City | Anime+", + "banner": { + "_id": "hjQOtyar483kLcmJb6DOPKPuSf-RaiKi89g3C7SVPt", + "tag": "banners", + "filename": "Starlight City.png", + "metadata": { + "type": "Image", + "width": 529, + "height": 164 + }, + "content_type": "image/png", + "size": 155878 + }, + "description": "ANIME+ Server. \nEveryone Welcome (16+)\nEnglish Only!!", + "icon": { + "_id": "odP3IrTBbBhBoWrKGXYdw4s3242GtxEerPTu2vElI7", + "tag": "icons", + "filename": "img-yW0jCZHPou5zNsQesUZIf.jpeg", + "metadata": { + "type": "Image", + "width": 768, + "height": 768 + }, + "content_type": "image/jpeg", + "size": 130899 + }, + "tags": [], + "members": 101, + "activity": "low" + }, + { + "_id": "01H0M660HG4MQV4Q7SZ79EX6JF", + "name": "ReVoYo", + "description": "The Revolt server for all Hoyoverse fans out there!\n\nTags: Genshin GenshinImpact Honkai StarRail HSR ZZZ", + "banner": { + "_id": "2ZtqTBG6DrMHxpz1hqzIgTQkuVVQKen3DpTa7Gw22F", + "tag": "banners", + "filename": "moby-kokomi.jpg", + "metadata": { + "type": "Image", + "width": 2150, + "height": 1518 + }, + "content_type": "image/jpeg", + "size": 493196 + }, + "icon": { + "_id": "wq9PL5UqwbnAIP2B_LtXytzVHTZ0001U2aEvZt0ipc", + "tag": "icons", + "filename": "firefy.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 55193 + }, + "tags": [ + "genshin", + "genshinimpact", + "honkai", + "starrail", + "hsr", + "zzz" + ], + "members": 615, + "activity": "low" + }, + { + "_id": "01GT8N19H8Y8XRA6TMZ9JGX3CZ", + "name": "Programmer's Lounge", + "icon": { + "_id": "N_qoHg4BjHDYyp0FQFZ8SgbwH_cIGK4mHmputuqoA0", + "tag": "icons", + "filename": "Sequence-4-_1_.webp", + "metadata": { + "type": "Image", + "width": 190, + "height": 190 + }, + "content_type": "image/webp", + "size": 1815636 + }, + "banner": { + "_id": "cui2dtXLcihgWSoX1zGxZ0xonLRpLs0VsJtPaVm4l5", + "tag": "banners", + "filename": "ezgif.com-resize-_7_.webp", + "metadata": { + "type": "Image", + "width": 300, + "height": 225 + }, + "content_type": "image/webp", + "size": 1487472 + }, + "description": "👨‍💻[NEW SERVER] - The old server has been raided and we had to start again here.\n 💿\nLounge for programmers 😎 - non-programmers are welcome as well!\n\nrevolt programming Linux memelords", + "flags": 2, + "tags": [ + "revolt", + "programming", + "linux", + "memelords" + ], + "members": 2098, + "activity": "low" + }, + { + "_id": "01HXH7XSA0SSGTS9YQNR28GRWS", + "name": "El Chat", + "icon": { + "_id": "XW8qDDiG3gk7B6RoOY_92CEhCnoPcZJVF4O4tAv2--", + "tag": "icons", + "filename": "Picture1.png", + "metadata": { + "type": "Image", + "width": 670, + "height": 595 + }, + "content_type": "image/png", + "size": 365714 + }, + "description": "This server is for all things chat worthy (or to just hang out)\n\n(Btw this is not a Spanish server, we just used 'el' bc 'the' is boring) \ngeneral chill\n", + "banner": { + "_id": "Cs8v3PLc-9Z0Bze1_6m96n0muyG3TlEw2heXGx3O5N", + "tag": "banners", + "filename": "Tumblr_l_610160573231594.jpg", + "metadata": { + "type": "Image", + "width": 1200, + "height": 817 + }, + "content_type": "image/jpeg", + "size": 261713 + }, + "tags": [ + "general", + "chill" + ], + "members": 62, + "activity": "low" + }, + { + "_id": "01FYW7ZZMBA66K1ZEVN0JTD51Y", + "name": "Artists Guild", + "description": "The Artist Guild, welcome all artist whether you area a traditional 2D, 3D, Mixed Media, Animator, Comic, Sculptor or anything else. This is a place for fun, sharing, critiques if wanted, and networking.\n\nArt, art, Painting, painting, 2D, 2d, 3D, 3d, Animation, animation,", + "banner": { + "_id": "AagHiHfldnhf7jHinrMJBx5cHr_cX8Ywn2gUBt0k8k", + "tag": "banners", + "filename": "new crossed swords on shield.png", + "metadata": { + "type": "Image", + "width": 1787, + "height": 1621 + }, + "content_type": "image/png", + "size": 4384651 + }, + "icon": { + "_id": "UfoWGkddrARrsw1TG7y4YLSUsCbC5VYJS7VtnsboU2", + "tag": "icons", + "filename": "images.jpg", + "metadata": { + "type": "Image", + "width": 227, + "height": 222 + }, + "content_type": "image/jpeg", + "size": 28005 + }, + "tags": [ + "art", + "painting", + "2d", + "3d", + "animation" + ], + "members": 229, + "activity": "low" + }, + { + "_id": "01H3A6J2B2SR014NB46Z9T92KH", + "name": "Homestuck", + "icon": { + "_id": "L8QTOHkHolX_1IBsCa1mfijyp9iqNYwDUUD9KiHHt5", + "tag": "icons", + "filename": "homestuck server logo.png", + "metadata": { + "type": "Image", + "width": 800, + "height": 800 + }, + "content_type": "image/png", + "size": 17398 + }, + "description": "An unofficial server for all things Homestuck! The current banner is [\"The Furthest Ring\"](https://www.deviantart.com/ursca/art/The-Furthest-Ring-201864211) by [Ursca on Deviantart](https://www.deviantart.com/ursca/gallery)!", + "banner": { + "_id": "fYVf1dcwNnHr84cc46cOmgF3AQgLtnMwmaf9joHE18", + "tag": "banners", + "filename": "the outer ring.jpg", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 163349 + }, + "tags": [], + "members": 261, + "activity": "low" + }, + { + "_id": "01HXY6ND7F2CJJWV02H6M8TXPB", + "name": "Neo_Tokyo", + "icon": { + "_id": "lFtUK_BNe0S4vm1orj-RIfsRJ_10Y_-83ud-XsT-cp", + "tag": "icons", + "filename": "download.jpg", + "metadata": { + "type": "Image", + "width": 225, + "height": 225 + }, + "content_type": "image/jpeg", + "size": 17617 + }, + "banner": { + "_id": "FIdodQ4cYGLgU22w8ptHMxZ4qMPqd65HYx_4sL6GyE", + "tag": "banners", + "filename": "images.jpg", + "metadata": { + "type": "Image", + "width": 290, + "height": 174 + }, + "content_type": "image/jpeg", + "size": 29636 + }, + "description": "A place to connect users and share hobbies to make a good community.", + "tags": [], + "members": 21, + "activity": "low" + }, + { + "_id": "01HAXF5YV13S4J1C9YDSY9GRXC", + "name": "✴☽ _🗡 - MYSTICA - 🪄 _ ☾✴", + "icon": { + "_id": "meXiFrbnAz4ndNHVnzi0tHKPij0jkNDwCK9SfyJsw2", + "tag": "icons", + "filename": "Mystica_Logo-1-01 (1).jpg", + "metadata": { + "type": "Image", + "width": 1080, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 114826 + }, + "banner": { + "_id": "Dp2OVVpNOWMyCXHdmTFRhbZQTC1fLwW6lsD3aPgGfQ", + "tag": "banners", + "filename": "fnf-6666666666666-min.gif", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/gif", + "size": 3559343 + }, + "description": "🗡️𝐌𝐘𝐒𝐓𝐈𝐂𝐀🪄Communauté occulte de la maison d'édition #ésotérique MYSTICA\n\n𝐌𝐘𝐒𝐓𝐈𝐂𝐀, un sanctuaire secret dédié aux praticiens et chercheurs de l' occultisme ésotérique. Entre sorcières, alchimistes, francs-maçons et rose-croix, plongez dans un monde envoûtant où les rituels mystiques et les enseignements ésotériques se rejoignent. Êtes-vous prêt à évoluer spirituellement en explorant les profondeurs cachées de la connaissance secrète ? 🗝️🌌\n\n🌙 Un Univers de Connaissances Cachées :\nTous se réunissent pour partager leurs connaissances ésotériques. Vous découvrirez des secrets anciens, des grimoires sacrés et des pratiques magiques qui transcendent les limites de la réalité. 📚🧙\n\n🌌 Des Convents, Cercles de Cérémonies et Rituels :\nRejoignez nos convents disponibles dans chaque région et à l'étranger, où les étoiles guident nos rituels ésotériques. De la magie cérémonielle à l'astrologie ésotérique, devenez un de nos praticiens éclairés...", + "tags": [ + "occulte", + "occultisme", + "sorci", + "alchimistes", + "francs", + "convents", + "rituels" + ], + "members": 19, + "activity": "low" + }, + { + "_id": "01FFJEZKYD3JVWBW3DHREE6EQH", + "name": "Revoltijo en español", + "description": "¡Primer servidor hispanohablante de Revolt!\nCreado en septiembre de 2021.\n\nspanish español gaming linux english inglés", + "icon": { + "_id": "75cUFEfe2maMQ85ssZSBcvjibQ5d1VSdDQ8DIHZOPu", + "tag": "icons", + "filename": "revolt español icon.png", + "metadata": { + "type": "Image", + "width": 1360, + "height": 1310 + }, + "content_type": "image/png", + "size": 1183870 + }, + "banner": { + "_id": "XGRlSt_TbEsA95Uw_53pqSYUvpTFrwyXPVaHKNweZn", + "tag": "banners", + "filename": "revolt español banner.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 1961079 + }, + "tags": [ + "spanish", + "espa", + "gaming", + "linux", + "english", + "ingl" + ], + "members": 1055, + "activity": "low" + }, + { + "_id": "01HXGAAE0G57M1VW3Z12CTNE79", + "name": "Mospixel", + "icon": { + "_id": "GCKM1r8KJqTzXvfqBHj0ZybJGyFlTJzeGFdQTDhSO8", + "tag": "icons", + "filename": "20230321_222813.png", + "metadata": { + "type": "Image", + "width": 3264, + "height": 3264 + }, + "content_type": "image/png", + "size": 1601740 + }, + "description": "A minecraft server that allows cheating. No bans. No punishment.\nThere's only one gamemode, skywars.\nYou can also test the anticheat!", + "banner": { + "_id": "iFFYVWPk_UdSAK48qzt7zYOPhnC9G5NEJn9bKdoTYI", + "tag": "banners", + "filename": "2024-06-13_22.37.38_4K.png", + "metadata": { + "type": "Image", + "width": 3840, + "height": 2160 + }, + "content_type": "image/png", + "size": 6044170 + }, + "tags": [], + "members": 17, + "activity": "low" + }, + { + "_id": "01H7ZPK9TSYX12YRTRJ8PGBWBS", + "name": "ADSCRAFT Season X (PorcupineNet)", + "icon": { + "_id": "uuzuC1teebda4VXZgagOZ1HzOj2QcfU2lF9LN7ptEK", + "tag": "icons", + "filename": "Copy of server-icon.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 21647 + }, + "banner": { + "_id": "wCQ7jDFXKM_2svlUOx0s0D816VP2mUzj4Sy10cLWff", + "tag": "banners", + "filename": "Untitled design (1).png", + "metadata": { + "type": "Image", + "width": 2245, + "height": 1587 + }, + "content_type": "image/png", + "size": 285907 + }, + "description": "The Main chat for ADSCRAFT Season 10 and the Porcupine Network!", + "tags": [], + "members": 42, + "activity": "low" + }, + { + "_id": "01GXPNCVM4QTPCGP2DJ5CZ0M74", + "name": "🇫🇷 | La Révolte | 🇫🇷", + "description": "🇫🇷 | Bienvenue dans la Révolte! C'est LE serveur communautaire pour tous les francophones et tous les français sur Revolt! | 🇫🇷\n‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎ ‎\n‎ ‎\nfrancaisfrancophonecommunautairefrenchfrenchspeakerscommunityfrance", + "banner": { + "_id": "HR_4NyvawzKsPDf0ydBK-liM0qL6oYD01X5Bz-eG6a", + "tag": "banners", + "filename": "proxy-image (28).jpeg", + "metadata": { + "type": "Image", + "width": 1600, + "height": 1200 + }, + "content_type": "image/jpeg", + "size": 336751 + }, + "icon": { + "_id": "xWlzD8Be81y0rmPK0M3uZztChhqE-S4JebL-_YOwsa", + "tag": "icons", + "filename": "IMG_20231102_033937.png", + "metadata": { + "type": "Image", + "width": 425, + "height": 427 + }, + "content_type": "image/png", + "size": 181144 + }, + "tags": [ + "francais", + "francophone", + "communautaire", + "french", + "frenchspeakers", + "community", + "france" + ], + "members": 296, + "activity": "low" + }, + { + "_id": "01H9RVA6E180ZN1X6E025BMHQ8", + "name": "🌸 Kokoro | こころ Revolt 🌸", + "icon": { + "_id": "ITrnY3ZLHPgOnqK7LtWhkpNmue8X4UPUaKHepKiZCX", + "tag": "icons", + "filename": "image (1) (15) (2).jpeg", + "metadata": { + "type": "Image", + "width": 394, + "height": 382 + }, + "content_type": "image/jpeg", + "size": 44161 + }, + "banner": { + "_id": "3BvSztWyLAOfpzAtKfiW1eYKTjRSTlZ68AYp8XG31r", + "tag": "banners", + "filename": "cherry-blossom.gif", + "metadata": { + "type": "Image", + "width": 640, + "height": 360 + }, + "content_type": "image/gif", + "size": 4351267 + }, + "description": "🌸🧡Revolt Version of Guilded's Big Server Kokoro, Discord Bot Fully Out! Enter the Magic House!🧡🌸", + "tags": [], + "members": 115, + "activity": "low" + }, + { + "_id": "01HTK55PQTM9JC1TNMBA056H5C", + "name": "Termux", + "icon": { + "_id": "ANklm_EU-jT6RuxOoAdxstqhpxBtDk4K1PB80JrBeZ", + "tag": "icons", + "filename": "tewmux.gif", + "metadata": { + "type": "Image", + "width": 600, + "height": 600 + }, + "content_type": "image/gif", + "size": 43994 + }, + "banner": { + "_id": "BRIKuy6CdJM96JsB0T3H0hyPnXJZ66boS9G_DlIljH", + "tag": "banners", + "filename": "termux-banner.png", + "metadata": { + "type": "Image", + "width": 1440, + "height": 812 + }, + "content_type": "image/png", + "size": 76956 + }, + "description": "Termux is an Android terminal emulator and Linux environment app that works directly with no rooting or setup required.", + "tags": [], + "members": 42, + "activity": "low" + }, + { + "_id": "01GQFCFF0MAT571TESKPW96CZ9", + "name": "AI Enthusiasts", + "description": "A server for those that are excited for the advancements in AI. And for those that like AI art.\nWe have a stable diffusion bot to generate images.\n\n\nAI Stable_diffusion art chatgpt gpt machine_learning #", + "icon": { + "_id": "Ru4C0CS1iZQ8WuQjAjol1zPF9QQC8S3dHBjHoLqfUW", + "tag": "icons", + "filename": "downloa2d.jpg", + "metadata": { + "type": "Image", + "width": 768, + "height": 768 + }, + "content_type": "image/jpeg", + "size": 128370 + }, + "banner": { + "_id": "QAp-4VLQWq6LjOjO2gqVn2_RbYFO3lQXjkM6HLVh0_", + "tag": "banners", + "filename": "robot painting.png", + "metadata": { + "type": "Image", + "width": 1042, + "height": 512 + }, + "content_type": "image/png", + "size": 1073895 + }, + "tags": [ + "ai", + "stable_diffusion", + "art", + "chatgpt", + "gpt", + "machine_learning" + ], + "members": 694, + "activity": "low" + }, + { + "_id": "01GK7CWZJ6RF7PXBW8DW0D6AT3", + "name": "The Pitlane: EURO 2024 Edition", + "description": "**Current Event:** 🇪🇺 UEFA EURO 2024. Hello and welcome to ***the Pitlane*** where it's mainly but not exclusively about sports, racing and motorsports.", + "icon": { + "_id": "lCgK3haewxs6-Sfr7RDgF9hAWGHWpE0yegUyEtisaH", + "tag": "icons", + "filename": "EURO Icon.png", + "metadata": { + "type": "Image", + "width": 1000, + "height": 1000 + }, + "content_type": "image/png", + "size": 773812 + }, + "banner": { + "_id": "fZiBW5mq6UpewZB109eyMLDCu6SIFy0S8HGgzw9mFt", + "tag": "banners", + "filename": "euro.png", + "metadata": { + "type": "Image", + "width": 912, + "height": 513 + }, + "content_type": "image/png", + "size": 246574 + }, + "tags": [ + "uefa", + "euro", + "sports", + "racing", + "motorsports" + ], + "members": 34, + "activity": "low" + }, + { + "_id": "01FE6SE1J3GQPGC7W8APGZ6T4M", + "name": "Nittama", + "description": "Para quienes buscan un lugar ordenado y agradable donde habitar. ¡Bienvenidos al templo en el que la calidad vence a la cantidad! Tenemos por objetivo ser y hacer amigos: aquí nadie es un simple número.\nsocial juegos arte anime ocio pasatiempos bots windows sociedad humanidades waifus husbandos nittama español\nServidor creado el 28 de agosto de 2021.", + "banner": { + "_id": "ZWX57CqFkA7QD7_x0eEg7Jk79cbf9Xf72GPhfQG96_", + "tag": "banners", + "filename": "Nittama Estandarte v2.png", + "metadata": { + "type": "Image", + "width": 800, + "height": 432 + }, + "content_type": "image/png", + "size": 584683 + }, + "icon": { + "_id": "Bat4jOksLMWgxHKLZtJlkFRV2peIzNzqTNl7BdW1sI", + "tag": "icons", + "filename": "tama.gif", + "metadata": { + "type": "Image", + "width": 192, + "height": 192 + }, + "content_type": "image/gif", + "size": 1147973 + }, + "tags": [ + "social", + "juegos", + "arte", + "anime", + "ocio", + "pasatiempos", + "bots", + "windows", + "sociedad", + "humanidades" + ], + "members": 22, + "activity": "low" + }, + { + "_id": "01HZB918XT1A42YMEEARQ21J5S", + "name": "Olympus RP Hub", + "icon": { + "_id": "AhT3OM8nIpKjJN9XCCL5HXDx-teRgV6XQCCw7vMYCt", + "tag": "icons", + "filename": "fasd (16).png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 401050 + }, + "banner": { + "_id": "-WiXYYieKxhmGKQxtT6-HPvSYEc3SwRh9h7iy8x-9b", + "tag": "banners", + "filename": "Nikki Marie (5).png", + "metadata": { + "type": "Image", + "width": 680, + "height": 240 + }, + "content_type": "image/png", + "size": 258200 + }, + "description": "Olympus RP Hub is a generalized Roleplay Server where users can chat about the hobby, get and give advice, find RP partners, and connect with other writers should they decide to create group games of their own. Roleplayers from all walks of the hobby are invited to join, hang out, and help make this community into more of what they would like to see from the hobby as a whole. \n\nRP roleplay roleplaying ttrp tbrp writing creativewriting literaterp", + "tags": [ + "rp", + "roleplay", + "roleplaying", + "ttrp", + "tbrp", + "writing", + "creativewriting", + "literaterp" + ], + "members": 34, + "activity": "low" + }, + { + "_id": "01HT96GXFPQCP100NF9NE0BAS8", + "name": "Saiko's Refuge", + "description": "A chat room simlar to a web chat room. Have fun. no racism, no bullshit. We also have lore now so theorise and read it.", + "icon": { + "_id": "PDwmCcSa-WIPwVqTwixmZFO3KB8269X5UlBNVoMRaq", + "tag": "icons", + "filename": "cartoon-lofi-young-manga-style-girl-while-listening-to-music-in-the-rain-ai-generative-photo.jpg", + "metadata": { + "type": "Image", + "width": 714, + "height": 400 + }, + "content_type": "image/jpeg", + "size": 59195 + }, + "banner": { + "_id": "UQoVVPO-PAW5HhlROaRJ_9armJlKRtxKixNJPeGEgj", + "tag": "banners", + "filename": "cartoon-lofi-young-manga-style-girl-while-listening-to-music-in-the-rain-ai-generative-photo.jpg", + "metadata": { + "type": "Image", + "width": 714, + "height": 400 + }, + "content_type": "image/jpeg", + "size": 59195 + }, + "tags": [], + "members": 37, + "activity": "low" + }, + { + "_id": "01H2V5VGM6GKX2BEMJHSSXY20J", + "name": "TXZ Official Server", + "description": "This is the TXZ Official Revolt Server, originally made in January 19, 2020 on Discord by A TXZ Project. A lot of us play Geometry Dash, Osu! and Minecraft, a chill community where you can get easily involved here! Currently, we are under construction, but hope you feel great here :)\ngeometrydash minecraft mc osu! gdps gaming spanish english español games discord anime chill", + "icon": { + "_id": "ZohqTf0D4x_7COIcX-2uDV6ElT-vfjmiDgO6qrHHf2", + "tag": "icons", + "filename": "1000007419.jpg", + "metadata": { + "type": "Image", + "width": 577, + "height": 577 + }, + "content_type": "image/jpeg", + "size": 25352 + }, + "banner": { + "_id": "qvT-8yglTwvvdLkE3EXadU48JmID1S-nWrxnBp7Dua", + "tag": "banners", + "filename": "TXZNewBanner.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 643139 + }, + "tags": [ + "geometrydash", + "minecraft", + "mc", + "osu", + "gdps", + "gaming", + "spanish", + "english", + "espa", + "games" + ], + "members": 15, + "activity": "low" + }, + { + "_id": "01GZYC59WCH8SB16Z9V06K57GH", + "name": "Pokémon: Revolt", + "banner": { + "_id": "V6aUnHPT89ix72S3noylO5Re25u5OjchbGYko3eZ45", + "tag": "banners", + "filename": "sky.png", + "metadata": { + "type": "Image", + "width": 2000, + "height": 1230 + }, + "content_type": "image/png", + "size": 978748 + }, + "description": "Welcome to the Pokémon Revolt Community Server, for Art, News and all kinds of discussions regarding the Games and Anime, as well as Tech in general. \nWe won't bite :3\n\n\npokemon gaming art nintendo lucario Pokemon digimon # Digimon", + "icon": { + "_id": "6jX4C17c4pLTOAZ54rnfaptPhPUlERyDrACo2hW9DQ", + "tag": "icons", + "filename": "TR_i_pkp1.png", + "metadata": { + "type": "Image", + "width": 138, + "height": 137 + }, + "content_type": "image/png", + "size": 23034 + }, + "tags": [ + "pokemon", + "gaming", + "art", + "nintendo", + "lucario", + "digimon" + ], + "members": 448, + "activity": "low" + }, + { + "_id": "01H3QFBM82MEAZK4HT8FXJ8PD6", + "name": "CYBΞRLabs", + "icon": { + "_id": "m613XMTBnec1nFyl5Ge3wwePPj8w6urHOxlPbVthv0", + "tag": "icons", + "filename": "Discord_logo_for_some_CYBERLABS_thing.png", + "metadata": { + "type": "Image", + "width": 1207, + "height": 1207 + }, + "content_type": "image/png", + "size": 44185 + }, + "description": "The Support server for **Revoltanator** A fun/multipurpose bot!\nAlso the home of MPPBridge!", + "banner": { + "_id": "VyPDylApVm4OHA5C4kQZFoTF2MF3eyEJOcHOQx8WVC", + "tag": "banners", + "filename": "ComfyUI_00011_.png", + "metadata": { + "type": "Image", + "width": 1248, + "height": 824 + }, + "content_type": "image/png", + "size": 1666670 + }, + "tags": [], + "members": 28, + "activity": "low" + }, + { + "_id": "01GG4G9PSPQH338SZQQB7YXCYQ", + "name": "Femboy Lounge", + "description": "A comfy place for all your femboy needs! ✨ :3\n\nWhether you identify as a femboy or are simply curious, feel free to join! ❤️️", + "icon": { + "_id": "ukwPS8XCWZ2Etd8trwaBn906f8q-CQHCFMTblgfgdo", + "tag": "icons", + "filename": "Ev9eA6vVcAEs-hs.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 116044 + }, + "banner": { + "_id": "5ND7kH5wiPPhgDjGz4qvXcpfC1c6IKN4C-5nVVBR-L", + "tag": "banners", + "filename": "ddmxnoa-0d31447a-ac6b-4c2a-8dcd-3bf0164c2512.png", + "metadata": { + "type": "Image", + "width": 2000, + "height": 1162 + }, + "content_type": "image/png", + "size": 1560906 + }, + "tags": [ + "comfy", + "femboy" + ], + "members": 2701, + "activity": "low" + }, + { + "_id": "01H4NBR374CYXKPRX4K9ECHZR3", + "name": "~ p l a c e h o l d e r ~", + "icon": { + "_id": "mWUdAgLRrItV20qbOJw9G0yghLiTu4qvA7qBZWzviU", + "tag": "icons", + "filename": "bridgetpinkcrop.png", + "metadata": { + "type": "Image", + "width": 675, + "height": 675 + }, + "content_type": "image/png", + "size": 483328 + }, + "banner": { + "_id": "yfdBGb9hZ8jo9fcloFfta3smDvDbcXGimgoyJpcDZe", + "tag": "banners", + "filename": "cmhJ4PMid4S8aPFN150LAt6QBhElBz_Hsp51qQtKgspink.png.png", + "metadata": { + "type": "Image", + "width": 480, + "height": 270 + }, + "content_type": "image/png", + "size": 49728 + }, + "description": "A silly gay server for silly gay goobers :3\n\nDo not join if you are below 18 years old", + "tags": [], + "members": 879, + "activity": "low" + }, + { + "_id": "01HKG62HFBA7HNEGZRB1GB5FW9", + "name": "restress/", + "description": "Русский сервер для общения с нотками видеоигры Doki Doki Literature Club(в частности по персонажу по имени Моника). Имеется мост с сервером stresscord(дискорд)\n\nTags/Тэги: russian ddlc nsfw monika bridged", + "banner": { + "_id": "5va9e4Rw6KP1V1UUgR3H4TZsuk-snejcZLMygjFJlu", + "tag": "banners", + "filename": "txt_1699709623248.jpg", + "metadata": { + "type": "Image", + "width": 4554, + "height": 2560 + }, + "content_type": "image/jpeg", + "size": 506312 + }, + "icon": { + "_id": "7Yyz7GakUjqmQfLtHlB9mmA7vilTkL8zHc0rQ7ejK0", + "tag": "icons", + "filename": "IMG_20240210_185350759_processed.jpg", + "metadata": { + "type": "Image", + "width": 2280, + "height": 2280 + }, + "content_type": "image/jpeg", + "size": 826092 + }, + "tags": [], + "members": 161, + "activity": "low" + }, + { + "_id": "01G45V2YS1TVTHCBDQ15R51WNJ", + "name": "Infinity", + "icon": { + "_id": "6q4OfKD585rP1GAeAiYo77GgrL3-4P1Vuo_rQBLg3x", + "tag": "icons", + "filename": "NITW9.png", + "metadata": { + "type": "Image", + "width": 194, + "height": 189 + }, + "content_type": "image/png", + "size": 65247 + }, + "banner": { + "_id": "jmypp6MkET6IEY1S3foftMX8Ah__483pmbDRFzRJnx", + "tag": "banners", + "filename": "NITW10.jpg", + "metadata": { + "type": "Image", + "width": 1024, + "height": 551 + }, + "content_type": "image/jpeg", + "size": 104871 + }, + "description": "🏳‍🌈We are a friendly welcoming community!\nMake friends, participate in events, and have fun!\nLots of NITW and Chicory emotes! \nGaming Community Chill LGBTQ Furry General NITW NightInTheWoods Night_in_the_Woods Chicory Chicory_A_Colorful_Tale", + "tags": [ + "gaming", + "community", + "chill", + "lgbtq", + "furry", + "general", + "nitw", + "nightinthewoods", + "night_in_the_woods", + "chicory" + ], + "members": 122, + "activity": "low" + }, + { + "_id": "01GVB8HXQCWQDCB63E24H5BZA3", + "name": "Bureaucracy: The Server", + "icon": { + "_id": "E9Gk-TgP1cQ9IYTnuirl5hCwf265vcr6NRHfCLOu5t", + "tag": "icons", + "filename": "monopoly man.jpg", + "metadata": { + "type": "Image", + "width": 172, + "height": 196 + }, + "content_type": "image/jpeg", + "size": 8200 + }, + "description": "Defending free speech and expression, a task that Revolt is making difficult. Regardless, our work must continue. \n", + "banner": { + "_id": "jiSDBMPQUGIFX6xGiyjHcVLgUKfXxrAUOuIIi1La5o", + "tag": "banners", + "filename": "claptrap robolution.png", + "metadata": { + "type": "Image", + "width": 474, + "height": 336 + }, + "content_type": "image/png", + "size": 390741 + }, + "tags": [], + "members": 88, + "activity": "low" + }, + { + "_id": "01HZJVJZRCVKRHKQY47PCMD6MZ", + "name": "UK Vote 2024 General Election", + "icon": { + "_id": "XS71mCR_0m85gyzMUWY1v0Ji2bg2yDc1-u2AJTn1HT", + "tag": "icons", + "filename": "OIP (7).jfif", + "metadata": { + "type": "Image", + "width": 315, + "height": 198 + }, + "content_type": "image/jpeg", + "size": 23434 + }, + "banner": { + "_id": "zO0LAhRmv_blp5ib_XHBaW_qYbwCUJcnveT1pAGosl", + "tag": "banners", + "filename": "d34cb1f60a9045598b7e37150dbdd242_18.jpg", + "metadata": { + "type": "Image", + "width": 1000, + "height": 562 + }, + "content_type": "image/jpeg", + "size": 126527 + }, + "description": "UK Focused Revolt and Discord server for the UK Vote 2024 General Election\n\npolitics left right election uk dxr", + "tags": [ + "politics", + "left", + "right", + "election", + "uk", + "dxr" + ], + "members": 16, + "activity": "low" + }, + { + "_id": "01FE6HQ77JYN0ERD5F5GTP5XMN", + "name": "Fluxpoint Development", + "icon": { + "_id": "EFeT0YxtMGjh_vs6YK447RQOvwDYAK-zWAkhW54p8b", + "tag": "icons", + "filename": "fluxpoint.png", + "metadata": { + "type": "Image", + "width": 2048, + "height": 2048 + }, + "content_type": "image/png", + "size": 150210 + }, + "description": "A community focused server for developers, gamers and anime fans that enjoy doing what we love.", + "banner": { + "_id": "NFB2C3DN4_TI73Lgh62umMfUkvRL1lGfyTmcV3es1G", + "tag": "banners", + "filename": "Webp.net-resizeimage(3).png", + "metadata": { + "type": "Image", + "width": 320, + "height": 320 + }, + "content_type": "image/png", + "size": 196632 + }, + "tags": [], + "members": 351, + "activity": "low" + }, + { + "_id": "01HTXX6G6JTTMBTT5B2Q0KV73B", + "name": "Lanterna dos Revoltados 🌽", + "description": "Servidor descontraído com intuito de reunir usuários brasileiros ou interessados em conhecer o país.\n\nbrazil general portuguese chatting chill brasil geral português conversar BR Pt-BR", + "banner": { + "_id": "ZAmOCmQBEcVvQUrXvMOGXsEqTrCoouIJDwqf0un9Fj", + "tag": "banners", + "filename": "istockphoto-1396194786-612x612.png", + "metadata": { + "type": "Image", + "width": 612, + "height": 253 + }, + "content_type": "image/png", + "size": 117954 + }, + "icon": { + "_id": "tHsUyl31Zeb4h6dwm4LbjulcHi_5b1wl__OvQ4RNWk", + "tag": "icons", + "filename": "1446882ad46daafb5ada89eb162a967b17ea2a73ba7f9b1cff2f85cb2239f6fb_1_cropped.png", + "metadata": { + "type": "Image", + "width": 291, + "height": 291 + }, + "content_type": "image/png", + "size": 146993 + }, + "tags": [ + "brazil", + "general", + "portuguese", + "chatting", + "chill", + "brasil", + "geral", + "portugu", + "conversar", + "br" + ], + "members": 38, + "activity": "low" + }, + { + "_id": "01FZB38TYPX73VSWFMMJTZE8C5", + "name": "Freakyville", + "icon": { + "_id": "tBqQ2eh6GZdZs3xnnO0llkd1Mde1gIOu-cXOCqSwpp", + "tag": "icons", + "filename": "logo.png", + "metadata": { + "type": "Image", + "width": 226, + "height": 235 + }, + "content_type": "image/png", + "size": 21591 + }, + "description": "This is a pretty chill server, if you're confused on how you joined this, this used to be the Mecha server. Annnnyyyyyy ways just join this server for a cookie and also to keep up to date on my latest projects, memes, inside jokes, and other stuff. Also linked with my personal discord server\n\nvoltage bot bots developer developing python py revoltbot community programming programmer support mechasupport supportserver mechabot mecha chill coding coder", + "banner": { + "_id": "3zTbBjh_RCVGTSN3GtK1FyAAa6AIaG6CvozT8NFnRQ", + "tag": "banners", + "filename": "20240129200003_1.jpg", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 436094 + }, + "tags": [ + "voltage", + "bot", + "bots", + "developer", + "developing", + "python", + "py", + "revoltbot", + "community", + "programming" + ], + "members": 130, + "activity": "low" + }, + { + "_id": "01GV54XVZBV12SYCC7CD8223K4", + "name": "Weeb's Cafe", + "description": "Hi. We like anime, manga, gaming and even films!\n", + "icon": { + "_id": "rnx_fuk7gp5C4bJ9GH9nWeZtTwWyzaQwir2m8pvAu5", + "tag": "icons", + "filename": "tumblr_c3f21628338e10f4a2c22c00b0171e13_30f2da15_1280.jpg", + "metadata": { + "type": "Image", + "width": 1179, + "height": 801 + }, + "content_type": "image/jpeg", + "size": 118416 + }, + "banner": { + "_id": "rbuiMpRFbgQ6wtUlhBwk_509mRA1wA1Nf5IUQ_HqZo", + "tag": "banners", + "filename": "anime-asuka.gif", + "metadata": { + "type": "Image", + "width": 498, + "height": 372 + }, + "content_type": "image/gif", + "size": 3144389 + }, + "tags": [], + "members": 547, + "activity": "low" + }, + { + "_id": "01GVQJVVFM233EHBV03K7B3HF5", + "name": "SuperTuxKart", + "icon": { + "_id": "-rzoEJPeWD8rAJJWNEx_6KfcYUPd-8C1zn53YUe6MP", + "tag": "icons", + "filename": "supertuxkart_256.png", + "metadata": { + "type": "Image", + "width": 256, + "height": 256 + }, + "content_type": "image/png", + "size": 52492 + }, + "description": "The (un)official SuperTuxKart Revolt server! SuperTuxKart is a 3D open source kart racing game with a variety of tracks, characters, and modes to play.", + "banner": { + "_id": "x3HyHSvMTc8nJpaRCPc5AP_SKHMin-kFQpayQjY5fg", + "tag": "banners", + "filename": "Screenshot from 2023-07-01 13-37-53.png", + "metadata": { + "type": "Image", + "width": 1361, + "height": 695 + }, + "content_type": "image/png", + "size": 1531368 + }, + "tags": [ + "supertuxkart" + ], + "members": 254, + "activity": "low" + }, + { + "_id": "01GKFQ23DBDBK1D978F5GTSNZ7", + "name": "Blobfox", + "banner": { + "_id": "7m8AVQv2CgBSmw1BBcyGN3zwPMiSt3EObAkDlEXWHh", + "tag": "banners", + "filename": "blobfox-banner3.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 1030870 + }, + "icon": { + "_id": "YcVEbFfn06htn2-zBSNq9GI-jdsPxOhBwjsoNOJHJU", + "tag": "icons", + "filename": "blobfoxbox.png", + "metadata": { + "type": "Image", + "width": 128, + "height": 128 + }, + "content_type": "image/png", + "size": 4272 + }, + "description": "A chat for those who really like foxxos :D \n\nAlso contains blobfox emojis.", + "tags": [], + "members": 505, + "activity": "low" + }, + { + "_id": "01H3K7D3N3X9AQNAKWYWT916WY", + "name": "Splatlands", + "icon": { + "_id": "5HyUDPiEmbtY77onG6aE4f1KCm-guIH2AMjad6fU1v", + "tag": "icons", + "filename": "InklingUsingSplatNet_cropped.png", + "metadata": { + "type": "Image", + "width": 679, + "height": 679 + }, + "content_type": "image/png", + "size": 259218 + }, + "banner": { + "_id": "-fQu2XCKU7qXcl5EktAD7VS9BA3SnUHIzbEeDE4QoV", + "tag": "banners", + "filename": "ec39a30dec6162a8855a7be5717d5e0d-744733100 (1).gif", + "metadata": { + "type": "Image", + "width": 480, + "height": 320 + }, + "content_type": "image/gif", + "size": 138684 + }, + "description": "400+ members & active! 🦑 A vibrant cross-platform community for battles, art, tips, & ink-redible fun! - see splatlands.com/revolt-metrics for why it says \"low activity\" - splatoon , splatoon2 , splatoon3 , nintendo , gaming, switch , wiiu", + "flags": 2, + "tags": [ + "splatoon", + "splatoon2", + "splatoon3", + "nintendo", + "gaming", + "switch", + "wiiu" + ], + "members": 110, + "activity": "low" + }, + { + "_id": "01G6JJDXXTC87T4VK2Y1A67M0C", + "name": "PopCord Support", + "icon": { + "_id": "HdZhrklZqHW8NmQnxa3Yo6BWdfgsjTiTudfI-6eg8R", + "tag": "icons", + "filename": "image_2023-07-06_015057428.png", + "metadata": { + "type": "Image", + "width": 636, + "height": 636 + }, + "content_type": "image/png", + "size": 413371 + }, + "banner": { + "_id": "CJ0uNsmy4rJmuitfiIgCuKvtLmXuktX0x93msEg6EY", + "tag": "banners", + "filename": "image_2023-09-30_222236459.png", + "metadata": { + "type": "Image", + "width": 1200, + "height": 300 + }, + "content_type": "image/png", + "size": 380078 + }, + "description": "the official support server of PopCord\n\ntags : nsfw support lewd anime hentai game adult social admin", + "tags": [ + "nsfw", + "support", + "lewd", + "anime", + "hentai", + "game", + "adult", + "social", + "admin" + ], + "members": 444, + "activity": "low" + }, + { + "_id": "01J019XD6STD9F76CDBM41MXHJ", + "name": "Evelyn's Sillies | Revolt 🌸", + "description": "Welcome to Evelyn's server! We hope you enjoy your stay here.", + "icon": { + "_id": "nrI-8zRH1kWZ67c-Fuj5QzZ5yjMVs6ru0DE_oLBNwY", + "tag": "icons", + "filename": "f0297c7462b4ec4e8b33914201afe304.jpg", + "metadata": { + "type": "Image", + "width": 474, + "height": 470 + }, + "content_type": "image/jpeg", + "size": 39173 + }, + "banner": { + "_id": "6WU6lKuPryw3vZ3y8R_kq2nQqPoRQD_0fa_G4IoBR2", + "tag": "banners", + "filename": "886ddaf1d23845256d097a340b08fc30.jpg", + "metadata": { + "type": "Image", + "width": 736, + "height": 414 + }, + "content_type": "image/jpeg", + "size": 21965 + }, + "tags": [], + "members": 16, + "activity": "low" + }, + { + "_id": "01HZ7C8RCF989ZKE783ADDWZNV", + "name": "Chlove's Cabin", + "icon": { + "_id": "rA03w4ObPzWIccWM5POlS8ewgHbLt0usbdkYml_hpi", + "tag": "icons", + "filename": "f42c89c57a58181a6b8152d50c55c952-Large.png", + "metadata": { + "type": "Image", + "width": 450, + "height": 450 + }, + "content_type": "image/png", + "size": 457804 + }, + "banner": { + "_id": "Kf30G7xjTOBm8X6T4rDktm2p6nIB9bJFBgLiSMCYIv", + "tag": "banners", + "filename": "1717083426.jpg", + "metadata": { + "type": "Image", + "width": 1200, + "height": 400 + }, + "content_type": "image/jpeg", + "size": 56053 + }, + "description": "Welcome to my cozy little Revolt server, everyone is welcome!", + "tags": [], + "members": 25, + "activity": "low" + }, + { + "_id": "01HZWWK052VKHKYK77RXWG7E0T", + "name": "Wales Teens", + "banner": { + "_id": "_znlbdlgHu2-oyafsekbdyyHXq2M1VTLt7vUhOLSXA", + "tag": "banners", + "filename": "0_yes-Cymru-Rally.webp", + "metadata": { + "type": "Image", + "width": 1841, + "height": 1227 + }, + "content_type": "image/webp", + "size": 259216 + }, + "icon": { + "_id": "UhkX6V7WzJQT_MdBA6ppywDjZRQmz0Bl3dANYH7lLV", + "tag": "icons", + "filename": "Screenshot_20240608_234337_Color-removebg-preview.png", + "metadata": { + "type": "Image", + "width": 499, + "height": 500 + }, + "content_type": "image/png", + "size": 119191 + }, + "description": "just Revolt x Discord server for any teens who are Welsh or live in Wales.\n\nplease consider joining as most of the other Welsh community servers are dead :)\n\nwe're still a small server but we do have a fairly active community\n\nWales Celtic Welsh Politics 14+ Community Friends politics dxr", + "tags": [ + "wales", + "celtic", + "welsh", + "politics", + "14", + "community", + "friends", + "dxr" + ], + "members": 7, + "activity": "low" + }, + { + "_id": "01HDYRVKEA83PFHY1NZH8C00C6", + "name": "⟳ .. Venting", + "banner": { + "_id": "DTjK7SiZCs08eMahEk7Mptik2WUyuhGo-MPXTO2tdr", + "tag": "banners", + "filename": "23a1724d1094c0a03bebb8072db7eca5.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 282678 + }, + "icon": { + "_id": "-GPafG3-JkiP_tGcfs72DEeQjGkm3JsXCwMS4SXSwf", + "tag": "icons", + "filename": "f68ed1d48ebc16f05ee3a4b22619afe9.jpg", + "metadata": { + "type": "Image", + "width": 236, + "height": 236 + }, + "content_type": "image/jpeg", + "size": 13357 + }, + "description": "> started on 10.29.23 .. this server's theme is: Echo by Crusher-P\n\n> **Welcome .. This is a comfort chat for ones who need help. We can also hangout.**", + "tags": [], + "members": 79, + "activity": "low" + }, + { + "_id": "01HYXTJ5T5ZNF82BM6ZNWVW47M", + "name": "Trace's Chill Corner トレースのチルコーナー", + "banner": { + "_id": "t2oIqupetequV1tA71x2evRa6eHJypR5AM0TsXKylt", + "tag": "banners", + "filename": "abstract-purple-moon-wallpaper-2560x1600.jpg", + "metadata": { + "type": "Image", + "width": 2560, + "height": 1548 + }, + "content_type": "image/jpeg", + "size": 670876 + }, + "icon": { + "_id": "seIYj_RNuz3TvxgJg4BkS0eetCdUSF7D72WbGEiyPo", + "tag": "icons", + "filename": "Trace's Chill Cornerトレースのチルコーナー", + "metadata": { + "type": "Image", + "width": 431, + "height": 431 + }, + "content_type": "image/jpeg", + "size": 34376 + }, + "description": "This server is a small friendly and wholesome community, where we can have a nice chat, share moments and experiences together, chill, vibe to some music, play games and have fun.", + "tags": [], + "members": 18, + "activity": "low" + }, + { + "_id": "01GA6VT1QGYPBBSQJG824664N5", + "name": "Capp n' Ccino", + "icon": { + "_id": "3gaTmL5spsKRyxDTDLVd1ffCyepOJRGS38SFadsK82", + "tag": "icons", + "filename": "capp.png", + "metadata": { + "type": "Image", + "width": 700, + "height": 700 + }, + "content_type": "image/png", + "size": 24751 + }, + "banner": { + "_id": "thvkrmF41c_sFbuDHFd0PvUvpcIZT-oMa3QXf_WtPS", + "tag": "banners", + "filename": "banner-cappu.png", + "metadata": { + "type": "Image", + "width": 1280, + "height": 720 + }, + "content_type": "image/png", + "size": 180687 + }, + "description": "🟢⚪Capp n' Ccino⚪🟢・📶 Servidor inovador・💬 Chats temáticos e diversos・🏆 Eventos・🐟 Peixe Alves (Deus) ・🇵🇹🇧🇷 Servidor português (PT/BR - Portugal e Brasil)", + "tags": [], + "members": 150, + "activity": "low" + }, + { + "_id": "01H89E5HFQHYZ68FSR7QCM4J0D", + "name": "⭐ | AutumnWoods", + "description": "Comunidade de ponte ao servidor com o mesmo nome no discord e atualmente o maior servidor furry do brasil no revolt.", + "icon": { + "_id": "Uq-FuS9fQla4fE3Fo16ShK81TwS-UIIt4lwIrLUwQg", + "tag": "icons", + "filename": "e87ce6de2fb6297e085b91c9c33173ce.png", + "metadata": { + "type": "Image", + "width": 469, + "height": 469 + }, + "content_type": "image/png", + "size": 105297 + }, + "banner": { + "_id": "wmSad7pB81OAtOMJJEOY9BoHC7XRjM0cQEzoyQDITt", + "tag": "banners", + "filename": "fda3f0ea0fbff59f8f2d442f7c0ccf05.png", + "metadata": { + "type": "Image", + "width": 1200, + "height": 821 + }, + "content_type": "image/png", + "size": 1690641 + }, + "tags": [ + "brasil" + ], + "members": 43, + "activity": "low" + }, + { + "_id": "01GQ14WC58C8AXCWNJQBFDZNT3", + "name": "RevoltBots", + "icon": { + "_id": "ep-ZfhyRjEbl3O1uLuLUOP8bJYqixm27KRhm9VXllz", + "tag": "icons", + "filename": "Pride.png", + "metadata": { + "type": "Image", + "width": 186, + "height": 186 + }, + "content_type": "image/png", + "size": 10369 + }, + "description": "The community and support server for the very first open-sourced Revolt Bot List.\nMade for an easier way to find bots and servers. \n\ncommunity support botlist open-sourced programming", + "banner": { + "_id": "KxuVkC7Q0_yBtUQCZm5FyUUsE8w0Mh26wBw-lOXEDH", + "tag": "banners", + "filename": "revolt-bot-list-banner-by-axorax.png", + "metadata": { + "type": "Image", + "width": 6912, + "height": 3456 + }, + "content_type": "image/png", + "size": 1634583 + }, + "tags": [ + "community", + "support", + "botlist", + "open", + "programming" + ], + "members": 583, + "activity": "low" + }, + { + "_id": "01GZXEJSR4YGRZ0780FB0TVQWR", + "name": "👊Unis Game Clube🎮", + "icon": { + "_id": "QwCNtMfe94cRHhbKCImh4AuNG04DebXC4zKnXGHK14", + "tag": "icons", + "filename": "UGC GAMES.png", + "metadata": { + "type": "Image", + "width": 628, + "height": 628 + }, + "content_type": "image/png", + "size": 125897 + }, + "banner": { + "_id": "2IBeL7cC1Z4mg3VhxHOYUbMvSU8lVigiXO1D1WMT6B", + "tag": "banners", + "filename": "Cyber_Punk.gif", + "metadata": { + "type": "Image", + "width": 498, + "height": 149 + }, + "content_type": "image/gif", + "size": 607446 + }, + "description": "Grupo Criado Especialmente Para o Publico >GAMER< Em Geral.\n\nbrasil revolt gaming community chat", + "tags": [ + "brasil", + "revolt", + "gaming", + "community", + "chat" + ], + "members": 29, + "activity": "low" + }, + { + "_id": "01H2NVSP015K3GQGEPGD09WZ4H", + "name": "Lucario's Hangout", + "icon": { + "_id": "aOO2Mk2eOpUjc7bQu38SfbXrMnR_fWkIuyjcI_cft7", + "tag": "icons", + "filename": "lucaROI again04a622a1316109f4a52b0a2a0b9714.jpg", + "metadata": { + "type": "Image", + "width": 564, + "height": 795 + }, + "content_type": "image/jpeg", + "size": 88966 + }, + "banner": { + "_id": "B34TbbuaN_qzI3QMyam7WA4_1XgnPOPjesQ1SYcPst", + "tag": "banners", + "filename": "lucariobannerQ8aHU8AASURK.jpg", + "metadata": { + "type": "Image", + "width": 1200, + "height": 400 + }, + "content_type": "image/jpeg", + "size": 112935 + }, + "description": "Welcome to Lucario's server! Here, you will find a lot of nice people and fun bots, such as level bots! This server is for everyone, no matter what. There are advertising channels too, if you'd like to grow your server, so feel free to join! o-o\n\n\nlucario pokemon revolt chill discord", + "tags": [ + "lucario", + "pokemon", + "revolt", + "chill", + "discord" + ], + "members": 83, + "activity": "low" + }, + { + "_id": "01FR5JKX0N7CAMETQF2KWPCQDY", + "name": "Minecraft", + "icon": { + "_id": "BcraeSn8FndarGQHF26U22y8OXZrqnuBnMG3XDwTrB", + "tag": "icons", + "filename": "revolt_mc_icon.png", + "metadata": { + "type": "Image", + "width": 1079, + "height": 1079 + }, + "content_type": "image/png", + "size": 3974 + }, + "banner": { + "_id": "YKEaIq2CK2hU02yQS0MBwRVWsH21J-FkenRr4ooXRj", + "tag": "banners", + "filename": "revolt_mc_banner.png", + "metadata": { + "type": "Image", + "width": 3072, + "height": 1024 + }, + "content_type": "image/png", + "size": 2151 + }, + "description": "Revolt Minecraft community run by Minecraft enthusiasts for players across the globe! Minecraft Gaming MC Gamer Memes Fun", + "tags": [ + "minecraft", + "gaming", + "mc", + "gamer", + "memes", + "fun" + ], + "members": 1201, + "activity": "low" + }, + { + "_id": "01H5XHVJ8FKHN8WXHWCV4MV4H4", + "name": "Gaming and more", + "icon": { + "_id": "jfkSm4KZdpfh3LmkiieloLo9LgZtmYZJhKHYYCRtm0", + "tag": "icons", + "filename": "images.jpeg", + "metadata": { + "type": "Image", + "width": 310, + "height": 163 + }, + "content_type": "image/jpeg", + "size": 15417 + }, + "description": "to discuss gaming and more", + "tags": [], + "members": 16, + "activity": "low" + }, + { + "_id": "01H2WTG24FHPC0E2GKB4EDS1ER", + "name": "Watame Wonkers 🐏", + "icon": { + "_id": "FCJxSXbXodD8tOR0ThMLWfXjUwWd1H9j_x7M-DHLey", + "tag": "icons", + "filename": "watamebigface.jpg", + "metadata": { + "type": "Image", + "width": 900, + "height": 900 + }, + "content_type": "image/jpeg", + "size": 105730 + }, + "banner": { + "_id": "boAv9G3YQnCUC-xPVAZml8pdk2ikw52T3TM9ZAjyxe", + "tag": "banners", + "filename": "watamehappy.jpg", + "metadata": { + "type": "Image", + "width": 1920, + "height": 811 + }, + "content_type": "image/jpeg", + "size": 144562 + }, + "description": "Konbandododoo! A server for all things Hololive and Vtuber related. This server also likes other groups like Nijisanji and Vshojo so don't be afraid! If you like the best sheep Watame, do join!", + "tags": [ + "hololive", + "vtuber", + "nijisanji", + "vshojo", + "sheep", + "watame" + ], + "members": 103, + "activity": "low" + }, + { + "_id": "01HXJD88FQMSKNZBNA23GWZ77D", + "name": "FREE PALESTINE", + "icon": { + "_id": "IlkN8LZoGgvPdcdDyfH95yfebUcW55y3ExM3UEPrn0", + "tag": "icons", + "filename": "depositphotos_44276519-stock-photo-waving-palestine-flag(1).png", + "metadata": { + "type": "Image", + "width": 600, + "height": 400 + }, + "content_type": "image/png", + "size": 132612 + }, + "banner": { + "_id": "TokKs-xc_amWrNscKqlouBezrWPuoQR1igzpjeSAcQ", + "tag": "banners", + "filename": "GettyImages-1958208313-scaled.jpg", + "metadata": { + "type": "Image", + "width": 1200, + "height": 630 + }, + "content_type": "image/jpeg", + "size": 118950 + }, + "description": "\"From the land to the sea, Palestine shall be free!\" This is a server dedicated to the support of Palestine and the wish for its freedom and worldwide recognition.", + "tags": [], + "members": 26, + "activity": "low" + }, + { + "_id": "01HHK2X45XDHXKAGBFF09TDP8F", + "name": "Minecraft BR", + "icon": { + "_id": "nzgeCRbFUC2IzHh_hn61lgcr3asVh93cQ4VWpk5ZKq", + "tag": "icons", + "filename": "mcforu10.png", + "metadata": { + "type": "Image", + "width": 512, + "height": 512 + }, + "content_type": "image/png", + "size": 166105 + }, + "banner": { + "_id": "F1Pn9H9O9pDmKBXZbkgI9i7D2egun3M4XSP8gIlJvZ", + "tag": "banners", + "filename": "minecraft-logo-v0-ak9w918zi5r81.webp", + "metadata": { + "type": "Image", + "width": 400, + "height": 400 + }, + "content_type": "image/webp", + "size": 10258 + }, + "description": "Servidor da comunidade de minecraft br <3", + "tags": [], + "members": 22, + "activity": "low" + }, + { + "_id": "01GVHCXDM9DMDDR6CWBCVQTXST", + "name": "Sonic The Hedgehog | 85+ Emojis", + "description": "The Number 1 Sonic The Hedgehog fandom server for Revolt.chat, and we got emojis, lots of em.\nTags: #sonic #fandom #emojis #gaming #SonicTheHedgehog #emoji #emotes ", + "banner": { + "_id": "WoWxFMMruS-AWFQ_3spCP2i7jf2T899sKLwLsqHGfB", + "tag": "banners", + "filename": "Untitled101_20240206164205.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 381 + }, + "content_type": "image/png", + "size": 251838 + }, + "icon": { + "_id": "3hob4UPtsEWefgOHHH6BDkgzbJI9hvYbtcoFi-hvM5", + "tag": "icons", + "filename": "Untitled90_20240105200635.png", + "metadata": { + "type": "Image", + "width": 1136, + "height": 1040 + }, + "content_type": "image/png", + "size": 104539 + }, + "tags": [ + "sonic", + "fandom", + "emojis", + "gaming", + "sonicthehedgehog", + "emoji", + "emotes" + ], + "members": 234, + "activity": "low" + }, + { + "_id": "01HGWHASQ5E2GK735SKJ96NDFQ", + "name": "::::Server Not Found::::", + "banner": { + "_id": "lTLc3rgIRSrvbs6Ndw2zCRV5vCxzEUgbO7Bo4pOoRn", + "tag": "banners", + "filename": "IMG_4975.png", + "metadata": { + "type": "Image", + "width": 1560, + "height": 624 + }, + "content_type": "image/png", + "size": 33769 + }, + "description": "This is sui-han-ki proxy server!!!(Server changed)\nOf course, English OK! Japanese OK!\nLet’s talk with us. Everyone is welcome.\n### [My server](https://rvlt.gg/3kNMTVTw) ([Discord ver.](https://discord.gg/3mzgvWQuc8)) Please join!\nsui-han-ki proxy japanese ", + "icon": { + "_id": "jlioGUKSUqRRW59t15XgY2DjdVMlzOqLCKJCezD4v4", + "tag": "icons", + "filename": "IMG_4436.jpeg", + "metadata": { + "type": "Image", + "width": 1056, + "height": 1064 + }, + "content_type": "image/jpeg", + "size": 51278 + }, + "tags": [ + "sui", + "proxy", + "japanese" + ], + "members": 194, + "activity": "low" + }, + { + "_id": "01GTPEPDFFRVWEKPXNTNY9S69H", + "name": "Programming Space", + "description": "A general-purpose SFW programming server for all developers/programmers and more.\n\nprogramming coding revolt linux", + "icon": { + "_id": "Ct4Pidnhx0Y7OMHcsFIcB2BiLijTl1xHzHsvyKujtw", + "tag": "icons", + "filename": "PFPNewest.png", + "metadata": { + "type": "Image", + "width": 550, + "height": 550 + }, + "content_type": "image/png", + "size": 286150 + }, + "banner": { + "_id": "ji24WY1PQ4MqWlA_2HmiOWhMPJ-3bOoBlV1rtpk0Js", + "tag": "banners", + "filename": "Banner2.png", + "metadata": { + "type": "Image", + "width": 3456, + "height": 900 + }, + "content_type": "image/png", + "size": 2485277 + }, + "tags": [ + "programming", + "coding", + "revolt", + "linux" + ], + "members": 243, + "activity": "low" + }, + { + "_id": "01GTWDKEMTKWKM5M1BW630YTV3", + "name": "Russian Shitpost", + "description": "Сервер для русскоязычной части Револьта\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nrussian russia programming tech алокакэтитегиубрать", + "banner": { + "_id": "qEXE4f9bXuRJnXVnHDkRF9_j6Y9J_hz6855Sef_e5a", + "tag": "banners", + "filename": "furry_pornushka.gif", + "metadata": { + "type": "Image", + "width": 600, + "height": 421 + }, + "content_type": "image/gif", + "size": 3541037 + }, + "icon": { + "_id": "XwFsjo4hQ_Gf7_7U2Oo0-B1kN5rSDX-nak4EH0mJ02", + "tag": "icons", + "filename": "rslogo.png", + "metadata": { + "type": "Image", + "width": 64, + "height": 64 + }, + "content_type": "image/png", + "size": 9911 + }, + "tags": [ + "russian", + "russia", + "programming", + "tech" + ], + "members": 449, + "activity": "low" + }, + { + "_id": "01H7WNWGEX0GCMV7FC0BR6PEEH", + "name": "jenvolt.", + "icon": { + "_id": "P0ObxzAOW3dvRvcO5bvfmgzkRmXjK4PHQnK66nyntp", + "tag": "icons", + "filename": "-LWDWjundDgaq3SSQUYcCm8a6zNR3G5zmaL1lcDFpK (4).png", + "metadata": { + "type": "Image", + "width": 715, + "height": 715 + }, + "content_type": "image/png", + "size": 222016 + }, + "banner": { + "_id": "X5lL3bygullQL3kSt7q94fcP3MYcCN_KMjVIbckSYv", + "tag": "banners", + "filename": "v5GDGIf0iHAzsKg2dpLSArNKG2d4-B2V2x1G96c4Gc.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 1041645 + }, + "description": "welcome to el servidor de jennifer\nwe also do the official revolt android app 🥶🥶🥶🥶🥶\n", + "flags": 2, + "tags": [], + "members": 430, + "activity": "low" + }, + { + "_id": "01HZR76BXQHHQ2ZPSS4W3NPXKX", + "name": "Revplace ", + "description": "Comunidade brasileira dedicada a trazer atualizações e novidades sobre as mudanças e inovações da plataforma Revolt.\n\nBrazil", + "banner": { + "_id": "7te8h0oZdywWacVfyz2SrP176XGZFeRtnbaziRIUyY", + "tag": "banners", + "filename": "IMG_5961.jpeg", + "metadata": { + "type": "Image", + "width": 1000, + "height": 548 + }, + "content_type": "image/jpeg", + "size": 72732 + }, + "icon": { + "_id": "U53GfG0VHT7MZ-CVnKZHf-L1bTId0-_X-URirVSxO9", + "tag": "icons", + "filename": "IMG_5962.png", + "metadata": { + "type": "Image", + "width": 512, + "height": 512 + }, + "content_type": "image/png", + "size": 24275 + }, + "tags": [ + "brazil" + ], + "members": 8, + "activity": "low" + }, + { + "_id": "01HSJPNS9H2MPPMZTJNW8T5MRD", + "name": "AutoMod", + "description": "The official AutoMod server.\n\nBot AutoMod Moderation", + "icon": { + "_id": "u1UnHIUee-DWKXO6AN6ZzOVoznde1TOaC4rv4lK_kj", + "tag": "icons", + "filename": "pYjK-QyMv92hy8GUM-b4IK1DMzYILys9s114khzzKY.png", + "metadata": { + "type": "Image", + "width": 512, + "height": 512 + }, + "content_type": "image/png", + "size": 29618 + }, + "banner": { + "_id": "vLwPp9Hf_AdMboqBnJrypI24cE8zRH2EHGOY7C35M1", + "tag": "banners", + "filename": "AoRvVsvP1Y8X_A4cEqJ_R7B29wDZI5lNrbiu0A5pJI.webp", + "metadata": { + "type": "Image", + "width": 1000, + "height": 562 + }, + "content_type": "image/webp", + "size": 44628 + }, + "flags": 2, + "tags": [ + "bot", + "automod", + "moderation" + ], + "members": 123, + "activity": "low" + }, + { + "_id": "01G3PKD1YJ2H484MDX6KP9WRBN", + "name": "Revolt API", + "icon": { + "_id": "vKzb5xTbhtAvXcFcY6PITtOuHDhjie4849gq6pWO5n", + "tag": "icons", + "filename": "BujbQ_t27M9iQ-ZKVQuv03LI-IndUYxq3sBHpx6XsM.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 12696 + }, + "description": "Official server for discussing the Revolt API. Get help, discuss improvements and hear about the latest changes here!\n\nrevolt api official ", + "flags": 1, + "tags": [ + "revolt", + "api", + "official" + ], + "members": 512, + "activity": "no" + }, + { + "_id": "01J0242M5KBX0QDNVQBVJGQ7SM", + "name": "Portugal", + "description": "O servidor Portugal trata-se de uma comunidade portuguesa, onde todos são muito bem vindos, espero que gostem do servidor!\n", + "icon": { + "_id": "iUNTQOoScm1skpVOa1x_3pR-EoUebARGhVTt0ZYOax", + "tag": "icons", + "filename": "Screenshot from 2024-06-16 22-11-50.png", + "metadata": { + "type": "Image", + "width": 829, + "height": 605 + }, + "content_type": "image/png", + "size": 580529 + }, + "banner": { + "_id": "WwRBopQryQv-WuGxBUAAE4JgnsIWo7ktHXUb2IPMaX", + "tag": "banners", + "filename": "portugal2.jpg", + "metadata": { + "type": "Image", + "width": 1500, + "height": 1000 + }, + "content_type": "image/jpeg", + "size": 286484 + }, + "tags": [], + "members": 10, + "activity": "no" + }, + { + "_id": "01H6WQGNWVEKH3BH7FS14NS7XH", + "name": "The Splatoon Lounge", + "icon": { + "_id": "iZtGw9smfIqvBpXFJKroLt6f2Xfbx1YtN2iJ0IeJsc", + "tag": "icons", + "filename": "IMG_3221.jpeg", + "metadata": { + "type": "Image", + "width": 416, + "height": 351 + }, + "content_type": "image/jpeg", + "size": 55007 + }, + "banner": { + "_id": "NFfaCjj69zHWSxKjrY-n1FLSCo3c_Z8VPmDHLQBcwc", + "tag": "banners", + "filename": "IMG_3220.jpeg", + "metadata": { + "type": "Image", + "width": 700, + "height": 200 + }, + "content_type": "image/jpeg", + "size": 28391 + }, + "description": "A server you can chat about Splatoon. Please make sure to read the rules before going into conversation. Enjoy your stay here! ", + "tags": [], + "members": 87, + "activity": "no" + }, + { + "_id": "01FEPATGXB3TGTKSA3ZVA7RSTM", + "name": "yui's comfy place", + "description": "comfy server for everyone!", + "banner": { + "_id": "lhq_rDDPfY3J3zUw1EaYQMCmptoatIqrkU0u9g4qgB", + "tag": "banners", + "filename": "7e0accaa2b2b902724863fffaa319f94.png", + "metadata": { + "type": "Image", + "width": 1024, + "height": 361 + }, + "content_type": "image/png", + "size": 432331 + }, + "icon": { + "_id": "1tU3Gn6rosEoZ0sbgfgZkHMp0dlAPOrge1CUxzKW--", + "tag": "icons", + "filename": "sssdwqqwd.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 226803 + }, + "tags": [], + "members": 786, + "activity": "no" + }, + { + "_id": "01H28NWQBPXA2FMZD7G0YVKZ61", + "name": "Language Learning", + "description": "The server for language learning and all things language. For both natlangs and conlangs! Also a very active chess channel...", + "icon": { + "_id": "X33zErm-vNBR0ObiRz90haDQp15nbXNYD4Wkt3dhoy", + "tag": "icons", + "filename": "planet-earth-on-blue-background-vector-3295462538(1).jpg", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/jpeg", + "size": 24115 + }, + "banner": { + "_id": "pjvGhN93qaSIRmMi-7nJpKNLS4huqHI3Lu91IQxoLm", + "tag": "banners", + "filename": "Untitled347_20230611115010.png", + "metadata": { + "type": "Image", + "width": 800, + "height": 450 + }, + "content_type": "image/png", + "size": 255952 + }, + "tags": [ + "language", + "learning", + "natlangs", + "conlangs", + "chess" + ], + "members": 783, + "activity": "no" + }, + { + "_id": "01HB2D9WWFMVHS4PH3KXT6HZTM", + "name": "blendOS", + "icon": { + "_id": "YDXDLRy9cFEGfiojdLIvLmif3EvIAXafvga1dFGsgc", + "tag": "icons", + "filename": "logo-1.png", + "metadata": { + "type": "Image", + "width": 1024, + "height": 1024 + }, + "content_type": "image/png", + "size": 634962 + }, + "description": "[blendOS](https://blendos.co) is an Arch based Linux Distribution, focused around allowing the user to use packages from different distributions, Android, and PWAs.", + "banner": { + "_id": "08JnC5g_2wnIvH9NlLMDB-wrkipLLZJmGF4RjltZDZ", + "tag": "banners", + "filename": "rn_image_picker_lib_temp_3b8c1800-a197-4563-aa8b-4b777d10255c.jpg", + "metadata": { + "type": "Image", + "width": 1080, + "height": 360 + }, + "content_type": "image/jpeg", + "size": 35160 + }, + "tags": [], + "members": 40, + "activity": "no" + }, + { + "_id": "01FZ62C8WFS3HBEN5QTN8RZRQG", + "name": "Remix HQ", + "icon": { + "_id": "abVmV8YgPfdZalAs0vqy2YaOgs3lNRV8oIj8wL8tAY", + "tag": "icons", + "filename": "Remix.jpg", + "metadata": { + "type": "Image", + "width": 1440, + "height": 1440 + }, + "content_type": "image/jpeg", + "size": 104575 + }, + "description": "The official community for the Remix music bot. \nremix remixbot firstmusicbot bot bots music musicbot revolt revoltbot support community cool epic", + "flags": 2, + "banner": { + "_id": "wj2r7z8c_dJt2UDw6NQOXM4sU8Vt-CNAAFjNURY4vC", + "tag": "banners", + "filename": "D4_hBq5D2DObGDeYuo6agc7-5kjWNcefnrqMgHAkL2.gif", + "metadata": { + "type": "Image", + "width": 600, + "height": 360 + }, + "content_type": "image/gif", + "size": 1739994 + }, + "tags": [ + "remix", + "remixbot", + "firstmusicbot", + "bot", + "bots", + "music", + "musicbot", + "revolt", + "revoltbot", + "support" + ], + "members": 514, + "activity": "no" + }, + { + "_id": "01GVKKSEJAPK8V9CXG9FNYW0DG", + "name": "Roblox Lounge", + "icon": { + "_id": "jb0YFUKg3v118tU3hIDVKblX5KFiG31tYIuHS4adyj", + "tag": "icons", + "filename": "Revolt Icon.png", + "metadata": { + "type": "Image", + "width": 512, + "height": 512 + }, + "content_type": "image/png", + "size": 6332 + }, + "banner": { + "_id": "2aIsCm0ZI6tNuJh9ohOmA2Xr365j0FcIjG0nKcj_eZ", + "tag": "banners", + "filename": "Revolt Banner.png", + "metadata": { + "type": "Image", + "width": 1222, + "height": 489 + }, + "content_type": "image/png", + "size": 11518 + }, + "description": "Fan Server about the game named Roblox! Share whatever you like here, find new people in our channels :)\n\nNot official neither affilitated with the Roblox Company.", + "tags": [], + "members": 281, + "activity": "no" + }, + { + "_id": "01HC0370ZP1VKKMXP4KB1666G0", + "name": "The Star Lounge ⭐", + "description": "> **Welcome to The Star Lounge ★ !!**\n*Relax, chat, and make lots of friends here ♡ !*\n> **Have fun, and enjoy your time 🎔 !!**\n``We celebrate with 40+ members and 13 spot in Discovery, join the fun !``\n", + "icon": { + "_id": "WGAMHgoQ4_hZWnOQ8Zx96L0GJE1vNpXwtfuuhL-aTx", + "tag": "icons", + "filename": "pixai-1671089269300275634-0.png", + "metadata": { + "type": "Image", + "width": 440, + "height": 378 + }, + "content_type": "image/png", + "size": 255760 + }, + "banner": { + "_id": "yK0QaOuRwkjTOrrDxtyrFXWMis4rhVloBhrRY7oSBx", + "tag": "banners", + "filename": "pixai-1671090919939491631-2.png", + "metadata": { + "type": "Image", + "width": 512, + "height": 357 + }, + "content_type": "image/png", + "size": 319166 + }, + "tags": [ + "13" + ], + "members": 115, + "activity": "no" + }, + { + "_id": "01HS75CA336AEZQCR3CZCW5TYF", + "name": "I like boars", + "description": "For anyone who interested in my games, software and other projects.\n\nAlso available is general Godot and game development discussion + guidance.\nOur procrastination is programming.\n\ngamedev godot selfhosting programming", + "icon": { + "_id": "pUjeTLgdRGfzLlYBdlhX34FdcVbVg2LrK7dByBew0P", + "tag": "icons", + "filename": "cecc2b48f468f6e8f313343e78ef3eb5.jpg", + "metadata": { + "type": "Image", + "width": 750, + "height": 750 + }, + "content_type": "image/jpeg", + "size": 101405 + }, + "banner": { + "_id": "pU9PoZNYrr07WtLj7xARbxAulZEZ8DA4xKEhuuBlra", + "tag": "banners", + "filename": "letton-ditch-768x1024-panorama-1ccb4f1d628031af4b38c3709211dc6c-58bc9620e17d7.jpg", + "metadata": { + "type": "Image", + "width": 768, + "height": 384 + }, + "content_type": "image/jpeg", + "size": 116377 + }, + "tags": [ + "gamedev", + "godot", + "selfhosting", + "programming" + ], + "members": 32, + "activity": "no" + }, + { + "_id": "01HYVDCETD8056A9R3S37569H2", + "name": "Wireframe: A 3D modelling server", + "description": "A server for 3D modellers to get together and share knowledge, creations and also improve their skills\n\n3d modelling blender art", + "icon": { + "_id": "Jth2gxEHBaFwtTwZeK98K1bQlaPLLMK2a2f2M41Nzr", + "tag": "icons", + "filename": "icon.gif", + "metadata": { + "type": "Image", + "width": 256, + "height": 256 + }, + "content_type": "image/gif", + "size": 2001855 + }, + "banner": { + "_id": "OLaoi-y3Vb7GaCSfNsd0NbozEj0sQWN17ACvO-YDEg", + "tag": "banners", + "filename": "banner.png", + "metadata": { + "type": "Image", + "width": 1024, + "height": 512 + }, + "content_type": "image/png", + "size": 652817 + }, + "tags": [ + "3d", + "modelling", + "blender", + "art" + ], + "members": 9, + "activity": "no" + }, + { + "_id": "01HZ94FSKNRP4XWWZ4XQ0BHYQ2", + "name": "🎊 | Solix’s World", + "description": null, + "banner": { + "_id": "tMpKyE2cCFCvjpP7NGKAmf6LwMgFH5o5ZL3af5jTJ3", + "tag": "banners", + "filename": "officialbanner.png", + "metadata": { + "type": "Image", + "width": 2560, + "height": 1440 + }, + "content_type": "image/png", + "size": 1915119 + }, + "tags": [], + "members": 10, + "activity": "no" + }, + { + "_id": "01FNYD7FYZF1YA4QGEY9PHSK2S", + "name": "Voltage", + "description": "Voltage is a python API wrapper for the revolt API.\nHere you can chill, talk about Voltage and stuff *i guess*.\n\nThis server is also bridged with Eludris so you'll find a lot of shitposting, programming talk and dumb shit regularly :^)\n\nRepo: https://github.com/EnokiUN/voltage\n\nTags: bots programming anime shitposting amogus ", + "icon": { + "_id": "wx2pNtOIszoFshiQH_vcxtjBzKVq1YcIBRIvdVvE5t", + "tag": "icons", + "filename": "Untitled40_20220314234237.png", + "metadata": { + "type": "Image", + "width": 514, + "height": 512 + }, + "content_type": "image/png", + "size": 25926 + }, + "banner": { + "_id": "t5djH5K6ElpuM0n9uZVXUxkLFYj1QlsfMMiuuQlkSg", + "tag": "banners", + "filename": "20220324_194432.jpg", + "metadata": { + "type": "Image", + "width": 1280, + "height": 718 + }, + "content_type": "image/jpeg", + "size": 46484 + }, + "tags": [ + "bots", + "programming", + "anime", + "shitposting", + "amogus" + ], + "members": 612, + "activity": "no" + }, + { + "_id": "01GCSWMPKQ8HRKR4AVB4C850CV", + "name": "Privacy Lounge", + "description": "Engage with users in conversations about privacy, Linux, and Fediverse, while also having the freedom to discuss a wide range of other topics.", + "icon": { + "_id": "5cTVZ_8TLEFRaKsGakcSZvgHnZCmQfyLxAor5XM1ss", + "tag": "icons", + "filename": "PLC1_UAPIG_RevoltLogo2.png", + "metadata": { + "type": "Image", + "width": 1080, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 46285 + }, + "banner": { + "_id": "CpgHPi9Kcm-6JiVR6ISKCTB1SHOyKI1wEIWzdX6xAw", + "tag": "banners", + "filename": "PLC1_UAPIG_RevoltBanner2-1.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/jpeg", + "size": 73561 + }, + "tags": [ + "privacy", + "linux", + "fediverse" + ], + "members": 538, + "activity": "no" + }, + { + "_id": "01H1H8JQ2J2HGE6109MVBSW4KH", + "name": "Celeste Hearts", + "description": "Cute Celeste-themed pride emotes for all your identity needs. Feel free to suggest additional flags!", + "icon": { + "_id": "pQgrTgBY5RulGqPe_oJne5lgM2z9jytuyaJh6BGNsg", + "tag": "icons", + "filename": "image", + "metadata": { + "type": "Image", + "width": 72, + "height": 68 + }, + "content_type": "image/png", + "size": 1057 + }, + "banner": { + "_id": "Gfzyt0iWKJGMxxDrVBQASGHbycl660VURVn2rxjbHR", + "tag": "banners", + "filename": "Celeste Hearts Banner.png", + "metadata": { + "type": "Image", + "width": 3000, + "height": 1500 + }, + "content_type": "image/png", + "size": 435383 + }, + "tags": [], + "members": 164, + "activity": "no" + }, + { + "_id": "01FDA8EPHMNWGF26NRDGSGZ3ND", + "name": "NitroMojis", + "icon": { + "_id": "B0xQlGTPH8GMqOOFedd2Xu5EIryHm1mgkQ2wvX2XeJ", + "tag": "icons", + "filename": "a_0893876ce40862ad9d83c27f6145909a (1).png", + "metadata": { + "type": "Image", + "width": 128, + "height": 128 + }, + "content_type": "image/png", + "size": 21577 + }, + "banner": { + "_id": "EIKeaHLERSMb6ENUYbwqkNfW9qkxC20ut4gCGC3u7Y", + "tag": "banners", + "filename": "NitroMojis Banner.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 757960 + }, + "description": "NitroMojis is a server that aims to provide Revolt users with the finest emojis to use all across Revolt. Emoji, Emote and Emotes.", + "flags": 2, + "tags": [], + "members": 1309, + "activity": "no" + }, + { + "_id": "01GVKNE144E7Y219RNATVX72QX", + "name": "idk what should i call this", + "description": "idk join if you want", + "banner": { + "_id": "KZBmJEjbW0tOa-gkgsU25jvh86ZEGxI2nT-C_K73Dt", + "tag": "banners", + "filename": "srhtdjfy.jpg", + "metadata": { + "type": "Image", + "width": 400, + "height": 382 + }, + "content_type": "image/jpeg", + "size": 30062 + }, + "icon": { + "_id": "EI0mcQDjnNq4OmF-Ho4IDpvPcHFYq2aNKChF5-lgVY", + "tag": "icons", + "filename": "RANMYFAVORITE.jpg", + "metadata": { + "type": "Image", + "width": 222, + "height": 177 + }, + "content_type": "image/jpeg", + "size": 14140 + }, + "tags": [], + "members": 52, + "activity": "no" + }, + { + "_id": "01H2RDRXT4VN0HQ5M4QAM5XC92", + "name": "Polandia✨", + "icon": { + "_id": "vLCUIf6mNebE4SaY_n5qBAOWtCc6hJ2vNV4k2hc5Lg", + "tag": "icons", + "filename": "Polandia.jpg", + "metadata": { + "type": "Image", + "width": 400, + "height": 400 + }, + "content_type": "image/jpeg", + "size": 32989 + }, + "banner": { + "_id": "VDOKLXl205tQ29267z-jI098Tf_DRijgPErkNFpyXI", + "tag": "banners", + "filename": "polandiatemplate.jpg", + "metadata": { + "type": "Image", + "width": 2000, + "height": 1000 + }, + "content_type": "image/jpeg", + "size": 472010 + }, + "description": "Do you like geography, history or countryballs? Than join the first geography server ever made: This one!\n\n", + "tags": [], + "members": 22, + "activity": "no" + }, + { + "_id": "01HD41EJ793JPHYS3Z64BACKBM", + "name": "Division Server!tintindaisuki", + "icon": { + "_id": "pP6qtvoODmmeNQABCkaYiS_P5klasMO1ag5dARH50m", + "tag": "icons", + "filename": "IMG_0502.jpeg", + "metadata": { + "type": "Image", + "width": 225, + "height": 225 + }, + "content_type": "image/jpeg", + "size": 7193 + }, + "description": "ばか?\n\n\nchat japan ja division revolt chating ", + "banner": { + "_id": "FKnd9SQx21ZTwUZRiai6NvCZ9SqfZTf8iVW9drR67D", + "tag": "banners", + "filename": "IMG_0559.jpeg", + "metadata": { + "type": "Image", + "width": 460, + "height": 380 + }, + "content_type": "image/jpeg", + "size": 58393 + }, + "tags": [ + "chat", + "japan", + "ja", + "division", + "revolt", + "chating" + ], + "members": 89, + "activity": "no" + }, + { + "_id": "01H6BZ13N71P5MJG1JFFHHRJHC", + "name": "Astroid", + "icon": { + "_id": "_CSrGoqeQCqNPdmAOzSQ54dcHaWCWLY2KpTvVI0XKT", + "tag": "icons", + "filename": "Astroid Logo.png", + "metadata": { + "type": "Image", + "width": 927, + "height": 927 + }, + "content_type": "image/jpeg", + "size": 54410 + }, + "description": "Astroid is a easy to use multi-plattform bridge including Discord, Guilded and Revolt!\n", + "banner": { + "_id": "42E70bk5H3DNnOmkGKbYQrBxLTYeCb8uV8dxetQODC", + "tag": "banners", + "filename": "Astroid-banner.png", + "metadata": { + "type": "Image", + "width": 1920, + "height": 1080 + }, + "content_type": "image/png", + "size": 204815 + }, + "tags": [ + "bridge", + "discord", + "guilded", + "revolt" + ], + "members": 44, + "activity": "no" + }, + { + "_id": "01G2RNRDXXEZP3WEHQZEY4GE79", + "name": "Revolt C#", + "icon": { + "_id": "IdMMWfYfiLLTXduMxer2qbHg7ZKIAbAXV6j7EgdxdS", + "tag": "icons", + "filename": "c-img2.png", + "metadata": { + "type": "Image", + "width": 231, + "height": 231 + }, + "content_type": "image/png", + "size": 5370 + }, + "description": "Revolt c# development server!\n\n\nThis is for our library RevoltSharp which you can use to interact with the revolt api and create your own bots.", + "tags": [], + "members": 114, + "activity": "no" + }, + { + "_id": "01H14M0JCRWT52VW7BR1XH5Y5W", + "name": "Revolt Translators", + "description": "Official server for discussing, expanding and improving Revolt's translations. revolt translations translators internationalisation internationalization i18n ", + "flags": 1, + "icon": { + "_id": "N0Oi3so_bu-X_M0xsXENUEl5oQuw4AkpwJ7y07Thlz", + "tag": "icons", + "filename": "Group_3.png", + "metadata": { + "type": "Image", + "width": 266, + "height": 266 + }, + "content_type": "image/png", + "size": 6672 + }, + "tags": [ + "revolt", + "translations", + "translators", + "internationalisation", + "internationalization", + "i18n" + ], + "members": 487, + "activity": "no" + }, + { + "_id": "01J01ZZQ9V749SK2MW1VJM3BQJ", + "name": "Socialize, Hangout & Chat!", + "description": "Join our community of free thinkers in a Revolt server dedicated to the principles of **freedom of speech**, **privacy** and ~**anime**~ **open discussion**. Our server is a safe space for individuals from all walks of life to come together, share their thoughts, and engage in respectful conversations. \n\n**What to expect:**\n1. A relaxed and welcoming atmosphere, perfect for socializing and making new friends.\n\n2. A platform for open and honest discussion on various topics, from politics and social issues to hobbies and interests.", + "icon": { + "_id": "yjDkbFq6OpPJFIZC8FUtbZ5O6JImkXfn6QDcfjzO0D", + "tag": "icons", + "filename": "Untitled-tCfr5M9Pu-transformed.jpeg", + "metadata": { + "type": "Image", + "width": 2048, + "height": 2048 + }, + "content_type": "image/jpeg", + "size": 362881 + }, + "banner": { + "_id": "j-OacdPeNJSL2MsppElviruqp0lGfVuZ3dvfAVlITu", + "tag": "banners", + "filename": "Untitled2-transformed.jpeg", + "metadata": { + "type": "Image", + "width": 3072, + "height": 2048 + }, + "content_type": "image/jpeg", + "size": 561340 + }, + "tags": [], + "members": 13, + "activity": "no" + }, + { + "_id": "01GPB36KBEVXZK77YHSA6SM2CW", + "name": "Workbench Team", + "icon": { + "_id": "GRD3bK68MZwvUxn2tQdcItj9QRLTO4pp-dzytIOvdc", + "tag": "icons", + "filename": "a_ce4d2d1b08978e4443958ecbc4827245(1).gif", + "metadata": { + "type": "Image", + "width": 320, + "height": 320 + }, + "content_type": "image/gif", + "size": 2164048 + }, + "banner": { + "_id": "VUC03mQMU9P_O-yOwz5ACxK53Yj48Ik2Nr4DnmRAH4", + "tag": "banners", + "filename": "943c3dbc098523861d34e9d3c5e676b1.png", + "metadata": { + "type": "Image", + "width": 1024, + "height": 576 + }, + "content_type": "image/png", + "size": 448438 + }, + "description": "🇺🇸 We are the team of game content creators and servers\n\n🇷🇺 Мы команда создателей игрового контента и серверов", + "tags": [], + "members": 276, + "activity": "no" + }, + { + "_id": "01H2JG3X52790F328P7X20GCM1", + "name": "Revolt Car Club", + "icon": { + "_id": "UeW1vFIr_8P5taoKA9LR247dNT5RrvOT5FBd4DMOT9", + "tag": "icons", + "filename": "TE37.png", + "metadata": { + "type": "Image", + "width": 960, + "height": 960 + }, + "content_type": "image/png", + "size": 1223704 + }, + "banner": { + "_id": "5nTKigMUv-m5BMNGq8UK2pI7lFmHOi20vHwoa8VVgA", + "tag": "banners", + "filename": "0_Car drifting through a mountain road curve, lots o_esrgan-v1-x2plus.png", + "metadata": { + "type": "Image", + "width": 1792, + "height": 1024 + }, + "content_type": "image/png", + "size": 2666535 + }, + "description": "Hello! Do you like cars, or things that move on wheels? Yes? Join us and let's build a community of car lovers and alike together!", + "tags": [], + "members": 15, + "activity": "no" + }, + { + "_id": "01HYDZPQFE9S86PGV1T24DSGA7", + "name": "むめーproxy鯖!", + "description": "scratch民が多いと思うけど仲良くしていこう!", + "banner": { + "_id": "Q2yzKB71NNza6aTRCHSGxXFCbPvcsYahSmGNly04M_", + "tag": "banners", + "filename": "あ.jpeg", + "metadata": { + "type": "Image", + "width": 457, + "height": 340 + }, + "content_type": "image/jpeg", + "size": 24946 + }, + "icon": { + "_id": "mQsTdm521JI-L6S3nF9aJQ7v491HdoT8dY1BqOuLsN", + "tag": "icons", + "filename": "images.jpeg", + "metadata": { + "type": "Image", + "width": 225, + "height": 225 + }, + "content_type": "image/jpeg", + "size": 11700 + }, + "tags": [], + "members": 19, + "activity": "no" + }, + { + "_id": "01J17WDGM1R7Q3HWE0FTT1R28A", + "name": "Rainbow Advertising", + "description": "Advertise for free here! Part of the Rainbow Network across Discord & Guilded.\n\nadvertise | advertising | promo | promote | promotion | self promo", + "icon": { + "_id": "kXpvtI-QWU4Zt_Lm5r4J9Ur5r8SiEhAAX7wdj_YbaJ", + "tag": "icons", + "filename": "Screenshot 2024-06-25 at 10.28.51 AM.png", + "metadata": { + "type": "Image", + "width": 144, + "height": 150 + }, + "content_type": "image/png", + "size": 35512 + }, + "tags": [ + "advertise", + "advertising", + "promo", + "promote", + "promotion" + ], + "members": 8, + "activity": "no" + }, + { + "_id": "01HE70NW72PF4HGCYGB9M8EH37", + "name": "The Legend of Zelda", + "icon": { + "_id": "W4gKFeKWc1P1edq651ipXtC7UW-o-TWJ25f2-WbOhZ", + "tag": "icons", + "filename": "zonai-charge-materials-zelda-tears-of-the-kingdom-wiki-guide-200px.png", + "metadata": { + "type": "Image", + "width": 512, + "height": 512 + }, + "content_type": "image/png", + "size": 116934 + }, + "description": "Unofficial server for all things Legend Of Zelda related.\n\n\nzelda totk link nintendo gaming", + "banner": { + "_id": "ij84WA7LHN0fKwCPTryblBKE0imoy28ccJhMBRs25r", + "tag": "banners", + "filename": "Screenshot 2024-02-20 12.34.26.png", + "metadata": { + "type": "Image", + "width": 461, + "height": 153 + }, + "content_type": "image/png", + "size": 136520 + }, + "tags": [ + "zelda", + "totk", + "link", + "nintendo", + "gaming" + ], + "members": 36, + "activity": "no" + }, + { + "_id": "01HX2MP6ANR0R83BCG9V66XCDD", + "name": "🌐 Translator | Bot", + "icon": { + "_id": "7DrjzKyrSfBAoPWf-uMsfi16l7McmOEbpQs7KELT4N", + "tag": "icons", + "filename": "translator.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 29249 + }, + "banner": { + "_id": "CJgHaUXKCN2tpg26TNBiGqbynicfH_xbIqVisc6LLT", + "tag": "banners", + "filename": "translator_bannière_2.png.png", + "metadata": { + "type": "Image", + "width": 1200, + "height": 480 + }, + "content_type": "image/png", + "size": 31782 + }, + "description": "😍 1st Revolt translation bot !\n👀 100% free, no premium features, no limitations.\n⭐ Try it now !\n\nTranslator", + "tags": [ + "translation", + "bot", + "free", + "translator" + ], + "members": 15, + "activity": "no" + }, + { + "_id": "01J163G9TCNM63DHHHHF9PVT0G", + "name": "Amici In Game", + "icon": { + "_id": "1pLkEMaF17yi_0FGvIeDNVSPEty6xwIPlViHPY_d8K", + "tag": "icons", + "filename": "Logo BOT Minecraft.png", + "metadata": { + "type": "Image", + "width": 1024, + "height": 1024 + }, + "content_type": "image/png", + "size": 736160 + }, + "banner": { + "_id": "WGFQyTn3908BB8XADhK7arbk-6W7SKmzH1c0bX1vYH", + "tag": "banners", + "filename": "Sfondo Ritagliato.jpg", + "metadata": { + "type": "Image", + "width": 999, + "height": 499 + }, + "content_type": "image/jpeg", + "size": 104187 + }, + "description": "Server Ufficiale su Revolt della Community di Amici In Game\nSeguici in Live su Twitch.Tv/Amici_In_Game\n\nTwitch Live Gaming Chill Music", + "tags": [ + "twitch", + "live", + "gaming", + "chill", + "music" + ], + "members": 5, + "activity": "no" + } + ], + "popularTags": [ + "gaming", + "revolt", + "programming", + "fun", + "chill", + "art", + "linux", + "politics", + "chat", + "music" + ] +} \ No newline at end of file diff --git a/tests/data/discovery/themes.json b/tests/data/discovery/themes.json new file mode 100644 index 0000000..b6389ca --- /dev/null +++ b/tests/data/discovery/themes.json @@ -0,0 +1,2346 @@ +{ + "themes": [ + { + "version": "1.0.1", + "slug": "amethyst", + "name": "Amethyst", + "creator": "Meow", + "description": "Purple... based on spinfish's amethyst betterdiscord theme.", + "tags": [ + "dark", + "purple" + ], + "variables": { + "accent": "#ff9100", + "background": "#230431", + "foreground": "#ffffff", + "block": "#1c0028", + "mention": "#310c44", + "message-box": "#2d0f3d", + "success": "#4dd75e", + "warning": "#FAA352", + "error": "#ed3f43", + "hover": "rgba(0, 0, 0, 0.2)", + "tooltip": "#000000", + "scrollbar-thumb": "#361741", + "scrollbar-track": "transparent", + "primary-background": "#230431", + "primary-header": "#150020", + "secondary-background": "#1c0028", + "secondary-foreground": "#d2d2d2", + "secondary-header": "#1c0028", + "tertiary-background": "#8b8b8b", + "tertiary-foreground": "#8b8b8b", + "status-online": "#3ba55d", + "status-away": "#faa81a", + "status-busy": "#ed4245", + "status-invisible": "#747f8d" + } + }, + { + "version": "0.0.1", + "slug": "amoled", + "name": "AMOLED", + "creator": "insert", + "description": "Pure black, perfect for mobile.", + "tags": [ + "oled", + "dark" + ], + "variables": { + "accent": "#FD6671", + "background": "#000000", + "foreground": "#FFFFFF", + "block": "#1D1D1D", + "message-box": "#000000", + "mention": "rgba(251, 255, 0, 0.06)", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#000000", + "primary-header": "#000000", + "secondary-background": "#000000", + "secondary-foreground": "#DDDDDD", + "secondary-header": "#1A1A1A", + "tertiary-background": "#000000", + "tertiary-foreground": "#AAAAAA", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "0.0.1", + "slug": "dark", + "name": "Ayu Dark", + "creator": "Zomatree", + "description": "A Theme using the Ayu colour pallete.", + "variables": { + "accent": "#e6b450", + "background": "#090e15", + "foreground": "#b3b1ad", + "block": "#0c1017", + "message-box": "#090e15", + "mention": "#0c1017", + "success": "#91b362", + "warning": "#FAA352", + "error": "#ff3333", + "hover": "#0c1017", + "scrollbar-thumb": "#e6b450", + "scrollbar-track": "transparent", + "primary-background": "#090e15", + "primary-header": "#090e15", + "secondary-background": "#090e15", + "secondary-foreground": "#4d5566", + "secondary-header": "#0c1017", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#848484", + "status-online": "#91b362", + "status-away": "#F39F00", + "status-busy": "#ff3333", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "tags": [ + "dark" + ] + }, + { + "version": "1.0.0", + "slug": "blobfox-theme", + "name": "Blobfox theme", + "creator": "Houl", + "description": "A dark theme with blobfox orange.", + "tags": [ + "dark", + "blobfox", + "orange" + ], + "variables": { + "accent": "#FF8702", + "background": "#101010", + "foreground": "#F6F6F6", + "block": "#202020", + "message-box": "#202020", + "mention": "rgba(255, 135, 2, 0.2)", + "success": "#57F287", + "warning": "#FE925C", + "error": "#ED4245", + "hover": "rgba(75, 75, 75, 0.1)", + "scrollbar-thumb": "#FF8702", + "scrollbar-track": "transparent", + "primary-background": "#101010", + "primary-header": "#151515", + "secondary-background": "#151515", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#181818", + "tertiary-background": "#505050", + "tertiary-foreground": "#848484", + "status-online": "#57F287", + "status-away": "#FE925C", + "status-busy": "#ED4245", + "status-streaming": "#AE45EB", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "1.0.0", + "slug": "blossom", + "name": "Blossom", + "creator": "Axorax", + "description": "A visually enchanting theme inspired by cherry blossoms.", + "tags": [ + "light", + "blossom", + "cherry" + ], + "variables": { + "accent": "#FF85A0", + "background": "#F6F0F7", + "foreground": "#2C2C2C", + "block": "#c7a9c7", + "message-box": "#F1EAF2", + "mention": "#FFC2D6", + "success": "#70D84C", + "warning": "#FFC93C", + "error": "#FF504E", + "hover": "#ECE7EC", + "scrollbar-thumb": "#D8D1D8", + "scrollbar-track": "#EDE8ED", + "primary-background": "#F1EAF2", + "primary-header": "#F1EAF2", + "secondary-background": "#EDE8ED", + "secondary-foreground": "#5D5D5D", + "secondary-header": "#F4EFF4", + "tertiary-background": "#D4CDD4", + "tertiary-foreground": "#787878", + "status-online": "#70D84C", + "status-away": "#FFC93C", + "status-busy": "#FF504E", + "status-streaming": "#CFA0D0", + "status-invisible": "#BDB7BD" + } + }, + { + "version": "0.0.1", + "slug": "blue-dark", + "name": "Blue Dark", + "creator": "SP46", + "description": "A modern and unique style for Revolt.", + "variables": { + "accent": "#e91e63", + "background": "#101823", + "foreground": "#f6f6f6", + "block": "#262f3d", + "message-box": "#212a38", + "mention": "rgba(251, 255, 0, 0.06)", + "success": "#31bf7e", + "warning": "#fae352", + "error": "#e91e63", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#ba184f", + "scrollbar-track": "transparent", + "primary-background": "#151e2b", + "primary-header": "#1a2433", + "secondary-background": "#1a2433", + "secondary-foreground": "#c8c8c8", + "secondary-header": "#212a38", + "tertiary-background": "#212a38", + "tertiary-foreground": "#848484", + "status-online": "#31bf7e", + "status-away": "#fae352", + "status-busy": "#e91e63", + "status-streaming": "#977eff", + "status-invisible": "#a5a5a5" + }, + "tags": [ + "dark" + ] + }, + { + "version": "0.0.1", + "slug": "bree", + "name": "bree's theme", + "creator": "bree", + "description": "this is bree's purple dark theme.", + "variables": { + "accent": "#FD6671", + "background": "#292937", + "foreground": "#F6F6F6", + "block": "rgb(25 25 30)", + "message-box": "rgb(55 55 66)", + "mention": "rgba(251, 255, 0, 0.06)", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgb(0 0 0 / 0.1)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "rgb(33 33 44)", + "primary-header": "rgb(44 44 55)", + "secondary-background": "rgb(28 28 36)", + "secondary-foreground": "#C8C8C8", + "secondary-header": "rgb(37 37 48)", + "tertiary-background": "rgb(77 77 88)", + "tertiary-foreground": "rgb(133 133 144)", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "/* todo: use a consistently named, stable css class */\n/* The chat input area */\n.tinfKpom71H-wBY-85CJ7 {\n background-color: var(--hover);\n}\n", + "tags": [ + "dark" + ] + }, + { + "version": "1.0.0", + "slug": "catppuccin-frappe", + "name": "Catppuccin Frappé", + "creator": "Catppuccin Org", + "description": "Soothing pastel theme for Revolt.", + "tags": [ + "pastel", + "soothing", + "dark" + ], + "variables": { + "accent": "#f2d5cf", + "background": "#303446", + "foreground": "#babbf1", + "block": "#232634", + "message-box": "#232634", + "mention": "rgba(30, 29, 47, .1)", + "success": "#a6d189", + "warning": "#ef9f76", + "tooltip": "#000000", + "error": "#e78284", + "hover": "rgba(35, 38, 52, 0.75)", + "scrollbar-thumb": "#babbf1", + "scrollbar-track": "transparent", + "primary-background": "#303446", + "primary-header": "#292c3c", + "secondary-background": "#292c3c", + "secondary-foreground": "#737994", + "secondary-header": "#292c3c", + "tertiary-background": "#232634", + "tertiary-foreground": "#626880", + "status-online": "#a6d189", + "status-away": "#eebebe", + "status-busy": "#ea999c", + "status-streaming": "#ca9ee6", + "status-invisible": "#626880" + }, + "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "catppuccin-latte", + "name": "Catppuccin Latte", + "creator": "Catppuccin Org", + "description": "Soothing pastel theme for Revolt.", + "tags": [ + "pastel", + "soothing", + "light" + ], + "variables": { + "accent": "#dc8a78", + "background": "#eff1f5", + "foreground": "#7287fd", + "block": "#dce0e8", + "message-box": "#dce0e8", + "mention": "rgba(30, 29, 47, .1)", + "success": "#40a02b", + "warning": "#fe640b", + "tooltip": "#000000", + "error": "#d20f39", + "hover": "rgba(220, 224, 232, 0.75)", + "scrollbar-thumb": "#7287fd", + "scrollbar-track": "transparent", + "primary-background": "#eff1f5", + "primary-header": "#e6e9ef", + "secondary-background": "#e6e9ef", + "secondary-foreground": "#9ca0b0", + "secondary-header": "#e6e9ef", + "tertiary-background": "#dce0e8", + "tertiary-foreground": "#acb0be", + "status-online": "#40a02b", + "status-away": "#dd7878", + "status-busy": "#e64553", + "status-streaming": "#8839ef", + "status-invisible": "#acb0be" + }, + "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "catppuccin-macchiato", + "name": "Catppuccin Macchiato", + "creator": "Catppuccin Org", + "description": "Soothing pastel theme for Revolt.", + "tags": [ + "pastel", + "soothing", + "dark" + ], + "variables": { + "accent": "#f4dbd6", + "background": "#24273a", + "foreground": "#b7bdf8", + "block": "#181926", + "message-box": "#181926", + "mention": "rgba(30, 29, 47, .1)", + "success": "#a6da95", + "warning": "#f5a97f", + "tooltip": "#000000", + "error": "#ed8796", + "hover": "rgba(24, 25, 38, 0.75)", + "scrollbar-thumb": "#b7bdf8", + "scrollbar-track": "transparent", + "primary-background": "#24273a", + "primary-header": "#1e2030", + "secondary-background": "#1e2030", + "secondary-foreground": "#6e738d", + "secondary-header": "#1e2030", + "tertiary-background": "#181926", + "tertiary-foreground": "#5b6078", + "status-online": "#a6da95", + "status-away": "#f0c6c6", + "status-busy": "#ee99a0", + "status-streaming": "#c6a0f6", + "status-invisible": "#5b6078" + }, + "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "catppuccin-mocha", + "name": "Catppuccin Mocha", + "creator": "Catppuccin Org", + "description": "Soothing pastel theme for Revolt.", + "tags": [ + "pastel", + "soothing", + "dark" + ], + "variables": { + "accent": "#f5e0dc", + "background": "#1e1e2e", + "foreground": "#b4befe", + "block": "#11111b", + "message-box": "#11111b", + "mention": "rgba(30, 29, 47, .1)", + "success": "#a6e3a1", + "warning": "#fab387", + "tooltip": "#000000", + "error": "#f38ba8", + "hover": "rgba(17, 17, 27, 0.75)", + "scrollbar-thumb": "#b4befe", + "scrollbar-track": "transparent", + "primary-background": "#1e1e2e", + "primary-header": "#181825", + "secondary-background": "#181825", + "secondary-foreground": "#6c7086", + "secondary-header": "#181825", + "tertiary-background": "#11111b", + "tertiary-foreground": "#585b70", + "status-online": "#a6e3a1", + "status-away": "#f2cdcd", + "status-busy": "#eba0ac", + "status-streaming": "#cba6f7", + "status-invisible": "#585b70" + }, + "css": "[data-type=\"mention\"] {\n border-radius: 4px !important;\n}\n" + }, + { + "version": "0.0.1", + "slug": "cats", + "name": "Cats", + "creator": "@LazyCat", + "description": "There are cats on bg and more blur.", + "tags": [ + "cats", + "blur", + "dark" + ], + "variables": { + "accent": "#FD6671", + "background": "transparent", + "foreground": "#FFF", + "block": "#2D2D2D69", + "message-box": "#000000aa", + "mention": "#ffff0015", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgb(0, 0, 0, 0.1)", + "tooltip": "#000000", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#000000a0", + "primary-header": "#00000069", + "secondary-background": "#00000069", + "secondary-foreground": "#C8C8C869", + "secondary-header": "#00000069", + "tertiary-background": "#00000069", + "tertiary-foreground": "#ffffff69", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "body { \nbackground-image: url('https://autumn.revolt.chat/attachments/PGJcmqTljyBeHQAsqq8aaJjJ6bQBZbrfCfWkoIjhgC/Hauskatze_langhaar.jpg');\nbackground-size: cover;\nbackground-position: center;\n}\n\n.EmbedInvite__EmbedInviteBase-sc-154n8c6-0.bTyHZC,\n._attachment_197qa_1._file_197qa_25,\n.MessageBox__Base-sc-jul4fa-0.jBEnry,\n._embed_1912h_1._website_1912h_12,\n.Theme__ThemeInfo-sc-12nukt1-0.jPrjw,\n.Sidebar__Base-sc-1uh6eub-0.cRCSBm,\n._content_sso5v_77, \n._details_605sm_31,\n._actions_ag1so_25 > *,\n.Modal__ModalBase-sc-1ppg3sd-0.bDDYwS,\n._settings_t7t23_33,\n._actions_iu4oa_1,\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh,\n.VoiceHeader__VoiceBase-sc-mvbohh-0.coVeIl,\n.Tip__TipBase-sc-1b2fe6b-1.bVXzUI,\n.ComboBox-sc-djv074-0.ddkErI,\n.FilePreview__Container-sc-znkm6h-0.ewmXHm,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-1qkt4w1-0.kyMBmJ,\n.Titlebar__TitlebarBase-sc-1ykozpe-0.hwmymO,\n.BottomNavigation__Base-sc-15obpw5-0.fmxapA,\n._invite_605sm_1, .Header-sc-39hpd7-0.fUpUzg,\n.ErrorBoundary__CrashContainer-sc-1s8h6qo-0.cHHQBt,\n._pending_sd6wo_95,\n._attachment_197qa_1._audio_197qa_13,\n.RequiresOnline__Base-sc-qthzj2-0.hwlBoN,\n._attachment_197qa_1._text_197qa_33,\n.RevoltApp__StatusBar-sc-1613ew5-1.iOZZrd,\n.Button-sc-1gxkzq3-0.iWNKPN,\n.Tile__Icon-sc-1szgf75-3.xlMfH,\nimg._image_197qa_75,\n.Base-sc-1d91xkm-0.kVCswW,\n._embed_dxa3n_1._website_dxa3n_12,\n.sc-iBPTik.bnZuoo,\n.Details-sc-3j94yd-0.jKeKvP,\n.InviteBot__BotInfo-sc-eg29o6-0.fNvYYN,\n.ComboBox-sc-f94r78-0.jfwTbF,\n\ninput, textarea, button, .entry, blockquote, .spoiler,\npre.code, .context-menu, audio, table, .mobile, pre\n{\n backdrop-filter: blur(25px) !important;\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry > * ,\n.MessageBox__Base-sc-jul4fa-0.jBEnry, \n.ServerListSidebar__ServerEntry-sc-10bk066-2.jFgSXl > span > svg > path\n{\n background-color: transparent !important;\n fill: #000000 !important;\n}\n\n.InviteBot__BotInfo-sc-eg29o6-0.fNvYYN {\n border-radius: 10px;\n}\n\noption, audio {\n background-color: #000000;\n color: #fff;\n}\n\noption:hover {\n background-color: var(--accent);\n}\n\nblockquote < pre.code {\n backdrop-filter: none;\n}\n\nblockquote > blockquote {\n backdrop-filter: none;\n backgound-color: #000000 !important;\n}\n\n*::selection {\n background-color: var(--accent);\n color: #fff;\n}\n\n.RevoltApp__StatusBar-sc-1613ew5-1.iOZZrd {\n color: var(--accent) !important;\n}\n\n.context-menu {\n transition: all 0.3s;\n background-color: #000000a0 !important;\n}\n\n.context-menu > *:hover {\n color: var(--accent);\n background-color: #00000015 !important;\n backdrop-filter: none;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Button-sc-1gxkzq3-0.iWNKPN, \ntable, .Details-sc-1qkt4w1-0.kyMBmJ, time\n{\n background-color: #00000069;\n}\n\n.Titlebar__TitlebarBase-sc-1ykozpe-0.hwmymO {\n background-color: #000000f0 !important;\n}\n\ntextarea, time {\n background-color: transparent !important;\n}\n\n::root {\n --sidebar-active: #000000;\n}\n\nimg._image_197qa_75 {\n background-color: #00000069 !important;\n}\n\n._container_197qa_64 {\n min-width: 20%;\n}\n\n.spoiler > img {\n display: none;\n}\n\n.Modal__ModalBase-sc-1ppg3sd-0.bDDYwS {\n background: rgba(0, 0, 0, 0.5);\n}\n" + }, + { + "version": "1.0.0", + "slug": "classic-grey", + "name": "Classic Grey", + "creator": "Sandwich247", + "description": "A classic grey theme with orange accents.", + "tags": [ + "light", + "grey", + "orange" + ], + "variables": { + "accent": "#F39F00", + "background": "#787878", + "foreground": "#ffffff", + "block": "#2e2e2e", + "message-box": "#3d3d3d", + "mention": "#637eb6", + "success": "#669a4d", + "warning": "#F39F00", + "tooltip": "#000000", + "error": "#420000", + "hover": "#7d7d7d", + "scrollbar-thumb": "#F39F00", + "scrollbar-track": "#3d3d3d", + "primary-background": "#707070", + "primary-header": "#3d3d3d", + "secondary-background": "#5e5e5e", + "secondary-foreground": "#d9d9d9", + "secondary-header": "#4f4f4f", + "tertiary-background": "#4f4f4f", + "tertiary-foreground": "#cccccc", + "status-online": "#669a4d", + "status-away": "#F39F00", + "status-focus": "#4799F0", + "status-busy": "ff0000", + "status-streaming": "#977EFF", + "status-invisible": "#383838" + } + }, + { + "version": "1.0.0", + "slug": "classic-xp", + "name": "Classic XP", + "creator": "Neptuner", + "description": "Throwback theme to the Windows XP era", + "tags": [ + "windows", + "windows-xp", + "xp", + "throwback", + "light" + ], + "variables": { + "accent": "#0855dd", + "background": "#ece9d8", + "foreground": "#000000", + "block": "#ece9d8", + "message-box": "#e2deca", + "mention": "rgba(252,207,3, 0.3)", + "success": "#81C046", + "warning": "#FAA352", + "error": "#DE482B", + "hover": "rgba(0, 0, 0, 0.2)", + "scrollbar-thumb": "#1255be", + "scrollbar-track": "transparent", + "primary-background": "#e2deca", + "primary-header": "#e2deca", + "secondary-background": "#ece9d8", + "secondary-foreground": "#1f1f1f", + "secondary-header": "#e2deca", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#3a3a3a", + "status-online": "#81C046", + "status-away": "#F39F00", + "status-busy": "#DE482B", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "0.0.1", + "slug": "comfy-blue", + "name": "Comfy Blue", + "creator": "Qky", + "description": "Soft and cute looking light blue theme.", + "tags": [ + "blue", + "light", + "pastel" + ], + "variables": { + "accent": "#165EC9", + "background": "#FFFFFF", + "foreground": "#071D40", + "block": "rgb(43, 118, 231, 0.9)", + "message-box": "rgba(89, 148, 236, 0.7)", + "mention": "rgba(236, 89, 221, 0.3)", + "success": "#2BE79C", + "warning": "#E79C2B", + "tooltip": "#5994EC", + "error": "#EC6859", + "hover": "#c8dbf9", + "scrollbar-thumb": "rgba(177, 89, 236, 0.6)", + "scrollbar-track": "transparent", + "primary-background": "rgba(181, 207, 246, 0.31)", + "primary-header": "#6296E3", + "secondary-background": "#87B2F1", + "secondary-foreground": "#0C336E", + "secondary-header": "#6C98DA", + "tertiary-background": "#5994EC", + "tertiary-foreground": "#11489C", + "status-online": "#59EC68", + "status-away": "#ece75b", + "status-busy": "#EC6859", + "status-streaming": "#9C2BE7", + "status-invisible": "#747f8d", + "border-color": "#d0d7de" + } + }, + { + "version": "1.0.0", + "slug": "dark", + "name": "Dark Mode", + "creator": "Revolt", + "description": "This is the default dark theme for Revolt.", + "tags": [ + "revolt", + "dark" + ], + "variables": { + "accent": "#FD6671", + "background": "#191919", + "foreground": "#F6F6F6", + "block": "#2D2D2D", + "message-box": "#363636", + "mention": "rgba(251, 255, 0, 0.06)", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#242424", + "primary-header": "#363636", + "secondary-background": "#1E1E1E", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#2D2D2D", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#848484", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "v1.0.0", + "name": "Darkmode Pink", + "slug": "darkmode-pink", + "description": "A pleasant dark theme with pink highlights.", + "creator": "maloriemeows", + "variables": { + "accent": "#e8a6aa", + "background": "#101010", + "foreground": "#faebeb", + "block": "#2D2D2D", + "message-box": "#282828", + "mention": "#564848", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "tooltip": "#000000", + "scrollbar-track": "transparent", + "scrollbar-thumb": "#ffb3b8", + "primary-header": "#665c5c", + "primary-background": "#0d0d0d", + "secondary-header": "#2D2D2D", + "secondary-background": "#131313", + "secondary-foreground": "#ccadad", + "tertiary-background": "#323232", + "tertiary-foreground": "#848484", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-focus": "#4799F0", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "tags": [ + "dark" + ] + }, + { + "version": "0.0.1", + "slug": "darkmode-purple", + "name": "Darkmode Purple", + "creator": "gurblygirl", + "description": "A snazzy, purple, and black theme", + "tags": [ + "purple", + "dark", + "darkmode" + ], + "variables": { + "accent": "#ad90a9", + "background": "#101010", + "foreground": "#c8c8c8", + "block": "#1e1e1e", + "message-box": "#0f0f0f", + "mention": "#171717", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "tooltip": "#000000", + "scrollbar-track": "transparent", + "scrollbar-thumb": "#191919", + "primary-header": "#1d1b1d", + "primary-background": "#0a0a0a", + "secondary-header": "#1e1e1e", + "secondary-background": "#0d0d0d", + "secondary-foreground": "#998593", + "tertiary-background": "#262224", + "tertiary-foreground": "#969696", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-focus": "#4799F0", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "1.0.0", + "slug": "deepocean", + "name": "Deep Ocean", + "creator": "hofq", + "description": "Deep oceanic blue inspired by Hyperocean", + "tags": [ + "ocean", + "space", + "blue", + "dark" + ], + "variables": { + "accent": "#00b0ff", + "background": "#0f111a", + "foreground": "#FFFFFF", + "block": "rgba(144, 164, 255, 0.1)", + "message-box": "#1c2031", + "mention": "rgba(242, 242, 122, 0.3)", + "success": "#14b37d", + "warning": "#f2f27a", + "error": "#ff5f56", + "hover": "rgba(144, 164, 255, 0.03)", + "scrollbar-thumb": "#ff5f56", + "scrollbar-track": "transparent", + "primary-background": "#0b0c13", + "primary-header": "#0f111a", + "secondary-background": "#0f111a", + "secondary-foreground": "#bcc6d6", + "secondary-header": "#1c2031", + "tertiary-background": "#1c2031", + "tertiary-foreground": "#9ba3b0", + "status-online": "#14b37d", + "status-away": "#f2f27a", + "status-busy": "#ff5f56", + "status-streaming": "#703faf", + "status-invisible": "#8f93a2" + } + }, + { + "version": "1.0.0", + "slug": "discord", + "name": "Discord", + "creator": "Axorax", + "description": "Theme based on Discord dark mode.", + "tags": [ + "dark", + "discord" + ], + "variables": { + "accent": "#5865F2", + "background": "#1E1F22", + "foreground": "#fff", + "block": "#2B2D31", + "message-box": "#313338", + "mention": "#444037", + "success": "#23A55A", + "warning": "#F0B132", + "error": "#DA373C", + "hover": "#2E3035", + "scrollbar-thumb": "#1A1B1E", + "scrollbar-track": "#2B2D31", + "primary-background": "#313338", + "primary-header": "#313338", + "secondary-background": "#2B2D31", + "secondary-foreground": "#dbdee1", + "secondary-header": "#3F4147", + "tertiary-background": "#4f4f4f", + "tertiary-foreground": "#8e8f90", + "status-online": "#23A55A", + "status-away": "#F0B232", + "status-busy": "#F23F43", + "status-streaming": "#593695", + "status-invisible": "#80848E" + } + }, + { + "version": "0.0.3", + "slug": "discord-colors", + "name": "Discord Colors", + "creator": "Eleven", + "description": "Discord colors for Revolt.", + "tags": [ + "discord", + "blurple", + "dark" + ], + "variables": { + "accent": "#5865F2", + "background": "#202225", + "foreground": "#ffffff", + "block": "#202225", + "message-box": "#40444b", + "mention": "#4b443b", + "success": "#57F287", + "warning": "#FEE75C", + "error": "#ED4245", + "hover": "rgba(0, 0, 0, 0.1)", + "sidebar-active": "#2f3136", + "scrollbar-thumb": "#202225", + "scrollbar-track": "transparent", + "primary-background": "#36393f", + "primary-header": "#2f3136", + "secondary-background": "#2f3136", + "secondary-foreground": "#b9bbbe", + "secondary-header": "#2a2c31", + "tertiary-background": "#202225", + "tertiary-foreground": "#b9bbbe", + "status-online": "#3ba55d", + "status-away": "#faa81a", + "status-busy": "#ed4245", + "status-streaming": "#593695", + "status-invisible": "#747f8d" + }, + "css": ":root {\n --sidebar-active: #2f3136;\n}\n\na,\na:hover {\n color: #13abe8;\n}\n\n/* Apply margin to server scroller */\n[class*=\"SidebarBase\"]:first-child > [class^=\"Base-sc\"] {\n margin-bottom: 10px;\n margin-left: 10px;\n margin-right: 10px;\n}\n\n[data-test-id^=\"virtuoso-item-list\"]{\n\tmargin-top: 10px !important;\n} \n\n[class*=\"SidebarBase\"]\n > [class^=\"Base-sc\"]\n > [data-test-id*=\"virtuoso-scroller\"] {\n width: 70px;\n} \n\n[class*=\"MessageBox__Base\"] {\n margin-left: 20px;\n margin-right: 20px;\n margin-bottom: 26px;\n border-radius: 10px;\n}\n\n/* Padding at the end of the chat. */\n[class*=\"MessageArea__Area\"] > div {\n padding-bottom: 0px;\n}\n\n[class*=\"RevoltApp__Routes\"] > [class*=\"Header\"] {\n background: #36393f;\n box-shadow: 1px 1px 1px #27292e;\n border-bottom: solid #363636 1px;\n}\n\n[class^=\"MessageBase__MessageInfo\"].csvICB {\n width: 90px;\n}\n\n[class*=\"MessageBase__MessageInfo\"] > [class*=\"avatar\"] {\n height: 44px !important;\n width: 44px;\n}\n\n[class*=\"MessageArea__Area\"] {\n --scrollbar-track: #2e3338;\n --scrollbar-thickness: 10px;\n}\n\n::-webkit-scrollbar-thumb {\n display: inline-block;\n background: #202225;\n border-radius: 15px;\n\n background-clip: border-box !important;\n height: 5px !important;\n background-size: 5px 5px !important;\n background-repeat: no-repeat;\n\n max-height: 5px !important;\n}\n\n::-webkit-scrollbar-track {\n border-radius: 30px;\n}\n\n:root {\n --foreground: #d3d3d3;\n}\n\n._item_1avxi_1[data-alert=\"true\"] {\n color: #ffffff !important;\n}\n\n._item_1avxi_1[data-active=\"true\"] {\n color: #ffffff !important;\n}\n\n:root {\n --hover: #3c3f45;\n}\n\n@media (hover: hover) {\n [class*=\"ServerSidebar__ServerList\"]:not(:hover) {\n overflow: hidden;\n }\n\n [data-test-id*=\"virtuoso-scroller\"]:not(:hover) {\n overflow-y: hidden !important;\n overflow: hidden;\n }\n}\n\n[class*=\"FilePreview__Container\"] {\n margin: 10px;\n}\n\n[class*=\"MessageBox__Blocked\"] {\n margin-left: 20px;\n}\n\n[class*=\"MessageBox__Action\"] {\n margin-right: 10px;\n}\n\n[class*=\"MessageArea__Area\"] > div > [class*=\"Base-sc\"]:has(> time) {\n border-top: solid 1px #42464d;\n justify-content: center;\n}\n\n[class*=\"MessageArea__Area\"] > div > [class*=\"Base-sc\"]:has(> time) > time {\n color: #b9bbbe;\n}\n\n[class*=\"MessageBase\"]:hover {\n background: #32353b;\n}\n\n[class*=\"ServerSidebar__ServerBase\"] {\n margin: 0 !important;\n}\n\nblockquote {\n background: none !important;\n border-radius: 2px !important;\n border-inline-start-color: #4f545c !important;\n}\n\n@media (hover: none), (pointer: coarse) {\n [class^=\"MessageBase__MessageInfo\"].csvICB { \n width: 60px;\n margin-right: 5px;\n }\n\n [class*=\"MessageBox__Action\"] {\n margin-right: 0px;\n }\n\n textarea[class*=\"TextArea\"] {\n width: 115%;\n }\n\n /* New observation, above and this can be deleted probably */\n textarea[class*=\"TextArea\"] {\n width: 100%;\n }\n}\n\n[class*=\"MessageArea__Area\"] {\n margin-bottom: -40px;\n}\n\n[class*=\"MessageArea__Area\"] > div {\n padding-bottom: 60px;\n}\n\n/* Messages scrollbar overflow fix */\n::-webkit-scrollbar-track {\n margin-bottom: 65px;\n margin-top: 53px;\n}\n\n::-webkit-scrollbar-thumb {\n min-height: 0px !important;\n}\n\na,\na:link,\na:visited,\na:hover {\n color: #15afe3;\n}\n\n[class^=\"SidebarBase\"] [class^=\"ParentBase-sc\"] {\n height: 48px;\n width: 48px;\n}\n\n[class^=\"SwooshWrapper\"] {\n top: -58px;\n left: -6px;\n}\n\n[class^=\"SwooshWrapper\"] > svg {\n width: 66px;\n height: 163px;\n}\n\n[class^=\"MessageBase__MessageInfo\"] {\n max-height: 38px;\n}\n\n[class^=\"MessageBase-sc\"] {\n padding: 0.225rem;\n}\n\n[class^=\"MessageBase__MessageContent-sc\"] > [class^=\"detail\"] {\n margin-bottom: 5px;\n}\n\n@media (hover: none), (pointer: coarse) {\n [class^=\"MessageArea__Area-sc\"]::-webkit-scrollbar {\n width: 0px;\n }\n}\n\n[class^=\"Search__SearchBase-sc\"] > input[class^=\"InputBox-sc\"] {\n background: #24262a;\n}\n\n\n@media (pointer: coarse) {\n [class^=\"ServerSidebar__ServerList-sc\"] [class^=\"_item\"] {\n height: 46px;\n }\n}\n\n@media (pointer: coarse) {\n [class^=\"MessageEditor__EditorBase-sc\"] {\n position: relative;\n left: -6%;\n }\n}\n\n@media (pointer: coarse) {\n [class^=\"MessageBox__FloatingLayer-sc\"] > [class^=\"Column-sc\"] {\n z-index: 6;\n }\n\n [class^=\"Header-sc\"] {\n z-index: 1;\n }\n}\n\n[class^=\"MessageBox__FileAction-sc\"] > [class^=\"IconButton-sc\"] {\n width: 56px;\n justify-content: flex-end;\n padding-right: 10px;\n}\n[class^=\"MessageBox__FileAction-sc\"] {\n padding-left: 4px;\n}\n\n\n/* Make scrollbar alike Discord for Desktop*/\n@media (pointer: fine) {\n\t[class^=\"Channel__ChannelContent-sc\"]{\n\t\tmargin-right: 5px;\n\t }\n\t[class^=\"MessageArea__Area-sc\"]{\n\t\tpadding-right: 4px;\n\t}\n}\n\n[class^=\"MessageBase__MessageContent\"] > [class^=\"_embed\"] {\n\tmargin: 0.3em 0;\n}\n\n \n/* Disable highlights on hover of server channels */\n[class^=\"ServerSidebar__ServerList-sc\"] [class^=\"_item_\"]:hover{\n\tcolor: var(--tertiary-foreground);\n} \n\n/* Animations for Server List & Chat Containers */\n\n@keyframes scaleanimation{\n 0% { transform: scale(0.9); } \n 100% { transform: scale(1.0); } \n} \n\n[class^=ParentBase-sc]:active, [class^=SwooshWrapper-sc] { \n animation: scaleanimation 2s forwards;\n} \n\n@keyframes customfadein{\n 0% { opacity: 0; } \n 100% { opacity: 1; } \n} \n\n[class^=SwooshWrapper-sc], [class^=Channel__ChannelContent-sc] > * {\n animation: customfadein 1.0s forwards; \n} \n\n/* Quick fix for the emoji picker due to recent changes in Revolt's emoji picker behavior. */\n[data-test-id=\"virtuoso-item-list\"] > [class^=RowContainer-sc] {\n flex-wrap: nowrap;\n}\n" + }, + { + "version": "0.1.0", + "slug": "discord-colours-layout", + "name": "Discord Colours & Layout", + "creator": "Derpius", + "description": "Original Discord colours as well as some custom CSS to replicate the feel", + "tags": [ + "discord", + "blurple", + "dark" + ], + "commit": "70bda88", + "variables": { + "accent": "#7289DA", + "background": "#202225", + "foreground": "#dcddde", + "block": "#2f3136", + "message-box": "#34363c", + "mention": "hsla(38,95.7%,54.1%,0.1)", + "success": "hsl(139,calc(47.3%),43.9%);", + "warning": "#FAA352", + "error": "hsl(359,66.7%,54.1%);", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#202225", + "scrollbar-track": "#2f3136", + "primary-background": "#36393f", + "primary-header": "#2d2f34", + "secondary-background": "#2f3136", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#2f3136", + "tertiary-background": "#4f545c", + "tertiary-foreground": "#a3a6aa", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "/*\n\tFonts\n*/\n:root {\n\t--text-size: 14px;\n}\nbody {\n\tfont-weight: 400;\n}\n[class^=\"_item_\"] > div[class^=\"_name_\"], [class^=\"Category-sc-\"], [class^=\"MessageReply__ReplyBase\"] .user {\n\tfont-weight: 500;\n}\n/* Revite actually uses important on the author, so we need to do a higher specificity important */\nspan.detail span.author {\n\tfont-weight: 500 !important;\n}\n\n/*\n\tLayout\n*/\n[class^=\"_settings_\"] {\n\tposition: relative !important;\n}\n\n[class^=\"MessageBase__MessageInfo\"] [class^=\"IconBase\"] {\n\twidth: 40px;\n\theight: 40px;\n}\n\n[class^=\"MessageBase\"], .detail {\n\tmin-height: 1.375em;\n\tpadding: 0.125em;\n}\n[class^=\"MessageBase__DetailBase\"],\n[class^=\"MessageBase\"] > time,\n[class^=\"MessageBase\"] > .edited {\n\tmin-height: 1em;\n\tdisplay: flex;\n\tjustify-content: center;\n\talign-items: center;\n}\n\n[class^=\"UserShort__BotBadge-sc-\"] {\n\theight: 1.3em;\n}\n\npre {\n\tpadding: 0.5em !important;\n\tmargin: 0;\n}\n\n/*\n\tRecolours\n*/\n::selection {\n\tbackground-color: var(--accent);\n\tcolor: var(--foreground);\n}\n\n[class^=\"MessageReply__ReplyBase\"]::before {\n\tborder-inline-start: 2px solid var(--tertiary-background);\n border-top: 2px solid var(--tertiary-background);\n}\n\npre {\n\tborder: 1px solid var(--background);\n}\n\n/*\n\tCustom Scrollbar\n*/\n:not(\n\t[data-test-id=\"virtuoso-scroller\"],\n\t[class^=\"Groups-sc\"],\n\t[class*=\"Sidebar\"]\n)::-webkit-scrollbar {\n\twidth: 16px;\n}\n\n::-webkit-scrollbar-track, ::-webkit-scrollbar-thumb {\n\tborder-radius: 8px;\n\tborder: solid 4px transparent !important;\n\tbackground-clip: padding-box;\n}\n\n::-webkit-scrollbar-thumb {\n\tmin-height: 40px;\n}\n\n::-webkit-scrollbar-track {\n\tmargin-bottom: 4px;\n\tmargin-top: 4px;\n}\n\n[data-scroll-offset]::-webkit-scrollbar-track {\n\tmargin-top: calc(var(--header-height) + 4px);\n}\n" + }, + { + "version": "0.0.1", + "slug": "dracula", + "name": "Dracula", + "creator": "Wild", + "description": "Dracula theme for Revolt. https://draculatheme.com/", + "variables": { + "accent": "#ff79c6", + "background": "#282a36", + "foreground": "#f8f8f2", + "block": "#1C1D26", + "message-box": "#343646", + "mention": "rgb(139, 233, 253, 0.05)", + "success": "#50fa7b", + "warning": "#ffb86c", + "error": "#ff5555", + "hover": "#282a36", + "scrollbar-thumb": "#bd93f9", + "scrollbar-track": "transparent", + "primary-background": "#282a36", + "primary-header": "#343646", + "secondary-background": "#1C1D26", + "secondary-foreground": "#f8f8f2", + "secondary-header": "#343646", + "tertiary-background": "#282a36", + "tertiary-foreground": "#AEAEA9", + "status-online": "#50fa7b", + "status-away": "#ffb86c", + "status-busy": "#ff5555", + "status-streaming": "#bd93f9", + "status-invisible": "#6272a4" + }, + "tags": [ + "dark" + ] + }, + { + "version": "1.0.0", + "slug": "forgejo-dark", + "name": "Forgejo Dark", + "creator": "Freeplay", + "description": "Forgejo's Dark Colors", + "tags": [ + "dark", + "forgejo" + ], + "variables": { + "accent": "#e5873a", + "background": "#10161d", + "foreground": "#D2E0F0", + "block": "#171e26", + "message-box": "#1d262f", + "mention": "#37435160", + "success": "#0be881", + "warning": "#ed8936", + "error": "#f66151", + "hover": "rgba(34, 45, 55, 0.5)", + "scrollbar-thumb": "#e5873a", + "scrollbar-track": "transparent", + "primary-background": "#171e26", + "primary-header": "#242d38", + "secondary-background": "#131a21", + "secondary-foreground": "#D2E0F0CC", + "secondary-header": "#1d262f", + "tertiary-background": "#171e26", + "tertiary-foreground": "#D2E0F099", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "1.0.0", + "slug": "github-dark", + "name": "Github Dark", + "creator": "CodeWhiteOfficial", + "description": "Github's Dark theme from the official color palette", + "tags": [ + "dark", + "github" + ], + "variables": { + "accent": "#6C63FF", + "background": "#1B1F23", + "foreground": "#CCCCCC", + "block": "#1B1F23", + "message-box": "#40444b", + "mention": "#4b443b", + "success": "#2D974D", + "warning": "#FEE75C", + "error": "#ED4245", + "hover": "rgba(0, 0, 0, 0.1)", + "tooltip": "#000000", + "scrollbar-thumb": "#1B1F23", + "scrollbar-track": "transparent", + "primary-background": "#1B1F23", + "primary-header": "#181A1F", + "secondary-background": "#181A1F", + "secondary-foreground": "#b9bbbe", + "secondary-header": "#1B1F23", + "tertiary-background": "#1B1F23", + "tertiary-foreground": "#b9bbbe", + "status-online": "#2D974D", + "status-away": "#FEE75C", + "status-focus": "#6C63FF", + "status-busy": "#ED4245", + "status-streaming": "#593695", + "status-invisible": "#747f8d" + } + }, + { + "version": "1.0.0", + "slug": "github-dark-dimmed", + "name": "Github Dark Dimmed", + "creator": "CodeWhiteOfficial", + "description": "Github's Dark Dimmed theme from the official color palette", + "tags": [ + "dark", + "dark-dimmed", + "github" + ], + "variables": { + "accent": "#0366d6", + "background": "#1B1B1B", + "foreground": "#C5C8C6", + "block": "#262626", + "mention": "rgba(3, 102, 214, 0.06)", + "message-box": "#2E2E2E", + "success": "#28A745", + "warning": "#F5A623", + "error": "#CB2431", + "hover": "rgba(0, 0, 0, 0.1)", + "tooltip": "#000000", + "scrollbar-thumb": "#3E3E3E", + "scrollbar-track": "transparent", + "primary-background": "#282828", + "primary-header": "#3C3C3C", + "secondary-background": "#252525", + "secondary-foreground": "#C5C8C6", + "secondary-header": "#2E2E2E", + "tertiary-background": "#383838", + "tertiary-foreground": "#848484", + "status-online": "#28A745", + "status-away": "#F5A623", + "status-focus": "#0366d6", + "status-busy": "#CB2431", + "status-streaming": "#3E3E3E", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "1.0.0", + "slug": "glass", + "name": "Glass", + "creator": "Axorax", + "description": "Modified nucleon theme with glass effect.", + "tags": [ + "dark", + "transparent", + "glass", + "blur", + "nucleon-glass" + ], + "variables": { + "background": "#11111192", + "mention": "rgba(251, 255, 0, 0.1)", + "hover": "rgba(0, 0, 0, 0.3)", + "primary-background": "#11111192", + "secondary-background": "#11111192", + "secondary-foreground": "#c9c9c9" + }, + "css": "._11Ivv {\n background: url(\"https://autumn.revolt.chat/attachments/FQkCgdyHs4z9OXbdGfbF396uhPahc3seYRFMdEMZqp/axo-theme-bg.jpg\");\n background-repeat: no-repeat;\n background-size: cover;\n}\n\n.PreloaderBase-sc-1xcl5li-0.ddQdoB {\n background: #11111192;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.gILwrq > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n margin-top: 2rem;\n border-radius: 6px;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div,\n.TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n backdrop-filter: blur(2px);\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div button.active {\n background: #111111c9;\n border: 1px solid #3d3d3d;\n}\n\n.MessageBase__DetailBase-sc-1s9ehlg-3.kyoVXf time {\n color: #c9c9c9;\n}\n\ntextarea#message::placeholder {\n color: #ffffff8e;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 1rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 0.5rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\npre.sc-bkzYnD.cjWexI {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\n.Base-sc-1kllx7r-0.iIKZoW {\n border-top: thin solid #5eb0c3;\n}\n\n.Base-sc-yb16g4-0.iDeTmr {\n margin: 1rem;\n background: #11111192;\n border-radius: 6px;\n}\n\n.Shadow-sc-yb16g4-1.bSmCBu {\n display: none;\n}\n\n.MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n margin: 5rem 1rem 1rem 1rem;\n background: #11111192;\n border-radius: 6px;\n backdrop-filter: blur(2.8px);\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n background: #11111192;\n border-radius: 6px;\n margin: 0 1rem 1rem 1rem;\n backdrop-filter: blur(3px);\n}\n\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n background: #11111192;\n margin: 5rem 1rem 0 0;\n height: calc(100vh - 6rem);\n border-radius: 6px;\n padding: 0.5rem;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX, .Header-sc-ujh2d9-0.fEKFaQ, .Header-sc-ujh2d9-0.dzRjrU, .Header-sc-ujh2d9-0.gXyCoD {\n margin: 1rem;\n border-radius: 6px;\n width: calc(100vw - 22rem);\n backdrop-filter: blur(0px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.gXyCoD {\n width: calc(100vw - 7.4rem) !important; \n}\n\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 6.4rem) !important;\n margin-left: -15.5rem !important;\n}\n\n._list_sd6wo_13.with-padding {\n background: #11111192;\n margin-right: 0.8rem;\n margin-top: 5rem;\n border-radius: 6px;\n height: calc(100vh - 6rem);\n}\n\n.Header-sc-ujh2d9-0.dzRjrU {\n width: calc(100vw - 7.4rem) !important;\n margin-left: 1rem !important;\n}\n\n.ItemContainer-sc-176t3v5-0.iRFMLa {\n overflow: hidden;\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n background: #11111192;\n border-radius: 6px;\n margin: 1rem 0;\n height: calc(100vh - 2rem);\n}\n\n.ThemeBaseSelector__List-sc-udzwdl-0.fTtexK > div h4 {\n margin-top: .5rem;\n color: #c9c9c9;\n}\n\n._contentcontainer_t7t23_62 h4 {\n color: #c9c9c9 !important;\n}\n\npre.sc-bkzYnD.cjWexI code,\n.MemberList__ListCategory-sc-1bwlier-0.fWMCga,\n.MemberList__ListCategory-sc-1bwlier-0.jOPNWX,\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Channel__ChannelMain-sc-k5myot-0.DmIgS,\n.RevoltApp__Routes-sc-1613ew5-2.hpNYLK,\n.Channel__ChannelContent-sc-k5myot-1.hfkRtg,\n.RevoltApp__Routes-sc-1613ew5-2.jAWgqa,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-3j94yd-0.jKeKvP summary {\n background: transparent !important;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq,\na.Base-sc-j9ukbi-0.bQqZNf,\n.TipBase-sc-upm053-0.jzUIcb {\n background: #11111192;\n border: 1px solid #3d3d3d;\n}\n\niframe.Discover__Frame-sc-6nwhb3-1.kcZkuw {\n margin: 1rem;\n border-radius: 10px;\n}\n\ndiv#Menu,\n.MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: blur(9px);\n background: #11111192;\n}\n\n._contentcontainer_t7t23_62::after {\n content: \"⠀\";\n}\n\n._embed_dxa3n_1._website_dxa3n_12 {\n backdrop-filter: blur(8px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX svg {\n width: 22px;\n aspect-ratio: 1/1;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw {\n background: transparent;\n backdrop-filter: blur(0);\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort {\n flex-direction: column !important;\n gap: .5rem !important;\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort button {\n padding: .5rem 1rem !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn {\n background: #0c0c0c;\n border: 1px solid #3d3d3d;\n border-radius: 6px;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:hover {\n outline: 1px dashed #5eb0c3 !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:focus {\n outline: none !important;\n}\n\n._item_1avxi_1._user_1avxi_23 {\n opacity: .7;\n}\n\n._item_1avxi_1._user_1avxi_23, ._item_1avxi_1 div._name_1avxi_60 ._subText_1avxi_67 {\n color: #e7e7e7bd;\n}" + }, + { + "version": "0.0.1", + "slug": "gruvbox-dark", + "name": "Gruvbox Dark", + "creator": "to268", + "description": "This is the gruvbox dark hard theme", + "variables": { + "accent": "#FB4934", + "background": "#282828", + "foreground": "#F6F6F6", + "block": "#282828", + "message-box": "#3C3836", + "mention": "#D3869B", + "success": "#B8BB26", + "warning": "#FABD2F", + "error": "#FE8019", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#FF424F", + "scrollbar-track": "transparent", + "primary-background": "#242424", + "primary-header": "#3C3836", + "secondary-background": "#3C3836", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#504945", + "tertiary-background": "#928374", + "tertiary-foreground": "#BDAE93", + "status-online": "#8EC07C", + "status-away": "#D65D0E", + "status-busy": "#FB4934", + "status-streaming": "#977ED5", + "status-invisible": "#FBF1C7" + }, + "tags": [ + "dark" + ] + }, + { + "version": "1.0.0", + "slug": "horizon", + "name": "Horizon", + "creator": "Demperor", + "description": "The VSCode Horizon theme ported to Revolt.", + "variables": { + "accent": "#6C6F93", + "background": "#16161C", + "foreground": "#8B8D8F", + "block": "#4B4C535A", + "message-box": "#1D1F27", + "mention": "#2E303E9F", + "success": "#09F7A0", + "warning": "#FAB28E", + "tooltip": "#000000", + "error": "#F43E5C", + "hover": "#083C5A9f", + "sidebar-sidebar-active": "#202225", + "scrollbar-thumb": "#8a1046", + "scrollbar-track": "#21252E2A", + "primary-background": "#1D1F27", + "primary-header": "#16161C", + "secondary-background": "#1D1F27", + "secondary-foreground": "#8B8D8F", + "secondary-header": "#1c2031", + "tertiary-background": "#1c2031", + "tertiary-foreground": "#9ba3b0", + "status-online": "#27D796", + "status-away": "#FAC29A", + "status-focus": "#4799F0", + "status-busy": "#F09383", + "status-streaming": "#21BFC2", + "status-invisible": "#c8ccd4" + }, + "tags": [ + "dark" + ] + }, + { + "version": "0.0.1", + "slug": "ice-shard", + "name": "Ice Shard", + "creator": "Cara", + "commit": "3cf8c64", + "description": "Very dark backgrounds and light blue details. Making use of custom css to give the app a sharp, cold feel and add a number of simple ux improvements.", + "variables": { + "accent": "#00fff5", + "background": "#0a0a0a", + "foreground": "#e2ffff", + "block": "#262630", + "message-box": "#1d1d1d", + "mention": "rgba(0, 0, 86, 0.40)", + "success": "#5bffbc", + "warning": "#ffb44a", + "error": "#e72a4f", + "hover": "rgba(37, 37, 48, 0.2)", + "scrollbar-thumb": "#6399a1", + "scrollbar-track": "transparent", + "primary-background": "#181818", + "primary-header": "#1e1e1e", + "secondary-background": "#141414", + "secondary-foreground": "#acf8ff", + "secondary-header": "#1e1e1e", + "tertiary-background": "#292929", + "tertiary-foreground": "#dbffff", + "status-online": "#4cfaff", + "status-away": "#cfe40b", + "status-busy": "#fe0e4d", + "status-streaming": "#b961ff", + "status-invisible": "#cdcdcd" + }, + "css": "/*** Custom css for the ice shard theme ***/\n\n/* Removes all round edges on everything */\n:root {\n --border-radius: 0;\n --border-radius-half: 0;\n}\n\n/* Adds a light blue line between the server and channel list */\n.dGrZZ {\n border-right: 2px solid #72F5F3;\n}\n\n/* Makes spoilers a lighter color, to make them better visible on the dark background */\n:root .spoiler {\n background: #313152;\n}\n\n/* Hides the wave used as a chosen server indicator by default */\n.QgupM span, .kGANuc span {\n display: none;\n}\n\n/* Adds a light blue border around the active server's icon */\n.QgupM svg, .kGANuc svg {\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 2px;\n}\n\n/* Colored outline and name of picked channel */\n._compact_12e90_19[data-active=true],\n._normal_12e90_16[data-active=true],\n._user_12e90_23[data-active=true] {\n color: #37FFF8;\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 2px;\n}\n\n/* Mention message colored + bold text */\ndiv.CaijV > div.jgcjMZ > ._markdown_8b8eo_2,\ndiv.cCwoWr > div.jgcjMZ > ._markdown_8b8eo_2 {\n color: #4CFAFF;\n font-weight: bold;\n}\n\n/* Nice home screen */\n._home_1khrk_1 {\n height: 100%;\n background-image: url(\"https://autumn.revolt.chat/attachments/hqI1nk7NBECh8n7GP3eqt-PXv-5tXGWGnbn9qJWZUO\");\n background-repeat: no-repeat;\n background-attachment: fixed;\n background-size: 100% 100%;\n}\n/* Outlining buttons on home screen for better readability */\ndiv._actions_1khrk_12 > a > div,\ndiv._actions_1khrk_12 > div > a > div {\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 1px;\n}\n\n/* Nicer link hovers */\np>a:hover,span>a:hover,li>a:hover,strong>a:hover,th>a:hover,td>a:hover,\nh1>a:hover,h2>a:hover,h3>a:hover,h4>a:hover,h5>a:hover,h6>a:hover{\n color: magenta;\n background: cyan;\n text-decoration: none !important;\n}\n\n/* Nicer name hovers */\n.author:hover,.user>span:hover{\n background: cyan !important;\n text-decoration: none !important;\n color: #000056;\n}\n\n/* Readable buttons on pop-up menus and bot badges */\n.ZAptB, .iYfESC, .kqSFkj {\n color: #000056;\n font-weight: bold;\n}\n\n/* Theme consistent colors on message search */\n.kRnBUt {\n color: #000056;\n font-weight: bold;\n background: #00FFF5;\n}\n.kRnBUt:hover {\n background: #4CFAFF;\n}\n.jqHpRY {\n color: #00FFF5;\n}\n\n/* buttons and checkboxes */\n.check, .dvKiPb {\n outline-style: solid;\n outline-color: #72F5F3;\n outline-width: 1px;\n}\n\n/***** END *****/\n", + "tags": [ + "dark" + ] + }, + { + "version": "1.2.0", + "slug": "iceshrimp-light", + "name": "IceShrimp Light", + "creator": "theycallhermax", + "description": "The light theme from the Misskey fork bringing you no-nonsense fixes, features and improvements you actually want", + "tags": [ + "light", + "blue", + "iceshrimp" + ], + "variables": { + "accent": "#9A92FF", + "background": "#f1f5ff", + "foreground": "#3B364C", + "block": "#414141", + "message-box": "#f1f5ff", + "mention": "rgb(229, 200, 144, 0.40)", + "success": "rgb(134, 179, 0)", + "warning": "rgb(255, 240, 219)", + "error": "rgb(236, 65, 55)", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#9A92FF", + "scrollbar-track": "transparent", + "primary-background": "#E7EDFF", + "primary-header": "#f1f5ff", + "secondary-background": "#f1f5ff", + "secondary-foreground": "#3B364C", + "secondary-header": "#c6d0f5", + "tertiary-background": "#9A92FF", + "tertiary-foreground": "#3B364C", + "status-online": "#40a02b", + "status-away": "#df8e1d", + "status-busy": "#e64553", + "status-streaming": "#7287fd", + "status-invisible": "#c6d0f5" + }, + "css": "/*\n This is the default CSS for the IceShrimp Light theme.\n\n This applies small fixes that makes Revolt more in place with the\n theme, it is entirely optional to have this CSS as-is, but\n things may feel slightly off.\n*/\n\n:root {\n --border-radius-user-icon: 8px;\n \n font-feature-settings: \"liga\" 1, \"calt\" 1;\n}\n\na[href^=\"/server/\"] svg > circle {\n fill: #303446 !important;\n}\n\n[class^=\"UserShort__Name\"] {\n filter: contrast(70%);\n}\n\n[class^=\"MessageReply__ReplyBase-sc-\"]::before {\n border-inline-start: 2px solid #a5adce;\n border-top: 2px solid #a5adce;\n}\n\n[class^=\"TypingIndicator__Base-sc-\"] > div > [class^=\"avatars\"] > img {\n border-radius: var(--border-radius-user-icon);\n}\n\n[data-item-index=\"0\"] [class^=\"ItemContainer-sc-176t3v5-0\"] foreignObject :is(div, img) {\n border-radius: var(--border-radius-user-icon) !important;\n}\n\n[class^=\"ServerHeader__ServerBanner-sc-\"] {\n margin: 0.5rem;\n border-radius: var(--border-radius-user-icon);\n}\n" + }, + { + "version": "0.0.1", + "slug": "irc-chat", + "name": "IRC Chat", + "creator": "ERROR_404_NULL_NOT_FOUND", + "description": "A pure CSS theme looking to emulate the look and feel of an IRC chat. Colors are amoled. Credits are in Custom.css", + "tags": [ + "amoled", + "irc", + "IRC", + "compact", + "no-pfps", + "dark" + ], + "variables": { + "accent": "#FD6671", + "background": "#000000", + "foreground": "#FFFFFF", + "block": "#1D1D1D", + "message-box": "#000000", + "mention": "rgba(251, 255, 0, 0.06)", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#000000", + "primary-header": "#000000", + "secondary-background": "#000000", + "secondary-foreground": "#DDDDDD", + "secondary-header": "#1A1A1A", + "tertiary-background": "#000000", + "tertiary-foreground": "#AAAAAA", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "/*Original theme: <@01FE43S0TRRR73MEP5Y63Z9MCB>(@Davari)\nEdits to make more usable made by <@01FTN0QWKA45VG5CPCVJH7H399>(@MYT)\n*/\n* {\n border-color: var(--border-color) !important;\n}\n@font-face {\n font-family: Blank;\n src: url(\"https://raw.githack.com/adobe-fonts/adobe-blank/master/AdobeBlank.ttf.woff?raw=true\");\n unicode-range: U+61-7a,U+41-5a,U+20\n}\n:root {\n font-variant-ligatures:normal!important;\n --font: var(--monospace-font) !important;\n --irc-chat-width: 260px;\n}\n[class^=MessageBase-] {\n position: relative;\n margin-top: 0;\n}\n[class^=MessageBase__MessageInfo] {\n width: var(--irc-chat-width);\n margin-right: 10px;\n}\n[class^=MessageBase__MessageInfo]>svg, .user>svg {\n display: none;\n}\n[class^=Channel__ChannelContent] [class^=MessageBase__MessageContent] {\n position: unset;\n}\n[class^=MessageBase__MessageContent]>.detail {\n position: absolute;\n left: 0;\n top: 0;\n width: var(--irc-chat-width);\n display: flex;\n flex-direction: row-reverse;\n}\n[class^=MessageBase__MessageContent]>.detail>.author {\n word-break: break-all;\n}\n[class^=MessageBase__MessageInfo]>time {\n opacity: 1 !important;\n flex-grow: 1; \n}\n[class^=MessageReply__ReplyBase] {\n margin-left: calc(var(--irc-chat-width) + 15px);\n margin-top: 0;\n}\n[class^=MessageReply__ReplyBase]::before {\n content: '>';\n border: none;\n align-self: center;\n width: auto;\n height: 100%;\n}\n[class^=MessageBase__DetailBase] {\n margin-right: auto;\n}\n[class^=MessageBase__DetailBase]>time {\n margin-left: 2px;\n font-family: Blank, var(--font);\n}\n[class^=MessageBase__]>time::before {\n content: '['\n}\n[class^=MessageBase__]>time::after {\n content: ']'\n}\n[class^=MessageBase__MessageContent]>.detail>.author::before, .user>span::before {\n content: '<'\n}\n[class^=MessageBase__MessageContent]>.detail>.author::after, .user>span::after {\n content: '>'\n}\n[class^=DateDivider__Base] {\n margin: 3px -6px;\n padding: 1px 4px;\n position: sticky;\n top: 0;\n border: unset;\n background: var(--secondary-header);\n height: auto;\n z-index: 1;\n}\n[class^=DateDivider__Base]>time {\n font-size: 10px;\n background: unset;\n}\n[class^=MessageArea__Area] {\n background: linear-gradient(to right, var(--secondary-background) calc(var(--irc-chat-width) + 7px), #0000 calc(var(--irc-chat-width) + 7px))\n}\n/*[class^=MessageArea__Area]>div {\n padding-bottom: 0;\n}\nto reactivate this, you need to hide the * is typing bar*/\n[class^=DateDivider__Unread] {\n padding: unset;\n border-radius: unset;\n background: unset;\n color: var(--accent);\n font-size: 10px;\n margin: 0 5px;\n}\n[class^=DateDivider__Unread]::after {\n content: '';\n position: absolute;\n width: 100%;\n background: var(--accent);\n height: 1px;\n top: 7px;\n margin-left: 5px;\n}\n[class^=Channel__ChannelContent] [class^=MessageBase__MessageInfo]>.copyTime {\n position: relative;\n}\n" + }, + { + "version": "0.0.1", + "slug": "kyli0x", + "name": "kyli0x's Theme", + "creator": "kyli0x", + "description": "Cyan and gray based theme for Revolt", + "versions": "1.0.0", + "tags": [ + "dark", + "cyan", + "gray", + "orange" + ], + "variables": { + "accent": "#1AABA0", + "background": "#444444", + "foreground": "#FFFFFF", + "block": "#333333", + "message-box": "#333333", + "mention": "AB581A", + "success": "#ff7d00", + "warning": "#aba01a", + "tooltip": "#5994EC", + "error": "#AB1A25", + "hover": "#222222", + "scrollbar-thumb": "#FF7D00", + "scrollbar-track": "transparent", + "primary-background": "#222222", + "primary-header": "#222222", + "secondary-background": "#222222", + "secondary-foreground": "BCC6D6", + "secondary-header": "#444444", + "tertiary-background": "#FF7D00", + "tertiary-foreground": "#9BA3B0", + "status-online": "#30B6A0", + "status-away": "#ABA01A", + "status-focus": "#9146FF", + "status-busy": "#AB1A25", + "status-streaming": "#A01AAB", + "status-invisible": "#8F93A2" + } + }, + { + "version": "1.0.0", + "slug": "light", + "name": "Light Mode", + "creator": "Revolt", + "description": "This is the default light theme for Revolt.", + "tags": [ + "revolt", + "light" + ], + "variables": { + "accent": "#FD6671", + "background": "#F6F6F6", + "foreground": "#101010", + "block": "#414141", + "message-box": "#F1F1F1", + "mention": "rgba(251, 255, 0, 0.40)", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.2)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#FFFFFF", + "primary-header": "#F1F1F1", + "secondary-background": "#F1F1F1", + "secondary-foreground": "#888888", + "secondary-header": "#F1F1F1", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#646464", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "v0.0.1", + "name": "Lucifer's Fall", + "slug": "lucifers-fall", + "creator": "The Humanoid", + "tags": [ + "amoled", + "purple", + "dark" + ], + "description": "AMOLED black theme based around Lucifer's Fall with a purple accent.", + "variables": { + "accent": "#662d73", + "background": "#000000", + "foreground": "#F6F6F6", + "block": "#000000", + "message-box": "#000000", + "mention": "#662d73", + "success": "#65E572", + "warning": "#FAA352", + "error": "#ED4245", + "hover": "rgba(0, 0, 0, 0.1)", + "tooltip": "#000000", + "scrollbar-track": "transparent", + "scrollbar-thumb": "#662d73", + "primary-header": "#000000", + "primary-background": "#000000", + "secondary-header": "#000000", + "secondary-background": "#000000", + "secondary-foreground": "#C8C8C8", + "tertiary-background": "#662d73", + "tertiary-foreground": "#C8C8C8", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-focus": "#4799F0", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": ":root {\n --border-radius-user-icon: 12px;\n --primary-background: rgba(var(--primary-background-rgb), 0.70);\n --secondary-background: rgba(var(--secondary-background-rgb), 0.35);\n --message-box: rgba(var(--secondary-background-rgb), 0.70);\n --block: rgba(var(--primary-background-rgb), 0.70);\n}\n\n[class^=\"RevoltApp__AppContainer-sc-\"] {\n z-index: -999;\n background: url('https://autumn.revolt.chat/attachments/KHu7zDNg6LcIvIYq22cICwDL8FzqRfjCpFiTW7UvG8/Untitled281_20221101085844.png');\n background-position: center;\n background-repeat: no-repeat;\n background-attachment: fixed;\n background-size: cover;\n position: absolute;\n display: block;\n inset: 0;\n top: 0;\n}\n\n[class^=\"MessageReply__ReplyBase\"]::before {\nborder-inline-start: 2px solid var(--tertiary-background);\nborder-top: 2px solid var(--tertiary-background);}\n" + }, + { + "version": "0.0.1", + "slug": "mint-glow", + "name": "Mint Glow", + "creator": "Potato#9983", + "description": "Dark theme with pastel green highlights.", + "variables": { + "accent": "#B8F684", + "background": "#141414", + "foreground": "#F5F3E7", + "block": "#414141", + "message-box": "#EEC3C3", + "mention": "#FBFF00", + "success": "#AAE95A", + "warning": "#F7D145", + "error": "#EA2A2A", + "hover": "#000000", + "scrollbar-thumb": "#B8F684", + "scrollbar-track": "transparent", + "primary-background": "#1E1E1E", + "primary-header": "#30313B", + "secondary-background": "#252526", + "secondary-foreground": "#BFBFBF", + "secondary-header": "#1A1A1A", + "tertiary-background": "#252526", + "tertiary-foreground": "#D8E4DA", + "status-online": "#3BBF51", + "status-away": "#F39F00", + "status-busy": "#E24050", + "status-streaming": "#9A81FD", + "status-invisible": "#A5A5A5", + "status-tags-0": "pastel", + "status-tags-1": "green", + "status-version": "0.0.1" + }, + "tags": [ + "dark" + ] + }, + { + "version": "0.0.1", + "slug": "neon-blue", + "name": "Neon Blue", + "creator": "WlodekM", + "commit": "782a9b5", + "description": "Neon theme inspired by Opera GX.", + "tags": [ + "neon", + "blue", + "dark" + ], + "variables": { + "accent": "#0084ff", + "background": "#11111192", + "foreground": "#dcddde", + "block": "#2f3136", + "message-box": "#34363c", + "mention": "rgba(251, 255, 0, 0.1)", + "success": "#3ABF7E", + "warning": "#FAA352", + "error": "hsl(359,66.7%,54.1%);", + "hover": "#0084ff50", + "scrollbar-thumb": "#0084ff", + "scrollbar-track": "#2f3136", + "primary-background": "#11111192", + "primary-header": "#2d2f34", + "secondary-background": "#11111192", + "secondary-foreground": "#c9c9c9", + "secondary-header": "#2f3136", + "tertiary-background": "#4f545c", + "tertiary-foreground": "#a3a6aa", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "/* Main BG */\n._11Ivv {\n background: #1a1b26;\n background-repeat: no-repeat;\n background-size: cover;\n z-index: 1;\n border: 1px var(--accent) solid;\n}\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n height: calc(100% - 3rem);\n}\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 58px) !important;\n}\n/* settings button */ \n\n.gafLvO svg {--accent: white !important}\n.FallbackBase-sc-3bwws8-1.gafLvO {\n border-radius: 2px;\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n --cp-size: 5px;\n background: var(--accent);\n}\n\n\n/* stop settings from overlapping border and topbar on desktop */\n._settings_t7t23_33.undefined {\n margin-inline: 1px;\n height: calc(100% - var(--titlebar-height) - 2px);\n width: calc(100% - 2px);\n margin-top: calc(var(--titlebar-height) + 1px) !important;\n}\n\n/* Topbar */\n.hwmymO .actions div:hover {\n background: #252631;\n cursor: pointer;\n}\n\n.drag {\n border-top: 1px var(--accent) solid;\n margin: 0 !important;\n z-index: 999999;\n}\n\n.hwmymO .actions, .hwmymO .title, .hwmymO .drag {\n background: #151621;\n}\n\n.hwmymO .actions {\n border-top: 1px var(--accent) solid;\n border-right: 1px var(--accent) solid;\n margin: 0 !important;\n padding-left: 6px;\n height: 100%;\n}\n\n.hwmymO .title {\n border-top: 1px var(--accent) solid;\n border-left: 1px var(--accent) solid;\n margin: 0 !important;\n padding-left: 10px;\n padding-top: 10px;\n height: 100%;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div, .TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #0002;\n backdrop-filter: blur(5px);\n border-radius: 0;\n border-top: 1px var(--accent) solid;\n}\n\n._tabs_kup3j_69>div:hover:not([data-active=true]) {\n border-bottom: 2px solid #09701a;\n}\n\n._tabs_kup3j_69>div[data-active=true] {\n border-bottom: 2px solid var(--accent);\n}\n\n.Content-sc-1d91xkm-3.bVQPpK {\n border: var(--accent) 1px solid;\n}\n\n._item_1avxi_1._user_1avxi_23:hover svg foreignObject img {\n border-radius: 25%;\n}\n\n._item_1avxi_1._user_1avxi_23 svg foreignObject img {\n transition: .1s ease-in-out all;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.gILwrq > div {\n background: #11111192;\n border-radius: 0;\n border-bottom: var(--accent) 1px solid;\n backdrop-filter: none;\n}\n\n._item_1avxi_1._compact_1avxi_19:hover ._avatar_1avxi_56 img.IconBase__ImageIconBase-sc-igncj4-1.hpNCHs, ._item_1avxi_1._compact_1avxi_19[data-active=\"true\"] ._avatar_1avxi_56 img.IconBase__ImageIconBase-sc-igncj4-1.hpNCHs {\n border-radius: calc(var(--border-radius-half) / 3);\n}\n\nimg.IconBase__ImageIconBase-sc-igncj4-1.hpNCHs {\n border-radius: calc(var(--border-radius-half) / 1.5);\n}\n\n._list_sd6wo_13.with-padding {\n margin-top: 3em;\n height: 100vh !important;\n}\n\n.iDeTmr {\n background: var(--secondary-background);\n border-right: 1px var(--accent) solid;\n}\n\n.Channel__ChannelMain-sc-k5myot-0.DmIgS .SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n border-right: 0;\n border-left: 1px var(--accent) solid;\n}\n\n\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n border-right: 1px var(--accent) solid;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq, a.Base-sc-j9ukbi-0.bQqZNf, .TipBase-sc-upm053-0.jzUIcb {\n --cp-size: 15px;\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n border: none !important;\n}\n\nselect {\n --cp-size: 15px;\n}\n\n.checkmark{\n --cp-size: 7px;\n border: none !important\n}\n\n.EmojiSelector__Container-sc-1b2o6ki-0 div div .button {\n --cp-size: 25px;\n border: none !important\n}\n\n.button, .checkmark, select {\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n}\n\n._item_1avxi_1._compact_1avxi_19 {\n --cp-size: 7.5px;\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n border-radius: 3px !important;\n}\n\n.ThemeOverrides__Container-sc-1brryxj-0 .entry {\n clip-path: polygon(var(--cp-size) 0, 100% 0, 100% calc(100% - var(--cp-size)), calc(100% - var(--cp-size)) 100%, 0 100%, 0 var(--cp-size)) !important;\n --cp-size: 15px;\n}\n\n._attachment_197qa_1._text_197qa_33 {\n border-radius: 0;\n}\n\ndiv._text_197qa_33 div._actions_iu4oa_1 {\n border-top: none !important;\n}\n\n._actions_iu4oa_1 {\n border-radius: 0 !important;\n backdrop-filter: none !important;\n background: #111 !important;\n border: 1px var(--accent) solid !important;\n}\n\n.Header-sc-ujh2d9-0.dzRjrU {\n border-radius: 0;\n border-bottom: 1px var(--accent) solid;\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n border-top: var(--accent) 1px solid;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n width: initial !important;\n margin: 0 !important;\n margin-left: 0 !important;\n border-top: 1px var(--accent) solid;\n}\n\ndiv#Menu, .MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: none;\n background: #141414;\n border-radius: 0;\n border: 1px var(--accent) solid;\n}\n\nbody {\n border: 1px var(--accent) solid\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n border-radius: 0;\n border-right: 1px var(--accent) solid\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n margin: 0 !important;\n border-top: 1px var(--accent) solid\n}\n\n.Header-sc-ujh2d9-0.fPzJRX {\n border-bottom: 1px var(--accent) solid;\n}\n\n.dUCGFk, .MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n margin-top: 3rem !important;\n}\n\n.Details-sc-3j94yd-0 summary div {\n width: 100%;\n}\n\n._item_1avxi_1[data-active=true] {\n cursor: default;\n background: var(--accent);\n}\n\n.Category-sc-tvyi45-0.iuNJGz {\n padding-top: 1.2em;\n border-top: var(--accent) 1px solid;\n width: 100%;\n}\n\n._sidebar_t7t23_39 {\n border-right: var(--accent) 1px solid;\n}\n\n/* cyrillic-ext */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtEacLLTQ.woff2) format('woff2');\n unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;\n}\n/* cyrillic */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtNacLLTQ.woff2) format('woff2');\n unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;\n}\n/* greek */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtKacLLTQ.woff2) format('woff2');\n unicode-range: U+0370-03FF;\n}\n/* vietnamese */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtGacLLTQ.woff2) format('woff2');\n unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;\n}\n/* latin-ext */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtHacLLTQ.woff2) format('woff2');\n unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;\n}\n/* latin */\n@font-face {\n font-family: 'Tektur';\n font-style: normal;\n font-weight: 400;\n font-stretch: normal;\n src: url(https://fonts.gstatic.com/s/tektur/v1/XoHN2YHtS7q969kXCjzlV0aSkS_o8OacmTe0TYlYFot8TrwuVbtJacI.woff2) format('woff2');\n unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;\n}\n\n:root {\n --font: Tektur !important;\n}\n\n.PreloaderBase-sc-1xcl5li-0.ddQdoB {\n background: #11111192;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div,\n.TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #11111192;\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n margin-top: -1rem;\n backdrop-filter: blur(2px);\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div button.active {\n background: #111111c9;\n border: 1px solid #3d3d3d;\n}\n\n.MessageBase__DetailBase-sc-1s9ehlg-3.kyoVXf time {\n color: #c9c9c9;\n}\n\ntextarea#message::placeholder {\n color: #ffffff8e;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 1rem 0;\n margin-left: 1rem;\n backdrop-filter: blur(9px);\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\npre.sc-bkzYnD.cjWexI {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\n.Base-sc-1kllx7r-0.iIKZoW {\n border-top: thin solid #5eb0c3;\n}\n\n.Shadow-sc-yb16g4-1.bSmCBu {\n display: none;\n}\n\n.MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n background: #11111192;\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n background: #11111192;\n margin: 0 1rem 1rem 1rem;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX, .Header-sc-ujh2d9-0.fEKFaQ, .Header-sc-ujh2d9-0.dzRjrU, .Header-sc-ujh2d9-0.gXyCoD {\n backdrop-filter: blur(0px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.gXyCoD {\n width: calc(100vw - 7.4rem) !important; \n}\n\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 58px) !important;\n margin-left: -14.5rem !important;\n border-bottom: var(--accent) 1px solid;\n border-radius: 0 !important;\n}\n\n._list_sd6wo_13.with-padding {\n background: #11111192;\n}\n\n.ItemContainer-sc-176t3v5-0.iRFMLa {\n overflow: hidden;\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n background: #11111192;\n}\n\n.ThemeBaseSelector__List-sc-udzwdl-0.fTtexK > div h4 {\n margin-top: .5rem;\n color: #c9c9c9;\n}\n\n._contentcontainer_t7t23_62 h4 {\n color: #c9c9c9 !important;\n}\n\npre.sc-bkzYnD.cjWexI code,\n.MemberList__ListCategory-sc-1bwlier-0.fWMCga,\n.MemberList__ListCategory-sc-1bwlier-0.jOPNWX,\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Channel__ChannelMain-sc-k5myot-0.DmIgS,\n.RevoltApp__Routes-sc-1613ew5-2.hpNYLK,\n.Channel__ChannelContent-sc-k5myot-1.hfkRtg,\n.RevoltApp__Routes-sc-1613ew5-2.jAWgqa,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-3j94yd-0.jKeKvP summary {\n background: transparent !important;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq,\na.Base-sc-j9ukbi-0.bQqZNf,\n.TipBase-sc-upm053-0.jzUIcb {\n background: #11111192;\n border: 1px solid #3d3d3d;\n}\n\ndiv#Menu,\n.MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: blur(9px);\n background: #11111192;\n}\n\n._contentcontainer_t7t23_62::after {\n content: \"⠀\";\n}\n\n._embed_dxa3n_1._website_dxa3n_12 {\n backdrop-filter: blur(8px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX svg {\n width: 22px;\n aspect-ratio: 1/1;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw {\n background: transparent;\n backdrop-filter: blur(0);\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort {\n flex-direction: column !important;\n gap: .5rem !important;\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort button {\n padding: .5rem 1rem !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn {\n background: #0c0c0c;\n border: 1px solid #3d3d3d;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:hover {\n outline: 1px dashed #5eb0c3 !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:focus {\n outline: none !important;\n}\n\n._item_1avxi_1._user_1avxi_23 {\n opacity: .7;\n}\n\n._item_1avxi_1._user_1avxi_23, ._item_1avxi_1 div._name_1avxi_60 ._subText_1avxi_67 {\n color: #e7e7e7bd;\n}\n" + }, + { + "version": "0.0.1", + "slug": "nocturnal", + "name": "Nocturnal", + "creator": "aeris", + "description": "For when default revolt just isn't enough, a theme for those who live a night.", + "variables": { + "accent": "#2275C9", + "background": "#1E2731", + "foreground": "#F6F6F6", + "block": "#1E2731", + "message-box": "#1E2731", + "mention": "#0F3827", + "success": "#35E680", + "warning": "#0F3827", + "error": "#ED4245", + "hover": "#1D242B", + "scrollbar-thumb": "#124A81", + "scrollbar-track": "transparent", + "primary-background": "#1E2731", + "primary-header": "#12171D", + "secondary-background": "#12171D", + "secondary-foreground": "#2ECC71", + "secondary-header": "#12171D", + "tertiary-background": "#1E2731", + "tertiary-foreground": "#8E9297", + "status-online": "#43B581", + "status-away": "#FAA61A", + "status-busy": "#F04747", + "status-streaming": "#643DA7", + "status-invisible": "#747F8D" + }, + "tags": [ + "dark" + ] + }, + { + "version": "1.0.0", + "slug": "nord-dark", + "name": "Nord (Dark)", + "creator": "o69mar", + "description": "Nordic dark theme for revolt.chat", + "tags": [ + "dark", + "nord" + ], + "variables": { + "accent": "#81a1c1", + "background": "#434c5e", + "foreground": "#f6f6f6", + "block": "#2e3440", + "message-box": "#3b4252", + "mention": "#434c5e", + "success": "#a3be8c", + "warning": "#ebcb8b", + "error": "#bf616a", + "hover": "#4c556a", + "scrollbar-thumb": "#81a1c1", + "scrollbar-track": "transparent", + "primary-background": "#2e3440", + "primary-header": "#3b4252", + "secondary-background": "#3b4252", + "secondary-foreground": "#d8dee9", + "secondary-header": "#3b4252", + "tertiary-background": "#3b4252", + "tertiary-foreground": "#d8dee9", + "status-online": "#a3be8c", + "status-away": "#ebcb8b", + "status-busy": "#bf616a", + "status-streaming": "#81a1c1", + "status-invisible": "#a5a5a5" + } + }, + { + "version": "1.0.0", + "slug": "nucleon", + "name": "Nucleon", + "creator": "Axorax", + "description": "Transparent background dark theme.", + "tags": [ + "dark", + "nucleon", + "background", + "transparent", + "glass", + "blur" + ], + "variables": { + "background": "#11111192", + "mention": "rgba(251, 255, 0, 0.1)", + "hover": "rgba(0, 0, 0, 0.3)", + "primary-background": "#11111192", + "secondary-background": "#11111192", + "secondary-foreground": "#c9c9c9" + }, + "css": "._11Ivv {\n background: url(\"https://autumn.revolt.chat/attachments/FQkCgdyHs4z9OXbdGfbF396uhPahc3seYRFMdEMZqp/axo-theme-bg.jpg\");\n background-repeat: no-repeat;\n background-size: cover;\n}\n\n.PreloaderBase-sc-1xcl5li-0.ddQdoB {\n background: #11111192;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.gILwrq > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n margin-top: 2rem;\n border-radius: 6px;\n}\n\n.JumpToBottom__Bar-sc-v9kqir-0.ggHcXn > div,\n.TypingIndicator__Base-sc-1jama4m-0.fUFJYB > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div {\n background: #11111192;\n width: calc(100% - 2rem);\n margin-left: 1rem;\n border-radius: 0 0 6px 6px;\n margin-top: -1rem;\n backdrop-filter: blur(2px);\n}\n\n.AutoComplete__Base-sc-dtvq9c-0.hkukmG > div button.active {\n background: #111111c9;\n border: 1px solid #3d3d3d;\n}\n\n.MessageBase__DetailBase-sc-1s9ehlg-3.kyoVXf time {\n color: #c9c9c9;\n}\n\ntextarea#message::placeholder {\n color: #ffffff8e;\n}\n\n.FilePreview__Container-sc-znkm6h-0.ewmXHm {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 1rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\n.ReplyBar__Base-sc-d2l7w7-0.jtuvHh {\n background: #11111192;\n width: calc(100% - 2rem);\n margin: 0 0 0.5rem 0;\n margin-left: 1rem;\n border-radius: 6px;\n backdrop-filter: blur(9px);\n}\n\npre.sc-bkzYnD.cjWexI {\n background: #11111192;\n backdrop-filter: blur(9px);\n}\n\n.Base-sc-1kllx7r-0.iIKZoW {\n border-top: thin solid #5eb0c3;\n}\n\n.Base-sc-yb16g4-0.iDeTmr {\n margin: 1rem;\n background: #11111192;\n border-radius: 6px;\n}\n\n.Shadow-sc-yb16g4-1.bSmCBu {\n display: none;\n}\n\n.MessageArea__Area-sc-1q4cka6-0.kpVPRw {\n margin: 5rem 1rem 1rem 1rem;\n background: #11111192;\n border-radius: 6px;\n}\n\n.MessageBox__Base-sc-jul4fa-0.jBEnry {\n background: #11111192;\n border-radius: 6px;\n margin: 0 1rem 1rem 1rem;\n}\n\n.SidebarBase__GenericSidebarBase-sc-1dma6vq-1.dUCGFk {\n background: #11111192;\n margin: 5rem 1rem 0 0;\n height: calc(100vh - 6rem);\n border-radius: 6px;\n padding: 0.5rem;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX, .Header-sc-ujh2d9-0.fEKFaQ, .Header-sc-ujh2d9-0.dzRjrU, .Header-sc-ujh2d9-0.gXyCoD {\n margin: 1rem;\n border-radius: 6px;\n width: calc(100vw - 22rem);\n backdrop-filter: blur(0px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.gXyCoD {\n width: calc(100vw - 7.4rem) !important; \n}\n\n.Header-sc-ujh2d9-0.fEKFaQ {\n width: calc(100vw - 6.4rem) !important;\n margin-left: -15.5rem !important;\n}\n\n._list_sd6wo_13.with-padding {\n background: #11111192;\n margin-right: 0.8rem;\n margin-top: 5rem;\n border-radius: 6px;\n height: calc(100vh - 6rem);\n}\n\n.Header-sc-ujh2d9-0.dzRjrU {\n width: calc(100vw - 7.4rem) !important;\n margin-left: 1rem !important;\n}\n\n.ItemContainer-sc-176t3v5-0.iRFMLa {\n overflow: hidden;\n}\n\n.ServerSidebar__ServerBase-sc-1lca5oe-0.JkpVA {\n background: #11111192;\n border-radius: 6px;\n margin: 1rem 0;\n height: calc(100vh - 2rem);\n}\n\n.ThemeBaseSelector__List-sc-udzwdl-0.fTtexK > div h4 {\n margin-top: .5rem;\n color: #c9c9c9;\n}\n\n._contentcontainer_t7t23_62 h4 {\n color: #c9c9c9 !important;\n}\n\npre.sc-bkzYnD.cjWexI code,\n.MemberList__ListCategory-sc-1bwlier-0.fWMCga,\n.MemberList__ListCategory-sc-1bwlier-0.jOPNWX,\n.SidebarBase-sc-1dma6vq-0.jyizw,\n.Channel__ChannelMain-sc-k5myot-0.DmIgS,\n.RevoltApp__Routes-sc-1613ew5-2.hpNYLK,\n.Channel__ChannelContent-sc-k5myot-1.hfkRtg,\n.RevoltApp__Routes-sc-1613ew5-2.jAWgqa,\n.Discover__Container-sc-6nwhb3-0.gAGipD,\n.Details-sc-3j94yd-0.jKeKvP summary {\n background: transparent !important;\n}\n\na.Base-sc-j9ukbi-0.eMqgHq,\na.Base-sc-j9ukbi-0.bQqZNf,\n.TipBase-sc-upm053-0.jzUIcb {\n background: #11111192;\n border: 1px solid #3d3d3d;\n}\n\niframe.Discover__Frame-sc-6nwhb3-1.kcZkuw {\n margin: 1rem;\n border-radius: 10px;\n}\n\ndiv#Menu,\n.MessageBase__MessageContent-sc-1s9ehlg-2.jkmmZm .sc-iBPTik.bnZuoo {\n backdrop-filter: blur(9px);\n background: #11111192;\n}\n\n._contentcontainer_t7t23_62::after {\n content: \"⠀\";\n}\n\n._embed_dxa3n_1._website_dxa3n_12 {\n backdrop-filter: blur(8px);\n background: #11111192;\n}\n\n.Header-sc-ujh2d9-0.fPzJRX svg {\n width: 22px;\n aspect-ratio: 1/1;\n}\n\n.SidebarBase-sc-1dma6vq-0.jyizw {\n background: transparent;\n backdrop-filter: blur(0);\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort {\n flex-direction: column !important;\n gap: .5rem !important;\n}\n\n.Search__SearchBase-sc-rl8cbc-0.fTrPfq .sort button {\n padding: .5rem 1rem !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn {\n background: #0c0c0c;\n border: 1px solid #3d3d3d;\n border-radius: 6px;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:hover {\n outline: 1px dashed #5eb0c3 !important;\n}\n\ninput.InputBox-sc-13s1218-0.bEeAEn:focus {\n outline: none !important;\n}\n\n._item_1avxi_1._user_1avxi_23 {\n opacity: .7;\n}\n\n._item_1avxi_1._user_1avxi_23, ._item_1avxi_1 div._name_1avxi_60 ._subText_1avxi_67 {\n color: #e7e7e7bd;\n}" + }, + { + "version": "0.0.1", + "slug": "one-dark", + "name": "One Dark", + "creator": "Odyssey346", + "description": "A theme based on Mahmoud Ali's Atom One Dark VSCode theme.", + "variables": { + "accent": "#4D78CC", + "background": "#21252B", + "foreground": "#D7DAE0", + "block": "#082730", + "message-box": "#21252B", + "mention": "rgba(251, 255, 0, 0.06)", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#CA525A", + "scrollbar-track": "transparent", + "primary-background": "#333842", + "primary-header": "#21252B", + "secondary-background": "#21252B", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#21252B", + "tertiary-background": "#21252B", + "tertiary-foreground": "#D7DAE0", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5", + "sidebar-sidebar-active": "#202225" + }, + "tags": [ + "dark" + ] + }, + { + "version": "0.0.1", + "slug": "pink", + "name": "Pink Theme", + "creator": "IanSkinner1982", + "description": "This is pink colored theme for Revolt.", + "variables": { + "accent": "#FD6671", + "background": "#ce5f5f", + "foreground": "#6c1414", + "block": "#414141", + "message-box": "#efc3c3", + "mention": "#00dbd8", + "success": "#65E572", + "warning": "#FAA352", + "error": "#ED4245", + "hover": "#ffadad", + "scrollbar-thumb": "#ca525a", + "scrollbar-track": "transparent", + "primary-background": "#fdcece", + "primary-header": "#ffa8a8", + "secondary-background": "#e99696", + "secondary-foreground": "#711414", + "secondary-header": "#fdb4b4", + "tertiary-background": "#ffffff", + "tertiary-foreground": "#813131", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#9161cc", + "status-invisible": "#A5A5A5" + }, + "tags": [ + "light" + ] + }, + { + "version": "1.0.0", + "slug": "pleasant-purple", + "name": "Pleasant Purple", + "creator": "Ridglef", + "description": "A pleasant looking purple theme", + "tags": [ + "purple", + "dark-purple", + "dark" + ], + "variables": { + "accent": "#1ABC9C", + "background": "#464664", + "foreground": "#ffffff", + "block": "#303030", + "message-box": "#363636", + "mention": "#7d5f9b", + "success": "#65E572", + "warning": "#FAA352", + "tooltip": "#000000", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#15967d", + "scrollbar-track": "transparent", + "primary-background": "#5a4674", + "primary-header": "#363636", + "secondary-background": "#59456e", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#2D2D2D", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#dedede", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-focus": "#4799F0", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "@font-face {\n font-family: 'Comfortaa';\n src: url('https://fonts.gstatic.com/s/comfortaa/v40/1Pt_g8LJRfWJmhDAuUsSQamb1W0lwk4S4WjMDrMfIA.woff2') format('woff2');\n}\n\np {\n font-family: 'Comfortaa', sans-serif;\n}\n" + }, + { + "version": "1.0.0", + "slug": "primer-dark", + "name": "Primer Dark", + "tags": [ + "dark" + ], + "creator": "bree", + "description": "Dark theme using primer color primatives.", + "variables": { + "accent": "#58a6ff", + "background": "#0d1117", + "foreground": "#c9d1d9", + "block": "#0d1117", + "message-box": "#0d1117", + "mention": "#db61a2", + "success": "#3fb950", + "warning": "#d29922", + "error": "#f85149", + "hover": "rgba(110,118,129,0.1)", + "border-color": "#30363d", + "scrollbar-thumb": "#f85149", + "scrollbar-track": "transparent", + "primary-background": "#0d1117", + "primary-foreground": "#c9d1d9", + "primary-header": "#161b22", + "secondary-background": "#161b22", + "secondary-foreground": "#8b949e", + "secondary-header": "#161b22", + "tertiary-background": "#161b22", + "tertiary-foreground": "#8b949e", + "status-online": "#3fb950", + "status-away": "#d29922", + "status-busy": "#f85149", + "status-streaming": "#a371f7", + "status-invisible": "#6e7681" + }, + "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "primer-dark-colorblind", + "name": "Primer Dark colorblind", + "tags": [ + "colorblind", + "dark" + ], + "creator": "bree", + "description": "Dark colorblind theme using primer color primatives.", + "variables": { + "accent": "#58a6ff", + "background": "#0d1117", + "foreground": "#c9d1d9", + "block": "#0d1117", + "message-box": "#0d1117", + "mention": "#db61a2", + "success": "#42a0ff", + "warning": "#d29922", + "error": "#c38000", + "hover": "rgba(110,118,129,0.1)", + "border-color": "#30363d", + "scrollbar-thumb": "#c38000", + "scrollbar-track": "transparent", + "primary-background": "#0d1117", + "primary-foreground": "#c9d1d9", + "primary-header": "#161b22", + "secondary-background": "#161b22", + "secondary-foreground": "#8b949e", + "secondary-header": "#161b22", + "tertiary-background": "#161b22", + "tertiary-foreground": "#8b949e", + "status-online": "#42a0ff", + "status-away": "#d29922", + "status-busy": "#c38000", + "status-streaming": "#a371f7", + "status-invisible": "#6e7681" + }, + "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "primer-dark-dimmed", + "name": "Primer Dark dimmed", + "tags": [ + "dark" + ], + "creator": "bree", + "description": "Dark dimmed theme using primer color primatives.", + "variables": { + "accent": "#539bf5", + "background": "#22272e", + "foreground": "#adbac7", + "block": "#22272e", + "message-box": "#22272e", + "mention": "#c96198", + "success": "#57ab5a", + "warning": "#c69026", + "error": "#e5534b", + "hover": "rgba(99,110,123,0.1)", + "border-color": "#444c56", + "scrollbar-thumb": "#e5534b", + "scrollbar-track": "transparent", + "primary-background": "#22272e", + "primary-foreground": "#adbac7", + "primary-header": "#2d333b", + "secondary-background": "#2d333b", + "secondary-foreground": "#768390", + "secondary-header": "#2d333b", + "tertiary-background": "#2d333b", + "tertiary-foreground": "#768390", + "status-online": "#57ab5a", + "status-away": "#c69026", + "status-busy": "#e5534b", + "status-streaming": "#986ee2", + "status-invisible": "#636e7b" + }, + "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "primer-dark-high-contrast", + "name": "Primer Dark high contrast", + "tags": [ + "high-contrast", + "dark" + ], + "creator": "bree", + "description": "Dark high contrast theme using primer color primatives.", + "variables": { + "accent": "#71b7ff", + "background": "#0a0c10", + "foreground": "#f0f3f6", + "block": "#0a0c10", + "message-box": "#0a0c10", + "mention": "#ef6eb1", + "success": "#26cd4d", + "warning": "#f0b72f", + "error": "#ff6a69", + "hover": "rgba(158,167,179,0.1)", + "border-color": "#7a828e", + "scrollbar-thumb": "#ff6a69", + "scrollbar-track": "transparent", + "primary-background": "#0a0c10", + "primary-foreground": "#f0f3f6", + "primary-header": "#272b33", + "secondary-background": "#272b33", + "secondary-foreground": "#f0f3f6", + "secondary-header": "#272b33", + "tertiary-background": "#272b33", + "tertiary-foreground": "#f0f3f6", + "status-online": "#26cd4d", + "status-away": "#f0b72f", + "status-busy": "#ff6a69", + "status-streaming": "#b780ff", + "status-invisible": "#9ea7b3" + }, + "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "primer-light", + "name": "Primer Light", + "tags": [ + "light" + ], + "creator": "bree", + "description": "Light theme using primer color primatives.", + "variables": { + "accent": "#0969da", + "background": "#ffffff", + "foreground": "#24292f", + "block": "#24292f", + "message-box": "#ffffff", + "mention": "#bf3989", + "success": "#1a7f37", + "warning": "#9a6700", + "error": "#cf222e", + "hover": "rgba(175,184,193,0.2)", + "border-color": "#d0d7de", + "scrollbar-thumb": "#fa4549", + "scrollbar-track": "transparent", + "primary-background": "#ffffff", + "primary-foreground": "#24292f", + "primary-header": "#f6f8fa", + "secondary-background": "#f6f8fa", + "secondary-foreground": "#57606a", + "secondary-header": "#f6f8fa", + "tertiary-background": "#f6f8fa", + "tertiary-foreground": "#57606a", + "status-online": "#1a7f37", + "status-away": "#9a6700", + "status-busy": "#cf222e", + "status-streaming": "#8250df", + "status-invisible": "#6e7781" + }, + "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "primer-light-colorblind", + "name": "Primer Light colorblind", + "tags": [ + "colorblind", + "light" + ], + "creator": "bree", + "description": "Light colorblind theme using primer color primatives.", + "variables": { + "accent": "#0969da", + "background": "#ffffff", + "foreground": "#24292f", + "block": "#24292f", + "message-box": "#ffffff", + "mention": "#bf3989", + "success": "#0566d5", + "warning": "#9a6700", + "error": "#ac5e00", + "hover": "rgba(175,184,193,0.2)", + "border-color": "#d0d7de", + "scrollbar-thumb": "#d08002", + "scrollbar-track": "transparent", + "primary-background": "#ffffff", + "primary-foreground": "#24292f", + "primary-header": "#f6f8fa", + "secondary-background": "#f6f8fa", + "secondary-foreground": "#57606a", + "secondary-header": "#f6f8fa", + "tertiary-background": "#f6f8fa", + "tertiary-foreground": "#57606a", + "status-online": "#0566d5", + "status-away": "#9a6700", + "status-busy": "#ac5e00", + "status-streaming": "#8250df", + "status-invisible": "#6e7781" + }, + "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" + }, + { + "version": "1.0.0", + "slug": "primer-light-high-contrast", + "name": "Primer Light high contrast", + "tags": [ + "high-contrast", + "light" + ], + "creator": "bree", + "description": "Light high contrast theme using primer color primatives.", + "variables": { + "accent": "#0349b4", + "background": "#ffffff", + "foreground": "#0E1116", + "block": "#0E1116", + "message-box": "#ffffff", + "mention": "#971368", + "success": "#055d20", + "warning": "#744500", + "error": "#a0111f", + "hover": "rgba(172,182,192,0.2)", + "border-color": "#20252C", + "scrollbar-thumb": "#d5232c", + "scrollbar-track": "transparent", + "primary-background": "#ffffff", + "primary-foreground": "#0E1116", + "primary-header": "#ffffff", + "secondary-background": "#ffffff", + "secondary-foreground": "#0E1116", + "secondary-header": "#ffffff", + "tertiary-background": "#ffffff", + "tertiary-foreground": "#0E1116", + "status-online": "#055d20", + "status-away": "#744500", + "status-busy": "#a0111f", + "status-streaming": "#622cbc", + "status-invisible": "#66707B" + }, + "css": "/* This works but it may cause issues that I haven't seen yet. */\n* {\n border-color: var(--border-color) !important;\n}\n" + }, + { + "version": "v1.0.0", + "slug": "purpel", + "name": "Purpel", + "creator": "Lea", + "description": "Sleek purple theme", + "tags": [ + "purple", + "dark" + ], + "variables": { + "accent": "#b18fe9", + "background": "#150d1f", + "foreground": "#d3b5d9", + "block": "var(--secondary-background)", + "message-box": "var(--secondary-background)", + "mention": "rgba(251, 255, 0, 0.06)", + "success": "#a6d189", + "warning": "#ef9f76", + "error": "#e78284", + "hover": "#b18fe920", + "scrollbar-thumb": "#b261bd", + "scrollbar-track": "transparent", + "primary-background": "#150d1f", + "primary-header": "var(--secondary-background)", + "secondary-background": "#21182c", + "secondary-foreground": "#b88fbf", + "secondary-header": "var(--secondary-background)", + "tertiary-background": "#96619f", + "tertiary-foreground": "#a57bad", + "status-online": "#a6d189", + "status-away": "#eebebe", + "status-busy": "#ea999c", + "status-streaming": "#8260e0", + "status-invisible": "#88748b" + }, + "css": "@import url(\"https://cdn.githubraw.com/sussycatgirl/d45b96f7af1632c9268e16bfee0e8fea/raw/7892d51888d3ee341868ddd4b23f040a06be8c5d/rounded.css\");\n\n[class^=\"UserShort__BotBadge\"] {\n background-color: var(--tertiary-background);\n}\n" + }, + { + "version": "1.0.0", + "slug": "rexo-turquoise", + "name": "Rexo's Turquoise Theme", + "creator": "Rexogamer", + "description": "A dark turquoise theme for Revolt.", + "tags": [ + "turquoise", + "seagreen", + "dark" + ], + "variables": { + "accent": "#00b7c3", + "background": "#05191f", + "foreground": "#F6F6F6", + "block": "#082730", + "message-box": "#0c3b46", + "mention": "rgba(251, 255, 90, 0.06)", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#00929c", + "scrollbar-track": "transparent", + "primary-background": "#041e25", + "primary-header": "#082d35", + "secondary-background": "#06252d", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#0d363f", + "tertiary-background": "#082c35", + "tertiary-foreground": "#848484", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5", + "sidebar-sidebar-active": "#202225" + } + }, + { + "version": "0.0.1", + "slug": "rosepine", + "name": "Rosé Pine", + "creator": "ThatOneCalculator", + "description": "soho vibes for revolt. https://rosepinetheme.com/", + "variables": { + "accent": "#eb6f92", + "background": "#191724", + "foreground": "#e0def4", + "block": "#26233a", + "message-box": "#26233a", + "mention": "#f6c17720", + "success": "#9ccfd8", + "warning": "#f6c177", + "error": "#ea9a97", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#eb6f92", + "scrollbar-track": "transparent", + "primary-background": "#1f1d2e", + "primary-header": "#26233a", + "secondary-background": "#26233a", + "secondary-foreground": "#e0def4", + "secondary-header": "#555169", + "tertiary-background": "#6e6a86", + "tertiary-foreground": "#6e6a86", + "status-online": "#9ccfd8", + "status-away": "#f6c177", + "status-busy": "#eb6f92", + "status-streaming": "#c4a7e7", + "status-invisible": "#c4a7e7" + }, + "tags": [ + "dark" + ] + }, + { + "version": "0.0.1", + "slug": "rosepine-dawn", + "name": "Rosé Pine Dawn", + "creator": "ThatOneCalculator", + "description": "soho vibes for revolt, dawn edition. https://rosepinetheme.com/", + "variables": { + "accent": "#b4637a", + "background": "#faf4ed", + "foreground": "#575279", + "block": "#f2e9de", + "message-box": "#f2e9de", + "mention": "#ea9d3420", + "success": "#56949f", + "warning": "#ea9d34", + "error": "#d7827e", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#b4637a", + "scrollbar-track": "transparent", + "primary-background": "#fffaf3", + "primary-header": "#f2e9de", + "secondary-background": "#f2e9de", + "secondary-foreground": "#6e6a86", + "secondary-header": "#9893a5", + "tertiary-background": "#6e6a86", + "tertiary-foreground": "#6e6a86", + "status-online": "#56949f", + "status-away": "#ea9d34", + "status-busy": "#b4637a", + "status-streaming": "#907aa9", + "status-invisible": "#907aa9" + }, + "tags": [ + "light" + ] + }, + { + "version": "0.0.1", + "slug": "rosepine-moon", + "name": "Rosé Pine Moon", + "creator": "ThatOneCalculator", + "description": "soho vibes for revolt, moon edition. https://rosepinetheme.com/", + "variables": { + "accent": "#eb6f92", + "background": "#232136", + "foreground": "#e0def4", + "block": "#393552", + "message-box": "#393552", + "mention": "#f6c17720", + "success": "#9ccfd8", + "warning": "#f6c177", + "error": "#ea9a97", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#eb6f92", + "scrollbar-track": "transparent", + "primary-background": "#1f1d2e", + "primary-header": "#26233a", + "secondary-background": "#26233a", + "secondary-foreground": "#e0def4", + "secondary-header": "#59546d", + "tertiary-background": "#6e6a86", + "tertiary-foreground": "#817c9c", + "status-online": "#9ccfd8", + "status-away": "#f6c177", + "status-busy": "#eb6f92", + "status-streaming": "#c4a7e7", + "status-invisible": "#c4a7e7" + }, + "tags": [ + "dark" + ] + }, + { + "version": "1.2.0", + "slug": "skeuovolt", + "name": "Skeuovolt", + "creator": "Septicity", + "commit": "9d83d7c", + "description": "A semi-skeuomorphic theme, based on/sourced from Marda33's Skeuocord", + "tags": [ + "skeuomorphism", + "skeuomorphic", + "gradient", + "dark", + "skeuocord", + "grey", + "black" + ], + "variables": { + "accent": "#FD6671", + "background": "#191919", + "foreground": "#F6F6F6", + "block": "#2D2D2D", + "message-box": "#c1c1e0", + "mention": "rgba(251, 255, 0, 0.2)", + "success": "#65E572", + "warning": "#DB7F29", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#A8A8A8", + "scrollbar-track": "#212121", + "primary-background": "#242424", + "primary-header": "#363636", + "secondary-background": "#050505", + "secondary-foreground": "#C8C8C8", + "secondary-header": "#323232", + "tertiary-background": "#5E5E5E", + "tertiary-foreground": "#848484", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "/* Avatar and Server Borders */\n\n:root {\n --border-radius-user-icon: 50%;\n --border-radius-server-icon: 50%;\n \n --border-radius-user-icon-active: 50%;\n --border-radius-server-icon-active: 6px;\n \n /* To disable this, replace \"--accent\" with \"--tertiary-background\" */\n --border-active-color: var(--accent);\n}\n\n:root{--border-radius-channel-icon:var(--border-radius-user-icon);--message-box-padding:10px;--scrollbar-thickness:6px;--primary-header-gradient:linear-gradient(to top,var(--background),var(--tertiary-background) 110%);--secondary-header-gradient:linear-gradient(to top,var(--secondary-background)10%,var(--secondary-header));--textbox-gradient:linear-gradient(to top,var(--message-box-accent),var(--secondary-background) 120%);--button-gradient:linear-gradient(transparent,#000A);--sidebar-gradient:linear-gradient(to top,var(--secondary-background)20%,var(--tertiary-background));--chat-gradient:linear-gradient(to top,var(--background),var(--message-box-accent));--shadow-color:#0009;--message-box-accent:rgba(var(--message-box-rgb),0.17);--common-border:1px solid var(--tertiary-background);--common-shadow:inset 0 0 2px 2px var(--shadow-color);--light-shadow:inset 0 0 3px 0 var(--shadow-color);--thin-shadow:inset 0 0 0 1px var(--shadow-color)}::-webkit-scrollbar-thumb{min-height:100px;color:var(--background);border-radius:3px;outline:var(--common-border)}::-webkit-scrollbar-thumb:vertical{background:linear-gradient(to top,var(--background) -60%,var(--scrollbar-thumb),var(--background) 160%)}::-webkit-scrollbar-thumb:horizontal{background:linear-gradient(to left,var(--background) -60%,var(--scrollbar-thumb),var(--background) 160%)}[data-scroll-offset]::-webkit-scrollbar-thumb{outline:var(--common-border)}::-webkit-scrollbar-track{box-shadow:var(--light-shadow)}::-webkit-scrollbar-corner{background:linear-gradient(to bottom right,var(--scrollbar-thumb),var(--background));outline:var(--common-border)}.Header-sc-ujh2d9-0{border-bottom:var(--common-border);border-right:var(--common-border);box-shadow:var(--light-shadow),5px 2px 5px 5px rgba(0,0,0,0.4);background:var(--primary-header-gradient);border-radius:0;text-shadow:1px 2px 2px #000}.Channel__ChannelContent-sc-k5myot-1{background-image:var(--chat-gradient)}.SidebarBase-sc-1dma6vq-0 .SidebarBase__GenericSidebarBase-sc-1dma6vq-1{background-image:var(--textbox-gradient);overflow:auto}.Channel__ChannelMain-sc-k5myot-0.DmIgS .SidebarBase-sc-1dma6vq-0{border-right:var(--common-border);border-left:var(--common-border)}.MemberList__ListCategory-sc-1bwlier-0.fWMCga{height:39px;padding-top:16px}.UserShort__Name-sc-1sbe9n1-1{text-shadow:none;filter:drop-shadow(1px 2px 1px black)}.HomeSidebar__Navbar-sc-1n8qxkj-0{border-bottom:var(--common-border);box-shadow:inset 0 0 0 1px rgba(6,6,6,0.5),0 0 5px 5px rgba(0,0,0,0.4);background:var(--secondary-header-gradient);border-right:3px double var(--tertiary-background);text-shadow:1px 2px 2px #000}.Base-sc-yb16g4-0,.Shadow-sc-yb16g4-1 + .ItemContainer-sc-176t3v5-0{border-right:3px double var(--tertiary-background)}.SidebarBase__GenericSidebarList-sc-1dma6vq-2{border-radius:0;background:var(--textbox-gradient);border-right:3px double var(--tertiary-background);overflow:auto}.ServerSidebar__ServerBase-sc-1lca5oe-0,.Sidebar__Base-sc-1uh6eub-0{background:var(--sidebar-gradient);border-right:3px double var(--tertiary-background);border-radius:0}.ServerSidebar__ServerList-sc-1lca5oe-1{box-shadow:var(--common-shadow);border-top:3px double var(--tertiary-background);overflow:auto}.ServerHeader__ServerBanner-sc-15ursl9-0{box-shadow:var(--common-shadow)}.Shadow-sc-yb16g4-1 + .ItemContainer-sc-176t3v5-0{height:55.88px;padding-bottom:5.6px;padding-top:5.6px}._settings_t7t23_33 ._sidebar_t7t23_39{background:linear-gradient(to top left,transparent 15%,var(--message-box-accent));background-color:var(--secondary-background);border:var(--common-border)}._content_t7t23_40{background:linear-gradient(to bottom right,transparent 15%,var(--message-box-accent));background-color:var(--secondary-background);border-top:var(--common-border);border-right:var(--common-border);border-bottom:var(--common-border)}._user_fwdgs_1 ._preview_fwdgs_107{background:transparent;width:90%}._settings_t7t23_33 ._action_t7t23_224 ._closeButton_t7t23_240{background:var(--secondary-header)}.MessageBox__Base-sc-jul4fa-0,.Shadow-sc-yb16g4-1 + .ItemContainer-sc-176t3v5-0,.FilePreview__Container-sc-znkm6h-0{border-top:var(--common-border);background-image:var(--button-gradient);background-color:var(--secondary-header);box-shadow:inset 0 1px 0 0 var(--shadow-color)}.MessageBox__FileAction-sc-jul4fa-3,.MessageBox__Action-sc-jul4fa-2{margin:3.7px 0 0}.TextAreaAutoSize__Container-sc-1ebfmuf-0,.ckpHTH{border:var(--common-border);background:var(--textbox-gradient);border-radius:var(--border-radius);box-shadow:var(--common-shadow);padding:0 10px;margin:6px 0 7px}.InputBox-sc-13s1218-0{border:var(--common-border);background:var(--textbox-gradient);border-radius:var(--border-radius);box-shadow:inset 0 0 3px 2px var(--shadow-color)}.InputBox-sc-13s1218-0:hover{background:var(--textbox-gradient)}.TextArea-sc-1kh63xd-0{background:transparent;padding:10px 0}.Column-sc-mmjmg6-0.Base-sc-1c6g74-0{border:var(--common-border);margin:0 10px 0 0}.MessageEditor__EditorBase-sc-133d9pc-0 .TextAreaAutoSize__Container-sc-1ebfmuf-0,._settings_t7t23_33 .TextAreaAutoSize__Container-sc-1ebfmuf-0{padding:0}.MessageEditor__EditorBase-sc-133d9pc-0 .TextArea-sc-1kh63xd-0,._settings_t7t23_33 .TextArea-sc-1kh63xd-0{background:transparent;padding:8px}.ReplyBar__Base-sc-d2l7w7-0,.TypingIndicator__Base-sc-1jama4m-0{background:var(--secondary-header-gradient);border-top:var(--common-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);padding:0}.jtuvHh .actions{margin-right:8px}.TypingIndicator__Base-sc-1jama4m-0 > div,.ggHcXn > div,.hitXXs > div{background-image:var(--button-gradient);background-color:var(--primary-background);border-top:var(--common-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);width:100%}.gILwrq > div,.NPPEn > div{background-image:var(--button-gradient);border-bottom:var(--common-border);border-bottom-left-radius:var(--border-radius);border-bottom-right-radius:var(--border-radius)}.ReplyBar__Base-sc-d2l7w7-0 .MessageReply__ReplyBase-sc-utzx7-0{height:100%;margin:0 8px}.MessageReply__ReplyBase-sc-utzx7-0 .content{align-self:center;height:100%;padding:0}.MessageReply__ReplyBase-sc-utzx7-0 .message{border:var(--common-border);border-right:var(--common-border);border-radius:3px;background:rgba(var(--foreground-rgb),0.075);font-size:.8em;padding:3px}.cZEhIO,.fhGxEk,.jRMJXP,.sc-dIUeWJ.kizbFb{border:1px solid var(--mention);border-radius:var(--border-radius);background:linear-gradient(var(--mention),transparent 130%)}.cZEhIO:hover,.fhGxEk:hover,.jRMJXP:hover,.sc-dIUeWJ.kizbFb:hover{border:1px solid var(--mention);border-radius:var(--border-radius);background:linear-gradient(var(--mention) -100%,transparent 130%)}.VoiceHeader__VoiceBase-sc-mvbohh-0{background:var(--secondary-header-gradient);border-bottom:var(--common-border);border-bottom-left-radius:var(--border-radius)}.Base-sc-j9ukbi-0,.TipBase-sc-upm053-0,.VoiceHeader__VoiceBase-sc-mvbohh-0 .status,.ComboBox-sc-f94r78-0,.jfwTbF,.bCPWex ._entry_fwdgs_300,._title_dxa3n_54,.jMjzeh,.jbwUqj,._friend_sd6wo_33,.EmbedInvite__EmbedInviteBase-sc-154n8c6-0,._user_fwdgs_1 .Button-sc-wdolxy-0,.Button-sc-wdolxy-0.bQkbYV,.Button-sc-wdolxy-0,.preact-context-menu .context-menu,._entry_kup3j_140,.TipBase-sc-upm053-0,._languages_fwdgs_383 ._list_fwdgs_383 ._entry_fwdgs_300,.Button-sc-wdolxy-0{background-image:var(--button-gradient)!important;border:var(--common-border);border-radius:15px;padding:8px;box-shadow:var(--thin-shadow);text-shadow:1px 1px 2px #000}.Base-sc-j9ukbi-0:hover{background-color:var(--background)}.sc-jSgvzq{background-image:var(--button-gradient);background-color:var(--secondary-header);box-shadow:var(--thin-shadow)}.sc-jSgvzq.cTcKPP{border:var(--common-border)}._item_1avxi_1,._item_1avxi_1:hover,._item_1avxi_1[data-active=\"true\"]{border-radius:var(--border-radius);background-image:linear-gradient(var(--foreground) -100%,transparent 50%);background-size:1px 208%;transition:250ms}._item_1avxi_1{background-position:50% 99%;border-left:1px solid transparent;border-right:1px solid transparent;text-shadow:1px 1px 2px #000}._item_1avxi_1:hover{background-position:50% 40%;border-left:var(--common-border);border-right:var(--common-border)}._item_1avxi_1[data-active=\"true\"]{background-position:top;border-left:var(--common-border);border-right:var(--common-border);box-shadow:var(--light-shadow)}.BottomNavigation__Base-sc-15obpw5-0{border-top:3px double var(--tertiary-background)}.BottomNavigation__Button-sc-15obpw5-2{border-radius:4px;box-shadow:var(--light-shadow);background:var(--button-gradient)}.BottomNavigation__Button-sc-15obpw5-2.brHoXv{border-left:var(--common-border);border-right:var(--common-border);background-color:#666}.BottomNavigation__Button-sc-15obpw5-2.bkKKgS{border-left:1px solid #1117;border-right:1px solid #1117;background-color:var(--primary-header)}.BottomNavigation__Button-sc-15obpw5-2:hover:active{background-color:#222}.Column-sc-mmjmg6-0.Controls-sc-1c6g74-1{border-bottom:var(--common-border);box-shadow:inset 0 0 2px 1px var(--shadow-color);background:var(--primary-header-gradient)}.Base-sc-1m0owfd-0{border-top:var(--common-border);box-shadow:inset 0 0 2px 1px var(--shadow-color);background:var(--textbox-gradient)}.Groups-sc-1c6g74-3{background:var(--secondary-header-gradient);border-radius:0;border-left:var(--common-border);overflow-x:hidden}.CategoryBar-sc-1c6g74-6,.MemberList__ListCategory-sc-1bwlier-0{border-top:var(--common-border);border-bottom:var(--common-border);background:var(--button-gradient)}._header_kup3j_8,.Settings__AccountHeader-sc-1gunfj7-0 .account{border-left:1px solid var(--foreground);border-top:1px solid var(--foreground);border-right:1px solid var(--foreground);border-bottom:var(--common-border);box-shadow:inset 0 0 15px 2px var(--shadow-color);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius)}._content_kup3j_90{border-left:1px solid var(--foreground);border-bottom:1px solid var(--foreground);border-right:1px solid var(--foreground);box-shadow:inset 0 0 15px 2px var(--shadow-color)}.Settings__AccountHeader-sc-1gunfj7-0 .statusChanger{border-left:1px solid var(--foreground);border-right:1px solid var(--foreground);border-bottom:1px solid var(--foreground);border-bottom-left-radius:var(--border-radius);border-bottom-right-radius:var(--border-radius);background:var(--textbox-gradient)}.Container-sc-1d91xkm-1,.create_group{border:var(--common-border);background:var(--background);background-image:var(--chat-gradient)}.Container-sc-1d91xkm-1.bKfOaB{background:none;border:none}.sc-jrAFXE.eiGSNL{border-radius:var(--border-radius);border:var(--common-border)}.Title-sc-1d91xkm-2{border-bottom:var(--common-border);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);box-shadow:var(--thin-shadow);background:var(--primary-header-gradient)}.Content-sc-1d91xkm-3{padding:1rem;background:var(--background)}.Actions-sc-1d91xkm-4{background:var(--secondary-header-gradient);border-top:var(--common-border)}.Container-sc-1d91xkm-1[type=\"user_profile\"],.jKeKvP summary,.Content-sc-1d91xkm-3{background:transparent;border:unset}.Content-sc-1d91xkm-3[type=\"user_picker\"]{padding:0;background:var(--background)}.sc-kstqJO.iDLnDV{padding:6px}.bCPWex{padding:4px 10px}.preact-context-menu .context-menu > span:not([data-disabled=true]){border-left:1px solid transparent;border-right:1px solid transparent}.preact-context-menu .context-menu > span:not([data-disabled=true]):hover{background:#0003;border-left:var(--common-border);border-right:var(--common-border)}.preact-context-menu .context-menu > span > span:hover{background:none}._attachment_197qa_1._text_197qa_33,._container_197qa_64{padding:0;border:var(--common-border);box-shadow:0 0 4px 1px #000}._attachment_197qa_1._text_197qa_33 ._textContent_197qa_39{border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius);box-shadow:var(--common-shadow)}._attachment_197qa_1._audio_197qa_13{background-image:var(--button-gradient);background-color:var(--primary-header);border:var(--common-border);border-radius:15px;padding:8px;box-shadow:var(--thin-shadow),0 0 4px 1px #000}._embed_dxa3n_1._website_dxa3n_12{border-top:var(--common-border);border-bottom:var(--common-border);border-right:var(--common-border);box-shadow:var(--thin-shadow),0 0 4px 1px #000}._embed_dxa3n_1._website_dxa3n_12 iframe{border:var(--common-border);box-shadow:0 0 4px 1px #000}.sc-bkzYnD{box-shadow:var(--common-shadow),0 0 4px 1px #000;border:var(--common-border);overflow:auto}._actions_iu4oa_1,._embed_dxa3n_1._website_dxa3n_12{background-image:var(--button-gradient);background-color:var(--primary-header);text-shadow:1px 1px 2px #000}._attachment_197qa_1._text_197qa_33 ._actions_iu4oa_1,._actions_iu4oa_1._imageAction_iu4oa_1{border-top:var(--common-border);border-bottom-left-radius:var(--border-radius);border-bottom-right-radius:var(--border-radius);box-shadow:var(--thin-shadow)}._container_197qa_64 ._actions_iu4oa_1{border-bottom:var(--common-border);box-shadow:var(--thin-shadow);border-top-left-radius:var(--border-radius);border-top-right-radius:var(--border-radius)}._attachment_197qa_1._audio_197qa_13 ._actions_iu4oa_1{border:none;background:transparent}._attachment_197qa_1._file_197qa_25 ._actions_iu4oa_1{border:var(--common-border);box-shadow:var(--thin-shadow)}.UserIcon__VoiceIndicator-sc-y109su-0{background-image:var(--button-gradient);box-shadow:inset 0 0 0 .6px #000C}a:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0,.ItemContainer-sc-176t3v5-0:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0,.SidebarBase__GenericSidebarList-sc-1dma6vq-2 ._item_1avxi_1[data-active=\"true\"] img{border-color:var(--border-active-color)}._item_1avxi_1:hover img,._item_1avxi_1[data-active=\"true\"] img,.IconBase-sc-igncj4-0.bTjVNf.avatar img:hover{border-radius:var(--border-radius-user-icon-active);transition:100ms;transition-delay:70ms}.sc-iBPTik,.sc-iBPTik:hover{border:var(--common-border);background:linear-gradient(to bottom,var(--background) 10%,var(--secondary-header));box-shadow:var(--thin-shadow);box-shadow:0 0 5px 0 var(--shadow-color)}.sc-fubCzh:hover,.preact-context-menu .context-menu span:hover{background:#0004}.FallbackBase-sc-3bwws8-1,._friend_sd6wo_33 .IconButton-sc-166lqkp-0,.EmbedInvite__EmbedInviteBase-sc-154n8c6-0 .IconBase__ImageIconBase-sc-igncj4-1,._settings_t7t23_33 ._action_t7t23_224 ._closeButton_t7t23_240,._image_1m2vo_5,._content_kup3j_90 img:not(.emoji){border:1px solid var(--tertiary-foreground);padding:1px;border-radius:50%;box-shadow:var(--thin-shadow);background-image:var(--button-gradient);background-color:var(--secondary-header)}.SidebarBase__GenericSidebarList-sc-1dma6vq-2 ._item_1avxi_1 img,._profile_kup3j_21 img,.bTjVNf img,.Image-sc-3bwws8-0{border:1px solid var(--tertiary-foreground);padding:1px;box-shadow:var(--thin-shadow);background-image:var(--button-gradient);background-color:var(--secondary-header);transition:100ms}.kPeClm{border-radius:var(--border-radius-server-icon)}.SwooshWrapper-sc-176t3v5-1{height:54px;width:56px;border-top:var(--common-border);border-bottom:var(--common-border);border-left:var(--common-border);background-image:var(--button-gradient);background-color:var(--primary-header);box-shadow:var(--thin-shadow);border-top-left-radius:25%;border-bottom-left-radius:25%;margin:26px 0 0;transition:position 150ms;filter:drop-shadow(2px 2px 2px #000B)}.FilePreview__EmptyEntry-sc-znkm6h-5.icon,.FilePreview__PreviewBox-sc-znkm6h-6 .icon{border-radius:var(--border-radius)}.FilePreview__EmptyEntry-sc-znkm6h-5{border:var(--common-border);border-radius:50%;box-shadow:var(--thin-shadow);background:var(--button-gradient)}.LineDivider-sc-135498f-0{background:linear-gradient(to left,transparent,rgba(var(--foreground-rgb),0.7),transparent)}.sc-pGacB{background:linear-gradient(transparent,rgba(var(--foreground-rgb),0.7),transparent)}.Base-sc-1kllx7r-0{padding:10px}.StyledIconBase-ea9ulj-0 path,.StyledIconBase-ea9ulj-0 circle{fill:var(--foreground)}._friend_sd6wo_33{margin:3px}.Base-sc-hcp9jm-0{cursor:pointer}._sessions_fwdgs_285 ._entry_fwdgs_300,._sessions_fwdgs_285 ._entry_fwdgs_300[data-active=true]{background-image:linear-gradient(transparent,#0008);border:var(--common-border);box-shadow:var(--thin-shadow)}.checkmark{border:2px solid var(--foreground);box-shadow:inset 0 0 0 2px var(--shadow-color);background:var(--background)}.tippy-box{background-image:var(--button-gradient);background-color:var(--primary-header);opacity:.95;border:var(--common-border);box-shadow:var(--thin-shadow),0 0 4px 1px #000;left:-6px}.checkmark.gfBZnu{background-image:linear-gradient(transparent -20%,var(--accent))}:active > svg,:active > ._avatar_1avxi_56,._item_1avxi_1:active,._content_kup3j_90 a:active img{transform:translateY(2px);filter:brightness(0.85) drop-shadow(2px 2px 2px #000B)}._item_1avxi_1 ._avatar_1avxi_56,._item_1avxi_1 svg{transform:none}.hwmymO .actions{margin-right:7px}.hwmymO .actions div{border:var(--common-border);border-radius:3px;background-image:var(--button-gradient);background-color:var(--primary-header);margin:10px 3px 10px 0;height:calc(var(--titlebar-height) - 8px)}.hwmymO .actions div:hover,.hwmymO .actions div.error:hover{background-image:var(--button-gradient)}.RevoltApp__AppContainer-sc-1613ew5-0{background:var(--textbox-gradient);border:var(--common-border)}::-webkit-scrollbar-track:vertical,::-webkit-scrollbar-track:horizontal{outline:1px solid rgba(var(--tertiary-background-rgb),0.7)}.SidebarBase-sc-1dma6vq-0,.sc-hBEYId .Base-sc-1c2l8gq-0,.FilePreview__PreviewBox-sc-znkm6h-6,.RevoltApp__Routes-sc-1613ew5-2{background:transparent}.Base-sc-yb16g4-0 .list,.FilePreview__Carousel-sc-znkm6h-1{overflow-x:hidden}.EmbedInvite__EmbedInviteBase-sc-154n8c6-0,._entry_kup3j_140{background-color:var(--primary-header)}.eiGSNL img,.Titlebar__TitlebarBase-sc-1ykozpe-0.hwmymO{border-bottom:var(--common-border);background:var(--secondary-header-gradient)}.sc-bkzYnD code,.iIKZoW time{background:unset}.Image-sc-3bwws8-0,.FallbackBase-sc-3bwws8-1,._item_1avxi_1 div{transition:100ms}.Image-sc-3bwws8-0:hover,.FallbackBase-sc-3bwws8-1:hover,a:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0,.ItemContainer-sc-176t3v5-0:has(.SwooshWrapper-sc-176t3v5-1) .Image-sc-3bwws8-0{border-radius:var(--border-radius-server-icon-active);transition:100ms;transition-delay:70ms}.SwooshWrapper-sc-176t3v5-1 svg,.tippy-arrow{display:none}svg,._content_kup3j_90 a img,._avatar_1avxi_56{filter:drop-shadow(2px 2px 2px #000B);transition:100ms}svg foreignObject svg,:active > svg foreignObject svg,._session_fwdgs_285 svg,.sc-dIUeWJ.kizbFb svg{filter:none;transform:none}\n" + }, + { + "version": "v1.0.1", + "slug": "social-classic", + "name": "Social Classic", + "creator": "DragShot", + "description": "A mixture of light, dark and a primary color of your preference, inspired by social websites from the 2010s.", + "tags": [ + "social", + "classic", + "light", + "2010" + ], + "variables": { + "accent": "#3B5998", + "background": "#F6F6F6", + "foreground": "#101010", + "block": "#414141", + "message-box": "#E2E6F0", + "mention": "#FFF5CE", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.2)", + "scrollbar-thumb": "#2F477A", + "scrollbar-track": "transparent", + "primary-background": "#FFFFFF", + "primary-header": "#3B5998", + "secondary-background": "#F1F1F1", + "secondary-foreground": "#1f1f1f", + "secondary-header": "#F1F1F1", + "tertiary-background": "#4D4D4D", + "tertiary-foreground": "#3A3A3A", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + }, + "css": "/* Constants */\r\n\r\n:root{\r\n /* --background: #F6F6F6;\r\n --foreground: #101010; */\r\n --primary-header: #F1F1F1;\r\n --serverbar-background: #303440;\r\n --serverbar-foreground: #F7F7F7;\r\n --serverbar-border: #1C202B;\r\n --serverbar-highlight: #505460;\r\n --scrollbar-color: rgba(0, 0, 0, 0.25);\r\n --accent-10: rgba(var(--accent-rgb), 0.10);\r\n --accent-15: rgba(var(--accent-rgb), 0.15);\r\n --accent-25: rgba(var(--accent-rgb), 0.25);\r\n --accent-50: rgba(var(--accent-rgb), 0.50);\r\n --accent-60: rgba(var(--accent-rgb), 0.60);\r\n --accent-75: rgba(var(--accent-rgb), 0.75);\r\n --form-border: #CCC;\r\n --button-success-border: #3B6E22;\r\n --button-success-background: #67A54B;\r\n --button-success-foreground: #F7F7F7;\r\n --white-pixel: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=);\r\n --error-darker: #CD2225;\r\n --mention: #FFF5CE;\r\n --mention-darker: #F6ECC2;\r\n --border-radius-user-icon: 6px;\r\n}\r\n\r\n/* Server Sidebar */\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] {\r\n background: var(--serverbar-background);\r\n border: solid 1px var(--serverbar-border);\r\n width: 58px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] [class^=\"ItemContainer-sc\"] div[class^=\"LineDivider-sc\"] {\r\n background: var(--serverbar-border);\r\n border-top: solid 1px #282B38;\r\n border-bottom: solid 1px #393D48;\r\n margin: 5px auto;\r\n width: 100%;\r\n height: 3px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] > [class^=\"Shadow-sc\"] > div {\r\n background: linear-gradient(to bottom, transparent, var(--serverbar-background));\r\n margin-top: -24px;\r\n height: 24px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] [class^=\"ItemContainer-sc\"] [class^=\"FallbackBase-sc\"] {\r\n background: var(--serverbar-border);\r\n color: var(--serverbar-foreground);\r\n /*padding-right: 8px;*/\r\n}\r\n\r\ndiv[class^=\"SidebarBase-sc\"] > [class^=\"Base-sc\"] [class^=\"ItemContainer-sc\"] [class^=\"FallbackBase-sc\"] > svg > * {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[class^=\"SwooshWrapper-sc\"] {\r\n --sidebar-active: var(--serverbar-highlight);\r\n top: -8px;\r\n overflow-y: hidden;\r\n /* z-index: 0; */\r\n}\r\n\r\n[class^=\"SwooshWrapper-sc-\"] > svg {\r\n margin-top: -24px;\r\n}\r\n\r\n[class^=\"SwooshWrapper-sc-\"] > svg > path:not(:first-child), \r\n[class^=\"SwooshWrapper-sc-\"] > svg > rect {\r\n display: none;\r\n}\r\n\r\n[class^=\"SwooshWrapper-sc-\"] > svg > path:first-child {\r\n fill: var(--accent);\r\n /* Fix uneven bubble around the server icon - by lo-kiss@github.com */\r\n transform: translateX(.8px) scale(0.95);\r\n transform-origin: center;\r\n}\r\n\r\ndiv[class^=\"ItemContainer-sc\"] > div:nth-child(1),\r\ndiv[class^=\"ItemContainer-sc\"] > a > div:nth-child(1) {\r\n position: relative;\r\n z-index: 0;\r\n}\r\n\r\ndiv[class^=\"ItemContainer-sc\"] > div:nth-child(2),\r\ndiv[class^=\"ItemContainer-sc\"] > a > div:nth-child(2) {\r\n position: relative;\r\n z-index: 1;\r\n}\r\n\r\n/* Tooltips */\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box {\r\n background: var(--serverbar-border);\r\n color: var(--serverbar-foreground);\r\n border-radius: 6px;\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box * {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box > .tippy-content {\r\n background: var(--serverbar-border);\r\n color: var(--serverbar-foreground);\r\n border-radius: 6px;\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='top'] > .tippy-arrow::before {\r\n border-top-color: var(--serverbar-border);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='bottom'] > .tippy-arrow::before {\r\n border-bottom-color: var(--serverbar-border);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='left'] > .tippy-arrow::before {\r\n border-left-color: var(--serverbar-border);\r\n}\r\n\r\ndiv[id^=\"tippy\"] > .tippy-box[data-placement^='right'] > .tippy-arrow::before {\r\n border-right-color: var(--serverbar-border);\r\n}\r\n\r\n/* Server Header */\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase-sc\"] {\r\n border-radius: 0px;\r\n}\r\n\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"],\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > div > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"] {\r\n margin-top: calc(var(--header-height) + 24px);\r\n}\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"] > [class^=\"HomeSidebar__Navbar-sc\"],\r\n[class^=\"RevoltApp__AppContainer-sc\"] > div > div > div > [class^=\"SidebarBase-sc\"] > [class^=\"SidebarBase__GenericSidebarBase-sc\"] > [class^=\"HomeSidebar__Navbar-sc\"] {\r\n background-image: url(\"/assets/wide.svg\");\r\n margin-top: -40px;\r\n padding-top: 40px;\r\n height: 88px;\r\n background-position-x: 12px;\r\n background-repeat: no-repeat;\r\n background-size: 50%;\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase-sc\"] > [class^=\"ServerHeader__ServerBanner-sc\"] {\r\n background-color: var(--accent);\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"].dKFhRZ > .container {\r\n\tbackground: linear-gradient(0deg, var(--serverbar-background), transparent);\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"].jXEHHp > .container {\r\n\tmargin-top: 24px;\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"] > .container > a.title,\r\ndiv[class^=\"ServerSidebar__ServerBase\"] > [class^=\"ServerHeader__ServerBanner\"] > .container a[class^=\"IconButton-sc\"] {\r\n color: var(--serverbar-foreground);\r\n /* text-shadow: -1px 0px black, 0px 1px black,\r\n 1px 0px black, 0px -1px black; */\r\n}\r\n\r\ndiv[class^=\"ServerSidebar__ServerBase-sc\"] > div[class^=\"ServerSidebar__ServerList-sc\"] {\r\n border-top: solid 5px var(--accent-60);\r\n}\r\n\r\n/* Server Channels */\r\n\r\n[class^=\"ServerSidebar__ServerList\"] {\r\n padding-right: 0px;\r\n -ms-overflow-style: none; /* Internet Explorer 10+ */\r\n scrollbar-width: none; /* Firefox */\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"]::-webkit-scrollbar {\r\n display: none; /* Safari and Chrome */\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"],\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"] * {\r\n transition: none;\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"] > [class^=\"_name\"] {\r\n font-weight: normal;\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-alert=\"true\"]:not(:hover) > div {\r\n color: var(--accent);\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-alert=\"true\"] > [class^=\"_button\"] > div {\r\n background: var(--accent);\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-alert=\"true\"]:hover > [class^=\"_button\"] > div {\r\n background: var(--serverbar-foreground);\r\n}\r\n\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"]:hover,\r\n[class^=\"ServerSidebar__ServerList\"] a > div[class^=\"_item\"][data-active=\"true\"] {\r\n background: var(--accent-75);\r\n color: var(--serverbar-foreground);\r\n border-top-right-radius: 0px;\r\n border-bottom-right-radius: 0px;\r\n}\r\n\r\n/* Content Header */\r\n\r\ndiv[class^=\"RevoltApp__Routes-sc\"],\r\ndiv[class^=\"Header-sc\"] {\r\n border-radius: 0px;\r\n}\r\n\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"],\r\ndiv[class^=\"RevoltApp__Routes-sc\"] [class^=\"Home__Overlay-sc\"] [class^=\"Header-sc\"] {\r\n background: var(--accent);\r\n}\r\n\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"] > div,\r\ndiv[class^=\"RevoltApp__Routes-sc\"] [class^=\"Home__Overlay-sc\"] [class^=\"Header-sc\"],\r\ndiv[class^=\"RevoltApp__Routes-sc\"] [class^=\"Home__Overlay-sc\"] [class^=\"Header-sc\"] > div,\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"] > [class^=\"HeaderActions__Container\"] > a,\r\ndiv[class^=\"RevoltApp__Routes-sc\"] > [class^=\"Header-sc\"] a[class^=\"IconButton-sc\"],\r\ndiv[class^=\"ChannelHeader__Info\"] > .desc {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[class^=\"ChannelHeader__Info\"] > .desc a {\r\n color: var(--serverbar-foreground);\r\n text-decoration: underline;\r\n}\r\n\r\ndiv[class^=\"ChannelHeader__Info\"] > .divider {\r\n background: var(--serverbar-foreground);\r\n}\r\n\r\ndiv[class^=\"Home__Overlay-sc\"] > .content h3 > img {\r\n filter: brightness(0%);\r\n}\r\n\r\n/* Chat Area */\r\n\r\n[class^=\"MessageArea__Area-sc\"]/*,\r\n[class^=\"_home_\"] .content > [class^=\"_homeScreen_\"],\r\n[class^=\"RevoltApp__Routes\"] > div[data-scroll-offset=\"true\"] > [class^=\"_list_\"]*/ {\r\n border: solid 1px rgba(0, 0, 0, 0.5);\r\n padding-top: 0px;\r\n margin-top: var(--header-height);\r\n}\r\n\r\n[class^=\"MessageBase-sc\"]:hover {\r\n background: var(--accent-10);\r\n}\r\n\r\n[class^=\"TypingIndicator__Base\"] {\r\n left: 1px;\r\n}\r\n\r\n[class^=\"MessageBox__Blocked-sc\"] > [class^=\"MessageBox__Action-sc\"],\r\n[class^=\"MessageBox__Blocked-sc\"] > .text {\r\n margin-left: 12px;\r\n}\r\n\r\n[class^=\"MessageArea__Area-sc\"] div[class^=\"Base-sc\"] { /* Day/NEW message separators */\r\n height: auto;\r\n margin: 8px 0px -4px 2px;\r\n border-top: solid 1px var(--accent);\r\n background: var(--accent-15);\r\n padding: 4px 4px 6px 4px;\r\n}\r\n\r\n[class^=\"MessageArea__Area-sc\"] div[class^=\"Base-sc\"] > time {\r\n margin: 0px;\r\n background: unset;\r\n color: var(--accent);\r\n display: block;\r\n}\r\n\r\n[class^=\"MessageArea__Area-sc\"] div[class^=\"Base-sc\"] > [class^=\"Unread-sc\"] {\r\n padding: 4px 6px;\r\n border-radius: 0px;\r\n margin: -5px 4px -6px -4px;\r\n}\r\n\r\n.cYBcvy {\r\n --emoji-size: 3.428em; /* 48px */\r\n}\r\n\r\n.fDfxAc { /* Reactions */\r\n background: var(--accent-10);\r\n}\r\n\r\n.fDfxAc:hover {\r\n filter: none;\r\n background: var(--accent-25)\r\n}\r\n\r\n.cZEhIO:not(:hover), .fhGxEk:not(:hover) { /* Mentions */\r\n /* border-bottom: solid 1px var(--mention-darker);*/\r\n box-shadow: inset 0px -1px 0px 0px var(--mention-darker);\r\n}\r\n\r\n/* Embeds */\r\n\r\n[class^=\"AutoComplete__Base-sc\"] > div {\r\n border: solid 1px rgba(0, 0, 0, 0.5);\r\n border-bottom: none;\r\n}\r\n\r\n[class^=\"ReplyBar__Base-sc\"],\r\n[class^=\"MessageBox__Base-sc\"] {\r\n border-left: solid 1px rgba(0, 0, 0, 0.5);\r\n border-right: solid 1px rgba(0, 0, 0, 0.5);\r\n}\r\n\r\n[class^=\"ReplyBar__Base-sc\"] {\r\n background: var(--accent-15);\r\n}\r\n\r\n[class^=\"MessageBox__Base-sc\"] {\r\n background: linear-gradient(to right, var(--accent-15), var(--accent-15)), var(--white-pixel);\r\n}\r\n\r\n/* Unread notification */\r\n\r\n[class^=\"JumpToBottom__Bar-sc\"] > div,\r\n[class^=\"JumpToBottom__Bar-sc\"] > div:hover {\r\n background: linear-gradient(to right, var(--accent-15), var(--accent-15)), var(--white-pixel);\r\n border: solid 1px var(--accent);\r\n color: var(--accent);\r\n}\r\n\r\n/* Right bar */\r\n\r\ndiv[class^=\"SidebarBase-sc\"] {\r\n background: var(--accent);\r\n padding-right: 0px;\r\n}\r\n\r\ndiv[class^=\"SidebarBase__GenericSidebarBase-sc\"] {\r\n border-top: solid 5px var(--accent-25);\r\n padding-top: 0px;\r\n margin-top: var(--header-height);\r\n margin-right: 0px;\r\n}\r\n\r\n/* BottonNav */\r\n\r\ndiv[class^=\"BottomNavigation__Base-sc\"] {\r\n /*background: var(--background);*/\r\n background: linear-gradient(to right, var(--accent-25), var(--accent-25)), var(--white-pixel);\r\n border-top: solid 2px var(--accent);\r\n}\r\n\r\n/*div[class^=\"BottomNavigation__Base-sc\"] > div[class^=\"BottomNavigation__Navbar-sc\"] {\r\n background: var(--accent-25);\r\n}*/\r\n\r\n/*div[class^=\"BottomNavigation__Base-sc\"] div[class^=\"BottomNavigation__Button-sc\"] > div[class^=\"IconButton-sc\"] {*/\r\n.bkKKgS * {\r\n color: var(--accent) !important;\r\n}\r\n\r\n.bkKKgS > a > a > svg {\r\n text-shadow: 0px 0px 2px var(--serverbar-foreground);\r\n}\r\n\r\n.brHoXv {\r\n background: var(--accent);\r\n}\r\n\r\n.brHoXv * {\r\n color: var(--serverbar-foreground) !important;\r\n}\r\n\r\n/* Floating action bar */\r\n\r\n[class^=\"MessageBase__MessageContent-sc\"] > .sc-iBPTik {\r\n background: var(--accent);\r\n border: solid 1px var(--scrollbar-thumb);\r\n}\r\n\r\n[class^=\"MessageBase__MessageContent-sc\"] > .sc-iBPTik .sc-fubCzh:hover {\r\n background: var(--scrollbar-thumb);\r\n}\r\n\r\n[class^=\"MessageBase__MessageContent-sc\"] > .sc-iBPTik div {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\n/* Context menu */\r\n\r\n.preact-context-menu > .context-menu > span:not([data-disabled=\"true\"]):hover,\r\n.preact-context-menu > .context-menu > span:not([data-disabled=\"true\"]):hover > .tip {\r\n background-color: var(--accent);\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\n.preact-context-menu > .context-menu > span:not([data-disabled=\"true\"]):hover > span[style^=\"color:var(--error)\"] {\r\n color: var(--serverbar-foreground) !important;\r\n background: var(--error);\r\n width: 100%;\r\n margin: -6px -8px;\r\n padding: 6px 8px;\r\n border-radius: calc(var(--border-radius) / 2);\r\n box-sizing: content-box;\r\n}\r\n\r\n/* Users/DMs sidebar */\r\n\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[data-test-id=\"virtuoso-item-list\"] div[class^=\"_item_\"]:hover,\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"]:hover,\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] {\r\n background: var(--accent-15);\r\n}\r\n\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[data-test-id=\"virtuoso-item-list\"] div[class^=\"_item_\"]:hover > div[class^=\"_name_\"] [class^=\"UserShort__Name-sc\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"]:hover > div[class^=\"_content_\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] > div[class^=\"_content_\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"]:hover > div[class^=\"_name_\"] [class^=\"UserShort__Name-sc\"],\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] > div[class^=\"_name_\"] [class^=\"UserShort__Name-sc\"] {\r\n color: var(--accent);\r\n}\r\n\r\n[class^=\"SidebarBase__GenericSidebarBase-sc\"] div[class^=\"SidebarBase__GenericSidebarList\"] div[class^=\"_item_\"][data-active=\"true\"] {\r\n border-bottom: solid 1px var(--accent);\r\n}\r\n\r\n/* User Profile */\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] {\r\n background: var(--accent);\r\n padding-bottom: 0px !important;\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] * {\r\n color: var(--serverbar-foreground);\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_profile_\"] + div:not([class]) {\r\n background-color: rgba(0, 0, 0, 0.5) !important;\r\n margin-top: -5px;\r\n margin-bottom: 15px;\r\n /*box-sizing: border-box;\r\n display: block;*/\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_tabs_\"] > div,\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_tabs_\"] > div:hover {\r\n border-bottom: 0px solid transparent;\r\n text-align: center;\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_header_\"] > [class^=\"_tabs_\"] > div[data-active=\"true\"] {\r\n background: linear-gradient(to right, var(--accent-25), var(--accent-25)), var(--white-pixel);\r\n color: var(--accent);\r\n border: 1px solid var(--accent);\r\n border-bottom: 0px solid transparent;\r\n border-top-left-radius: 6px;\r\n border-top-right-radius: 6px;\r\n}\r\n\r\n[class^=\"Base-sc\"] > [class^=\"Container-sc\"] [class^=\"_content_\"] [class^=\"_entry_\"]:hover {\r\n background: var(--accent-15);\r\n color: var(--accent);\r\n}\r\n\r\n/* Settings */\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] {\r\n background: var(--accent);\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"_sidebar_\"] [class^=\"_container_\"] {\r\n background: var(--accent-15);\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Header-sc\"],\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Header-sc\"] > a {\r\n background: var(--accent);\r\n color: white;\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Settings__AccountHeader-sc\"] {\r\n border: solid 1px rgba(0, 0, 0, 0.25);\r\n}\r\n\r\n[class^=\"_settings_\"][data-mobile=\"true\"] [class^=\"Settings__AccountHeader-sc\"] > .statusChanger {\r\n --secondary-foreground: var(--accent);\r\n color: var(--accent);\r\n background: white;\r\n border-top: solid 1px rgba(0, 0, 0, 0.25);\r\n}\r\n\r\n[class^=\"_settings_\"]:not([data-mobile=\"true\"]) [class^=\"_sidebar_\"] * {\r\n color: white;\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"],\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"] * {\r\n transition: none;\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"]:hover,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][data-active=\"true\"] {\r\n background: linear-gradient(to right, var(--accent-25), var(--accent-25)), var(--white-pixel);\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"]:hover *,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][data-active=\"true\"] * {\r\n color: var(--accent);\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_donate_\"]:hover,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_donate_\"]:hover * {\r\n background: #daa520;\r\n color: white;\r\n}\r\n\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_logOut_\"]:hover,\r\n[class^=\"_settings_\"] [class^=\"_sidebar_\"] div[class^=\"_item_\"][class*=\"_logOut_\"]:hover * {\r\n background: var(--error);\r\n color: white;\r\n}\r\n\r\n/* Text fields */\r\n\r\ndiv[class^=\"MessageBox__Base-sc\"] div[class^=\"TextAreaAutoSize__Container-sc\"] > textarea {\r\n background: white;\r\n border: solid 1px var(--accent);\r\n border-radius: 4px;\r\n /*;padding: 11px 7px 11px 7px;\r\n margin: 3px 7px 3px 0px;*/\r\n padding: 12px 7px 12px 7px;\r\n margin: 0px 7px 0px 0px;\r\n max-height: 120px;\r\n}\r\n\r\ndiv[class^=\"MessageBox__Base-sc\"] div[class^=\"TextAreaAutoSize__Container-sc\"] > textarea:not(:focus):placeholder-shown {\r\n border: solid 1px var(--form-border);\r\n /*border-bottom: solid 2px var(--accent);*/\r\n padding: 7px 7px 7px 7px;\r\n margin: 5px 7px 5px 0px;\r\n height: 38px !important;\r\n}\r\n\r\ninput[class^=\"InputBox-sc\"],\r\nselect[class^=\"ComboBox-sc\"] {\r\n background: white;\r\n border: solid 1px var(--form-border);\r\n border-radius: 4px;\r\n}\r\n\r\ndiv[class^=\"MessageBox__Base-sc\"] div[class^=\"TextAreaAutoSize__Container-sc\"] > textarea:not(:focus):placeholder-shown,\r\ninput[class^=\"InputBox-sc\"]:focus,\r\nselect[class^=\"ComboBox-sc\"]:focus {\r\n border-bottom: solid 1px var(--accent);\r\n -webkit-box-shadow: inset 0px -1px 0px 0px var(--accent);\r\n -moz-box-shadow: inset 0px -1px 0px 0px var(--accent);\r\n box-shadow: inset 0px -1px 0px 0px var(--accent);\r\n}\r\n\r\ninput[class^=\"InputBox-sc\"]:hover,\r\nselect[class^=\"ComboBox-sc\"]:hover {\r\n background: white;\r\n}\r\n\r\n/* Buttons */\r\n\r\nbutton[class^=\"Button-sc\"]:not(.jMjzeh) { /* Default */\r\n border: solid 1px var(--form-border);\r\n box-shadow: inset 0px 1px 0px 0px hsla(0deg, 0%, 100%, 0.25);\r\n padding: 1px 11px 1px 11px !important;\r\n border-radius: 4px;\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].uZBqX { /* Primary */\r\n border-color: var(--scrollbar-thumb);\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].claKep {\r\n --foreground: white;\r\n background: var(--accent);\r\n border-color: var(--scrollbar-thumb);\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].claKep:hover {\r\n filter: brightness(1.2);\r\n}\r\n\r\nbutton[class^=\"Button-sc\"].hWskOV { /* Danger */\r\n border-color: var(--error-darker);\r\n}\r\n\r\n/* Invite links */\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] {\r\n background: var(--accent-15);\r\n border: solid 1px var(--accent-25);\r\n}\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"EmbedInvite__EmbedInviteName-sc\"] {\r\n color: var(--scrollbar-thumb);\r\n}\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"EmbedInvite__EmbedInviteDetails-sc\"] {}\r\n\r\n@media(min-width: 1024px) {\r\n /*[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"Button-sc\"].jbwUqj*/\r\n [class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"EmbedInvite__EmbedInviteDetails-sc\"] {\r\n /*margin-left: 6px;*/\r\n padding-right: 6px;\r\n }\r\n}\r\n\r\n[class^=\"EmbedInvite__EmbedInviteBase\"] [class^=\"Button-sc\"].jbwUqj { /* Invite */\r\n border-color: var(--button-success-border);\r\n background: var(--button-success-background);\r\n color: var(--button-success-foreground);\r\n padding-top: 0px !important;\r\n z-index: auto;\r\n}\r\n\r\n/* Scrollbar */\r\n\r\n::-webkit-scrollbar {\r\n width: var(--scrollbar-thickness);\r\n height: var(--scrollbar-thickness);\r\n}\r\n\r\n::-webkit-scrollbar-track {\r\n background: var(--scrollbar-track);\r\n}\r\n\r\n::-webkit-scrollbar-thumb {\r\n min-width: 30px;\r\n min-height: 30px;\r\n background-clip: content-box;\r\n background: var(--scrollbar-color);\r\n border-radius: 15px;\r\n}\r\n\r\nhtml,\r\nbody {\r\n scrollbar-color: var(--scrollbar-color) var(--scrollbar-track);\r\n}\r\n\r\n* {\r\n scrollbar-width: var(--scrollbar-thickness-ff);\r\n}" + }, + { + "version": "0.0.1", + "slug": "striking-stardust", + "name": "Striking Stardust", + "creator": "giantspacemonster", + "description": "a vibrant purple theme", + "tags": [ + "purple", + "dark" + ], + "variables": { + "accent": "#00b46c", + "background": "#160a33", + "foreground": "#9dffec", + "block": "#2e1948", + "message-box": "#461e59", + "mention": "11393c", + "success": "#65E572", + "warning": "#FAA352", + "error": "#F06464", + "hover": "rgba(0, 0, 0, 0.1)", + "scrollbar-thumb": "#00b46c", + "scrollbar-track": "transparent", + "primary-background": "#241440", + "primary-header": "#461e59", + "secondary-background": "#2e1948", + "secondary-foreground": "#51dff4", + "secondary-header": "#3c1c55", + "tertiary-background": "#3c1c55", + "tertiary-foreground": "#44bcfa", + "status-online": "#3ABF7E", + "status-away": "#F39F00", + "status-busy": "#F84848", + "status-streaming": "#977EFF", + "status-invisible": "#A5A5A5" + } + }, + { + "version": "0.0.1", + "slug": "tokyo-night", + "name": "Tokyo Night", + "creator": "hecker", + "description": "A Clean Revolt Theme.", + "variables": { + "accent": "#4651c2", + "background": "#12131b", + "foreground": "#F6F6F6", + "block": "#171722", + "message-box": "#171722", + "mention": "#4b443b", + "success": "#57F287", + "warning": "#FEE75C", + "error": "#ED4245", + "hover": "#181822", + "scrollbar-thumb": "#4651c2", + "scrollbar-track": "transparent", + "primary-background": "#1b1a26", + "primary-header": "#17161f", + "secondary-background": "#171722", + "secondary-foreground": "#6e728e", + "secondary-header": "#171722", + "tertiary-background": "#171722", + "tertiary-foreground": "#848484", + "status-online": "#57F287", + "status-away": "#FEE75C", + "status-busy": "#ED4245", + "status-streaming": "#7100fd", + "status-invisible": "#747f8d" + }, + "tags": [ + "dark" + ] + }, + { + "version": "1.0.0", + "slug": "twitch-theme", + "name": "Twitch Theme", + "creator": "NoLogicAlan", + "description": "Theme inspired by Twitch's dark theme.", + "tags": [ + "Twitch", + "dark", + "purple", + "streaming" + ], + "variables": { + "accent": "#9146FF", + "background": "#0F0F13", + "foreground": "#FFFFFF", + "block": "rgba(255, 255, 255, 0.1)", + "message-box": "rgba(255, 255, 255, 0.05)", + "mention": "rgba(236, 89, 221, 0.3)", + "success": "#2BE79C", + "warning": "#E79C2B", + "tooltip": "#9146FF", + "error": "#EC6859", + "hover": "#191919", + "scrollbar-thumb": "rgba(145, 70, 255, 0.6)", + "scrollbar-track": "transparent", + "primary-background": "#191919", + "primary-header": "#9146FF", + "secondary-background": "#0F0F13", + "secondary-foreground": "#FFFFFF", + "secondary-header": "#9146FF", + "tertiary-background": "#383838", + "tertiary-foreground": "#FFFFFF", + "status-online": "#4BBD4B", + "status-away": "#FFAF30", + "status-focus": "#9146FF", + "status-busy": "#FF453A", + "status-streaming": "#9146FF", + "status-invisible": "#747f8d", + "status-border-color": "#1F1F24", + "status-font": "Roboto", + "status-monospaceFont": "Fira Code" + } + }, + { + "version": "1.0.0", + "slug": "vintage-terminal", + "name": "Vintage Terminal", + "creator": "Axorax", + "description": "Vintage looking terminal theme for Revolt.", + "tags": [ + "dark", + "terminal", + "vintage" + ], + "variables": { + "accent": "#ffff33", + "background": "#000000", + "foreground": "#00ff00", + "block": "#333333", + "message-box": "#000000", + "mention": "#121212", + "success": "\t#00ff00", + "warning": "#ffff33", + "error": "#FF0000", + "hover": "#00ff001A", + "scrollbar-thumb": "#ffffff", + "scrollbar-track": "transparent", + "primary-background": "#000000", + "primary-header": "#333333", + "secondary-background": "#000000", + "secondary-foreground": "#00ff00", + "secondary-header": "#333333", + "tertiary-background": "#000000", + "tertiary-foreground": "#ffffff", + "status-online": "#00ff00", + "status-away": "#ffff33", + "status-busy": "#FF0000", + "status-streaming": "#A020F0", + "status-invisible": "#80848E" + } + } + ], + "popularTags": [ + "dark", + "light", + "purple", + "pastel", + "soothing", + "blue", + "orange", + "blur", + "discord", + "grey" + ] +} \ No newline at end of file diff --git a/test/data/servers/member.json b/tests/data/servers/member.json similarity index 100% rename from test/data/servers/member.json rename to tests/data/servers/member.json diff --git a/test/data/servers/server.json b/tests/data/servers/server.json similarity index 100% rename from test/data/servers/server.json rename to tests/data/servers/server.json diff --git a/test/data/servers/server_with_channels.json b/tests/data/servers/server_with_channels.json similarity index 100% rename from test/data/servers/server_with_channels.json rename to tests/data/servers/server_with_channels.json diff --git a/tests/data/users/me.json b/tests/data/users/me.json new file mode 100644 index 0000000..3027646 --- /dev/null +++ b/tests/data/users/me.json @@ -0,0 +1,11 @@ +{ + "_id": "01H1QAGNCAP1VHW0CYXBZ5P176", + "username": "vlf", + "discriminator": "3510", + "status": { + "text": "doing python", + "presence": "Online" + }, + "relationship": "None", + "online": true +} \ No newline at end of file diff --git a/tests/data/users/mecha.json b/tests/data/users/mecha.json new file mode 100644 index 0000000..8cb2a05 --- /dev/null +++ b/tests/data/users/mecha.json @@ -0,0 +1,26 @@ +{ + "_id": "01FZB4GBHDVYY6KT8JH4RBX4KR", + "username": "Mecha", + "discriminator": "9862", + "avatar": { + "_id": "u4A-x_8pjkqjHnBBPo3bfT0o7Hwkr_45Ly8qS7LtPe", + "tag": "avatars", + "filename": "1C53F195-E56E-43E0-B12A-4F0F72F6A004.png", + "metadata": { + "type": "Image", + "width": 500, + "height": 500 + }, + "content_type": "image/png", + "size": 326253 + }, + "status": { + "text": "Watching 11096 users!", + "presence": "Online" + }, + "bot": { + "owner": "01FZB2QAPRVT8PVMF11480GRCD" + }, + "relationship": "None", + "online": false +} \ No newline at end of file diff --git a/test/data/users/user.json b/tests/data/users/user.json similarity index 100% rename from test/data/users/user.json rename to tests/data/users/user.json diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..18043a5 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,378 @@ +from aiohttp import ClientSession, web +import json +import pytest_asyncio +import pytest +import pyvolt + +with open('./tests/data/discovery/bots.json', 'r') as fp: + bots = json.load(fp) + +with open('./tests/data/discovery/servers.json', 'r') as fp: + servers = json.load(fp) + +with open('./tests/data/discovery/themes.json', 'r') as fp: + themes = json.load(fp) + + +def related_tags_for(entities: list) -> list[str]: + related_tags = [] + for entity in entities: + for tag in entity['tags']: + if tag not in related_tags: + related_tags.append(tag) + return related_tags + + +routes = web.RouteTableDef() + + +@routes.get('/discover/bots.json') +async def discover_bots(request: web.Request) -> web.Response: + return web.json_response( + { + 'pageProps': bots, + '__N_SSP': True, + } + ) + + +@routes.get('/discover/servers.json') +async def discover_servers(request: web.Request) -> web.Response: + return web.json_response( + { + 'pageProps': servers, + '__N_SSP': True, + } + ) + + +@routes.get('/discover/themes.json') +async def discover_themes(request: web.Request) -> web.Response: + return web.json_response( + { + 'pageProps': themes, + '__N_SSP': True, + } + ) + + +async def run_discovery_site(port: int) -> web.TCPSite: + app = web.Application() + app.add_routes(routes) + + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, host='localhost', port=port) + + await site.start() + return site + + +@pytest.mark.asyncio +async def test_bots(): + site = await run_discovery_site(5101) + + state = pyvolt.State() + state.setup(parser=pyvolt.Parser(state)) + client = pyvolt.DiscoveryClient(base='http://127.0.0.1:5101/', session=ClientSession(), state=state) + + page = await client.bots() + assert len(page.bots) == 21 + assert page.popular_tags == [ + 'nsfw', + 'utility', + 'moderation', + 'revolt', + 'bridge', + 'mod', + 'logging', + 'multipurpose', + 'anime', + '18', + ] + + bot = page.bots[0] + assert bot.id == '01GA9PC742D72BM6CNDNXS3X7D' + assert bot.name == 'Reddit' + assert bot.profile.content + assert not bot.profile.background + assert bot.tags == [] + assert bot.server_count == 318 + assert bot.usage is pyvolt.BotUsage.high + + bot = page.bots[1] + assert bot.id == '01FHGJ3NPP7XANQQH8C2BE44ZY' + assert bot.name == 'AutoMod' + avatar = bot.avatar + assert avatar + assert avatar.id == 'pYjK-QyMv92hy8GUM-b4IK1DMzYILys9s114khzzKY' + assert avatar.filename == 'cut-automod-gay.png' + metadata = avatar.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 512 + assert metadata.height == 512 + assert avatar.content_type == 'image/png' + assert avatar.size == 29618 + + background = bot.profile.background + assert background + assert background.id == 'AoRvVsvP1Y8X_A4cEqJ_R7B29wDZI5lNrbiu0A5pJI' + assert background.filename == 'banner.png' + metadata = background.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 1366 + assert metadata.height == 768 + assert background.content_type == 'image/png' + assert background.size == 151802 + assert bot.tags == [] + assert bot.server_count == 3287 + assert bot.usage is pyvolt.BotUsage.high + + await site.stop() + + +@pytest.mark.asyncio +async def test_servers(): + site = await run_discovery_site(5102) + + state = pyvolt.State() + state.setup(parser=pyvolt.Parser(state)) + client = pyvolt.DiscoveryClient(base='http://127.0.0.1:5102/', session=ClientSession(), state=state) + + page = await client.servers() + + assert page.popular_tags == [ + 'gaming', + 'revolt', + 'programming', + 'fun', + 'chill', + 'art', + 'linux', + 'politics', + 'chat', + 'music', + ] + assert len(page.servers) == 105 + + server = page.servers[0] + assert server.id == '01HZJX93WB0W6QMFFC2E5VNG23' + assert server.name == 'peeps' + assert server.description == 'peeps is an active chatting server with memes and more' + icon = server.icon + assert icon + assert icon.id == 'JTnWjuOrDZw6-A0ey94dvfVtDIvydD7bisr_aU82v_' + assert icon.filename == 'GSKz91QUnRHWnO92E697pRpDl7BBkHnBCVvQVHTppK.jpeg' + metadata = icon.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 768 + assert metadata.height == 768 + assert icon.content_type == 'image/jpeg' + assert icon.size == 96701 + banner = server.banner + assert banner + assert banner.id == 'ZDeq632SkOHK26Arq7j4uX0V_bbyD10g8NWd8l1eH_' + assert banner.filename == 'il_fullxfull.2923896440_644n.jpg' + metadata = banner.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 2750 + assert metadata.height == 2125 + assert banner.content_type == 'image/jpeg' + assert banner.size == 453049 + assert server.flags.value == 0 + assert server.tags == [] + assert server.member_count == 71 + assert server.activity is pyvolt.ServerActivity.high + + server = page.servers[1] + assert server.id == '01F80118K1F2EYD9XAMCPQ0BCT' + assert server.name == 'Catgirl Dungeon' + # What is the hell, Catgirl Dungeon????? + # assert server.description + icon = server.icon + assert icon + assert icon.id == 'XIwQosw_3USL_XIvzZDyIKSi9LlMryrOPJNKsTrqts' + assert icon.filename == 'gaysex.png' + metadata = icon.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 500 + assert metadata.height == 500 + assert icon.content_type == 'image/png' + assert icon.size == 17439 + banner = server.banner + assert banner + assert banner.id == 'ZTPUKiZ6OP1Yqox2PNOBVx_q1U3u9hXBbn84tLJXzK' + assert banner.filename == '20230626_221308-2.jpg' + metadata = banner.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 5120 + assert metadata.height == 2880 + assert banner.content_type == 'image/jpeg' + assert banner.size == 1321952 + assert server.flags.value == 2 + assert server.tags == [] + assert server.member_count == 6804 + assert server.activity is pyvolt.ServerActivity.high + + server = page.servers[2] + assert server.id == '01HVKQBBQ3DQVVNK3M8DHXV30D' + assert server.name == 'Femboy Kingdom' + assert ( + server.description + == '! **18+ ONLY** !\n\na comfy server for femboys, but also home to girllikers, boykissers, silly gays of all kinds, and everyone else, too!!' + ) + icon = server.icon + assert icon + assert icon.id == '1tTV6RqTik3qntjw2tFXg-HfCW_Dtmp4cYBA385BzO' + assert icon.filename == 'femdom logo small.png' + metadata = icon.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 2500 + assert metadata.height == 2500 + assert icon.content_type == 'image/png' + assert icon.size == 2082228 + banner = server.banner + assert banner + assert banner.id == 'LoBAfzfxv-0bObWR4pYpByG2joAQ_o1LeqZByOrLSY' + assert banner.filename == 'Hyrule_Castlesmallimagesize.png' + metadata = banner.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 3840 + assert metadata.height == 2160 + assert banner.content_type == 'image/png' + assert banner.size == 7096268 + assert server.flags.value == 0 + assert server.tags == [] + assert server.member_count == 225 + assert server.activity is pyvolt.ServerActivity.high + + server = page.servers[3] + assert server.id == '01F7ZSBSFHQ8TA81725KQCSDDP' + assert server.name == 'Revolt' + assert ( + server.description + == 'Official server run by the team behind Revolt.\nGeneral conversation and support server.\n \nAppeals and reports: lounge@revolt.chat' + ) + icon = server.icon + assert icon + assert icon.id == 'gtc0gJE2S3RvuDhrl2-JeakvgbqEGr2acvBnRTTh6k' + assert icon.filename == 'logo_round.png' + metadata = icon.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 500 + assert metadata.height == 500 + assert icon.content_type == 'image/png' + assert icon.size == 7558 + banner = server.banner + assert banner + assert banner.id == 'G_Q-6Y8KiGFVBNY2qVtDS25bX9Dh14CK2Py3TmLN_P' + assert banner.filename == 'Lounge_Banner_Old.png' + metadata = banner.metadata + assert metadata.type is pyvolt.AssetMetadataType.image + assert metadata.width == 1920 + assert metadata.height == 1080 + assert banner.content_type == 'image/png' + assert banner.size == 138982 + assert server.flags.value == 1 + assert server.tags == ['revolt'] + assert server.member_count == 39737 + assert server.activity is pyvolt.ServerActivity.medium + + await site.stop() + + +@pytest.mark.asyncio +async def test_themes(): + site = await run_discovery_site(5103) + + state = pyvolt.State() + state.setup(parser=pyvolt.Parser(state)) + client = pyvolt.DiscoveryClient(base='http://127.0.0.1:5103/', session=ClientSession(), state=state) + + page = await client.themes() + assert page.popular_tags == [ + 'dark', + 'light', + 'purple', + 'pastel', + 'soothing', + 'blue', + 'orange', + 'blur', + 'discord', + 'grey', + ] + assert len(page.themes) == 61 + + theme = page.themes[0] + + assert theme.name == 'Amethyst' + assert theme.version == '1.0.1' + assert theme.slug == 'amethyst' + assert theme.creator == 'Meow' + assert theme.description == "Purple... based on spinfish's amethyst betterdiscord theme." + assert theme.tags == ['dark', 'purple'] + assert theme.overrides == { + 'accent': '#ff9100', + 'background': '#230431', + 'foreground': '#ffffff', + 'block': '#1c0028', + 'mention': '#310c44', + 'message-box': '#2d0f3d', + 'success': '#4dd75e', + 'warning': '#FAA352', + 'error': '#ed3f43', + 'hover': 'rgba(0, 0, 0, 0.2)', + 'tooltip': '#000000', + 'scrollbar-thumb': '#361741', + 'scrollbar-track': 'transparent', + 'primary-background': '#230431', + 'primary-header': '#150020', + 'secondary-background': '#1c0028', + 'secondary-foreground': '#d2d2d2', + 'secondary-header': '#1c0028', + 'tertiary-background': '#8b8b8b', + 'tertiary-foreground': '#8b8b8b', + 'status-online': '#3ba55d', + 'status-away': '#faa81a', + 'status-busy': '#ed4245', + 'status-invisible': '#747f8d', + } + assert not theme.custom_css + + theme = page.themes[1] + assert theme.name == 'AMOLED' + assert theme.version == '0.0.1' + assert theme.slug == 'amoled' + assert theme.creator == 'insert' + assert theme.description == 'Pure black, perfect for mobile.' + assert theme.tags == ['oled', 'dark'] + assert theme.overrides == { + 'accent': '#FD6671', + 'background': '#000000', + 'foreground': '#FFFFFF', + 'block': '#1D1D1D', + 'message-box': '#000000', + 'mention': 'rgba(251, 255, 0, 0.06)', + 'success': '#65E572', + 'warning': '#FAA352', + 'error': '#F06464', + 'hover': 'rgba(0, 0, 0, 0.1)', + 'scrollbar-thumb': '#CA525A', + 'scrollbar-track': 'transparent', + 'primary-background': '#000000', + 'primary-header': '#000000', + 'secondary-background': '#000000', + 'secondary-foreground': '#DDDDDD', + 'secondary-header': '#1A1A1A', + 'tertiary-background': '#000000', + 'tertiary-foreground': '#AAAAAA', + 'status-online': '#3ABF7E', + 'status-away': '#F39F00', + 'status-busy': '#F84848', + 'status-streaming': '#977EFF', + 'status-invisible': '#A5A5A5', + } + assert not theme.custom_css + + await site.stop()