Compare commits

..
Author SHA1 Message Date
stoat-release[bot] 58fb269450 chore(main): release 0.9.1 (#484)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-01-10 12:59:50 +00:00
Paul Makles 303e52b476 fix(ci): pipeline fixes (marked as fix to force release) (#483) 2026-01-10 12:59:20 +00:00
stoat-release[bot] 868894f500 chore(main): release 0.9.0 (#475)
Co-authored-by: stoat-release[bot] <245062572+stoat-release[bot]@users.noreply.github.com>
2026-01-10 12:51:09 +00:00
Paul Makles 67d49b461f ci: use our arc runner set for Rust builds (#482) 2026-01-10 12:44:26 +00:00
Paul Makles f89ee5a370 ci: send release notification on create (#481) 2026-01-09 21:05:42 +00:00
Angelo Kontaxis 2afea56e56 feat: add id field to role (#470) 2026-01-09 20:06:16 +00:00
Paul Makles a5387b64af ci: use dev-dependency for revolt-presence (#480) 2026-01-09 19:21:45 +00:00
Paul Makles 0b178fc791 fix: correct string_cache in lockfile (#479) 2026-01-09 19:10:01 +00:00
Paul Makles 5d27a91e90 fix: correct miniz_oxide in lockfile (#478) 2026-01-09 19:05:49 +00:00
Paul Makles e2016a2ab4 ci: add the dependency chain into release please (#477) 2026-01-08 23:07:33 +00:00
Paul Makles 924c7f5b18 ci: bootstrap sha (#476) 2026-01-08 22:39:44 +00:00
Paul Makles 22e46194ab ci: switch release please to simple mode (#474) 2026-01-08 22:36:04 +00:00
Paul Makles ffdb2b6900 ci: switch api server as main package for release please (#473) 2026-01-08 22:24:45 +00:00
Paul Makles a7b870c397 chore: configure release please (#472) 2026-01-08 22:16:50 +00:00
Simon Marty b48eb28fc2 docs: remove outdated notice about perf. regression (#467) 2026-01-07 21:12:24 +00:00
Angelo Kontaxis dc9c82aa4e fix: github webhook incorrect payload and formatting (#468) 2026-01-07 21:11:42 +00:00
Paul Makles 24fedf8c4d fix: respond with 201 if no body in requests (#465)
Signed-off-by: izzy <me@insrt.uk>
2025-11-16 15:24:39 +00:00
Paul Makles dda20f44ae ci: correct build to ./docs/build (#463) 2025-11-14 22:11:21 +00:00
Paul Makles 0b7c132ace docs: initialise new docs with docusaurus (#460)
* docs: initialise new docs with docusaurus

* chore: sign off
Signed-off-by: izzy <me@insrt.uk>

* chore: lock pnpm version in package.json

Signed-off-by: izzy <me@insrt.uk>

* ci: specify package json to load pnpm version from

Signed-off-by: izzy <me@insrt.uk>

* docs: update repo. lifecycle details

Signed-off-by: izzy <me@insrt.uk>

* docs: add external links to for-web, for-android
chore: add default.nix to docs

* docs: correct headings in contrib

* ci: use cache dependency path

* fix: docusaurus configuration

---------

Signed-off-by: izzy <me@insrt.uk>
2025-11-14 22:06:20 +00:00
Paul Makles e006cdd051 ci: prevent concurrency on docker/rust actions (#461) 2025-11-14 21:41:22 +00:00
stoat-tofu[bot] ac2971a78d chore: modify .github/workflows/validate-pr-title.yml 2025-11-14 20:25:09 +00:00
Paul Makles b7c2ca84ab chore: update branding in transactional emails (#458)
Signed-off-by: izzy <me@insrt.uk>
2025-11-12 22:19:34 +00:00
Paul Makles 82b492e529 ci: normalise workflow triggers (#459)
Signed-off-by: izzy <me@insrt.uk>
2025-11-12 22:17:11 +00:00
Angelo Kontaxis d567155f12 feat: voice chats v2 (#414)
* initial livekit support

fix up code

undo changes to compose file

add back .env.example

* move voice states to global

* track current state

* more stuff

* fix redis key inconsistancy

* split voice ops into its own library

* feat(livekit): more permission handling

* feat(livekit): push unfinished code

* fix: include voice states in servercreate event
feat: call started system message in dms

* feat: implement missing permission syncs

* fix: remove locked rocket version

* fix: remove local testing values from config

* chore: switch to ids for parameter

* feat: make Channel::server return a reference

* feat: support multiple voice nodes

* fix: expose list of nodes

* fix: respond with the url when joining a voice channel

* feat: being moved between voice channels

* fix: use existing node if someone is already in the voice channel

* refactor: Remove VoiceChannel

* chore: add pushd to debug image script

* refactor: add error messages for Rabbit issues

* docs: add some notes on overrides; fix defaults

* fix: ensure limit is always at least 1 when sent to database

* fix: change permission check for fetching channel webhooks

* chore: mount API at /0.8/ as well

* fix: ensure ratelimiter adjusts for version prefix

* chore: bump version to 0.8.2

* chore: disable LTO because it likely overloads GitHub worker

* fix: don't allow users to time themselves out
closes #376

* fix: match new error type for status code

* feat: init crond crate

* feat: file deletion implementation

* chore: fix version references

* feat: add crond container definitions

* chore: clarify cargo deny / tomls

* feat: add company information to email footers

* chore: bump version to 0.8.3

* fix: don't bump the lockfile version

* fix: include `production` default value for config

* chore: ignore python venv and dev script for user generation

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* test: Add tests for invite/fetch

Signed-off-by: phazeschift <51278042+phazeschift@users.noreply.github.com>

* test: add utilities for common setup

Signed-off-by: phazeschift <51278042+phazeschift@users.noreply.github.com>

* test: add delete server channel test

Signed-off-by: phazeschift <51278042+phazeschift@users.noreply.github.com>

* feat: allow to set an icon when creating a group

Signed-off-by: Nils Ponsard <nilsponsard@gmail.com>

* fix: allow message pinning and unpinning for DM-like channels

Signed-off-by: Bradlee Barnes <69256931+StupidRepo@users.noreply.github.com>

* fix: remove SavedMessages and Group due to given permissions

Signed-off-by: Bradlee Barnes <69256931+StupidRepo@users.noreply.github.com>

* feat: allow bots to manage emojis (#407)

* Remove bot check on emoji create

Signed-off-by: Builderb <builderbgamer@gmail.com>

* Remove bot check on emoji delete

Signed-off-by: Builderb <builderbgamer@gmail.com>

---------

Signed-off-by: Builderb <builderbgamer@gmail.com>

* feat: add option to send message with missing replies

Signed-off-by: ShaksterNano <54268387+shaksternano@users.noreply.github.com>

* chore: bump version to 0.8.4
chore: enable webhooks in test builds

* ci: downgrade lockfile

* feat: Add Mass Mentions to the backend (#394)

* feat: create base of push daemon

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* Add outbound senders

* Make web_push send to rabbit instead (temp stuff)

* feat: stability and friend requests

* make vapid fr stuff not suck

* swap naming of queue

* move pushd into daemons folder

* fix cargo file for move into daemons folder

* feat: probably working fcm push notifs

* comment out fcm webpush stuff since the config keys dont exist

* fix fcm, name queues according to their prod status and configure routing keys

* add pushd to docker

* mix: Remove old code, add stuff to pushd

* fix: lockfile

* feat: update rocket to 5.0.1

* fix: fix queues and ack bugs

* Move rabbit messsage processing into ack queue

* chore: update readme

* chore: optimizations for ack database hits

* pushd flowchart

* misc: update flowchart

* exit dependancy hell

* add rocket_impl flag to authifier

* make the tests file of delta actually compile

* fix: don't silence every push message

* fix: don't silence all messages

* add debug logging for sending data to rabbit from message events

* validate mentions at a server membership level

* put back that import that was actually important

* minor fix to lockfile

* update delta authifier

* feat: proper permissions for push notifications

* add unit test for mention sanitization

* remove local file dependancy on authifier

* update ports to proper defaults

* fixTM the node bindings

* Theoretically configure docker releases for pushd

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* declare exchange in pushd and delta

* fix createbuckets script

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: reference db implementation

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: remove finally redundant code

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: changes

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: other changes

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: make channel name return result

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* Add role mention parsing

* feat: update to mongo 3.1, add member generator.

* integrate mass mentions into pushd

* patch redis-rs with updated versions

* feat: chunk role mentions

* move permission bits to 37/38 to avoid livekit conflict

* change role mention format to <%id>

* fix the lockfile from merge

* fix: PR change requests

* feat: add tests

* fix: i am a dumbass

* fix: tests, again

---------

Signed-off-by: IAmTomahawkx <iamtomahawkx@gmail.com>

* fix: show full branch name on github webhook messages

* fix: dont leak invisible presence to others

* fix: add back missing early adopter badge

* fix: update branch from main

* fix: update branch from main

* Merge remote-tracking branch 'origin/main' into livekit

* feat: private livekit nodes

* fix: remove node from channel voice state

* fix: get_voice_state has incorrect unique_key and allowed_sources should be lowercase

* chore: bump livekit dep

* feat: ability to force disconnect existing voice connection

* chore: cleanup code

* feat: - track call length
- move voice and video limits to config
- seperate VoiceInformation into model and db model
- fix build scripts

* fix: move call system message to daemon,
check max participants when creating a token to avoid giving tokens but erroring when attempting to join,
check if the channel actually supports voice

* fix: dont set max participants in livekit

* fix: remove VoiceChannel channel type
fix: calculate user overwrites correctly
fix: dont include personal info in livekit user metadata
fix: revoke video permissons on denied publish
fix: add video to default permissions

* fix: update migration

* feat: Send push notifications for dm call start/end

* chore: cleanup

* feat: allow users to specify who is notified when starting a call

* feat: track join time

* merge: branch 'main' into feat/livekit

* fix: include voice-ingress in build

* fix: dont presume channel has a node

* chore: add error logging to internal errors

* chore: add debug logging to voice ingress

* refactor: seperate out disconnect logic

* fix: return correct error if user is already in a voice channel

* fix: dont break on user not still being in channel and force disconnecting

* fix: add Speak and Listen to default permissions

* fix: include channel ids in UserMoveVoiceChannel

* chore: add temp sentry logging system

* fix: handle track_muted and track_unmuted

* fix: dont set notification recipients when empty vec is passed

* fix: only send UserMoveVoiceChannel to the user

* fix: preserve order of replies in message (#447)

Signed-off-by: Aeledfyr <aeledfyr@gmail.com>

* fix: prevent timing out members which have TimeoutMembers permission

* chore: update ci

* fix: temporarily disable call started system messages
See #457

* fix: update code from review
2025-11-12 21:27:04 +00:00
izzy 27ea7345ea ci: update api token; add validate PR title 2025-11-11 20:07:18 +00:00
Zomatree 5b534242c4 chore: update ci 2025-11-08 01:21:24 +00:00
Zomatree e36fc9738b fix: prevent timing out members which have TimeoutMembers permission 2025-11-06 20:26:31 +00:00
Aeledfyr 657a3f08e5 fix: preserve order of replies in message (#447)
Signed-off-by: Aeledfyr <aeledfyr@gmail.com>
2025-10-27 00:10:07 +00:00
Zomatree af78ac0586 chore(ci): move to stoatchat 2025-10-11 23:22:41 +01:00
Zomatree d65c1a1ab3 fix(ci): publish images under stoatchat and remove docker hub 2025-10-11 23:01:35 +01:00
izzy 4fb99e3bd0 ci: also include gifbox src 2025-09-23 12:12:33 -05:00
izzy f0a83abcfa ci: add missing src copies for Docker build 2025-09-23 12:09:19 -05:00
izzy 6f1c715b8c chore: bump version to 0.8.9 2025-09-23 12:02:35 -05:00
Zomatree 38dd4d1079 fix: swap to using reqwest for query building 2025-09-19 02:50:00 +01:00
Zomatree 154204742d feat: add ratelimits to gifbox 2025-09-19 02:50:00 +01:00
Zomatree db55998546 docs: document revolt-coalesced 2025-09-19 02:50:00 +01:00
Zomatree 5885e067a6 feat: trending and categories routes 2025-09-19 02:50:00 +01:00
Zomatree a92152d86d fix: use our own result types instead of tenors types
add user auth
2025-09-19 02:50:00 +01:00
Zomatree b5cd5e30ef feat: require auth for search 2025-09-19 02:50:00 +01:00
Zomatree b0c977b324 feat: initial work on tenor gif searching 2025-09-19 02:50:00 +01:00
Zomatree bfe4018e43 fix: apple music to use original url instead of metadata url
Apple Music returns the url without the query parameter in the opengraph meta tags, causing the regex to not find the track id, meaning any embed links generated would only link to the album.
2025-09-19 02:41:53 +01:00
Zomatree cc7a7962a8 fix: use trust_cloudflare config value instead of env var 2025-09-18 21:22:31 +01:00
Zomatree fb4011084d refactor: move ratelimits to a generic system for all web servers 2025-09-18 21:22:31 +01:00
Zomatree 3a3415915f fix: relax settings name regex 2025-09-17 15:41:54 +01:00
Zomatree 964884a5de chore: add policy changes 2025-09-17 15:41:54 +01:00
Zomatree db57706794 feat: ready payload field customisation 2025-09-17 15:41:54 +01:00
Paul Makles a186732ca5 merge: pull request #428 from ElfFlu/bugfix/16byte-attachment-gain 2025-09-12 12:48:28 +01:00
Tom e635bc23ec fix: don't remove timeouts when a member leaves a server (#409) 2025-09-06 18:41:23 -07:00
Zomatree 3cb7da95e3 chore: add missing traits implementations to PermissionValue 2025-09-05 15:22:55 +01:00
Zomatree dea0f675dd fix: implement Serialize to ClientMessage 2025-09-05 13:07:52 +01:00
Zomatree 480f210ce8 feat: include member payload in ServerMemberJoin event 2025-08-24 06:32:38 +01:00
Zomatree caa8607468 feat: include groups and dms in fetch mutuals 2025-08-24 03:40:10 +01:00
ElfFlu 32e6600272 fix: remove authentication tag bytes from attachment download
Signed-off-by: ElfFlu <228066338+ElfFlu@users.noreply.github.com>
2025-08-23 19:05:32 +02:00
Zomatree 5335124306 fix: add license to revolt-parser 2025-08-20 18:55:52 +01:00
Zomatree 1561481eb4 feat: make message lexer use unowned string 2025-08-20 18:55:39 +01:00
IAmTomahawkx 8f9015a6ff fix: apply uname fix to january and autumn 2025-08-18 16:59:08 -07:00
299 changed files with 39016 additions and 3246 deletions
-37
View File
@@ -1,37 +0,0 @@
name: Build documentation
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write # To push a branch
pages: write # To push to a GitHub Pages site
id-token: write # To update the deployment status
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install latest mdbook
run: |
tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name')
url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz"
mkdir mdbook
curl -sSL $url | tar -xz --directory=./mdbook
echo `pwd`/mdbook >> $GITHUB_PATH
- name: Build Book
run: |
cd doc
mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "doc/book"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+57 -29
View File
@@ -5,19 +5,22 @@ on:
tags: tags:
- "*" - "*"
pull_request: pull_request:
branches:
- "main"
paths: paths:
- "Dockerfile" - "Dockerfile"
workflow_dispatch:
permissions: permissions:
contents: read contents: read
packages: write packages: write
concurrency:
group: ${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs: jobs:
base: base:
name: Test base image build name: Test base image build
runs-on: ubuntu-latest runs-on: arc-runner-set
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
# Configure build environment # Configure build environment
@@ -38,7 +41,7 @@ jobs:
cache-to: type=gha,scope=buildx-base-multi-arch,mode=max cache-to: type=gha,scope=buildx-base-multi-arch,mode=max
publish: publish:
runs-on: self-hosted runs-on: arc-runner-set
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
name: Publish Docker images name: Publish Docker images
steps: steps:
@@ -49,13 +52,6 @@ jobs:
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v2
# Authenticate with Docker Hub and GHCR # Authenticate with Docker Hub and GHCR
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Github Container Registry - name: Login to Github Container Registry
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:
@@ -72,14 +68,13 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository_owner }}/base:latest tags: ghcr.io/${{ github.repository_owner }}/base:latest
# revoltchat/server # stoatchat/api
- name: Docker meta - name: Docker meta
id: meta-delta id: meta-delta
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
docker.io/revoltchat/server ghcr.io/stoatchat/api
ghcr.io/revoltchat/server
- name: Publish - name: Publish
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
@@ -92,14 +87,13 @@ jobs:
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-delta.outputs.labels }} labels: ${{ steps.meta-delta.outputs.labels }}
# revoltchat/bonfire # stoatchat/events
- name: Docker meta - name: Docker meta
id: meta-bonfire id: meta-bonfire
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
docker.io/revoltchat/bonfire ghcr.io/stoatchat/events
ghcr.io/revoltchat/bonfire
- name: Publish - name: Publish
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
@@ -112,14 +106,13 @@ jobs:
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-bonfire.outputs.labels }} labels: ${{ steps.meta-bonfire.outputs.labels }}
# revoltchat/autumn # stoatchat/file-server
- name: Docker meta - name: Docker meta
id: meta-autumn id: meta-autumn
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
docker.io/revoltchat/autumn ghcr.io/stoatchat/file-server
ghcr.io/revoltchat/autumn
- name: Publish - name: Publish
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
@@ -132,14 +125,13 @@ jobs:
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-autumn.outputs.labels }} labels: ${{ steps.meta-autumn.outputs.labels }}
# revoltchat/january # stoatchat/proxy
- name: Docker meta - name: Docker meta
id: meta-january id: meta-january
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
docker.io/revoltchat/january ghcr.io/stoatchat/proxy
ghcr.io/revoltchat/january
- name: Publish - name: Publish
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
@@ -152,14 +144,32 @@ jobs:
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-january.outputs.labels }} labels: ${{ steps.meta-january.outputs.labels }}
# revoltchat/crond # stoatchat/gifbox
- name: Docker meta
id: meta-gifbox
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/gifbox
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/services/gifbox/Dockerfile
tags: ${{ steps.meta-gifbox.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-gifbox.outputs.labels }}
# stoatchat/crond
- name: Docker meta - name: Docker meta
id: meta-crond id: meta-crond
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
docker.io/revoltchat/crond ghcr.io/stoatchat/crond
ghcr.io/revoltchat/crond
- name: Publish - name: Publish
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
@@ -172,14 +182,13 @@ jobs:
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-crond.outputs.labels }} labels: ${{ steps.meta-crond.outputs.labels }}
# revoltchat/pushd # stoatchat/pushd
- name: Docker meta - name: Docker meta
id: meta-pushd id: meta-pushd
uses: docker/metadata-action@v4 uses: docker/metadata-action@v4
with: with:
images: | images: |
docker.io/revoltchat/pushd ghcr.io/stoatchat/pushd
ghcr.io/revoltchat/pushd
- name: Publish - name: Publish
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
with: with:
@@ -191,3 +200,22 @@ jobs:
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-pushd.outputs.labels }} labels: ${{ steps.meta-pushd.outputs.labels }}
# stoatchat/voice-ingress
- name: Docker meta
id: meta-voice-ingress
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/voice-ingress
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/daemons/voice-ingress/Dockerfile
tags: ${{ steps.meta-voice-ingress.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-voice-ingress.outputs.labels }}
+29
View File
@@ -0,0 +1,29 @@
name: Documentation (test)
on:
pull_request:
jobs:
test-deploy:
name: Test deployment
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
package_json_file: ./docs/package.json
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
cache-dependency-path: ./docs/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Test build website
run: pnpm run build
+54
View File
@@ -0,0 +1,54 @@
name: Documentation
on:
push:
branches:
- main
jobs:
build:
name: Build Docusaurus
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
with:
package_json_file: ./docs/package.json
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
cache-dependency-path: ./docs/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Build website
run: pnpm run build
- name: Upload Build Artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./docs/build
deploy:
name: Deploy to GitHub Pages
needs: build
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+60
View File
@@ -0,0 +1,60 @@
name: Release Please
on:
push:
branches: [main] # updates/opens the release PR when commits land on main
workflow_dispatch:
permissions:
contents: write
pull-requests: write
id-token: write
concurrency:
group: release-please
cancel-in-progress: true
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.rp.outputs.release_created }}
tag_name: ${{ steps.rp.outputs.tag_name }}
steps:
- id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_STOAT_RELEASE_APP_ID }}
private-key: ${{ secrets.GH_STOAT_RELEASE_APP_PRIVATE_KEY }}
- id: rp
uses: googleapis/release-please-action@v4
with:
token: ${{ steps.app-token.outputs.token }}
config-file: release-please-config.json
publish-please:
name: Publish Please
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_STOAT_RELEASE_APP_ID }}
private-key: ${{ secrets.GH_STOAT_RELEASE_APP_PRIVATE_KEY }}
- name: Send release notification webhook
run: |
RELEASE_URL="https://github.com/revoltchat/backend/releases/tag/${{ needs.release-please.outputs.tag_name }}"
curl -X POST "${{ secrets.STOAT_WEBHOOK_UPDATES_URL }}" \
-H "Content-Type: application/json" \
-d "{\"content\": \"$RELEASE_URL\"}"
- name: Publish crates
uses: katyo/publish-crates@v2
with:
registry-token: ${{ secrets.CRATES_IO_PUBLISH_TOKEN }}
+25 -26
View File
@@ -2,16 +2,12 @@ name: Rust build, test, and generate specification
on: on:
push: push:
paths-ignore:
- ".github/**"
- "!.github/workflows/rust.yaml"
- ".vscode/**"
- "doc/**"
- ".gitignore"
- "LICENSE"
- "README"
pull_request:
branches: [main] branches: [main]
pull_request:
concurrency:
group: ${{ github.head_ref || github.ref }}
cancel-in-progress: true
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -19,25 +15,28 @@ env:
jobs: jobs:
check: check:
name: Rust project name: Rust project
runs-on: ubuntu-latest runs-on: arc-runner-set
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# Using our own runners for now:
# - name: Free up disk space
# run: |
# sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc
- name: Install latest stable - name: Install latest stable
uses: actions-rs/toolchain@v1 uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with: with:
toolchain: stable toolchain: stable
override: true
components: rustfmt, clippy components: rustfmt, clippy
- name: Install cargo-nextest - name: Install cargo-nextest
uses: baptiste0928/cargo-install@v1 uses: taiki-e/install-action@a58ae9526b2c3acee9ad8e1926b950b7863305d4 # 2.65.16
with: with:
crate: cargo-nextest tool: cargo-nextest@0.9.119
locked: true
- name: Run cargo build - name: Run cargo build
uses: actions-rs/cargo@v1 run: cargo build
with:
command: build
- name: Run services in background - name: Run services in background
run: | run: |
@@ -65,17 +64,17 @@ jobs:
- name: Wait for API to go up - name: Wait for API to go up
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: nev7n/wait_for_response@v1 uses: nev7n/wait_for_response@7fef3c1a6e8939d0b09062f14fec50d3c5d15fa1 # v1.0.1
with: with:
url: "http://localhost:14702/" url: "http://localhost:14702/"
- name: Checkout API repository - name: Checkout API repository
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: actions/checkout@v3 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
repository: revoltchat/api repository: stoatchat/javascript-client-api
path: api path: api
token: ${{ secrets.PAT }} ssh-key: ${{ secrets.DEPLOY_KEY_JAVASCRIPT_CLIENT_API }}
- name: Download OpenAPI specification - name: Download OpenAPI specification
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'
@@ -83,10 +82,10 @@ jobs:
- name: Commit changes - name: Commit changes
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: EndBug/add-and-commit@v4 uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
with: with:
cwd: "api" cwd: "api"
add: "*.json" add: "*.json"
author_name: Revolt CI author_name: Stoat CI
author_email: revolt-ci@users.noreply.github.com author_email: stoat-ci@users.noreply.github.com
message: "chore: generate OpenAPI specification" message: "chore: generate OpenAPI specification"
-54
View File
@@ -1,54 +0,0 @@
name: Add Issue to Board
on:
issues:
types: [opened]
jobs:
track_issue:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectV2(number: 3) {
id
fields(first:20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
- name: Add issue to project
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
ISSUE_ID: ${{ github.event.issue.node_id }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $issue:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
-79
View File
@@ -1,79 +0,0 @@
name: Add PR to Board
on:
pull_request_target:
types: [opened, synchronize, ready_for_review, review_requested]
jobs:
track_pr:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectV2(number: 5) {
id
fields(first:20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="🆕 Untriaged") |.id' project_data.json) >> $GITHUB_ENV
- name: Add PR to project
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
PR_ID: ${{ github.event.pull_request.node_id }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
mutation (
$project: ID!
$item: ID!
$status_field: ID!
$status_value: String!
) {
set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $status_field
value: {
singleSelectOptionId: $status_value
}
}) {
projectV2Item {
id
}
}
}' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent
+20
View File
@@ -0,0 +1,20 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1
View File
@@ -11,4 +11,5 @@ venv/
.vercel .vercel
.DS_Store .DS_Store
livekit.yml
.idea .idea
+3
View File
@@ -0,0 +1,3 @@
{
".": "0.9.1"
}
+51
View File
@@ -0,0 +1,51 @@
# Changelog
## [0.9.1](https://github.com/stoatchat/stoatchat/compare/v0.9.0...v0.9.1) (2026-01-10)
### Bug Fixes
* **ci:** pipeline fixes (marked as fix to force release) ([#483](https://github.com/stoatchat/stoatchat/issues/483)) ([303e52b](https://github.com/stoatchat/stoatchat/commit/303e52b476585eea81c33837f1b01506ce387684))
## [0.9.0](https://github.com/stoatchat/stoatchat/compare/v0.8.8...v0.9.0) (2026-01-10)
### Features
* add id field to role ([#470](https://github.com/stoatchat/stoatchat/issues/470)) ([2afea56](https://github.com/stoatchat/stoatchat/commit/2afea56e56017f02de98e67316b4457568ad5b26))
* add ratelimits to gifbox ([1542047](https://github.com/stoatchat/stoatchat/commit/154204742d21cbeff6e2577b00f50b495ea44631))
* include groups and dms in fetch mutuals ([caa8607](https://github.com/stoatchat/stoatchat/commit/caa86074680d46223cebc20f41e9c91c41ec825d))
* include member payload in ServerMemberJoin event ([480f210](https://github.com/stoatchat/stoatchat/commit/480f210ce85271e13d1dac58a5dae08de108579d))
* initial work on tenor gif searching ([b0c977b](https://github.com/stoatchat/stoatchat/commit/b0c977b324b8144c1152589546eb8fec5954c3e7))
* make message lexer use unowned string ([1561481](https://github.com/stoatchat/stoatchat/commit/1561481eb4cdc0f385fbf0a81e4950408050e11f))
* ready payload field customisation ([db57706](https://github.com/stoatchat/stoatchat/commit/db577067948f13e830b5fb773034e9713a1abaff))
* require auth for search ([b5cd5e3](https://github.com/stoatchat/stoatchat/commit/b5cd5e30ef7d5e56e8964fb7c543965fa6bf5a4a))
* trending and categories routes ([5885e06](https://github.com/stoatchat/stoatchat/commit/5885e067a627b8fff1c8ce2bf9e852ff8cf3f07a))
* voice chats v2 ([#414](https://github.com/stoatchat/stoatchat/issues/414)) ([d567155](https://github.com/stoatchat/stoatchat/commit/d567155f124e4da74115b1a8f810062f7c6559d9))
### Bug Fixes
* add license to revolt-parser ([5335124](https://github.com/stoatchat/stoatchat/commit/53351243064cac8d499dd74284be73928fa78a43))
* allow for disabling default features ([65fbd36](https://github.com/stoatchat/stoatchat/commit/65fbd3662462aed1333b79e59155fa6377e83fcc))
* apple music to use original url instead of metadata url ([bfe4018](https://github.com/stoatchat/stoatchat/commit/bfe4018e436a4075bae780dd4d35a9b58315e12f))
* apply uname fix to january and autumn ([8f9015a](https://github.com/stoatchat/stoatchat/commit/8f9015a6ff181d208d9269ab8691bd417d39811a))
* **ci:** publish images under stoatchat and remove docker hub ([d65c1a1](https://github.com/stoatchat/stoatchat/commit/d65c1a1ab3bdc7e5684b03f280af77d881661a3d))
* correct miniz_oxide in lockfile ([#478](https://github.com/stoatchat/stoatchat/issues/478)) ([5d27a91](https://github.com/stoatchat/stoatchat/commit/5d27a91e901dd2ea3e860aeaed8468db6c5f3214))
* correct shebang for try-tag-and-release ([050ba16](https://github.com/stoatchat/stoatchat/commit/050ba16d4adad5d0fb247867aa3e94e3d42bd12d))
* correct string_cache in lockfile ([#479](https://github.com/stoatchat/stoatchat/issues/479)) ([0b178fc](https://github.com/stoatchat/stoatchat/commit/0b178fc791583064bf9ca94b1d39b42d021e1d79))
* don't remove timeouts when a member leaves a server ([#409](https://github.com/stoatchat/stoatchat/issues/409)) ([e635bc2](https://github.com/stoatchat/stoatchat/commit/e635bc23ec857d648d5705e1a3875d7bc3402b0d))
* don't update the same field while trying to remove it ([f4ee35f](https://github.com/stoatchat/stoatchat/commit/f4ee35fb093ca49f0a64ff4b17fd61587df28145)), closes [#392](https://github.com/stoatchat/stoatchat/issues/392)
* github webhook incorrect payload and formatting ([#468](https://github.com/stoatchat/stoatchat/issues/468)) ([dc9c82a](https://github.com/stoatchat/stoatchat/commit/dc9c82aa4e9667ea6639256c65ac8de37a24d1f7))
* implement Serialize to ClientMessage ([dea0f67](https://github.com/stoatchat/stoatchat/commit/dea0f675dde7a63c7a59b38d469f878b7a8a3af4))
* newly created roles should be ranked the lowest ([947eb15](https://github.com/stoatchat/stoatchat/commit/947eb15771ed6785b3dcd16c354c03ded5e4cbe0))
* permit empty `remove` array in edit requests ([6ad3da5](https://github.com/stoatchat/stoatchat/commit/6ad3da5f35f989a2e7d8e29718b98374248e76af))
* preserve order of replies in message ([#447](https://github.com/stoatchat/stoatchat/issues/447)) ([657a3f0](https://github.com/stoatchat/stoatchat/commit/657a3f08e5d652814bbf0647e089ed9ebb139bbf))
* prevent timing out members which have TimeoutMembers permission ([e36fc97](https://github.com/stoatchat/stoatchat/commit/e36fc9738bac0de4f3fcbccba521f1e3754f7ae7))
* relax settings name regex ([3a34159](https://github.com/stoatchat/stoatchat/commit/3a3415915f0d0fdce1499d47a2b7fa097f5946ea))
* remove authentication tag bytes from attachment download ([32e6600](https://github.com/stoatchat/stoatchat/commit/32e6600272b885c595c094f0bc69459250220dcb))
* rename openapi operation ids ([6048587](https://github.com/stoatchat/stoatchat/commit/6048587d348fbca0dc3a9b47690c56df8fece576)), closes [#406](https://github.com/stoatchat/stoatchat/issues/406)
* respond with 201 if no body in requests ([#465](https://github.com/stoatchat/stoatchat/issues/465)) ([24fedf8](https://github.com/stoatchat/stoatchat/commit/24fedf8c4d9cd3160bdec97aa451520f8beaa739))
* swap to using reqwest for query building ([38dd4d1](https://github.com/stoatchat/stoatchat/commit/38dd4d10797b3e6e397fc219e818f379bdff19f2))
* use `trust_cloudflare` config value instead of env var ([cc7a796](https://github.com/stoatchat/stoatchat/commit/cc7a7962a882e1627fcd0bc75858a017415e8cfc))
* use our own result types instead of tenors types ([a92152d](https://github.com/stoatchat/stoatchat/commit/a92152d86da136997817e797c7af8e38731cdde8))
Generated
+1260 -754
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -27,10 +27,14 @@ COPY crates/core/parser/Cargo.toml ./crates/core/parser/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/ COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/ COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/ COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/core/coalesced/Cargo.toml ./crates/core/coalesced/
COPY crates/core/ratelimits/Cargo.toml ./crates/core/ratelimits/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/ COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/ COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/ COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/ COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
COPY crates/daemons/voice-ingress/Cargo.toml ./crates/daemons/voice-ingress/
RUN sh /tmp/build-image-layer.sh deps RUN sh /tmp/build-image-layer.sh deps
# Build all apps # Build all apps
+4
View File
@@ -23,10 +23,14 @@ COPY crates/core/parser/Cargo.toml ./crates/core/parser/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/ COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/ COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/ COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/core/coalesced/Cargo.toml ./crates/core/coalesced/
COPY crates/core/ratelimits/Cargo.toml ./crates/core/ratelimits/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/ COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/ COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/ COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/ COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
COPY crates/daemons/voice-ingress/Cargo.toml ./crates/daemons/voice-ingress/
RUN sh /tmp/build-image-layer.sh deps RUN sh /tmp/build-image-layer.sh deps
# Build all apps # Build all apps
+5 -3
View File
@@ -21,9 +21,11 @@ The services and libraries that power the Revolt service.<br/>
| `core/permissions` | [crates/core/permissions](crates/core/permissions) | Core: Permission Logic | ![Crates.io Version](https://img.shields.io/crates/v/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/size/revolt-permissions) ![Crates.io License](https://img.shields.io/crates/l/revolt-permissions) | | `core/permissions` | [crates/core/permissions](crates/core/permissions) | Core: Permission Logic | ![Crates.io Version](https://img.shields.io/crates/v/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/size/revolt-permissions) ![Crates.io License](https://img.shields.io/crates/l/revolt-permissions) |
| `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence | ![Crates.io Version](https://img.shields.io/crates/v/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/size/revolt-presence) ![Crates.io License](https://img.shields.io/crates/l/revolt-presence) | | `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence | ![Crates.io Version](https://img.shields.io/crates/v/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/size/revolt-presence) ![Crates.io License](https://img.shields.io/crates/l/revolt-presence) |
| `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types | ![Crates.io Version](https://img.shields.io/crates/v/revolt-result) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-result) ![Crates.io Version](https://img.shields.io/crates/size/revolt-result) ![Crates.io License](https://img.shields.io/crates/l/revolt-result) | | `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types | ![Crates.io Version](https://img.shields.io/crates/v/revolt-result) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-result) ![Crates.io Version](https://img.shields.io/crates/size/revolt-result) ![Crates.io License](https://img.shields.io/crates/l/revolt-result) |
| `core/coalesced` | [crates/core/coalesced](crates/core/coalesced) | Core: Coalescion service | ![Crates.io Version](https://img.shields.io/crates/v/revolt-coalesced) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-coalesced) ![Crates.io Version](https://img.shields.io/crates/size/revolt-coalesced) ![Crates.io License](https://img.shields.io/crates/l/revolt-coalesced) |
| `delta` | [crates/delta](crates/delta) | REST API server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) | | `delta` | [crates/delta](crates/delta) | REST API server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) | | `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/january` | [crates/services/january](crates/services/january) | Proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) | | `services/january` | [crates/services/january](crates/services/january) | Proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/gifbox` | [crates/services/gifbox](crates/services/gifbox) | Tenor proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/autumn` | [crates/services/autumn](crates/services/autumn) | File server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) | | `services/autumn` | [crates/services/autumn](crates/services/autumn) | File server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `daemons/crond` | [crates/daemons/crond](crates/daemons/crond) | Timed data clean up daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) | | `daemons/crond` | [crates/daemons/crond](crates/daemons/crond) | Timed data clean up daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `daemons/pushd` | [crates/daemons/pushd](crates/daemons/pushd) | Push notification daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) | | `daemons/pushd` | [crates/daemons/pushd](crates/daemons/pushd) | Push notification daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
@@ -35,9 +37,6 @@ The services and libraries that power the Revolt service.<br/>
Rust 1.86.0 or higher. Rust 1.86.0 or higher.
> [!CAUTION]
> The events server has a significant performance regression between Rust 1.77.2 and 1.78.0 onwards, see [issue #341](https://github.com/revoltchat/backend/issues/341). This is currently solved by build time options but we are looking for a proper fix.
## Development Guide ## Development Guide
Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html) and the [technical documentation for this project](https://revoltchat.github.io/backend/). Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html) and the [technical documentation for this project](https://revoltchat.github.io/backend/).
@@ -66,6 +65,7 @@ As a heads-up, the development environment uses the following ports:
| `crates/bonfire` | 14703 | | `crates/bonfire` | 14703 |
| `crates/services/autumn` | 14704 | | `crates/services/autumn` | 14704 |
| `crates/services/january` | 14705 | | `crates/services/january` | 14705 |
| `crates/services/gifbox` | 14706 |
Now you can clone and build the project: Now you can clone and build the project:
@@ -141,6 +141,8 @@ cargo run --bin revolt-bonfire
cargo run --bin revolt-autumn cargo run --bin revolt-autumn
# run the proxy server # run the proxy server
cargo run --bin revolt-january cargo run --bin revolt-january
# run the tenor proxy
cargo run --bin revolt-gifbox
# run the push daemon (not usually needed in regular development) # run the push daemon (not usually needed in regular development)
cargo run --bin revolt-pushd cargo run --bin revolt-pushd
+17
View File
@@ -26,6 +26,11 @@ january = "http://local.revolt.chat:14705"
voso_legacy = "" voso_legacy = ""
voso_legacy_ws = "" voso_legacy_ws = ""
# Public urls for livekit nodes
# each entry here should have a corresponding entry under `api.livekit.nodes`
[hosts.livekit]
worldwide = "ws://local.revolt.chat:14706"
[api] [api]
[api.smtp] [api.smtp]
@@ -40,6 +45,18 @@ port = 14025
use_tls = false use_tls = false
use_starttls = false use_starttls = false
[api.livekit]
# Config for livekit nodes
# Make sure to change the secret when deploying
# The key and secret should match the values livekit is using
[api.livekit.nodes.worldwide]
url = "http://livekit"
lat = 0.0
lon = 0.0
key = "worldwide"
secret = "ZjCofRlfm6GGtjlifmNpCDkcQbEIIVC0"
[files.s3] [files.s3]
# S3 protocol endpoint # S3 protocol endpoint
endpoint = "http://127.0.0.1:14009" endpoint = "http://127.0.0.1:14009"
-73
View File
@@ -1,73 +0,0 @@
# Code Style Guide
Beyond using Cargo format and Clippy, there are some specific code style guidelines laid out in this document for different parts of the project.
## Writing Style
- Shorten "identifier" to "Id" with that exact casing, i.e. Server Id.
## `core/database` crate
w.r.t. `model.rs` files
- All struct definitions must be commented.
```rust
/// Server
pub struct Server {
/// Name of the server
pub name: String,
```
- Struct definitions should not include derives unless necessary (if additional traits such as Hash are required) and instead use `auto_derived!` and `auto_derived_partial!`.
```rust
auto_derived_partial!(
/// Server
pub struct Server { .. },
"PartialServer"
);
```
- `auto_derived!` macro accepts multiple entries and should be used as such:
```rust
auto_derived!(
/// Optional fields on server object
pub enum FieldsServer { .. }
/// Optional fields on server object
pub enum FieldsRole { .. }
);
```
- If special serialisation conditions are required, such as checking if a boolean is false, use the existing definitions for these functions from the crate root:
```rust
#[serde(skip_serializing_if = "crate::if_false", default)]
```
- `impl` blocks may be defined below the struct definitions and should be ordered in the same order of definition. Methods in the block must follow the same guidelines as traits where-in: methods are ordered in terms of CRUD, there are empty line breaks, and methods are commented.
w.r.t. `ops` module for models
- All traits must use a the name format `AbstractPlural` where Plural is the plural form of the collection. e.g. Servers
- Traits defined must follow these guidelines:
- Methods are ordered in terms of CRUD, create-read-update-delete ordering.
```rust
#[async_trait]
pub trait AbstractServerMembers: Sync + Send {
/// Insert a new server member into the database
async fn insert_member(&self, member: &Member) -> Result<()>;
/// Fetch a server member by their id
async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result<Member>;
/// Update information for a server member
async fn update_member(&self, .. ) -> Result<()>;
/// Delete a server member by their id
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()>;
}
```
- There should be an empty line break between each method declaration.
- All methods must have an appropriate comment.
- When implementing the trait defined in `ops.rs` with each driver, the method declaration style should be the same for ease of searching: same ordering, same comments, same line breaks.
-4
View File
@@ -26,7 +26,3 @@ disallowed-methods = [
# Prefer to use Object::delete(&self) # Prefer to use Object::delete(&self)
"revolt_database::models::bots::ops::AbstractBots::delete_bot", "revolt_database::models::bots::ops::AbstractBots::delete_bot",
] ]
disallowed-types = [
"rocket::serde::json::Json",
]
+8 -1
View File
@@ -40,7 +40,7 @@ services:
# Rabbit # Rabbit
rabbit: rabbit:
image: rabbitmq:3-management image: rabbitmq:4-management
environment: environment:
RABBITMQ_DEFAULT_USER: rabbituser RABBITMQ_DEFAULT_USER: rabbituser
RABBITMQ_DEFAULT_PASS: rabbitpass RABBITMQ_DEFAULT_PASS: rabbitpass
@@ -64,3 +64,10 @@ services:
MAILDEV_WEB_PORT: 8080 MAILDEV_WEB_PORT: 8080
MAILDEV_INCOMING_USER: smtp MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp MAILDEV_INCOMING_PASS: smtp
livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.9
command: --config /etc/livekit.yml
network_mode: "host"
volumes:
- ./livekit.yml:/etc/livekit.yml
+5 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-bonfire" name = "revolt-bonfire"
version = "0.8.8" version = "0.9.1"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
@@ -19,6 +19,7 @@ async-channel = "2.3.1"
# parsing # parsing
querystring = "1.1.0" querystring = "1.1.0"
regex = "1.11.1"
# serde # serde
bincode = "1.3.3" bincode = "1.3.3"
@@ -37,11 +38,11 @@ async-std = { version = "1.8.0", features = [
# core # core
authifier = { version = "1.0.15" } authifier = { version = "1.0.15" }
revolt-result = { path = "../core/result", features = ["sentry"] } revolt-result = { path = "../core/result" }
revolt-models = { path = "../core/models" } revolt-models = { path = "../core/models" }
revolt-config = { path = "../core/config" } revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database" } revolt-database = { path = "../core/database", features = ["voice"] }
revolt-permissions = { version = "0.8.8", path = "../core/permissions" } revolt-permissions = { path = "../core/permissions" }
revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] } revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
# redis # redis
+1 -1
View File
@@ -1,5 +1,5 @@
# Build Stage # Build Stage
FROM ghcr.io/revoltchat/base:latest AS builder FROM ghcr.io/stoatchat/base:latest AS builder
FROM debian:12 AS debian FROM debian:12 AS debian
# Bundle Stage # Bundle Stage
+59 -19
View File
@@ -1,9 +1,15 @@
use async_tungstenite::tungstenite::{handshake, Message}; use async_tungstenite::tungstenite::{handshake, Message};
use futures::channel::oneshot::Sender; use futures::channel::oneshot::Sender;
use once_cell::sync::Lazy;
use regex::Regex;
use revolt_database::events::client::ReadyPayloadFields; use revolt_database::events::client::ReadyPayloadFields;
use revolt_result::{create_error, Result, ToRevoltError}; use revolt_result::{create_error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
/// matches either a single word ie "users" or a key and value ie "settings[notifications]"
static READY_PAYLOAD_FIELD_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"^(\w+)(?:\[(\S+)\])?$"#).unwrap());
/// Enumeration of supported protocol formats /// Enumeration of supported protocol formats
#[derive(Debug)] #[derive(Debug)]
pub enum ProtocolFormat { pub enum ProtocolFormat {
@@ -17,6 +23,7 @@ pub struct ProtocolConfiguration {
protocol_version: i32, protocol_version: i32,
format: ProtocolFormat, format: ProtocolFormat,
session_token: Option<String>, session_token: Option<String>,
ready_payload_fields: ReadyPayloadFields,
} }
impl ProtocolConfiguration { impl ProtocolConfiguration {
@@ -25,11 +32,13 @@ impl ProtocolConfiguration {
protocol_version: i32, protocol_version: i32,
format: ProtocolFormat, format: ProtocolFormat,
session_token: Option<String>, session_token: Option<String>,
ready_payload_fields: ReadyPayloadFields,
) -> Self { ) -> Self {
Self { Self {
protocol_version, protocol_version,
format, format,
session_token, session_token,
ready_payload_fields,
} }
} }
@@ -38,22 +47,16 @@ impl ProtocolConfiguration {
match self.format { match self.format {
ProtocolFormat::Json => { ProtocolFormat::Json => {
if let Message::Text(text) = msg { if let Message::Text(text) = msg {
// Log the error in-case we make a breaking change to the payload serde_json::from_str(text).map_err(|_| create_error!(InternalError))
serde_json::from_str(text)
.capture_error()
.map_err(|_| create_error!(UnprocessableEntity))
} else { } else {
Err(create_error!(UnprocessableEntity)) Err(create_error!(InternalError))
} }
} }
ProtocolFormat::Msgpack => { ProtocolFormat::Msgpack => {
if let Message::Binary(buf) = msg { if let Message::Binary(buf) = msg {
rmp_serde::from_slice(buf) rmp_serde::from_slice(buf).map_err(|_| create_error!(InternalError))
.capture_error()
.map_err(|_| create_error!(UnprocessableEntity))
} else { } else {
Err(create_error!(UnprocessableEntity)) Err(create_error!(InternalError))
} }
} }
} }
@@ -92,14 +95,8 @@ impl ProtocolConfiguration {
} }
/// Get ready payload fields /// Get ready payload fields
pub fn get_ready_payload_fields(&self) -> Vec<ReadyPayloadFields> { pub fn get_ready_payload_fields(&self) -> &ReadyPayloadFields {
vec![ &self.ready_payload_fields
ReadyPayloadFields::Users,
ReadyPayloadFields::Servers,
ReadyPayloadFields::Channels,
ReadyPayloadFields::Members,
ReadyPayloadFields::Emoji,
]
} }
} }
@@ -130,6 +127,23 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
let mut protocol_version = 1; let mut protocol_version = 1;
let mut format = ProtocolFormat::Json; let mut format = ProtocolFormat::Json;
let mut session_token = None; let mut session_token = None;
let mut ready_payload_fields = if params.iter().any(|(k, _)| *k == "ready") {
// If they pass the ready field, set all fields to false
ReadyPayloadFields {
users: false,
servers: false,
channels: false,
members: false,
emojis: false,
voice_states: false,
user_settings: Vec::new(),
channel_unreads: false,
policy_changes: false,
}
} else {
ReadyPayloadFields::default()
};
// Parse and map parameters from key-value to known variables. // Parse and map parameters from key-value to known variables.
for (key, value) in params { for (key, value) in params {
@@ -145,6 +159,31 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
_ => {} _ => {}
}, },
"token" => session_token = Some(value.into()), "token" => session_token = Some(value.into()),
"ready" => {
// Re-enable all the fields the client specifies
if let Some(captures) = READY_PAYLOAD_FIELD_REGEX.captures(value) {
if let Some(field) = captures.get(0) {
match field.as_str() {
"users" => ready_payload_fields.users = true,
"servers" => ready_payload_fields.servers = true,
"channels" => ready_payload_fields.channels = true,
"members" => ready_payload_fields.members = true,
"emojis" => ready_payload_fields.emojis = true,
"voice_states" => ready_payload_fields.voice_states = true,
"channel_unreads" => ready_payload_fields.channel_unreads = true,
"user_settings" => {
if let Some(subkey) = captures.get(1) {
ready_payload_fields
.user_settings
.push(subkey.as_str().to_string());
}
}
"policy_changes" => ready_payload_fields.policy_changes = true,
_ => {}
}
}
}
}
_ => {} _ => {}
} }
} }
@@ -157,6 +196,7 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
protocol_version, protocol_version,
format, format,
session_token, session_token,
ready_payload_fields,
}) })
.is_ok() .is_ok()
{ {
+99 -47
View File
@@ -1,9 +1,10 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use futures::future::join_all; use futures::future::join_all;
use revolt_database::{ use revolt_database::{
events::client::{EventV1, ReadyPayloadFields}, events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery, util::permissions::DatabasePermissionQuery,
voice::get_channel_voice_state,
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus, Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
}; };
use revolt_models::v0; use revolt_models::v0;
@@ -17,8 +18,9 @@ use super::state::{Cache, State};
impl Cache { impl Cache {
/// Check whether the current user can view a channel /// Check whether the current user can view a channel
pub async fn can_view_channel(&self, db: &Database, channel: &Channel) -> bool { pub async fn can_view_channel(&self, db: &Database, channel: &Channel) -> bool {
#[allow(deprecated)]
match &channel { match &channel {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { Channel::TextChannel { server, .. } => {
let member = self.members.get(server); let member = self.members.get(server);
let server = self.servers.get(server); let server = self.servers.get(server);
let mut query = let mut query =
@@ -95,21 +97,23 @@ impl State {
pub async fn generate_ready_payload( pub async fn generate_ready_payload(
&mut self, &mut self,
db: &Database, db: &Database,
fields: Vec<ReadyPayloadFields>, fields: &ReadyPayloadFields,
) -> Result<EventV1> { ) -> Result<EventV1> {
let user = self.clone_user(); let user = self.clone_user();
self.cache.is_bot = user.bot.is_some(); self.cache.is_bot = user.bot.is_some();
// Fetch pending policy changes. // Fetch pending policy changes.
let policy_changes = if user.bot.is_some() { let policy_changes = if user.bot.is_some() || !fields.policy_changes {
vec![] None
} else { } else {
db.fetch_policy_changes() Some(
.await? db.fetch_policy_changes()
.into_iter() .await?
.filter(|policy| policy.created_time > user.last_acknowledged_policy_change) .into_iter()
.map(Into::into) .filter(|policy| policy.created_time > user.last_acknowledged_policy_change)
.collect() .map(Into::into)
.collect(),
)
}; };
// Find all relationships to the user. // Find all relationships to the user.
@@ -120,12 +124,7 @@ impl State {
.unwrap_or_default(); .unwrap_or_default();
// Fetch all memberships with their corresponding servers. // Fetch all memberships with their corresponding servers.
let members: Vec<Member> = db.fetch_all_memberships(&user.id).await?; let mut members: Vec<Member> = db.fetch_all_memberships(&user.id).await?;
self.cache.members = members
.iter()
.cloned()
.map(|x| (x.id.server.clone(), x))
.collect();
let server_ids: Vec<String> = members.iter().map(|x| x.id.server.clone()).collect(); let server_ids: Vec<String> = members.iter().map(|x| x.id.server.clone()).collect();
let servers = db.fetch_servers(&server_ids).await?; let servers = db.fetch_servers(&server_ids).await?;
@@ -154,6 +153,51 @@ impl State {
} }
} }
let voice_states = if fields.voice_states {
let mut voice_state_server_members: HashMap<String, HashSet<String>> = HashMap::new();
// fetch voice states for all the channels we can see
let mut voice_states = Vec::new();
for channel in channels.iter().filter(|c| {
matches!(
c,
Channel::DirectMessage { .. }
| Channel::Group { .. }
| Channel::TextChannel { voice: Some(_), .. }
)
}) {
if let Ok(Some(voice_state)) = get_channel_voice_state(channel).await {
if let Some(server) = channel.server() {
let set = voice_state_server_members.entry(server.to_string()).or_default();
for participant in &voice_state.participants {
user_ids.insert(participant.id.clone());
set.insert(participant.id.clone());
}
} else {
for participant in &voice_state.participants {
user_ids.insert(participant.id.clone());
}
}
voice_states.push(voice_state);
}
}
// Fetch all the members for for the participants who are in a server
for (server, user_ids) in voice_state_server_members {
let user_ids = user_ids.into_iter().collect::<Vec<_>>();
let voice_members = db.fetch_members(&server, &user_ids).await?;
members.extend(voice_members);
}
Some(voice_states)
} else {
None
};
// Fetch presence data for known users. // Fetch presence data for known users.
let online_ids = filter_online(&user_ids.iter().cloned().collect::<Vec<String>>()).await; let online_ids = filter_online(&user_ids.iter().cloned().collect::<Vec<String>>()).await;
@@ -167,8 +211,14 @@ impl State {
) )
.await?; .await?;
self.cache.members = members
.iter()
.cloned()
.map(|x| (x.id.server.clone(), x))
.collect();
// Fetch customisations. // Fetch customisations.
let emojis = if fields.contains(&ReadyPayloadFields::Emoji) { let emojis = if fields.emojis {
Some( Some(
db.fetch_emoji_by_parent_ids( db.fetch_emoji_by_parent_ids(
&servers &servers
@@ -176,25 +226,34 @@ impl State {
.map(|x| x.id.to_string()) .map(|x| x.id.to_string())
.collect::<Vec<String>>(), .collect::<Vec<String>>(),
) )
.await?, .await?
.into_iter()
.map(|emoji| emoji.into())
.collect(),
) )
} else { } else {
None None
}; };
// Fetch user settings // Fetch user settings
let user_settings = if let Some(ReadyPayloadFields::UserSettings(keys)) = fields let user_settings = if !fields.user_settings.is_empty() {
.iter() Some(
.find(|e| matches!(e, ReadyPayloadFields::UserSettings(_))) db.fetch_user_settings(&user.id, &fields.user_settings)
{ .await?,
Some(db.fetch_user_settings(&user.id, keys).await?) )
} else { } else {
None None
}; };
// Fetch channel unreads // Fetch channel unreads
let channel_unreads = if fields.contains(&ReadyPayloadFields::ChannelUnreads) { let channel_unreads = if fields.channel_unreads {
Some(db.fetch_unreads(&user.id).await?) Some(
db.fetch_unreads(&user.id)
.await?
.into_iter()
.map(|unread| unread.into())
.collect(),
)
} else { } else {
None None
}; };
@@ -241,30 +300,27 @@ impl State {
} }
Ok(EventV1::Ready { Ok(EventV1::Ready {
users: if fields.contains(&ReadyPayloadFields::Users) { users: if fields.users { Some(users) } else { None },
Some(users) servers: if fields.servers {
} else {
None
},
servers: if fields.contains(&ReadyPayloadFields::Servers) {
Some(servers.into_iter().map(Into::into).collect()) Some(servers.into_iter().map(Into::into).collect())
} else { } else {
None None
}, },
channels: if fields.contains(&ReadyPayloadFields::Channels) { channels: if fields.channels {
Some(channels.into_iter().map(Into::into).collect()) Some(channels.into_iter().map(Into::into).collect())
} else { } else {
None None
}, },
members: if fields.contains(&ReadyPayloadFields::Members) { members: if fields.members {
Some(members.into_iter().map(Into::into).collect()) Some(members.into_iter().map(Into::into).collect())
} else { } else {
None None
}, },
emojis: emojis.map(|vec| vec.into_iter().map(Into::into).collect()), voice_states,
emojis,
user_settings, user_settings,
channel_unreads: channel_unreads.map(|vec| vec.into_iter().map(Into::into).collect()), channel_unreads,
policy_changes, policy_changes,
}) })
@@ -279,19 +335,14 @@ impl State {
let id = &id.to_string(); let id = &id.to_string();
for (channel_id, channel) in &self.cache.channels { for (channel_id, channel) in &self.cache.channels {
match channel { if channel.server() == Some(id) {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { channel_ids.insert(channel_id.clone());
if server == id {
channel_ids.insert(channel_id.clone());
if self.cache.can_view_channel(db, channel).await { if self.cache.can_view_channel(db, channel).await {
added_channels.push(channel_id.clone()); added_channels.push(channel_id.clone());
} else { } else {
removed_channels.push(channel_id.clone()); removed_channels.push(channel_id.clone());
}
}
} }
_ => {}
} }
} }
@@ -459,6 +510,7 @@ impl State {
server, server,
channels, channels,
emojis: _, emojis: _,
voice_states: _,
} => { } => {
self.insert_subscription(id.clone()).await; self.insert_subscription(id.clone()).await;
+20 -20
View File
@@ -14,6 +14,7 @@ use futures::{
FutureExt, SinkExt, StreamExt, TryStreamExt, FutureExt, SinkExt, StreamExt, TryStreamExt,
}; };
use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI}; use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_config::report_internal_error;
use revolt_database::{ use revolt_database::{
events::{client::EventV1, server::ClientMessage}, events::{client::EventV1, server::ClientMessage},
iso8601_timestamp::Timestamp, iso8601_timestamp::Timestamp,
@@ -26,7 +27,7 @@ use async_std::{
sync::{Mutex, RwLock}, sync::{Mutex, RwLock},
task::spawn, task::spawn,
}; };
use revolt_result::{create_error, ToRevoltError}; use revolt_result::create_error;
use sentry::Level; use sentry::Level;
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback}; use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
@@ -109,21 +110,21 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
let user_id = state.cache.user_id.clone(); let user_id = state.cache.user_id.clone();
// Notify socket we have authenticated. // Notify socket we have authenticated.
if write.send(config.encode(&EventV1::Authenticated)).await.to_internal_error().is_err() { if report_internal_error!(write.send(config.encode(&EventV1::Authenticated)).await).is_err() {
return; return;
} }
// Download required data to local cache and send Ready payload. // Download required data to local cache and send Ready payload.
let ready_payload = match state let ready_payload = match report_internal_error!(
state
.generate_ready_payload(db, config.get_ready_payload_fields()) .generate_ready_payload(db, config.get_ready_payload_fields())
.await .await
.to_internal_error() ) {
{
Ok(ready_payload) => ready_payload, Ok(ready_payload) => ready_payload,
Err(_) => return, Err(_) => return,
}; };
if write.send(config.encode(&ready_payload)).await.to_internal_error().is_err() { if report_internal_error!(write.send(config.encode(&ready_payload)).await).is_err() {
return; return;
} }
@@ -218,15 +219,14 @@ async fn listener(
write: &Mutex<WsWriter>, write: &Mutex<WsWriter>,
) { ) {
let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap(); let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap();
let subscriber = match fred::types::Builder::from_config(redis_config) let subscriber = match report_internal_error!(
.build_subscriber_client() fred::types::Builder::from_config(redis_config).build_subscriber_client()
.to_internal_error() ) {
{
Ok(subscriber) => subscriber, Ok(subscriber) => subscriber,
Err(_) => return, Err(_) => return,
}; };
if subscriber.init().await.to_internal_error().is_err() { if report_internal_error!(subscriber.init().await).is_err() {
return; return;
} }
@@ -249,13 +249,13 @@ async fn listener(
// Check for state changes for subscriptions. // Check for state changes for subscriptions.
match state.apply_state().await { match state.apply_state().await {
SubscriptionStateChange::Reset => { SubscriptionStateChange::Reset => {
if subscriber.unsubscribe_all().await.to_internal_error().is_err() { if report_internal_error!(subscriber.unsubscribe_all().await).is_err() {
break 'out; break 'out;
} }
let subscribed = state.subscribed.read().await; let subscribed = state.subscribed.read().await;
for id in subscribed.iter() { for id in subscribed.iter() {
if subscriber.subscribe(id).await.to_internal_error().is_err() { if report_internal_error!(subscriber.subscribe(id).await).is_err() {
break 'out; break 'out;
} }
} }
@@ -268,7 +268,7 @@ async fn listener(
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
info!("{addr:?} unsubscribing from {id}"); info!("{addr:?} unsubscribing from {id}");
if subscriber.unsubscribe(id).await.to_internal_error().is_err() { if report_internal_error!(subscriber.unsubscribe(id).await).is_err() {
break 'out; break 'out;
} }
} }
@@ -277,7 +277,7 @@ async fn listener(
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
info!("{addr:?} subscribing to {id}"); info!("{addr:?} subscribing to {id}");
if subscriber.subscribe(id).await.to_internal_error().is_err() { if report_internal_error!(subscriber.subscribe(id).await).is_err() {
break 'out; break 'out;
} }
} }
@@ -302,7 +302,7 @@ async fn listener(
_ = t2 => {}, _ = t2 => {},
message = t1 => { message = t1 => {
// Handle incoming events. // Handle incoming events.
let message = match message.to_internal_error() { let message = match report_internal_error!(message) {
Ok(message) => message, Ok(message) => message,
Err(_) => break 'out Err(_) => break 'out
}; };
@@ -311,15 +311,15 @@ async fn listener(
PayloadType::Json => message PayloadType::Json => message
.value .value
.as_str() .as_str()
.and_then(|s| serde_json::from_str::<EventV1>(s.as_ref()).to_internal_error().ok()), .and_then(|s| report_internal_error!(serde_json::from_str::<EventV1>(s.as_ref())).ok()),
PayloadType::Msgpack => message PayloadType::Msgpack => message
.value .value
.as_bytes() .as_bytes()
.and_then(|b| rmp_serde::from_slice::<EventV1>(b).to_internal_error().ok()), .and_then(|b| report_internal_error!(rmp_serde::from_slice::<EventV1>(b)).ok()),
PayloadType::Bincode => message PayloadType::Bincode => message
.value .value
.as_bytes() .as_bytes()
.and_then(|b| bincode::deserialize::<EventV1>(b).to_internal_error().ok()), .and_then(|b| report_internal_error!(bincode::deserialize::<EventV1>(b)).ok()),
}; };
let Some(mut event) = event else { let Some(mut event) = event else {
@@ -379,7 +379,7 @@ async fn listener(
} }
} }
subscriber.quit().await.to_internal_error().ok(); report_internal_error!(subscriber.quit().await).ok();
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "revolt-coalesced"
version = "0.9.1"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"]
description = "Revolt Backend: Coalescion service"
[features]
tokio = ["dep:tokio"]
queue = ["dep:indexmap"]
cache = ["dep:lru"]
default = ["tokio"]
[dependencies]
tokio = { version = "1.47.0", features = ["sync"], optional = true }
indexmap = { version = "*", optional = true }
lru = { version = "*", optional = true }
[dev-dependencies]
tokio = { version = "1.47.0", features = ["rt", "rt-multi-thread", "macros", "time"] }
+9
View File
@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 Pawel Makles
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.
+24
View File
@@ -0,0 +1,24 @@
#[derive(Clone, PartialEq, Eq, Debug)]
/// Config values for [`CoalescionService`].
pub struct CoalescionServiceConfig {
/// How many tasks are running at once
pub max_concurrent: Option<usize>,
/// Whether to queue tasks once `max_concurrent` is reached
#[cfg(feature = "queue")]
pub queue_requests: bool,
/// Max amount of tasks in the buffer queue
#[cfg(feature = "queue")]
pub max_queue: Option<usize>,
}
impl Default for CoalescionServiceConfig {
fn default() -> Self {
Self {
max_concurrent: Some(100),
#[cfg(feature = "queue")]
queue_requests: true,
#[cfg(feature = "queue")]
max_queue: Some(100)
}
}
}
+27
View File
@@ -0,0 +1,27 @@
use std::fmt;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
/// Coalescion service error.
pub enum Error {
/// Failed to receive the actions return from the channel for unknown reason
RecvError,
/// Reached the `max_concurrent` amount of actions running at once and could not queue the action
MaxConcurrent,
/// Reached the `max_queue` amount of actions in the queue
MaxQueue,
/// Failed to downcast the type to the current type being returned, this will be most likely an ID collision
DowncastError,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::RecvError => write!(f, "Unable to receive data from the channel"),
Error::MaxConcurrent => write!(f, "Max number of tasks running at once"),
Error::MaxQueue => write!(f, "Max number of tasks in queue"),
Error::DowncastError => write!(f, "Failed to downcast type, possible key collision with different types")
}
}
}
impl std::error::Error for Error {}
+39
View File
@@ -0,0 +1,39 @@
//! # Coalesced
//!
//! Coalescion service to group, caching and queue duplicate actions.
//! useful for deduplicating web requests, database lookups and other similar resource
//! intensive or rate-limited actions.
//!
//! ## Features
//! - `tokio`: Uses tokio for the async backend, this is currently the only backend.
//! - `queue`: Whether to support queueing requests to only allow X amount of actions running at once.
//! - `cache`: Whether to cache the actions results for future actions with the same id, uses an LRU cache internally.
//!
//! [`CoalescionService`] uses both [`Arc`] and [`RwLock`] internally and can be cheaply cloned to
//! use in your codebase.
//!
//! It is common practice to wrap the service and in your own which delegates the executions to ensure all ids are tracked in one location across your codebase.
//!
//! All values are stored using [`Any`] and must be [`'static`] + [`Send`] + [`Sync`], if there is an id mismatch
//! and a type is wrong the library will return an error, values returned from the service are also
//! wrapped in an [`Arc`] as they are shared to each duplicate action.
//!
//! ## Example:
//! ```rs
//! use revolt_coalesced::CoalescionService;
//!
//! let service = CoalescionService::new();
//!
//! let user_id = "my_user_id";
//! let user = service.execute(user_id, || async move {
//! database.fetch_user(user_id).await.unwrap()
//! }).await;
//! ```
mod config;
mod error;
mod service;
pub use config::CoalescionServiceConfig;
pub use error::Error;
pub use service::CoalescionService;
+208
View File
@@ -0,0 +1,208 @@
use std::{any::Any, collections::HashMap, fmt::Debug, future::Future, hash::Hash, sync::Arc};
use tokio::sync::{
watch::{channel as watch_channel, Receiver},
RwLock,
};
#[cfg(feature = "cache")]
use lru::LruCache;
#[cfg(feature = "queue")]
use indexmap::IndexMap;
use crate::{CoalescionServiceConfig, Error};
#[derive(Debug, Clone)]
#[allow(clippy::type_complexity)]
/// # Coalescion service
///
/// See module description for example usage.
pub struct CoalescionService<Id: Hash + Clone + Eq> {
config: Arc<CoalescionServiceConfig>,
watchers: Arc<RwLock<HashMap<Id, Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>>>>,
#[cfg(feature = "queue")]
queue: Arc<RwLock<IndexMap<Id, Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>>>>,
#[cfg(feature = "cache")]
cache: Option<Arc<tokio::sync::Mutex<LruCache<Id, Arc<dyn Any + Send + Sync>>>>>,
}
impl<Id: Hash + Clone + Eq> CoalescionService<Id> {
pub fn new() -> Self {
Default::default()
}
pub fn from_config(config: CoalescionServiceConfig) -> Self {
Self {
config: Arc::new(config),
watchers: Arc::new(RwLock::new(HashMap::new())),
#[cfg(feature = "queue")]
queue: Arc::new(RwLock::new(IndexMap::new())),
#[cfg(feature = "cache")]
cache: None,
}
}
#[cfg(feature = "cache")]
pub fn from_cache(
config: CoalescionServiceConfig,
cache: LruCache<Id, Arc<dyn Any + Send + Sync>>,
) -> Self {
Self {
cache: Some(Arc::new(Mutex::new(cache))),
..Self::from_config(config)
}
}
async fn wait_for<Value: Any + Send + Sync>(
&self,
mut receiver: Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>,
) -> Result<Arc<Value>, Error> {
receiver
.wait_for(|v| v.is_some())
.await
.map_err(|_| Error::RecvError)
.and_then(|r| r.clone().unwrap())
.and_then(|arc| Arc::downcast(arc).map_err(|_| Error::DowncastError))
}
async fn insert_and_execute<
Value: Send + Sync + 'static,
F: FnOnce() -> Fut,
Fut: Future<Output = Value>,
>(
&self,
id: Id,
func: F,
) -> Result<Arc<Value>, Error> {
let (send, recv) = watch_channel(None);
self.watchers.write().await.insert(id.clone(), recv);
let value = Ok(Arc::new(func().await));
send.send_modify(|opt| {
opt.replace(value.clone().map(|v| v as Arc<dyn Any + Send + Sync>));
});
#[cfg(feature = "cache")]
if let Some(cache) = self.cache.as_ref() {
if let Ok(value) = &value {
cache.lock().await.push(id.clone(), value.clone());
}
};
self.watchers.write().await.remove(&id);
value
}
/// Coalesces an function, the actual function may not run if one with the same id is already running,
/// queued to be ran, or cached, the id should be globally unique for this specific action.
pub async fn execute<
Value: Send + Sync + 'static,
F: FnOnce() -> Fut,
Fut: Future<Output = Value>,
>(
&self,
id: Id,
func: F,
) -> Result<Arc<Value>, Error> {
#[cfg(feature = "cache")]
if let Some(cache) = self.cache.as_ref() {
if let Some(value) = cache.lock().await.get(&id) {
return Arc::downcast::<Value>(value.clone()).map_err(|_| Error::DowncastError);
}
};
let (receiver, length) = {
let watchers = self.watchers.read().await;
let length = watchers.len();
(watchers.get(&id).cloned(), length)
};
if let Some(receiver) = receiver {
self.wait_for(receiver).await
} else {
match self.config.max_concurrent {
Some(max_concurrent) if length >= max_concurrent => {
#[cfg(feature = "queue")]
if self.config.queue_requests {
let (receiver, length) = {
let queue = self.queue.read().await;
(queue.get(&id).cloned(), queue.len())
};
if let Some(receiver) = receiver {
return self.wait_for(receiver).await;
} else {
if self
.config
.max_queue
.is_some_and(|max_queue| max_queue >= length)
{
return Err(Error::MaxQueue);
};
let (send, recv) = watch_channel(None);
self.queue.write().await.insert(id.clone(), recv);
loop {
let length = self.watchers.read().await.len();
if length < max_concurrent {
let first_key = {
let queue = self.queue.read().await;
queue.first().map(|v| v.0).cloned()
};
if first_key == Some(id.clone()) {
self.queue.write().await.shift_remove(&id);
let response = self.insert_and_execute(id, func).await;
send.send_modify(|opt| {
opt.replace(
response
.clone()
.map(|v| v as Arc<dyn Any + Send + Sync>),
);
});
return response;
}
}
}
}
} else {
Err(Error::MaxConcurrent)
}
#[cfg(not(feature = "queue"))]
Err(Error::MaxConcurrent)
}
_ => self.insert_and_execute(id, func).await,
}
}
}
/// Fetches the amount of currently running tasks
pub async fn current_task_count(&self) -> usize {
self.watchers.read().await.len()
}
#[cfg(feature = "queue")]
/// Fetches the current length of the queue
pub async fn current_queue_len(&self) -> usize {
self.queue.read().await.len()
}
}
impl<Id: Hash + Clone + Eq> Default for CoalescionService<Id> {
fn default() -> Self {
Self::from_config(CoalescionServiceConfig::default())
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-config" name = "revolt-config"
version = "0.8.8" version = "0.9.1"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -37,4 +37,4 @@ sentry = { version = "0.31.5", optional = true }
sentry-anyhow = { version = "0.38.1", optional = true } sentry-anyhow = { version = "0.38.1", optional = true }
# Core # Core
revolt-result = { version = "0.8.8", path = "../result", optional = true } revolt-result = { version = "0.9.1", path = "../result", optional = true }
+37 -1
View File
@@ -22,6 +22,8 @@ january = "http://local.revolt.chat/january"
voso_legacy = "" voso_legacy = ""
voso_legacy_ws = "" voso_legacy_ws = ""
[hosts.livekit]
[rabbit] [rabbit]
host = "rabbit" host = "rabbit"
port = 5672 port = 5672
@@ -56,6 +58,8 @@ voso_legacy_token = ""
trust_cloudflare = false trust_cloudflare = false
# easypwned endpoint # easypwned endpoint
easypwned = "" easypwned = ""
# Tenor API Key
tenor_key = ""
[api.security.captcha] [api.security.captcha]
# hCaptcha configuration # hCaptcha configuration
@@ -66,8 +70,13 @@ hcaptcha_sitekey = ""
# Maximum concurrent connections (to proxy server) # Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50 max_concurrent_connections = 50
[api.users] [api.livekit]
# How long to ring devices for when calling in dms/groups, in seconds
call_ring_duration = 30
[api.livekit.nodes]
[api.users]
[pushd] [pushd]
# this changes the names of the queues to not overlap # this changes the names of the queues to not overlap
@@ -85,6 +94,7 @@ message_queue = "notifications.origin.message"
mass_mention_queue = "notifications.origin.mass_mention" # handles messages that contain role or everyone mentions mass_mention_queue = "notifications.origin.mass_mention" # handles messages that contain role or everyone mentions
fr_accepted_queue = "notifications.ingest.fr_accepted" # friend request accepted fr_accepted_queue = "notifications.ingest.fr_accepted" # friend request accepted
fr_received_queue = "notifications.ingest.fr_received" # friend request received fr_received_queue = "notifications.ingest.fr_received" # friend request received
dm_call_queue = "notifications.ingest.dm_call" # direct message voice call
generic_queue = "notifications.ingest.generic" # generic messages (title + body) generic_queue = "notifications.ingest.generic" # generic messages (title + body)
ack_queue = "notifications.process.ack" # updates badges for apple devices ack_queue = "notifications.process.ack" # updates badges for apple devices
@@ -228,6 +238,18 @@ message_attachments = 5
# Maximum number of servers the user can create/join # Maximum number of servers the user can create/join
servers = 50 servers = 50
# Maximum audio frequency (Hz) in voice calls
voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Mamimum resolution (width, height) of video streams in voice calls
video_resolution = [1080, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5]
[features.limits.new_user.file_upload_size_limit] [features.limits.new_user.file_upload_size_limit]
# Maximum file size limits (in bytes) # Maximum file size limits (in bytes)
attachments = 20_000_000 attachments = 20_000_000
@@ -255,6 +277,18 @@ message_attachments = 5
# Maximum number of servers the user can create/join # Maximum number of servers the user can create/join
servers = 100 servers = 100
# Maximum audio frequency (Hz) in voice calls
voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Mamimum resolution (width, height) of video streams in voice calls
video_resolution = [1080, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5]
[features.limits.default.file_upload_size_limit] [features.limits.default.file_upload_size_limit]
# Maximum file size limits (in bytes) # Maximum file size limits (in bytes)
attachments = 20_000_000 attachments = 20_000_000
@@ -273,7 +307,9 @@ process_message_delay_limit = 5
# Configuration for Sentry error reporting # Configuration for Sentry error reporting
api = "" api = ""
events = "" events = ""
voice_ingress = ""
files = "" files = ""
proxy = "" proxy = ""
pushd = "" pushd = ""
crond = "" crond = ""
gifbox = ""
+33 -2
View File
@@ -125,8 +125,7 @@ pub struct Hosts {
pub events: String, pub events: String,
pub autumn: String, pub autumn: String,
pub january: String, pub january: String,
pub voso_legacy: String, pub livekit: HashMap<String, String>,
pub voso_legacy_ws: String,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -190,6 +189,7 @@ pub struct ApiSecurity {
pub captcha: ApiSecurityCaptcha, pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool, pub trust_cloudflare: bool,
pub easypwned: String, pub easypwned: String,
pub tenor_key: String,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -197,6 +197,25 @@ pub struct ApiWorkers {
pub max_concurrent_connections: usize, pub max_concurrent_connections: usize,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct ApiLiveKit {
pub call_ring_duration: usize,
pub nodes: HashMap<String, LiveKitNode>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LiveKitNode {
pub url: String,
pub lat: f64,
pub lon: f64,
pub key: String,
pub secret: String,
// whether to hide the node in the nodes list
#[serde(default)]
pub private: bool,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers { pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>, pub early_adopter_cutoff: Option<u64>,
@@ -208,6 +227,7 @@ pub struct Api {
pub smtp: ApiSmtp, pub smtp: ApiSmtp,
pub security: ApiSecurity, pub security: ApiSecurity,
pub workers: ApiWorkers, pub workers: ApiWorkers,
pub livekit: ApiLiveKit,
pub users: ApiUsers, pub users: ApiUsers,
} }
@@ -220,6 +240,7 @@ pub struct Pushd {
// Queues // Queues
pub message_queue: String, pub message_queue: String,
pub mass_mention_queue: String, pub mass_mention_queue: String,
pub dm_call_queue: String,
pub fr_accepted_queue: String, pub fr_accepted_queue: String,
pub fr_received_queue: String, pub fr_received_queue: String,
pub generic_queue: String, pub generic_queue: String,
@@ -250,6 +271,10 @@ impl Pushd {
self.get_routing_key(self.mass_mention_queue.clone()) self.get_routing_key(self.mass_mention_queue.clone())
} }
pub fn get_dm_call_routing_key(&self) -> String {
self.get_routing_key(self.dm_call_queue.clone())
}
pub fn get_fr_accepted_routing_key(&self) -> String { pub fn get_fr_accepted_routing_key(&self) -> String {
self.get_routing_key(self.fr_accepted_queue.clone()) self.get_routing_key(self.fr_accepted_queue.clone())
} }
@@ -317,6 +342,10 @@ pub struct FeaturesLimits {
pub message_length: usize, pub message_length: usize,
pub message_attachments: usize, pub message_attachments: usize,
pub servers: usize, pub servers: usize,
pub voice_quality: u32,
pub video: bool,
pub video_resolution: [u32; 2],
pub video_aspect_ratio: [f32; 2],
pub file_upload_size_limit: HashMap<String, usize>, pub file_upload_size_limit: HashMap<String, usize>,
} }
@@ -361,10 +390,12 @@ pub struct Features {
pub struct Sentry { pub struct Sentry {
pub api: String, pub api: String,
pub events: String, pub events: String,
pub voice_ingress: String,
pub files: String, pub files: String,
pub proxy: String, pub proxy: String,
pub pushd: String, pub pushd: String,
pub crond: String, pub crond: String,
pub gifbox: String,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
+13 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-database" name = "revolt-database"
version = "0.8.8" version = "0.9.1"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -18,25 +18,26 @@ async-std-runtime = ["async-std", "authifier/async-std-runtime"]
rocket-impl = ["rocket", "schemars", "revolt_okapi", "revolt_rocket_okapi", "authifier/rocket_impl"] rocket-impl = ["rocket", "schemars", "revolt_okapi", "revolt_rocket_okapi", "authifier/rocket_impl"]
axum-impl = ["axum"] axum-impl = ["axum"]
redis-is-patched = ["revolt-presence/redis-is-patched"] redis-is-patched = ["revolt-presence/redis-is-patched"]
voice = ["livekit-api", "livekit-protocol", "livekit-runtime"]
# Default Features # Default Features
default = ["mongodb", "async-std-runtime", "tasks"] default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies] [dependencies]
# Core # Core
revolt-config = { version = "0.8.8", path = "../config", features = [ revolt-config = { version = "0.9.1", path = "../config", features = [
"report-macros", "report-macros",
] } ] }
revolt-result = { version = "0.8.8", path = "../result" } revolt-result = { version = "0.9.1", path = "../result" }
revolt-models = { version = "0.8.8", path = "../models", features = [ revolt-models = { version = "0.9.1", path = "../models", features = [
"validator", "validator",
] } ] }
revolt-presence = { version = "0.8.8", path = "../presence" } revolt-presence = { version = "0.9.1", path = "../presence" }
revolt-permissions = { version = "0.8.8", path = "../permissions", features = [ revolt-permissions = { version = "0.9.1", path = "../permissions", features = [
"serde", "serde",
"bson", "bson",
] } ] }
revolt-parser = { version = "0.8.8", path = "../parser" } revolt-parser = { version = "0.9.1", path = "../parser" }
# Utility # Utility
log = "0.4" log = "0.4"
@@ -96,3 +97,8 @@ authifier = { version = "1.0.15" }
# RabbitMQ # RabbitMQ
amqprs = { version = "1.7.0" } amqprs = { version = "1.7.0" }
# Voice
livekit-api = { version = "0.4.4", optional = true}
livekit-protocol = { version = "0.4.0", optional = true }
livekit-runtime = { version = "0.3.1", features = ["tokio"], optional = true }
@@ -47,6 +47,7 @@
], ],
"roles": { "roles": {
"__ID:5__": { "__ID:5__": {
"_id": "__ID:5__",
"name": "Moderator", "name": "Moderator",
"permissions": { "permissions": {
"a": 545270208, "a": 545270208,
@@ -55,6 +56,7 @@
"rank": 1 "rank": 1
}, },
"__ID:6__": { "__ID:6__": {
"_id": "__ID:6__",
"name": "Owner", "name": "Owner",
"permissions": { "permissions": {
"a": 0, "a": 0,
+77 -1
View File
@@ -2,7 +2,8 @@ use std::collections::HashSet;
use crate::events::rabbit::*; use crate::events::rabbit::*;
use crate::User; use crate::User;
use amqprs::channel::BasicPublishArguments; use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments};
use amqprs::connection::OpenConnectionArguments;
use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError}; use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError};
use amqprs::{BasicProperties, FieldTable}; use amqprs::{BasicProperties, FieldTable};
use revolt_models::v0::PushNotification; use revolt_models::v0::PushNotification;
@@ -25,6 +26,35 @@ impl AMQP {
} }
} }
pub async fn new_auto() -> AMQP {
let config = revolt_config::config().await;
let connection = Connection::open(&OpenConnectionArguments::new(
&config.rabbit.host,
config.rabbit.port,
&config.rabbit.username,
&config.rabbit.password,
))
.await
.expect("Failed to connect to RabbitMQ");
let channel = connection
.open_channel(None)
.await
.expect("Failed to open RabbitMQ channel");
channel
.exchange_declare(
ExchangeDeclareArguments::new(&config.pushd.exchange, "direct")
.durable(true)
.finish(),
)
.await
.expect("Failed to declare exchange");
AMQP::new(connection, channel)
}
pub async fn friend_request_accepted( pub async fn friend_request_accepted(
&self, &self,
accepted_request_user: &User, accepted_request_user: &User,
@@ -240,4 +270,50 @@ impl AMQP {
) )
.await .await
} }
/// # DM Call Update
/// Used to send an update about a DM call, eg. start or end of a call.
/// Recipients can be used to narrow the scope of recipients, otherwise all recipients will be notified.
/// `ended` refers to the ringing period, not necessarily the call itself.
pub async fn dm_call_updated(
&self,
initiator_id: &str,
channel_id: &str,
started_at: Option<&str>,
ended: bool,
recipients: Option<Vec<String>>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = InternalDmCallPayload {
payload: DmCallPayload {
initiator_id: initiator_id.to_string(),
channel_id: channel_id.to_string(),
started_at: started_at.map(|f| f.to_string()),
ended,
},
recipients,
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending dm call update payload on channel {}: {}",
config.pushd.get_dm_call_routing_key(),
payload
);
self.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_dm_call_routing_key(),
),
)
.await
}
} }
+1 -1
View File
@@ -35,7 +35,7 @@ pub enum DatabaseInfo {
} }
/// Database /// Database
#[derive(Clone)] #[derive(Clone, Debug)]
pub enum Database { pub enum Database {
/// Mock database /// Mock database
Reference(ReferenceDb), Reference(ReferenceDb),
@@ -11,6 +11,7 @@ use serde::Serialize;
database_derived!( database_derived!(
/// MongoDB implementation /// MongoDB implementation
#[derive(Debug)]
pub struct MongoDb(pub ::mongodb::Client, pub String); pub struct MongoDb(pub ::mongodb::Client, pub String);
); );
@@ -10,7 +10,7 @@ use crate::{
database_derived!( database_derived!(
/// Reference implementation /// Reference implementation
#[derive(Default)] #[derive(Default, Debug)]
pub struct ReferenceDb { pub struct ReferenceDb {
pub bots: Arc<Mutex<HashMap<String, Bot>>>, pub bots: Arc<Mutex<HashMap<String, Bot>>>,
pub channels: Arc<Mutex<HashMap<String, Channel>>>, pub channels: Arc<Mutex<HashMap<String, Channel>>>,
+66 -15
View File
@@ -3,10 +3,7 @@ use revolt_result::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use revolt_models::v0::{ use revolt_models::v0::{
AppendMessage, Channel, ChannelUnread, Emoji, FieldsChannel, FieldsMember, FieldsMessage, AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message,
PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser,
PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, Webhook,
}; };
use crate::Database; use crate::Database;
@@ -20,16 +17,33 @@ pub enum Ping {
} }
/// Fields provided in Ready payload /// Fields provided in Ready payload
#[derive(PartialEq)] #[derive(PartialEq, Debug, Clone, Deserialize)]
pub enum ReadyPayloadFields { pub struct ReadyPayloadFields {
Users, pub users: bool,
Servers, pub servers: bool,
Channels, pub channels: bool,
Members, pub members: bool,
Emoji, pub emojis: bool,
pub voice_states: bool,
pub user_settings: Vec<String>,
pub channel_unreads: bool,
pub policy_changes: bool,
}
UserSettings(Vec<String>), impl Default for ReadyPayloadFields {
ChannelUnreads, fn default() -> Self {
Self {
users: true,
servers: true,
channels: true,
members: true,
emojis: true,
voice_states: true,
user_settings: Vec::new(),
channel_unreads: false,
policy_changes: true,
}
}
} }
/// Protocol Events /// Protocol Events
@@ -57,13 +71,16 @@ pub enum EventV1 {
members: Option<Vec<Member>>, members: Option<Vec<Member>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
emojis: Option<Vec<Emoji>>, emojis: Option<Vec<Emoji>>,
#[serde(skip_serializing_if = "Option::is_none")]
voice_states: Option<Vec<ChannelVoiceState>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
user_settings: Option<UserSettings>, user_settings: Option<UserSettings>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
channel_unreads: Option<Vec<ChannelUnread>>, channel_unreads: Option<Vec<ChannelUnread>>,
policy_changes: Vec<PolicyChange>, #[serde(skip_serializing_if = "Option::is_none")]
policy_changes: Option<Vec<PolicyChange>>,
}, },
/// Ping response /// Ping response
@@ -122,6 +139,7 @@ pub enum EventV1 {
server: Server, server: Server,
channels: Vec<Channel>, channels: Vec<Channel>,
emojis: Vec<Emoji>, emojis: Vec<Emoji>,
voice_states: Vec<ChannelVoiceState>
}, },
/// Update existing server /// Update existing server
@@ -144,7 +162,13 @@ pub enum EventV1 {
}, },
/// User joins server /// User joins server
ServerMemberJoin { id: String, user: String }, ServerMemberJoin {
id: String,
// Deprecated: use member.id.user
#[deprecated = "Use member.id.user instead"]
user: String,
member: Member,
},
/// User left server /// User left server
ServerMemberLeave { ServerMemberLeave {
@@ -248,6 +272,33 @@ pub enum EventV1 {
/// Auth events /// Auth events
Auth(AuthifierEvent), Auth(AuthifierEvent),
/// Voice events
VoiceChannelJoin {
id: String,
state: UserVoiceState,
},
VoiceChannelLeave {
id: String,
user: String,
},
VoiceChannelMove {
user: String,
from: String,
to: String,
state: UserVoiceState
},
UserVoiceStateUpdate {
id: String,
channel_id: String,
data: PartialUserVoiceState,
},
UserMoveVoiceChannel {
node: String,
from: String,
to: String,
token: String,
}
} }
impl EventV1 { impl EventV1 {
+15
View File
@@ -37,6 +37,20 @@ pub struct GenericPayload {
pub user: User, pub user: User,
} }
#[derive(Serialize, Deserialize, Clone)]
pub struct DmCallPayload {
pub initiator_id: String,
pub channel_id: String,
pub started_at: Option<String>,
pub ended: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct InternalDmCallPayload {
pub payload: DmCallPayload,
pub recipients: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")] #[serde(tag = "type", content = "data")]
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
@@ -46,6 +60,7 @@ pub enum PayloadKind {
FRReceived(FRReceivedPayload), FRReceived(FRReceivedPayload),
BadgeUpdate(usize), BadgeUpdate(usize),
Generic(GenericPayload), Generic(GenericPayload),
DmCallStartEnd(DmCallPayload),
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
+2 -2
View File
@@ -1,8 +1,8 @@
use serde::Deserialize; use serde::{Serialize, Deserialize};
use super::client::Ping; use super::client::Ping;
#[derive(Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum ClientMessage { pub enum ClientMessage {
Authenticate { token: String }, Authenticate { token: String },
+4
View File
@@ -112,6 +112,10 @@ pub mod tasks;
mod amqp; mod amqp;
pub use amqp::amqp::AMQP; pub use amqp::amqp::AMQP;
#[cfg(feature = "voice")]
pub mod voice;
/// Utility function to check if a boolean value is false /// Utility function to check if a boolean value is false
pub fn if_false(t: &bool) -> bool { pub fn if_false(t: &bool) -> bool {
!t !t
@@ -9,14 +9,13 @@ use crate::{
bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document}, bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document},
options::FindOptions, options::FindOptions,
}, },
AbstractChannels, AbstractServers, Channel, Invite, MongoDb, User, DISCRIMINATOR_SEARCH_SPACE, AbstractServers, Invite, MongoDb, User, DISCRIMINATOR_SEARCH_SPACE,
}; };
use bson::{oid::ObjectId, to_bson}; use bson::{oid::ObjectId, to_bson};
use futures::StreamExt; use futures::StreamExt;
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use revolt_permissions::DEFAULT_WEBHOOK_PERMISSIONS; use revolt_permissions::{ChannelPermission, DEFAULT_WEBHOOK_PERMISSIONS};
use revolt_result::{Error, ErrorType};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
@@ -26,7 +25,7 @@ struct MigrationInfo {
revision: i32, revision: i32,
} }
pub const LATEST_REVISION: i32 = 42; // MUST BE +1 to last migration pub const LATEST_REVISION: i32 = 50; // MUST BE +1 to last migration
pub async fn migrate_database(db: &MongoDb) { pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations"); let migrations = db.col::<Document>("migrations");
@@ -914,6 +913,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
} }
if revision <= 26 { if revision <= 26 {
// Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
info!("Running migration [revision 26 / 15-05-2024]: fix invites being incorrectly serialized with wrong enum tagging."); info!("Running migration [revision 26 / 15-05-2024]: fix invites being incorrectly serialized with wrong enum tagging.");
auto_derived!( auto_derived!(
@@ -1080,6 +1080,14 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
channel_id: String, channel_id: String,
} }
#[allow(clippy::enum_variant_names)]
#[derive(serde::Serialize, serde::Deserialize)]
enum Channel {
Group { owner: String },
TextChannel { server: String },
VoiceChannel { server: String }
}
let webhooks = db let webhooks = db
.db() .db()
.collection::<WebhookShell>("channel_webhooks") .collection::<WebhookShell>("channel_webhooks")
@@ -1091,8 +1099,8 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.await; .await;
for webhook in webhooks { for webhook in webhooks {
match db.fetch_channel(&webhook.channel_id).await { match db.col::<Channel>("channels").find_one(doc! { "_id": &webhook.channel_id }).await.unwrap() {
Ok(channel) => { Some(channel) => {
let creator_id = match channel { let creator_id = match channel {
Channel::Group { owner, .. } => owner, Channel::Group { owner, .. } => owner,
Channel::TextChannel { server, .. } Channel::TextChannel { server, .. }
@@ -1100,7 +1108,6 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
let server = db.fetch_server(&server).await.expect("server"); let server = db.fetch_server(&server).await.expect("server");
server.owner server.owner
} }
_ => unreachable!("not server or group channel!"),
}; };
db.db() db.db()
@@ -1118,17 +1125,13 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.await .await
.expect("update webhook"); .expect("update webhook");
} }
Err(Error { None => {
error_type: ErrorType::NotFound,
..
}) => {
db.db() db.db()
.collection::<WebhookShell>("channel_webhooks") .collection::<WebhookShell>("channel_webhooks")
.delete_one(doc! { "_id": webhook._id }) .delete_one(doc! { "_id": webhook._id })
.await .await
.expect("failed to delete invalid webhook"); .expect("failed to delete invalid webhook");
} }
Err(err) => panic!("{err:?}"),
} }
} }
} }
@@ -1169,9 +1172,9 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.expect("failed to update users"); .expect("failed to update users");
} }
if revision <= 41 { if revision <= 43 {
info!( info!(
"Running migration [revision 41 / 05-06-2025]: convert role ranks to uniform numbers." "Running migration [revision 43 / 05-06-2025]: convert role ranks to uniform numbers."
); );
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
@@ -1226,6 +1229,83 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
} }
} }
if revision <= 46 {
info!("Running migration [revision 46 / 29-04-2025]: Convert all `VoiceChannel`'s into `TextChannel`");
db.col::<Document>("channels")
.update_many(
doc! { "channel_type": "VoiceChannel" },
doc! {
"$set": {
"channel_type": "TextChannel",
"voice": {}
}
}
)
.await
.expect("Failed to update voice channels");
};
if revision <= 48 {
info!("Running migration [revision 48 / 22-10-2025]: Add Video + Listen to default permissions");
db.col::<Document>("servers")
.update_many(
doc! { },
doc! {
"$bit": {
"default_permissions": {
"or": (ChannelPermission::Video + ChannelPermission::Speak + ChannelPermission::Listen) as i64
},
}
}
)
.await
.expect("Failed to update default_permissions");
};
if revision <= 49 {
info!("Running migration [revision 49 / 12-12-2025]: Add _id key to roles");
#[derive(Serialize, Deserialize, Clone)]
struct Server {
#[serde(rename = "_id")]
pub id: String,
#[serde(default = "HashMap::<String, Document>::new")]
pub roles: HashMap<String, Document>,
}
let mut servers = db
.db()
.collection::<Server>("servers")
.find(doc! {
"roles": {
"$exists": true,
"$ne": {}
}
})
.await
.unwrap()
.map(|res| res.expect("Failed to decode Server { id, roles }"));
while let Some(server) = servers.next().await {
let mut doc = doc! {};
for id in server.roles.keys() {
doc.insert(
format!("roles.{id}._id"),
id,
);
}
db.db()
.collection::<Server>("servers")
.update_one(doc! { "_id": &server.id }, doc! { "$set": doc })
.await
.unwrap();
}
};
// Reminder to update LATEST_REVISION when adding new migrations. // Reminder to update LATEST_REVISION when adding new migrations.
LATEST_REVISION.max(revision) LATEST_REVISION.max(revision)
} }
@@ -69,7 +69,7 @@ impl Invite {
creator: creator.id.clone(), creator: creator.id.clone(),
channel: id.clone(), channel: id.clone(),
}), }),
Channel::TextChannel { id, server, .. } | Channel::VoiceChannel { id, server, .. } => { Channel::TextChannel { id, server, .. } => {
Ok(Invite::Server { Ok(Invite::Server {
code, code,
creator: creator.id.clone(), creator: creator.id.clone(),
@@ -1,4 +1,5 @@
use std::collections::HashMap; #![allow(deprecated)]
use std::{borrow::Cow, collections::HashMap};
use revolt_config::config; use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor}; use revolt_models::v0::{self, MessageAuthor};
@@ -8,8 +9,7 @@ use serde::{Deserialize, Serialize};
use ulid::Ulid; use ulid::Ulid;
use crate::{ use crate::{
events::client::EventV1, Database, File, PartialServer, events::client::EventV1, Database, File, PartialServer, Server, SystemMessage, User, AMQP,
Server, SystemMessage, User, AMQP,
}; };
#[cfg(feature = "mongodb")] #[cfg(feature = "mongodb")]
@@ -106,38 +106,18 @@ auto_derived!(
/// Whether this channel is marked as not safe for work /// Whether this channel is marked as not safe for work
#[serde(skip_serializing_if = "crate::if_false", default)] #[serde(skip_serializing_if = "crate::if_false", default)]
nsfw: bool, nsfw: bool,
/// Voice Information for when this channel is also a voice channel
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<VoiceInformation>,
}, },
/// Voice channel belonging to a server }
VoiceChannel {
/// Unique Id
#[serde(rename = "_id")]
id: String,
/// Id of the server this channel belongs to
server: String,
/// Display name of the channel #[derive(Default)]
name: String, pub struct VoiceInformation {
#[serde(skip_serializing_if = "Option::is_none")] /// Maximium amount of users allowed in the voice channel at once
/// Channel description #[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>, pub max_users: Option<usize>,
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<File>,
/// Default permissions assigned to users in this channel
#[serde(skip_serializing_if = "Option::is_none")]
default_permissions: Option<OverrideField>,
/// Permissions assigned based on role to this channel
#[serde(
default = "HashMap::<String, OverrideField>::new",
skip_serializing_if = "HashMap::<String, OverrideField>::is_empty"
)]
role_permissions: HashMap<String, OverrideField>,
/// Whether this channel is marked as not safe for work
#[serde(skip_serializing_if = "crate::if_false", default)]
nsfw: bool,
},
} }
); );
@@ -164,6 +144,8 @@ auto_derived!(
pub default_permissions: Option<OverrideField>, pub default_permissions: Option<OverrideField>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub last_message_id: Option<String>, pub last_message_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<VoiceInformation>,
} }
/// Optional fields on channel object /// Optional fields on channel object
@@ -171,6 +153,7 @@ auto_derived!(
Description, Description,
Icon, Icon,
DefaultPermissions, DefaultPermissions,
Voice,
} }
); );
@@ -222,16 +205,19 @@ impl Channel {
default_permissions: None, default_permissions: None,
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()),
}, },
v0::LegacyServerChannelType::Voice => Channel::VoiceChannel { v0::LegacyServerChannelType::Voice => Channel::TextChannel {
id: id.clone(), id: id.clone(),
server: server.id.to_owned(), server: server.id.to_owned(),
name: data.name, name: data.name,
description: data.description, description: data.description,
icon: None, icon: None,
last_message_id: None,
default_permissions: None, default_permissions: None,
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()),
}, },
}; };
@@ -432,8 +418,28 @@ impl Channel {
Channel::DirectMessage { id, .. } Channel::DirectMessage { id, .. }
| Channel::Group { id, .. } | Channel::Group { id, .. }
| Channel::SavedMessages { id, .. } | Channel::SavedMessages { id, .. }
| Channel::TextChannel { id, .. } | Channel::TextChannel { id, .. } => id,
| Channel::VoiceChannel { id, .. } => id, }
}
/// Clone this channel's server id
pub fn server(&self) -> Option<&str> {
match self {
Channel::TextChannel { server, .. } => Some(server),
_ => None,
}
}
/// Gets this channel's voice information
pub fn voice(&self) -> Option<Cow<VoiceInformation>> {
match self {
Self::DirectMessage { .. } | Self::Group { .. } => {
Some(Cow::Owned(VoiceInformation::default()))
}
Self::TextChannel {
voice: Some(voice), ..
} => Some(Cow::Borrowed(voice)),
_ => None,
} }
} }
@@ -450,12 +456,6 @@ impl Channel {
server, server,
role_permissions, role_permissions,
.. ..
}
| Channel::VoiceChannel {
id,
server,
role_permissions,
..
} => { } => {
db.set_channel_role_permission(id, role_id, permissions) db.set_channel_role_permission(id, role_id, permissions)
.await?; .await?;
@@ -502,7 +502,7 @@ impl Channel {
clear: remove.into_iter().map(|v| v.into()).collect(), clear: remove.into_iter().map(|v| v.into()).collect(),
} }
.p(match self { .p(match self {
Self::TextChannel { server, .. } | Self::VoiceChannel { server, .. } => server.clone(), Self::TextChannel { server, .. } => server.clone(),
_ => id, _ => id,
}) })
.await; .await;
@@ -514,17 +514,13 @@ impl Channel {
pub fn remove_field(&mut self, field: &FieldsChannel) { pub fn remove_field(&mut self, field: &FieldsChannel) {
match field { match field {
FieldsChannel::Description => match self { FieldsChannel::Description => match self {
Self::Group { description, .. } Self::Group { description, .. } | Self::TextChannel { description, .. } => {
| Self::TextChannel { description, .. }
| Self::VoiceChannel { description, .. } => {
description.take(); description.take();
} }
_ => {} _ => {}
}, },
FieldsChannel::Icon => match self { FieldsChannel::Icon => match self {
Self::Group { icon, .. } Self::Group { icon, .. } | Self::TextChannel { icon, .. } => {
| Self::TextChannel { icon, .. }
| Self::VoiceChannel { icon, .. } => {
icon.take(); icon.take();
} }
_ => {} _ => {}
@@ -533,15 +529,17 @@ impl Channel {
Self::TextChannel { Self::TextChannel {
default_permissions, default_permissions,
.. ..
}
| Self::VoiceChannel {
default_permissions,
..
} => { } => {
default_permissions.take(); default_permissions.take();
} }
_ => {} _ => {}
}, },
FieldsChannel::Voice => match self {
Self::TextChannel { voice, .. } => {
voice.take();
}
_ => {}
},
} }
} }
@@ -553,6 +551,7 @@ impl Channel {
} }
/// Apply partial channel to channel /// Apply partial channel to channel
#[allow(deprecated)]
pub fn apply_options(&mut self, partial: PartialChannel) { pub fn apply_options(&mut self, partial: PartialChannel) {
match self { match self {
Self::SavedMessages { .. } => {} Self::SavedMessages { .. } => {}
@@ -601,15 +600,7 @@ impl Channel {
nsfw, nsfw,
default_permissions, default_permissions,
role_permissions, role_permissions,
.. voice,
}
| Self::VoiceChannel {
name,
description,
icon,
nsfw,
default_permissions,
role_permissions,
.. ..
} => { } => {
if let Some(v) = partial.name { if let Some(v) = partial.name {
@@ -635,6 +626,10 @@ impl Channel {
if let Some(v) = partial.default_permissions { if let Some(v) = partial.default_permissions {
default_permissions.replace(v); default_permissions.replace(v);
} }
if let Some(v) = partial.voice {
voice.replace(v);
}
} }
} }
} }
@@ -777,6 +772,7 @@ impl IntoDocumentPath for FieldsChannel {
FieldsChannel::Description => "description", FieldsChannel::Description => "description",
FieldsChannel::Icon => "icon", FieldsChannel::Icon => "icon",
FieldsChannel::DefaultPermissions => "default_permissions", FieldsChannel::DefaultPermissions => "default_permissions",
FieldsChannel::Voice => "voice",
}) })
} }
} }
@@ -184,7 +184,7 @@ impl AbstractChannels for MongoDb {
async fn delete_channel(&self, channel: &Channel) -> Result<()> { async fn delete_channel(&self, channel: &Channel) -> Result<()> {
let id = channel.id().to_string(); let id = channel.id().to_string();
let server_id = match channel { let server_id = match channel {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => { Channel::TextChannel { server, .. } => {
Some(server) Some(server)
} }
_ => None, _ => None,
@@ -94,9 +94,6 @@ impl AbstractChannels for ReferenceDb {
match &mut channel { match &mut channel {
Channel::TextChannel { Channel::TextChannel {
role_permissions, .. role_permissions, ..
}
| Channel::VoiceChannel {
role_permissions, ..
} => { } => {
if role_permissions.get(role_id).is_some() { if role_permissions.get(role_id).is_some() {
role_permissions.remove(role_id); role_permissions.remove(role_id);
@@ -1,7 +1,7 @@
use bson::to_document; use bson::to_document;
use bson::Document; use bson::Document;
use revolt_config::report_internal_error;
use revolt_result::Result; use revolt_result::Result;
use revolt_result::ToRevoltError;
use crate::File; use crate::File;
use crate::FileUsedFor; use crate::FileUsedFor;
@@ -106,7 +106,7 @@ impl AbstractAttachments for MongoDb {
}, },
doc! { doc! {
"$set": { "$set": {
"used_for": to_document(&used_for).to_internal_error()?, "used_for": report_internal_error!(to_document(&used_for))?,
"uploader_id": uploader_id "uploader_id": uploader_id
} }
}, },
@@ -10,6 +10,7 @@ use revolt_models::v0::{
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
use revolt_result::{ErrorType, Result}; use revolt_result::{ErrorType, Result};
use ulid::Ulid; use ulid::Ulid;
use validator::Validate;
use crate::{ use crate::{
events::client::EventV1, events::client::EventV1,
@@ -113,6 +114,11 @@ auto_derived!(
MessagePinned { id: String, by: String }, MessagePinned { id: String, by: String },
#[serde(rename = "message_unpinned")] #[serde(rename = "message_unpinned")]
MessageUnpinned { id: String, by: String }, MessageUnpinned { id: String, by: String },
#[serde(rename = "call_started")]
CallStarted {
by: String,
finished_at: Option<Timestamp>,
},
} }
/// Name and / or avatar override information /// Name and / or avatar override information
@@ -325,9 +331,7 @@ impl Message {
} }
let server_id = match channel { let server_id = match channel {
Channel::TextChannel { ref server, .. } | Channel::VoiceChannel { ref server, .. } => { Channel::TextChannel { ref server, .. } => Some(server.clone()),
Some(server.clone())
}
_ => None, _ => None,
}; };
@@ -439,7 +443,8 @@ impl Message {
} }
// Verify replies are valid. // Verify replies are valid.
let mut replies = HashSet::new(); let mut replies = Vec::new();
if let Some(entries) = data.replies { if let Some(entries) = data.replies {
if entries.len() > config.features.limits.global.message_replies { if entries.len() > config.features.limits.global.message_replies {
return Err(create_error!(TooManyReplies { return Err(create_error!(TooManyReplies {
@@ -447,6 +452,8 @@ impl Message {
})); }));
} }
replies.reserve(entries.len());
for ReplyIntent { for ReplyIntent {
id, id,
mention, mention,
@@ -460,7 +467,12 @@ impl Message {
user_mentions.insert(message.author.to_owned()); user_mentions.insert(message.author.to_owned());
} }
replies.insert(message.id); // This is O(n^2), but this is faster than a HashSet
// when n < 20; as long as the message_replies limit
// is reasonable, this will be fast.
if !replies.contains(&message.id) {
replies.push(message.id);
}
} }
// If the referenced message doesn't exist and fail_if_not_exists // If the referenced message doesn't exist and fail_if_not_exists
// is set to false, send the message without the reply. // is set to false, send the message without the reply.
@@ -477,6 +489,7 @@ impl Message {
// Validate the mentions go to users in the channel/server // Validate the mentions go to users in the channel/server
if !user_mentions.is_empty() { if !user_mentions.is_empty() {
#[allow(deprecated)]
match channel { match channel {
Channel::DirectMessage { ref recipients, .. } Channel::DirectMessage { ref recipients, .. }
| Channel::Group { ref recipients, .. } => { | Channel::Group { ref recipients, .. } => {
@@ -484,32 +497,34 @@ impl Message {
user_mentions.retain(|m| recipients_hash.contains(m)); user_mentions.retain(|m| recipients_hash.contains(m));
role_mentions.clear(); role_mentions.clear();
} }
Channel::TextChannel { ref server, .. } Channel::TextChannel { ref server, .. }=> {
| Channel::VoiceChannel { ref server, .. } => {
let mentions_vec = Vec::from_iter(user_mentions.iter().cloned()); let mentions_vec = Vec::from_iter(user_mentions.iter().cloned());
let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await?; let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await;
if let Ok(valid_members) = valid_members {
let valid_mentions = HashSet::<&String, RandomState>::from_iter(
valid_members.iter().map(|m| &m.id.user),
);
let valid_mentions = HashSet::<&String, RandomState>::from_iter( user_mentions.retain(|m| valid_mentions.contains(m)); // quick pass, validate mentions are in the server
valid_members.iter().map(|m| &m.id.user),
);
user_mentions.retain(|m| valid_mentions.contains(m)); // quick pass, validate mentions are in the server if !user_mentions.is_empty() {
// if there are still mentions, drill down to a channel-level
let member_channel_view_perms =
BulkDatabasePermissionQuery::from_server_id(db, server)
.await
.channel(&channel)
.members(&valid_members)
.members_can_see_channel()
.await;
if !user_mentions.is_empty() { user_mentions
// if there are still mentions, drill down to a channel-level .retain(|m| *member_channel_view_perms.get(m).unwrap_or(&false));
let member_channel_view_perms = }
BulkDatabasePermissionQuery::from_server_id(db, server) } else {
.await revolt_config::capture_error(&valid_members.unwrap_err());
.channel(&channel) return Err(create_error!(InternalError));
.members(&valid_members)
.members_can_see_channel()
.await;
user_mentions
.retain(|m| *member_channel_view_perms.get(m).unwrap_or(&false));
} }
} }
Channel::SavedMessages { .. } => { Channel::SavedMessages { .. } => {
user_mentions.clear(); user_mentions.clear();
@@ -530,9 +545,7 @@ impl Message {
} }
if !replies.is_empty() { if !replies.is_empty() {
message message.replies.replace(replies);
.replies
.replace(replies.into_iter().collect::<Vec<String>>());
} }
// Calculate final message flags // Calculate final message flags
@@ -674,7 +687,6 @@ impl Message {
) )
.await?; .await?;
if !self.has_suppressed_notifications() if !self.has_suppressed_notifications()
&& (self.mentions.is_some() || self.contains_mass_push_mention()) && (self.mentions.is_some() || self.contains_mass_push_mention())
{ {
@@ -713,6 +725,12 @@ impl Message {
/// Create text embed from sendable embed /// Create text embed from sendable embed
pub async fn create_embed(&self, db: &Database, embed: SendableEmbed) -> Result<Embed> { pub async fn create_embed(&self, db: &Database, embed: SendableEmbed) -> Result<Embed> {
embed.validate().map_err(|error| {
create_error!(FailedValidation {
error: error.to_string()
})
})?;
let media = if let Some(id) = embed.media { let media = if let Some(id) = embed.media {
Some(File::use_attachment(db, &id, &self.id, &self.author).await?) Some(File::use_attachment(db, &id, &self.id, &self.author).await?)
} else { } else {
@@ -784,7 +802,7 @@ impl Message {
query: MessageQuery, query: MessageQuery,
perspective: &User, perspective: &User,
include_users: Option<bool>, include_users: Option<bool>,
server_id: Option<String>, server_id: Option<&str>,
) -> Result<BulkMessageResponse> { ) -> Result<BulkMessageResponse> {
let messages: Vec<v0::Message> = db let messages: Vec<v0::Message> = db
.fetch_messages(query) .fetch_messages(query)
@@ -827,6 +845,7 @@ impl Message {
v0::SystemMessage::MessageUnpinned { by, .. } => { v0::SystemMessage::MessageUnpinned { by, .. } => {
users.push(by.clone()); users.push(by.clone());
} }
v0::SystemMessage::CallStarted { by, .. } => users.push(by.clone()),
} }
} }
users users
@@ -841,7 +860,7 @@ impl Message {
users, users,
members: if let Some(server_id) = server_id { members: if let Some(server_id) = server_id {
Some( Some(
db.fetch_members(&server_id, &user_ids) db.fetch_members(server_id, &user_ids)
.await? .await?
.into_iter() .into_iter()
.map(Into::into) .map(Into::into)
@@ -3,10 +3,18 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use crate::{ use crate::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File, events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel,
Server, SystemMessage, User, Database, File, Server, SystemMessage, User,
}; };
fn default_true() -> bool {
true
}
fn is_true(x: &bool) -> bool {
*x
}
auto_derived_partial!( auto_derived_partial!(
/// Server Member /// Server Member
pub struct Member { pub struct Member {
@@ -30,6 +38,17 @@ auto_derived_partial!(
/// Timestamp this member is timed out until /// Timestamp this member is timed out until
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<Timestamp>, pub timeout: Option<Timestamp>,
/// Whether the member is server-wide voice muted
#[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_publish: bool,
/// Whether the member is server-wide voice deafened
#[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_receive: bool,
// This value only exists in the database, not the models.
// If it is not-None, the database layer should return None to member fetching queries.
// pub pending_deletion_at: Option<Timestamp>
}, },
"PartialMember" "PartialMember"
); );
@@ -50,6 +69,9 @@ auto_derived!(
Avatar, Avatar,
Roles, Roles,
Timeout, Timeout,
CanReceive,
CanPublish,
JoinedAt,
} }
/// Member removal intention /// Member removal intention
@@ -69,6 +91,8 @@ impl Default for Member {
avatar: None, avatar: None,
roles: vec![], roles: vec![],
timeout: None, timeout: None,
can_publish: true,
can_receive: true,
} }
} }
} }
@@ -90,7 +114,7 @@ impl Member {
return Err(create_error!(AlreadyInServer)); return Err(create_error!(AlreadyInServer));
} }
let member = Member { let mut member = Member {
id: MemberCompositeKey { id: MemberCompositeKey {
server: server.id.to_string(), server: server.id.to_string(),
user: user.id.to_string(), user: user.id.to_string(),
@@ -98,7 +122,9 @@ impl Member {
..Default::default() ..Default::default()
}; };
db.insert_member(&member).await?; if let Some(updated) = db.insert_or_merge_member(&member).await? {
member = updated;
}
let should_fetch = channels.is_none(); let should_fetch = channels.is_none();
let mut channels = channels.unwrap_or_default(); let mut channels = channels.unwrap_or_default();
@@ -121,9 +147,20 @@ impl Member {
let emojis = db.fetch_emoji_by_parent_id(&server.id).await?; let emojis = db.fetch_emoji_by_parent_id(&server.id).await?;
#[allow(unused_mut)]
let mut voice_states = Vec::new();
#[cfg(feature = "voice")]
for channel in &channels {
if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state(channel).await {
voice_states.push(voice_state)
}
}
EventV1::ServerMemberJoin { EventV1::ServerMemberJoin {
id: server.id.clone(), id: server.id.clone(),
user: user.id.clone(), user: user.id.clone(),
member: member.clone().into(),
} }
.p(server.id.clone()) .p(server.id.clone())
.await; .await;
@@ -137,6 +174,7 @@ impl Member {
.map(|channel| channel.into()) .map(|channel| channel.into())
.collect(), .collect(),
emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(), emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(),
voice_states
} }
.private(user.id.clone()) .private(user.id.clone())
.await; .await;
@@ -186,10 +224,13 @@ impl Member {
pub fn remove_field(&mut self, field: &FieldsMember) { pub fn remove_field(&mut self, field: &FieldsMember) {
match field { match field {
FieldsMember::JoinedAt => (),
FieldsMember::Avatar => self.avatar = None, FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None, FieldsMember::Nickname => self.nickname = None,
FieldsMember::Roles => self.roles.clear(), FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None, FieldsMember::Timeout => self.timeout = None,
FieldsMember::CanReceive => self.can_receive = true,
FieldsMember::CanPublish => self.can_publish = true,
} }
} }
@@ -224,7 +265,7 @@ impl Member {
intention: RemovalIntention, intention: RemovalIntention,
silent: bool, silent: bool,
) -> Result<()> { ) -> Result<()> {
db.delete_member(&self.id).await?; db.soft_delete_member(&self.id).await?;
EventV1::ServerMemberLeave { EventV1::ServerMemberLeave {
id: self.id.server.to_string(), id: self.id.server.to_string(),
@@ -260,3 +301,74 @@ impl Member {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use iso8601_timestamp::{Duration, Timestamp};
use revolt_models::v0::DataCreateServer;
use crate::{Member, PartialMember, RemovalIntention, Server, User};
#[async_std::test]
async fn muted_member_rejoin() {
database_test!(|db| async move {
match db {
crate::Database::Reference(_) => return,
crate::Database::MongoDb(_) => (),
}
let owner = User::create(&db, "Server Owner".to_string(), None, None)
.await
.unwrap();
let kickable_user = User::create(&db, "Member".to_string(), None, None)
.await
.unwrap();
let server = Server::create(
&db,
DataCreateServer {
name: "Server".to_string(),
description: None,
nsfw: None,
},
&owner,
false,
)
.await
.unwrap()
.0;
Member::create(&db, &server, &owner, None).await.unwrap();
let mut kickable_member = Member::create(&db, &server, &kickable_user, None)
.await
.unwrap()
.0;
kickable_member
.update(
&db,
PartialMember {
timeout: Some(Timestamp::now_utc() + Duration::minutes(5)),
..Default::default()
},
vec![],
)
.await
.unwrap();
assert!(kickable_member.in_timeout());
kickable_member
.remove(&db, &server, RemovalIntention::Kick, false)
.await
.unwrap();
let kickable_member = Member::create(&db, &server, &kickable_user, None)
.await
.unwrap()
.0;
assert!(kickable_member.in_timeout())
});
}
}
@@ -73,13 +73,13 @@ impl ChunkedServerMembersGenerator {
#[async_trait] #[async_trait]
pub trait AbstractServerMembers: Sync + Send { pub trait AbstractServerMembers: Sync + Send {
/// Insert a new server member into the database /// Insert a new server member into the database
async fn insert_member(&self, member: &Member) -> Result<()>; async fn insert_or_merge_member(&self, member: &Member) -> Result<Option<Member>>;
/// Fetch a server member by their id /// Fetch a server member by their id
async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result<Member>; async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result<Member>;
/// Fetch all members in a server /// Fetch all members in a server
async fn fetch_all_members<'a>(&self, server_id: &str) -> Result<Vec<Member>>; async fn fetch_all_members(&self, server_id: &str) -> Result<Vec<Member>>;
/// Fetch all members in a server as an iterator /// Fetch all members in a server as an iterator
async fn fetch_all_members_chunked( async fn fetch_all_members_chunked(
@@ -100,10 +100,10 @@ pub trait AbstractServerMembers: Sync + Send {
) -> Result<ChunkedServerMembersGenerator>; ) -> Result<ChunkedServerMembersGenerator>;
/// Fetch all memberships for a user /// Fetch all memberships for a user
async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result<Vec<Member>>; async fn fetch_all_memberships(&self, user_id: &str) -> Result<Vec<Member>>;
/// Fetch multiple members by their ids /// Fetch multiple members by their ids
async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>>; async fn fetch_members(&self, server_id: &str, ids: &[String]) -> Result<Vec<Member>>;
/// Fetch member count of a server /// Fetch member count of a server
async fn fetch_member_count(&self, server_id: &str) -> Result<usize>; async fn fetch_member_count(&self, server_id: &str) -> Result<usize>;
@@ -119,6 +119,14 @@ pub trait AbstractServerMembers: Sync + Send {
remove: Vec<FieldsMember>, remove: Vec<FieldsMember>,
) -> Result<()>; ) -> Result<()>;
/// Delete a server member by their id /// Marks a user as no longer a member of a server, while retaining the database value.
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()>; /// This is used to keep information such as timeouts in place, but will remove information such as join date and applied roles.
async fn soft_delete_member(&self, id: &MemberCompositeKey) -> Result<()>;
/// Forcibly delete a server member by their id.
/// This will cancel any pending timeouts or other longer term actions, and they will not be reapplied on rejoin.
async fn force_delete_member(&self, id: &MemberCompositeKey) -> Result<()>;
/// Fetch all members who have been marked for deletion.
async fn remove_dangling_members(&self) -> Result<()>;
} }
@@ -1,4 +1,6 @@
use bson::Document;
use futures::StreamExt; use futures::StreamExt;
use iso8601_timestamp::Timestamp;
use mongodb::options::ReadConcern; use mongodb::options::ReadConcern;
use revolt_result::Result; use revolt_result::Result;
@@ -11,9 +13,42 @@ static COL: &str = "server_members";
#[async_trait] #[async_trait]
impl AbstractServerMembers for MongoDb { impl AbstractServerMembers for MongoDb {
/// Insert a new server member into the database /// Insert a new server member (or use the existing member if one is found)
async fn insert_member(&self, member: &Member) -> Result<()> { async fn insert_or_merge_member(&self, member: &Member) -> Result<Option<Member>> {
query!(self, insert_one, COL, &member).map(|_| ()) let existing: Result<Option<Document>> = query!(
self,
find_one,
COL,
doc! {
"_id.server": &member.id.server,
"_id.user": &member.id.user,
"pending_deletion_at": {"$exists": true}
}
);
// Update the existing record if it exist, otherwise make a new record
if existing.is_ok_and(|x| x.is_some()) {
self.col::<Member>(COL)
.find_one_and_update(
doc! {
"_id.server": &member.id.server,
"_id.user": &member.id.user,
},
doc! {
"$set": {
"joined_at": member.joined_at.duration_since(Timestamp::UNIX_EPOCH).whole_seconds(),
},
"$unset": {
"pending_deletion_at": ""
}
},
)
.return_document(mongodb::options::ReturnDocument::After)
.await
.map_err(|_| create_database_error!("update_one", COL))
} else {
query!(self, insert_one, COL, &member).map(|_| ())?;
Ok(None)
}
} }
/// Fetch a server member by their id /// Fetch a server member by their id
@@ -24,18 +59,20 @@ impl AbstractServerMembers for MongoDb {
COL, COL,
doc! { doc! {
"_id.server": server_id, "_id.server": server_id,
"_id.user": user_id "_id.user": user_id,
"pending_deletion_at": {"$exists": false}
} }
)? )?
.ok_or_else(|| create_error!(NotFound)) .ok_or_else(|| create_error!(NotFound))
} }
/// Fetch all members in a server /// Fetch all members in a server
async fn fetch_all_members<'a>(&self, server_id: &str) -> Result<Vec<Member>> { async fn fetch_all_members(&self, server_id: &str) -> Result<Vec<Member>> {
Ok(self Ok(self
.col::<Member>(COL) .col::<Member>(COL)
.find(doc! { .find(doc! {
"_id.server": server_id "_id.server": server_id,
"pending_deletion_at": {"$exists": false}
}) })
.await .await
.map_err(|_| create_database_error!("find", COL))? .map_err(|_| create_database_error!("find", COL))?
@@ -139,11 +176,12 @@ impl AbstractServerMembers for MongoDb {
} }
/// Fetch all memberships for a user /// Fetch all memberships for a user
async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result<Vec<Member>> { async fn fetch_all_memberships(&self, user_id: &str) -> Result<Vec<Member>> {
Ok(self Ok(self
.col::<Member>(COL) .col::<Member>(COL)
.find(doc! { .find(doc! {
"_id.user": user_id "_id.user": user_id,
"pending_deletion_at": {"$exists": false}
}) })
.await .await
.map_err(|_| create_database_error!("find", COL))? .map_err(|_| create_database_error!("find", COL))?
@@ -159,11 +197,12 @@ impl AbstractServerMembers for MongoDb {
} }
/// Fetch multiple members by their ids /// Fetch multiple members by their ids
async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>> { async fn fetch_members(&self, server_id: &str, ids: &[String]) -> Result<Vec<Member>> {
Ok(self Ok(self
.col::<Member>(COL) .col::<Member>(COL)
.find(doc! { .find(doc! {
"_id.server": server_id, "_id.server": server_id,
"pending_deletion_at": {"$exists": false},
"_id.user": { "_id.user": {
"$in": ids "$in": ids
} }
@@ -185,7 +224,8 @@ impl AbstractServerMembers for MongoDb {
async fn fetch_member_count(&self, server_id: &str) -> Result<usize> { async fn fetch_member_count(&self, server_id: &str) -> Result<usize> {
self.col::<Member>(COL) self.col::<Member>(COL)
.count_documents(doc! { .count_documents(doc! {
"_id.server": server_id "_id.server": server_id,
"pending_deletion_at": {"$exists": false}
}) })
.await .await
.map(|c| c as usize) .map(|c| c as usize)
@@ -196,7 +236,8 @@ impl AbstractServerMembers for MongoDb {
async fn fetch_server_count(&self, user_id: &str) -> Result<usize> { async fn fetch_server_count(&self, user_id: &str) -> Result<usize> {
self.col::<Member>(COL) self.col::<Member>(COL)
.count_documents(doc! { .count_documents(doc! {
"_id.user": user_id "_id.user": user_id,
"pending_deletion_at": {"$exists": false}
}) })
.await .await
.map(|c| c as usize) .map(|c| c as usize)
@@ -225,8 +266,42 @@ impl AbstractServerMembers for MongoDb {
.map(|_| ()) .map(|_| ())
} }
/// Marks a member for deletion.
/// This will remove the record if the user has no pending actions (eg. timeout),
/// otherwise will slate the record for deletion by revolt_crond once the actions expire.
async fn soft_delete_member(&self, id: &MemberCompositeKey) -> Result<()> {
let member = self.fetch_member(&id.server, &id.user).await;
if let Ok(member) = member {
if member.in_timeout() {
self.col::<Document>(COL)
.update_many(
doc! {
"_id.server": &id.server,
"_id.user": &id.user,
},
doc! {
"$set": {"pending_deletion_at": format!("{}", member.timeout.unwrap().format())},
"$unset": {
"joined_at": "",
"avatar": "",
"nickname": "",
"roles": ""
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_many", COL))
} else {
self.force_delete_member(id).await
}
} else {
Err(create_database_error!("fetch_member", COL))
}
}
/// Delete a server member by their id /// Delete a server member by their id
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()> { async fn force_delete_member(&self, id: &MemberCompositeKey) -> Result<()> {
query!( query!(
self, self,
delete_one, delete_one,
@@ -238,15 +313,31 @@ impl AbstractServerMembers for MongoDb {
) )
.map(|_| ()) .map(|_| ())
} }
async fn remove_dangling_members(&self) -> Result<()> {
let now = Timestamp::now_utc();
let date = bson::to_bson(&now).expect("Failed to serialize timestamp");
self.col::<Document>(COL)
.delete_many(doc! {
"pending_deletion_at": {"$lt": date}
})
.await
.map(|_| ())
.map_err(|_| create_database_error!("count_documents", COL))
}
} }
impl IntoDocumentPath for FieldsMember { impl IntoDocumentPath for FieldsMember {
fn as_path(&self) -> Option<&'static str> { fn as_path(&self) -> Option<&'static str> {
Some(match self { Some(match self {
FieldsMember::JoinedAt => "joined_at",
FieldsMember::Avatar => "avatar", FieldsMember::Avatar => "avatar",
FieldsMember::Nickname => "nickname", FieldsMember::Nickname => "nickname",
FieldsMember::Roles => "roles", FieldsMember::Roles => "roles",
FieldsMember::Timeout => "timeout", FieldsMember::Timeout => "timeout",
FieldsMember::CanPublish => "can_publish",
FieldsMember::CanReceive => "can_receive",
}) })
} }
} }
@@ -8,13 +8,13 @@ use super::{AbstractServerMembers, ChunkedServerMembersGenerator};
#[async_trait] #[async_trait]
impl AbstractServerMembers for ReferenceDb { impl AbstractServerMembers for ReferenceDb {
/// Insert a new server member into the database /// Insert a new server member into the database
async fn insert_member(&self, member: &Member) -> Result<()> { async fn insert_or_merge_member(&self, member: &Member) -> Result<Option<Member>> {
let mut server_members = self.server_members.lock().await; let mut server_members = self.server_members.lock().await;
if server_members.contains_key(&member.id) { if server_members.contains_key(&member.id) {
Err(create_database_error!("insert", "member")) Err(create_database_error!("insert", "member"))
} else { } else {
server_members.insert(member.id.clone(), member.clone()); server_members.insert(member.id.clone(), member.clone());
Ok(()) Ok(None)
} }
} }
@@ -31,7 +31,7 @@ impl AbstractServerMembers for ReferenceDb {
} }
/// Fetch all members in a server /// Fetch all members in a server
async fn fetch_all_members<'a>(&self, server_id: &str) -> Result<Vec<Member>> { async fn fetch_all_members(&self, server_id: &str) -> Result<Vec<Member>> {
let server_members = self.server_members.lock().await; let server_members = self.server_members.lock().await;
Ok(server_members Ok(server_members
.values() .values()
@@ -105,7 +105,7 @@ impl AbstractServerMembers for ReferenceDb {
} }
/// Fetch all memberships for a user /// Fetch all memberships for a user
async fn fetch_all_memberships<'a>(&self, user_id: &str) -> Result<Vec<Member>> { async fn fetch_all_memberships(&self, user_id: &str) -> Result<Vec<Member>> {
let server_members = self.server_members.lock().await; let server_members = self.server_members.lock().await;
Ok(server_members Ok(server_members
.values() .values()
@@ -115,7 +115,7 @@ impl AbstractServerMembers for ReferenceDb {
} }
/// Fetch multiple members by their ids /// Fetch multiple members by their ids
async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>> { async fn fetch_members(&self, server_id: &str, ids: &[String]) -> Result<Vec<Member>> {
let server_members = self.server_members.lock().await; let server_members = self.server_members.lock().await;
Ok(ids Ok(ids
.iter() .iter()
@@ -169,8 +169,26 @@ impl AbstractServerMembers for ReferenceDb {
} }
} }
/// Soft delete a member
async fn soft_delete_member(&self, id: &MemberCompositeKey) -> Result<()> {
let mut server_members = self.server_members.lock().await;
let member = server_members.get_mut(id);
if let Some(member) = member {
if member.in_timeout() {
panic!("Soft deletion is not implemented.")
} else if server_members.remove(id).is_some() {
Ok(())
} else {
Err(create_error!(NotFound))
}
} else {
Err(create_error!(NotFound))
}
}
/// Delete a server member by their id /// Delete a server member by their id
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()> { async fn force_delete_member(&self, id: &MemberCompositeKey) -> Result<()> {
let mut server_members = self.server_members.lock().await; let mut server_members = self.server_members.lock().await;
if server_members.remove(id).is_some() { if server_members.remove(id).is_some() {
Ok(()) Ok(())
@@ -178,4 +196,8 @@ impl AbstractServerMembers for ReferenceDb {
Err(create_error!(NotFound)) Err(create_error!(NotFound))
} }
} }
async fn remove_dangling_members(&self) -> Result<()> {
todo!()
}
} }
@@ -68,6 +68,9 @@ auto_derived_partial!(
auto_derived_partial!( auto_derived_partial!(
/// Role /// Role
pub struct Role { pub struct Role {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Role name /// Role name
pub name: String, pub name: String,
/// Permissions available to this role /// Permissions available to this role
@@ -246,7 +249,6 @@ impl Server {
role.update( role.update(
db, db,
&self.id, &self.id,
role_id,
PartialRole { PartialRole {
permissions: Some(permissions), permissions: Some(permissions),
..Default::default() ..Default::default()
@@ -297,6 +299,7 @@ impl Role {
/// Into optional struct /// Into optional struct
pub fn into_optional(self) -> PartialRole { pub fn into_optional(self) -> PartialRole {
PartialRole { PartialRole {
id: Some(self.id),
name: Some(self.name), name: Some(self.name),
permissions: Some(self.permissions), permissions: Some(self.permissions),
colour: self.colour, colour: self.colour,
@@ -306,20 +309,29 @@ impl Role {
} }
/// Create a role /// Create a role
pub async fn create(&self, db: &Database, server_id: &str) -> Result<String> { pub async fn create(db: &Database, server: &Server, name: String) -> Result<Self> {
let role_id = Ulid::new().to_string(); let role = Role {
db.insert_role(server_id, &role_id, self).await?; id: Ulid::new().to_string(),
name,
// Rank of the new role should be below the lowest role
rank: server.roles.len() as i64,
colour: None,
hoist: false,
permissions: Default::default(),
};
db.insert_role(&server.id, &role).await?;
EventV1::ServerRoleUpdate { EventV1::ServerRoleUpdate {
id: server_id.to_string(), id: server.id.clone(),
role_id: role_id.to_string(), role_id: role.id.clone(),
data: self.clone().into_optional().into(), data: role.clone().into_optional().into(),
clear: vec![], clear: vec![],
} }
.p(server_id.to_string()) .p(server.id.clone())
.await; .await;
Ok(role_id) Ok(role)
} }
/// Update server data /// Update server data
@@ -327,7 +339,6 @@ impl Role {
&mut self, &mut self,
db: &Database, db: &Database,
server_id: &str, server_id: &str,
role_id: &str,
partial: PartialRole, partial: PartialRole,
remove: Vec<FieldsRole>, remove: Vec<FieldsRole>,
) -> Result<()> { ) -> Result<()> {
@@ -337,14 +348,14 @@ impl Role {
self.apply_options(partial.clone()); self.apply_options(partial.clone());
db.update_role(server_id, role_id, &partial, remove.clone()) db.update_role(server_id, &self.id, &partial, remove.clone())
.await?; .await?;
EventV1::ServerRoleUpdate { EventV1::ServerRoleUpdate {
id: server_id.to_string(), id: server_id.to_string(),
role_id: role_id.to_string(), role_id: self.id.clone(),
data: partial.into(), data: partial.into(),
clear: vec![], clear: remove.into_iter().map(Into::into).collect(),
} }
.p(server_id.to_string()) .p(server_id.to_string())
.await; .await;
@@ -360,15 +371,15 @@ impl Role {
} }
/// Delete a role /// Delete a role
pub async fn delete(self, db: &Database, server_id: &str, role_id: &str) -> Result<()> { pub async fn delete(self, db: &Database, server_id: &str) -> Result<()> {
EventV1::ServerRoleDelete { EventV1::ServerRoleDelete {
id: server_id.to_string(), id: server_id.to_string(),
role_id: role_id.to_string(), role_id: self.id.clone(),
} }
.p(server_id.to_string()) .p(server_id.to_string())
.await; .await;
db.delete_role(server_id, role_id).await db.delete_role(server_id, &self.id).await
} }
} }
@@ -29,7 +29,7 @@ pub trait AbstractServers: Sync + Send {
async fn delete_server(&self, id: &str) -> Result<()>; async fn delete_server(&self, id: &str) -> Result<()>;
/// Insert a new role into server object /// Insert a new role into server object
async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()>; async fn insert_role(&self, server_id: &str, role: &Role) -> Result<()>;
/// Update an existing role on a server /// Update an existing role on a server
async fn update_role( async fn update_role(
@@ -69,7 +69,7 @@ impl AbstractServers for MongoDb {
} }
/// Insert a new role into server object /// Insert a new role into server object
async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()> { async fn insert_role(&self, server_id: &str, role: &Role) -> Result<()> {
self.col::<Document>(COL) self.col::<Document>(COL)
.update_one( .update_one(
doc! { doc! {
@@ -77,7 +77,7 @@ impl AbstractServers for MongoDb {
}, },
doc! { doc! {
"$set": { "$set": {
"roles.".to_owned() + role_id: to_document(role) "roles.".to_owned() + &role.id: to_document(role)
.map_err(|_| create_database_error!("to_document", "role"))? .map_err(|_| create_database_error!("to_document", "role"))?
} }
}, },
@@ -72,10 +72,10 @@ impl AbstractServers for ReferenceDb {
} }
/// Insert a new role into server object /// Insert a new role into server object
async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()> { async fn insert_role(&self, server_id: &str, role: &Role) -> Result<()> {
let mut servers = self.servers.lock().await; let mut servers = self.servers.lock().await;
if let Some(server) = servers.get_mut(server_id) { if let Some(server) = servers.get_mut(server_id) {
server.roles.insert(role_id.to_string(), role.clone()); server.roles.insert(role.id.clone(), role.clone());
Ok(()) Ok(())
} else { } else {
Err(create_error!(NotFound)) Err(create_error!(NotFound))
@@ -1,14 +1,20 @@
use axum::{extract::FromRequestParts, http::request::Parts}; use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
use revolt_result::{create_error, Error, Result}; use revolt_result::{create_error, Error, Result};
use crate::{Database, User}; use crate::{Database, User};
#[async_trait::async_trait] #[async_trait::async_trait]
impl FromRequestParts<Database> for User { impl<S> FromRequestParts<S> for User
where
Database: FromRef<S>,
S: Send + Sync
{
type Rejection = Error; type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, db: &Database) -> Result<User> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<User> {
let db = Database::from_ref(state);
if let Some(Ok(bot_token)) = parts.headers.get("x-bot-token").map(|v| v.to_str()) { if let Some(Ok(bot_token)) = parts.headers.get("x-bot-token").map(|v| v.to_str()) {
let bot = db.fetch_bot_by_token(bot_token).await?; let bot = db.fetch_bot_by_token(bot_token).await?;
db.fetch_user(&bot.id).await db.fetch_user(&bot.id).await
@@ -705,8 +705,6 @@ impl User {
duration_days: Option<usize>, duration_days: Option<usize>,
reason: Option<Vec<String>>, reason: Option<Vec<String>>,
) -> Result<()> { ) -> Result<()> {
// TODO: authifier Error should implement Error
let authifier = db.clone().to_authifier().await; let authifier = db.clone().to_authifier().await;
let mut account = authifier let mut account = authifier
.database .database
@@ -38,7 +38,6 @@ impl<'r> FromRequest<'r> for User {
if let Some(user) = user { if let Some(user) = user {
Outcome::Success(user.clone()) Outcome::Success(user.clone())
} else { } else {
request.local_cache(|| Some(create_error!(InvalidSession)));
Outcome::Error((Status::Unauthorized, authifier::Error::InvalidSession)) Outcome::Error((Status::Unauthorized, authifier::Error::InvalidSession))
} }
} }
+8 -11
View File
@@ -14,7 +14,7 @@ use validator::HasLen;
use revolt_result::Result; use revolt_result::Result;
use super::DelayedTask; use super::DelayedTask;
use crate::Channel::{TextChannel, VoiceChannel}; use crate::Channel::TextChannel;
/// Enumeration of possible events /// Enumeration of possible events
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
@@ -191,17 +191,14 @@ pub async fn handle_ack_event(
.await .await
.expect("Failed to fetch channel from db"); .expect("Failed to fetch channel from db");
match channel { if let TextChannel { server, .. } = channel {
TextChannel { server, .. } | VoiceChannel { server, .. } => { if let Err(err) =
if let Err(err) = amqp.mass_mention_message_sent(server, mass_mentions).await
amqp.mass_mention_message_sent(server, mass_mentions).await {
{ revolt_config::capture_error(&err);
revolt_config::capture_error(&err);
}
}
_ => {
panic!("Unknown channel type when sending mass mention event");
} }
} else {
panic!("Unknown channel type when sending mass mention event");
} }
} }
} }
+45 -38
View File
@@ -143,6 +143,7 @@ impl From<crate::FieldsWebhook> for FieldsWebhook {
} }
impl From<crate::Channel> for Channel { impl From<crate::Channel> for Channel {
#[allow(deprecated)]
fn from(value: crate::Channel) -> Self { fn from(value: crate::Channel) -> Self {
match value { match value {
crate::Channel::SavedMessages { id, user } => Channel::SavedMessages { id, user }, crate::Channel::SavedMessages { id, user } => Channel::SavedMessages { id, user },
@@ -188,6 +189,7 @@ impl From<crate::Channel> for Channel {
default_permissions, default_permissions,
role_permissions, role_permissions,
nsfw, nsfw,
voice,
} => Channel::TextChannel { } => Channel::TextChannel {
id, id,
server, server,
@@ -198,31 +200,14 @@ impl From<crate::Channel> for Channel {
default_permissions, default_permissions,
role_permissions, role_permissions,
nsfw, nsfw,
}, voice: voice.map(|voice| voice.into()),
crate::Channel::VoiceChannel {
id,
server,
name,
description,
icon,
default_permissions,
role_permissions,
nsfw,
} => Channel::VoiceChannel {
id,
server,
name,
description,
icon: icon.map(|file| file.into()),
default_permissions,
role_permissions,
nsfw,
}, },
} }
} }
} }
impl From<Channel> for crate::Channel { impl From<Channel> for crate::Channel {
#[allow(deprecated)]
fn from(value: Channel) -> crate::Channel { fn from(value: Channel) -> crate::Channel {
match value { match value {
Channel::SavedMessages { id, user } => crate::Channel::SavedMessages { id, user }, Channel::SavedMessages { id, user } => crate::Channel::SavedMessages { id, user },
@@ -268,6 +253,7 @@ impl From<Channel> for crate::Channel {
default_permissions, default_permissions,
role_permissions, role_permissions,
nsfw, nsfw,
voice,
} => crate::Channel::TextChannel { } => crate::Channel::TextChannel {
id, id,
server, server,
@@ -278,25 +264,7 @@ impl From<Channel> for crate::Channel {
default_permissions, default_permissions,
role_permissions, role_permissions,
nsfw, nsfw,
}, voice: voice.map(|voice| voice.into()),
Channel::VoiceChannel {
id,
server,
name,
description,
icon,
default_permissions,
role_permissions,
nsfw,
} => crate::Channel::VoiceChannel {
id,
server,
name,
description,
icon: icon.map(|file| file.into()),
default_permissions,
role_permissions,
nsfw,
}, },
} }
} }
@@ -315,6 +283,7 @@ impl From<crate::PartialChannel> for PartialChannel {
role_permissions: value.role_permissions, role_permissions: value.role_permissions,
default_permissions: value.default_permissions, default_permissions: value.default_permissions,
last_message_id: value.last_message_id, last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into())
} }
} }
} }
@@ -332,6 +301,7 @@ impl From<PartialChannel> for crate::PartialChannel {
role_permissions: value.role_permissions, role_permissions: value.role_permissions,
default_permissions: value.default_permissions, default_permissions: value.default_permissions,
last_message_id: value.last_message_id, last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into())
} }
} }
} }
@@ -342,6 +312,7 @@ impl From<FieldsChannel> for crate::FieldsChannel {
FieldsChannel::Description => crate::FieldsChannel::Description, FieldsChannel::Description => crate::FieldsChannel::Description,
FieldsChannel::Icon => crate::FieldsChannel::Icon, FieldsChannel::Icon => crate::FieldsChannel::Icon,
FieldsChannel::DefaultPermissions => crate::FieldsChannel::DefaultPermissions, FieldsChannel::DefaultPermissions => crate::FieldsChannel::DefaultPermissions,
FieldsChannel::Voice => crate::FieldsChannel::Voice,
} }
} }
} }
@@ -352,6 +323,7 @@ impl From<crate::FieldsChannel> for FieldsChannel {
crate::FieldsChannel::Description => FieldsChannel::Description, crate::FieldsChannel::Description => FieldsChannel::Description,
crate::FieldsChannel::Icon => FieldsChannel::Icon, crate::FieldsChannel::Icon => FieldsChannel::Icon,
crate::FieldsChannel::DefaultPermissions => FieldsChannel::DefaultPermissions, crate::FieldsChannel::DefaultPermissions => FieldsChannel::DefaultPermissions,
crate::FieldsChannel::Voice => FieldsChannel::Voice,
} }
} }
} }
@@ -543,6 +515,7 @@ impl From<crate::SystemMessage> for SystemMessage {
crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by }, crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by },
crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by }, crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by },
crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by }, crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by },
crate::SystemMessage::CallStarted { by, finished_at } => Self::CallStarted { by, finished_at }
} }
} }
} }
@@ -639,6 +612,8 @@ impl From<crate::Member> for Member {
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
can_publish: value.can_publish,
can_receive: value.can_receive,
} }
} }
} }
@@ -652,6 +627,8 @@ impl From<Member> for crate::Member {
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
can_publish: value.can_publish,
can_receive: value.can_receive,
} }
} }
} }
@@ -665,6 +642,8 @@ impl From<crate::PartialMember> for PartialMember {
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
can_publish: value.can_publish,
can_receive: value.can_receive,
} }
} }
} }
@@ -678,6 +657,8 @@ impl From<PartialMember> for crate::PartialMember {
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
can_publish: value.can_publish,
can_receive: value.can_receive,
} }
} }
} }
@@ -707,6 +688,9 @@ impl From<crate::FieldsMember> for FieldsMember {
crate::FieldsMember::Nickname => FieldsMember::Nickname, crate::FieldsMember::Nickname => FieldsMember::Nickname,
crate::FieldsMember::Roles => FieldsMember::Roles, crate::FieldsMember::Roles => FieldsMember::Roles,
crate::FieldsMember::Timeout => FieldsMember::Timeout, crate::FieldsMember::Timeout => FieldsMember::Timeout,
crate::FieldsMember::CanReceive => FieldsMember::CanReceive,
crate::FieldsMember::CanPublish => FieldsMember::CanPublish,
crate::FieldsMember::JoinedAt => FieldsMember::JoinedAt,
} }
} }
} }
@@ -718,6 +702,9 @@ impl From<FieldsMember> for crate::FieldsMember {
FieldsMember::Nickname => crate::FieldsMember::Nickname, FieldsMember::Nickname => crate::FieldsMember::Nickname,
FieldsMember::Roles => crate::FieldsMember::Roles, FieldsMember::Roles => crate::FieldsMember::Roles,
FieldsMember::Timeout => crate::FieldsMember::Timeout, FieldsMember::Timeout => crate::FieldsMember::Timeout,
FieldsMember::CanReceive => crate::FieldsMember::CanReceive,
FieldsMember::CanPublish => crate::FieldsMember::CanPublish,
FieldsMember::JoinedAt => crate::FieldsMember::JoinedAt,
} }
} }
} }
@@ -909,6 +896,7 @@ impl From<SystemMessageChannels> for crate::SystemMessageChannels {
impl From<crate::Role> for Role { impl From<crate::Role> for Role {
fn from(value: crate::Role) -> Self { fn from(value: crate::Role) -> Self {
Role { Role {
id: value.id,
name: value.name, name: value.name,
permissions: value.permissions, permissions: value.permissions,
colour: value.colour, colour: value.colour,
@@ -921,6 +909,7 @@ impl From<crate::Role> for Role {
impl From<Role> for crate::Role { impl From<Role> for crate::Role {
fn from(value: Role) -> crate::Role { fn from(value: Role) -> crate::Role {
crate::Role { crate::Role {
id: value.id,
name: value.name, name: value.name,
permissions: value.permissions, permissions: value.permissions,
colour: value.colour, colour: value.colour,
@@ -933,6 +922,7 @@ impl From<Role> for crate::Role {
impl From<crate::PartialRole> for PartialRole { impl From<crate::PartialRole> for PartialRole {
fn from(value: crate::PartialRole) -> Self { fn from(value: crate::PartialRole) -> Self {
PartialRole { PartialRole {
id: value.id,
name: value.name, name: value.name,
permissions: value.permissions, permissions: value.permissions,
colour: value.colour, colour: value.colour,
@@ -945,6 +935,7 @@ impl From<crate::PartialRole> for PartialRole {
impl From<PartialRole> for crate::PartialRole { impl From<PartialRole> for crate::PartialRole {
fn from(value: PartialRole) -> crate::PartialRole { fn from(value: PartialRole) -> crate::PartialRole {
crate::PartialRole { crate::PartialRole {
id: value.id,
name: value.name, name: value.name,
permissions: value.permissions, permissions: value.permissions,
colour: value.colour, colour: value.colour,
@@ -1385,3 +1376,19 @@ impl From<FieldsMessage> for crate::FieldsMessage {
} }
} }
} }
impl From<VoiceInformation> for crate::VoiceInformation {
fn from(value: VoiceInformation) -> Self {
crate::VoiceInformation {
max_users: value.max_users
}
}
}
impl From<crate::VoiceInformation> for VoiceInformation {
fn from(value: crate::VoiceInformation) -> Self {
VoiceInformation {
max_users: value.max_users
}
}
}
@@ -144,10 +144,6 @@ impl<'z> BulkDatabasePermissionQuery<'z> {
Channel::TextChannel { Channel::TextChannel {
default_permissions, default_permissions,
.. ..
}
| Channel::VoiceChannel {
default_permissions,
..
} => default_permissions.unwrap_or_default().into(), } => default_permissions.unwrap_or_default().into(),
_ => Default::default(), _ => Default::default(),
} }
@@ -156,16 +152,14 @@ impl<'z> BulkDatabasePermissionQuery<'z> {
} }
} }
#[allow(dead_code)] #[allow(dead_code, deprecated)]
fn get_channel_type(&mut self) -> ChannelType { fn get_channel_type(&mut self) -> ChannelType {
if let Some(channel) = &self.channel { if let Some(channel) = &self.channel {
match channel { match channel {
Channel::DirectMessage { .. } => ChannelType::DirectMessage, Channel::DirectMessage { .. } => ChannelType::DirectMessage,
Channel::Group { .. } => ChannelType::Group, Channel::Group { .. } => ChannelType::Group,
Channel::SavedMessages { .. } => ChannelType::SavedMessages, Channel::SavedMessages { .. } => ChannelType::SavedMessages,
Channel::TextChannel { .. } | Channel::VoiceChannel { .. } => { Channel::TextChannel { .. } => ChannelType::ServerChannel,
ChannelType::ServerChannel
}
} }
} else { } else {
ChannelType::Unknown ChannelType::Unknown
@@ -179,9 +173,6 @@ impl<'z> BulkDatabasePermissionQuery<'z> {
match channel { match channel {
Channel::TextChannel { Channel::TextChannel {
role_permissions, .. role_permissions, ..
}
| Channel::VoiceChannel {
role_permissions, ..
} => role_permissions, } => role_permissions,
_ => panic!("Not supported for non-server channels"), _ => panic!("Not supported for non-server channels"),
} }
@@ -208,12 +199,6 @@ async fn calculate_members_permissions<'a>(
role_permissions, role_permissions,
default_permissions, default_permissions,
.. ..
}
| Channel::VoiceChannel {
id,
role_permissions,
default_permissions,
..
} => (id, role_permissions, default_permissions), } => (id, role_permissions, default_permissions),
_ => panic!("Calculation of member permissions must be done on a server channel"), _ => panic!("Calculation of member permissions must be done on a server channel"),
}; };
+24
View File
@@ -0,0 +1,24 @@
use crate::Database;
use revolt_result::Result;
/// Formats a user's name depending on their optional features and location.
/// Factors in server display names and user display names before falling back to username#discriminator.
/// Passing a server in which the user is not a member will result in an Err.
pub async fn format_display_name(
db: &Database,
user_id: &str,
server_id: Option<&str>,
) -> Result<String> {
if let Some(server_id) = server_id {
let member = db.fetch_member(server_id, user_id).await?;
if let Some(nick) = member.nickname {
return Ok(nick);
}
}
let user = db.fetch_user(user_id).await?;
if let Some(display) = user.display_name {
return Ok(display);
}
Ok(format!("{}#{}", user.username, user.discriminator))
}
@@ -113,8 +113,6 @@ impl<'r> FromRequest<'r> for IdempotencyKey {
let idempotency = IdempotencyKey { key }; let idempotency = IdempotencyKey { key };
let mut cache = TOKEN_CACHE.lock().await; let mut cache = TOKEN_CACHE.lock().await;
if cache.get(&idempotency.key).is_some() { if cache.get(&idempotency.key).is_some() {
request.local_cache(|| Some(create_error!(DuplicateNonce)));
return Outcome::Error((Status::Conflict, create_error!(DuplicateNonce))); return Outcome::Error((Status::Conflict, create_error!(DuplicateNonce)));
} }
+3
View File
@@ -1,6 +1,9 @@
pub mod bridge; pub mod bridge;
pub mod bulk_permissions; pub mod bulk_permissions;
mod funcs;
pub mod idempotency; pub mod idempotency;
pub mod permissions; pub mod permissions;
pub mod reference; pub mod reference;
pub mod test_fixtures; pub mod test_fixtures;
pub use funcs::*;
+20 -20
View File
@@ -185,9 +185,26 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
} }
} }
async fn do_we_have_publish_overwrites(&mut self) -> bool {
if let Some(member) = &self.member {
member.can_publish
} else {
true
}
}
async fn do_we_have_receive_overwrites(&mut self) -> bool {
if let Some(member) = &self.member {
member.can_receive
} else {
true
}
}
// * For calculating channel permission // * For calculating channel permission
/// Get the type of the channel /// Get the type of the channel
#[allow(deprecated)]
async fn get_channel_type(&mut self) -> ChannelType { async fn get_channel_type(&mut self) -> ChannelType {
if let Some(channel) = &self.channel { if let Some(channel) = &self.channel {
match channel { match channel {
@@ -199,9 +216,7 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
Cow::Borrowed(Channel::SavedMessages { .. }) Cow::Borrowed(Channel::SavedMessages { .. })
| Cow::Owned(Channel::SavedMessages { .. }) => ChannelType::SavedMessages, | Cow::Owned(Channel::SavedMessages { .. }) => ChannelType::SavedMessages,
Cow::Borrowed(Channel::TextChannel { .. }) Cow::Borrowed(Channel::TextChannel { .. })
| Cow::Owned(Channel::TextChannel { .. }) | Cow::Owned(Channel::TextChannel { .. }) => ChannelType::ServerChannel,
| Cow::Borrowed(Channel::VoiceChannel { .. })
| Cow::Owned(Channel::VoiceChannel { .. }) => ChannelType::ServerChannel,
} }
} else { } else {
ChannelType::Unknown ChannelType::Unknown
@@ -225,14 +240,6 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
| Cow::Owned(Channel::TextChannel { | Cow::Owned(Channel::TextChannel {
default_permissions, default_permissions,
.. ..
})
| Cow::Borrowed(Channel::VoiceChannel {
default_permissions,
..
})
| Cow::Owned(Channel::VoiceChannel {
default_permissions,
..
}) => default_permissions.unwrap_or_default().into(), }) => default_permissions.unwrap_or_default().into(),
_ => Default::default(), _ => Default::default(),
} }
@@ -250,12 +257,6 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
}) })
| Cow::Owned(Channel::TextChannel { | Cow::Owned(Channel::TextChannel {
role_permissions, .. role_permissions, ..
})
| Cow::Borrowed(Channel::VoiceChannel {
role_permissions, ..
})
| Cow::Owned(Channel::VoiceChannel {
role_permissions, ..
}) => { }) => {
if let Some(server) = &self.server { if let Some(server) = &self.server {
let member_roles = self let member_roles = self
@@ -343,11 +344,10 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
/// (this will only ever be called for server channels, use unimplemented!() for other code paths) /// (this will only ever be called for server channels, use unimplemented!() for other code paths)
async fn set_server_from_channel(&mut self) { async fn set_server_from_channel(&mut self) {
if let Some(channel) = &self.channel { if let Some(channel) = &self.channel {
#[allow(deprecated)]
match channel { match channel {
Cow::Borrowed(Channel::TextChannel { server, .. }) Cow::Borrowed(Channel::TextChannel { server, .. })
| Cow::Owned(Channel::TextChannel { server, .. }) | Cow::Owned(Channel::TextChannel { server, .. }) => {
| Cow::Borrowed(Channel::VoiceChannel { server, .. })
| Cow::Owned(Channel::VoiceChannel { server, .. }) => {
if let Some(known_server) = if let Some(known_server) =
// I'm not sure why I can't just pattern match both at once here? // I'm not sure why I can't just pattern match both at once here?
// It throws some weird error and the provided fix doesn't work :/ // It throws some weird error and the provided fix doesn't work :/
@@ -39,7 +39,9 @@ pub async fn load_fixture(db: &Database, input: &str) -> HashMap<String, String>
LoadedFixture::User(user) => db.insert_user(&user).await.unwrap(), LoadedFixture::User(user) => db.insert_user(&user).await.unwrap(),
LoadedFixture::Channel(channel) => db.insert_channel(&channel).await.unwrap(), LoadedFixture::Channel(channel) => db.insert_channel(&channel).await.unwrap(),
LoadedFixture::Server(server) => db.insert_server(&server).await.unwrap(), LoadedFixture::Server(server) => db.insert_server(&server).await.unwrap(),
LoadedFixture::ServerMember(member) => db.insert_member(&member).await.unwrap(), LoadedFixture::ServerMember(member) => {
db.insert_or_merge_member(&member).await.unwrap();
}
} }
} }
+598
View File
@@ -0,0 +1,598 @@
use crate::{
events::client::EventV1,
models::{Channel, User},
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, Server,
};
use iso8601_timestamp::{Duration, Timestamp};
use livekit_protocol::ParticipantPermission;
use redis_kiss::{get_connection as _get_connection, redis::Pipeline, AsyncCommands, Conn};
use revolt_config::FeaturesLimits;
use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
use revolt_result::{create_error, Result, ToRevoltError};
mod voice_client;
pub use voice_client::VoiceClient;
async fn get_connection() -> Result<Conn> {
_get_connection().await.map_err(|_| create_error!(InternalError))
}
pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> {
let mut conn = get_connection().await?;
if user.bot.is_some()
// bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to
&& conn.sismember(format!("vc:{}", &user.id), channel_id)
.await
.to_internal_error()?
{
Err(create_error!(AlreadyConnected))
} else if conn
.scard::<_, u32>(format!("vc:{}", &user.id)) // check if the current vc set is empty
.await
.to_internal_error()?
> 0
{
Err(create_error!(AlreadyConnected))
} else {
Ok(())
}
}
pub async fn set_channel_node(channel: &str, node: &str) -> Result<()> {
get_connection()
.await?
.set(format!("node:{channel}"), node)
.await
.to_internal_error()
}
pub async fn get_channel_node(channel: &str) -> Result<Option<String>> {
get_connection()
.await?
.get(format!("node:{channel}"))
.await
.to_internal_error()
}
pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<String>> {
get_connection()
.await?
.smembers(format!("vc:{user_id}"))
.await
.to_internal_error()
}
pub async fn set_user_moved_from_voice(
old_channel: &str,
new_channel: &str,
user_id: &str,
) -> Result<()> {
get_connection()
.await?
.set_ex(
format!("moved_from:{user_id}:{old_channel}"),
new_channel,
10,
)
.await
.to_internal_error()
}
pub async fn get_user_moved_from_voice(channel_id: &str, user_id: &str) -> Result<Option<String>> {
get_connection()
.await?
.get_del(format!("moved_from:{user_id}:{channel_id}"))
.await
.to_internal_error()
}
pub async fn set_user_moved_to_voice(
new_channel: &str,
old_channel: &str,
user_id: &str,
) -> Result<()> {
get_connection()
.await?
.set_ex(format!("moved_to:{user_id}:{new_channel}"), old_channel, 10)
.await
.to_internal_error()
}
pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result<Option<String>> {
get_connection()
.await?
.get_del(format!("moved_to:{user_id}:{channel_id}"))
.await
.to_internal_error()
}
pub async fn is_in_voice_channel(user_id: &str, channel_id: &str) -> Result<bool> {
get_connection()
.await?
.sismember(format!("vc:{user_id}"), channel_id)
.await
.to_internal_error()
}
pub async fn get_user_voice_channel_in_server(
user_id: &str,
server_id: &str,
) -> Result<Option<String>> {
let mut conn = get_connection().await?;
let unique_key = format!("{user_id}:{server_id}");
conn.get(&unique_key).await.to_internal_error()
}
pub fn get_allowed_sources(
limits: &FeaturesLimits,
permissions: PermissionValue,
) -> Vec<&'static str> {
let mut allowed_sources = Vec::new();
if permissions.has(ChannelPermission::Speak as u64) {
allowed_sources.push("microphone")
};
if permissions.has(ChannelPermission::Video as u64) && limits.video {
allowed_sources.extend(["camera", "screen_share", "screen_share_audio"]);
};
allowed_sources
}
pub async fn create_voice_state(
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
joined_at: Timestamp,
) -> Result<UserVoiceState> {
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
let voice_state = UserVoiceState {
joined_at,
id: user_id.to_string(),
is_receiving: true,
is_publishing: false,
screensharing: false,
camera: false,
};
Pipeline::new()
.sadd(format!("vc_members:{channel_id}"), user_id)
.sadd(format!("vc:{user_id}"), channel_id)
.set(&unique_key, channel_id)
.set(
format!("joined_at:{unique_key}"),
joined_at
.duration_since(Timestamp::UNIX_EPOCH)
.whole_milliseconds() as i64,
)
.set(
format!("is_publishing:{unique_key}"),
voice_state.is_publishing,
)
.set(
format!("is_receiving:{unique_key}"),
voice_state.is_receiving,
)
.set(
format!("screensharing:{unique_key}"),
voice_state.screensharing,
)
.set(format!("camera:{unique_key}"), voice_state.camera)
.query_async::<_, ()>(&mut get_connection().await?.into_inner())
.await
.to_internal_error()?;
Ok(voice_state)
}
pub async fn delete_voice_state(
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
) -> Result<()> {
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
Pipeline::new()
.srem(format!("vc_members:{channel_id}"), user_id)
.srem(format!("vc:{user_id}"), channel_id)
.del(&[
format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"),
format!("is_receiving:{unique_key}"),
format!("screensharing:{unique_key}"),
format!("camera:{unique_key}"),
unique_key.clone(),
])
.query_async(&mut get_connection().await?.into_inner())
.await
.to_internal_error()
}
pub async fn delete_channel_voice_state(
channel_id: &str,
server_id: Option<&str>,
user_ids: &[String],
) -> Result<()> {
let parent_id = server_id.unwrap_or(channel_id);
let mut pipeline = Pipeline::new();
pipeline.del(format!("vc_members:{channel_id}"));
for user_id in user_ids {
let unique_key = format!("{user_id}:{parent_id}");
pipeline.srem(format!("vc:{user_id}"), channel_id).del(&[
format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"),
format!("is_receiving:{unique_key}"),
format!("screensharing:{unique_key}"),
format!("camera:{unique_key}"),
unique_key.clone(),
]);
}
pipeline
.query_async(&mut get_connection().await?.into_inner())
.await
.to_internal_error()
}
pub async fn update_voice_state_tracks(
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
added: bool,
track: i32,
) -> Result<PartialUserVoiceState> {
let partial = match track {
/* TrackSource::Unknown */ 0 => PartialUserVoiceState::default(),
/* TrackSource::Camera */
1 => PartialUserVoiceState {
camera: Some(added),
..Default::default()
},
/* TrackSource::Microphone */
2 => PartialUserVoiceState {
is_publishing: Some(added),
..Default::default()
},
/* TrackSource::ScreenShare | TrackSource::ScreenShareAudio */
3 | 4 => PartialUserVoiceState {
screensharing: Some(added),
..Default::default()
},
_ => unreachable!(),
};
update_voice_state(channel_id, server_id, user_id, &partial).await?;
Ok(partial)
}
pub async fn update_voice_state(
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
partial: &PartialUserVoiceState,
) -> Result<()> {
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
let mut pipeline = Pipeline::new();
if let Some(camera) = &partial.camera {
pipeline.set(format!("camera:{unique_key}"), camera);
};
if let Some(is_publishing) = &partial.is_publishing {
pipeline.set(format!("is_publishing:{unique_key}"), is_publishing);
}
if let Some(is_receiving) = &partial.is_receiving {
pipeline.set(format!("is_receiving:{unique_key}"), is_receiving);
}
if let Some(screensharing) = &partial.screensharing {
pipeline.set(format!("screensharing:{unique_key}"), screensharing);
}
pipeline
.query_async(&mut get_connection().await?.into_inner())
.await
.to_internal_error()
}
pub async fn get_voice_channel_members(channel_id: &str) -> Result<Option<Vec<String>>> {
get_connection()
.await?
.smembers::<_, Option<Vec<String>>>(format!("vc_members:{channel_id}"))
.await
.to_internal_error()
.map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) }))
}
pub async fn get_voice_state(
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
) -> Result<Option<UserVoiceState>> {
let unique_key = format!("{}:{}", user_id, server_id.unwrap_or(channel_id));
let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection()
.await?
.mget(&[
format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"),
format!("is_receiving:{unique_key}"),
format!("screensharing:{unique_key}"),
format!("camera:{unique_key}"),
])
.await
.to_internal_error()?;
match (
joined_at,
is_publishing,
is_receiving,
screensharing,
camera,
) {
(
Some(joined_at),
Some(is_publishing),
Some(is_receiving),
Some(screensharing),
Some(camera),
) => Ok(Some(v0::UserVoiceState {
joined_at: Timestamp::UNIX_EPOCH
.checked_add(Duration::milliseconds(joined_at))
.unwrap(),
id: user_id.to_string(),
is_receiving,
is_publishing,
screensharing,
camera,
})),
_ => Ok(None),
}
}
pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::ChannelVoiceState>> {
let members = get_voice_channel_members(channel.id()).await?;
let server = channel.server();
if let Some(members) = members {
let mut participants = Vec::with_capacity(members.len());
for user_id in members {
if let Some(voice_state) = get_voice_state(channel.id(), server, &user_id).await? {
participants.push(voice_state);
} else {
log::info!("Voice state not found but member in voice channel members, removing.");
delete_voice_state(channel.id(), server, &user_id).await?;
}
}
// In case a user voice state failed to be fetched, the vec's capacity will be larger than the length, shrink it
participants.shrink_to_fit();
Ok(Some(v0::ChannelVoiceState {
id: channel.id().to_string(),
participants,
}))
} else {
Ok(None)
}
}
pub async fn move_user(user: &str, from: &str, to: &str) -> Result<()> {
get_connection()
.await?
.smove(
format!("vc-members-{from}"),
format!("vc-members-{to}"),
user,
)
.await
.to_internal_error()
}
pub async fn sync_voice_permissions(
db: &Database,
voice_client: &VoiceClient,
channel: &Channel,
server: Option<&Server>,
role_id: Option<&str>,
) -> Result<()> {
let Some(node) = get_channel_node(channel.id()).await? else {
return Ok(());
};
for user_id in get_voice_channel_members(channel.id())
.await?
.iter()
.flatten()
{
let user = Reference::from_unchecked(user_id).as_user(db).await?;
sync_user_voice_permissions(db, voice_client, &node, &user, channel, server, role_id)
.await?;
}
Ok(())
}
pub async fn sync_user_voice_permissions(
db: &Database,
voice_client: &VoiceClient,
node: &str,
user: &User,
channel: &Channel,
server: Option<&Server>,
role_id: Option<&str>,
) -> Result<()> {
let channel_id = channel.id();
let server_id = server.as_ref().map(|s| s.id.as_str());
let member = match server_id {
Some(server_id) => Some(
Reference::from_unchecked(&user.id)
.as_member(db, server_id)
.await?,
),
None => None,
};
if role_id.is_none_or(|role_id| {
member
.as_ref()
.is_none_or(|member| member.roles.iter().any(|r| r == role_id))
}) {
let Some(voice_state) = get_voice_state(channel_id, server_id, &user.id).await? else {
return Ok(());
};
let mut query = DatabasePermissionQuery::new(db, user)
.channel(channel)
.user(user);
if let (Some(server), Some(member)) = (server, member.as_ref()) {
query = query.member(member).server(server)
}
let permissions = calculate_channel_permissions(&mut query).await;
let limits = user.limits().await;
let mut update_event = PartialUserVoiceState {
id: Some(user.id.clone()),
..Default::default()
};
let before = update_event.clone();
let can_video =
limits.video && permissions.has_channel_permission(ChannelPermission::Video);
let can_speak = permissions.has_channel_permission(ChannelPermission::Speak);
let can_listen = permissions.has_channel_permission(ChannelPermission::Listen);
update_event.camera = voice_state.camera.then_some(can_video);
update_event.screensharing = voice_state.screensharing.then_some(can_video);
update_event.is_publishing = voice_state.is_publishing.then_some(can_speak);
update_voice_state(channel_id, server_id, &user.id, &update_event).await?;
voice_client
.update_permissions(
node,
user,
channel_id,
ParticipantPermission {
can_subscribe: can_listen,
can_publish: can_speak,
can_publish_data: can_speak,
..Default::default()
},
)
.await?;
if update_event != before {
EventV1::UserVoiceStateUpdate {
id: user.id.clone(),
channel_id: channel_id.to_string(),
data: update_event,
}
.p(channel_id.to_string())
.await;
};
};
Ok(())
}
pub async fn set_channel_call_started_system_message(
channel_id: &str,
message_id: &str,
) -> Result<()> {
get_connection()
.await?
.set(format!("call_started_message:{channel_id}"), message_id)
.await
.to_internal_error()
}
pub async fn take_channel_call_started_system_message(channel_id: &str) -> Result<Option<String>> {
get_connection()
.await?
.get_del(format!("call_started_message:{channel_id}"))
.await
.to_internal_error()
}
pub async fn set_call_notification_recipients(
channel_id: &str,
user_id: &str,
recipients: &[String],
) -> Result<()> {
get_connection()
.await?
.set_ex(
format!("call_notification_recipients:{channel_id}-{user_id}"),
recipients,
10,
)
.await
.to_internal_error()
}
pub async fn get_call_notification_recipients(
channel_id: &str,
user_id: &str,
) -> Result<Option<Vec<String>>> {
get_connection()
.await?
.get_del(format!(
"call_notification_recipients:{channel_id}-{user_id}"
))
.await
.to_internal_error()
}
pub async fn remove_user_from_voice_channels(db: &Database, voice_client: &VoiceClient, user_id: &str) -> Result<()> {
for channel_id in get_user_voice_channels(user_id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?;
};
Ok(())
}
pub async fn remove_user_from_voice_channel(db: &Database, voice_client: &VoiceClient, channel_id: &str, user_id: &str) -> Result<()> {
if let Some(node) = get_channel_node(channel_id).await? {
let _ = voice_client.remove_user(&node, user_id, channel_id).await;
}
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
delete_voice_state(channel_id, channel.server(), user_id).await?;
Ok(())
}
pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, server_id: Option<&str>) -> Result<()> {
if let Some(users) = get_voice_channel_members(channel_id).await? {
let node = get_channel_node(channel_id).await?.unwrap();
voice_client.delete_room(&node, channel_id).await?;
delete_channel_voice_state(channel_id, server_id, &users).await?;
};
Ok(())
}
@@ -0,0 +1,156 @@
use crate::{
models::{Channel, User},
Database,
};
use livekit_api::{
access_token::{AccessToken, VideoGrants},
services::room::{CreateRoomOptions, RoomClient as InnerRoomClient, UpdateParticipantOptions},
};
use livekit_protocol::{ParticipantInfo, ParticipantPermission, Room};
use revolt_config::{config, LiveKitNode};
use revolt_permissions::{ChannelPermission, PermissionValue};
use revolt_result::{create_error, Result, ToRevoltError};
use std::{collections::HashMap, time::Duration};
use super::get_allowed_sources;
#[derive(Debug)]
pub struct RoomClient {
pub client: InnerRoomClient,
pub node: LiveKitNode,
}
#[derive(Debug)]
pub struct VoiceClient {
pub rooms: HashMap<String, RoomClient>,
}
impl VoiceClient {
pub fn new(nodes: HashMap<String, LiveKitNode>) -> Self {
Self {
rooms: nodes
.into_iter()
.map(|(name, node)| {
(
name,
RoomClient {
client: InnerRoomClient::with_api_key(
&node.url,
&node.key,
&node.secret,
),
node,
},
)
})
.collect(),
}
}
pub fn is_enabled(&self) -> bool {
!self.rooms.is_empty()
}
pub async fn from_revolt_config() -> Self {
let config = config().await;
Self::new(config.api.livekit.nodes.clone())
}
pub fn get_node(&self, name: &str) -> Result<&RoomClient> {
self.rooms
.get(name)
.ok_or_else(|| create_error!(UnknownNode))
}
pub async fn create_token(
&self,
node: &str,
db: &Database,
user: &User,
permissions: PermissionValue,
channel: &Channel,
) -> Result<String> {
let room = self.get_node(node)?;
let limits = user.limits().await;
let allowed_sources = get_allowed_sources(&limits, permissions);
AccessToken::with_api_key(&room.node.key, &room.node.secret)
.with_name(&format!("{}#{}", user.username, user.discriminator))
.with_identity(&user.id)
.with_metadata(
&serde_json::to_string(&user.clone().into(db, None).await).to_internal_error()?,
)
.with_ttl(Duration::from_secs(10))
.with_grants(VideoGrants {
room_join: true,
can_publish: true,
can_publish_data: false,
can_publish_sources: allowed_sources
.into_iter()
.map(ToString::to_string)
.collect(),
can_subscribe: permissions.has_channel_permission(ChannelPermission::Listen),
room: channel.id().to_string(),
..Default::default()
})
.to_jwt()
.to_internal_error()
}
pub async fn create_room(&self, node: &str, channel: &Channel) -> Result<Room> {
let room = self.get_node(node)?;
room.client
.create_room(
channel.id(),
CreateRoomOptions {
empty_timeout: 5 * 60, // 5 minutes,
..Default::default()
},
)
.await
.to_internal_error()
}
pub async fn update_permissions(
&self,
node: &str,
user: &User,
channel_id: &str,
new_permissions: ParticipantPermission,
) -> Result<ParticipantInfo> {
let room = self.get_node(node)?;
room.client
.update_participant(
channel_id,
&user.id,
UpdateParticipantOptions {
permission: Some(new_permissions),
..Default::default()
},
)
.await
.to_internal_error()
}
pub async fn remove_user(&self, node: &str, user_id: &str, channel_id: &str) -> Result<()> {
let room = self.get_node(node)?;
room.client
.remove_participant(channel_id, user_id)
.await
.to_internal_error()
}
pub async fn delete_room(&self, node: &str, channel_id: &str) -> Result<()> {
let room = self.get_node(node)?;
room.client
.delete_room(channel_id)
.await
.to_internal_error()
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html> <html>
<head> <head>
<!-- Compiled with Bootstrap Email version: 1.5.1 --><meta http-equiv="x-ua-compatible" content="ie=edge"> <!-- Compiled with Bootstrap Email version: 1.6.0 --><meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting"> <meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> <meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
@@ -42,7 +42,7 @@
<tbody> <tbody>
<tr> <tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left"> <td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img alt="Revolt Logo" class="w-24" src="https://app.revolt.chat/assets/logo_round.png" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96"> <img alt="Stoat Logo" class="w-24" src="https://stoat.chat/favicon.svg" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -107,7 +107,7 @@
</table> </table>
<div class="text-muted text-center" style="color: #718096;" align="center"> <div class="text-muted text-center" style="color: #718096;" align="center">
This email is intended for {{email}}<br> This email is intended for {{email}}<br>
Sent from Revolt<br> Sent from Stoat<br>
Made in Europe Made in Europe
</div> </div>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%"> <table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
@@ -1,38 +1,37 @@
<html> <html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <head>
<style> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
/* Add custom classes and styles that you want inlined here */ <style>
</style> /* Add custom classes and styles that you want inlined here */
</head> </style>
<body class="bg-light"> </head>
<div class="container">
<img <body class="bg-light">
alt="Revolt Logo" <div class="container">
class="ax-center my-10 w-24" <img alt="Stoat Logo" class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" />
src="https://app.revolt.chat/assets/logo_round.png" <div class="card p-6 p-lg-10 space-y-4">
/> <h1 class="h3 fw-700">Account Deletion</h1>
<div class="card p-6 p-lg-10 space-y-4"> <p>
<h1 class="h3 fw-700">Account Deletion</h1> You requested to have your account deleted, if you did not perform
<p> this action please take measures to secure your account immediately.
You requested to have your account deleted, if you did not perform </p>
this action please take measures to secure your account immediately. <a class="btn btn-primary p-3 fw-700" href="{{url}}">Confirm</a>
</p>
<a class="btn btn-primary p-3 fw-700" href="{{url}}">Confirm</a>
</div>
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div> </div>
</body> <div class="text-muted text-center my-6">
</html> This email is intended for {{email}}<br />
Sent from Stoat<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -3,7 +3,7 @@ You requested to have your account deleted, if you did not perform this action p
Please navigate to: {{url}} Please navigate to: {{url}}
This email is intended for {{email}} This email is intended for {{email}}
Sent by Revolt Sent by Stoat
Made in Europe Made in Europe
Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales. Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales.
@@ -4,6 +4,6 @@ Please navigate to: {{url}}
This email is intended for {{email}} This email is intended for {{email}}
This email has no association with Revolt or Revolt Platforms Ltd. This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here: Learn more about third party instances here:
https://developers.revolt.chat/faq.html https://developers.stoat.chat/faq.html
@@ -1,7 +1,7 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html> <html>
<head> <head>
<!-- Compiled with Bootstrap Email version: 1.5.1 --><meta http-equiv="x-ua-compatible" content="ie=edge"> <!-- Compiled with Bootstrap Email version: 1.6.0 --><meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting"> <meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> <meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
@@ -42,7 +42,7 @@
<tbody> <tbody>
<tr> <tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left"> <td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img class="w-24" src="https://app.revolt.chat/assets/logo_round.png" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96"> <img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -120,7 +120,7 @@
</table> </table>
<div class="text-muted text-center" style="color: #718096;" align="center"> <div class="text-muted text-center" style="color: #718096;" align="center">
This email is intended for {{email}}<br> This email is intended for {{email}}<br>
Sent from Revolt<br> Sent from Stoat<br>
Made in Europe Made in Europe
</div> </div>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%"> <table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
@@ -9,7 +9,8 @@
<div class="container"> <div class="container">
<img <img
class="ax-center my-10 w-24" class="ax-center my-10 w-24"
src="https://app.revolt.chat/assets/logo_round.png" src="https://stoat.chat/favicon.svg"
alt="Stoat Logo"
/> />
<div class="card p-6 p-lg-10 space-y-4"> <div class="card p-6 p-lg-10 space-y-4">
<h1 class="h3 fw-700">Password Reset</h1> <h1 class="h3 fw-700">Password Reset</h1>
@@ -25,7 +26,7 @@
</div> </div>
<div class="text-muted text-center my-6"> <div class="text-muted text-center my-6">
This email is intended for {{email}}<br /> This email is intended for {{email}}<br />
Sent from Revolt<br /> Sent from Stoat<br />
Made in Europe Made in Europe
</div> </div>
<div class="text-muted text-xs my-6 ax-center"> <div class="text-muted text-xs my-6 ax-center">
@@ -8,7 +8,7 @@ password on it, click below to continue.
Please navigate to: {{url}} Please navigate to: {{url}}
This email is intended for {{email}} This email is intended for {{email}}
Sent by Revolt Sent by Stoat
Made in Europe Made in Europe
Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales. Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales.
+3 -3
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html> <html>
<head> <head>
<!-- Compiled with Bootstrap Email version: 1.5.1 --><meta http-equiv="x-ua-compatible" content="ie=edge"> <!-- Compiled with Bootstrap Email version: 1.6.0 --><meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting"> <meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> <meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
@@ -42,7 +42,7 @@
<tbody> <tbody>
<tr> <tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left"> <td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img class="w-24" src="https://app.revolt.chat/assets/logo_round.png" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96"> <img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -104,7 +104,7 @@
</table> </table>
<div class="text-muted text-center" style="color: #718096;" align="center"> <div class="text-muted text-center" style="color: #718096;" align="center">
This email is intended for {{email}}<br> This email is intended for {{email}}<br>
Sent from Revolt<br> Sent from Stoat<br>
Made in Europe Made in Europe
</div> </div>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%"> <table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
@@ -1,34 +1,34 @@
<html> <html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <head>
<style> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
/* Add custom classes and styles that you want inlined here */ <style>
</style> /* Add custom classes and styles that you want inlined here */
</head> </style>
<body class="bg-light"> </head>
<div class="container">
<img <body class="bg-light">
class="ax-center my-10 w-24" <div class="container">
src="https://app.revolt.chat/assets/logo_round.png" <img class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" />
/> <div class="card p-6 p-lg-10 space-y-4">
<div class="card p-6 p-lg-10 space-y-4"> <h1 class="h3 fw-700">Password Reset</h1>
<h1 class="h3 fw-700">Password Reset</h1> <p>You requested a password reset, click below to continue.</p>
<p>You requested a password reset, click below to continue.</p> <a class="btn btn-primary p-3 fw-700" href="{{url}}">Reset</a>
<a class="btn btn-primary p-3 fw-700" href="{{url}}">Reset</a>
</div>
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div> </div>
</body> <div class="text-muted text-center my-6">
</html> This email is intended for {{email}}<br />
Sent from Stoat<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -3,7 +3,7 @@ You requested a password reset, if you did not perform this action you can safel
Please navigate to: {{url}} Please navigate to: {{url}}
This email is intended for {{email}} This email is intended for {{email}}
Sent by Revolt Sent by Stoat
Made in Europe Made in Europe
Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales. Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales.
@@ -1,7 +1,7 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html> <html>
<head> <head>
<!-- Compiled with Bootstrap Email version: 1.5.1 --><meta http-equiv="x-ua-compatible" content="ie=edge"> <!-- Compiled with Bootstrap Email version: 1.6.0 --><meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting"> <meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> <meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
@@ -42,7 +42,7 @@
<tbody> <tbody>
<tr> <tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left"> <td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img alt="Revolt Logo" class="w-24" src="https://app.revolt.chat/assets/logo_round.png" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96"> <img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -134,7 +134,7 @@
</table> </table>
<div class="text-muted text-center" style="color: #718096;" align="center"> <div class="text-muted text-center" style="color: #718096;" align="center">
This email is intended for {{email}}<br> This email is intended for {{email}}<br>
Sent from Revolt<br> Sent from Stoat<br>
Made in Europe Made in Europe
</div> </div>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%"> <table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
@@ -1,46 +1,45 @@
<html> <html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <head>
<style> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
/* Add custom classes and styles that you want inlined here */ <style>
</style> /* Add custom classes and styles that you want inlined here */
</head> </style>
<body class="bg-light"> </head>
<div class="container">
<img <body class="bg-light">
alt="Revolt Logo" <div class="container">
class="ax-center my-10 w-24" <img class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" />
src="https://app.revolt.chat/assets/logo_round.png" <div class="card p-6 p-lg-10 space-y-4">
/> <h1 class="h3 fw-700">Account Suspended</h1>
<div class="card p-6 p-lg-10 space-y-4"> <p>Your account has been suspended, for one or more reasons:</p>
<h1 class="h3 fw-700">Account Suspended</h1> <ul>
<p>Your account has been suspended, for one or more reasons:</p> {{list}}
<ul> </ul>
{{list}} <p style="display:{{duration_display}}">
</ul> You will be able to use your account again in {{duration}} days.
<p style="display:{{duration_display}}"> </p>
You will be able to use your account again in {{duration}} days. <p>
</p> Further violations may result in a permanent ban depending on
<p> severity, please abide by the
Further violations may result in a permanent ban depending on <a href="https://revolt.chat/aup">Acceptable Usage Policy</a>.
severity, please abide by the </p>
<a href="https://revolt.chat/aup">Acceptable Usage Policy</a>. <p>Ban evasion is prohibited and will be dealt with accordingly.</p>
</p>
<p>Ban evasion is prohibited and will be dealt with accordingly.</p>
</div>
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div> </div>
</body> <div class="text-muted text-center my-6">
</html> This email is intended for {{email}}<br />
Sent from Stoat<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div>
</body>
</html>
@@ -8,7 +8,7 @@ Further violations may result in a permanent ban depending on severity, please a
Ban evasion is prohibited and will be dealt with accordingly. Ban evasion is prohibited and will be dealt with accordingly.
This email is intended for {{email}} This email is intended for {{email}}
Sent by Revolt Sent by Stoat
Made in Europe Made in Europe
Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales. Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales.
+3 -3
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html> <html>
<head> <head>
<!-- Compiled with Bootstrap Email version: 1.5.1 --><meta http-equiv="x-ua-compatible" content="ie=edge"> <!-- Compiled with Bootstrap Email version: 1.6.0 --><meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="x-apple-disable-message-reformatting"> <meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no"> <meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
@@ -42,7 +42,7 @@
<tbody> <tbody>
<tr> <tr>
<td style="line-height: 24px; font-size: 16px; margin: 0;" align="left"> <td style="line-height: 24px; font-size: 16px; margin: 0;" align="left">
<img alt="Revolt Logo" class="w-24" src="https://app.revolt.chat/assets/logo_round.png" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96"> <img class="w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" style="height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; width: 96px; border-style: none; border-width: 0;" width="96">
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -104,7 +104,7 @@
</table> </table>
<div class="text-muted text-center" style="color: #718096;" align="center"> <div class="text-muted text-center" style="color: #718096;" align="center">
This email is intended for {{email}}<br> This email is intended for {{email}}<br>
Sent from Revolt<br> Sent from Stoat<br>
Made in Europe Made in Europe
</div> </div>
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%"> <table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
@@ -1,35 +1,34 @@
<html> <html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <head>
<style> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
/* Add custom classes and styles that you want inlined here */ <style>
</style> /* Add custom classes and styles that you want inlined here */
</head> </style>
<body class="bg-light"> </head>
<div class="container">
<img <body class="bg-light">
alt="Revolt Logo" <div class="container">
class="ax-center my-10 w-24" <img class="ax-center my-10 w-24" src="https://stoat.chat/favicon.svg" alt="Stoat Logo" />
src="https://app.revolt.chat/assets/logo_round.png" <div class="card p-6 p-lg-10 space-y-4">
/> <h1 class="h3 fw-700">Almost there!</h1>
<div class="card p-6 p-lg-10 space-y-4"> <p>To complete your sign up, we just need to verify your email.</p>
<h1 class="h3 fw-700">Almost there!</h1> <a class="btn btn-primary p-3 fw-700" href="{{url}}">Confirm</a>
<p>To complete your sign up, we just need to verify your email.</p>
<a class="btn btn-primary p-3 fw-700" href="{{url}}">Confirm</a>
</div>
<div class="text-muted text-center my-6">
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div> </div>
</body> <div class="text-muted text-center my-6">
</html> This email is intended for {{email}}<br />
Sent from Stoat<br />
Made in Europe
</div>
<div class="text-muted text-xs my-6 ax-center">
Revolt Platforms Ltd. is a company incorporated and registered under the
laws of England and Wales.<br />
Registered Company Number: 16260658<br />
Registered Office:<br />
Suite 5703 Unit 3A, 34-35 Hatton Garden,<br />
Holborn, United Kingdom, EC1N 8DX
</div>
</div>
</body>
</html>
+1 -1
View File
@@ -4,7 +4,7 @@ To complete your sign up, we just need to verify your email.
Please navigate to: {{url}} Please navigate to: {{url}}
This email is intended for {{email}} This email is intended for {{email}}
Sent by Revolt Sent by Stoat
Made in Europe Made in Europe
Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales. Revolt Platforms Ltd. is a company incorporated and registered under the laws of England and Wales.
+4 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-files" name = "revolt-files"
version = "0.8.8" version = "0.9.1"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -14,16 +14,16 @@ imagesize = "0.13.0"
tempfile = "3.12.0" tempfile = "3.12.0"
base64 = "0.22.1" base64 = "0.22.1"
aes-gcm = { version = "0.10.3", features = ["std"] } aes-gcm = "0.10.3"
typenum = "1.17.0" typenum = "1.17.0"
aws-config = "1.5.5" aws-config = "1.5.5"
aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] } aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
revolt-config = { version = "0.8.8", path = "../config", features = [ revolt-config = { version = "0.9.1", path = "../config", features = [
"report-macros", "report-macros",
] } ] }
revolt-result = { version = "0.8.8", path = "../result", features = ["sentry"] } revolt-result = { version = "0.9.1", path = "../result" }
# image processing # image processing
jxl-oxide = "0.8.1" jxl-oxide = "0.8.1"
+34 -28
View File
@@ -6,7 +6,7 @@ use aes_gcm::{
}; };
use image::{DynamicImage, ImageBuffer}; use image::{DynamicImage, ImageBuffer};
use revolt_config::{config, report_internal_error, FilesS3}; use revolt_config::{config, report_internal_error, FilesS3};
use revolt_result::{create_error, Result, ToRevoltError}; use revolt_result::{create_error, Result};
use aws_sdk_s3::{ use aws_sdk_s3::{
config::{Credentials, Region}, config::{Credentials, Region},
@@ -55,12 +55,13 @@ pub async fn fetch_from_s3(bucket_id: &str, path: &str, nonce: &str) -> Result<V
// Send a request for the file // Send a request for the file
let mut obj = let mut obj =
client.get_object().bucket(bucket_id).key(path).send().await.to_internal_error()?; report_internal_error!(client.get_object().bucket(bucket_id).key(path).send().await)?;
// Read the file from remote // Read the file from remote
let mut buf = vec![]; let mut buf = vec![];
while let Some(bytes) = obj.body.next().await { while let Some(bytes) = obj.body.next().await {
buf.write_all(&bytes.to_internal_error()?).to_internal_error()?; let data = report_internal_error!(bytes)?;
report_internal_error!(buf.write_all(&data))?;
// is there a more efficient way to do this? // is there a more efficient way to do this?
// we just want the Vec<u8> // we just want the Vec<u8>
} }
@@ -77,7 +78,10 @@ pub async fn fetch_from_s3(bucket_id: &str, path: &str, nonce: &str) -> Result<V
// Decrypt the file // Decrypt the file
create_cipher(&config.files.encryption_key) create_cipher(&config.files.encryption_key)
.decrypt_in_place(nonce, b"", &mut buf) .decrypt_in_place(nonce, b"", &mut buf)
.to_internal_error()?; .map_err(|_| create_error!(InternalError))?;
// Remove the authentication tag bytes that were added during encryption
buf.truncate(buf.len() - AUTHENTICATION_TAG_SIZE_BYTES);
Ok(buf) Ok(buf)
} }
@@ -96,17 +100,18 @@ pub async fn upload_to_s3(bucket_id: &str, path: &str, buf: &[u8]) -> Result<Str
// Encrypt the file in place // Encrypt the file in place
create_cipher(&config.files.encryption_key) create_cipher(&config.files.encryption_key)
.encrypt_in_place(&nonce, b"", &mut buf) .encrypt_in_place(&nonce, b"", &mut buf)
.to_internal_error()?; .map_err(|_| create_error!(InternalError))?;
// Upload the file to remote // Upload the file to remote
client report_internal_error!(
.put_object() client
.bucket(bucket_id) .put_object()
.key(path) .bucket(bucket_id)
.body(buf.into()) .key(path)
.send() .body(buf.into())
.await .send()
.to_internal_error()?; .await
)?;
Ok(BASE64_STANDARD.encode(nonce)) Ok(BASE64_STANDARD.encode(nonce))
} }
@@ -116,13 +121,14 @@ pub async fn delete_from_s3(bucket_id: &str, path: &str) -> Result<()> {
let config = config().await; let config = config().await;
let client = create_client(config.files.s3); let client = create_client(config.files.s3);
client report_internal_error!(
.delete_object() client
.bucket(bucket_id) .delete_object()
.key(path) .bucket(bucket_id)
.send() .key(path)
.await .send()
.to_internal_error()?; .await
)?;
Ok(()) Ok(())
} }
@@ -142,7 +148,8 @@ pub fn image_size(f: &NamedTempFile) -> Option<(usize, usize)> {
pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> { pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> {
match mime { match mime {
"image/svg+xml" => { "image/svg+xml" => {
let tree = usvg::Tree::from_data(v, &Default::default()).to_internal_error().ok()?; let tree =
report_internal_error!(usvg::Tree::from_data(v, &Default::default())).ok()?;
let size = tree.size(); let size = tree.size();
Some((size.width() as usize, size.height() as usize)) Some((size.width() as usize, size.height() as usize))
@@ -217,9 +224,9 @@ pub fn decode_image<R: Read + BufRead + Seek>(reader: &mut R, mime: &str) -> Res
"image/svg+xml" => { "image/svg+xml" => {
// usvg doesn't support Read trait so copy to buffer // usvg doesn't support Read trait so copy to buffer
let mut buf = Vec::new(); let mut buf = Vec::new();
reader.read_to_end(&mut buf).to_internal_error()?; report_internal_error!(reader.read_to_end(&mut buf))?;
let tree = usvg::Tree::from_data(&buf, &Default::default()).to_internal_error()?; let tree = report_internal_error!(usvg::Tree::from_data(&buf, &Default::default()))?;
let size = tree.size(); let size = tree.size();
let mut pixmap = Pixmap::new(size.width() as u32, size.height() as u32) let mut pixmap = Pixmap::new(size.width() as u32, size.height() as u32)
.ok_or_else(|| create_error!(ImageProcessingFailed))?; .ok_or_else(|| create_error!(ImageProcessingFailed))?;
@@ -237,11 +244,10 @@ pub fn decode_image<R: Read + BufRead + Seek>(reader: &mut R, mime: &str) -> Res
)) ))
} }
// Check if we can read using image-rs crate // Check if we can read using image-rs crate
_ => image::ImageReader::new(reader) _ => report_internal_error!(report_internal_error!(
.with_guessed_format() image::ImageReader::new(reader).with_guessed_format()
.to_internal_error()? )?
.decode() .decode()),
.to_internal_error()
} }
} }
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-models" name = "revolt-models"
version = "0.8.8" version = "0.9.1"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -20,8 +20,8 @@ default = ["serde", "partials", "rocket"]
[dependencies] [dependencies]
# Core # Core
revolt-config = { version = "0.8.8", path = "../config" } revolt-config = { version = "0.9.1", path = "../config" }
revolt-permissions = { version = "0.8.8", path = "../permissions" } revolt-permissions = { version = "0.9.1", path = "../permissions" }
# Utility # Utility
regex = "1.11" regex = "1.11"
+53 -45
View File
@@ -1,4 +1,5 @@
use super::File; #![allow(deprecated)]
use super::{File, UserVoiceState};
use revolt_permissions::{Override, OverrideField}; use revolt_permissions::{Override, OverrideField};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -107,44 +108,21 @@ auto_derived!(
serde(skip_serializing_if = "crate::if_false", default) serde(skip_serializing_if = "crate::if_false", default)
)] )]
nsfw: bool, nsfw: bool,
/// Voice Information for when this channel is also a voice channel
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
voice: Option<VoiceInformation>,
}, },
/// Voice channel belonging to a server }
VoiceChannel {
/// Unique Id
#[cfg_attr(feature = "serde", serde(rename = "_id"))]
id: String,
/// Id of the server this channel belongs to
server: String,
/// Display name of the channel /// Voice information for a channel
name: String, #[derive(Default)]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] #[cfg_attr(feature = "validator", derive(validator::Validate))]
/// Channel description pub struct VoiceInformation {
description: Option<String>, /// Maximium amount of users allowed in the voice channel at once
/// Custom icon attachment #[cfg_attr(feature = "validator", validate(range(min = 1)))]
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
icon: Option<File>, pub max_users: Option<usize>,
/// Default permissions assigned to users in this channel
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
default_permissions: Option<OverrideField>,
/// Permissions assigned based on role to this channel
#[cfg_attr(
feature = "serde",
serde(
default = "HashMap::<String, OverrideField>::new",
skip_serializing_if = "HashMap::<String, OverrideField>::is_empty"
)
)]
role_permissions: HashMap<String, OverrideField>,
/// Whether this channel is marked as not safe for work
#[cfg_attr(
feature = "serde",
serde(skip_serializing_if = "crate::if_false", default)
)]
nsfw: bool,
},
} }
/// Partial representation of a channel /// Partial representation of a channel
@@ -170,6 +148,8 @@ auto_derived!(
pub default_permissions: Option<OverrideField>, pub default_permissions: Option<OverrideField>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub last_message_id: Option<String>, pub last_message_id: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub voice: Option<VoiceInformation>,
} }
/// Optional fields on channel object /// Optional fields on channel object
@@ -177,6 +157,7 @@ auto_derived!(
Description, Description,
Icon, Icon,
DefaultPermissions, DefaultPermissions,
Voice,
} }
/// New webhook information /// New webhook information
@@ -205,6 +186,9 @@ auto_derived!(
/// Whether this channel is archived /// Whether this channel is archived
pub archived: Option<bool>, pub archived: Option<bool>,
/// Voice Information for voice channels
pub voice: Option<VoiceInformation>,
/// Fields to remove from channel /// Fields to remove from channel
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsChannel>, pub remove: Vec<FieldsChannel>,
@@ -260,6 +244,10 @@ auto_derived!(
/// Whether this channel is age restricted /// Whether this channel is age restricted
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub nsfw: Option<bool>, pub nsfw: Option<bool>,
/// Voice Information for when this channel is also a voice channel
#[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<VoiceInformation>,
} }
/// New default permissions /// New default permissions
@@ -270,7 +258,7 @@ auto_derived!(
permissions: u64, permissions: u64,
}, },
Field { Field {
/// Allow / deny values to set for members in this `TextChannel` or `VoiceChannel` /// Allow / deny values to set for members in this server channel
permissions: Override, permissions: Override,
}, },
} }
@@ -289,9 +277,32 @@ auto_derived!(
} }
/// Voice server token response /// Voice server token response
pub struct LegacyCreateVoiceUserResponse { pub struct CreateVoiceUserResponse {
/// Token for authenticating with the voice server /// Token for authenticating with the voice server
token: String, pub token: String,
/// Url of the livekit server to connect to
pub url: String,
}
/// Voice state for a channel
pub struct ChannelVoiceState {
pub id: String,
/// The states of the users who are connected to the channel
pub participants: Vec<UserVoiceState>,
}
/// Join a voice channel
pub struct DataJoinCall {
/// Name of the node to join
pub node: Option<String>,
/// Whether to force disconnect any other existing voice connections
///
/// Useful for disconnecting on another device and joining on a new.
pub force_disconnect: Option<bool>,
/// Users which should be notified of the call starting
///
/// Only used when the user is the first one connected.
pub recipients: Option<Vec<String>>,
} }
); );
@@ -302,8 +313,7 @@ impl Channel {
Channel::DirectMessage { id, .. } Channel::DirectMessage { id, .. }
| Channel::Group { id, .. } | Channel::Group { id, .. }
| Channel::SavedMessages { id, .. } | Channel::SavedMessages { id, .. }
| Channel::TextChannel { id, .. } | Channel::TextChannel { id, .. } => id,
| Channel::VoiceChannel { id, .. } => id,
} }
} }
@@ -315,9 +325,7 @@ impl Channel {
match self { match self {
Channel::DirectMessage { .. } => None, Channel::DirectMessage { .. } => None,
Channel::SavedMessages { .. } => Some("Saved Messages"), Channel::SavedMessages { .. } => Some("Saved Messages"),
Channel::TextChannel { name, .. } Channel::TextChannel { name, .. } | Channel::Group { name, .. } => Some(name),
| Channel::Group { name, .. }
| Channel::VoiceChannel { name, .. } => Some(name),
} }
} }
} }
+3
View File
@@ -132,6 +132,8 @@ auto_derived!(
MessagePinned { id: String, by: String }, MessagePinned { id: String, by: String },
#[serde(rename = "message_unpinned")] #[serde(rename = "message_unpinned")]
MessageUnpinned { id: String, by: String }, MessageUnpinned { id: String, by: String },
#[serde(rename = "call_started")]
CallStarted { by: String, finished_at: Option<Timestamp> },
} }
/// Name and / or avatar override information /// Name and / or avatar override information
@@ -445,6 +447,7 @@ impl From<SystemMessage> for String {
} }
SystemMessage::MessagePinned { .. } => "Message pinned.".to_string(), SystemMessage::MessagePinned { .. } => "Message pinned.".to_string(),
SystemMessage::MessageUnpinned { .. } => "Message unpinned.".to_string(), SystemMessage::MessageUnpinned { .. } => "Message unpinned.".to_string(),
SystemMessage::CallStarted { .. } => "Call started.".to_string(),
} }
} }
} }
-2
View File
@@ -7,7 +7,6 @@ mod embeds;
mod emojis; mod emojis;
mod files; mod files;
mod messages; mod messages;
mod onboard;
mod policy_changes; mod policy_changes;
mod safety_reports; mod safety_reports;
mod server_bans; mod server_bans;
@@ -25,7 +24,6 @@ pub use embeds::*;
pub use emojis::*; pub use emojis::*;
pub use files::*; pub use files::*;
pub use messages::*; pub use messages::*;
pub use onboard::*;
pub use policy_changes::*; pub use policy_changes::*;
pub use safety_reports::*; pub use safety_reports::*;
pub use server_bans::*; pub use server_bans::*;
-15
View File
@@ -1,15 +0,0 @@
auto_derived!(
/// # New User Data
#[derive(validator::Validate)]
pub struct DataOnboard {
/// New username which will be used to identify the user on the platform
#[validate(length(min = 2, max = 32), regex = "super::RE_USERNAME")]
pub username: String,
}
/// # Onboarding Status
pub struct DataHello {
/// Whether onboarding is required
pub onboarding: bool,
}
);
@@ -1,17 +1,6 @@
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
auto_derived!( auto_derived!(
/// # Report Data
#[derive(validator::Validate)]
pub struct DataReportContent {
/// Content being reported
pub content: ReportedContent,
/// Additional report description
#[validate(length(min = 0, max = 1000))]
#[serde(default)]
pub additional_context: String,
}
/// User-generated platform moderation report /// User-generated platform moderation report
pub struct Report { pub struct Report {
/// Unique Id /// Unique Id
@@ -31,6 +31,14 @@ pub static RE_COLOUR: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:[a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|(repeating-)?(linear|conic|radial)-gradient\(([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|\d+deg)([ ]+(\d{1,3}%|0))?(,[ ]*([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+)([ ]+(\d{1,3}%|0))?)+\))$").unwrap() Regex::new(r"(?i)^(?:[a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|(repeating-)?(linear|conic|radial)-gradient\(([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+|\d+deg)([ ]+(\d{1,3}%|0))?(,[ ]*([a-z ]+|var\(--[a-z\d-]+\)|rgba?\([\d, ]+\)|#[a-f0-9]+)([ ]+(\d{1,3}%|0))?)+\))$").unwrap()
}); });
fn default_true() -> bool {
true
}
fn is_true(x: &bool) -> bool {
*x
}
auto_derived_partial!( auto_derived_partial!(
/// Server Member /// Server Member
pub struct Member { pub struct Member {
@@ -57,6 +65,13 @@ auto_derived_partial!(
/// Timestamp this member is timed out until /// Timestamp this member is timed out until
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub timeout: Option<Timestamp>, pub timeout: Option<Timestamp>,
/// Whether the member is server-wide voice muted
#[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_publish: bool,
/// Whether the member is server-wide voice deafened
#[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_receive: bool,
}, },
"PartialMember" "PartialMember"
); );
@@ -77,6 +92,9 @@ auto_derived!(
Avatar, Avatar,
Roles, Roles,
Timeout, Timeout,
CanReceive,
CanPublish,
JoinedAt,
} }
/// Member removal intention /// Member removal intention
@@ -123,6 +141,12 @@ auto_derived!(
pub roles: Option<Vec<String>>, pub roles: Option<Vec<String>>,
/// Timestamp this member is timed out until /// Timestamp this member is timed out until
pub timeout: Option<Timestamp>, pub timeout: Option<Timestamp>,
/// server-wide voice muted
pub can_publish: Option<bool>,
/// server-wide voice deafened
pub can_receive: Option<bool>,
/// voice channel to move to if already in a voice channel
pub voice_channel: Option<String>,
/// Fields to remove from channel object /// Fields to remove from channel object
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsMember>, pub remove: Vec<FieldsMember>,
+5 -19
View File
@@ -1,4 +1,4 @@
use super::{Channel, File, Member, User, RE_COLOUR}; use super::{Channel, File, RE_COLOUR};
use revolt_permissions::{Override, OverrideField}; use revolt_permissions::{Override, OverrideField};
use std::collections::HashMap; use std::collections::HashMap;
@@ -85,6 +85,9 @@ auto_derived_partial!(
auto_derived_partial!( auto_derived_partial!(
/// Role /// Role
pub struct Role { pub struct Role {
/// Unique Id
#[cfg_attr(feature = "serde", serde(rename = "_id"))]
pub id: String,
/// Role name /// Role name
pub name: String, pub name: String,
/// Permissions available to this role /// Permissions available to this role
@@ -181,6 +184,7 @@ auto_derived!(
} }
/// Response after creating new role /// Response after creating new role
// TODO: remove this in favor of just Role
pub struct NewRoleResponse { pub struct NewRoleResponse {
/// Id of the role /// Id of the role
pub id: String, pub id: String,
@@ -293,22 +297,4 @@ auto_derived!(
pub struct DataEditRoleRanks { pub struct DataEditRoleRanks {
pub ranks: Vec<String>, pub ranks: Vec<String>,
} }
/// # Query Parameters
#[derive(FromForm)]
pub struct OptionsQueryMembers {
/// String to search for
pub query: String,
/// Discourage use of this API
pub experimental_api: bool,
}
/// # Query members by name
pub struct MemberQueryResponse {
/// List of members
pub members: Vec<Member>,
/// List of users
pub users: Vec<User>,
}
); );
+16 -11
View File
@@ -1,3 +1,4 @@
use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use regex::Regex; use regex::Regex;
@@ -255,12 +256,14 @@ auto_derived!(
pub flags: i32, pub flags: i32,
} }
/// Mutual friends and servers response /// Mutual friends, servers, groups and DMs response
pub struct MutualResponse { pub struct MutualResponse {
/// Array of mutual user IDs that both users are friends with /// Array of mutual user IDs that both users are friends with
pub users: Vec<String>, pub users: Vec<String>,
/// Array of mutual server IDs that both users are in /// Array of mutual server IDs that both users are in
pub servers: Vec<String>, pub servers: Vec<String>,
/// Array of mutual group and dm IDs that both users are in
pub channels: Vec<String>,
} }
/// Bot information for if the user is a bot /// Bot information for if the user is a bot
@@ -275,17 +278,19 @@ auto_derived!(
/// Username and discriminator combo separated by # /// Username and discriminator combo separated by #
pub username: String, pub username: String,
} }
);
/// # Username Information auto_derived_partial!(
#[derive(Validate)] /// Voice State information for a user
pub struct DataChangeUsername { pub struct UserVoiceState {
/// New username pub id: String,
#[validate(length(min = 2, max = 32), regex = "super::RE_USERNAME")] pub joined_at: Timestamp,
pub username: String, pub is_receiving: bool,
/// Current account password pub is_publishing: bool,
#[validate(length(min = 8, max = 1024))] pub screensharing: bool,
pub password: String, pub camera: bool,
} },
"PartialUserVoiceState"
); );
pub trait CheckRelationship { pub trait CheckRelationship {
+3 -2
View File
@@ -1,8 +1,9 @@
[package] [package]
name = "revolt-parser" name = "revolt-parser"
version = "0.8.8" version = "0.9.1"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "MIT"
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Message Parser" description = "Revolt Backend: Message Parser"
[dependencies] [dependencies]

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