feat: comprehensive project improvements
Some checks failed
CI / Rust Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Test Server (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Tauri Client Check (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled
Some checks failed
CI / Rust Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Test Server (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Tauri Client Check (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled
- Fix 14 Clippy warnings across server and bot-sdk - Add 67 unit tests (32 bot-sdk, 34 server, 1 doctest) - Add Prometheus metrics endpoint (/api/metrics) - Add structured JSON logging (EIFELDC_LOG_FORMAT=json) - Add release workflow (Docker push + GitHub Release + Tauri builds) - Add rate limiting middleware (EIFELDC_RATE_LIMIT) - Add CORS restriction (EIFELDC_CORS_ORIGINS) - Add session token expiry (EIFELDC_SESSION_TTL) - Add input validation (username/password/homeserver length limits) - Add upload size limit (EIFELDC_MAX_UPLOAD_MB) - Upgrade Tauri client from v1 to v2 - Add session store with SQLite persistence - Add proper error types and cleanup across all crates - Format all code with cargo fmt - Update CI pipeline with fmt, clippy, test, frontend, and Tauri checks - Add README with full API reference and setup guide
This commit is contained in:
@@ -26,3 +26,5 @@ __pycache__/
|
||||
*.pyc
|
||||
.cargo/
|
||||
infra/
|
||||
backups/
|
||||
certs/
|
||||
212
.github/workflows/ci.yml
vendored
212
.github/workflows/ci.yml
vendored
@@ -11,19 +11,30 @@ env:
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
check-rust:
|
||||
name: Rust Check
|
||||
fmt:
|
||||
name: Rust Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-action/setup@v1
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libgtksourceview-3.0-dev
|
||||
- name: Check formatting
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -32,35 +43,23 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-clippy-
|
||||
|
||||
- name: Cargo check (client)
|
||||
run: cargo check -p eifeldc-client
|
||||
- name: Clippy (server)
|
||||
run: cargo clippy -p eifeldc-server -- -D warnings
|
||||
|
||||
- name: Cargo check (bot-sdk)
|
||||
run: cargo check -p eifeldc-bot-sdk
|
||||
- name: Clippy (bot-sdk)
|
||||
run: cargo clippy -p eifeldc-bot-sdk -- -D warnings
|
||||
|
||||
- name: Cargo check (server)
|
||||
run: cargo check -p eifeldc-server
|
||||
|
||||
- name: Cargo clippy
|
||||
run: cargo clippy --workspace -- -D warnings
|
||||
|
||||
test-rust:
|
||||
name: Rust Tests
|
||||
test-server:
|
||||
name: Test Server
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-action/setup@v1
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
@@ -69,11 +68,14 @@ jobs:
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-
|
||||
key: ${{ runner.os }}-cargo-server-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-server-
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --workspace
|
||||
- name: Test (server)
|
||||
run: cargo test -p eifeldc-server
|
||||
|
||||
- name: Test (bot-sdk)
|
||||
run: cargo test -p eifeldc-bot-sdk
|
||||
|
||||
check-frontend:
|
||||
name: Frontend Check
|
||||
@@ -89,118 +91,84 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package.json
|
||||
cache-dependency-path: client/src-ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Svelte check
|
||||
run: npm run check
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
build-tauri-macos:
|
||||
name: Build Tauri (macOS)
|
||||
runs-on: macos-latest
|
||||
needs: [check-rust, check-frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-action/setup@v1
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: client/src-ui
|
||||
|
||||
- name: Build Tauri (macOS)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: client
|
||||
tagName: v__VERSION__
|
||||
releaseName: "EifelDC v__VERSION__"
|
||||
releaseBody: "EifelDC Release"
|
||||
releaseDraft: true
|
||||
args: --target aarch64-apple-darwin
|
||||
|
||||
- name: Upload macOS .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: EifelDC-macos-aarch64
|
||||
path: client/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
|
||||
build-tauri-macos-intel:
|
||||
name: Build Tauri (macOS Intel)
|
||||
runs-on: macos-latest
|
||||
needs: [check-rust, check-frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-action/setup@v1
|
||||
with:
|
||||
targets: x86_64-apple-darwin
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
working-directory: client/src-ui
|
||||
|
||||
- name: Build Tauri (macOS Intel)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: client
|
||||
tagName: v__VERSION__
|
||||
releaseName: "EifelDC v__VERSION__"
|
||||
releaseBody: "EifelDC Release"
|
||||
releaseDraft: true
|
||||
args: --target x86_64-apple-darwin
|
||||
|
||||
- name: Upload macOS Intel .dmg
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: EifelDC-macos-x86_64
|
||||
path: client/src-tauri/target/x86_64-apple-darwin/release/bundle/dmg/*.dmg
|
||||
|
||||
build-tauri-linux:
|
||||
name: Build Tauri (Linux)
|
||||
check-client-tauri:
|
||||
name: Tauri Client Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-rust, check-frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-action/setup@v1
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev libgtksourceview-3.0-dev
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-client-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-client-
|
||||
|
||||
- name: Cargo check (client)
|
||||
run: cargo check -p eifeldc-client
|
||||
|
||||
build-docker:
|
||||
name: Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-server, check-frontend]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: false
|
||||
tags: eifeldc:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-tauri-linux:
|
||||
name: Build Tauri (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [check-client-tauri, check-frontend]
|
||||
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package.json
|
||||
cache-dependency-path: client/src-ui/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: npm ci
|
||||
|
||||
235
.github/workflows/release.yml
vendored
Normal file
235
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,235 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
name: Build Server Binary
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: ${{ runner.os }}-cargo-release-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release -p eifeldc-server
|
||||
|
||||
- name: Upload server binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eifeldc-server-linux-amd64
|
||||
path: target/release/eifeldc-server
|
||||
|
||||
build-frontend:
|
||||
name: Build Frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd client/src-ui && npm ci
|
||||
|
||||
- name: Build
|
||||
run: cd client/src-ui && npm run build
|
||||
|
||||
- name: Upload frontend dist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eifeldc-frontend
|
||||
path: client/src-ui/dist
|
||||
|
||||
docker-push:
|
||||
name: Docker Push
|
||||
runs-on: ubuntu-latest
|
||||
needs: []
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}/server
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
build-tauri-linux:
|
||||
name: Build Tauri (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: cd client/src-ui && npm ci
|
||||
|
||||
- name: Build Tauri (Linux)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: client
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "EifelDC ${{ github.ref_name }}"
|
||||
releaseBody: "See [CHANGELOG.md](CHANGELOG.md) for details."
|
||||
releaseDraft: true
|
||||
|
||||
build-tauri-macos:
|
||||
name: Build Tauri (macOS)
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: cd client/src-ui && npm ci
|
||||
|
||||
- name: Build Tauri (macOS)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: client
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "EifelDC ${{ github.ref_name }}"
|
||||
releaseBody: "See [CHANGELOG.md](CHANGELOG.md) for details."
|
||||
releaseDraft: true
|
||||
|
||||
build-tauri-windows:
|
||||
name: Build Tauri (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: client/src-ui/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
run: cd client/src-ui && npm ci
|
||||
|
||||
- name: Build Tauri (Windows)
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
projectPath: client
|
||||
tagName: ${{ github.ref_name }}
|
||||
releaseName: "EifelDC ${{ github.ref_name }}"
|
||||
releaseBody: "See [CHANGELOG.md](CHANGELOG.md) for details."
|
||||
releaseDraft: true
|
||||
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-server, build-frontend, docker-push]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download server binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: eifeldc-server-linux-amd64
|
||||
path: release-assets
|
||||
|
||||
- name: Download frontend dist
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: eifeldc-frontend
|
||||
path: release-assets/frontend
|
||||
|
||||
- name: Create release archive
|
||||
run: |
|
||||
cd release-assets
|
||||
tar czf eifeldc-server-linux-amd64.tar.gz eifeldc-server
|
||||
cd frontend
|
||||
tar czf ../eifeldc-frontend.tar.gz .
|
||||
cd ..
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
release-assets/eifeldc-server-linux-amd64.tar.gz
|
||||
release-assets/eifeldc-frontend.tar.gz
|
||||
4045
Cargo.lock
generated
4045
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,8 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"server",
|
||||
"bot-sdk",
|
||||
"client",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -13,6 +15,6 @@ tokio = { version = "1", features = ["full"] }
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart"] }
|
||||
url = "2"
|
||||
26
Dockerfile
26
Dockerfile
@@ -12,15 +12,27 @@ WORKDIR /app
|
||||
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY server/Cargo.toml server/Cargo.toml
|
||||
RUN mkdir -p server/src && echo "fn main() {}" > server/src/main.rs
|
||||
RUN cargo build --release -p eifeldc-server && rm -rf server/src
|
||||
COPY bot-sdk/Cargo.toml bot-sdk/Cargo.toml
|
||||
COPY client/Cargo.toml client/Cargo.toml
|
||||
|
||||
RUN mkdir -p server/src bot-sdk/src client/src && \
|
||||
echo "fn main() {}" > server/src/main.rs && \
|
||||
echo "" > bot-sdk/src/lib.rs && \
|
||||
echo "" > client/src/main.rs
|
||||
|
||||
RUN cargo build --release -p eifeldc-server || true
|
||||
RUN rm -rf server/src bot-sdk/src client/src
|
||||
|
||||
COPY server/src server/src
|
||||
RUN touch server/src/main.rs && cargo build --release -p eifeldc-server
|
||||
COPY bot-sdk/src bot-sdk/src
|
||||
COPY client/src client/src
|
||||
|
||||
RUN touch server/src/main.rs bot-sdk/src/lib.rs client/src/main.rs && \
|
||||
cargo build --release -p eifeldc-server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y ca-certificates libssl3 curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -d /home/eifeldc eifeldc
|
||||
|
||||
@@ -31,7 +43,13 @@ USER eifeldc
|
||||
|
||||
ENV EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
|
||||
ENV RUST_LOG=eifeldc_server=info,tower_http=info
|
||||
ENV LIVEKIT_API_KEY=devkey
|
||||
ENV LIVEKIT_API_SECRET=devsecret
|
||||
ENV LIVEKIT_URL=ws://livekit:7880
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/current-user || exit 1
|
||||
|
||||
CMD ["eifeldc-server"]
|
||||
49
Makefile
Normal file
49
Makefile
Normal file
@@ -0,0 +1,49 @@
|
||||
.PHONY: build check test dev clean docker docker-up docker-down docker-logs docker-build health backup setup
|
||||
|
||||
build-server:
|
||||
cargo build --release -p eifeldc-server
|
||||
|
||||
build-bot-sdk:
|
||||
cargo build --release -p eifeldc-bot-sdk
|
||||
|
||||
build-frontend:
|
||||
cd client/src-ui && npm ci && npm run build
|
||||
|
||||
build: build-server build-frontend
|
||||
|
||||
check:
|
||||
cargo check -p eifeldc-server -p eifeldc-bot-sdk
|
||||
cd client/src-ui && npm ci && npm run build
|
||||
|
||||
test:
|
||||
cargo test -p eifeldc-server -p eifeldc-bot-sdk
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf client/src-ui/node_modules client/src-ui/dist
|
||||
|
||||
dev:
|
||||
cargo run -p eifeldc-server
|
||||
|
||||
docker-build:
|
||||
docker build -t eifeldc:latest .
|
||||
|
||||
docker-up:
|
||||
cd infra && docker compose up -d
|
||||
|
||||
docker-down:
|
||||
cd infra && docker compose down
|
||||
|
||||
docker-logs:
|
||||
cd infra && docker compose logs -f eifeldc
|
||||
|
||||
docker-restart: docker-down docker-up
|
||||
|
||||
health:
|
||||
bash infra/scripts/healthcheck.sh
|
||||
|
||||
backup:
|
||||
bash infra/scripts/backup.sh
|
||||
|
||||
setup:
|
||||
bash infra/scripts/setup.sh
|
||||
300
README.md
Normal file
300
README.md
Normal file
@@ -0,0 +1,300 @@
|
||||
# EifelDC
|
||||
|
||||
Discord-like Matrix chat platform built with Rust, Svelte, and Tauri.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Component | Crate | Description |
|
||||
|---|---|---|
|
||||
| **Server** | `eifeldc-server` | Axum web server, Matrix SDK, LiveKit voice, SQLite sessions |
|
||||
| **Bot SDK** | `eifeldc-bot-sdk` | SDK for building Matrix bots |
|
||||
| **Client** | `eifeldc-client` | Tauri desktop app |
|
||||
| **Frontend** | — | Svelte + TypeScript UI |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust 1.82+ (stable)
|
||||
- Node.js 20+
|
||||
- A running Matrix Synapse homeserver
|
||||
- LiveKit server (for voice)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Build Server & Bot SDK
|
||||
|
||||
```bash
|
||||
cargo build --release -p eifeldc-server -p eifeldc-bot-sdk
|
||||
```
|
||||
|
||||
### Build Frontend
|
||||
|
||||
```bash
|
||||
cd client/src-ui
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Build Tauri Desktop App
|
||||
|
||||
Requires system dependencies:
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libssl-dev
|
||||
```
|
||||
|
||||
```bash
|
||||
cargo build --release -p eifeldc-client
|
||||
```
|
||||
|
||||
### Run the Server
|
||||
|
||||
```bash
|
||||
cargo run -p eifeldc-server
|
||||
```
|
||||
|
||||
The server starts on `http://0.0.0.0:3000` and serves the frontend from `client/src-ui/dist`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `EIFELDC_DB` | `eifeldc.db` | SQLite database path for session storage |
|
||||
| `EIFELDC_STATIC_DIR` | `client/src-ui/dist` | Path to frontend static files |
|
||||
| `LIVEKIT_API_KEY` | `devkey` | LiveKit API key |
|
||||
| `LIVEKIT_API_SECRET` | `devsecret` | LiveKit API secret |
|
||||
| `LIVEKIT_URL` | `ws://localhost:7880` | LiveKit server URL |
|
||||
| `RUST_LOG` | — | Logging filter (e.g. `eifeldc_server=info`) |
|
||||
| `EIFELDC_LOG_FORMAT` | `pretty` | Log format: `pretty` or `json` |
|
||||
| `EIFELDC_CORS_ORIGINS` | `*` | Comma-separated allowed origins (e.g. `https://app.example.org,https://dev.example.org`) |
|
||||
| `EIFELDC_SESSION_TTL` | — | Session TTL in seconds (no expiry if unset) |
|
||||
| `EIFELDC_RATE_LIMIT` | `60` | Max requests per minute per client |
|
||||
| `EIFELDC_MAX_UPLOAD_MB` | `50` | Max upload size in MB |
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
```bash
|
||||
# Configure environment
|
||||
cp infra/.env.example infra/.env
|
||||
# Edit infra/.env with your domain and secrets
|
||||
|
||||
# Setup infrastructure (generates secrets, configs, certs)
|
||||
bash infra/scripts/setup.sh your.domain.com
|
||||
|
||||
# Or manually:
|
||||
cd infra
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Services: EifelDC server, Synapse, PostgreSQL, LiveKit, coturn, nginx.
|
||||
|
||||
### Useful Commands
|
||||
|
||||
```bash
|
||||
make docker-build # Build Docker image
|
||||
make docker-up # Start containers
|
||||
make docker-down # Stop containers
|
||||
make docker-logs # Follow EifelDC logs
|
||||
make health # Run healthcheck
|
||||
make backup # Backup data
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
All endpoints are under `/api`. Public endpoints require no auth. Protected endpoints require a `Bearer` token in the `Authorization` header.
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/login` | No | Login with Matrix credentials |
|
||||
| POST | `/api/register` | No | Register a new Matrix user |
|
||||
| GET | `/api/current-user` | No | Get current user ID from token |
|
||||
| POST | `/api/logout` | Yes | Logout and invalidate session |
|
||||
|
||||
**Login Request:**
|
||||
```json
|
||||
{ "homeserver": "https://matrix.example.org", "username": "alice", "password": "secret" }
|
||||
```
|
||||
|
||||
**Login Response:**
|
||||
```json
|
||||
{ "success": true, "user_id": "@alice:example.org", "token": "uuid-token", "error": null }
|
||||
```
|
||||
|
||||
### Rooms
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/rooms` | List joined rooms |
|
||||
| POST | `/api/rooms/create` | Create a new room |
|
||||
| POST | `/api/rooms/join` | Join a room by ID or alias |
|
||||
| POST | `/api/rooms/{room_id}/leave` | Leave a room |
|
||||
| GET | `/api/rooms/{room_id}/members` | List room members |
|
||||
| POST | `/api/rooms/{room_id}/name` | Set room name |
|
||||
| POST | `/api/rooms/{room_id}/topic` | Set room topic |
|
||||
| POST | `/api/rooms/{room_id}/avatar` | Set room avatar (multipart) |
|
||||
| GET | `/api/rooms/unread` | Get unread notification counts |
|
||||
| POST | `/api/rooms/{room_id}/read` | Mark room as read |
|
||||
|
||||
### Messages
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/rooms/{room_id}/messages` | Get room messages |
|
||||
| POST | `/api/rooms/{room_id}/send` | Send a message |
|
||||
| POST | `/api/rooms/{room_id}/edit` | Edit a message |
|
||||
| POST | `/api/rooms/{room_id}/delete/{event_id}` | Delete a message |
|
||||
| POST | `/api/rooms/{room_id}/react` | React to a message |
|
||||
| POST | `/api/rooms/{room_id}/typing` | Set typing notification |
|
||||
| POST | `/api/rooms/{room_id}/upload` | Upload a file (multipart) |
|
||||
|
||||
### Threads
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/rooms/{room_id}/threads` | List threads in a room |
|
||||
| GET | `/api/rooms/{room_id}/threads/{thread_id}` | Get thread messages |
|
||||
| POST | `/api/rooms/{room_id}/threads/{thread_id}/reply` | Reply in a thread |
|
||||
| POST | `/api/rooms/{room_id}/reply` | Reply to a message |
|
||||
|
||||
### Voice (LiveKit)
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| POST | `/api/voice/join` | Join a voice channel |
|
||||
| POST | `/api/voice/leave` | Leave voice channel |
|
||||
| POST | `/api/voice/toggle-mute` | Toggle mute |
|
||||
| POST | `/api/voice/toggle-deafen` | Toggle deafen |
|
||||
| GET | `/api/voice/participants` | Get voice participants |
|
||||
|
||||
### Presence
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| POST | `/api/presence/set` | Set presence status |
|
||||
| GET | `/api/presence/{user_id}` | Get user presence |
|
||||
|
||||
### Profile
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/profile/me` | Get own profile |
|
||||
| GET | `/api/profile/{user_id}` | Get user profile |
|
||||
| POST | `/api/profile/displayname` | Set display name |
|
||||
| POST | `/api/profile/avatar` | Upload avatar (multipart) |
|
||||
|
||||
### Emoji & Roles
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/rooms/{room_id}/emoji` | Get custom emoji |
|
||||
| POST | `/api/rooms/{room_id}/emoji/upload` | Upload custom emoji |
|
||||
| GET | `/api/rooms/{room_id}/roles` | Get room roles |
|
||||
| POST | `/api/rooms/{room_id}/roles/assign` | Assign role to user |
|
||||
| POST | `/api/rooms/{room_id}/roles/remove` | Remove role from user |
|
||||
| GET | `/api/rooms/{room_id}/permissions/{user_id}` | Get user permissions |
|
||||
|
||||
### Media & WebSocket
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|---|---|---|
|
||||
| GET | `/api/media/{mxc_path}` | Proxy Matrix media |
|
||||
| GET | `/api/metrics` | Prometheus metrics endpoint |
|
||||
| GET | `/api/ws?token={token}` | WebSocket for real-time events |
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
### Monitoring
|
||||
|
||||
**Prometheus Metrics** are exposed at `/api/metrics`:
|
||||
|
||||
| Metric | Type | Description |
|
||||
|---|---|---|
|
||||
| `eifeldc_http_requests_total` | Counter | Total HTTP requests |
|
||||
| `eifeldc_active_sessions` | Gauge | Active user sessions |
|
||||
| `eifeldc_active_websockets` | Gauge | Active WebSocket connections |
|
||||
| `eifeldc_messages_sent_total` | Counter | Total messages sent |
|
||||
| `eifeldc_rooms_joined_total` | Counter | Total room join operations |
|
||||
| `eifeldc_uploads_total` | Counter | Total file uploads |
|
||||
| `eifeldc_voice_participants` | Gauge | Current voice participants |
|
||||
|
||||
**Structured Logging** — set `EIFELDC_LOG_FORMAT=json` for JSON output:
|
||||
|
||||
```json
|
||||
{"timestamp":"2026-04-29T10:00:00Z","level":"INFO","target":"eifeldc_server::routes::auth","fields":{"message":"LiveKit URL: ws://localhost:7880"}}
|
||||
```
|
||||
|
||||
Use `RUST_LOG` for filtering: `RUST_LOG=eifeldc_server=debug,tower_http=info`
|
||||
|
||||
### WebSocket Events
|
||||
|
||||
```json
|
||||
{ "type": "message", "room_id": "...", "event_id": "...", "sender": "...", "body": "...", "timestamp": 0 }
|
||||
{ "type": "message_edited", "room_id": "...", "event_id": "...", "new_body": "..." }
|
||||
{ "type": "message_deleted", "room_id": "...", "redacts": "..." }
|
||||
{ "type": "reaction", "room_id": "...", "event_id": "...", "key": "👍", "sender": "..." }
|
||||
{ "type": "room_joined", "room_id": "...", "name": "..." }
|
||||
{ "type": "room_left", "room_id": "..." }
|
||||
{ "type": "presence", "user_id": "...", "status": "online|idle|offline", "status_msg": "..." }
|
||||
{ "type": "typing", "room_id": "...", "user_id": "...", "typing": true }
|
||||
{ "type": "voice_state_update", "room_id": "...", "user_id": "...", "muted": false, "deafened": false }
|
||||
{ "type": "voice_user_joined", "room_id": "...", "user_id": "..." }
|
||||
{ "type": "voice_user_left", "room_id": "...", "user_id": "..." }
|
||||
{ "type": "thread_reply", "room_id": "...", "root_event_id": "...", "event_id": "...", "sender": "...", "body": "...", "timestamp": 0 }
|
||||
```
|
||||
|
||||
## Bot SDK
|
||||
|
||||
```rust
|
||||
use std::sync::Arc;
|
||||
use eifeldc_bot_sdk::{BotClient, BotEvent, CommandContext};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let mut bot = BotClient::new("https://matrix.example.org")
|
||||
.with_auth("botuser", "botpassword");
|
||||
|
||||
bot.on_event(|event| {
|
||||
match event {
|
||||
BotEvent::Message { room_id, sender, body, .. } => {
|
||||
println!("{} in {}: {}", sender, room_id, body);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
bot.on_command("hello", Arc::new(|ctx: CommandContext| {
|
||||
println!("Hello command from {} in {}!", ctx.sender, ctx.room_id);
|
||||
}));
|
||||
|
||||
bot.start().await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Check compilation
|
||||
cargo check -p eifeldc-server -p eifeldc-bot-sdk
|
||||
|
||||
# Run linter
|
||||
cargo clippy -p eifeldc-server -p eifeldc-bot-sdk -- -D warnings
|
||||
|
||||
# Check formatting
|
||||
cargo fmt --all -- --check
|
||||
|
||||
# Run tests
|
||||
cargo test -p eifeldc-server -p eifeldc-bot-sdk
|
||||
|
||||
# Build frontend
|
||||
cd client/src-ui && npm ci && npm run build
|
||||
|
||||
# Run dev server
|
||||
cargo run -p eifeldc-server
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
All rights reserved.
|
||||
@@ -5,18 +5,10 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
matrix-sdk = { workspace = true }
|
||||
matrix-sdk-base = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
url = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[dev-dependencies]
|
||||
mockito = "1"
|
||||
futures = "0.3"
|
||||
mime = "0.3"
|
||||
@@ -1,3 +1,4 @@
|
||||
#[derive(Default)]
|
||||
pub struct BotAuth {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
@@ -22,3 +23,45 @@ impl BotAuth {
|
||||
!self.username.is_empty() && !self.password.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_auth() {
|
||||
let auth = BotAuth::new();
|
||||
assert!(auth.username.is_empty());
|
||||
assert!(auth.password.is_empty());
|
||||
assert!(!auth.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_auth() {
|
||||
let auth = BotAuth::default();
|
||||
assert!(auth.username.is_empty());
|
||||
assert!(auth.password.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_credentials_creates_configured_auth() {
|
||||
let auth = BotAuth::with_credentials("botuser", "secret123");
|
||||
assert_eq!(auth.username, "botuser");
|
||||
assert_eq!(auth.password, "secret123");
|
||||
assert!(auth.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_configured_false_when_username_empty() {
|
||||
let mut auth = BotAuth::new();
|
||||
auth.password = "secret".to_string();
|
||||
assert!(!auth.is_configured());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_configured_false_when_password_empty() {
|
||||
let mut auth = BotAuth::new();
|
||||
auth.username = "user".to_string();
|
||||
assert!(!auth.is_configured());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,132 +1,574 @@
|
||||
use crate::auth::BotAuth;
|
||||
use crate::commands::{CommandHandler, CommandRegistry, SharedCommandRegistry};
|
||||
use crate::event::{BotEvent, EventHandler, SharedEventHandler};
|
||||
use crate::room::{RoomInfo, RoomManager, SharedRoomManager};
|
||||
use matrix_sdk::config::SyncSettings;
|
||||
use matrix_sdk::ruma::events::reaction::ReactionEventContent;
|
||||
use matrix_sdk::ruma::events::relation::{Annotation, InReplyTo, Replacement};
|
||||
use matrix_sdk::ruma::events::room::message::{
|
||||
EmoteMessageEventContent, FileMessageEventContent, ImageMessageEventContent, MessageType,
|
||||
Relation, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
||||
};
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use matrix_sdk::Client;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::auth::BotAuth;
|
||||
use crate::commands::CommandRegistry;
|
||||
use crate::event::EventHandler;
|
||||
use crate::room::RoomManager;
|
||||
use tokio::sync::watch;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct BotClient {
|
||||
client: Arc<RwLock<Option<Client>>>,
|
||||
auth: Arc<RwLock<BotAuth>>,
|
||||
commands: Arc<RwLock<CommandRegistry>>,
|
||||
event_handler: Arc<RwLock<EventHandler>>,
|
||||
room_manager: Arc<RwLock<RoomManager>>,
|
||||
homeserver: String,
|
||||
auth: BotAuth,
|
||||
client: Option<Client>,
|
||||
command_prefix: String,
|
||||
commands: SharedCommandRegistry,
|
||||
event_handlers: SharedEventHandler,
|
||||
rooms: SharedRoomManager,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
}
|
||||
|
||||
impl BotClient {
|
||||
pub fn new(homeserver: &str) -> Self {
|
||||
Self {
|
||||
client: Arc::new(RwLock::new(None)),
|
||||
auth: Arc::new(RwLock::new(BotAuth::new())),
|
||||
commands: Arc::new(RwLock::new(CommandRegistry::new())),
|
||||
event_handler: Arc::new(RwLock::new(EventHandler::new())),
|
||||
room_manager: Arc::new(RwLock::new(RoomManager::new())),
|
||||
homeserver: homeserver.to_string(),
|
||||
auth: BotAuth::new(),
|
||||
client: None,
|
||||
command_prefix: "!".to_string(),
|
||||
commands: Arc::new(Mutex::new(CommandRegistry::new())),
|
||||
event_handlers: Arc::new(Mutex::new(EventHandler::new())),
|
||||
rooms: Arc::new(Mutex::new(RoomManager::new())),
|
||||
shutdown_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_auth(self, username: &str, password: &str) -> Self {
|
||||
let auth = BotAuth::with_credentials(username, password);
|
||||
self.auth = Arc::new(RwLock::new(auth));
|
||||
pub fn with_auth(mut self, username: &str, password: &str) -> Self {
|
||||
self.auth = BotAuth::with_credentials(username, password);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn start(&self) -> anyhow::Result<()> {
|
||||
pub fn with_command_prefix(mut self, prefix: &str) -> Self {
|
||||
self.command_prefix = prefix.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_command(&self, name: &str, handler: CommandHandler) {
|
||||
let mut commands = self.commands.blocking_lock();
|
||||
commands.set_prefix(&self.command_prefix);
|
||||
commands.register(name, handler);
|
||||
}
|
||||
|
||||
pub fn on_event(&self, handler: impl Fn(BotEvent) + Send + Sync + 'static) {
|
||||
let mut handlers = self.event_handlers.blocking_lock();
|
||||
handlers.add_handler(Arc::new(handler));
|
||||
}
|
||||
|
||||
pub fn get_rooms(&self) -> Vec<RoomInfo> {
|
||||
self.rooms
|
||||
.blocking_lock()
|
||||
.list_rooms()
|
||||
.iter()
|
||||
.map(|r| (*r).clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn send_message(&self, room_id: &str, message: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let content =
|
||||
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(message);
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send message: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn send_notice(&self, room_id: &str, message: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let content =
|
||||
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::notice_plain(message);
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send notice: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn send_emote(&self, room_id: &str, message: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let content = RoomMessageEventContent::new(MessageType::Emote(
|
||||
EmoteMessageEventContent::plain(message),
|
||||
));
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send emote: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn edit_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
event_id: &str,
|
||||
new_body: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
let new_content = RoomMessageEventContentWithoutRelation::text_plain(new_body);
|
||||
let replaces = Replacement::new(eid, new_content);
|
||||
let mut content = RoomMessageEventContent::text_plain(new_body);
|
||||
content.relates_to = Some(Relation::Replacement(replaces));
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to edit message: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn delete_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
event_id: &str,
|
||||
reason: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
room.redact(&eid, reason, None)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to delete message: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn react(&self, room_id: &str, event_id: &str, key: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
let annotation = Annotation::new(eid.clone(), key.to_string());
|
||||
let react_content = ReactionEventContent::new(annotation);
|
||||
let response = room
|
||||
.send(react_content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to react: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn upload_media(
|
||||
&self,
|
||||
room_id: &str,
|
||||
data: Vec<u8>,
|
||||
content_type: &str,
|
||||
filename: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let mime_type: mime::Mime = content_type
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid MIME type: {}", e))?;
|
||||
let upload = client
|
||||
.media()
|
||||
.upload(&mime_type, data)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Upload failed: {}", e))?;
|
||||
let content = if mime_type.type_() == mime::IMAGE {
|
||||
RoomMessageEventContent::new(MessageType::Image(ImageMessageEventContent::plain(
|
||||
filename.to_string(),
|
||||
upload.content_uri,
|
||||
)))
|
||||
} else {
|
||||
RoomMessageEventContent::new(MessageType::File(FileMessageEventContent::plain(
|
||||
filename.to_string(),
|
||||
upload.content_uri,
|
||||
)))
|
||||
};
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send media: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn join_room(&self, room_id_or_alias: &str) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let alias: matrix_sdk::ruma::OwnedRoomOrAliasId = room_id_or_alias
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room alias: {}", e))?;
|
||||
let room = client
|
||||
.join_room_by_id_or_alias(&alias, &[])
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to join room: {}", e))?;
|
||||
Ok(room.room_id().to_string())
|
||||
}
|
||||
|
||||
pub async fn leave_room(&self, room_id: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
room.leave()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to leave room: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_room_members(&self, room_id: &str) -> anyhow::Result<Vec<String>> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let members = room
|
||||
.members(matrix_sdk::RoomMemberships::JOIN)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get members: {}", e))?;
|
||||
Ok(members.iter().map(|m| m.user_id().to_string()).collect())
|
||||
}
|
||||
|
||||
pub async fn set_room_name(&self, room_id: &str, name: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
room.set_name(name.to_string())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set room name: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_room_topic(&self, room_id: &str, topic: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
room.set_room_topic(topic)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set room topic: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_display_name(&self, user_id: &str) -> anyhow::Result<Option<String>> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid user ID: {}", e))?;
|
||||
let profile = client
|
||||
.get_profile(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get profile: {}", e))?;
|
||||
Ok(profile.displayname)
|
||||
}
|
||||
|
||||
pub async fn set_display_name(&self, name: &str) -> anyhow::Result<()> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
client
|
||||
.account()
|
||||
.set_display_name(Some(name))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set display name: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_reply(
|
||||
&self,
|
||||
room_id: &str,
|
||||
reply_to_event_id: &str,
|
||||
message: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = self
|
||||
.client
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Bot not started"))?;
|
||||
let rid: OwnedRoomId = room_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid room ID: {}", e))?;
|
||||
let room = client
|
||||
.get_room(&rid)
|
||||
.ok_or_else(|| anyhow::anyhow!("Room not found: {}", room_id))?;
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = reply_to_event_id
|
||||
.parse()
|
||||
.map_err(|e| anyhow::anyhow!("Invalid event ID: {}", e))?;
|
||||
let mut content = RoomMessageEventContent::text_plain(message);
|
||||
let reply = InReplyTo::new(eid);
|
||||
content.relates_to = Some(Relation::Reply { in_reply_to: reply });
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to send reply: {}", e))?;
|
||||
Ok(response.event_id.to_string())
|
||||
}
|
||||
|
||||
pub async fn start(&mut self) -> anyhow::Result<()> {
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&self.homeserver)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut guard = self.client.write().await;
|
||||
*guard = Some(client.clone());
|
||||
}
|
||||
|
||||
let auth = self.auth.read().await;
|
||||
client
|
||||
.matrix_auth()
|
||||
.login_username(&auth.username, &auth.password)
|
||||
.login_username(&self.auth.username, &self.auth.password)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
tracing::info!("Bot logged in as {}", auth.username);
|
||||
drop(auth);
|
||||
tracing::info!("Bot logged in as {}", self.auth.username);
|
||||
|
||||
self.client = Some(client.clone());
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
|
||||
let commands = self.commands.clone();
|
||||
let event_handlers = self.event_handlers.clone();
|
||||
let rooms = self.rooms.clone();
|
||||
|
||||
tracing::info!("Bot starting sync loop with manual event processing...");
|
||||
|
||||
let mut sync_token: Option<String> = None;
|
||||
loop {
|
||||
let mut settings = matrix_sdk::config::SyncSettings::new();
|
||||
if let Some(token) = sync_token.as_ref() {
|
||||
settings = settings.token(token.clone());
|
||||
}
|
||||
match client.sync_once(settings).await {
|
||||
Ok(response) => {
|
||||
sync_token = Some(response.next_batch);
|
||||
tokio::select! {
|
||||
result = client.sync_once(SyncSettings::new()) => {
|
||||
match result {
|
||||
Ok(response) => {
|
||||
{
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::SyncComplete);
|
||||
}
|
||||
|
||||
let handler = self.event_handler.read().await;
|
||||
handler.dispatch("sync");
|
||||
drop(handler);
|
||||
for (room_id, joined) in &response.rooms.join {
|
||||
let _room = match client.get_room(room_id) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let rooms = client.joined_rooms();
|
||||
let mut room_mgr = self.room_manager.write().await;
|
||||
for room in rooms {
|
||||
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||
room_mgr.add_room(crate::room::RoomInfo {
|
||||
room_id: room.room_id().to_string(),
|
||||
name,
|
||||
is_encrypted: room.is_encrypted().await.unwrap_or(false),
|
||||
});
|
||||
let room_id_str = room_id.to_string();
|
||||
|
||||
for event in &joined.timeline.events {
|
||||
let raw_json: serde_json::Value = match event.event.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type = raw_json.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let sender = raw_json.get("sender").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let event_id = event.event_id().map(|e| e.to_string()).unwrap_or_default();
|
||||
let content = raw_json.get("content").unwrap_or(&serde_json::Value::Null);
|
||||
|
||||
if event_type == "m.room.message" {
|
||||
let body = content.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let msgtype = content.get("msgtype").and_then(|v| v.as_str()).unwrap_or("m.text");
|
||||
let url = content.get("url").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
if msgtype == "m.image" {
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Image {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
body: body.to_string(),
|
||||
url: url.unwrap_or_default(),
|
||||
});
|
||||
drop(eh);
|
||||
} else if msgtype == "m.file" || msgtype == "m.video" || msgtype == "m.audio" {
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::File {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
body: body.to_string(),
|
||||
url: url.unwrap_or_default(),
|
||||
});
|
||||
drop(eh);
|
||||
} else if !body.is_empty() {
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Message {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
body: body.to_string(),
|
||||
});
|
||||
drop(eh);
|
||||
|
||||
let reg = commands.lock().await;
|
||||
reg.parse_and_execute(body, sender, &room_id_str);
|
||||
}
|
||||
} else if event_type == "m.reaction" {
|
||||
let key = content.get("m.relates_to")
|
||||
.and_then(|r| r.get("key"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let relates_to = content.get("m.relates_to")
|
||||
.and_then(|r| r.get("event_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Reaction {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
key: key.to_string(),
|
||||
relates_to: relates_to.to_string(),
|
||||
});
|
||||
} else if event_type == "m.room.redaction" {
|
||||
let redacts = raw_json.get("redacts").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let eh = event_handlers.lock().await;
|
||||
eh.dispatch(BotEvent::Redaction {
|
||||
room_id: room_id_str.clone(),
|
||||
event_id: event_id.clone(),
|
||||
sender: sender.to_string(),
|
||||
redacts: redacts.to_string(),
|
||||
});
|
||||
} else if event_type == "m.room.member" {
|
||||
let membership = content.get("membership").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let eh = event_handlers.lock().await;
|
||||
if membership == "join" {
|
||||
eh.dispatch(BotEvent::MemberJoined {
|
||||
room_id: room_id_str.clone(),
|
||||
user_id: sender.to_string(),
|
||||
});
|
||||
} else if membership == "leave" || membership == "ban" {
|
||||
eh.dispatch(BotEvent::MemberLeft {
|
||||
room_id: room_id_str.clone(),
|
||||
user_id: sender.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let joined_rooms = client.joined_rooms();
|
||||
let mut room_mgr = rooms.lock().await;
|
||||
room_mgr.rooms.clear();
|
||||
for room in &joined_rooms {
|
||||
let room_id = room.room_id().to_string();
|
||||
let name = room.display_name().await
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|_| room_id.clone());
|
||||
let is_encrypted = room.is_encrypted().await.unwrap_or(false);
|
||||
room_mgr.add_room(RoomInfo::new(room_id, name, is_encrypted));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Sync error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Sync error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
_ = shutdown_rx.changed() => {
|
||||
tracing::info!("Bot shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> anyhow::Result<()> {
|
||||
let mut guard = self.client.write().await;
|
||||
*guard = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_message(&self, room_id: &str, message: &str) -> anyhow::Result<()> {
|
||||
let guard = self.client.read().await;
|
||||
let client = guard.as_ref().ok_or(anyhow::anyhow!("Not connected"))?;
|
||||
|
||||
let rid = matrix_sdk::ruma::room_id!(room_id)
|
||||
.map_err(|_| anyhow::anyhow!("Invalid room ID"))?;
|
||||
let room = client.get_room(&rid)
|
||||
.ok_or(anyhow::anyhow!("Room not found"))?;
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(message);
|
||||
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||
room.send(content, Some(&txn_id)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn on_command(&self, name: &str, handler: Box<dyn Fn(&str, &str) + Send + Sync>) {
|
||||
let mut commands = self.commands.write().await;
|
||||
commands.register(name, handler);
|
||||
}
|
||||
|
||||
pub async fn on_event(&self, handler: Box<dyn Fn(&str) + Send + Sync>) {
|
||||
let mut event_handler = self.event_handler.write().await;
|
||||
event_handler.add_handler(handler);
|
||||
}
|
||||
|
||||
pub async fn get_rooms(&self) -> Vec<crate::room::RoomInfo> {
|
||||
let room_mgr = self.room_manager.read().await;
|
||||
room_mgr.list_rooms().into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn handle_message(&self, room_id: &str, sender: &str, body: &str) {
|
||||
let commands = self.commands.read().await;
|
||||
commands.parse_and_execute(body, sender);
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
}
|
||||
self.client = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BotClient {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,31 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
type CommandHandler = Box<dyn Fn(&str, &str) + Send + Sync>;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommandContext {
|
||||
pub room_id: String,
|
||||
pub sender: String,
|
||||
pub args: String,
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
pub type CommandHandler = Arc<dyn Fn(CommandContext) + Send + Sync>;
|
||||
|
||||
pub struct CommandRegistry {
|
||||
commands: HashMap<String, CommandHandler>,
|
||||
prefix: String,
|
||||
}
|
||||
|
||||
impl Default for CommandRegistry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
commands: HashMap::new(),
|
||||
prefix: "!".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
@@ -15,9 +34,11 @@ impl CommandRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_prefix(mut self, prefix: &str) -> Self {
|
||||
self.prefix = prefix.to_string();
|
||||
self
|
||||
pub fn with_prefix(prefix: &str) -> Self {
|
||||
Self {
|
||||
commands: HashMap::new(),
|
||||
prefix: prefix.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_prefix(&mut self, prefix: &str) {
|
||||
@@ -32,18 +53,24 @@ impl CommandRegistry {
|
||||
self.commands.remove(name);
|
||||
}
|
||||
|
||||
pub fn parse_and_execute(&self, message: &str, sender: &str) {
|
||||
pub fn parse_and_execute(&self, message: &str, sender: &str, room_id: &str) {
|
||||
if !message.starts_with(&self.prefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
let content = &message[self.prefix.len()..];
|
||||
let parts: Vec<&str> = content.splitn(2, ' ').collect();
|
||||
let parts: Vec<&str> = content.splitn(2, char::is_whitespace).collect();
|
||||
let command = parts[0];
|
||||
let args = parts.get(1).unwrap_or(&"");
|
||||
let args = parts.get(1).unwrap_or(&"").to_string();
|
||||
|
||||
if let Some(handler) = self.commands.get(command) {
|
||||
handler(args, sender);
|
||||
let ctx = CommandContext {
|
||||
room_id: room_id.to_string(),
|
||||
sender: sender.to_string(),
|
||||
args,
|
||||
command: command.to_string(),
|
||||
};
|
||||
handler(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +82,141 @@ impl CommandRegistry {
|
||||
self.commands.contains_key(name)
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedCommandRegistry = Arc<Mutex<CommandRegistry>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_registry() {
|
||||
let reg = CommandRegistry::new();
|
||||
assert!(reg.list_commands().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_registry() {
|
||||
let reg = CommandRegistry::default();
|
||||
assert!(reg.list_commands().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_prefix_sets_custom_prefix() {
|
||||
let mut reg = CommandRegistry::with_prefix("~");
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"test",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("~test", "user", "!room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_and_execute_command() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"hello",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("!hello", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_command_with_args() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
let captured = Arc::new(Mutex::new(String::new()));
|
||||
let captured_clone = captured.clone();
|
||||
reg.register(
|
||||
"echo",
|
||||
Arc::new(move |ctx| {
|
||||
let _ = captured_clone.try_lock().map(|mut g| *g = ctx.args);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("!echo hello world", "user", "room");
|
||||
assert_eq!(captured_args(&captured), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_message_without_prefix() {
|
||||
let reg = CommandRegistry::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let mut reg = reg;
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"hello",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("hello", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_unknown_command() {
|
||||
let reg = CommandRegistry::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let mut reg = reg;
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"hello",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("!unknown", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_removes_command() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
reg.register("hello", Arc::new(|_| {}));
|
||||
assert!(reg.has_command("hello"));
|
||||
reg.unregister("hello");
|
||||
assert!(!reg.has_command("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_prefix_changes_prefix() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
reg.set_prefix("~");
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let counter_clone = counter.clone();
|
||||
reg.register(
|
||||
"test",
|
||||
Arc::new(move |_| {
|
||||
counter_clone.fetch_add(1, Ordering::SeqCst);
|
||||
}),
|
||||
);
|
||||
reg.parse_and_execute("~test", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
reg.parse_and_execute("!test", "user", "room");
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_commands_returns_all_names() {
|
||||
let mut reg = CommandRegistry::new();
|
||||
reg.register("hello", Arc::new(|_| {}));
|
||||
reg.register("ping", Arc::new(|_| {}));
|
||||
let mut names = reg.list_commands();
|
||||
names.sort();
|
||||
assert_eq!(names, vec!["hello", "ping"]);
|
||||
}
|
||||
|
||||
fn captured_args(captured: &Arc<Mutex<String>>) -> String {
|
||||
captured.try_lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,80 @@
|
||||
type EventCallback = Box<dyn Fn(&str) + Send + Sync>;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum BotEvent {
|
||||
Message {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
},
|
||||
Image {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
url: String,
|
||||
},
|
||||
File {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
url: String,
|
||||
},
|
||||
Reaction {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
key: String,
|
||||
relates_to: String,
|
||||
},
|
||||
Redaction {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
redacts: String,
|
||||
},
|
||||
MemberJoined {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
MemberLeft {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
RoomJoined {
|
||||
room_id: String,
|
||||
room_name: String,
|
||||
},
|
||||
RoomLeft {
|
||||
room_id: String,
|
||||
},
|
||||
SyncComplete,
|
||||
}
|
||||
|
||||
type EventCallback = Arc<dyn Fn(BotEvent) + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct EventHandler {
|
||||
handlers: Vec<EventCallback>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new() -> Self {
|
||||
Self { handlers: Vec::new() }
|
||||
Self {
|
||||
handlers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_handler(&mut self, handler: EventCallback) {
|
||||
self.handlers.push(handler);
|
||||
}
|
||||
|
||||
pub fn dispatch(&self, event: &str) {
|
||||
pub fn dispatch(&self, event: BotEvent) {
|
||||
for handler in &self.handlers {
|
||||
handler(event);
|
||||
handler(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,3 +82,106 @@ impl EventHandler {
|
||||
self.handlers.len()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedEventHandler = Arc<Mutex<EventHandler>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_handler() {
|
||||
let handler = EventHandler::new();
|
||||
assert_eq!(handler.handler_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_handler() {
|
||||
let handler = EventHandler::default();
|
||||
assert_eq!(handler.handler_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_handler_increments_count() {
|
||||
let mut handler = EventHandler::new();
|
||||
handler.add_handler(Arc::new(|_| {}));
|
||||
assert_eq!(handler.handler_count(), 1);
|
||||
handler.add_handler(Arc::new(|_| {}));
|
||||
assert_eq!(handler.handler_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_calls_all_handlers() {
|
||||
let mut handler = EventHandler::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let c1 = counter.clone();
|
||||
handler.add_handler(Arc::new(move |_| {
|
||||
c1.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
let c2 = counter.clone();
|
||||
handler.add_handler(Arc::new(move |_| {
|
||||
c2.fetch_add(1, Ordering::SeqCst);
|
||||
}));
|
||||
handler.dispatch(BotEvent::SyncComplete);
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_message_event() {
|
||||
let mut handler = EventHandler::new();
|
||||
let captured = Arc::new(Mutex::new(String::new()));
|
||||
let captured_clone = captured.clone();
|
||||
handler.add_handler(Arc::new(move |event| {
|
||||
if let BotEvent::Message { body, .. } = event {
|
||||
let _ = captured_clone.try_lock().map(|mut g| *g = body);
|
||||
}
|
||||
}));
|
||||
handler.dispatch(BotEvent::Message {
|
||||
room_id: "!room:server".to_string(),
|
||||
event_id: "$event".to_string(),
|
||||
sender: "@user:server".to_string(),
|
||||
body: "hello".to_string(),
|
||||
});
|
||||
assert_eq!(captured.try_lock().unwrap().as_str(), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_reaction_event() {
|
||||
let mut handler = EventHandler::new();
|
||||
let counter = Arc::new(AtomicUsize::new(0));
|
||||
let c = counter.clone();
|
||||
handler.add_handler(Arc::new(move |event| {
|
||||
if let BotEvent::Reaction { key, .. } = event {
|
||||
if key == "👍" {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}));
|
||||
handler.dispatch(BotEvent::Reaction {
|
||||
room_id: "!room:server".to_string(),
|
||||
event_id: "$event".to_string(),
|
||||
sender: "@user:server".to_string(),
|
||||
key: "👍".to_string(),
|
||||
relates_to: "$orig".to_string(),
|
||||
});
|
||||
assert_eq!(counter.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_member_joined_event() {
|
||||
let mut handler = EventHandler::new();
|
||||
let captured = Arc::new(Mutex::new(String::new()));
|
||||
let c = captured.clone();
|
||||
handler.add_handler(Arc::new(move |event| {
|
||||
if let BotEvent::MemberJoined { user_id, .. } = event {
|
||||
let _ = c.try_lock().map(|mut g| *g = user_id);
|
||||
}
|
||||
}));
|
||||
handler.dispatch(BotEvent::MemberJoined {
|
||||
room_id: "!room:server".to_string(),
|
||||
user_id: "@alice:server".to_string(),
|
||||
});
|
||||
assert_eq!(captured.try_lock().unwrap().as_str(), "@alice:server");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
//! EifelDC Bot SDK — Build Matrix bots for the EifelDC platform.
|
||||
//!
|
||||
//! # Quick Start
|
||||
//! ```no_run
|
||||
//! use std::sync::Arc;
|
||||
//! use eifeldc_bot_sdk::{BotClient, BotEvent, CommandContext};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() -> anyhow::Result<()> {
|
||||
//! let mut bot = BotClient::new("https://matrix.example.org")
|
||||
//! .with_auth("botuser", "botpassword");
|
||||
//!
|
||||
//! bot.on_event(|event| {
|
||||
//! match event {
|
||||
//! BotEvent::Message { room_id, sender, body, .. } => {
|
||||
//! println!("{} in {}: {}", sender, room_id, body);
|
||||
//! }
|
||||
//! _ => {}
|
||||
//! }
|
||||
//! });
|
||||
//!
|
||||
//! bot.on_command("hello", Arc::new(|ctx: CommandContext| {
|
||||
//! println!("Hello command from {} in {}!", ctx.sender, ctx.room_id);
|
||||
//! }));
|
||||
//!
|
||||
//! bot.start().await?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod auth;
|
||||
pub mod client;
|
||||
pub mod commands;
|
||||
pub mod event;
|
||||
pub mod room;
|
||||
pub mod auth;
|
||||
|
||||
pub use client::BotClient;
|
||||
pub use auth::BotAuth;
|
||||
pub use client::BotClient;
|
||||
pub use commands::{CommandContext, CommandHandler, CommandRegistry};
|
||||
pub use event::{BotEvent, EventHandler};
|
||||
pub use room::{RoomInfo, RoomManager};
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RoomInfo {
|
||||
pub room_id: String,
|
||||
pub name: String,
|
||||
pub is_encrypted: bool,
|
||||
}
|
||||
|
||||
impl RoomInfo {
|
||||
pub fn new(room_id: String, name: String, is_encrypted: bool) -> Self {
|
||||
Self {
|
||||
room_id,
|
||||
name,
|
||||
is_encrypted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RoomManager {
|
||||
rooms: HashMap<String, RoomInfo>,
|
||||
pub rooms: HashMap<String, RoomInfo>,
|
||||
}
|
||||
|
||||
impl RoomManager {
|
||||
pub fn new() -> Self {
|
||||
Self { rooms: HashMap::new() }
|
||||
Self {
|
||||
rooms: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_room(&mut self, room: RoomInfo) {
|
||||
@@ -36,3 +51,111 @@ impl RoomManager {
|
||||
self.rooms.len()
|
||||
}
|
||||
}
|
||||
|
||||
pub type SharedRoomManager = Arc<Mutex<RoomManager>>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn new_creates_empty_manager() {
|
||||
let mgr = RoomManager::new();
|
||||
assert_eq!(mgr.room_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_creates_empty_manager() {
|
||||
let mgr = RoomManager::default();
|
||||
assert_eq!(mgr.room_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_room_inserts_room() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_multiple_rooms() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new("!r1:server".into(), "A".into(), false));
|
||||
mgr.add_room(RoomInfo::new("!r2:server".into(), "B".into(), true));
|
||||
assert_eq!(mgr.room_count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_room_returns_correct_room() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
let room = mgr.get_room("!room1:server").unwrap();
|
||||
assert_eq!(room.name, "Room One");
|
||||
assert!(!room.is_encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_room_returns_none_for_missing() {
|
||||
let mgr = RoomManager::new();
|
||||
assert!(mgr.get_room("!nonexistent:server").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_room_deletes_room() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
mgr.remove_room("!room1:server");
|
||||
assert_eq!(mgr.room_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_nonexistent_room_is_noop() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Room One".into(),
|
||||
false,
|
||||
));
|
||||
mgr.remove_room("!nonexistent:server");
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_room_overwrites_existing() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"Old Name".into(),
|
||||
false,
|
||||
));
|
||||
mgr.add_room(RoomInfo::new(
|
||||
"!room1:server".into(),
|
||||
"New Name".into(),
|
||||
true,
|
||||
));
|
||||
assert_eq!(mgr.room_count(), 1);
|
||||
assert_eq!(mgr.get_room("!room1:server").unwrap().name, "New Name");
|
||||
assert!(mgr.get_room("!room1:server").unwrap().is_encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_rooms_returns_all() {
|
||||
let mut mgr = RoomManager::new();
|
||||
mgr.add_room(RoomInfo::new("!r1:server".into(), "A".into(), false));
|
||||
mgr.add_room(RoomInfo::new("!r2:server".into(), "B".into(), true));
|
||||
assert_eq!(mgr.list_rooms().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
matrix-sdk = { workspace = true }
|
||||
matrix-sdk-base = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
@@ -15,15 +13,9 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tauri = { version = "1", features = ["shell-open", "dialog-all"] }
|
||||
tauri-plugin-oauth = "2.0"
|
||||
sled = "0.34"
|
||||
futures = "0.3"
|
||||
async-trait = "0.1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
rand = "0.8"
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "1", features = [] }
|
||||
tauri-build = { version = "2", features = [] }
|
||||
1
client/gen/schemas/acl-manifests.json
Normal file
1
client/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
client/gen/schemas/capabilities.json
Normal file
1
client/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
2630
client/gen/schemas/desktop-schema.json
Normal file
2630
client/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2630
client/gen/schemas/linux-schema.json
Normal file
2630
client/gen/schemas/linux-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
15
client/src-tauri/capabilities/default.json
Normal file
15
client/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for EifelDC",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"shell:allow-open",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-ask",
|
||||
"dialog:allow-confirm"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 505 B After Width: | Height: | Size: 471 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 935 B |
Binary file not shown.
|
Before Width: | Height: | Size: 164 B After Width: | Height: | Size: 185 B |
@@ -1,69 +1,55 @@
|
||||
{
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../src-ui/dist"
|
||||
},
|
||||
"package": {
|
||||
"productName": "EifelDC",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"tauri": {
|
||||
"allowlist": {
|
||||
"all": false,
|
||||
"shell": { "open": true },
|
||||
"dialog": { "all": true },
|
||||
"window": { "all": true }
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"identifier": "de.eifeldc",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [],
|
||||
"copyright": "",
|
||||
"category": "SocialNetworking",
|
||||
"shortDescription": "EifelDC - Matrix Chat Client",
|
||||
"longDescription": "EifelDC is a Discord-like Matrix chat client built with Tauri, Svelte and Rust.",
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": ""
|
||||
},
|
||||
"macOS": {
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "10.15",
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": null,
|
||||
"entitlements": "macos/EifelDC.entitlements",
|
||||
"providerShortName": null
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src https: wss: http://localhost:*; img-src https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
},
|
||||
"windows": [
|
||||
{
|
||||
"title": "EifelDC",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"systemTray": {
|
||||
"iconPath": "icons/32x32.png",
|
||||
"iconAsTemplate": true
|
||||
}
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "EifelDC",
|
||||
"version": "0.1.0",
|
||||
"identifier": "de.eifeldc",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"frontendDist": "../src-ui/dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "EifelDC",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src https: wss: http://localhost:*; img-src https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"icon": [
|
||||
"src-tauri/icons/32x32.png",
|
||||
"src-tauri/icons/128x128.png",
|
||||
"src-tauri/icons/128x128@2x.png",
|
||||
"src-tauri/icons/icon.icns",
|
||||
"src-tauri/icons/icon.ico"
|
||||
],
|
||||
"category": "SocialNetworking",
|
||||
"shortDescription": "EifelDC - Matrix Chat Client",
|
||||
"longDescription": "EifelDC is a Discord-like Matrix chat client built with Tauri, Svelte and Rust.",
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"entitlements": "macos/EifelDC.entitlements"
|
||||
},
|
||||
"windows": {
|
||||
"digestAlgorithm": "sha256"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
130
client/src-ui/package-lock.json
generated
130
client/src-ui/package-lock.json
generated
@@ -7,6 +7,9 @@
|
||||
"": {
|
||||
"name": "eifeldc-ui",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"livekit-client": "^2.18.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3",
|
||||
"@tauri-apps/api": "^1",
|
||||
@@ -32,6 +35,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -462,6 +471,21 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.45.3",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.3.tgz",
|
||||
"integrity": "sha512-WmMxBTsy4dRBqcrswFwUUlgq3Z0nnhOqKR6tX749Rb/PcB1yBMUtrHxZvcsS6qi3/5+86zHeVG+exmu1sZqfJg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
||||
@@ -869,6 +893,13 @@
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1138,6 +1169,15 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -1290,6 +1330,15 @@
|
||||
"@types/estree": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -1300,6 +1349,26 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.18.7",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.18.7.tgz",
|
||||
"integrity": "sha512-8mMNfJ9sPTWT9W5CsV23yJhut4vtw0M4c/AR5diRvLn6XsUDij7Odr6GrI9Oj5/T5vDis4y2JTumMiTweGlqMA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.45.3",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
@@ -1307,6 +1376,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -1569,6 +1651,16 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
@@ -1595,6 +1687,21 @@
|
||||
"rimraf": "^2.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
|
||||
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/sorcery": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz",
|
||||
@@ -1774,9 +1881,17 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1866,6 +1981,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
@@ -18,5 +18,8 @@
|
||||
"tslib": "^2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"livekit-client": "^2.18.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
import ChatArea from './components/ChatArea.svelte';
|
||||
import MemberList from './components/MemberList.svelte';
|
||||
import VoicePanel from './components/VoicePanel.svelte';
|
||||
import { currentUser, refreshChannels } from './lib/store';
|
||||
import { currentUser, refreshChannels, handleWsEvent, addServer, switchServer } from './lib/store';
|
||||
import { voiceRoom, disconnectFromVoice } from './lib/voice';
|
||||
import { connectWebSocket, disconnectWebSocket } from './lib/api';
|
||||
|
||||
let loggedIn = false;
|
||||
let loading = true;
|
||||
@@ -15,7 +17,21 @@
|
||||
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
|
||||
}
|
||||
|
||||
async function setupTauriSync() {
|
||||
if (!isTauri()) return;
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
await listen<Record<string, unknown>>('sync-event', (event) => {
|
||||
handleWsEvent(event.payload as any);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to listen for Tauri sync events', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await setupTauriSync();
|
||||
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/tauri');
|
||||
@@ -51,6 +67,7 @@
|
||||
status: 'online',
|
||||
});
|
||||
await refreshChannels();
|
||||
connectWebSocket(handleWsEvent);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -60,6 +77,24 @@
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
function handleLogin(e: CustomEvent) {
|
||||
loggedIn = true;
|
||||
const homeserver = e.detail.homeserver;
|
||||
if (homeserver) {
|
||||
const server = addServer(homeserver);
|
||||
switchServer(server);
|
||||
}
|
||||
if (!isTauri()) {
|
||||
connectWebSocket(handleWsEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
disconnectWebSocket();
|
||||
disconnectFromVoice();
|
||||
loggedIn = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
@@ -73,7 +108,7 @@
|
||||
<p>Loading EifelDC...</p>
|
||||
</div>
|
||||
{:else if !loggedIn}
|
||||
<LoginScreen on:login={() => (loggedIn = true)} />
|
||||
<LoginScreen on:login={handleLogin} />
|
||||
{:else}
|
||||
<div class="app-layout">
|
||||
<ServerSidebar />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { currentServer, channels, currentChannel, textChannels, voiceChannels, currentUser, voiceState, refreshChannels } from '../lib/store';
|
||||
import { currentServer, channels, currentChannel, textChannels, voiceChannels, currentUser, voiceState, refreshChannels, userProfile, refreshProfile, updateDisplayName, unreadCounts } from '../lib/store';
|
||||
import { connectToVoice, disconnectFromVoice, toggleMute, toggleDeafen, voiceRoom } from '../lib/voice';
|
||||
import { onMount } from 'svelte';
|
||||
import { getJoinedRooms, joinVoiceChannel, createRoom, joinRoom, leaveRoom, logout, setPresence, type VoiceStateInfo, type RoomInfo } from '../lib/api';
|
||||
import { getJoinedRooms, createRoom, joinRoom, leaveRoom, logout, setPresence, type RoomInfo } from '../lib/api';
|
||||
|
||||
let showCreateRoom = false;
|
||||
let showJoinRoom = false;
|
||||
@@ -12,6 +13,8 @@
|
||||
let joinError = '';
|
||||
let showSettings = false;
|
||||
let presenceStatus: 'online' | 'idle' | 'dnd' | 'offline' = 'online';
|
||||
let editingName = false;
|
||||
let editNameValue = '';
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -26,6 +29,7 @@
|
||||
} catch (e) {
|
||||
console.error('Failed to load rooms', e);
|
||||
}
|
||||
refreshProfile();
|
||||
});
|
||||
|
||||
async function handleCreateRoom() {
|
||||
@@ -55,13 +59,11 @@
|
||||
|
||||
async function handleJoinVoice(channel: any) {
|
||||
try {
|
||||
const result: VoiceStateInfo = await joinVoiceChannel(channel.id);
|
||||
voiceState.set({
|
||||
channelId: result.room_id,
|
||||
muted: result.muted,
|
||||
deafened: result.deafened,
|
||||
streaming: result.streaming,
|
||||
});
|
||||
await connectToVoice(channel.id);
|
||||
voiceState.update(s => ({
|
||||
...s,
|
||||
channelId: channel.id,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to join voice', e);
|
||||
}
|
||||
@@ -88,6 +90,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveName() {
|
||||
if (!editNameValue.trim()) return;
|
||||
try {
|
||||
await updateDisplayName(editNameValue.trim());
|
||||
editingName = false;
|
||||
} catch (e) {
|
||||
console.error('Failed to update name', e);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditName() {
|
||||
editNameValue = $userProfile?.display_name || $currentUser?.username || '';
|
||||
editingName = true;
|
||||
}
|
||||
|
||||
async function handlePresenceChange(status: string) {
|
||||
try {
|
||||
await setPresence(status);
|
||||
@@ -140,13 +157,16 @@
|
||||
<button
|
||||
class="channel-item"
|
||||
class:active={$currentChannel?.id === channel.id}
|
||||
on:click={() => currentChannel.set(channel)}
|
||||
on:click={() => { currentChannel.set(channel); unreadCounts.update(c => { delete c[channel.id]; return c; }); }}
|
||||
on:contextmenu|preventDefault={() => handleLeaveRoom(channel.id)}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" class="channel-icon">
|
||||
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.89044C10.2015 3 10.4371 3.28107 10.3827 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8904C18.2015 3 18.4371 3.28107 18.3827 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.755C20.0656 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8696C13.5585 21 13.3229 20.7189 13.3773 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z"/>
|
||||
</svg>
|
||||
<span>{channel.name}</span>
|
||||
{#if $unreadCounts[channel.id]}
|
||||
<span class="unread-badge">{$unreadCounts[channel.id]}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -159,8 +179,14 @@
|
||||
{#each $voiceChannels as channel}
|
||||
<button
|
||||
class="channel-item voice"
|
||||
class:active={$currentChannel?.id === channel.id}
|
||||
on:click={() => handleJoinVoice(channel)}
|
||||
class:active={$voiceRoom.roomId === channel.id}
|
||||
on:click={() => {
|
||||
if ($voiceRoom.roomId === channel.id) {
|
||||
disconnectFromVoice();
|
||||
} else {
|
||||
handleJoinVoice(channel);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" class="channel-icon">
|
||||
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/>
|
||||
@@ -220,17 +246,31 @@
|
||||
|
||||
<div class="user-panel">
|
||||
<div class="user-info">
|
||||
<div class="avatar-small">{$currentUser?.username?.charAt(0).toUpperCase() || '?'}</div>
|
||||
<div class="avatar-small">{$userProfile?.display_name?.charAt(0).toUpperCase() || $currentUser?.username?.charAt(0).toUpperCase() || '?'}</div>
|
||||
<div class="user-details">
|
||||
<span class="username">{$currentUser?.username || 'Benutzer'}</span>
|
||||
<span class="status-text">{presenceStatus === 'online' ? 'Online' : presenceStatus === 'idle' ? 'Abwesend' : presenceStatus === 'dnd' ? 'Nicht stören' : 'Offline'}</span>
|
||||
{#if editingName}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editNameValue}
|
||||
placeholder="Anzeigename"
|
||||
class="edit-name-input"
|
||||
on:keydown={(e) => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') editingName = false; }}
|
||||
/>
|
||||
<div class="edit-name-actions">
|
||||
<button class="btn-icon-small" on:click={handleSaveName}>✓</button>
|
||||
<button class="btn-icon-small" on:click={() => editingName = false}>✕</button>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="username" on:click={startEditName} title="Klicken zum Bearbeiten">{$userProfile?.display_name || $currentUser?.username || 'Benutzer'}</span>
|
||||
<span class="status-text">{presenceStatus === 'online' ? 'Online' : presenceStatus === 'idle' ? 'Abwesend' : presenceStatus === 'dnd' ? 'Nicht stören' : 'Offline'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<button class="btn-icon" title="Mikrofon" on:click={() => { if ($voiceState.channelId) voiceState.update(s => ({ ...s, muted: !s.muted })); }}>
|
||||
<button class="btn-icon" title="Mikrofon" on:click={() => { if ($voiceRoom.connected) toggleMute(); }}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" title="Kopfhörer" on:click={() => { if ($voiceState.channelId) voiceState.update(s => ({ ...s, deafened: !s.deafened })); }}>
|
||||
<button class="btn-icon" title="Kopfhörer" on:click={() => { if ($voiceRoom.connected) toggleDeafen(); }}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/></svg>
|
||||
</button>
|
||||
<button class="btn-icon" title="Einstellungen" on:click={() => showSettings = !showSettings}>⚙</button>
|
||||
@@ -322,9 +362,20 @@
|
||||
}
|
||||
|
||||
.channel-item.active {
|
||||
background: var(--bg-active);
|
||||
background-color: var(--bg-active);
|
||||
color: var(--text-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
margin-left: auto;
|
||||
min-width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.channel-icon {
|
||||
@@ -383,11 +434,45 @@
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.username:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.edit-name-input {
|
||||
width: 100%;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.edit-name-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btn-icon-small {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-icon-small:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
187
client/src-ui/src/components/EmojiPicker.svelte
Normal file
187
client/src-ui/src/components/EmojiPicker.svelte
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getCustomEmoji } from '../lib/api';
|
||||
|
||||
export let onSelect: (emoji: string) => void;
|
||||
export let roomId: string | null = null;
|
||||
export let show = false;
|
||||
|
||||
let search = '';
|
||||
let customEmojis: { id: string; name: string; url: string; animated: boolean }[] = [];
|
||||
|
||||
const commonEmojis = [
|
||||
'😀','😂','🥲','😊','😍','🥰','😘','😜','🤪','😎',
|
||||
'🤔','🤗','🤩','🥳','😤','😠','🥺','😢','😭','🤯',
|
||||
'🥶','🥵','😱','😨','😰','🤮','🤧','😴','🤤','😈',
|
||||
'👍','👎','👏','🙌','🤝','✌️','🤞','🤟','💪','🖕',
|
||||
'❤️','🧡','💛','💚','💙','💜','🖤','🤍','💔','❣️',
|
||||
'🔥','✨','⭐','💫','🌟','💯','🎉','🎊','🏆','🎯',
|
||||
'👀','🫡','🫠','🫣','🫢','🤌','🤌','💅','🤳','🙏',
|
||||
];
|
||||
|
||||
const categories = [
|
||||
{ name: 'Smileys', emojis: '😀😂🥲😊😍🥰😘😜🤪😎🤔🤗🤩🥳😤😠🥺😢😭🤯🥶🥵😱😨😰🤮🤧😴🤤😈👀🫡🫠🫣🫢🤌💅🤳🙏' },
|
||||
{ name: 'Gesten', emojis: '👍👎👏🙌🤝✌️🤞🤟💪🖕' },
|
||||
{ name: 'Herzen', emojis: '❤️🧡💛💚💙💜🖤🤍💔❣️' },
|
||||
{ name: 'Symbole', emojis: '🔥✨⭐💫🌟💯🎉🎊🏆🎯' },
|
||||
];
|
||||
|
||||
$: filteredCommon = search
|
||||
? commonEmojis.filter((_, i) => {
|
||||
const allEmojis = categories.flatMap(c => c.emojis.split(''));
|
||||
return true;
|
||||
})
|
||||
: commonEmojis;
|
||||
|
||||
$: filteredCustom = search
|
||||
? customEmojis.filter(e => e.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: customEmojis;
|
||||
|
||||
onMount(async () => {
|
||||
if (roomId) {
|
||||
try {
|
||||
customEmojis = await getCustomEmoji(roomId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load custom emojis', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function pick(emoji: string) {
|
||||
onSelect(emoji);
|
||||
show = false;
|
||||
search = '';
|
||||
}
|
||||
|
||||
function pickCustom(emoji: { name: string }) {
|
||||
onSelect(`:${emoji.name}:`);
|
||||
show = false;
|
||||
search = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="emoji-picker" on:click|stopPropagation>
|
||||
<div class="emoji-search">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={search}
|
||||
placeholder="Emoji suchen..."
|
||||
on:keydown|stopPropagation={() => {}}
|
||||
/>
|
||||
</div>
|
||||
<div class="emoji-grid">
|
||||
{#each categories as cat}
|
||||
<div class="emoji-category">
|
||||
<div class="emoji-category-name">{cat.name}</div>
|
||||
<div class="emoji-row">
|
||||
{#each cat.emojis.split('') as emoji}
|
||||
<button class="emoji-btn" on:click={() => pick(emoji)}>{emoji}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if filteredCustom.length > 0}
|
||||
<div class="emoji-category">
|
||||
<div class="emoji-category-name">Custom</div>
|
||||
<div class="emoji-row">
|
||||
{#each filteredCustom as emoji}
|
||||
<button class="emoji-btn custom-emoji" on:click={() => pickCustom(emoji)} title={emoji.name}>
|
||||
{emoji.name.slice(0, 2)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
left: 16px;
|
||||
width: 340px;
|
||||
max-height: 320px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.emoji-search {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.emoji-search input {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.emoji-search input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.emoji-category {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.emoji-category-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.emoji-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.3rem;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.emoji-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.custom-emoji {
|
||||
font-size: 0.7rem;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -19,7 +19,7 @@
|
||||
if (mode === 'login') {
|
||||
const result = await login(homeserver, username, password);
|
||||
if (result.success) {
|
||||
dispatch('login', { userId: result.user_id });
|
||||
dispatch('login', { userId: result.user_id, homeserver });
|
||||
} else {
|
||||
error = result.error || 'Login fehlgeschlagen';
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
const result = await register(homeserver, username, password);
|
||||
if (result.success) {
|
||||
dispatch('login', { userId: result.user_id });
|
||||
dispatch('login', { userId: result.user_id, homeserver });
|
||||
} else {
|
||||
error = result.error || 'Registrierung fehlgeschlagen';
|
||||
}
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { servers, currentServer } from '../lib/store';
|
||||
import { onMount } from 'svelte';
|
||||
import { servers, currentServer, addServer, removeServer, switchServer, currentUser } from '../lib/store';
|
||||
|
||||
let showAddServer = false;
|
||||
let newServerUrl = '';
|
||||
let addError = '';
|
||||
|
||||
function selectServer(server: any) {
|
||||
currentServer.set(server);
|
||||
function handleAddServer() {
|
||||
addError = '';
|
||||
let url = newServerUrl.trim();
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'https://' + url;
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
addError = 'Ungültige URL';
|
||||
return;
|
||||
}
|
||||
const server = addServer(url);
|
||||
if ($currentServer === null || !$servers.some(s => s.id === $currentServer?.id)) {
|
||||
switchServer(server);
|
||||
}
|
||||
showAddServer = false;
|
||||
newServerUrl = '';
|
||||
}
|
||||
|
||||
function handleRemoveServer(id: string, e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
removeServer(id);
|
||||
}
|
||||
|
||||
function extractUsername(userId: string): string {
|
||||
return userId.split(':')[0].replace('@', '');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="server-sidebar">
|
||||
<div class="server-list">
|
||||
<button class="server-icon home" on:click={() => currentServer.set(null)}>
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor">
|
||||
<path d="M12 2L2 12h3v8h6v-6h2v6h6v-8h3L12 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
{#each $servers as server}
|
||||
<button
|
||||
class="server-icon"
|
||||
class:active={$currentServer?.id === server.id}
|
||||
on:click={() => selectServer(server)}
|
||||
on:click={() => switchServer(server)}
|
||||
title={server.name}
|
||||
>
|
||||
{#if server.iconUrl}
|
||||
@@ -32,17 +50,43 @@
|
||||
<span class="server-initial">{server.name.charAt(0).toUpperCase()}</span>
|
||||
{/if}
|
||||
<div class="pill"></div>
|
||||
<button class="remove-btn" on:click={(e) => handleRemoveServer(server.id, e)} title="Server entfernen">x</button>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button class="server-icon add" on:click={() => showAddServer = !showAddServer} title="Server beitreten">
|
||||
<button class="server-icon add" on:click={() => showAddServer = !showAddServer} title="Server hinzufügen">
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $currentUser}
|
||||
<div class="user-section">
|
||||
<div class="user-bubble">{extractUsername($currentUser.id).charAt(0).toUpperCase()}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showAddServer}
|
||||
<div class="modal-overlay" on:click={() => showAddServer = false}>
|
||||
<div class="modal" on:click|stopPropagation>
|
||||
<h3>Server hinzufügen</h3>
|
||||
{#if addError}
|
||||
<div class="modal-error">{addError}</div>
|
||||
{/if}
|
||||
<div class="modal-field">
|
||||
<label>Homeserver-URL</label>
|
||||
<input type="text" bind:value={newServerUrl} placeholder="matrix.example.org" on:keydown={(e) => { if (e.key === 'Enter') handleAddServer(); }} />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-secondary" on:click={() => showAddServer = false}>Abbrechen</button>
|
||||
<button class="btn-primary" on:click={handleAddServer} disabled={!newServerUrl.trim()}>Hinzufügen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.server-sidebar {
|
||||
width: 72px;
|
||||
@@ -60,14 +104,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 32px;
|
||||
height: 2px;
|
||||
background: var(--border);
|
||||
border-radius: 1px;
|
||||
margin: 4px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
@@ -83,7 +120,7 @@
|
||||
cursor: pointer;
|
||||
transition: border-radius 0.2s, background 0.2s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.server-icon:hover {
|
||||
@@ -107,11 +144,6 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.home {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.add {
|
||||
background: transparent;
|
||||
color: var(--success);
|
||||
@@ -124,7 +156,7 @@
|
||||
|
||||
.pill {
|
||||
position: absolute;
|
||||
left: -4px;
|
||||
left: -8px;
|
||||
width: 4px;
|
||||
height: 0;
|
||||
background: white;
|
||||
@@ -136,4 +168,146 @@
|
||||
.server-icon:hover .pill {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 0.6rem;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.server-icon:hover .remove-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.user-bubble {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.modal-field input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.modal-field input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
background: rgba(237, 66, 69, 0.15);
|
||||
color: var(--danger);
|
||||
padding: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,57 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { voiceState } from '../lib/store';
|
||||
import { leaveVoiceChannel, toggleMute, toggleDeafen } from '../lib/api';
|
||||
import { voiceRoom, disconnectFromVoice, toggleMute, toggleDeafen, voiceParticipants } from '../lib/voice';
|
||||
|
||||
function extractUsername(userId: string): string {
|
||||
return userId.split(':')[0].replace('@', '');
|
||||
}
|
||||
|
||||
async function handleLeave() {
|
||||
if (!$voiceState.channelId) return;
|
||||
try {
|
||||
await leaveVoiceChannel($voiceState.channelId);
|
||||
voiceState.set({ channelId: null, muted: false, deafened: false, streaming: false });
|
||||
} catch (e) {
|
||||
console.error('Failed to leave voice', e);
|
||||
}
|
||||
await disconnectFromVoice();
|
||||
}
|
||||
|
||||
async function handleMute() {
|
||||
if (!$voiceState.channelId) return;
|
||||
try {
|
||||
const newMuted = await toggleMute($voiceState.channelId);
|
||||
voiceState.update(s => ({ ...s, muted: newMuted }));
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle mute', e);
|
||||
}
|
||||
await toggleMute();
|
||||
}
|
||||
|
||||
async function handleDeafen() {
|
||||
if (!$voiceState.channelId) return;
|
||||
try {
|
||||
const newDeaf = await toggleDeafen($voiceState.channelId);
|
||||
voiceState.update(s => ({ ...s, deafened: newDeaf }));
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle deafen', e);
|
||||
}
|
||||
await toggleDeafen();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $voiceState.channelId}
|
||||
{#if $voiceRoom.connected || $voiceRoom.connecting}
|
||||
<div class="voice-panel">
|
||||
<div class="voice-info">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--success)">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<span class="voice-label">Sprachverbunden</span>
|
||||
{#if $voiceRoom.connecting}
|
||||
<div class="voice-spinner"></div>
|
||||
<span class="voice-label connecting">Verbinden...</span>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--success)">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<div class="voice-details">
|
||||
<span class="voice-label">Sprachverbunden</span>
|
||||
<span class="voice-room-name">{extractUsername($voiceRoom.roomId || '')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="voice-participants">
|
||||
{#each $voiceParticipants as participant}
|
||||
<div class="participant-dot" class:muted={participant.muted} class:speaking={participant.speaking} title="{extractUsername(participant.userId)} {participant.muted ? '(stumm)' : ''}">
|
||||
<span class="participant-avatar">{extractUsername(participant.userId).charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="voice-actions">
|
||||
<button class="voice-btn" class:active={$voiceState.muted} on:click={handleMute} title="Mikrofon stumm">
|
||||
{#if $voiceState.muted}
|
||||
<button class="voice-btn" class:active={$voiceRoom.localMuted} on:click={handleMute} title="Mikrofon stumm">
|
||||
{#if $voiceRoom.localMuted}
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.6.6L16 13.6V11c0-1.66-1.34-3-3-3-.36 0-.71.07-1.03.19l1.43 1.41zm3.6-6.8L3.86 16.14 5.27 17.55l2.62-2.62c.82.66 1.83 1.07 2.93 1.07h.18v3.08c-3.39.49-6 3.39-6 6.92h2c0-2.76 2.24-5 5-5h1v-4.77l5.27 5.27 1.41-1.41z"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="voice-btn" class:active={$voiceState.deafened} on:click={handleDeafen} title="Kopfhörer stumm">
|
||||
{#if $voiceState.deafened}
|
||||
<button class="voice-btn" class:active={$voiceRoom.localDeafened} on:click={handleDeafen} title="Kopfhörer stumm">
|
||||
{#if $voiceRoom.localDeafened}
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3.63 3.63a.996.996 0 000 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l4.29 4.29c.63.63 1.71.18 1.71-.71v-3.17l5.67 5.67a.996.996 0 101.41-1.41L5.04 3.63a.996.996 0 00-1.41 0zM19 12c0 .82-.33 1.56-.85 2.12l1.42 1.42A4.978 4.978 0 0021 12c0-2.12-1.31-3.93-3.17-4.68L17 8.62C18.23 9.24 19 10.53 19 12zm-4-6c0-2.76-2.24-5-5-5-.71 0-1.38.15-2 .42l1.46 1.46C9.96 2.54 10.96 2 12 2c1.66 0 3 1.34 3 3v4.17l2 2V6z"/></svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/></svg>
|
||||
@@ -64,6 +64,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $voiceRoom.error}
|
||||
<div class="voice-error">
|
||||
<span class="voice-error-text">{$voiceRoom.error}</span>
|
||||
<button class="voice-error-dismiss" on:click={() => voiceRoom.update(s => ({ ...s, error: null }))}>x</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.voice-panel {
|
||||
position: fixed;
|
||||
@@ -84,17 +91,93 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voice-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voice-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--success);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.voice-label.connecting {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.voice-room-name {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.voice-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--border);
|
||||
border-top: 2px solid var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.voice-participants {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.participant-dot {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.participant-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
border: 2px solid var(--success);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.participant-dot.muted .participant-avatar {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.participant-dot.muted .participant-avatar {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.participant-dot.speaking .participant-avatar {
|
||||
border-color: var(--success);
|
||||
box-shadow: 0 0 4px var(--success);
|
||||
}
|
||||
|
||||
.voice-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.voice-btn {
|
||||
@@ -127,4 +210,29 @@
|
||||
.voice-btn.disconnect:hover {
|
||||
background: #c03033;
|
||||
}
|
||||
|
||||
.voice-error {
|
||||
position: fixed;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.voice-error-dismiss {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -12,6 +12,8 @@ export interface RoomInfo {
|
||||
is_encrypted: boolean;
|
||||
member_count: number;
|
||||
topic: string | null;
|
||||
unread_notifications: number;
|
||||
unread_messages: number;
|
||||
}
|
||||
|
||||
export interface MessageInfo {
|
||||
@@ -20,6 +22,15 @@ export interface MessageInfo {
|
||||
body: string;
|
||||
timestamp: number;
|
||||
reply_to: string | null;
|
||||
edited?: boolean;
|
||||
reactions?: Record<string, string[]>;
|
||||
msgtype?: string;
|
||||
media_url?: string | null;
|
||||
filename?: string | null;
|
||||
mimetype?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
@@ -50,6 +61,23 @@ export interface VoiceStateInfo {
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceJoinResult {
|
||||
room_id: string;
|
||||
livekit_url: string;
|
||||
livekit_token: string;
|
||||
}
|
||||
|
||||
export interface VoiceToggleResult {
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
}
|
||||
|
||||
export interface VoiceParticipantInfo {
|
||||
user_id: string;
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
}
|
||||
|
||||
export interface PresenceInfo {
|
||||
user_id: string;
|
||||
status: string;
|
||||
@@ -196,7 +224,7 @@ export async function getPresence(userId: string): Promise<PresenceInfo> {
|
||||
return httpGet(`/api/presence/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
export async function joinVoiceChannel(roomId: string): Promise<VoiceStateInfo> {
|
||||
export async function joinVoiceChannel(roomId: string): Promise<VoiceJoinResult> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('join_voice_channel', { roomId });
|
||||
}
|
||||
@@ -210,20 +238,27 @@ export async function leaveVoiceChannel(roomId: string): Promise<boolean> {
|
||||
return httpPost('/api/voice/leave', { room_id: roomId });
|
||||
}
|
||||
|
||||
export async function toggleMute(roomId: string): Promise<boolean> {
|
||||
export async function toggleMute(roomId: string): Promise<VoiceToggleResult> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('toggle_mute', { roomId });
|
||||
}
|
||||
return httpPost('/api/voice/toggle-mute', { room_id: roomId });
|
||||
}
|
||||
|
||||
export async function toggleDeafen(roomId: string): Promise<boolean> {
|
||||
export async function toggleDeafen(roomId: string): Promise<VoiceToggleResult> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('toggle_deafen', { roomId });
|
||||
}
|
||||
return httpPost('/api/voice/toggle-deafen', { room_id: roomId });
|
||||
}
|
||||
|
||||
export async function getVoiceParticipants(): Promise<VoiceParticipantInfo[]> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_voice_participants', {});
|
||||
}
|
||||
return httpGet('/api/voice/participants');
|
||||
}
|
||||
|
||||
export async function getRoles(roomId: string): Promise<RoleInfo[]> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_roles', { roomId });
|
||||
@@ -251,3 +286,342 @@ export async function getPermissions(roomId: string, userId: string): Promise<Pe
|
||||
}
|
||||
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/permissions/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
export async function editMessage(roomId: string, eventId: string, newContent: string): Promise<string> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('edit_message', { roomId, eventId, newContent });
|
||||
}
|
||||
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/edit`, { event_id: eventId, new_content: newContent });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteMessage(roomId: string, eventId: string, reason?: string): Promise<string> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('delete_message', { roomId, eventId, reason });
|
||||
}
|
||||
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/delete/${encodeURIComponent(eventId)}`, { reason });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function reactToMessage(roomId: string, eventId: string, key: string): Promise<string> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('react_to_message', { roomId, eventId, key });
|
||||
}
|
||||
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/react`, { event_id: eventId, key });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function setTyping(roomId: string, typing: boolean): Promise<boolean> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('set_typing', { roomId, typing });
|
||||
}
|
||||
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/typing`, { typing });
|
||||
}
|
||||
|
||||
export type WsEventType = 'message' | 'message_edited' | 'message_deleted' | 'reaction' | 'room_joined' | 'room_left' | 'presence' | 'typing' | 'connected' | 'voice_state_update' | 'voice_user_joined' | 'voice_user_left' | 'thread_reply';
|
||||
|
||||
export interface WsMessage {
|
||||
type: WsEventType;
|
||||
room_id?: string;
|
||||
event_id?: string;
|
||||
sender?: string;
|
||||
body?: string;
|
||||
timestamp?: number;
|
||||
reply_to?: string | null;
|
||||
name?: string;
|
||||
user_id?: string;
|
||||
status?: string;
|
||||
status_msg?: string | null;
|
||||
typing?: boolean;
|
||||
new_body?: string;
|
||||
redacts?: string;
|
||||
key?: string;
|
||||
muted?: boolean;
|
||||
deafened?: boolean;
|
||||
root_event_id?: string;
|
||||
msgtype?: string | null;
|
||||
media_url?: string | null;
|
||||
filename?: string | null;
|
||||
mimetype?: string | null;
|
||||
}
|
||||
|
||||
export type WsEventHandler = (event: WsMessage) => void;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let wsReconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let wsHandler: WsEventHandler | null = null;
|
||||
|
||||
export function connectWebSocket(handler: WsEventHandler): void {
|
||||
wsHandler = handler;
|
||||
doConnect();
|
||||
}
|
||||
|
||||
function doConnect(): void {
|
||||
if (isTauri()) return;
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = `${protocol}//${window.location.host}/api/ws?token=${encodeURIComponent(token)}`;
|
||||
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[WS] Connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data: WsMessage = JSON.parse(event.data);
|
||||
if (wsHandler) {
|
||||
wsHandler(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WS] Failed to parse message', e);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[WS] Disconnected, reconnecting in 3s...');
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws?.close();
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
|
||||
wsReconnectTimer = setTimeout(() => {
|
||||
doConnect();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function disconnectWebSocket(): void {
|
||||
if (wsReconnectTimer) {
|
||||
clearTimeout(wsReconnectTimer);
|
||||
wsReconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.onclose = null;
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
wsHandler = null;
|
||||
}
|
||||
|
||||
export interface ThreadInfo {
|
||||
root_event_id: string;
|
||||
root_sender: string;
|
||||
root_body: string;
|
||||
root_timestamp: number;
|
||||
reply_count: number;
|
||||
last_reply_event_id: string | null;
|
||||
last_reply_sender: string | null;
|
||||
last_reply_body: string | null;
|
||||
last_reply_timestamp: number | null;
|
||||
}
|
||||
|
||||
export interface ThreadMessageInfo {
|
||||
event_id: string;
|
||||
sender: string;
|
||||
body: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export async function getThreads(roomId: string): Promise<ThreadInfo[]> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_threads', { roomId });
|
||||
}
|
||||
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/threads`);
|
||||
}
|
||||
|
||||
export async function getThreadMessages(roomId: string, threadId: string, limit: number = 50): Promise<ThreadMessageInfo[]> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_thread_messages', { roomId, threadId, limit });
|
||||
}
|
||||
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/threads/${encodeURIComponent(threadId)}?limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function sendThreadReply(roomId: string, threadId: string, message: string): Promise<string> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('send_thread_reply', { roomId, threadId, message });
|
||||
}
|
||||
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/threads/${encodeURIComponent(threadId)}/reply`, { message });
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function sendReply(roomId: string, replyTo: string, message: string): Promise<string> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('send_reply', { roomId, replyTo, message });
|
||||
}
|
||||
const result = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/reply`, { message, reply_to: replyTo });
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getMediaUrl(mediaUrl: string | null | undefined): string | null {
|
||||
if (!mediaUrl) return null;
|
||||
const mxcMatch = mediaUrl.match(/^mxc:\/\/([^/]+)\/(.+)$/);
|
||||
if (!mxcMatch) return null;
|
||||
const serverName = mxcMatch[1];
|
||||
const mediaId = mxcMatch[2];
|
||||
const token = getToken();
|
||||
return `${getApiBase()}/api/media/${encodeURIComponent(serverName + '/' + mediaId)}`;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
event_id: string;
|
||||
media_url: string | null;
|
||||
filename: string;
|
||||
mimetype: string | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
export async function uploadFile(roomId: string, file: File): Promise<UploadResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (isTauri()) {
|
||||
const { invoke } = await import('@tauri-apps/api/tauri');
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
return invoke('upload_file', { roomId, fileName: file.name, mimeType: file.type, bytes });
|
||||
}
|
||||
|
||||
const res = await fetch(`${getApiBase()}/api/rooms/${encodeURIComponent(roomId)}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Profile API
|
||||
|
||||
export interface UserProfile {
|
||||
user_id: string;
|
||||
display_name: string | null;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export async function getOwnProfile(): Promise<UserProfile> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_own_profile', {});
|
||||
}
|
||||
return httpGet('/api/profile/me');
|
||||
}
|
||||
|
||||
export async function getUserProfile(userId: string): Promise<UserProfile> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_user_profile', { userId });
|
||||
}
|
||||
return httpGet(`/api/profile/${encodeURIComponent(userId)}`);
|
||||
}
|
||||
|
||||
export async function setDisplayName(displayName: string): Promise<boolean> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('set_display_name', { displayName });
|
||||
}
|
||||
const res = await httpPost('/api/profile/displayname', { display_name: displayName });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function uploadAvatar(file: File): Promise<UserProfile> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (isTauri()) {
|
||||
const { invoke } = await import('@tauri-apps/api/tauri');
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
return invoke('upload_avatar', { fileName: file.name, mimeType: file.type, bytes });
|
||||
}
|
||||
|
||||
const res = await fetch(`${getApiBase()}/api/profile/avatar`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Room Settings API
|
||||
|
||||
export async function setRoomName(roomId: string, name: string): Promise<boolean> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('set_room_name', { roomId, name });
|
||||
}
|
||||
const res = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/name`, { name });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function setRoomTopic(roomId: string, topic: string): Promise<boolean> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('set_room_topic', { roomId, topic });
|
||||
}
|
||||
const res = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/topic`, { topic });
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function setRoomAvatar(roomId: string, file: File): Promise<boolean> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (isTauri()) {
|
||||
const { invoke } = await import('@tauri-apps/api/tauri');
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const bytes = Array.from(new Uint8Array(arrayBuffer));
|
||||
return invoke('set_room_avatar', { roomId, fileName: file.name, mimeType: file.type, bytes });
|
||||
}
|
||||
|
||||
const res = await fetch(`${getApiBase()}/api/rooms/${encodeURIComponent(roomId)}/avatar`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Emoji API
|
||||
|
||||
export interface CustomEmoji {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
export async function getCustomEmoji(roomId: string): Promise<CustomEmoji[]> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_custom_emoji', { roomId });
|
||||
}
|
||||
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/emoji`);
|
||||
}
|
||||
|
||||
// Unread API
|
||||
|
||||
export interface UnreadInfo {
|
||||
room_id: string;
|
||||
unread_notifications: number;
|
||||
unread_messages: number;
|
||||
}
|
||||
|
||||
export async function getUnreadCounts(): Promise<UnreadInfo[]> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('get_joined_rooms', {});
|
||||
}
|
||||
return httpGet('/api/rooms/unread');
|
||||
}
|
||||
|
||||
export async function markRoomRead(roomId: string, eventId: string): Promise<boolean> {
|
||||
if (isTauri()) {
|
||||
return tauriInvoke('mark_room_read', { roomId, eventId });
|
||||
}
|
||||
const res = await httpPost(`/api/rooms/${encodeURIComponent(roomId)}/read`, { event_id: eventId });
|
||||
return res;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { invoke } from '@tauri-apps/api/tauri';
|
||||
import * as api from './api';
|
||||
import type { UserProfile } from './api';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -32,13 +33,23 @@ interface Message {
|
||||
replyTo: string | null;
|
||||
isBot: boolean;
|
||||
reactions: Record<string, string[]>;
|
||||
edited?: boolean;
|
||||
threadRoot?: string;
|
||||
threadReplyCount?: number;
|
||||
msgtype?: string;
|
||||
mediaUrl?: string | null;
|
||||
filename?: string | null;
|
||||
mimetype?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
interface Server {
|
||||
id: string;
|
||||
name: string;
|
||||
homeserver: string;
|
||||
iconUrl: string | null;
|
||||
roles: Role[];
|
||||
}
|
||||
|
||||
interface Role {
|
||||
@@ -60,20 +71,93 @@ interface Member {
|
||||
export const currentUser = writable<User | null>(null);
|
||||
export const currentServer = writable<Server | null>(null);
|
||||
export const currentChannel = writable<Channel | null>(null);
|
||||
export const servers = writable<Server[]>([]);
|
||||
export const servers = writable<Server[]>(loadServers());
|
||||
export const channels = writable<Channel[]>([]);
|
||||
export const unreadCounts = writable<Record<string, number>>({});
|
||||
export const messages = writable<Message[]>([]);
|
||||
export const members = writable<Member[]>([]);
|
||||
export const userProfile = writable<UserProfile | null>(null);
|
||||
|
||||
function loadServers(): Server[] {
|
||||
try {
|
||||
const raw = localStorage.getItem('eifeldc_servers');
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveServers(list: Server[]) {
|
||||
localStorage.setItem('eifeldc_servers', JSON.stringify(list));
|
||||
}
|
||||
|
||||
export function addServer(homeserver: string, name?: string): Server {
|
||||
const id = btoa(homeserver).replace(/[^a-zA-Z0-9]/g, '').slice(0, 12);
|
||||
const server: Server = {
|
||||
id,
|
||||
name: name || new URL(homeserver).hostname,
|
||||
homeserver,
|
||||
iconUrl: null,
|
||||
};
|
||||
servers.update(list => {
|
||||
if (list.some(s => s.homeserver === homeserver)) return list;
|
||||
const updated = [...list, server];
|
||||
saveServers(updated);
|
||||
return updated;
|
||||
});
|
||||
return server;
|
||||
}
|
||||
|
||||
export function removeServer(id: string) {
|
||||
servers.update(list => {
|
||||
const updated = list.filter(s => s.id !== id);
|
||||
saveServers(updated);
|
||||
return updated;
|
||||
});
|
||||
currentServer.update(cs => {
|
||||
if (cs && cs.id === id) return null;
|
||||
return cs;
|
||||
});
|
||||
}
|
||||
|
||||
export function switchServer(server: Server) {
|
||||
currentServer.set(server);
|
||||
currentChannel.set(null);
|
||||
messages.set([]);
|
||||
members.set([]);
|
||||
channels.set([]);
|
||||
refreshChannels();
|
||||
refreshProfile();
|
||||
}
|
||||
|
||||
export async function refreshProfile() {
|
||||
try {
|
||||
const profile = await api.getOwnProfile();
|
||||
userProfile.set(profile);
|
||||
} catch (e) {
|
||||
console.error('Failed to load profile', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDisplayName(name: string) {
|
||||
try {
|
||||
await api.setDisplayName(name);
|
||||
await refreshProfile();
|
||||
} catch (e) {
|
||||
console.error('Failed to update display name', e);
|
||||
}
|
||||
}
|
||||
export const voiceState = writable<{
|
||||
channelId: string | null;
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
streaming: boolean;
|
||||
participants: { userId: string; muted: boolean; deafened: boolean }[];
|
||||
}>({
|
||||
channelId: null,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
streaming: false,
|
||||
participants: [],
|
||||
});
|
||||
|
||||
export const sortedMembers = derived(members, ($members) => {
|
||||
@@ -91,21 +175,185 @@ export const voiceChannels = derived(channels, ($channels) =>
|
||||
$channels.filter((c) => c.type === 'voice')
|
||||
);
|
||||
|
||||
export async function getJoinedRooms(): Promise<import('./api').RoomInfo[]> {
|
||||
return invoke('get_joined_rooms');
|
||||
}
|
||||
|
||||
export async function refreshChannels() {
|
||||
try {
|
||||
const rooms = await getJoinedRooms();
|
||||
channels.set(rooms.map(r => ({
|
||||
id: r.room_id,
|
||||
name: r.name || r.room_id,
|
||||
type: 'text' as const,
|
||||
topic: null,
|
||||
parentId: null,
|
||||
})));
|
||||
const rooms = await api.getJoinedRooms();
|
||||
const unreads: Record<string, number> = {};
|
||||
channels.set(rooms.map(r => {
|
||||
const count = r.unread_notifications || r.unread_messages || 0;
|
||||
if (count > 0) unreads[r.room_id] = count;
|
||||
return {
|
||||
id: r.room_id,
|
||||
name: r.name || r.room_id,
|
||||
type: (r.name || '').startsWith('🔊') || (r.name || '').toLowerCase().startsWith('voice') ? 'voice' as const : 'text' as const,
|
||||
topic: r.topic || null,
|
||||
parentId: null,
|
||||
};
|
||||
}));
|
||||
unreadCounts.set(unreads);
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh channels', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWsEvent(event: api.WsMessage): void {
|
||||
switch (event.type) {
|
||||
case 'message':
|
||||
if (!event.room_id || !event.event_id) break;
|
||||
if (event.room_id !== $currentChannel?.id) {
|
||||
unreadCounts.update(counts => {
|
||||
counts[event.room_id!] = (counts[event.room_id!] || 0) + 1;
|
||||
return counts;
|
||||
});
|
||||
}
|
||||
const newMsg: Message = {
|
||||
id: event.event_id,
|
||||
sender: event.sender || '',
|
||||
content: event.body || '',
|
||||
timestamp: event.timestamp || Date.now(),
|
||||
replyTo: event.reply_to || null,
|
||||
isBot: false,
|
||||
reactions: {},
|
||||
msgtype: event.msgtype || 'm.text',
|
||||
mediaUrl: event.media_url || null,
|
||||
filename: event.filename || null,
|
||||
mimetype: event.mimetype || null,
|
||||
};
|
||||
messages.update(existing => {
|
||||
if (existing.some(m => m.id === newMsg.id)) return existing;
|
||||
return [...existing, newMsg];
|
||||
});
|
||||
break;
|
||||
|
||||
case 'message_edited':
|
||||
if (!event.event_id || !event.new_body) break;
|
||||
messages.update(existing =>
|
||||
existing.map(m =>
|
||||
m.id === event.event_id ? { ...m, content: event.new_body!, edited: true } : m
|
||||
)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'message_deleted':
|
||||
if (!event.redacts) break;
|
||||
messages.update(existing => existing.filter(m => m.id !== event.redacts));
|
||||
break;
|
||||
|
||||
case 'reaction':
|
||||
if (!event.event_id || !event.key || !event.sender) break;
|
||||
messages.update(existing =>
|
||||
existing.map(m => {
|
||||
if (m.id === event.event_id) {
|
||||
const reactions = { ...m.reactions };
|
||||
if (!reactions[event.key!]) {
|
||||
reactions[event.key!] = [];
|
||||
}
|
||||
if (!reactions[event.key!].includes(event.sender!)) {
|
||||
reactions[event.key!].push(event.sender!);
|
||||
}
|
||||
return { ...m, reactions };
|
||||
}
|
||||
return m;
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
||||
case 'room_joined':
|
||||
if (!event.room_id) break;
|
||||
channels.update(existing => {
|
||||
if (existing.some(c => c.id === event.room_id)) {
|
||||
return existing.map(c =>
|
||||
c.id === event.room_id ? { ...c, name: event.name || c.name } : c
|
||||
);
|
||||
}
|
||||
return [...existing, {
|
||||
id: event.room_id!,
|
||||
name: event.name || event.room_id!,
|
||||
type: 'text' as const,
|
||||
topic: null,
|
||||
parentId: null,
|
||||
}];
|
||||
});
|
||||
break;
|
||||
|
||||
case 'room_left':
|
||||
if (!event.room_id) break;
|
||||
channels.update(existing => existing.filter(c => c.id !== event.room_id));
|
||||
break;
|
||||
|
||||
case 'presence':
|
||||
if (!event.user_id) break;
|
||||
const statusMap: Record<string, Member['status']> = {
|
||||
online: 'online',
|
||||
idle: 'idle',
|
||||
offline: 'offline',
|
||||
};
|
||||
const newStatus = (event.status && statusMap[event.status]) || 'offline';
|
||||
members.update(existing =>
|
||||
existing.map(m =>
|
||||
m.id === event.user_id ? { ...m, status: newStatus } : m
|
||||
)
|
||||
);
|
||||
currentUser.update(u => {
|
||||
if (u && u.id === event.user_id) {
|
||||
return { ...u, status: newStatus };
|
||||
}
|
||||
return u;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
break;
|
||||
|
||||
case 'voice_user_joined':
|
||||
if (!event.room_id || !event.user_id) break;
|
||||
voiceState.update(s => {
|
||||
if (s.channelId !== event.room_id) return s;
|
||||
if (s.participants.some(p => p.userId === event.user_id)) return s;
|
||||
return {
|
||||
...s,
|
||||
participants: [...s.participants, { userId: event.user_id!, muted: false, deafened: false }],
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'voice_user_left':
|
||||
if (!event.room_id || !event.user_id) break;
|
||||
voiceState.update(s => {
|
||||
if (s.channelId !== event.room_id) return s;
|
||||
return {
|
||||
...s,
|
||||
participants: s.participants.filter(p => p.userId !== event.user_id),
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'voice_state_update':
|
||||
if (!event.room_id || !event.user_id) break;
|
||||
voiceState.update(s => {
|
||||
if (s.channelId !== event.room_id) return s;
|
||||
return {
|
||||
...s,
|
||||
participants: s.participants.map(p =>
|
||||
p.userId === event.user_id ? { ...p, muted: event.muted ?? p.muted, deafened: event.deafened ?? p.deafened } : p
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'thread_reply':
|
||||
if (!event.room_id || !event.root_event_id) break;
|
||||
messages.update(existing =>
|
||||
existing.map(m => {
|
||||
if (m.id === event.root_event_id) {
|
||||
return { ...m, threadReplyCount: (m.threadReplyCount || 0) + 1 };
|
||||
}
|
||||
if (m.threadRoot === event.root_event_id) {
|
||||
return { ...m, threadReplyCount: (m.threadReplyCount || 0) + 1 };
|
||||
}
|
||||
return m;
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
213
client/src-ui/src/lib/voice.ts
Normal file
213
client/src-ui/src/lib/voice.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Room, Track, ConnectionState, LocalParticipant, RemoteParticipant, RemoteTrackPublication, TrackEvent, RoomEvent } from 'livekit-client';
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import * as api from './api';
|
||||
|
||||
export interface VoiceParticipantState {
|
||||
userId: string;
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
speaking: boolean;
|
||||
}
|
||||
|
||||
interface VoiceState {
|
||||
connected: boolean;
|
||||
connecting: boolean;
|
||||
roomId: string | null;
|
||||
room: Room | null;
|
||||
localMuted: boolean;
|
||||
localDeafened: boolean;
|
||||
participants: VoiceParticipantState[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export const voiceRoom = writable<VoiceState>({
|
||||
connected: false,
|
||||
connecting: false,
|
||||
roomId: null,
|
||||
room: null,
|
||||
localMuted: false,
|
||||
localDeafened: false,
|
||||
participants: [],
|
||||
error: null,
|
||||
});
|
||||
|
||||
export const isConnected = derived(voiceRoom, ($v) => $v.connected);
|
||||
export const voiceParticipants = derived(voiceRoom, ($v) => $v.participants);
|
||||
export const voiceError = derived(voiceRoom, ($v) => $v.error);
|
||||
|
||||
function updateParticipantsFromRoom(room: Room): VoiceParticipantState[] {
|
||||
const participants: VoiceParticipantState[] = [];
|
||||
|
||||
const local = room.localParticipant;
|
||||
participants.push({
|
||||
userId: local.identity || local.name || 'unknown',
|
||||
muted: local.isMicrophoneEnabled === false,
|
||||
deafened: false,
|
||||
speaking: local.isSpeaking,
|
||||
});
|
||||
|
||||
for (const [, remote] of room.remoteParticipants) {
|
||||
participants.push({
|
||||
userId: remote.identity || remote.name || 'unknown',
|
||||
muted: !remote.isMicrophoneEnabled,
|
||||
deafened: false,
|
||||
speaking: remote.isSpeaking,
|
||||
});
|
||||
}
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
||||
export async function connectToVoice(roomId: string): Promise<void> {
|
||||
const current = get(voiceRoom);
|
||||
if (current.connecting || current.connected) {
|
||||
if (current.roomId === roomId) return;
|
||||
await disconnectFromVoice();
|
||||
}
|
||||
|
||||
voiceRoom.update(s => ({ ...s, connecting: true, error: null }));
|
||||
|
||||
try {
|
||||
const result = await api.joinVoiceChannel(roomId);
|
||||
|
||||
const room = new Room();
|
||||
|
||||
room.on(RoomEvent.TrackMuted, () => {
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
participants: updateParticipantsFromRoom(room),
|
||||
}));
|
||||
});
|
||||
|
||||
room.on(RoomEvent.TrackUnmuted, () => {
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
participants: updateParticipantsFromRoom(room),
|
||||
}));
|
||||
});
|
||||
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, () => {
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
participants: updateParticipantsFromRoom(room),
|
||||
}));
|
||||
});
|
||||
|
||||
room.on(RoomEvent.ParticipantConnected, () => {
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
participants: updateParticipantsFromRoom(room),
|
||||
}));
|
||||
});
|
||||
|
||||
room.on(RoomEvent.ParticipantDisconnected, () => {
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
participants: updateParticipantsFromRoom(room),
|
||||
}));
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Disconnected, () => {
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
connected: false,
|
||||
roomId: null,
|
||||
room: null,
|
||||
participants: [],
|
||||
}));
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Reconnecting, () => {
|
||||
voiceRoom.update(s => ({ ...s, connecting: true }));
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Reconnected, () => {
|
||||
voiceRoom.update(s => ({ ...s, connecting: false, connected: true }));
|
||||
});
|
||||
|
||||
await room.connect(result.livekit_url, result.livekit_token);
|
||||
|
||||
const local = room.localParticipant;
|
||||
await local.setCameraEnabled(false);
|
||||
await local.setMicrophoneEnabled(true);
|
||||
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
connected: true,
|
||||
connecting: false,
|
||||
roomId: result.room_id,
|
||||
room,
|
||||
participants: updateParticipantsFromRoom(room),
|
||||
}));
|
||||
} catch (e: any) {
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
connecting: false,
|
||||
error: e.message || 'Verbindung fehlgeschlagen',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectFromVoice(): Promise<void> {
|
||||
const current = get(voiceRoom);
|
||||
|
||||
if (current.room) {
|
||||
await current.room.disconnect();
|
||||
}
|
||||
|
||||
if (current.roomId) {
|
||||
try {
|
||||
await api.leaveVoiceChannel(current.roomId);
|
||||
} catch (e) {
|
||||
console.error('Failed to leave voice channel', e);
|
||||
}
|
||||
}
|
||||
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
connected: false,
|
||||
connecting: false,
|
||||
roomId: null,
|
||||
room: null,
|
||||
localMuted: false,
|
||||
localDeafened: false,
|
||||
participants: [],
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function toggleMute(): Promise<void> {
|
||||
const current = get(voiceRoom);
|
||||
if (!current.room || !current.roomId) return;
|
||||
|
||||
const result = await api.toggleMute(current.roomId);
|
||||
|
||||
if (current.room) {
|
||||
const local = current.room.localParticipant;
|
||||
await local.setMicrophoneEnabled(!result.muted);
|
||||
}
|
||||
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
localMuted: result.muted,
|
||||
localDeafened: result.deafened,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function toggleDeafen(): Promise<void> {
|
||||
const current = get(voiceRoom);
|
||||
if (!current.room || !current.roomId) return;
|
||||
|
||||
const result = await api.toggleDeafen(current.roomId);
|
||||
|
||||
if (current.room) {
|
||||
const local = current.room.localParticipant;
|
||||
await local.setMicrophoneEnabled(!result.muted);
|
||||
}
|
||||
|
||||
voiceRoom.update(s => ({
|
||||
...s,
|
||||
localMuted: result.muted,
|
||||
localDeafened: result.deafened,
|
||||
}));
|
||||
}
|
||||
@@ -1,107 +1,151 @@
|
||||
use matrix_sdk::Client;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use crate::state::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResult {
|
||||
pub success: bool,
|
||||
pub user_id: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ServerLoginRequest {
|
||||
homeserver: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ServerLoginResponse {
|
||||
success: bool,
|
||||
user_id: String,
|
||||
token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct ServerRegisterRequest {
|
||||
homeserver: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
homeserver: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<LoginResult, String> {
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&homeserver)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let server_url = format!("https://{}", homeserver.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
client
|
||||
.matrix_auth()
|
||||
.login_username(&username, &password)
|
||||
let res = client
|
||||
.post(format!("{}/api/login", server_url.trim_end_matches('/')))
|
||||
.json(&ServerLoginRequest {
|
||||
homeserver,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let user_id = client
|
||||
.user_id()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
let data: ServerLoginResponse = res.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
if !data.success {
|
||||
return Ok(LoginResult {
|
||||
success: false,
|
||||
user_id: String::new(),
|
||||
error: data.error,
|
||||
});
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.client = Some(client);
|
||||
s.logged_in = true;
|
||||
s.user_id = Some(user_id.clone());
|
||||
s.user_id = Some(data.user_id.clone());
|
||||
s.auth_token = data.token.clone();
|
||||
s.server_url = Some(server_url.trim_end_matches('/').to_string());
|
||||
|
||||
Ok(LoginResult {
|
||||
success: true,
|
||||
user_id,
|
||||
user_id: data.user_id,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn logout(state: State<'_, crate::state::AppState>) -> Result<bool, String> {
|
||||
let mut s = state.write().await;
|
||||
if let Some(client) = s.client.take() {
|
||||
let _ = client.matrix_auth().logout().await;
|
||||
pub async fn logout(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
let (server_url, token) = {
|
||||
let s = state.read().await;
|
||||
(s.server_url.clone(), s.auth_token.clone())
|
||||
};
|
||||
|
||||
if let (Some(url), Some(tok)) = (server_url, token) {
|
||||
let client = reqwest::Client::new();
|
||||
let _ = client
|
||||
.post(format!("{}/api/logout", url.trim_end_matches('/')))
|
||||
.header("Authorization", format!("Bearer {}", tok))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.logged_in = false;
|
||||
s.user_id = None;
|
||||
s.auth_token = None;
|
||||
s.server_url = None;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn register(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
homeserver: String,
|
||||
username: String,
|
||||
password: String,
|
||||
) -> Result<LoginResult, String> {
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&homeserver)
|
||||
.build()
|
||||
let server_url = format!("https://{}", homeserver.trim_end_matches('/'));
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let res = client
|
||||
.post(format!("{}/api/register", server_url.trim_end_matches('/')))
|
||||
.json(&ServerRegisterRequest {
|
||||
homeserver,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut request = matrix_sdk::ruma::api::client::account::register::v3::Request::new();
|
||||
request.username = Some(username);
|
||||
request.password = Some(password);
|
||||
let data: ServerLoginResponse = res.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
client
|
||||
.matrix_auth()
|
||||
.register(request)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let user_id = client
|
||||
.user_id()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
if !data.success {
|
||||
return Ok(LoginResult {
|
||||
success: false,
|
||||
user_id: String::new(),
|
||||
error: data.error,
|
||||
});
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.client = Some(client);
|
||||
s.logged_in = true;
|
||||
s.user_id = Some(user_id.clone());
|
||||
s.user_id = Some(data.user_id.clone());
|
||||
s.auth_token = data.token.clone();
|
||||
s.server_url = Some(server_url.trim_end_matches('/').to_string());
|
||||
|
||||
Ok(LoginResult {
|
||||
success: true,
|
||||
user_id,
|
||||
user_id: data.user_id,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_user(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
pub async fn get_current_user(state: State<'_, AppState>) -> Result<Option<String>, String> {
|
||||
let s = state.read().await;
|
||||
Ok(s.user_id.clone())
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
use matrix_sdk::ruma::room_id;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use crate::state::AppState;
|
||||
use std::path::Path;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CustomEmoji {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -13,6 +11,76 @@ pub struct CustomEmoji {
|
||||
animated: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_custom_emoji(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<CustomEmoji>, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/rooms/{}/emoji",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UploadEmojiResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
url: String,
|
||||
category: String,
|
||||
animated: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_emoji(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
name: String,
|
||||
image_path: String,
|
||||
) -> Result<CustomEmoji, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/emoji/upload",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "name": name, "image_path": image_path }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let data: UploadEmojiResponse = res.json().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(CustomEmoji {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
category: data.category,
|
||||
animated: data.animated,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct StickerPack {
|
||||
id: String,
|
||||
@@ -28,66 +96,6 @@ pub struct Sticker {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_custom_emoji(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<CustomEmoji>, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let _room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
|
||||
let _emojis = Vec::new();
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_emoji(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
room_id: String,
|
||||
name: String,
|
||||
image_path: String,
|
||||
) -> Result<CustomEmoji, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
|
||||
let path = Path::new(&image_path);
|
||||
if !path.exists() {
|
||||
return Err("Image file not found".to_string());
|
||||
}
|
||||
|
||||
let mime_type = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
|
||||
let data = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let content_type = mime_type.parse::<matrix_sdk::ruma::mime::Mime>().map_err(|e| e.to_string())?;
|
||||
|
||||
let response = client
|
||||
.media()
|
||||
.upload(&content_type, data)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(CustomEmoji {
|
||||
id: format!("emoji_{}", chrono::Utc::now().timestamp()),
|
||||
name,
|
||||
url: response.content_uri.to_string(),
|
||||
category: "custom".to_string(),
|
||||
animated: mime_type == "image/gif",
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_sticker_packs(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
) -> Result<Vec<StickerPack>, String> {
|
||||
let _s = state.read().await;
|
||||
pub async fn get_sticker_packs(_state: State<'_, AppState>) -> Result<Vec<StickerPack>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
pub mod auth;
|
||||
pub mod emoji;
|
||||
pub mod presence;
|
||||
pub mod profile;
|
||||
pub mod roles;
|
||||
pub mod rooms;
|
||||
pub mod threads;
|
||||
pub mod presence;
|
||||
pub mod voice;
|
||||
pub mod emoji;
|
||||
pub mod roles;
|
||||
@@ -1,9 +1,8 @@
|
||||
use matrix_sdk::ruma::user_id;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use crate::state::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct PresenceInfo {
|
||||
user_id: String,
|
||||
status: String,
|
||||
@@ -13,63 +12,51 @@ pub struct PresenceInfo {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_presence(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
status: String,
|
||||
status_msg: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let presence_state = match status.as_str() {
|
||||
"online" => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||
"away" => matrix_sdk::ruma::presence::PresenceState::Away,
|
||||
"unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable,
|
||||
_ => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||
};
|
||||
|
||||
let request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new(
|
||||
client.user_id().ok_or("No user ID")?.to_owned(),
|
||||
);
|
||||
let mut request = request;
|
||||
request.presence = presence_state;
|
||||
request.status_msg = status_msg.clone();
|
||||
|
||||
client
|
||||
.send(request, None)
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/presence/set",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "status": status, "status_msg": status_msg }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_presence(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
user_id_str: String,
|
||||
state: State<'_, AppState>,
|
||||
user_id: String,
|
||||
) -> Result<PresenceInfo, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let uid = user_id!(user_id_str.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
|
||||
let request = matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned());
|
||||
|
||||
let response = client
|
||||
.send(request, None)
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/presence/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
user_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let status_str = match response.presence {
|
||||
matrix_sdk::ruma::presence::PresenceState::Online => "online",
|
||||
matrix_sdk::ruma::presence::PresenceState::Away => "away",
|
||||
matrix_sdk::ruma::presence::PresenceState::Unavailable => "unavailable",
|
||||
_ => "offline",
|
||||
};
|
||||
|
||||
Ok(PresenceInfo {
|
||||
user_id: user_id_str,
|
||||
status: status_str.to_string(),
|
||||
status_msg: response.status_msg,
|
||||
last_active: response.last_active_ago.map(|d| d.as_secs()),
|
||||
})
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
130
client/src/commands/profile.rs
Normal file
130
client/src/commands/profile.rs
Normal file
@@ -0,0 +1,130 @@
|
||||
use crate::state::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UserProfile {
|
||||
user_id: String,
|
||||
display_name: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_own_profile(state: State<'_, AppState>) -> Result<UserProfile, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/profile/me",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_user_profile(
|
||||
state: State<'_, AppState>,
|
||||
user_id: String,
|
||||
) -> Result<UserProfile, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/profile/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
user_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_display_name(
|
||||
state: State<'_, AppState>,
|
||||
display_name: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/profile/displayname",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "display_name": display_name }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_avatar(
|
||||
state: State<'_, AppState>,
|
||||
file_path: String,
|
||||
) -> Result<UserProfile, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let data = std::fs::read(&file_path).map_err(|e| e.to_string())?;
|
||||
let file_name = std::path::Path::new(&file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("avatar")
|
||||
.to_string();
|
||||
|
||||
let mime_type = match std::path::Path::new(&file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
{
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let part = reqwest::multipart::Part::bytes(data)
|
||||
.file_name(file_name)
|
||||
.mime_str(mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/profile/avatar",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
use matrix_sdk::ruma::room_id;
|
||||
use matrix_sdk::ruma::user_id;
|
||||
use crate::state::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Role {
|
||||
id: String,
|
||||
name: String,
|
||||
@@ -13,7 +11,7 @@ pub struct Role {
|
||||
position: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct Permissions {
|
||||
can_send_messages: bool,
|
||||
can_delete_messages: bool,
|
||||
@@ -27,155 +25,107 @@ pub struct Permissions {
|
||||
can_voice_stream: bool,
|
||||
}
|
||||
|
||||
fn power_level_to_permissions(power_level: i64) -> Permissions {
|
||||
Permissions {
|
||||
can_send_messages: power_level >= 0,
|
||||
can_delete_messages: power_level >= 50,
|
||||
can_manage_channels: power_level >= 50,
|
||||
can_manage_roles: power_level >= 75,
|
||||
can_kick: power_level >= 50,
|
||||
can_ban: power_level >= 75,
|
||||
can_manage_emoji: power_level >= 50,
|
||||
can_manage_threads: power_level >= 0,
|
||||
can_voice_connect: power_level >= 0,
|
||||
can_voice_stream: power_level >= 25,
|
||||
}
|
||||
}
|
||||
|
||||
fn default_roles() -> Vec<Role> {
|
||||
vec![
|
||||
Role {
|
||||
id: "admin".to_string(),
|
||||
name: "Admin".to_string(),
|
||||
color: "#ed4245".to_string(),
|
||||
permissions: vec!["*".to_string()],
|
||||
position: 100,
|
||||
},
|
||||
Role {
|
||||
id: "moderator".to_string(),
|
||||
name: "Moderator".to_string(),
|
||||
color: "#fee75c".to_string(),
|
||||
permissions: vec!["kick".to_string(), "ban".to_string(), "manage_channels".to_string()],
|
||||
position: 50,
|
||||
},
|
||||
Role {
|
||||
id: "member".to_string(),
|
||||
name: "Mitglied".to_string(),
|
||||
color: "#5865f2".to_string(),
|
||||
permissions: vec!["send_messages".to_string(), "voice_connect".to_string()],
|
||||
position: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_roles(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<Role>, String> {
|
||||
pub async fn get_roles(state: State<'_, AppState>, room_id: String) -> Result<Vec<Role>, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let mut roles = Vec::new();
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/rooms/{}/roles",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Ok(power_levels) = room.power_levels().await {
|
||||
for (uid, power_level) in &power_levels.users {
|
||||
roles.push(Role {
|
||||
id: format!("role_{}", uid),
|
||||
name: uid.to_string(),
|
||||
color: if *power_level >= 100 { "#ed4245".to_string() } else if *power_level >= 50 { "#fee75c".to_string() } else { "#5865f2".to_string() },
|
||||
permissions: vec![],
|
||||
position: *power_level as i32,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if roles.is_empty() {
|
||||
roles = default_roles();
|
||||
}
|
||||
|
||||
Ok(roles)
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_role(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
role_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let power_level: i64 = match role_id.as_str() {
|
||||
"admin" => 100,
|
||||
"moderator" => 50,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::new();
|
||||
let mut content = content;
|
||||
content.users.insert(uid.to_owned(), power_level.into());
|
||||
|
||||
room.send_state_event(content)
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/roles/assign",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "user_id": user_id, "role_id": role_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(true)
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_role(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
_role_id: String,
|
||||
role_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
if let Ok(mut power_levels) = room.power_levels().await {
|
||||
power_levels.users.remove(&uid);
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/roles/remove",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "user_id": user_id, "role_id": role_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::from(power_levels);
|
||||
room.send_state_event(content)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_permissions(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
) -> Result<Permissions, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let user_power = if let Ok(power_levels) = room.power_levels().await {
|
||||
power_levels
|
||||
.users
|
||||
.get(&uid)
|
||||
.copied()
|
||||
.map(|p| p.into())
|
||||
.unwrap_or(power_levels.users_default as i64)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/rooms/{}/permissions/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id,
|
||||
user_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(power_level_to_permissions(user_power))
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
use matrix_sdk::ruma::room_id;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use crate::state::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RoomInfo {
|
||||
room_id: String,
|
||||
name: String,
|
||||
@@ -13,165 +12,466 @@ pub struct RoomInfo {
|
||||
topic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MessageInfo {
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
timestamp: u64,
|
||||
reply_to: Option<String>,
|
||||
edited: Option<bool>,
|
||||
reactions: Option<std::collections::HashMap<String, Vec<String>>>,
|
||||
msgtype: Option<String>,
|
||||
media_url: Option<String>,
|
||||
filename: Option<String>,
|
||||
mimetype: Option<String>,
|
||||
width: Option<u64>,
|
||||
height: Option<u64>,
|
||||
size: Option<u64>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_joined_rooms(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
) -> Result<Vec<RoomInfo>, String> {
|
||||
pub async fn get_joined_rooms(state: State<'_, AppState>) -> Result<Vec<RoomInfo>, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let rooms = client.joined_rooms();
|
||||
let mut result = Vec::new();
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!("{}/api/rooms", server_url.trim_end_matches('/')))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for room in rooms {
|
||||
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||
let avatar_url = room.avatar_url().map(|u| u.to_string());
|
||||
let member_count = room.joined_members().len() as u64;
|
||||
let topic = room.topic().map(|t| t.to_string());
|
||||
let is_encrypted = room.is_encrypted().await.unwrap_or(false);
|
||||
|
||||
result.push(RoomInfo {
|
||||
room_id: room.room_id().to_string(),
|
||||
name,
|
||||
avatar_url,
|
||||
is_encrypted,
|
||||
member_count,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_room_messages(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
limit: u32,
|
||||
from: Option<String>,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
|
||||
options.limit = limit.into();
|
||||
options.from = from.map(|t| t.into()).or_else(|| None);
|
||||
let client = reqwest::Client::new();
|
||||
let mut url = format!(
|
||||
"{}/api/rooms/{}/messages?limit={}",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id,
|
||||
limit
|
||||
);
|
||||
if let Some(from) = from {
|
||||
url = format!("{}&from={}", url, from);
|
||||
}
|
||||
|
||||
let messages = room
|
||||
.messages(options)
|
||||
let res = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for msg in messages.chunk {
|
||||
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg {
|
||||
let body = ev.content().body().to_string();
|
||||
result.push(MessageInfo {
|
||||
event_id: ev.event_id().to_string(),
|
||||
sender: ev.sender().to_string(),
|
||||
body,
|
||||
timestamp: ev.origin_server_ts().0,
|
||||
reply_to: ev.content().in_reply_to().map(|r| r.event_id.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
message: String,
|
||||
) -> Result<String, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&message);
|
||||
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||
let response = room.send(content, Some(&txn_id)).await.map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/send",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "message": message }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(response.event_id.to_string())
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_room(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
topic: Option<String>,
|
||||
visibility: String,
|
||||
) -> Result<String, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let vis = match visibility.as_str() {
|
||||
"public" => matrix_sdk::ruma::Space::Public,
|
||||
_ => matrix_sdk::ruma::Space::Private,
|
||||
};
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/create",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "name": name, "topic": topic, "visibility": visibility }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut request = matrix_sdk::ruma::api::client::room::create_room::v3::Request::new();
|
||||
request.name = Some(name);
|
||||
request.topic = topic;
|
||||
request.visibility = vis;
|
||||
|
||||
let response = client.create_room(request).await.map_err(|e| e.to_string())?;
|
||||
Ok(response.room_id.to_string())
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn join_room(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id_or_alias: String,
|
||||
) -> Result<String, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let room_id = client.join_room_by_id_or_alias(
|
||||
&room_id_or_alias.try_into().map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?,
|
||||
&[]
|
||||
).await.map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/join",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "room_id_or_alias": room_id_or_alias }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(room_id.to_string())
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn leave_room(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
room_id: String,
|
||||
) -> Result<bool, String> {
|
||||
pub async fn leave_room(state: State<'_, AppState>, room_id: String) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
room.leave().await.map_err(|e| e.to_string())?;
|
||||
Ok(true)
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/leave",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_room_members(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let members = room.joined_members();
|
||||
Ok(members.iter().map(|m| m.user_id().to_string()).collect())
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/rooms/{}/members",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn edit_message(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
new_body: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/edit",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "event_id": event_id, "new_body": new_body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/delete/{}",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id,
|
||||
event_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn react_to_message(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
key: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/react",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "event_id": event_id, "key": key }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_typing(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
typing: bool,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/typing",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "typing": typing }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn upload_file(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
file_path: String,
|
||||
) -> Result<String, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let data = std::fs::read(&file_path).map_err(|e| e.to_string())?;
|
||||
let file_name = std::path::Path::new(&file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("upload")
|
||||
.to_string();
|
||||
|
||||
let mime_type = match std::path::Path::new(&file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
{
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("mp4") => "video/mp4",
|
||||
Some("mp3") => "audio/mpeg",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let part = reqwest::multipart::Part::bytes(data)
|
||||
.file_name(file_name)
|
||||
.mime_str(mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/upload",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.text().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_room_name(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
name: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/name",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "name": name }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_room_topic(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
topic: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/topic",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "topic": topic }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_room_avatar(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
file_path: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("Not logged in")?;
|
||||
let token = s.auth_token.clone().ok_or("Not logged in")?;
|
||||
drop(s);
|
||||
|
||||
let data = std::fs::read(&file_path).map_err(|e| e.to_string())?;
|
||||
let file_name = std::path::Path::new(&file_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("avatar")
|
||||
.to_string();
|
||||
|
||||
let mime_type = match std::path::Path::new(&file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
{
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let part = reqwest::multipart::Part::bytes(data)
|
||||
.file_name(file_name)
|
||||
.mime_str(mime_type)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let form = reqwest::multipart::Form::new().part("file", part);
|
||||
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/avatar",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,87 +1,135 @@
|
||||
use matrix_sdk::ruma::room_id;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use crate::state::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ThreadInfo {
|
||||
thread_id: String,
|
||||
root_event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
reply_count: u32,
|
||||
pub root_event_id: String,
|
||||
pub root_sender: String,
|
||||
pub root_body: String,
|
||||
pub root_timestamp: u64,
|
||||
pub reply_count: u32,
|
||||
pub last_reply_event_id: Option<String>,
|
||||
pub last_reply_sender: Option<String>,
|
||||
pub last_reply_body: Option<String>,
|
||||
pub last_reply_timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ThreadMessageInfo {
|
||||
pub event_id: String,
|
||||
pub sender: String,
|
||||
pub body: String,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_threads(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<Vec<ThreadInfo>, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/rooms/{}/threads",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_thread_messages(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
thread_id: String,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ThreadInfo>, String> {
|
||||
) -> Result<Vec<ThreadMessageInfo>, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
drop(s);
|
||||
|
||||
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
|
||||
options.limit = limit.into();
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/rooms/{}/threads/{}?limit={}",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id,
|
||||
thread_id,
|
||||
limit
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let messages = room.messages(options).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for msg in messages.chunk {
|
||||
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg {
|
||||
let relates_to = ev.content().relates_to();
|
||||
let is_thread = relates_to.as_ref().map_or(false, |r| {
|
||||
matches!(r, matrix_sdk::ruma::events::room::message::Relation::Thread(_))
|
||||
});
|
||||
if is_thread {
|
||||
if let Some(matrix_sdk::ruma::events::room::message::Relation::Thread(thread)) = relates_to {
|
||||
result.push(ThreadInfo {
|
||||
thread_id: thread.event_id.to_string(),
|
||||
root_event_id: thread.event_id.to_string(),
|
||||
sender: ev.sender().to_string(),
|
||||
body: ev.content().body().to_string(),
|
||||
reply_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_thread_reply(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
thread_id: String,
|
||||
message: String,
|
||||
) -> Result<String, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
drop(s);
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&message);
|
||||
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||
let response = room.send(content, Some(&txn_id)).await.map_err(|e| e.to_string())?;
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/threads/{}/reply",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id,
|
||||
thread_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "message": message }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(response.event_id.to_string())
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_thread(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
pub async fn send_reply(
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
root_event_id: String,
|
||||
reply_to: String,
|
||||
message: String,
|
||||
) -> Result<String, String> {
|
||||
let s = state.read().await;
|
||||
let client = s.client.as_ref().ok_or("Not logged in")?;
|
||||
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
|
||||
let _room = client.get_room(&rid).ok_or("Room not found")?;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
drop(s);
|
||||
|
||||
Ok(root_event_id)
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/rooms/{}/reply",
|
||||
server_url.trim_end_matches('/'),
|
||||
room_id
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "message": message, "reply_to": reply_to }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,68 +1,152 @@
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use crate::state::AppState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VoiceState {
|
||||
room_id: String,
|
||||
muted: bool,
|
||||
deafened: bool,
|
||||
streaming: bool,
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct VoiceJoinResult {
|
||||
pub room_id: String,
|
||||
pub livekit_url: String,
|
||||
pub livekit_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct VoiceToggleResult {
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct VoiceParticipantInfo {
|
||||
pub user_id: String,
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn join_voice_channel(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<VoiceState, String> {
|
||||
) -> Result<VoiceJoinResult, String> {
|
||||
let s = state.read().await;
|
||||
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.voice_manager.join_channel(room_id.clone(), user_id);
|
||||
drop(s);
|
||||
|
||||
Ok(VoiceState {
|
||||
room_id,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
streaming: false,
|
||||
})
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/voice/join",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "room_id": room_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let result: VoiceJoinResult = res.json().await.map_err(|e| e.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn leave_voice_channel(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<bool, String> {
|
||||
let s = state.read().await;
|
||||
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
|
||||
let mut s = state.write().await;
|
||||
Ok(s.voice_manager.leave_channel(&room_id, &user_id))
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/voice/leave",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "room_id": room_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_mute(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<VoiceToggleResult, String> {
|
||||
let s = state.read().await;
|
||||
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.voice_manager.toggle_mute(&room_id, &user_id)
|
||||
.ok_or("Not in voice channel".to_string())
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/voice/toggle-mute",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "room_id": room_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_deafen(
|
||||
state: State<'_, crate::state::AppState>,
|
||||
state: State<'_, AppState>,
|
||||
room_id: String,
|
||||
) -> Result<bool, String> {
|
||||
) -> Result<VoiceToggleResult, String> {
|
||||
let s = state.read().await;
|
||||
let user_id = s.user_id.clone().ok_or("Not logged in")?;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.voice_manager.toggle_deafen(&room_id, &user_id)
|
||||
.ok_or("Not in voice channel".to_string())
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.post(format!(
|
||||
"{}/api/voice/toggle-deafen",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(&serde_json::json!({ "room_id": room_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_voice_participants(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<VoiceParticipantInfo>, String> {
|
||||
let s = state.read().await;
|
||||
let server_url = s.server_url.clone().ok_or("No server URL")?;
|
||||
let token = s.auth_token.clone().ok_or("No auth token")?;
|
||||
|
||||
drop(s);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(format!(
|
||||
"{}/api/voice/participants",
|
||||
server_url.trim_end_matches('/')
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
res.json().await.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
pub mod matrix;
|
||||
pub mod commands;
|
||||
pub mod state;
|
||||
|
||||
use state::AppState;
|
||||
use state::AppStateInner;
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState::new())
|
||||
.manage(AppStateInner::default())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::auth::login,
|
||||
commands::auth::logout,
|
||||
@@ -15,19 +16,29 @@ fn main() {
|
||||
commands::rooms::get_joined_rooms,
|
||||
commands::rooms::get_room_messages,
|
||||
commands::rooms::send_message,
|
||||
commands::rooms::edit_message,
|
||||
commands::rooms::delete_message,
|
||||
commands::rooms::react_to_message,
|
||||
commands::rooms::set_typing,
|
||||
commands::rooms::upload_file,
|
||||
commands::rooms::create_room,
|
||||
commands::rooms::join_room,
|
||||
commands::rooms::leave_room,
|
||||
commands::rooms::get_room_members,
|
||||
commands::rooms::set_room_name,
|
||||
commands::rooms::set_room_topic,
|
||||
commands::rooms::set_room_avatar,
|
||||
commands::threads::get_thread_messages,
|
||||
commands::threads::send_thread_reply,
|
||||
commands::threads::create_thread,
|
||||
commands::threads::get_threads,
|
||||
commands::threads::send_reply,
|
||||
commands::presence::set_presence,
|
||||
commands::presence::get_presence,
|
||||
commands::voice::join_voice_channel,
|
||||
commands::voice::leave_voice_channel,
|
||||
commands::voice::toggle_mute,
|
||||
commands::voice::toggle_deafen,
|
||||
commands::voice::get_voice_participants,
|
||||
commands::emoji::get_custom_emoji,
|
||||
commands::emoji::upload_emoji,
|
||||
commands::emoji::get_sticker_packs,
|
||||
@@ -35,6 +46,10 @@ fn main() {
|
||||
commands::roles::assign_role,
|
||||
commands::roles::remove_role,
|
||||
commands::roles::get_permissions,
|
||||
commands::profile::get_own_profile,
|
||||
commands::profile::get_user_profile,
|
||||
commands::profile::set_display_name,
|
||||
commands::profile::upload_avatar,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use matrix_sdk::Client;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::room::RoomManager;
|
||||
use super::event::EventHandler;
|
||||
use super::sync::SyncService;
|
||||
use super::presence::PresenceManager;
|
||||
use super::voice::VoiceManager;
|
||||
|
||||
pub struct MatrixClient {
|
||||
pub client: Arc<RwLock<Option<Client>>>,
|
||||
pub room_manager: Arc<RwLock<RoomManager>>,
|
||||
pub event_handler: EventHandler,
|
||||
pub sync_service: Arc<RwLock<SyncService>>,
|
||||
pub presence_manager: Arc<RwLock<PresenceManager>>,
|
||||
pub voice_manager: Arc<RwLock<VoiceManager>>,
|
||||
}
|
||||
|
||||
impl MatrixClient {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Arc::new(RwLock::new(None)),
|
||||
room_manager: Arc::new(RwLock::new(RoomManager::new())),
|
||||
event_handler: EventHandler::new(),
|
||||
sync_service: Arc::new(RwLock::new(SyncService::new())),
|
||||
presence_manager: Arc::new(RwLock::new(PresenceManager::new())),
|
||||
voice_manager: Arc::new(RwLock::new(VoiceManager::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&self, homeserver: &str) -> Result<()> {
|
||||
let client = Client::builder()
|
||||
.homeserver_url(homeserver)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let mut guard = self.client.write().await;
|
||||
*guard = Some(client);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn login(&self, username: &str, password: &str) -> Result<String> {
|
||||
let guard = self.client.read().await;
|
||||
let client = guard.as_ref().ok_or(anyhow::anyhow!("Not connected"))?;
|
||||
|
||||
client
|
||||
.matrix_auth()
|
||||
.login_username(username, password)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let user_id = client
|
||||
.user_id()
|
||||
.ok_or(anyhow::anyhow!("No user ID"))?
|
||||
.to_string();
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
pub async fn start_sync(&self) -> Result<()> {
|
||||
let guard = self.client.read().await;
|
||||
let client = guard.as_ref().ok_or(anyhow::anyhow!("Not connected"))?;
|
||||
|
||||
let mut sync = self.sync_service.write().await;
|
||||
sync.start(client.clone()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_sync(&self) -> Result<()> {
|
||||
let mut sync = self.sync_service.write().await;
|
||||
sync.stop().await
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ClientEvent {
|
||||
NewMessage {
|
||||
room_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
event_id: String,
|
||||
},
|
||||
RoomJoined {
|
||||
room_id: String,
|
||||
name: String,
|
||||
},
|
||||
RoomLeft {
|
||||
room_id: String,
|
||||
},
|
||||
Typing {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
PresenceChange {
|
||||
user_id: String,
|
||||
status: String,
|
||||
},
|
||||
MemberJoined {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
MemberLeft {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
Receipt {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
user_id: String,
|
||||
},
|
||||
VoiceStateChange {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
joined: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct EventHandler {
|
||||
sender: mpsc::UnboundedSender<ClientEvent>,
|
||||
receiver: tokio::sync::Mutex<mpsc::UnboundedReceiver<ClientEvent>>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
sender,
|
||||
receiver: tokio::sync::Mutex::new(receiver),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn emit(&self, event: ClientEvent) {
|
||||
let _ = self.sender.send(event);
|
||||
}
|
||||
|
||||
pub async fn next(&self) -> Option<ClientEvent> {
|
||||
let mut guard = self.receiver.lock().await;
|
||||
guard.recv().await
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
pub mod client;
|
||||
pub mod room;
|
||||
pub mod event;
|
||||
pub mod sync;
|
||||
pub mod voice;
|
||||
pub mod presence;
|
||||
|
||||
pub use client::MatrixClient;
|
||||
@@ -1,39 +0,0 @@
|
||||
use matrix_sdk::ruma::presence::PresenceState;
|
||||
|
||||
pub struct PresenceManager {
|
||||
current_presence: PresenceState,
|
||||
status_msg: Option<String>,
|
||||
}
|
||||
|
||||
impl PresenceManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_presence: PresenceState::Online,
|
||||
status_msg: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_online(&mut self) {
|
||||
self.current_presence = PresenceState::Online;
|
||||
self.status_msg = None;
|
||||
}
|
||||
|
||||
pub fn set_away(&mut self, msg: Option<String>) {
|
||||
self.current_presence = PresenceState::Away;
|
||||
self.status_msg = msg;
|
||||
}
|
||||
|
||||
pub fn set_unavailable(&mut self) {
|
||||
self.current_presence = PresenceState::Unavailable;
|
||||
self.status_msg = None;
|
||||
}
|
||||
|
||||
pub fn get_status(&self) -> (PresenceState, &Option<String>) {
|
||||
(self.current_presence, &self.status_msg)
|
||||
}
|
||||
|
||||
pub fn set_presence(&mut self, presence: PresenceState, status_msg: Option<String>) {
|
||||
self.current_presence = presence;
|
||||
self.status_msg = status_msg;
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use matrix_sdk::Client;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct RoomManager {
|
||||
rooms: HashMap<String, RoomState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RoomState {
|
||||
pub room_id: String,
|
||||
pub name: String,
|
||||
pub unread_count: u32,
|
||||
pub is_voice: bool,
|
||||
pub topic: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub member_count: u64,
|
||||
}
|
||||
|
||||
impl RoomManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rooms: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn refresh_rooms(&mut self, client: &Client) -> Result<()> {
|
||||
self.rooms.clear();
|
||||
let joined = client.joined_rooms();
|
||||
for room in joined {
|
||||
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||
let avatar_url = room.avatar_url().map(|u| u.to_string());
|
||||
let member_count = room.joined_members().len() as u64;
|
||||
let topic = room.topic().map(|t| t.to_string());
|
||||
|
||||
self.rooms.insert(
|
||||
room.room_id().to_string(),
|
||||
RoomState {
|
||||
room_id: room.room_id().to_string(),
|
||||
name,
|
||||
unread_count: 0,
|
||||
is_voice: false,
|
||||
topic,
|
||||
avatar_url,
|
||||
member_count,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_rooms(&self) -> Vec<&RoomState> {
|
||||
let mut rooms: Vec<&RoomState> = self.rooms.values().collect();
|
||||
rooms.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
rooms
|
||||
}
|
||||
|
||||
pub fn get_room(&self, room_id: &str) -> Option<&RoomState> {
|
||||
self.rooms.get(room_id)
|
||||
}
|
||||
|
||||
pub fn update_unread(&mut self, room_id: &str, count: u32) {
|
||||
if let Some(room) = self.rooms.get_mut(room_id) {
|
||||
room.unread_count = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use matrix_sdk::Client;
|
||||
use matrix_sdk::config::SyncSettings;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub struct SyncService {
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl SyncService {
|
||||
pub fn new() -> Self {
|
||||
Self { handle: None }
|
||||
}
|
||||
|
||||
pub async fn start(&mut self, client: Client) -> Result<()> {
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut sync_token: Option<String> = None;
|
||||
loop {
|
||||
let mut settings = SyncSettings::new();
|
||||
if let Some(token) = sync_token.as_ref() {
|
||||
settings = settings.token(token.clone());
|
||||
}
|
||||
match client.sync_once(settings).await {
|
||||
Ok(response) => {
|
||||
sync_token = Some(response.next_batch);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Sync error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.handle = Some(handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop(&mut self) -> Result<()> {
|
||||
if let Some(handle) = self.handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct VoiceManager {
|
||||
channels: HashMap<String, VoiceChannel>,
|
||||
active_channel: Option<String>,
|
||||
}
|
||||
|
||||
pub struct VoiceChannel {
|
||||
pub room_id: String,
|
||||
pub participants: Vec<VoiceParticipant>,
|
||||
}
|
||||
|
||||
pub struct VoiceParticipant {
|
||||
pub user_id: String,
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
impl VoiceManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: HashMap::new(),
|
||||
active_channel: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_channel(&mut self, room_id: String, user_id: String) -> bool {
|
||||
if let Some(ref old_channel) = self.active_channel {
|
||||
self.leave_channel_internal(old_channel, &user_id);
|
||||
}
|
||||
|
||||
let channel = self.channels.entry(room_id.clone()).or_insert_with(|| VoiceChannel {
|
||||
room_id: room_id.clone(),
|
||||
participants: Vec::new(),
|
||||
});
|
||||
if channel.participants.iter().any(|p| p.user_id == user_id) {
|
||||
return false;
|
||||
}
|
||||
channel.participants.push(VoiceParticipant {
|
||||
user_id,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
streaming: false,
|
||||
});
|
||||
self.active_channel = Some(room_id);
|
||||
true
|
||||
}
|
||||
|
||||
fn leave_channel_internal(&mut self, room_id: &str, user_id: &str) {
|
||||
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||
channel.participants.retain(|p| p.user_id != user_id);
|
||||
if channel.participants.is_empty() {
|
||||
self.channels.remove(room_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn leave_channel(&mut self, room_id: &str, user_id: &str) -> bool {
|
||||
self.leave_channel_internal(room_id, user_id);
|
||||
if self.active_channel.as_deref() == Some(room_id) {
|
||||
self.active_channel = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn toggle_mute(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||
participant.muted = !participant.muted;
|
||||
return Some(participant.muted);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn toggle_deafen(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||
participant.deafened = !participant.deafened;
|
||||
if participant.deafened {
|
||||
participant.muted = true;
|
||||
}
|
||||
return Some(participant.deafened);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_active_channel(&self) -> Option<&str> {
|
||||
self.active_channel.as_deref()
|
||||
}
|
||||
|
||||
pub fn get_participants(&self, room_id: &str) -> Vec<&VoiceParticipant> {
|
||||
self.channels
|
||||
.get(room_id)
|
||||
.map(|c| c.participants.iter().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use matrix_sdk::Client;
|
||||
|
||||
use crate::matrix::voice::VoiceManager;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppStateInner {
|
||||
pub client: Option<Client>,
|
||||
pub logged_in: bool,
|
||||
pub user_id: Option<String>,
|
||||
pub voice_manager: VoiceManager,
|
||||
pub auth_token: Option<String>,
|
||||
pub server_url: Option<String>,
|
||||
}
|
||||
|
||||
pub type AppState = Arc<RwLock<AppStateInner>>;
|
||||
pub type AppState = tauri::async_runtime::RwLock<AppStateInner>;
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
Arc::new(RwLock::new(AppStateInner::default()))
|
||||
}
|
||||
pub fn get_client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
55
client/tauri.conf.json
Normal file
55
client/tauri.conf.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "EifelDC",
|
||||
"version": "0.1.0",
|
||||
"identifier": "de.eifeldc",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"frontendDist": "../src-ui/dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "EifelDC",
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src https: wss: http://localhost:*; img-src https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"icon": [
|
||||
"src-tauri/icons/32x32.png",
|
||||
"src-tauri/icons/128x128.png",
|
||||
"src-tauri/icons/128x128@2x.png",
|
||||
"src-tauri/icons/icon.icns",
|
||||
"src-tauri/icons/icon.ico"
|
||||
],
|
||||
"category": "SocialNetworking",
|
||||
"shortDescription": "EifelDC - Matrix Chat Client",
|
||||
"longDescription": "EifelDC is a Discord-like Matrix chat client built with Tauri, Svelte and Rust.",
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"entitlements": "macos/EifelDC.entitlements"
|
||||
},
|
||||
"windows": {
|
||||
"digestAlgorithm": "sha256"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,6 @@ POSTGRES_PASSWORD=changeme_postgres_password
|
||||
TURN_SECRET=changeme_turn_secret
|
||||
MACAROON_SECRET=changeme_macaroon_secret
|
||||
FORM_SECRET=changeme_form_secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=devsecret
|
||||
LIVEKIT_NODE_IP=127.0.0.1
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
eifeldc:
|
||||
build:
|
||||
@@ -12,12 +10,21 @@ services:
|
||||
environment:
|
||||
- EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
|
||||
- RUST_LOG=eifeldc_server=info,tower_http=info
|
||||
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY:-devkey}
|
||||
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET:-devsecret}
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
- SYNAPSE_URL=http://synapse:8008
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/current-user"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -48,6 +55,11 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
cpus: "2.0"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -72,6 +84,11 @@ services:
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: "1.0"
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
@@ -95,6 +112,32 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
container_name: eifeldc-livekit
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
- "7882:7882/udp"
|
||||
- "50000-50200:50000-50200/udp"
|
||||
environment:
|
||||
- LIVEKIT_KEYS=${LIVEKIT_API_KEY:-devkey}: ${LIVEKIT_API_SECRET:-devsecret}
|
||||
command: --dev --node-ip ${LIVEKIT_NODE_IP:-127.0.0.1}
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:7880"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- eifeldc
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: eifeldc-nginx
|
||||
@@ -110,6 +153,8 @@ services:
|
||||
condition: service_healthy
|
||||
synapse:
|
||||
condition: service_healthy
|
||||
livekit:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80/"]
|
||||
interval: 15s
|
||||
|
||||
@@ -6,6 +6,10 @@ upstream synapse_server {
|
||||
server synapse:8008;
|
||||
}
|
||||
|
||||
upstream livekit_server {
|
||||
server livekit:7880;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name eifeldc.local;
|
||||
@@ -60,6 +64,18 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /livekit/ {
|
||||
proxy_pass http://livekit_server/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://eifeldc_server;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -15,8 +15,17 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
url = { workspace = true }
|
||||
axum = { version = "0.7", features = ["ws"] }
|
||||
axum = { version = "0.7", features = ["ws", "multipart"] }
|
||||
tower = "0.4"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
mime = "0.3"
|
||||
futures = "0.3"
|
||||
rusqlite = { version = "0.30", features = ["bundled"] }
|
||||
livekit-api = { version = "0.4", features = ["access-token"] }
|
||||
prometheus = "0.13"
|
||||
lazy_static = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod routes;
|
||||
pub mod session_store;
|
||||
pub mod state;
|
||||
|
||||
pub use session_store::SessionStore;
|
||||
pub use state::ServerState;
|
||||
@@ -1,20 +1,66 @@
|
||||
use eifeldc_server::routes::api_router;
|
||||
use eifeldc_server::routes::metrics;
|
||||
use eifeldc_server::session_store::SessionStore;
|
||||
use eifeldc_server::state::{LiveKitConfig, Session};
|
||||
use eifeldc_server::ServerState;
|
||||
use tower_http::services::ServeDir;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt::init();
|
||||
init_tracing();
|
||||
|
||||
metrics::init_metrics();
|
||||
tracing::info!("Metrics initialized");
|
||||
|
||||
let db_path = std::env::var("EIFELDC_DB").unwrap_or_else(|_| "eifeldc.db".to_string());
|
||||
|
||||
let session_store =
|
||||
Arc::new(SessionStore::new(&db_path).expect("Failed to open session database"));
|
||||
|
||||
let livekit = LiveKitConfig {
|
||||
api_key: std::env::var("LIVEKIT_API_KEY").unwrap_or_else(|_| "devkey".to_string()),
|
||||
api_secret: std::env::var("LIVEKIT_API_SECRET").unwrap_or_else(|_| "devsecret".to_string()),
|
||||
url: std::env::var("LIVEKIT_URL").unwrap_or_else(|_| "ws://localhost:7880".to_string()),
|
||||
};
|
||||
tracing::info!("LiveKit URL: {}", livekit.url);
|
||||
|
||||
let session_ttl: Option<std::time::Duration> =
|
||||
std::env::var("EIFELDC_SESSION_TTL").ok().map(|v| {
|
||||
let secs: u64 = v.parse().unwrap_or(86400);
|
||||
std::time::Duration::from_secs(secs)
|
||||
});
|
||||
if let Some(ttl) = session_ttl {
|
||||
tracing::info!("Session TTL: {}s", ttl.as_secs());
|
||||
} else {
|
||||
tracing::info!("Session TTL: unlimited");
|
||||
}
|
||||
|
||||
let state = ServerState::new(session_store.clone(), livekit, session_ttl);
|
||||
|
||||
{
|
||||
let stored_sessions = session_store.get_all_sessions().await.unwrap_or_default();
|
||||
if !stored_sessions.is_empty() {
|
||||
tracing::info!("Restoring {} session(s)...", stored_sessions.len());
|
||||
for stored in &stored_sessions {
|
||||
match restore_session(stored, &state).await {
|
||||
Ok(()) => tracing::info!("Restored session for user {}", stored.user_id),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to restore session for {}: {}", stored.user_id, e);
|
||||
session_store.delete_session(&stored.token).await.ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let state = ServerState::new();
|
||||
let api = api_router(state);
|
||||
|
||||
let static_dir = std::env::var("EIFELDC_STATIC_DIR")
|
||||
.unwrap_or_else(|_| "client/src-ui/dist".to_string());
|
||||
let static_dir =
|
||||
std::env::var("EIFELDC_STATIC_DIR").unwrap_or_else(|_| "client/src-ui/dist".to_string());
|
||||
|
||||
let app = api
|
||||
.fallback_service(ServeDir::new(&static_dir));
|
||||
let app = api.fallback_service(ServeDir::new(&static_dir));
|
||||
|
||||
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
|
||||
tracing::info!("EifelDC Web Server listening on http://{}", addr);
|
||||
@@ -23,3 +69,79 @@ async fn main() {
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
async fn restore_session(
|
||||
stored: &eifeldc_server::session_store::StoredSession,
|
||||
state: &ServerState,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use matrix_sdk::matrix_auth::{MatrixSession, MatrixSessionTokens};
|
||||
use matrix_sdk::ruma::device_id;
|
||||
use matrix_sdk::Client;
|
||||
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&stored.homeserver)
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let user_id: matrix_sdk::ruma::OwnedUserId = stored.user_id.parse()?;
|
||||
let device_id = stored
|
||||
.device_id
|
||||
.as_deref()
|
||||
.map(matrix_sdk::ruma::OwnedDeviceId::from)
|
||||
.unwrap_or_else(|| device_id!("EIFELDC").to_owned());
|
||||
|
||||
let session = MatrixSession {
|
||||
meta: matrix_sdk::SessionMeta { user_id, device_id },
|
||||
tokens: MatrixSessionTokens {
|
||||
access_token: stored.access_token.clone(),
|
||||
refresh_token: stored.refresh_token.clone(),
|
||||
},
|
||||
};
|
||||
|
||||
client.matrix_auth().restore_session(session).await?;
|
||||
|
||||
let mut s = Session::new(
|
||||
client.clone(),
|
||||
stored.user_id.clone(),
|
||||
stored.homeserver.clone(),
|
||||
);
|
||||
|
||||
let sender = s.event_sender.clone();
|
||||
let sync_client = client.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
eifeldc_server::routes::auth::start_sync(sync_client, sender).await;
|
||||
});
|
||||
s.sync_handle = Some(handle);
|
||||
|
||||
let mut state_inner = state.write().await;
|
||||
state_inner.sessions.insert(stored.token.clone(), s);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_tracing() {
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
let log_format = std::env::var("EIFELDC_LOG_FORMAT").unwrap_or_else(|_| "pretty".to_string());
|
||||
let env_filter = EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("eifeldc_server=info,tower_http=info"));
|
||||
|
||||
match log_format.as_str() {
|
||||
"json" => {
|
||||
tracing_subscriber::fmt()
|
||||
.json()
|
||||
.with_env_filter(env_filter)
|
||||
.with_target(true)
|
||||
.with_thread_ids(false)
|
||||
.with_file(false)
|
||||
.with_line_number(false)
|
||||
.init();
|
||||
}
|
||||
_ => {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_target(true)
|
||||
.init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
Json,
|
||||
};
|
||||
use crate::session_store::StoredSession;
|
||||
use crate::state::{Session, WsEvent};
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use matrix_sdk::config::SyncSettings;
|
||||
use matrix_sdk::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::state::VoiceManager;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
@@ -14,6 +12,21 @@ pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl LoginRequest {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.homeserver.is_empty() || self.homeserver.len() > 2048 {
|
||||
return Err("Invalid homeserver");
|
||||
}
|
||||
if self.username.is_empty() || self.username.len() > 256 {
|
||||
return Err("Invalid username");
|
||||
}
|
||||
if self.password.is_empty() || self.password.len() > 4096 {
|
||||
return Err("Invalid password");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LoginResult {
|
||||
pub success: bool,
|
||||
@@ -29,36 +42,83 @@ pub struct RegisterRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
impl RegisterRequest {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.homeserver.is_empty() || self.homeserver.len() > 2048 {
|
||||
return Err("Invalid homeserver");
|
||||
}
|
||||
if self.username.is_empty() || self.username.len() > 256 {
|
||||
return Err("Invalid username");
|
||||
}
|
||||
if self.password.is_empty() || self.password.len() > 4096 {
|
||||
return Err("Invalid password");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<crate::state::ServerState>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<LoginResult>, StatusCode> {
|
||||
) -> Result<Json<LoginResult>, axum::http::StatusCode> {
|
||||
req.validate()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&req.homeserver)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
client
|
||||
.matrix_auth()
|
||||
.login_username(&req.username, &req.password)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
.map_err(|_| axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let user_id = client
|
||||
.user_id()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
let user_id = client.user_id().map(|u| u.to_string()).unwrap_or_default();
|
||||
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.sessions.insert(token.clone(), crate::state::Session {
|
||||
client,
|
||||
user_id: user_id.clone(),
|
||||
voice_manager: VoiceManager::new(),
|
||||
let matrix_session = client
|
||||
.matrix_auth()
|
||||
.session()
|
||||
.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut session = Session::new(client.clone(), user_id.clone(), req.homeserver.clone());
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Some(ttl) = s.session_ttl {
|
||||
session = session.with_ttl(ttl);
|
||||
}
|
||||
}
|
||||
|
||||
let sender = session.event_sender.clone();
|
||||
let sync_client = client.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
start_sync(sync_client, sender).await;
|
||||
});
|
||||
session.sync_handle = Some(handle);
|
||||
|
||||
let stored = StoredSession {
|
||||
token: token.clone(),
|
||||
user_id: user_id.clone(),
|
||||
homeserver: req.homeserver.clone(),
|
||||
access_token: matrix_session.tokens.access_token.clone(),
|
||||
device_id: Some(matrix_session.meta.device_id.to_string()),
|
||||
refresh_token: matrix_session.tokens.refresh_token.clone(),
|
||||
};
|
||||
|
||||
{
|
||||
let s = state.read().await;
|
||||
s.session_store
|
||||
.save_session(&stored)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.sessions.insert(token.clone(), session);
|
||||
|
||||
Ok(Json(LoginResult {
|
||||
success: true,
|
||||
@@ -71,12 +131,14 @@ pub async fn login(
|
||||
pub async fn register(
|
||||
State(state): State<crate::state::ServerState>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> Result<Json<LoginResult>, StatusCode> {
|
||||
) -> Result<Json<LoginResult>, axum::http::StatusCode> {
|
||||
req.validate()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let client = Client::builder()
|
||||
.homeserver_url(&req.homeserver)
|
||||
.build()
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut request = matrix_sdk::ruma::api::client::account::register::v3::Request::new();
|
||||
request.username = Some(req.username);
|
||||
@@ -86,21 +148,51 @@ pub async fn register(
|
||||
.matrix_auth()
|
||||
.register(request)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let user_id = client
|
||||
.user_id()
|
||||
.map(|u| u.to_string())
|
||||
.unwrap_or_default();
|
||||
let user_id = client.user_id().map(|u| u.to_string()).unwrap_or_default();
|
||||
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.sessions.insert(token.clone(), crate::state::Session {
|
||||
client,
|
||||
user_id: user_id.clone(),
|
||||
voice_manager: VoiceManager::new(),
|
||||
let matrix_session = client
|
||||
.matrix_auth()
|
||||
.session()
|
||||
.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut session = Session::new(client.clone(), user_id.clone(), req.homeserver.clone());
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Some(ttl) = s.session_ttl {
|
||||
session = session.with_ttl(ttl);
|
||||
}
|
||||
}
|
||||
|
||||
let sender = session.event_sender.clone();
|
||||
let sync_client = client.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
start_sync(sync_client, sender).await;
|
||||
});
|
||||
session.sync_handle = Some(handle);
|
||||
|
||||
let stored = StoredSession {
|
||||
token: token.clone(),
|
||||
user_id: user_id.clone(),
|
||||
homeserver: req.homeserver.clone(),
|
||||
access_token: matrix_session.tokens.access_token.clone(),
|
||||
device_id: Some(matrix_session.meta.device_id.to_string()),
|
||||
refresh_token: matrix_session.tokens.refresh_token.clone(),
|
||||
};
|
||||
|
||||
{
|
||||
let s = state.read().await;
|
||||
s.session_store
|
||||
.save_session(&stored)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
|
||||
let mut s = state.write().await;
|
||||
s.sessions.insert(token.clone(), session);
|
||||
|
||||
Ok(Json(LoginResult {
|
||||
success: true,
|
||||
@@ -113,22 +205,34 @@ pub async fn register(
|
||||
pub async fn logout(
|
||||
State(state): State<crate::state::ServerState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<bool>, StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let mut s = state.write().await;
|
||||
if let Some(session) = s.sessions.remove(&token) {
|
||||
if let Some(mut session) = s.sessions.remove(&token) {
|
||||
if let Some(handle) = session.sync_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
let _ = session.client.matrix_auth().logout().await;
|
||||
}
|
||||
|
||||
{
|
||||
let s_read = state.read().await;
|
||||
s_read
|
||||
.session_store
|
||||
.delete_session(&token)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
pub async fn get_current_user(
|
||||
State(state): State<crate::state::ServerState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Option<String>>, StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
) -> Result<Json<Option<String>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let s = state.read().await;
|
||||
let user_id = s.sessions.get(&token).map(|s| s.user_id.clone());
|
||||
@@ -147,14 +251,377 @@ pub async fn auth_middleware(
|
||||
headers: HeaderMap,
|
||||
State(state): State<crate::state::ServerState>,
|
||||
request: axum::extract::Request,
|
||||
next: middleware::Next,
|
||||
) -> Result<axum::response::Response, StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
next: axum::middleware::Next,
|
||||
) -> Result<axum::response::Response, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let s = state.read().await;
|
||||
if !s.sessions.contains_key(&token) {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
let expired = {
|
||||
let s = state.read().await;
|
||||
match s.sessions.get(&token) {
|
||||
Some(session) => session.is_expired(),
|
||||
None => return Err(axum::http::StatusCode::UNAUTHORIZED),
|
||||
}
|
||||
};
|
||||
|
||||
if expired {
|
||||
let mut s = state.write().await;
|
||||
if let Some(mut session) = s.sessions.remove(&token) {
|
||||
if let Some(handle) = session.sync_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
drop(s);
|
||||
{
|
||||
let s = state.read().await;
|
||||
let _ = s.session_store.delete_session(&token).await;
|
||||
}
|
||||
return Err(axum::http::StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
pub async fn start_sync(client: Client, sender: tokio::sync::broadcast::Sender<WsEvent>) {
|
||||
let mut sync_token: Option<String> = None;
|
||||
loop {
|
||||
let mut settings = SyncSettings::new();
|
||||
if let Some(token) = sync_token.as_ref() {
|
||||
settings = settings.token(token.clone());
|
||||
}
|
||||
match client.sync_once(settings).await {
|
||||
Ok(response) => {
|
||||
sync_token = Some(response.next_batch);
|
||||
|
||||
for (room_id, joined) in &response.rooms.join {
|
||||
let room = match client.get_room(room_id) {
|
||||
Some(r) => r,
|
||||
None => continue,
|
||||
};
|
||||
let name = match room.display_name().await {
|
||||
Ok(n) => n.to_string(),
|
||||
Err(_) => room_id.to_string(),
|
||||
};
|
||||
let _ = sender.send(WsEvent::RoomJoined {
|
||||
room_id: room_id.to_string(),
|
||||
name,
|
||||
});
|
||||
|
||||
for event in &joined.timeline.events {
|
||||
if let Some(event_id) = event.event_id() {
|
||||
let raw_json: serde_json::Value = match event.event.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let sender_user = raw_json
|
||||
.get("sender")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let event_type =
|
||||
raw_json.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
if event_type == "m.room.message" {
|
||||
let content =
|
||||
raw_json.get("content").unwrap_or(&serde_json::Value::Null);
|
||||
let new_content = content.get("m.new_content");
|
||||
|
||||
if new_content.is_some() {
|
||||
let relates_to = content.get("m.relates_to");
|
||||
let original_id = relates_to
|
||||
.and_then(|r| r.get("event_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let new_body = new_content
|
||||
.and_then(|nc| nc.get("body"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !original_id.is_empty() && !new_body.is_empty() {
|
||||
let _ = sender.send(WsEvent::MessageEdited {
|
||||
room_id: room_id.to_string(),
|
||||
event_id: original_id.to_string(),
|
||||
new_body: new_body.to_string(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let body =
|
||||
content.get("body").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let timestamp = raw_json
|
||||
.get("origin_server_ts")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let msgtype = content
|
||||
.get("msgtype")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("m.text");
|
||||
|
||||
let media_url = content
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
content
|
||||
.get("file")
|
||||
.and_then(|f| f.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let filename = if msgtype == "m.image"
|
||||
|| msgtype == "m.file"
|
||||
|| msgtype == "m.video"
|
||||
|| msgtype == "m.audio"
|
||||
{
|
||||
Some(body.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mimetype = content
|
||||
.get("info")
|
||||
.and_then(|i| i.get("mimetype"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let relates_to = content.get("m.relates_to");
|
||||
let reply_to = relates_to
|
||||
.and_then(|r| r.get("m.in_reply_to"))
|
||||
.and_then(|r| r.get("event_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let is_thread = relates_to
|
||||
.and_then(|r| r.get("rel_type"))
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("m.thread");
|
||||
|
||||
if !body.is_empty() || media_url.is_some() {
|
||||
if is_thread {
|
||||
if let Some(root_id) = relates_to
|
||||
.and_then(|r| r.get("event_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let _ = sender.send(WsEvent::ThreadReply {
|
||||
room_id: room_id.to_string(),
|
||||
root_event_id: root_id.to_string(),
|
||||
event_id: event_id.to_string(),
|
||||
sender: sender_user.to_string(),
|
||||
body: body.to_string(),
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let _ = sender.send(WsEvent::Message {
|
||||
room_id: room_id.to_string(),
|
||||
event_id: event_id.to_string(),
|
||||
sender: sender_user.to_string(),
|
||||
body: body.to_string(),
|
||||
timestamp,
|
||||
reply_to,
|
||||
msgtype: Some(msgtype.to_string()),
|
||||
media_url: media_url.clone(),
|
||||
filename,
|
||||
mimetype,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if event_type == "m.reaction" {
|
||||
let content =
|
||||
raw_json.get("content").unwrap_or(&serde_json::Value::Null);
|
||||
if let Some(relates_to) = content.get("m.relates_to") {
|
||||
let target_id = relates_to
|
||||
.get("event_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let key = relates_to
|
||||
.get("key")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !target_id.is_empty() && !key.is_empty() {
|
||||
let _ = sender.send(WsEvent::Reaction {
|
||||
room_id: room_id.to_string(),
|
||||
event_id: target_id.to_string(),
|
||||
key: key.to_string(),
|
||||
sender: sender_user.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if event_type == "m.room.redaction" {
|
||||
let redacts = raw_json
|
||||
.get("redacts")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if !redacts.is_empty() {
|
||||
let _ = sender.send(WsEvent::MessageDeleted {
|
||||
room_id: room_id.to_string(),
|
||||
redacts: redacts.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for raw_ephemeral in &joined.ephemeral {
|
||||
let val: serde_json::Value = match raw_ephemeral.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let event_type = val.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if event_type == "m.typing" {
|
||||
let user_ids = val
|
||||
.get("content")
|
||||
.and_then(|c| c.get("user_ids"))
|
||||
.and_then(|v| v.as_array());
|
||||
if let Some(ids) = user_ids {
|
||||
for uid in ids {
|
||||
if let Some(user_str) = uid.as_str() {
|
||||
let _ = sender.send(WsEvent::Typing {
|
||||
room_id: room_id.to_string(),
|
||||
user_id: user_str.to_string(),
|
||||
typing: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for room_id in response.rooms.leave.keys() {
|
||||
let _ = sender.send(WsEvent::RoomLeft {
|
||||
room_id: room_id.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
for raw_presence in &response.presence {
|
||||
let val: serde_json::Value = match raw_presence.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let user_id = val.get("sender").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let presence = val
|
||||
.get("content")
|
||||
.and_then(|c| c.get("presence"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("offline");
|
||||
let status_msg = val
|
||||
.get("content")
|
||||
.and_then(|c| c.get("status_msg"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
if !user_id.is_empty() {
|
||||
let status = match presence {
|
||||
"online" => "online",
|
||||
"unavailable" => "idle",
|
||||
_ => "offline",
|
||||
};
|
||||
let _ = sender.send(WsEvent::Presence {
|
||||
user_id: user_id.to_string(),
|
||||
status: status.to_string(),
|
||||
status_msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Sync error: {}", e);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn login_request_validate_ok() {
|
||||
let req = LoginRequest {
|
||||
homeserver: "https://matrix.example.org".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret123".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_request_reject_empty_homeserver() {
|
||||
let req = LoginRequest {
|
||||
homeserver: "".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "secret".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_request_reject_empty_username() {
|
||||
let req = LoginRequest {
|
||||
homeserver: "https://matrix.example.org".to_string(),
|
||||
username: "".to_string(),
|
||||
password: "secret".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_request_reject_empty_password() {
|
||||
let req = LoginRequest {
|
||||
homeserver: "https://matrix.example.org".to_string(),
|
||||
username: "alice".to_string(),
|
||||
password: "".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_request_reject_long_username() {
|
||||
let req = LoginRequest {
|
||||
homeserver: "https://matrix.example.org".to_string(),
|
||||
username: "a".repeat(300),
|
||||
password: "secret".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_request_validate_ok() {
|
||||
let req = RegisterRequest {
|
||||
homeserver: "https://matrix.example.org".to_string(),
|
||||
username: "bob".to_string(),
|
||||
password: "password".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_request_reject_empty_fields() {
|
||||
let req = RegisterRequest {
|
||||
homeserver: "".to_string(),
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
};
|
||||
assert!(req.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_token_from_bearer() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Authorization", "Bearer my-token-123".parse().unwrap());
|
||||
assert_eq!(extract_token(&headers), Some("my-token-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_token_missing_header() {
|
||||
let headers = HeaderMap::new();
|
||||
assert_eq!(extract_token(&headers), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_token_invalid_scheme() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Authorization", "Basic dXNlcjpwYXNz".parse().unwrap());
|
||||
assert_eq!(extract_token(&headers), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ServerState;
|
||||
use super::auth::extract_token;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct CustomEmoji {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
@@ -33,10 +33,69 @@ pub struct Sticker {
|
||||
pub async fn get_custom_emoji(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(_room_id): Path<String>,
|
||||
Path(room_id): Path<String>,
|
||||
) -> Result<Json<Vec<CustomEmoji>>, axum::http::StatusCode> {
|
||||
let _token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
Ok(Json(Vec::new()))
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let mut emojis = Vec::new();
|
||||
|
||||
let event_type: matrix_sdk::ruma::events::StateEventType = "im.ponies.room_emotes".into();
|
||||
|
||||
if let Ok(Some(raw_event)) = room.get_state_event(event_type, "").await {
|
||||
let raw_json_str = match &raw_event {
|
||||
matrix_sdk::deserialized_responses::RawAnySyncOrStrippedState::Sync(s) => {
|
||||
s.json().get().to_string()
|
||||
}
|
||||
matrix_sdk::deserialized_responses::RawAnySyncOrStrippedState::Stripped(s) => {
|
||||
s.json().get().to_string()
|
||||
}
|
||||
};
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw_json_str) {
|
||||
if let Some(content) = value.get("content") {
|
||||
if let Some(images) = content.get("images").and_then(|v| v.as_object()) {
|
||||
for (key, entry) in images {
|
||||
let url = entry
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let name = entry
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(key)
|
||||
.to_string();
|
||||
let animated = url.contains(".gif");
|
||||
emojis.push(CustomEmoji {
|
||||
id: key.clone(),
|
||||
name,
|
||||
url,
|
||||
category: "custom".to_string(),
|
||||
animated,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(emojis))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -53,34 +112,50 @@ pub async fn upload_emoji(
|
||||
) -> Result<Json<CustomEmoji>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let _room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let _room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let path = std::path::Path::new(&req.image_path);
|
||||
if !path.exists() {
|
||||
return Err(axum::http::StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
let mime_type = match path.extension().and_then(|e| e.to_str()) {
|
||||
let mime_type: mime::Mime = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "image/png",
|
||||
};
|
||||
}
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let data = std::fs::read(path).map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let content_type = mime_type.parse::<matrix_sdk::ruma::mime::Mime>().map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let is_animated = mime_type.subtype() == mime::IMAGE_GIF.subtype();
|
||||
|
||||
let response = session.client.media().upload(&content_type, data).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let response = session
|
||||
.client
|
||||
.media()
|
||||
.upload(&mime_type, data)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(CustomEmoji {
|
||||
id: format!("emoji_{}", chrono::Utc::now().timestamp()),
|
||||
name: req.name,
|
||||
url: response.content_uri.to_string(),
|
||||
category: "custom".to_string(),
|
||||
animated: mime_type == "image/gif",
|
||||
animated: is_animated,
|
||||
}))
|
||||
}
|
||||
47
server/src/routes/media.rs
Normal file
47
server/src/routes/media.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
response::Response,
|
||||
};
|
||||
use matrix_sdk::ruma::OwnedMxcUri;
|
||||
|
||||
pub async fn get_media(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(mxc_path): Path<String>,
|
||||
) -> Result<Response<Body>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let mxc_uri: OwnedMxcUri = mxc_path.as_str().into();
|
||||
|
||||
let request =
|
||||
matrix_sdk::ruma::api::client::media::get_content::v3::Request::from_url(&mxc_uri)
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let response = session
|
||||
.client
|
||||
.send(request, None)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let content_type = response
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||
|
||||
let body = response.file;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", content_type)
|
||||
.header("Cache-Control", "public, max-age=86400")
|
||||
.body(Body::from(body))
|
||||
.unwrap())
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::routes::metrics::MESSAGES_SENT_TOTAL;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Path, State, Query},
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::ServerState;
|
||||
use super::auth::extract_token;
|
||||
use matrix_sdk::room::MessagesOptions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct MessageInfo {
|
||||
@@ -14,6 +16,15 @@ pub struct MessageInfo {
|
||||
pub body: String,
|
||||
pub timestamp: u64,
|
||||
pub reply_to: Option<String>,
|
||||
pub edited: bool,
|
||||
pub reactions: std::collections::HashMap<String, Vec<String>>,
|
||||
pub msgtype: String,
|
||||
pub media_url: Option<String>,
|
||||
pub filename: Option<String>,
|
||||
pub mimetype: Option<String>,
|
||||
pub width: Option<u64>,
|
||||
pub height: Option<u64>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -30,29 +41,196 @@ pub async fn get_room_messages(
|
||||
) -> Result<Json<Vec<MessageInfo>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let limit = query.limit.unwrap_or(50);
|
||||
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new();
|
||||
options.limit = limit.into();
|
||||
options.from = query.from.map(|t| t.into());
|
||||
let mut options = MessagesOptions::backward();
|
||||
options.limit = matrix_sdk::ruma::uint!(50);
|
||||
if let Some(from) = query.from {
|
||||
options.from = Some(from);
|
||||
}
|
||||
|
||||
let messages = room.messages(options).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let messages = room
|
||||
.messages(options)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut reactions_map: std::collections::HashMap<
|
||||
String,
|
||||
std::collections::HashMap<String, Vec<String>>,
|
||||
> = std::collections::HashMap::new();
|
||||
let mut result = Vec::new();
|
||||
for msg in messages.chunk {
|
||||
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg {
|
||||
result.push(MessageInfo {
|
||||
event_id: ev.event_id().to_string(),
|
||||
sender: ev.sender().to_string(),
|
||||
body: ev.content().body().to_string(),
|
||||
timestamp: ev.origin_server_ts().0,
|
||||
reply_to: ev.content().in_reply_to().map(|r| r.event_id.to_string()),
|
||||
});
|
||||
|
||||
for msg in &messages.chunk {
|
||||
let event_value: serde_json::Value = match msg.event.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type = event_value
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if event_type == "m.reaction" {
|
||||
if let Some(content) = event_value.get("content") {
|
||||
if let Some(relates_to) = content.get("m.relates_to") {
|
||||
if let (Some(target_id), Some(key)) = (
|
||||
relates_to.get("event_id").and_then(|v| v.as_str()),
|
||||
relates_to.get("key").and_then(|v| v.as_str()),
|
||||
) {
|
||||
let sender = event_value
|
||||
.get("sender")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
reactions_map
|
||||
.entry(target_id.to_string())
|
||||
.or_default()
|
||||
.entry(key.to_string())
|
||||
.or_default()
|
||||
.push(sender.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if event_type == "m.room.redaction" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event_id = match event_value.get("event_id").and_then(|v| v.as_str()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let sender = match event_value.get("sender").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let timestamp = event_value
|
||||
.get("origin_server_ts")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
|
||||
let content = event_value
|
||||
.get("content")
|
||||
.unwrap_or(&serde_json::Value::Null);
|
||||
let msgtype = content
|
||||
.get("msgtype")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if msgtype != "m.text"
|
||||
&& msgtype != "m.notice"
|
||||
&& msgtype != "m.emote"
|
||||
&& msgtype != "m.image"
|
||||
&& msgtype != "m.file"
|
||||
&& msgtype != "m.video"
|
||||
&& msgtype != "m.audio"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let body = content
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let new_content = content.get("m.new_content");
|
||||
let (display_body, edited) = if let Some(nc) = new_content {
|
||||
let new_body = nc.get("body").and_then(|v| v.as_str()).unwrap_or(&body);
|
||||
(new_body.to_string(), true)
|
||||
} else {
|
||||
(body.clone(), false)
|
||||
};
|
||||
|
||||
if body.is_empty() && (msgtype == "m.text" || msgtype == "m.notice" || msgtype == "m.emote")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let reply_to = content
|
||||
.get("m.relates_to")
|
||||
.and_then(|r| r.get("m.in_reply_to"))
|
||||
.and_then(|r| r.get("event_id"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let reactions = reactions_map.remove(&event_id).unwrap_or_default();
|
||||
|
||||
let media_url = content
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
content
|
||||
.get("file")
|
||||
.and_then(|f| f.get("url"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
});
|
||||
|
||||
let filename = if msgtype == "m.image"
|
||||
|| msgtype == "m.file"
|
||||
|| msgtype == "m.video"
|
||||
|| msgtype == "m.audio"
|
||||
{
|
||||
Some(body.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mimetype = content
|
||||
.get("info")
|
||||
.and_then(|i| i.get("mimetype"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let width = content
|
||||
.get("info")
|
||||
.and_then(|i| i.get("w"))
|
||||
.and_then(|v| v.as_u64());
|
||||
|
||||
let height = content
|
||||
.get("info")
|
||||
.and_then(|i| i.get("h"))
|
||||
.and_then(|v| v.as_u64());
|
||||
|
||||
let size = content
|
||||
.get("info")
|
||||
.and_then(|i| i.get("size"))
|
||||
.and_then(|v| v.as_u64());
|
||||
|
||||
result.push(MessageInfo {
|
||||
event_id,
|
||||
sender,
|
||||
body: display_body,
|
||||
timestamp,
|
||||
reply_to,
|
||||
edited,
|
||||
reactions,
|
||||
msgtype: msgtype.to_string(),
|
||||
media_url,
|
||||
filename,
|
||||
mimetype,
|
||||
width,
|
||||
height,
|
||||
size,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(result))
|
||||
@@ -71,14 +249,192 @@ pub async fn send_message(
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&req.message);
|
||||
let txn_id = matrix_sdk::ruma::TransactionId::new();
|
||||
let response = room.send(content, Some(&txn_id)).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let content =
|
||||
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&req.message);
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
MESSAGES_SENT_TOTAL.inc();
|
||||
Ok(Json(response.event_id.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditMessageRequest {
|
||||
pub event_id: String,
|
||||
pub new_content: String,
|
||||
}
|
||||
|
||||
pub async fn edit_message(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<EditMessageRequest>,
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let original_event_id: matrix_sdk::ruma::OwnedEventId = req
|
||||
.event_id
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(
|
||||
&req.new_content,
|
||||
)
|
||||
.make_replacement(
|
||||
matrix_sdk::ruma::events::room::message::ReplacementMetadata::new(original_event_id, None),
|
||||
None,
|
||||
);
|
||||
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(response.event_id.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteMessageRequest {
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn delete_message(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path((room_id, event_id)): Path<(String, String)>,
|
||||
Json(req): Json<DeleteMessageRequest>,
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let eid: matrix_sdk::ruma::OwnedEventId = event_id
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let response = room
|
||||
.redact(&eid, req.reason.as_deref(), None)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(response.event_id.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ReactRequest {
|
||||
pub event_id: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
pub async fn react_to_message(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<ReactRequest>,
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let event_id: matrix_sdk::ruma::OwnedEventId = req
|
||||
.event_id
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let content = matrix_sdk::ruma::events::reaction::ReactionEventContent::new(
|
||||
matrix_sdk::ruma::events::relation::Annotation::new(event_id, req.key),
|
||||
);
|
||||
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(response.event_id.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TypingRequest {
|
||||
pub typing: bool,
|
||||
}
|
||||
|
||||
pub async fn set_typing(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<TypingRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
room.typing_notice(req.typing)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
82
server/src/routes/metrics.rs
Normal file
82
server/src/routes/metrics.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use axum::{body::Body, extract::State, http::HeaderMap, response::IntoResponse};
|
||||
use lazy_static::lazy_static;
|
||||
use prometheus::{Encoder, IntCounter, IntGauge, Registry, TextEncoder};
|
||||
|
||||
lazy_static! {
|
||||
pub static ref REGISTRY: Registry = Registry::new();
|
||||
pub static ref HTTP_REQUESTS_TOTAL: IntCounter =
|
||||
IntCounter::new("eifeldc_http_requests_total", "Total HTTP requests")
|
||||
.expect("metric can be created");
|
||||
pub static ref ACTIVE_SESSIONS: IntGauge =
|
||||
IntGauge::new("eifeldc_active_sessions", "Number of active sessions")
|
||||
.expect("metric can be created");
|
||||
pub static ref ACTIVE_WEBSOCKETS: IntGauge = IntGauge::new(
|
||||
"eifeldc_active_websockets",
|
||||
"Number of active WebSocket connections"
|
||||
)
|
||||
.expect("metric can be created");
|
||||
pub static ref MESSAGES_SENT_TOTAL: IntCounter =
|
||||
IntCounter::new("eifeldc_messages_sent_total", "Total messages sent")
|
||||
.expect("metric can be created");
|
||||
pub static ref ROOMS_JOINED_TOTAL: IntCounter =
|
||||
IntCounter::new("eifeldc_rooms_joined_total", "Total room join operations")
|
||||
.expect("metric can be created");
|
||||
pub static ref UPLOADS_TOTAL: IntCounter =
|
||||
IntCounter::new("eifeldc_uploads_total", "Total file uploads")
|
||||
.expect("metric can be created");
|
||||
pub static ref VOICE_PARTICIPANTS: IntGauge = IntGauge::new(
|
||||
"eifeldc_voice_participants",
|
||||
"Current voice channel participants"
|
||||
)
|
||||
.expect("metric can be created");
|
||||
}
|
||||
|
||||
pub fn init_metrics() {
|
||||
REGISTRY
|
||||
.register(Box::new(HTTP_REQUESTS_TOTAL.clone()))
|
||||
.expect("metric can be registered");
|
||||
REGISTRY
|
||||
.register(Box::new(ACTIVE_SESSIONS.clone()))
|
||||
.expect("metric can be registered");
|
||||
REGISTRY
|
||||
.register(Box::new(ACTIVE_WEBSOCKETS.clone()))
|
||||
.expect("metric can be registered");
|
||||
REGISTRY
|
||||
.register(Box::new(MESSAGES_SENT_TOTAL.clone()))
|
||||
.expect("metric can be registered");
|
||||
REGISTRY
|
||||
.register(Box::new(ROOMS_JOINED_TOTAL.clone()))
|
||||
.expect("metric can be registered");
|
||||
REGISTRY
|
||||
.register(Box::new(UPLOADS_TOTAL.clone()))
|
||||
.expect("metric can be registered");
|
||||
REGISTRY
|
||||
.register(Box::new(VOICE_PARTICIPANTS.clone()))
|
||||
.expect("metric can be registered");
|
||||
}
|
||||
|
||||
pub async fn get_metrics(
|
||||
State(state): State<crate::ServerState>,
|
||||
_headers: HeaderMap,
|
||||
) -> impl IntoResponse {
|
||||
ACTIVE_SESSIONS.set(state.read().await.sessions.len() as i64);
|
||||
|
||||
let mut participants: i64 = 0;
|
||||
let s = state.read().await;
|
||||
for room in s.voice_rooms.rooms.values() {
|
||||
participants += room.participants.len() as i64;
|
||||
}
|
||||
VOICE_PARTICIPANTS.set(participants);
|
||||
drop(s);
|
||||
|
||||
let metric_families = REGISTRY.gather();
|
||||
let mut buffer = Vec::new();
|
||||
let encoder = TextEncoder::new();
|
||||
encoder.encode(&metric_families, &mut buffer).unwrap();
|
||||
|
||||
axum::http::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
.body(Body::from(buffer))
|
||||
.unwrap()
|
||||
}
|
||||
@@ -1,37 +1,79 @@
|
||||
pub mod auth;
|
||||
pub mod rooms;
|
||||
pub mod messages;
|
||||
pub mod presence;
|
||||
pub mod voice;
|
||||
pub mod emoji;
|
||||
pub mod media;
|
||||
pub mod messages;
|
||||
pub mod metrics;
|
||||
pub mod presence;
|
||||
pub mod profile;
|
||||
pub mod rate_limit;
|
||||
pub mod roles;
|
||||
pub mod rooms;
|
||||
pub mod threads;
|
||||
pub mod upload;
|
||||
pub mod voice;
|
||||
pub mod ws;
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
middleware,
|
||||
http::HeaderMap,
|
||||
};
|
||||
use tower_http::cors::{CorsLayer, Any};
|
||||
use crate::ServerState;
|
||||
use crate::routes::auth::auth_middleware;
|
||||
use crate::routes::rate_limit::{RateLimitConfig, RateLimiter};
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use std::time::Duration;
|
||||
use tower_http::cors::{AllowHeaders, AllowMethods, CorsLayer};
|
||||
|
||||
pub fn api_router(state: ServerState) -> Router {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
let allowed_origins = std::env::var("EIFELDC_CORS_ORIGINS").unwrap_or_else(|_| "*".to_string());
|
||||
|
||||
let cors = if allowed_origins == "*" {
|
||||
CorsLayer::permissive()
|
||||
} else {
|
||||
let origins: Vec<_> = allowed_origins
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(origins)
|
||||
.allow_methods(AllowMethods::any())
|
||||
.allow_headers(AllowHeaders::any())
|
||||
.max_age(Duration::from_secs(3600))
|
||||
};
|
||||
|
||||
let max_rpm = std::env::var("EIFELDC_RATE_LIMIT")
|
||||
.ok()
|
||||
.map(|v| v.parse().unwrap_or(60))
|
||||
.unwrap_or(60);
|
||||
let rate_limiter = RateLimiter::new(RateLimitConfig {
|
||||
max_requests: max_rpm,
|
||||
window_secs: 60,
|
||||
});
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api_routes(state))
|
||||
.layer(cors)
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
rate_limiter,
|
||||
rate_limit::rate_limit_middleware,
|
||||
))
|
||||
}
|
||||
|
||||
fn api_routes(state: ServerState) -> Router {
|
||||
let max_upload_bytes: usize = std::env::var("EIFELDC_MAX_UPLOAD_MB")
|
||||
.ok()
|
||||
.map(|v| v.parse().unwrap_or(50))
|
||||
.unwrap_or(50)
|
||||
* 1024
|
||||
* 1024;
|
||||
|
||||
let default_body_limit = axum::extract::DefaultBodyLimit::max(max_upload_bytes);
|
||||
|
||||
let public = Router::new()
|
||||
.route("/login", post(auth::login))
|
||||
.route("/register", post(auth::register))
|
||||
.route("/current-user", get(auth::get_current_user));
|
||||
.route("/current-user", get(auth::get_current_user))
|
||||
.route("/ws", get(ws::ws_handler))
|
||||
.route("/metrics", get(metrics::get_metrics));
|
||||
|
||||
let protected = Router::new()
|
||||
.route("/logout", post(auth::logout))
|
||||
@@ -40,24 +82,63 @@ fn api_routes(state: ServerState) -> Router {
|
||||
.route("/rooms/join", post(rooms::join_room))
|
||||
.route("/rooms/{room_id}/leave", post(rooms::leave_room))
|
||||
.route("/rooms/{room_id}/members", get(rooms::get_room_members))
|
||||
.route("/rooms/{room_id}/messages", get(messages::get_room_messages))
|
||||
.route("/rooms/{room_id}/name", post(rooms::set_room_name))
|
||||
.route("/rooms/{room_id}/topic", post(rooms::set_room_topic))
|
||||
.route("/rooms/{room_id}/avatar", post(rooms::set_room_avatar))
|
||||
.route(
|
||||
"/rooms/{room_id}/messages",
|
||||
get(messages::get_room_messages),
|
||||
)
|
||||
.route("/rooms/{room_id}/send", post(messages::send_message))
|
||||
.route("/rooms/{room_id}/edit", post(messages::edit_message))
|
||||
.route(
|
||||
"/rooms/{room_id}/delete/{event_id}",
|
||||
post(messages::delete_message),
|
||||
)
|
||||
.route("/rooms/{room_id}/react", post(messages::react_to_message))
|
||||
.route("/rooms/{room_id}/typing", post(messages::set_typing))
|
||||
.route("/presence/set", post(presence::set_presence))
|
||||
.route("/presence/{user_id}", get(presence::get_presence))
|
||||
.route("/voice/join", post(voice::join_voice_channel))
|
||||
.route("/voice/leave", post(voice::leave_voice_channel))
|
||||
.route("/voice/toggle-mute", post(voice::toggle_mute))
|
||||
.route("/voice/toggle-deafen", post(voice::toggle_deafen))
|
||||
.route("/voice/participants", get(voice::get_voice_participants))
|
||||
.route("/rooms/{room_id}/roles", get(roles::get_roles))
|
||||
.route("/rooms/{room_id}/roles/assign", post(roles::assign_role))
|
||||
.route("/rooms/{room_id}/roles/remove", post(roles::remove_role))
|
||||
.route("/rooms/{room_id}/permissions/{user_id}", get(roles::get_permissions))
|
||||
.route(
|
||||
"/rooms/{room_id}/permissions/{user_id}",
|
||||
get(roles::get_permissions),
|
||||
)
|
||||
.route("/rooms/{room_id}/emoji", get(emoji::get_custom_emoji))
|
||||
.route("/rooms/{room_id}/emoji/upload", post(emoji::upload_emoji))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware));
|
||||
.route("/rooms/{room_id}/threads", get(threads::get_threads))
|
||||
.route(
|
||||
"/rooms/{room_id}/threads/{thread_id}",
|
||||
get(threads::get_thread_messages),
|
||||
)
|
||||
.route(
|
||||
"/rooms/{room_id}/threads/{thread_id}/reply",
|
||||
post(threads::send_thread_reply),
|
||||
)
|
||||
.route("/rooms/{room_id}/reply", post(threads::send_reply))
|
||||
.route("/rooms/{room_id}/upload", post(upload::upload_file))
|
||||
.route("/rooms/unread", get(rooms::get_unread_counts))
|
||||
.route("/rooms/{room_id}/read", post(rooms::mark_room_read))
|
||||
.route("/media/{mxc_path}", get(media::get_media))
|
||||
.route("/profile/me", get(profile::get_own_profile))
|
||||
.route("/profile/{user_id}", get(profile::get_profile))
|
||||
.route("/profile/displayname", post(profile::set_display_name))
|
||||
.route("/profile/avatar", post(profile::upload_avatar))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
Router::new()
|
||||
.merge(public)
|
||||
.merge(protected)
|
||||
.with_state(state)
|
||||
.layer(default_body_limit)
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ServerState;
|
||||
use super::auth::extract_token;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetPresenceRequest {
|
||||
@@ -28,21 +28,31 @@ pub async fn set_presence(
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let presence_state = match req.status.as_str() {
|
||||
"online" => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||
"away" => matrix_sdk::ruma::presence::PresenceState::Away,
|
||||
"unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable,
|
||||
_ => matrix_sdk::ruma::presence::PresenceState::Online,
|
||||
};
|
||||
|
||||
let user_id = session.client.user_id().ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new(user_id.to_owned());
|
||||
request.presence = presence_state;
|
||||
request.status_msg = req.status_msg;
|
||||
let user_id = session
|
||||
.client
|
||||
.user_id()
|
||||
.ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new(
|
||||
user_id.to_owned(),
|
||||
presence_state,
|
||||
);
|
||||
|
||||
session.client.send(request, None).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
session
|
||||
.client
|
||||
.send(request, None)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
@@ -54,16 +64,26 @@ pub async fn get_presence(
|
||||
) -> Result<Json<PresenceInfo>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let request = matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned());
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let request =
|
||||
matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned());
|
||||
|
||||
let response = session.client.send(request, None).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let response = session
|
||||
.client
|
||||
.send(request, None)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let status_str = match response.presence {
|
||||
matrix_sdk::ruma::presence::PresenceState::Online => "online",
|
||||
matrix_sdk::ruma::presence::PresenceState::Away => "away",
|
||||
matrix_sdk::ruma::presence::PresenceState::Unavailable => "unavailable",
|
||||
_ => "offline",
|
||||
};
|
||||
|
||||
156
server/src/routes/profile.rs
Normal file
156
server/src/routes/profile.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserProfile {
|
||||
pub user_id: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_profile(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(user_id): Path<String>,
|
||||
) -> Result<Json<UserProfile>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let profile = session
|
||||
.client
|
||||
.get_profile(&uid)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(UserProfile {
|
||||
user_id,
|
||||
display_name: profile.displayname,
|
||||
avatar_url: profile.avatar_url.map(|u| u.to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_own_profile(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<UserProfile>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let user_id = session.user_id.clone();
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let profile = session
|
||||
.client
|
||||
.get_profile(&uid)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
Ok(Json(UserProfile {
|
||||
user_id,
|
||||
display_name: profile.displayname,
|
||||
avatar_url: profile.avatar_url.map(|u| u.to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetDisplayNameRequest {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
pub async fn set_display_name(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<SetDisplayNameRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
session
|
||||
.client
|
||||
.account()
|
||||
.set_display_name(Some(&req.display_name))
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
pub async fn upload_avatar(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UserProfile>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?
|
||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mime_type: mime::Mime = content_type.parse().unwrap_or(mime::IMAGE_PNG);
|
||||
|
||||
let upload = session
|
||||
.client
|
||||
.media()
|
||||
.upload(&mime_type, data.to_vec())
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mxc_uri = upload.content_uri;
|
||||
|
||||
session
|
||||
.client
|
||||
.account()
|
||||
.set_avatar_url(Some(&mxc_uri))
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let user_id = session.user_id.clone();
|
||||
|
||||
Ok(Json(UserProfile {
|
||||
user_id,
|
||||
display_name: None,
|
||||
avatar_url: Some(mxc_uri.to_string()),
|
||||
}))
|
||||
}
|
||||
155
server/src/routes/rate_limit.rs
Normal file
155
server/src/routes/rate_limit.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
struct RateLimitEntry {
|
||||
count: u32,
|
||||
window_start: Instant,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimitConfig {
|
||||
pub max_requests: u32,
|
||||
pub window_secs: u64,
|
||||
}
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_requests: 60,
|
||||
window_secs: 60,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
entries: Arc<RwLock<HashMap<String, RateLimitEntry>>>,
|
||||
config: RateLimitConfig,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(config: RateLimitConfig) -> Self {
|
||||
Self {
|
||||
entries: Arc::new(RwLock::new(HashMap::new())),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(&self, key: &str) -> bool {
|
||||
let mut entries = self.entries.write().await;
|
||||
let now = Instant::now();
|
||||
let window = std::time::Duration::from_secs(self.config.window_secs);
|
||||
|
||||
match entries.get_mut(key) {
|
||||
Some(entry) => {
|
||||
if now.duration_since(entry.window_start) > window {
|
||||
entry.count = 1;
|
||||
entry.window_start = now;
|
||||
true
|
||||
} else if entry.count < self.config.max_requests {
|
||||
entry.count += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
None => {
|
||||
entries.insert(
|
||||
key.to_string(),
|
||||
RateLimitEntry {
|
||||
count: 1,
|
||||
window_start: now,
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rate_limit_middleware(
|
||||
State(limiter): State<RateLimiter>,
|
||||
headers: axum::http::HeaderMap,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let key = headers
|
||||
.get("x-forwarded-for")
|
||||
.or_else(|| headers.get("x-real-ip"))
|
||||
.or_else(|| headers.get("authorization"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if limiter.check(&key).await {
|
||||
Ok(next.run(request).await)
|
||||
} else {
|
||||
Err(StatusCode::TOO_MANY_REQUESTS)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_allows_within_limit() {
|
||||
let limiter = RateLimiter::new(RateLimitConfig {
|
||||
max_requests: 5,
|
||||
window_secs: 60,
|
||||
});
|
||||
for _ in 0..5 {
|
||||
assert!(limiter.check("user1").await);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_blocks_over_limit() {
|
||||
let limiter = RateLimiter::new(RateLimitConfig {
|
||||
max_requests: 3,
|
||||
window_secs: 60,
|
||||
});
|
||||
assert!(limiter.check("user1").await);
|
||||
assert!(limiter.check("user1").await);
|
||||
assert!(limiter.check("user1").await);
|
||||
assert!(!limiter.check("user1").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_per_key() {
|
||||
let limiter = RateLimiter::new(RateLimitConfig {
|
||||
max_requests: 2,
|
||||
window_secs: 60,
|
||||
});
|
||||
assert!(limiter.check("user1").await);
|
||||
assert!(limiter.check("user1").await);
|
||||
assert!(!limiter.check("user1").await);
|
||||
assert!(limiter.check("user2").await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_default_config() {
|
||||
let config = RateLimitConfig::default();
|
||||
assert_eq!(config.max_requests, 60);
|
||||
assert_eq!(config.window_secs, 60);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit_new_key_always_allowed() {
|
||||
let limiter = RateLimiter::new(RateLimitConfig {
|
||||
max_requests: 1,
|
||||
window_secs: 60,
|
||||
});
|
||||
assert!(limiter.check("new_user").await);
|
||||
assert!(!limiter.check("new_user").await);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use matrix_sdk::ruma::Int;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ServerState;
|
||||
use super::auth::extract_token;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
pub struct Role {
|
||||
@@ -52,22 +53,65 @@ pub async fn get_roles(
|
||||
) -> Result<Json<Vec<Role>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let mut roles = Vec::new();
|
||||
if let Ok(power_levels) = room.power_levels().await {
|
||||
for (uid, power_level) in &power_levels.users {
|
||||
roles.push(Role {
|
||||
id: format!("role_{}", uid),
|
||||
name: uid.to_string(),
|
||||
color: if *power_level >= 100 { "#ed4245".to_string() } else if *power_level >= 50 { "#fee75c".to_string() } else { "#5865f2".to_string() },
|
||||
permissions: vec![],
|
||||
position: *power_level as i32,
|
||||
});
|
||||
}
|
||||
let members = room
|
||||
.members(matrix_sdk::RoomMemberships::JOIN)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let power_levels_content: Option<
|
||||
matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent,
|
||||
> = room
|
||||
.get_state_event_static()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|e| e.deserialize().ok())
|
||||
.and_then(|e| e.as_sync().cloned())
|
||||
.and_then(|e| e.as_original().cloned())
|
||||
.map(|e| e.content);
|
||||
|
||||
for member in members {
|
||||
let pl: i64 = if let Some(ref pl_content) = power_levels_content {
|
||||
pl_content
|
||||
.users
|
||||
.get(member.user_id())
|
||||
.map_or(0, |v| i64::from(*v))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let color = if pl >= 100 {
|
||||
"#ed4245".to_string()
|
||||
} else if pl >= 50 {
|
||||
"#fee75c".to_string()
|
||||
} else {
|
||||
"#5865f2".to_string()
|
||||
};
|
||||
let display_name = member
|
||||
.display_name()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| member.user_id().to_string());
|
||||
roles.push(Role {
|
||||
id: format!("role_{}", member.user_id()),
|
||||
name: display_name,
|
||||
color,
|
||||
permissions: vec![],
|
||||
position: pl as i32,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(roles))
|
||||
@@ -87,22 +131,34 @@ pub async fn assign_role(
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = req
|
||||
.user_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let power_level: i64 = match req.role_id.as_str() {
|
||||
let power_level: i32 = match req.role_id.as_str() {
|
||||
"admin" => 100,
|
||||
"moderator" => 50,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
let mut content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::new();
|
||||
content.users.insert(uid.to_owned(), power_level.into());
|
||||
|
||||
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
room.update_power_levels(vec![(uid.as_ref(), Int::from(power_level))])
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
@@ -121,17 +177,28 @@ pub async fn remove_role(
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = req
|
||||
.user_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
if let Ok(mut power_levels) = room.power_levels().await {
|
||||
power_levels.users.remove(&uid);
|
||||
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::from(power_levels);
|
||||
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
}
|
||||
room.update_power_levels(vec![(uid.as_ref(), Int::from(0))])
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
@@ -143,14 +210,38 @@ pub async fn get_permissions(
|
||||
) -> Result<Json<Permissions>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let uid: matrix_sdk::ruma::OwnedUserId = user_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let user_power = if let Ok(power_levels) = room.power_levels().await {
|
||||
power_levels.users.get(&uid).copied().map(|p| p.into()).unwrap_or(power_levels.users_default as i64)
|
||||
let power_levels_content: Option<
|
||||
matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent,
|
||||
> = room
|
||||
.get_state_event_static()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|e| e.deserialize().ok())
|
||||
.and_then(|e| e.as_sync().cloned())
|
||||
.and_then(|e| e.as_original().cloned())
|
||||
.map(|e| e.content);
|
||||
|
||||
let user_power = if let Some(pl) = power_levels_content {
|
||||
pl.users.get(&uid).map_or(0, |v| i64::from(*v))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::routes::metrics::ROOMS_JOINED_TOTAL;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Multipart, Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use crate::ServerState;
|
||||
use super::auth::extract_token;
|
||||
use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
|
||||
use matrix_sdk::ruma::events::receipt::ReceiptThread;
|
||||
use matrix_sdk::RoomMemberships;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RoomInfo {
|
||||
@@ -15,6 +19,8 @@ pub struct RoomInfo {
|
||||
pub is_encrypted: bool,
|
||||
pub member_count: u64,
|
||||
pub topic: Option<String>,
|
||||
pub unread_notifications: u64,
|
||||
pub unread_messages: u64,
|
||||
}
|
||||
|
||||
pub async fn get_joined_rooms(
|
||||
@@ -23,16 +29,29 @@ pub async fn get_joined_rooms(
|
||||
) -> Result<Json<Vec<RoomInfo>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rooms = session.client.joined_rooms();
|
||||
let mut result = Vec::new();
|
||||
for room in rooms {
|
||||
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default();
|
||||
let name = room
|
||||
.display_name()
|
||||
.await
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_default();
|
||||
let avatar_url = room.avatar_url().map(|u| u.to_string());
|
||||
let member_count = room.joined_members().len() as u64;
|
||||
let member_count = room
|
||||
.members(RoomMemberships::JOIN)
|
||||
.await
|
||||
.map(|m| m.len() as u64)
|
||||
.unwrap_or(0);
|
||||
let topic = room.topic().map(|t| t.to_string());
|
||||
let is_encrypted = room.is_encrypted().await.unwrap_or(false);
|
||||
let unread_notifications = room.unread_notification_counts().notification_count;
|
||||
let unread_messages = room.num_unread_messages();
|
||||
result.push(RoomInfo {
|
||||
room_id: room.room_id().to_string(),
|
||||
name,
|
||||
@@ -40,6 +59,8 @@ pub async fn get_joined_rooms(
|
||||
is_encrypted,
|
||||
member_count,
|
||||
topic,
|
||||
unread_notifications,
|
||||
unread_messages,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,11 +81,14 @@ pub async fn create_room(
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let vis = match req.visibility.as_deref() {
|
||||
Some("public") => matrix_sdk::ruma::Space::Public,
|
||||
_ => matrix_sdk::ruma::Space::Private,
|
||||
Some("public") => matrix_sdk::ruma::api::client::room::Visibility::Public,
|
||||
_ => matrix_sdk::ruma::api::client::room::Visibility::Private,
|
||||
};
|
||||
|
||||
let mut request = matrix_sdk::ruma::api::client::room::create_room::v3::Request::new();
|
||||
@@ -72,8 +96,12 @@ pub async fn create_room(
|
||||
request.topic = req.topic;
|
||||
request.visibility = vis;
|
||||
|
||||
let response = session.client.create_room(request).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(response.room_id.to_string()))
|
||||
let response = session
|
||||
.client
|
||||
.create_room(request)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(response.room_id().to_string()))
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -88,17 +116,24 @@ pub async fn join_room(
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let room_id = session.client
|
||||
.join_room_by_id_or_alias(
|
||||
&req.room_id_or_alias.try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?,
|
||||
&[],
|
||||
)
|
||||
let alias: matrix_sdk::ruma::OwnedRoomOrAliasId = req
|
||||
.room_id_or_alias
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let room = session
|
||||
.client
|
||||
.join_room_by_id_or_alias(&alias, &[])
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(room_id.to_string()))
|
||||
ROOMS_JOINED_TOTAL.inc();
|
||||
Ok(Json(room.room_id().to_string()))
|
||||
}
|
||||
|
||||
pub async fn leave_room(
|
||||
@@ -108,11 +143,22 @@ pub async fn leave_room(
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
room.leave().await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
room.leave()
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
@@ -124,11 +170,219 @@ pub async fn get_room_members(
|
||||
) -> Result<Json<Vec<String>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s.sessions.get(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let members = room.joined_members();
|
||||
Ok(Json(members.iter().map(|m| m.user_id().to_string()).collect()))
|
||||
let members = room
|
||||
.members(RoomMemberships::JOIN)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(
|
||||
members.iter().map(|m| m.user_id().to_string()).collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetRoomNameRequest {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
pub async fn set_room_name(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<SetRoomNameRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
room.set_name(req.name)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetRoomTopicRequest {
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
pub async fn set_room_topic(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<SetRoomTopicRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
room.set_room_topic(&req.topic)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
pub async fn set_room_avatar(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?
|
||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mime_type: mime::Mime = content_type.parse().unwrap_or(mime::IMAGE_PNG);
|
||||
|
||||
let upload = session
|
||||
.client
|
||||
.media()
|
||||
.upload(&mime_type, data.to_vec())
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
room.set_avatar_url(&upload.content_uri, None)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UnreadInfo {
|
||||
pub room_id: String,
|
||||
pub unread_notifications: u64,
|
||||
pub unread_messages: u64,
|
||||
}
|
||||
|
||||
pub async fn get_unread_counts(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Vec<UnreadInfo>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rooms = session.client.joined_rooms();
|
||||
let mut result = Vec::new();
|
||||
for room in rooms {
|
||||
let unread_notifications = room.unread_notification_counts().notification_count;
|
||||
let unread_messages = room.num_unread_messages();
|
||||
result.push(UnreadInfo {
|
||||
room_id: room.room_id().to_string(),
|
||||
unread_notifications,
|
||||
unread_messages,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MarkReadRequest {
|
||||
pub event_id: String,
|
||||
}
|
||||
|
||||
pub async fn mark_room_read(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<MarkReadRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let event_id: matrix_sdk::ruma::OwnedEventId = req
|
||||
.event_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
room.send_single_receipt(ReceiptType::Read, ReceiptThread::Main, event_id)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
379
server/src/routes/threads.rs
Normal file
379
server/src/routes/threads.rs
Normal file
@@ -0,0 +1,379 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use matrix_sdk::room::MessagesOptions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ThreadInfo {
|
||||
pub root_event_id: String,
|
||||
pub root_sender: String,
|
||||
pub root_body: String,
|
||||
pub root_timestamp: u64,
|
||||
pub reply_count: u32,
|
||||
pub last_reply_event_id: Option<String>,
|
||||
pub last_reply_sender: Option<String>,
|
||||
pub last_reply_body: Option<String>,
|
||||
pub last_reply_timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ThreadMessageInfo {
|
||||
pub event_id: String,
|
||||
pub sender: String,
|
||||
pub body: String,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ThreadQuery {
|
||||
pub limit: Option<u32>,
|
||||
pub from: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_threads(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
) -> Result<Json<Vec<ThreadInfo>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let mut options = MessagesOptions::backward();
|
||||
options.limit = matrix_sdk::ruma::uint!(100);
|
||||
|
||||
let messages = room
|
||||
.messages(options)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut thread_roots: std::collections::HashMap<String, ThreadInfo> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for msg in &messages.chunk {
|
||||
let event_value: serde_json::Value = match msg.event.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type = event_value
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if event_type != "m.room.message" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event_id = match event_value.get("event_id").and_then(|v| v.as_str()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let sender = event_value
|
||||
.get("sender")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let timestamp = event_value
|
||||
.get("origin_server_ts")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let content = event_value
|
||||
.get("content")
|
||||
.unwrap_or(&serde_json::Value::Null);
|
||||
let body = content
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let new_content = content.get("m.new_content");
|
||||
let display_body = if let Some(nc) = new_content {
|
||||
nc.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&body)
|
||||
.to_string()
|
||||
} else {
|
||||
body.clone()
|
||||
};
|
||||
|
||||
let relates_to = content.get("m.relates_to");
|
||||
|
||||
let is_thread = relates_to
|
||||
.and_then(|r| r.get("rel_type"))
|
||||
.and_then(|v| v.as_str())
|
||||
== Some("m.thread");
|
||||
|
||||
if is_thread {
|
||||
if let Some(thread_info) = relates_to {
|
||||
let root_id = thread_info
|
||||
.get("event_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if root_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_falling_back = thread_info
|
||||
.get("is_falling_back")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_falling_back {
|
||||
let reply_count = thread_roots
|
||||
.get(&root_id)
|
||||
.map(|t: &ThreadInfo| t.reply_count)
|
||||
.unwrap_or(0);
|
||||
thread_roots
|
||||
.entry(root_id.clone())
|
||||
.or_insert_with(|| ThreadInfo {
|
||||
root_event_id: root_id.clone(),
|
||||
root_sender: sender.clone(),
|
||||
root_body: display_body.clone(),
|
||||
root_timestamp: timestamp,
|
||||
reply_count,
|
||||
last_reply_event_id: None,
|
||||
last_reply_sender: None,
|
||||
last_reply_body: None,
|
||||
last_reply_timestamp: None,
|
||||
});
|
||||
} else {
|
||||
let entry = thread_roots
|
||||
.entry(root_id.clone())
|
||||
.or_insert_with(|| ThreadInfo {
|
||||
root_event_id: root_id.clone(),
|
||||
root_sender: String::new(),
|
||||
root_body: String::new(),
|
||||
root_timestamp: 0,
|
||||
reply_count: 0,
|
||||
last_reply_event_id: None,
|
||||
last_reply_sender: None,
|
||||
last_reply_body: None,
|
||||
last_reply_timestamp: None,
|
||||
});
|
||||
entry.reply_count += 1;
|
||||
entry.last_reply_event_id = Some(event_id);
|
||||
entry.last_reply_sender = Some(sender.clone());
|
||||
entry.last_reply_body = Some(display_body);
|
||||
entry.last_reply_timestamp = Some(timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(thread_roots.into_values().collect()))
|
||||
}
|
||||
|
||||
pub async fn get_thread_messages(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path((room_id, thread_id)): Path<(String, String)>,
|
||||
Query(query): Query<ThreadQuery>,
|
||||
) -> Result<Json<Vec<ThreadMessageInfo>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let limit = query.limit.unwrap_or(50);
|
||||
|
||||
let mut options = MessagesOptions::backward();
|
||||
options.limit = matrix_sdk::ruma::uint!(100);
|
||||
|
||||
let messages = room
|
||||
.messages(options)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
for msg in &messages.chunk {
|
||||
let event_value: serde_json::Value = match msg.event.deserialize_as() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let event_type = event_value
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if event_type != "m.room.message" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = event_value
|
||||
.get("content")
|
||||
.unwrap_or(&serde_json::Value::Null);
|
||||
let relates_to = content.get("m.relates_to");
|
||||
|
||||
let is_in_thread = relates_to
|
||||
.and_then(|r| {
|
||||
let rel_type = r.get("rel_type").and_then(|v| v.as_str());
|
||||
let event_id = r.get("event_id").and_then(|v| v.as_str());
|
||||
if rel_type == Some("m.thread") && event_id == Some(thread_id.as_str()) {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let is_root =
|
||||
event_value.get("event_id").and_then(|v| v.as_str()) == Some(thread_id.as_str());
|
||||
|
||||
if !is_in_thread && !is_root {
|
||||
continue;
|
||||
}
|
||||
|
||||
let event_id = match event_value.get("event_id").and_then(|v| v.as_str()) {
|
||||
Some(id) => id.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
let sender = event_value
|
||||
.get("sender")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let timestamp = event_value
|
||||
.get("origin_server_ts")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0);
|
||||
let body = content
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if body.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(ThreadMessageInfo {
|
||||
event_id,
|
||||
sender,
|
||||
body,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
result.truncate(limit as usize);
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ThreadReplyRequest {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub async fn send_thread_reply(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path((room_id, thread_id)): Path<(String, String)>,
|
||||
Json(req): Json<ThreadReplyRequest>,
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let root_event_id: matrix_sdk::ruma::OwnedEventId = thread_id
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut content =
|
||||
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&req.message);
|
||||
content.relates_to = Some(matrix_sdk::ruma::events::room::message::Relation::Thread(
|
||||
matrix_sdk::ruma::events::relation::Thread::without_fallback(root_event_id),
|
||||
));
|
||||
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(response.event_id.to_string()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SendReplyRequest {
|
||||
pub message: String,
|
||||
pub reply_to: String,
|
||||
}
|
||||
|
||||
pub async fn send_reply(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
Json(req): Json<SendReplyRequest>,
|
||||
) -> Result<Json<String>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let reply_to_event_id: matrix_sdk::ruma::OwnedEventId = req
|
||||
.reply_to
|
||||
.parse()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut content =
|
||||
matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&req.message);
|
||||
content.relates_to = Some(matrix_sdk::ruma::events::room::message::Relation::Reply {
|
||||
in_reply_to: matrix_sdk::ruma::events::relation::InReplyTo::new(reply_to_event_id),
|
||||
});
|
||||
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
Ok(Json(response.event_id.to_string()))
|
||||
}
|
||||
97
server/src/routes/upload.rs
Normal file
97
server/src/routes/upload.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
use super::auth::extract_token;
|
||||
use crate::routes::metrics::UPLOADS_TOTAL;
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{Multipart, Path, State},
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use matrix_sdk::ruma::events::room::message::{
|
||||
FileMessageEventContent, ImageMessageEventContent, MessageType, RoomMessageEventContent,
|
||||
};
|
||||
use matrix_sdk::ruma::OwnedMxcUri;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UploadResult {
|
||||
pub event_id: String,
|
||||
pub media_url: Option<String>,
|
||||
pub filename: String,
|
||||
pub mimetype: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
pub async fn upload_file(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Path(room_id): Path<String>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<UploadResult>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let rid: matrix_sdk::ruma::OwnedRoomId = room_id
|
||||
.as_str()
|
||||
.try_into()
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
let room = session
|
||||
.client
|
||||
.get_room(&rid)
|
||||
.ok_or(axum::http::StatusCode::NOT_FOUND)?;
|
||||
|
||||
let field = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?
|
||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let filename = field.file_name().unwrap_or("upload").to_string();
|
||||
let content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let data = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mime_type: mime::Mime = content_type
|
||||
.parse()
|
||||
.unwrap_or(mime::APPLICATION_OCTET_STREAM);
|
||||
|
||||
let media_response = session
|
||||
.client
|
||||
.media()
|
||||
.upload(&mime_type, data.to_vec())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Media upload failed: {}", e);
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let media_uri: OwnedMxcUri = media_response.content_uri.clone();
|
||||
|
||||
let content: RoomMessageEventContent = if mime_type.type_() == mime::IMAGE {
|
||||
MessageType::Image(ImageMessageEventContent::plain(filename.clone(), media_uri)).into()
|
||||
} else {
|
||||
MessageType::File(FileMessageEventContent::plain(filename.clone(), media_uri)).into()
|
||||
};
|
||||
|
||||
let response = room
|
||||
.send(content)
|
||||
.await
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
UPLOADS_TOTAL.inc();
|
||||
Ok(Json(UploadResult {
|
||||
event_id: response.event_id.to_string(),
|
||||
media_url: Some(media_response.content_uri.to_string()),
|
||||
filename,
|
||||
mimetype: Some(content_type),
|
||||
size: Some(data.len() as u64),
|
||||
}))
|
||||
}
|
||||
@@ -1,80 +1,318 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::ServerState;
|
||||
use super::auth::extract_token;
|
||||
use crate::state::WsEvent;
|
||||
use crate::ServerState;
|
||||
use axum::{extract::State, http::HeaderMap, Json};
|
||||
use livekit_api::access_token::{AccessToken, VideoGrants};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct VoiceRequest {
|
||||
pub struct VoiceJoinRequest {
|
||||
pub room_id: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VoiceStateInfo {
|
||||
pub struct VoiceJoinResponse {
|
||||
pub room_id: String,
|
||||
pub livekit_url: String,
|
||||
pub livekit_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VoiceToggleResponse {
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct VoiceParticipantInfo {
|
||||
pub user_id: String,
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
}
|
||||
|
||||
fn sanitize_room_id(room_id: &str) -> String {
|
||||
room_id
|
||||
.replace(':', "-")
|
||||
.replace("!", "")
|
||||
.replace("/", "-")
|
||||
.replace(" ", "_")
|
||||
}
|
||||
|
||||
pub async fn join_voice_channel(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<VoiceRequest>,
|
||||
) -> Result<Json<VoiceStateInfo>, axum::http::StatusCode> {
|
||||
Json(req): Json<VoiceJoinRequest>,
|
||||
) -> Result<Json<VoiceJoinResponse>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let mut s = state.write().await;
|
||||
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
session.voice_manager.join_channel(req.room_id.clone(), session.user_id.clone());
|
||||
let (user_id, livekit_url, livekit_api_key, livekit_api_secret) = {
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
(
|
||||
session.user_id.clone(),
|
||||
s.livekit.url.clone(),
|
||||
s.livekit.api_key.clone(),
|
||||
s.livekit.api_secret.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(Json(VoiceStateInfo {
|
||||
let lk_room = sanitize_room_id(&req.room_id);
|
||||
let lk_identity = sanitize_room_id(&user_id);
|
||||
|
||||
let grants = VideoGrants {
|
||||
room_join: true,
|
||||
room: lk_room.clone(),
|
||||
can_publish: true,
|
||||
can_subscribe: true,
|
||||
can_publish_data: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let lk_token = AccessToken::with_api_key(&livekit_api_key, &livekit_api_secret)
|
||||
.with_identity(&lk_identity)
|
||||
.with_name(&user_id)
|
||||
.with_grants(grants)
|
||||
.to_jwt()
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to generate LiveKit token: {}", e);
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let old_channel;
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get_mut(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
old_channel = session.voice_manager.active_channel.clone();
|
||||
session.voice_manager.join_channel(&req.room_id);
|
||||
}
|
||||
|
||||
if let Some(ref old) = old_channel {
|
||||
if old != &req.room_id {
|
||||
let mut s = state.write().await;
|
||||
if let Some(room) = s.voice_rooms.rooms.get_mut(old) {
|
||||
room.participants.remove(&user_id);
|
||||
if room.participants.is_empty() {
|
||||
s.voice_rooms.rooms.remove(old);
|
||||
}
|
||||
}
|
||||
drop(s);
|
||||
|
||||
let s = state.read().await;
|
||||
if let Some(session) = s.sessions.get(&token) {
|
||||
let _ = session.event_sender.send(WsEvent::VoiceUserLeft {
|
||||
room_id: old.clone(),
|
||||
user_id: user_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
let room_entry = s
|
||||
.voice_rooms
|
||||
.rooms
|
||||
.entry(req.room_id.clone())
|
||||
.or_insert_with(|| crate::state::VoiceRoom {
|
||||
participants: HashMap::new(),
|
||||
});
|
||||
room_entry.participants.insert(
|
||||
user_id.clone(),
|
||||
crate::state::VoiceParticipant {
|
||||
user_id: user_id.clone(),
|
||||
muted: false,
|
||||
deafened: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Some(session) = s.sessions.get(&token) {
|
||||
let _ = session.event_sender.send(WsEvent::VoiceUserJoined {
|
||||
room_id: req.room_id.clone(),
|
||||
user_id: user_id.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(VoiceJoinResponse {
|
||||
room_id: req.room_id,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
streaming: false,
|
||||
livekit_url,
|
||||
livekit_token: lk_token,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn leave_voice_channel(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<VoiceRequest>,
|
||||
Json(req): Json<VoiceJoinRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let mut s = state.write().await;
|
||||
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
Ok(Json(session.voice_manager.leave_channel(&req.room_id, &session.user_id)))
|
||||
let user_id;
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get_mut(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
user_id = session.user_id.clone();
|
||||
session.voice_manager.leave_channel();
|
||||
|
||||
if let Some(room) = s.voice_rooms.rooms.get_mut(&req.room_id) {
|
||||
room.participants.remove(&user_id);
|
||||
if room.participants.is_empty() {
|
||||
s.voice_rooms.rooms.remove(&req.room_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Some(session) = s.sessions.get(&token) {
|
||||
let _ = session.event_sender.send(WsEvent::VoiceUserLeft {
|
||||
room_id: req.room_id,
|
||||
user_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(true))
|
||||
}
|
||||
|
||||
pub async fn toggle_mute(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<VoiceRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
Json(_req): Json<VoiceJoinRequest>,
|
||||
) -> Result<Json<VoiceToggleResponse>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let mut s = state.write().await;
|
||||
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
session.voice_manager.toggle_mute(&req.room_id, &session.user_id)
|
||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)
|
||||
.map(Json)
|
||||
let (user_id, room_id, new_muted, deafened);
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get_mut(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
new_muted = session.voice_manager.toggle_mute();
|
||||
deafened = session.voice_manager.deafened;
|
||||
room_id = session
|
||||
.voice_manager
|
||||
.active_channel
|
||||
.clone()
|
||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
||||
user_id = session.user_id.clone();
|
||||
|
||||
if let Some(room) = s.voice_rooms.rooms.get_mut(&room_id) {
|
||||
if let Some(participant) = room.participants.get_mut(&user_id) {
|
||||
participant.muted = new_muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Some(session) = s.sessions.get(&token) {
|
||||
let _ = session.event_sender.send(WsEvent::VoiceStateUpdate {
|
||||
room_id,
|
||||
user_id,
|
||||
muted: new_muted,
|
||||
deafened,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(VoiceToggleResponse {
|
||||
muted: new_muted,
|
||||
deafened,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn toggle_deafen(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<VoiceRequest>,
|
||||
) -> Result<Json<bool>, axum::http::StatusCode> {
|
||||
Json(_req): Json<VoiceJoinRequest>,
|
||||
) -> Result<Json<VoiceToggleResponse>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let mut s = state.write().await;
|
||||
let session = s.sessions.get_mut(&token).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
session.voice_manager.toggle_deafen(&req.room_id, &session.user_id)
|
||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)
|
||||
.map(Json)
|
||||
let (user_id, room_id, muted, new_deafened);
|
||||
{
|
||||
let mut s = state.write().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get_mut(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
new_deafened = session.voice_manager.toggle_deafen();
|
||||
muted = session.voice_manager.muted;
|
||||
room_id = session
|
||||
.voice_manager
|
||||
.active_channel
|
||||
.clone()
|
||||
.ok_or(axum::http::StatusCode::BAD_REQUEST)?;
|
||||
user_id = session.user_id.clone();
|
||||
|
||||
if let Some(room) = s.voice_rooms.rooms.get_mut(&room_id) {
|
||||
if let Some(participant) = room.participants.get_mut(&user_id) {
|
||||
participant.deafened = new_deafened;
|
||||
participant.muted = muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let s = state.read().await;
|
||||
if let Some(session) = s.sessions.get(&token) {
|
||||
let _ = session.event_sender.send(WsEvent::VoiceStateUpdate {
|
||||
room_id,
|
||||
user_id,
|
||||
muted,
|
||||
deafened: new_deafened,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(VoiceToggleResponse {
|
||||
muted,
|
||||
deafened: new_deafened,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_voice_participants(
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Vec<VoiceParticipantInfo>>, axum::http::StatusCode> {
|
||||
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
let s = state.read().await;
|
||||
let session = s
|
||||
.sessions
|
||||
.get(&token)
|
||||
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
let room_id = match &session.voice_manager.active_channel {
|
||||
Some(r) => r.clone(),
|
||||
None => return Ok(Json(Vec::new())),
|
||||
};
|
||||
|
||||
let participants = match s.voice_rooms.rooms.get(&room_id) {
|
||||
Some(room) => room
|
||||
.participants
|
||||
.values()
|
||||
.map(|p| VoiceParticipantInfo {
|
||||
user_id: p.user_id.clone(),
|
||||
muted: p.muted,
|
||||
deafened: p.deafened,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
Ok(Json(participants))
|
||||
}
|
||||
84
server/src/routes/ws.rs
Normal file
84
server/src/routes/ws.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use crate::ServerState;
|
||||
use axum::{
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
Query, State, WebSocketUpgrade,
|
||||
},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WsQuery {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
Query(query): Query<WsQuery>,
|
||||
State(state): State<ServerState>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws(socket, state, query.token.unwrap_or_default()))
|
||||
}
|
||||
|
||||
async fn handle_ws(socket: WebSocket, state: ServerState, token: String) {
|
||||
let (user_id, mut event_rx) = {
|
||||
let s = state.read().await;
|
||||
match s.sessions.get(&token) {
|
||||
Some(session) => (session.user_id.clone(), session.event_sender.subscribe()),
|
||||
None => {
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let connected_msg = serde_json::json!({"type": "connected", "user_id": user_id}).to_string();
|
||||
let (mut ws_sender, mut ws_receiver) = socket.split();
|
||||
|
||||
if ws_sender.send(Message::Text(connected_msg)).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let (out_tx, mut out_rx) = tokio::sync::mpsc::channel::<String>(64);
|
||||
|
||||
let sender_task = tokio::spawn(async move {
|
||||
while let Some(text) = out_rx.recv().await {
|
||||
if ws_sender.send(Message::Text(text)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let receiver_task = tokio::spawn(async move {
|
||||
while let Some(msg) = ws_receiver.next().await {
|
||||
match msg {
|
||||
Ok(Message::Text(text)) if text.as_str() == "ping" => {}
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let forward_task = tokio::spawn(async move {
|
||||
while let Ok(event) = event_rx.recv().await {
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(json) => {
|
||||
if out_tx.send(json).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to serialize WS event: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = sender_task => {},
|
||||
_ = receiver_task => {},
|
||||
_ = forward_task => {},
|
||||
}
|
||||
}
|
||||
181
server/src/session_store.rs
Normal file
181
server/src/session_store.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
pub struct SessionStore {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StoredSession {
|
||||
pub token: String,
|
||||
pub user_id: String,
|
||||
pub homeserver: String,
|
||||
pub access_token: String,
|
||||
pub device_id: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new(path: &str) -> Result<Self, rusqlite::Error> {
|
||||
let conn = if Path::new(path).exists() {
|
||||
Connection::open(path)?
|
||||
} else {
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
homeserver TEXT NOT NULL,
|
||||
access_token TEXT NOT NULL,
|
||||
device_id TEXT,
|
||||
refresh_token TEXT
|
||||
);",
|
||||
)?;
|
||||
conn
|
||||
};
|
||||
Ok(Self {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn save_session(&self, session: &StoredSession) -> Result<(), rusqlite::Error> {
|
||||
let conn = self.conn.lock().await;
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sessions (token, user_id, homeserver, access_token, device_id, refresh_token)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
rusqlite::params![
|
||||
session.token,
|
||||
session.user_id,
|
||||
session.homeserver,
|
||||
session.access_token,
|
||||
session.device_id,
|
||||
session.refresh_token,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_all_sessions(&self) -> Result<Vec<StoredSession>, rusqlite::Error> {
|
||||
let conn = self.conn.lock().await;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT token, user_id, homeserver, access_token, device_id, refresh_token FROM sessions"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(StoredSession {
|
||||
token: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
homeserver: row.get(2)?,
|
||||
access_token: row.get(3)?,
|
||||
device_id: row.get(4)?,
|
||||
refresh_token: row.get(5)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub async fn delete_session(&self, token: &str) -> Result<(), rusqlite::Error> {
|
||||
let conn = self.conn.lock().await;
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
async fn create_test_store() -> (SessionStore, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("test.db");
|
||||
let path_str = path.to_str().unwrap().to_string();
|
||||
let store = SessionStore::new(&path_str).unwrap();
|
||||
(store, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_and_retrieve_session() {
|
||||
let (store, _dir) = create_test_store().await;
|
||||
let session = StoredSession {
|
||||
token: "test-token".to_string(),
|
||||
user_id: "@alice:server".to_string(),
|
||||
homeserver: "https://matrix.server".to_string(),
|
||||
access_token: "access-123".to_string(),
|
||||
device_id: Some("DEVICE1".to_string()),
|
||||
refresh_token: Some("refresh-456".to_string()),
|
||||
};
|
||||
|
||||
store.save_session(&session).await.unwrap();
|
||||
let sessions = store.get_all_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].token, "test-token");
|
||||
assert_eq!(sessions[0].user_id, "@alice:server");
|
||||
assert_eq!(sessions[0].access_token, "access-123");
|
||||
assert_eq!(sessions[0].device_id, Some("DEVICE1".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_session_upserts() {
|
||||
let (store, _dir) = create_test_store().await;
|
||||
let session = StoredSession {
|
||||
token: "test-token".to_string(),
|
||||
user_id: "@alice:server".to_string(),
|
||||
homeserver: "https://matrix.server".to_string(),
|
||||
access_token: "access-123".to_string(),
|
||||
device_id: None,
|
||||
refresh_token: None,
|
||||
};
|
||||
|
||||
store.save_session(&session).await.unwrap();
|
||||
let mut updated = session.clone();
|
||||
updated.access_token = "access-456".to_string();
|
||||
store.save_session(&updated).await.unwrap();
|
||||
|
||||
let sessions = store.get_all_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].access_token, "access-456");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_session_removes_it() {
|
||||
let (store, _dir) = create_test_store().await;
|
||||
let session = StoredSession {
|
||||
token: "test-token".to_string(),
|
||||
user_id: "@alice:server".to_string(),
|
||||
homeserver: "https://matrix.server".to_string(),
|
||||
access_token: "access-123".to_string(),
|
||||
device_id: None,
|
||||
refresh_token: None,
|
||||
};
|
||||
|
||||
store.save_session(&session).await.unwrap();
|
||||
store.delete_session("test-token").await.unwrap();
|
||||
let sessions = store.get_all_sessions().await.unwrap();
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_nonexistent_session_is_ok() {
|
||||
let (store, _dir) = create_test_store().await;
|
||||
store.delete_session("nonexistent").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_multiple_sessions() {
|
||||
let (store, _dir) = create_test_store().await;
|
||||
for i in 0..3 {
|
||||
let session = StoredSession {
|
||||
token: format!("token-{}", i),
|
||||
user_id: format!("@user{}:server", i),
|
||||
homeserver: "https://matrix.server".to_string(),
|
||||
access_token: format!("access-{}", i),
|
||||
device_id: None,
|
||||
refresh_token: None,
|
||||
};
|
||||
store.save_session(&session).await.unwrap();
|
||||
}
|
||||
let sessions = store.get_all_sessions().await.unwrap();
|
||||
assert_eq!(sessions.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,117 +1,381 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use crate::session_store::SessionStore;
|
||||
use matrix_sdk::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum WsEvent {
|
||||
#[serde(rename = "message")]
|
||||
Message {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
timestamp: u64,
|
||||
reply_to: Option<String>,
|
||||
msgtype: Option<String>,
|
||||
media_url: Option<String>,
|
||||
filename: Option<String>,
|
||||
mimetype: Option<String>,
|
||||
},
|
||||
#[serde(rename = "message_edited")]
|
||||
MessageEdited {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
new_body: String,
|
||||
},
|
||||
#[serde(rename = "message_deleted")]
|
||||
MessageDeleted { room_id: String, redacts: String },
|
||||
#[serde(rename = "reaction")]
|
||||
Reaction {
|
||||
room_id: String,
|
||||
event_id: String,
|
||||
key: String,
|
||||
sender: String,
|
||||
},
|
||||
#[serde(rename = "room_joined")]
|
||||
RoomJoined { room_id: String, name: String },
|
||||
#[serde(rename = "room_left")]
|
||||
RoomLeft { room_id: String },
|
||||
#[serde(rename = "presence")]
|
||||
Presence {
|
||||
user_id: String,
|
||||
status: String,
|
||||
status_msg: Option<String>,
|
||||
},
|
||||
#[serde(rename = "typing")]
|
||||
Typing {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
typing: bool,
|
||||
},
|
||||
#[serde(rename = "voice_state_update")]
|
||||
VoiceStateUpdate {
|
||||
room_id: String,
|
||||
user_id: String,
|
||||
muted: bool,
|
||||
deafened: bool,
|
||||
},
|
||||
#[serde(rename = "voice_user_joined")]
|
||||
VoiceUserJoined { room_id: String, user_id: String },
|
||||
#[serde(rename = "voice_user_left")]
|
||||
VoiceUserLeft { room_id: String, user_id: String },
|
||||
#[serde(rename = "thread_reply")]
|
||||
ThreadReply {
|
||||
room_id: String,
|
||||
root_event_id: String,
|
||||
event_id: String,
|
||||
sender: String,
|
||||
body: String,
|
||||
timestamp: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct LiveKitConfig {
|
||||
pub api_key: String,
|
||||
pub api_secret: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub client: Client,
|
||||
pub user_id: String,
|
||||
pub homeserver: String,
|
||||
pub voice_manager: VoiceManager,
|
||||
pub event_sender: broadcast::Sender<WsEvent>,
|
||||
pub sync_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
pub created_at: Instant,
|
||||
pub expires_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(client: Client, user_id: String, homeserver: String) -> Self {
|
||||
let (sender, _) = broadcast::channel(256);
|
||||
Self {
|
||||
client,
|
||||
user_id,
|
||||
homeserver,
|
||||
voice_manager: VoiceManager::new(),
|
||||
event_sender: sender,
|
||||
sync_handle: None,
|
||||
created_at: Instant::now(),
|
||||
expires_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, ttl: std::time::Duration) -> Self {
|
||||
self.expires_at = Some(self.created_at + ttl);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_expired(&self) -> bool {
|
||||
self.expires_at
|
||||
.map(|expires| Instant::now() > expires)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct VoiceManager {
|
||||
channels: HashMap<String, VoiceChannel>,
|
||||
active_channel: Option<String>,
|
||||
}
|
||||
|
||||
pub struct VoiceChannel {
|
||||
pub room_id: String,
|
||||
pub participants: Vec<VoiceParticipant>,
|
||||
}
|
||||
|
||||
pub struct VoiceParticipant {
|
||||
pub user_id: String,
|
||||
pub active_channel: Option<String>,
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
impl VoiceManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: HashMap::new(),
|
||||
active_channel: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_channel(&mut self, room_id: String, user_id: String) -> bool {
|
||||
if let Some(ref old_channel) = self.active_channel {
|
||||
self.leave_channel_internal(old_channel, &user_id);
|
||||
}
|
||||
let channel = self.channels.entry(room_id.clone()).or_insert_with(|| VoiceChannel {
|
||||
room_id: room_id.clone(),
|
||||
participants: Vec::new(),
|
||||
});
|
||||
if channel.participants.iter().any(|p| p.user_id == user_id) {
|
||||
return false;
|
||||
}
|
||||
channel.participants.push(VoiceParticipant {
|
||||
user_id,
|
||||
muted: false,
|
||||
deafened: false,
|
||||
streaming: false,
|
||||
});
|
||||
self.active_channel = Some(room_id);
|
||||
true
|
||||
}
|
||||
|
||||
fn leave_channel_internal(&mut self, room_id: &str, user_id: &str) {
|
||||
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||
channel.participants.retain(|p| p.user_id != user_id);
|
||||
if channel.participants.is_empty() {
|
||||
self.channels.remove(room_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn leave_channel(&mut self, room_id: &str, user_id: &str) -> bool {
|
||||
self.leave_channel_internal(room_id, user_id);
|
||||
if self.active_channel.as_deref() == Some(room_id) {
|
||||
self.active_channel = None;
|
||||
}
|
||||
true
|
||||
pub fn join_channel(&mut self, room_id: &str) {
|
||||
self.active_channel = Some(room_id.to_string());
|
||||
self.muted = false;
|
||||
self.deafened = false;
|
||||
}
|
||||
|
||||
pub fn toggle_mute(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||
participant.muted = !participant.muted;
|
||||
return Some(participant.muted);
|
||||
}
|
||||
}
|
||||
None
|
||||
pub fn leave_channel(&mut self) {
|
||||
self.active_channel = None;
|
||||
self.muted = false;
|
||||
self.deafened = false;
|
||||
}
|
||||
|
||||
pub fn toggle_deafen(&mut self, room_id: &str, user_id: &str) -> Option<bool> {
|
||||
if let Some(channel) = self.channels.get_mut(room_id) {
|
||||
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) {
|
||||
participant.deafened = !participant.deafened;
|
||||
if participant.deafened {
|
||||
participant.muted = true;
|
||||
}
|
||||
return Some(participant.deafened);
|
||||
}
|
||||
pub fn toggle_mute(&mut self) -> bool {
|
||||
self.muted = !self.muted;
|
||||
self.muted
|
||||
}
|
||||
|
||||
pub fn toggle_deafen(&mut self) -> bool {
|
||||
self.deafened = !self.deafened;
|
||||
if self.deafened {
|
||||
self.muted = true;
|
||||
}
|
||||
None
|
||||
self.deafened
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn voice_manager_new_is_idle() {
|
||||
let vm = VoiceManager::new();
|
||||
assert!(vm.active_channel.is_none());
|
||||
assert!(!vm.muted);
|
||||
assert!(!vm.deafened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_manager_default_is_idle() {
|
||||
let vm = VoiceManager::default();
|
||||
assert!(vm.active_channel.is_none());
|
||||
assert!(!vm.muted);
|
||||
assert!(!vm.deafened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_channel_sets_active() {
|
||||
let mut vm = VoiceManager::new();
|
||||
vm.join_channel("!room:server");
|
||||
assert_eq!(vm.active_channel, Some("!room:server".to_string()));
|
||||
assert!(!vm.muted);
|
||||
assert!(!vm.deafened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_channel_resets_mute_deafen() {
|
||||
let mut vm = VoiceManager::new();
|
||||
vm.muted = true;
|
||||
vm.deafened = true;
|
||||
vm.join_channel("!room:server");
|
||||
assert!(!vm.muted);
|
||||
assert!(!vm.deafened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_channel_clears_state() {
|
||||
let mut vm = VoiceManager::new();
|
||||
vm.join_channel("!room:server");
|
||||
vm.muted = true;
|
||||
vm.leave_channel();
|
||||
assert!(vm.active_channel.is_none());
|
||||
assert!(!vm.muted);
|
||||
assert!(!vm.deafened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_mute_flips_state() {
|
||||
let mut vm = VoiceManager::new();
|
||||
assert!(!vm.muted);
|
||||
let result = vm.toggle_mute();
|
||||
assert!(result);
|
||||
assert!(vm.muted);
|
||||
let result = vm.toggle_mute();
|
||||
assert!(!result);
|
||||
assert!(!vm.muted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_deafen_sets_muted_when_deafened() {
|
||||
let mut vm = VoiceManager::new();
|
||||
let result = vm.toggle_deafen();
|
||||
assert!(result);
|
||||
assert!(vm.deafened);
|
||||
assert!(vm.muted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_deafen_off_keeps_muted() {
|
||||
let mut vm = VoiceManager::new();
|
||||
vm.muted = true;
|
||||
vm.toggle_deafen();
|
||||
assert!(vm.muted);
|
||||
vm.toggle_deafen();
|
||||
assert!(vm.deafened == false);
|
||||
assert!(vm.muted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_event_serialization() {
|
||||
let event = WsEvent::Message {
|
||||
room_id: "!room:server".to_string(),
|
||||
event_id: "$event".to_string(),
|
||||
sender: "@user:server".to_string(),
|
||||
body: "hello".to_string(),
|
||||
timestamp: 123456,
|
||||
reply_to: None,
|
||||
msgtype: Some("m.text".to_string()),
|
||||
media_url: None,
|
||||
filename: None,
|
||||
mimetype: None,
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"message\""));
|
||||
assert!(json.contains("\"body\":\"hello\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_event_presence_serialization() {
|
||||
let event = WsEvent::Presence {
|
||||
user_id: "@alice:server".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_msg: Some("working".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert!(json.contains("\"type\":\"presence\""));
|
||||
assert!(json.contains("\"status\":\"online\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_expired_true_when_past() {
|
||||
let created = Instant::now() - std::time::Duration::from_secs(120);
|
||||
let expires = created + std::time::Duration::from_secs(60);
|
||||
assert!(Instant::now() > expires);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_expired_none_means_not_expired() {
|
||||
let expires_at: Option<Instant> = None;
|
||||
let expired = expires_at
|
||||
.map(|expires| Instant::now() > expires)
|
||||
.unwrap_or(false);
|
||||
assert!(!expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_expired_future_means_not_expired() {
|
||||
let expires_at: Option<Instant> =
|
||||
Some(Instant::now() + std::time::Duration::from_secs(3600));
|
||||
let expired = expires_at
|
||||
.map(|expires| Instant::now() > expires)
|
||||
.unwrap_or(false);
|
||||
assert!(!expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_expired_past_means_expired() {
|
||||
let expires_at: Option<Instant> = Some(Instant::now() - std::time::Duration::from_secs(1));
|
||||
let expired = expires_at
|
||||
.map(|expires| Instant::now() > expires)
|
||||
.unwrap_or(false);
|
||||
assert!(expired);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ServerStateInner {
|
||||
pub sessions: HashMap<String, Session>,
|
||||
pub session_store: Arc<SessionStore>,
|
||||
pub livekit: LiveKitConfig,
|
||||
pub voice_rooms: VoiceRooms,
|
||||
pub session_ttl: Option<std::time::Duration>,
|
||||
}
|
||||
|
||||
pub struct VoiceRooms {
|
||||
pub rooms: HashMap<String, VoiceRoom>,
|
||||
}
|
||||
|
||||
pub struct VoiceRoom {
|
||||
pub participants: HashMap<String, VoiceParticipant>,
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct VoiceParticipant {
|
||||
pub user_id: String,
|
||||
pub muted: bool,
|
||||
pub deafened: bool,
|
||||
}
|
||||
|
||||
impl ServerStateInner {
|
||||
pub fn new() -> Self {
|
||||
pub fn new(
|
||||
session_store: Arc<SessionStore>,
|
||||
livekit: LiveKitConfig,
|
||||
session_ttl: Option<std::time::Duration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sessions: HashMap::new(),
|
||||
session_store,
|
||||
livekit,
|
||||
voice_rooms: VoiceRooms {
|
||||
rooms: HashMap::new(),
|
||||
},
|
||||
session_ttl,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ServerState = Arc<RwLock<ServerStateInner>>;
|
||||
#[derive(Clone)]
|
||||
pub struct ServerState {
|
||||
inner: Arc<RwLock<ServerStateInner>>,
|
||||
}
|
||||
|
||||
impl ServerState {
|
||||
pub fn new() -> Self {
|
||||
Arc::new(RwLock::new(ServerStateInner::new()))
|
||||
pub fn new(
|
||||
session_store: Arc<SessionStore>,
|
||||
livekit: LiveKitConfig,
|
||||
session_ttl: Option<std::time::Duration>,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RwLock::new(ServerStateInner::new(
|
||||
session_store,
|
||||
livekit,
|
||||
session_ttl,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(&self) -> tokio::sync::RwLockReadGuard<'_, ServerStateInner> {
|
||||
self.inner.read().await
|
||||
}
|
||||
|
||||
pub async fn write(&self) -> tokio::sync::RwLockWriteGuard<'_, ServerStateInner> {
|
||||
self.inner.write().await
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user