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

- 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:
root
2026-04-29 13:08:01 +02:00
parent 0978d0c2e9
commit cacd2b04a7
80 changed files with 18307 additions and 1724 deletions

View File

@@ -26,3 +26,5 @@ __pycache__/
*.pyc *.pyc
.cargo/ .cargo/
infra/ infra/
backups/
certs/

View File

@@ -11,19 +11,30 @@ env:
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
jobs: jobs:
check-rust: fmt:
name: Rust Check name: Rust Format
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-action/setup@v1 uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Install system dependencies - name: Check formatting
run: | run: cargo fmt --all -- --check
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 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 - name: Cache Cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -32,35 +43,23 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo- restore-keys: ${{ runner.os }}-cargo-clippy-
- name: Cargo check (client) - name: Clippy (server)
run: cargo check -p eifeldc-client run: cargo clippy -p eifeldc-server -- -D warnings
- name: Cargo check (bot-sdk) - name: Clippy (bot-sdk)
run: cargo check -p eifeldc-bot-sdk run: cargo clippy -p eifeldc-bot-sdk -- -D warnings
- name: Cargo check (server) test-server:
run: cargo check -p eifeldc-server name: Test Server
- name: Cargo clippy
run: cargo clippy --workspace -- -D warnings
test-rust:
name: Rust Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: check-rust
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust stable - 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
- name: Cache Cargo - name: Cache Cargo
uses: actions/cache@v4 uses: actions/cache@v4
@@ -69,11 +68,14 @@ jobs:
~/.cargo/registry ~/.cargo/registry
~/.cargo/git ~/.cargo/git
target target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} key: ${{ runner.os }}-cargo-server-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo- restore-keys: ${{ runner.os }}-cargo-server-
- name: Run tests - name: Test (server)
run: cargo test --workspace run: cargo test -p eifeldc-server
- name: Test (bot-sdk)
run: cargo test -p eifeldc-bot-sdk
check-frontend: check-frontend:
name: Frontend Check name: Frontend Check
@@ -89,118 +91,84 @@ jobs:
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: client/src-ui/package.json cache-dependency-path: client/src-ui/package-lock.json
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Svelte check
run: npm run check
- name: Build - name: Build
run: npm run build run: npm run build
build-tauri-macos: check-client-tauri:
name: Build Tauri (macOS) name: Tauri Client Check
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)
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [check-rust, check-frontend]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-action/setup@v1 uses: dtolnay/rust-toolchain@stable
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update 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 - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: npm cache: npm
cache-dependency-path: client/src-ui/package.json cache-dependency-path: client/src-ui/package-lock.json
- name: Install frontend deps - name: Install frontend deps
run: npm ci run: npm ci

235
.github/workflows/release.yml vendored Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@
resolver = "2" resolver = "2"
members = [ members = [
"server", "server",
"bot-sdk",
"client",
] ]
[workspace.dependencies] [workspace.dependencies]
@@ -13,6 +15,6 @@ tokio = { version = "1", features = ["full"] }
anyhow = "1" anyhow = "1"
thiserror = "1" thiserror = "1"
tracing = "0.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"] } reqwest = { version = "0.12", features = ["json", "multipart"] }
url = "2" url = "2"

View File

@@ -12,15 +12,27 @@ WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY server/Cargo.toml server/Cargo.toml COPY server/Cargo.toml server/Cargo.toml
RUN mkdir -p server/src && echo "fn main() {}" > server/src/main.rs COPY bot-sdk/Cargo.toml bot-sdk/Cargo.toml
RUN cargo build --release -p eifeldc-server && rm -rf server/src 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 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 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 RUN useradd -m -d /home/eifeldc eifeldc
@@ -31,7 +43,13 @@ USER eifeldc
ENV EIFELDC_STATIC_DIR=/usr/share/eifeldc/client ENV EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
ENV RUST_LOG=eifeldc_server=info,tower_http=info 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 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"] CMD ["eifeldc-server"]

49
Makefile Normal file
View 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
View 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.

View File

@@ -5,18 +5,10 @@ edition = "2021"
[dependencies] [dependencies]
matrix-sdk = { workspace = true } matrix-sdk = { workspace = true }
matrix-sdk-base = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } futures = "0.3"
reqwest = { workspace = true } mime = "0.3"
url = { workspace = true }
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
mockito = "1"

View File

@@ -1,3 +1,4 @@
#[derive(Default)]
pub struct BotAuth { pub struct BotAuth {
pub username: String, pub username: String,
pub password: String, pub password: String,
@@ -22,3 +23,45 @@ impl BotAuth {
!self.username.is_empty() && !self.password.is_empty() !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());
}
}

View File

@@ -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 matrix_sdk::Client;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::watch;
use tokio::sync::Mutex;
use crate::auth::BotAuth;
use crate::commands::CommandRegistry;
use crate::event::EventHandler;
use crate::room::RoomManager;
pub struct BotClient { 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, 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 { impl BotClient {
pub fn new(homeserver: &str) -> Self { pub fn new(homeserver: &str) -> Self {
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(), 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 { pub fn with_auth(mut self, username: &str, password: &str) -> Self {
let auth = BotAuth::with_credentials(username, password); self.auth = BotAuth::with_credentials(username, password);
self.auth = Arc::new(RwLock::new(auth));
self 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() let client = Client::builder()
.homeserver_url(&self.homeserver) .homeserver_url(&self.homeserver)
.build() .build()
.await?; .await?;
{
let mut guard = self.client.write().await;
*guard = Some(client.clone());
}
let auth = self.auth.read().await;
client client
.matrix_auth() .matrix_auth()
.login_username(&auth.username, &auth.password) .login_username(&self.auth.username, &self.auth.password)
.send() .send()
.await?; .await?;
tracing::info!("Bot logged in as {}", auth.username); tracing::info!("Bot logged in as {}", self.auth.username);
drop(auth);
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 { loop {
let mut settings = matrix_sdk::config::SyncSettings::new(); tokio::select! {
if let Some(token) = sync_token.as_ref() { result = client.sync_once(SyncSettings::new()) => {
settings = settings.token(token.clone()); match result {
} Ok(response) => {
match client.sync_once(settings).await { {
Ok(response) => { let eh = event_handlers.lock().await;
sync_token = Some(response.next_batch); eh.dispatch(BotEvent::SyncComplete);
}
let handler = self.event_handler.read().await; for (room_id, joined) in &response.rooms.join {
handler.dispatch("sync"); let _room = match client.get_room(room_id) {
drop(handler); Some(r) => r,
None => continue,
};
let rooms = client.joined_rooms(); let room_id_str = room_id.to_string();
let mut room_mgr = self.room_manager.write().await;
for room in rooms { for event in &joined.timeline.events {
let name = room.display_name().await.map(|n| n.to_string()).unwrap_or_default(); let raw_json: serde_json::Value = match event.event.deserialize_as() {
room_mgr.add_room(crate::room::RoomInfo { Ok(v) => v,
room_id: room.room_id().to_string(), Err(_) => continue,
name, };
is_encrypted: room.is_encrypted().await.unwrap_or(false),
}); 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) => { _ = shutdown_rx.changed() => {
tracing::error!("Sync error: {}", e); tracing::info!("Bot shutting down...");
tokio::time::sleep(std::time::Duration::from_secs(5)).await; 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(()) Ok(())
} }
pub async fn on_command(&self, name: &str, handler: Box<dyn Fn(&str, &str) + Send + Sync>) { pub fn stop(&mut self) {
let mut commands = self.commands.write().await; if let Some(tx) = self.shutdown_tx.take() {
commands.register(name, handler); let _ = tx.send(true);
} }
self.client = None;
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);
} impl Drop for BotClient {
fn drop(&mut self) {
pub async fn get_rooms(&self) -> Vec<crate::room::RoomInfo> { self.stop();
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);
} }
} }

View File

@@ -1,12 +1,31 @@
use std::collections::HashMap; 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 { pub struct CommandRegistry {
commands: HashMap<String, CommandHandler>, commands: HashMap<String, CommandHandler>,
prefix: String, prefix: String,
} }
impl Default for CommandRegistry {
fn default() -> Self {
Self {
commands: HashMap::new(),
prefix: "!".to_string(),
}
}
}
impl CommandRegistry { impl CommandRegistry {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -15,9 +34,11 @@ impl CommandRegistry {
} }
} }
pub fn with_prefix(mut self, prefix: &str) -> Self { pub fn with_prefix(prefix: &str) -> Self {
self.prefix = prefix.to_string(); Self {
self commands: HashMap::new(),
prefix: prefix.to_string(),
}
} }
pub fn set_prefix(&mut self, prefix: &str) { pub fn set_prefix(&mut self, prefix: &str) {
@@ -32,18 +53,24 @@ impl CommandRegistry {
self.commands.remove(name); 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) { if !message.starts_with(&self.prefix) {
return; return;
} }
let content = &message[self.prefix.len()..]; 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 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) { 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) 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()
}
}

View File

@@ -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 { pub struct EventHandler {
handlers: Vec<EventCallback>, handlers: Vec<EventCallback>,
} }
impl EventHandler { impl EventHandler {
pub fn new() -> Self { pub fn new() -> Self {
Self { handlers: Vec::new() } Self {
handlers: Vec::new(),
}
} }
pub fn add_handler(&mut self, handler: EventCallback) { pub fn add_handler(&mut self, handler: EventCallback) {
self.handlers.push(handler); self.handlers.push(handler);
} }
pub fn dispatch(&self, event: &str) { pub fn dispatch(&self, event: BotEvent) {
for handler in &self.handlers { for handler in &self.handlers {
handler(event); handler(event.clone());
} }
} }
@@ -23,3 +82,106 @@ impl EventHandler {
self.handlers.len() 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");
}
}

View File

@@ -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 client;
pub mod commands; pub mod commands;
pub mod event; pub mod event;
pub mod room; pub mod room;
pub mod auth;
pub use client::BotClient;
pub use auth::BotAuth; pub use auth::BotAuth;
pub use client::BotClient;
pub use commands::{CommandContext, CommandHandler, CommandRegistry};
pub use event::{BotEvent, EventHandler};
pub use room::{RoomInfo, RoomManager};

View File

@@ -1,19 +1,34 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct RoomInfo { pub struct RoomInfo {
pub room_id: String, pub room_id: String,
pub name: String, pub name: String,
pub is_encrypted: bool, 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 { pub struct RoomManager {
rooms: HashMap<String, RoomInfo>, pub rooms: HashMap<String, RoomInfo>,
} }
impl RoomManager { impl RoomManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { rooms: HashMap::new() } Self {
rooms: HashMap::new(),
}
} }
pub fn add_room(&mut self, room: RoomInfo) { pub fn add_room(&mut self, room: RoomInfo) {
@@ -36,3 +51,111 @@ impl RoomManager {
self.rooms.len() 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);
}
}

View File

@@ -4,8 +4,6 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
matrix-sdk = { workspace = true }
matrix-sdk-base = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
@@ -15,15 +13,9 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
url = { workspace = true } url = { workspace = true }
tauri = { version = "1", features = ["shell-open", "dialog-all"] } tauri = { version = "2", features = [] }
tauri-plugin-oauth = "2.0" tauri-plugin-shell = "2"
sled = "0.34" tauri-plugin-dialog = "2"
futures = "0.3"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
base64 = "0.22"
sha2 = "0.10"
[build-dependencies] [build-dependencies]
tauri-build = { version = "1", features = [] } tauri-build = { version = "2", features = [] }

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -1,69 +1,55 @@
{ {
"build": { "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"beforeDevCommand": "npm run dev", "productName": "EifelDC",
"beforeBuildCommand": "npm run build", "version": "0.1.0",
"devPath": "http://localhost:5173", "identifier": "de.eifeldc",
"distDir": "../src-ui/dist" "build": {
}, "beforeDevCommand": "npm run dev",
"package": { "beforeBuildCommand": "npm run build",
"productName": "EifelDC", "devUrl": "http://localhost:5173",
"version": "0.1.0" "frontendDist": "../src-ui/dist"
}, },
"tauri": { "app": {
"allowlist": { "windows": [
"all": false, {
"shell": { "open": true }, "title": "EifelDC",
"dialog": { "all": true }, "width": 1280,
"window": { "all": true } "height": 720,
}, "minWidth": 800,
"bundle": { "minHeight": 600,
"active": true, "resizable": true,
"identifier": "de.eifeldc", "fullscreen": false,
"icon": [ "decorations": true,
"icons/32x32.png", "transparent": false
"icons/128x128.png", }
"icons/128x128@2x.png", ],
"icons/icon.icns", "security": {
"icons/icon.ico" "csp": "default-src 'self'; connect-src https: wss: http://localhost:*; img-src https: data:; style-src 'self' 'unsafe-inline'; script-src 'self'"
],
"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
}
} }
},
"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
}
}
} }

View File

@@ -7,6 +7,9 @@
"": { "": {
"name": "eifeldc-ui", "name": "eifeldc-ui",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"livekit-client": "^2.18.7"
},
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3", "@sveltejs/vite-plugin-svelte": "^3",
"@tauri-apps/api": "^1", "@tauri-apps/api": "^1",
@@ -32,6 +35,12 @@
"node": ">=6.0.0" "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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -462,6 +471,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.2", "version": "4.60.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", "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" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1138,6 +1169,15 @@
"@types/estree": "^1.0.0" "@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": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -1290,6 +1330,15 @@
"@types/estree": "^1.0.6" "@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": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1300,6 +1349,26 @@
"node": ">=6" "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": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -1307,6 +1376,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1569,6 +1651,16 @@
"fsevents": "~2.3.2" "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": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -1595,6 +1687,21 @@
"rimraf": "^2.5.2" "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": { "node_modules/sorcery": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz",
@@ -1774,9 +1881,17 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "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": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@@ -18,5 +18,8 @@
"tslib": "^2", "tslib": "^2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^5" "vite": "^5"
},
"dependencies": {
"livekit-client": "^2.18.7"
} }
} }

View File

@@ -6,7 +6,9 @@
import ChatArea from './components/ChatArea.svelte'; import ChatArea from './components/ChatArea.svelte';
import MemberList from './components/MemberList.svelte'; import MemberList from './components/MemberList.svelte';
import VoicePanel from './components/VoicePanel.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 loggedIn = false;
let loading = true; let loading = true;
@@ -15,7 +17,21 @@
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; 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 () => { onMount(async () => {
await setupTauriSync();
if (isTauri()) { if (isTauri()) {
try { try {
const { invoke } = await import('@tauri-apps/api/tauri'); const { invoke } = await import('@tauri-apps/api/tauri');
@@ -51,6 +67,7 @@
status: 'online', status: 'online',
}); });
await refreshChannels(); await refreshChannels();
connectWebSocket(handleWsEvent);
} }
} }
} catch (e) { } catch (e) {
@@ -60,6 +77,24 @@
} }
loading = false; 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> </script>
{#if loading} {#if loading}
@@ -73,7 +108,7 @@
<p>Loading EifelDC...</p> <p>Loading EifelDC...</p>
</div> </div>
{:else if !loggedIn} {:else if !loggedIn}
<LoginScreen on:login={() => (loggedIn = true)} /> <LoginScreen on:login={handleLogin} />
{:else} {:else}
<div class="app-layout"> <div class="app-layout">
<ServerSidebar /> <ServerSidebar />

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <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 { 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 showCreateRoom = false;
let showJoinRoom = false; let showJoinRoom = false;
@@ -12,6 +13,8 @@
let joinError = ''; let joinError = '';
let showSettings = false; let showSettings = false;
let presenceStatus: 'online' | 'idle' | 'dnd' | 'offline' = 'online'; let presenceStatus: 'online' | 'idle' | 'dnd' | 'offline' = 'online';
let editingName = false;
let editNameValue = '';
onMount(async () => { onMount(async () => {
try { try {
@@ -26,6 +29,7 @@
} catch (e) { } catch (e) {
console.error('Failed to load rooms', e); console.error('Failed to load rooms', e);
} }
refreshProfile();
}); });
async function handleCreateRoom() { async function handleCreateRoom() {
@@ -55,13 +59,11 @@
async function handleJoinVoice(channel: any) { async function handleJoinVoice(channel: any) {
try { try {
const result: VoiceStateInfo = await joinVoiceChannel(channel.id); await connectToVoice(channel.id);
voiceState.set({ voiceState.update(s => ({
channelId: result.room_id, ...s,
muted: result.muted, channelId: channel.id,
deafened: result.deafened, }));
streaming: result.streaming,
});
} catch (e) { } catch (e) {
console.error('Failed to join voice', 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) { async function handlePresenceChange(status: string) {
try { try {
await setPresence(status); await setPresence(status);
@@ -140,13 +157,16 @@
<button <button
class="channel-item" class="channel-item"
class:active={$currentChannel?.id === channel.id} 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)} on:contextmenu|preventDefault={() => handleLeaveRoom(channel.id)}
> >
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" class="channel-icon"> <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"/> <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> </svg>
<span>{channel.name}</span> <span>{channel.name}</span>
{#if $unreadCounts[channel.id]}
<span class="unread-badge">{$unreadCounts[channel.id]}</span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
@@ -159,8 +179,14 @@
{#each $voiceChannels as channel} {#each $voiceChannels as channel}
<button <button
class="channel-item voice" class="channel-item voice"
class:active={$currentChannel?.id === channel.id} class:active={$voiceRoom.roomId === channel.id}
on:click={() => handleJoinVoice(channel)} 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"> <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"/> <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-panel">
<div class="user-info"> <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"> <div class="user-details">
<span class="username">{$currentUser?.username || 'Benutzer'}</span> {#if editingName}
<span class="status-text">{presenceStatus === 'online' ? 'Online' : presenceStatus === 'idle' ? 'Abwesend' : presenceStatus === 'dnd' ? 'Nicht stören' : 'Offline'}</span> <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> </div>
<div class="user-actions"> <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> <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>
<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> <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>
<button class="btn-icon" title="Einstellungen" on:click={() => showSettings = !showSettings}>⚙</button> <button class="btn-icon" title="Einstellungen" on:click={() => showSettings = !showSettings}>⚙</button>
@@ -322,9 +362,20 @@
} }
.channel-item.active { .channel-item.active {
background: var(--bg-active); background-color: var(--bg-active);
color: var(--text-primary); 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 { .channel-icon {
@@ -383,11 +434,45 @@
} }
.username { .username {
font-size: 0.85rem;
font-weight: 600; font-weight: 600;
white-space: nowrap; font-size: 0.95rem;
overflow: hidden; cursor: pointer;
text-overflow: ellipsis; }
.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 { .status-text {

File diff suppressed because it is too large Load Diff

View 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>

View File

@@ -19,7 +19,7 @@
if (mode === 'login') { if (mode === 'login') {
const result = await login(homeserver, username, password); const result = await login(homeserver, username, password);
if (result.success) { if (result.success) {
dispatch('login', { userId: result.user_id }); dispatch('login', { userId: result.user_id, homeserver });
} else { } else {
error = result.error || 'Login fehlgeschlagen'; error = result.error || 'Login fehlgeschlagen';
} }
@@ -30,7 +30,7 @@
} }
const result = await register(homeserver, username, password); const result = await register(homeserver, username, password);
if (result.success) { if (result.success) {
dispatch('login', { userId: result.user_id }); dispatch('login', { userId: result.user_id, homeserver });
} else { } else {
error = result.error || 'Registrierung fehlgeschlagen'; error = result.error || 'Registrierung fehlgeschlagen';
} }

View File

@@ -1,29 +1,47 @@
<script lang="ts"> <script lang="ts">
import { servers, currentServer } from '../lib/store'; import { servers, currentServer, addServer, removeServer, switchServer, currentUser } from '../lib/store';
import { onMount } from 'svelte';
let showAddServer = false; let showAddServer = false;
let newServerUrl = '';
let addError = '';
function selectServer(server: any) { function handleAddServer() {
currentServer.set(server); 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> </script>
<div class="server-sidebar"> <div class="server-sidebar">
<div class="server-list"> <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} {#each $servers as server}
<button <button
class="server-icon" class="server-icon"
class:active={$currentServer?.id === server.id} class:active={$currentServer?.id === server.id}
on:click={() => selectServer(server)} on:click={() => switchServer(server)}
title={server.name} title={server.name}
> >
{#if server.iconUrl} {#if server.iconUrl}
@@ -32,17 +50,43 @@
<span class="server-initial">{server.name.charAt(0).toUpperCase()}</span> <span class="server-initial">{server.name.charAt(0).toUpperCase()}</span>
{/if} {/if}
<div class="pill"></div> <div class="pill"></div>
<button class="remove-btn" on:click={(e) => handleRemoveServer(server.id, e)} title="Server entfernen">x</button>
</button> </button>
{/each} {/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"> <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" /> <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> </svg>
</button> </button>
</div> </div>
{#if $currentUser}
<div class="user-section">
<div class="user-bubble">{extractUsername($currentUser.id).charAt(0).toUpperCase()}</div>
</div>
{/if}
</div> </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> <style>
.server-sidebar { .server-sidebar {
width: 72px; width: 72px;
@@ -60,14 +104,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} flex: 1;
.separator {
width: 32px;
height: 2px;
background: var(--border);
border-radius: 1px;
margin: 4px 0;
} }
.server-icon { .server-icon {
@@ -83,7 +120,7 @@
cursor: pointer; cursor: pointer;
transition: border-radius 0.2s, background 0.2s; transition: border-radius 0.2s, background 0.2s;
position: relative; position: relative;
overflow: hidden; overflow: visible;
} }
.server-icon:hover { .server-icon:hover {
@@ -107,11 +144,6 @@
font-weight: 700; font-weight: 700;
} }
.home {
background: var(--bg-tertiary);
color: var(--accent);
}
.add { .add {
background: transparent; background: transparent;
color: var(--success); color: var(--success);
@@ -124,7 +156,7 @@
.pill { .pill {
position: absolute; position: absolute;
left: -4px; left: -8px;
width: 4px; width: 4px;
height: 0; height: 0;
background: white; background: white;
@@ -136,4 +168,146 @@
.server-icon:hover .pill { .server-icon:hover .pill {
height: 20px; 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> </style>

View File

@@ -1,57 +1,57 @@
<script lang="ts"> <script lang="ts">
import { voiceState } from '../lib/store'; import { voiceRoom, disconnectFromVoice, toggleMute, toggleDeafen, voiceParticipants } from '../lib/voice';
import { leaveVoiceChannel, toggleMute, toggleDeafen } from '../lib/api';
function extractUsername(userId: string): string {
return userId.split(':')[0].replace('@', '');
}
async function handleLeave() { async function handleLeave() {
if (!$voiceState.channelId) return; await disconnectFromVoice();
try {
await leaveVoiceChannel($voiceState.channelId);
voiceState.set({ channelId: null, muted: false, deafened: false, streaming: false });
} catch (e) {
console.error('Failed to leave voice', e);
}
} }
async function handleMute() { async function handleMute() {
if (!$voiceState.channelId) return; await toggleMute();
try {
const newMuted = await toggleMute($voiceState.channelId);
voiceState.update(s => ({ ...s, muted: newMuted }));
} catch (e) {
console.error('Failed to toggle mute', e);
}
} }
async function handleDeafen() { async function handleDeafen() {
if (!$voiceState.channelId) return; await toggleDeafen();
try {
const newDeaf = await toggleDeafen($voiceState.channelId);
voiceState.update(s => ({ ...s, deafened: newDeaf }));
} catch (e) {
console.error('Failed to toggle deafen', e);
}
} }
</script> </script>
{#if $voiceState.channelId} {#if $voiceRoom.connected || $voiceRoom.connecting}
<div class="voice-panel"> <div class="voice-panel">
<div class="voice-info"> <div class="voice-info">
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--success)"> {#if $voiceRoom.connecting}
<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"/> <div class="voice-spinner"></div>
<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"/> <span class="voice-label connecting">Verbinden...</span>
</svg> {:else}
<span class="voice-label">Sprachverbunden</span> <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>
<div class="voice-actions"> <div class="voice-actions">
<button class="voice-btn" class:active={$voiceState.muted} on:click={handleMute} title="Mikrofon stumm"> <button class="voice-btn" class:active={$voiceRoom.localMuted} on:click={handleMute} title="Mikrofon stumm">
{#if $voiceState.muted} {#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> <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} {: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> <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} {/if}
</button> </button>
<button class="voice-btn" class:active={$voiceState.deafened} on:click={handleDeafen} title="Kopfhörer stumm"> <button class="voice-btn" class:active={$voiceRoom.localDeafened} on:click={handleDeafen} title="Kopfhörer stumm">
{#if $voiceState.deafened} {#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> <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} {: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> <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> </div>
{/if} {/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> <style>
.voice-panel { .voice-panel {
position: fixed; position: fixed;
@@ -84,17 +91,93 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
min-width: 0;
}
.voice-details {
display: flex;
flex-direction: column;
min-width: 0;
} }
.voice-label { .voice-label {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--success); color: var(--success);
font-weight: 600; 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 { .voice-actions {
display: flex; display: flex;
gap: 4px; gap: 4px;
flex-shrink: 0;
} }
.voice-btn { .voice-btn {
@@ -127,4 +210,29 @@
.voice-btn.disconnect:hover { .voice-btn.disconnect:hover {
background: #c03033; 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> </style>

View File

@@ -12,6 +12,8 @@ export interface RoomInfo {
is_encrypted: boolean; is_encrypted: boolean;
member_count: number; member_count: number;
topic: string | null; topic: string | null;
unread_notifications: number;
unread_messages: number;
} }
export interface MessageInfo { export interface MessageInfo {
@@ -20,6 +22,15 @@ export interface MessageInfo {
body: string; body: string;
timestamp: number; timestamp: number;
reply_to: string | null; 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 { export interface RoleInfo {
@@ -50,6 +61,23 @@ export interface VoiceStateInfo {
streaming: boolean; 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 { export interface PresenceInfo {
user_id: string; user_id: string;
status: string; status: string;
@@ -196,7 +224,7 @@ export async function getPresence(userId: string): Promise<PresenceInfo> {
return httpGet(`/api/presence/${encodeURIComponent(userId)}`); return httpGet(`/api/presence/${encodeURIComponent(userId)}`);
} }
export async function joinVoiceChannel(roomId: string): Promise<VoiceStateInfo> { export async function joinVoiceChannel(roomId: string): Promise<VoiceJoinResult> {
if (isTauri()) { if (isTauri()) {
return tauriInvoke('join_voice_channel', { roomId }); 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 }); 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()) { if (isTauri()) {
return tauriInvoke('toggle_mute', { roomId }); return tauriInvoke('toggle_mute', { roomId });
} }
return httpPost('/api/voice/toggle-mute', { room_id: 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()) { if (isTauri()) {
return tauriInvoke('toggle_deafen', { roomId }); return tauriInvoke('toggle_deafen', { roomId });
} }
return httpPost('/api/voice/toggle-deafen', { room_id: 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[]> { export async function getRoles(roomId: string): Promise<RoleInfo[]> {
if (isTauri()) { if (isTauri()) {
return tauriInvoke('get_roles', { roomId }); 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)}`); 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;
}

View File

@@ -1,5 +1,6 @@
import { writable, derived } from 'svelte/store'; 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 { interface User {
id: string; id: string;
@@ -32,13 +33,23 @@ interface Message {
replyTo: string | null; replyTo: string | null;
isBot: boolean; isBot: boolean;
reactions: Record<string, string[]>; 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 { interface Server {
id: string; id: string;
name: string; name: string;
homeserver: string;
iconUrl: string | null; iconUrl: string | null;
roles: Role[];
} }
interface Role { interface Role {
@@ -60,20 +71,93 @@ interface Member {
export const currentUser = writable<User | null>(null); export const currentUser = writable<User | null>(null);
export const currentServer = writable<Server | null>(null); export const currentServer = writable<Server | null>(null);
export const currentChannel = writable<Channel | 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 channels = writable<Channel[]>([]);
export const unreadCounts = writable<Record<string, number>>({});
export const messages = writable<Message[]>([]); export const messages = writable<Message[]>([]);
export const members = writable<Member[]>([]); 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<{ export const voiceState = writable<{
channelId: string | null; channelId: string | null;
muted: boolean; muted: boolean;
deafened: boolean; deafened: boolean;
streaming: boolean; streaming: boolean;
participants: { userId: string; muted: boolean; deafened: boolean }[];
}>({ }>({
channelId: null, channelId: null,
muted: false, muted: false,
deafened: false, deafened: false,
streaming: false, streaming: false,
participants: [],
}); });
export const sortedMembers = derived(members, ($members) => { export const sortedMembers = derived(members, ($members) => {
@@ -91,21 +175,185 @@ export const voiceChannels = derived(channels, ($channels) =>
$channels.filter((c) => c.type === 'voice') $channels.filter((c) => c.type === 'voice')
); );
export async function getJoinedRooms(): Promise<import('./api').RoomInfo[]> {
return invoke('get_joined_rooms');
}
export async function refreshChannels() { export async function refreshChannels() {
try { try {
const rooms = await getJoinedRooms(); const rooms = await api.getJoinedRooms();
channels.set(rooms.map(r => ({ const unreads: Record<string, number> = {};
id: r.room_id, channels.set(rooms.map(r => {
name: r.name || r.room_id, const count = r.unread_notifications || r.unread_messages || 0;
type: 'text' as const, if (count > 0) unreads[r.room_id] = count;
topic: null, return {
parentId: null, 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) { } catch (e) {
console.error('Failed to refresh channels', 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;
}
}

View 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,
}));
}

View File

@@ -1,107 +1,151 @@
use matrix_sdk::Client;
use serde::Serialize;
use tauri::State;
use crate::state::AppState; use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)] #[derive(Serialize)]
pub struct LoginResult { 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, success: bool,
user_id: String, user_id: String,
token: Option<String>,
error: Option<String>, error: Option<String>,
} }
#[derive(Deserialize, Serialize)]
struct ServerRegisterRequest {
homeserver: String,
username: String,
password: String,
}
#[tauri::command] #[tauri::command]
pub async fn login( pub async fn login(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
homeserver: String, homeserver: String,
username: String, username: String,
password: String, password: String,
) -> Result<LoginResult, String> { ) -> Result<LoginResult, String> {
let client = Client::builder() let server_url = format!("https://{}", homeserver.trim_end_matches('/'));
.homeserver_url(&homeserver) let client = reqwest::Client::new();
.build()
.await
.map_err(|e| e.to_string())?;
client let res = client
.matrix_auth() .post(format!("{}/api/login", server_url.trim_end_matches('/')))
.login_username(&username, &password) .json(&ServerLoginRequest {
homeserver,
username,
password,
})
.send() .send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let user_id = client let data: ServerLoginResponse = res.json().await.map_err(|e| e.to_string())?;
.user_id()
.map(|u| u.to_string()) if !data.success {
.unwrap_or_default(); return Ok(LoginResult {
success: false,
user_id: String::new(),
error: data.error,
});
}
let mut s = state.write().await; let mut s = state.write().await;
s.client = Some(client);
s.logged_in = true; 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 { Ok(LoginResult {
success: true, success: true,
user_id, user_id: data.user_id,
error: None, error: None,
}) })
} }
#[tauri::command] #[tauri::command]
pub async fn logout(state: State<'_, crate::state::AppState>) -> Result<bool, String> { pub async fn logout(state: State<'_, AppState>) -> Result<bool, String> {
let mut s = state.write().await; let (server_url, token) = {
if let Some(client) = s.client.take() { let s = state.read().await;
let _ = client.matrix_auth().logout().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.logged_in = false;
s.user_id = None; s.user_id = None;
s.auth_token = None;
s.server_url = None;
Ok(true) Ok(true)
} }
#[tauri::command] #[tauri::command]
pub async fn register( pub async fn register(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
homeserver: String, homeserver: String,
username: String, username: String,
password: String, password: String,
) -> Result<LoginResult, String> { ) -> Result<LoginResult, String> {
let client = Client::builder() let server_url = format!("https://{}", homeserver.trim_end_matches('/'));
.homeserver_url(&homeserver) let client = reqwest::Client::new();
.build()
let res = client
.post(format!("{}/api/register", server_url.trim_end_matches('/')))
.json(&ServerRegisterRequest {
homeserver,
username,
password,
})
.send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut request = matrix_sdk::ruma::api::client::account::register::v3::Request::new(); let data: ServerLoginResponse = res.json().await.map_err(|e| e.to_string())?;
request.username = Some(username);
request.password = Some(password);
client if !data.success {
.matrix_auth() return Ok(LoginResult {
.register(request) success: false,
.await user_id: String::new(),
.map_err(|e| e.to_string())?; error: data.error,
});
let user_id = client }
.user_id()
.map(|u| u.to_string())
.unwrap_or_default();
let mut s = state.write().await; let mut s = state.write().await;
s.client = Some(client);
s.logged_in = true; 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 { Ok(LoginResult {
success: true, success: true,
user_id, user_id: data.user_id,
error: None, error: None,
}) })
} }
#[tauri::command] #[tauri::command]
pub async fn get_current_user( pub async fn get_current_user(state: State<'_, AppState>) -> Result<Option<String>, String> {
state: State<'_, crate::state::AppState>,
) -> Result<Option<String>, String> {
let s = state.read().await; let s = state.read().await;
Ok(s.user_id.clone()) Ok(s.user_id.clone())
} }

View File

@@ -1,10 +1,8 @@
use matrix_sdk::ruma::room_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState; use crate::state::AppState;
use std::path::Path; use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct CustomEmoji { pub struct CustomEmoji {
id: String, id: String,
name: String, name: String,
@@ -13,6 +11,76 @@ pub struct CustomEmoji {
animated: bool, 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)] #[derive(Serialize)]
pub struct StickerPack { pub struct StickerPack {
id: String, id: String,
@@ -28,66 +96,6 @@ pub struct Sticker {
} }
#[tauri::command] #[tauri::command]
pub async fn get_custom_emoji( pub async fn get_sticker_packs(_state: State<'_, AppState>) -> Result<Vec<StickerPack>, String> {
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;
Ok(Vec::new()) Ok(Vec::new())
} }

View File

@@ -1,7 +1,8 @@
pub mod auth; pub mod auth;
pub mod emoji;
pub mod presence;
pub mod profile;
pub mod roles;
pub mod rooms; pub mod rooms;
pub mod threads; pub mod threads;
pub mod presence;
pub mod voice; pub mod voice;
pub mod emoji;
pub mod roles;

View File

@@ -1,9 +1,8 @@
use matrix_sdk::ruma::user_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState; use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct PresenceInfo { pub struct PresenceInfo {
user_id: String, user_id: String,
status: String, status: String,
@@ -13,63 +12,51 @@ pub struct PresenceInfo {
#[tauri::command] #[tauri::command]
pub async fn set_presence( pub async fn set_presence(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
status: String, status: String,
status_msg: Option<String>, status_msg: Option<String>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let s = state.read().await; 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() { let client = reqwest::Client::new();
"online" => matrix_sdk::ruma::presence::PresenceState::Online, let res = client
"away" => matrix_sdk::ruma::presence::PresenceState::Away, .post(format!(
"unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable, "{}/api/presence/set",
_ => matrix_sdk::ruma::presence::PresenceState::Online, server_url.trim_end_matches('/')
}; ))
.header("Authorization", format!("Bearer {}", token))
let request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new( .json(&serde_json::json!({ "status": status, "status_msg": status_msg }))
client.user_id().ok_or("No user ID")?.to_owned(), .send()
);
let mut request = request;
request.presence = presence_state;
request.status_msg = status_msg.clone();
client
.send(request, None)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(true) res.json().await.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn get_presence( pub async fn get_presence(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
user_id_str: String, user_id: String,
) -> Result<PresenceInfo, String> { ) -> Result<PresenceInfo, String> {
let s = state.read().await; 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 client = reqwest::Client::new();
let res = client
let request = matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned()); .get(format!(
"{}/api/presence/{}",
let response = client server_url.trim_end_matches('/'),
.send(request, None) user_id
))
.header("Authorization", format!("Bearer {}", token))
.send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let status_str = match response.presence { res.json().await.map_err(|e| e.to_string())
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()),
})
} }

View 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())
}

View File

@@ -1,10 +1,8 @@
use matrix_sdk::ruma::room_id; use crate::state::AppState;
use matrix_sdk::ruma::user_id;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tauri::State; use tauri::State;
use crate::state::AppState;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize)]
pub struct Role { pub struct Role {
id: String, id: String,
name: String, name: String,
@@ -13,7 +11,7 @@ pub struct Role {
position: i32, position: i32,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct Permissions { pub struct Permissions {
can_send_messages: bool, can_send_messages: bool,
can_delete_messages: bool, can_delete_messages: bool,
@@ -27,155 +25,107 @@ pub struct Permissions {
can_voice_stream: bool, 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] #[tauri::command]
pub async fn get_roles( pub async fn get_roles(state: State<'_, AppState>, room_id: String) -> Result<Vec<Role>, String> {
state: State<'_, crate::state::AppState>,
room_id: String,
) -> Result<Vec<Role>, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; 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 { res.json().await.map_err(|e| e.to_string())
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)
} }
#[tauri::command] #[tauri::command]
pub async fn assign_role( pub async fn assign_role(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
user_id: String, user_id: String,
role_id: String, role_id: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
let power_level: i64 = match role_id.as_str() { let client = reqwest::Client::new();
"admin" => 100, let res = client
"moderator" => 50, .post(format!(
_ => 0, "{}/api/rooms/{}/roles/assign",
}; server_url.trim_end_matches('/'),
room_id
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::new(); ))
let mut content = content; .header("Authorization", format!("Bearer {}", token))
content.users.insert(uid.to_owned(), power_level.into()); .json(&serde_json::json!({ "user_id": user_id, "role_id": role_id }))
.send()
room.send_state_event(content)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok(true) res.json().await.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
pub async fn remove_role( pub async fn remove_role(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
user_id: String, user_id: String,
_role_id: String, role_id: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
if let Ok(mut power_levels) = room.power_levels().await { let client = reqwest::Client::new();
power_levels.users.remove(&uid); 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); res.json().await.map_err(|e| e.to_string())
room.send_state_event(content)
.await
.map_err(|e| e.to_string())?;
}
Ok(true)
} }
#[tauri::command] #[tauri::command]
pub async fn get_permissions( pub async fn get_permissions(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
user_id: String, user_id: String,
) -> Result<Permissions, String> { ) -> Result<Permissions, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let uid = user_id!(user_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?;
let user_power = if let Ok(power_levels) = room.power_levels().await { let client = reqwest::Client::new();
power_levels let res = client
.users .get(format!(
.get(&uid) "{}/api/rooms/{}/permissions/{}",
.copied() server_url.trim_end_matches('/'),
.map(|p| p.into()) room_id,
.unwrap_or(power_levels.users_default as i64) user_id
} else { ))
0 .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())
} }

View File

@@ -1,9 +1,8 @@
use matrix_sdk::ruma::room_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState; use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct RoomInfo { pub struct RoomInfo {
room_id: String, room_id: String,
name: String, name: String,
@@ -13,165 +12,466 @@ pub struct RoomInfo {
topic: Option<String>, topic: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct MessageInfo { pub struct MessageInfo {
event_id: String, event_id: String,
sender: String, sender: String,
body: String, body: String,
timestamp: u64, timestamp: u64,
reply_to: Option<String>, 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] #[tauri::command]
pub async fn get_joined_rooms( pub async fn get_joined_rooms(state: State<'_, AppState>) -> Result<Vec<RoomInfo>, String> {
state: State<'_, crate::state::AppState>,
) -> Result<Vec<RoomInfo>, String> {
let s = state.read().await; 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 client = reqwest::Client::new();
let mut result = Vec::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 { res.json().await.map_err(|e| e.to_string())
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)
} }
#[tauri::command] #[tauri::command]
pub async fn get_room_messages( pub async fn get_room_messages(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
limit: u32, limit: u32,
from: Option<String>, from: Option<String>,
) -> Result<Vec<MessageInfo>, String> { ) -> Result<Vec<MessageInfo>, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new(); let client = reqwest::Client::new();
options.limit = limit.into(); let mut url = format!(
options.from = from.map(|t| t.into()).or_else(|| None); "{}/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 let res = client
.messages(options) .get(&url)
.header("Authorization", format!("Bearer {}", token))
.send()
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut result = Vec::new(); res.json().await.map_err(|e| e.to_string())
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)
} }
#[tauri::command] #[tauri::command]
pub async fn send_message( pub async fn send_message(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
message: String, message: String,
) -> Result<String, String> { ) -> Result<String, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&message); let client = reqwest::Client::new();
let txn_id = matrix_sdk::ruma::TransactionId::new(); let res = client
let response = room.send(content, Some(&txn_id)).await.map_err(|e| e.to_string())?; .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] #[tauri::command]
pub async fn create_room( pub async fn create_room(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
name: String, name: String,
topic: Option<String>, topic: Option<String>,
visibility: String, visibility: String,
) -> Result<String, String> { ) -> Result<String, String> {
let s = state.read().await; 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() { let client = reqwest::Client::new();
"public" => matrix_sdk::ruma::Space::Public, let res = client
_ => matrix_sdk::ruma::Space::Private, .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(); res.json().await.map_err(|e| e.to_string())
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())
} }
#[tauri::command] #[tauri::command]
pub async fn join_room( pub async fn join_room(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id_or_alias: String, room_id_or_alias: String,
) -> Result<String, String> { ) -> Result<String, String> {
let s = state.read().await; 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( let client = reqwest::Client::new();
&room_id_or_alias.try_into().map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?, let res = client
&[] .post(format!(
).await.map_err(|e| e.to_string())?; "{}/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] #[tauri::command]
pub async fn leave_room( pub async fn leave_room(state: State<'_, AppState>, room_id: String) -> Result<bool, String> {
state: State<'_, crate::state::AppState>,
room_id: String,
) -> Result<bool, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
room.leave().await.map_err(|e| e.to_string())?; let client = reqwest::Client::new();
Ok(true) 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] #[tauri::command]
pub async fn get_room_members( pub async fn get_room_members(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
let s = state.read().await; 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 rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("Not logged in")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let members = room.joined_members(); let client = reqwest::Client::new();
Ok(members.iter().map(|m| m.user_id().to_string()).collect()) 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())
} }

View File

@@ -1,87 +1,135 @@
use matrix_sdk::ruma::room_id;
use serde::Serialize;
use tauri::State;
use crate::state::AppState; use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct ThreadInfo { pub struct ThreadInfo {
thread_id: String, pub root_event_id: String,
root_event_id: String, pub root_sender: String,
sender: String, pub root_body: String,
body: String, pub root_timestamp: u64,
reply_count: u32, 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] #[tauri::command]
pub async fn get_thread_messages( pub async fn get_thread_messages(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
thread_id: String, thread_id: String,
limit: u32, limit: u32,
) -> Result<Vec<ThreadInfo>, String> { ) -> Result<Vec<ThreadMessageInfo>, String> {
let s = state.read().await; 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("No server URL")?;
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("No auth token")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new(); let client = reqwest::Client::new();
options.limit = limit.into(); 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())?; res.json().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)
} }
#[tauri::command] #[tauri::command]
pub async fn send_thread_reply( pub async fn send_thread_reply(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
thread_id: String, thread_id: String,
message: String, message: String,
) -> Result<String, String> { ) -> Result<String, String> {
let s = state.read().await; 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("No server URL")?;
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("No auth token")?;
let room = client.get_room(&rid).ok_or("Room not found")?; drop(s);
let content = matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&message); let client = reqwest::Client::new();
let txn_id = matrix_sdk::ruma::TransactionId::new(); let res = client
let response = room.send(content, Some(&txn_id)).await.map_err(|e| e.to_string())?; .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] #[tauri::command]
pub async fn create_thread( pub async fn send_reply(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
root_event_id: String, reply_to: String,
message: String,
) -> Result<String, String> { ) -> Result<String, String> {
let s = state.read().await; 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("No server URL")?;
let rid = room_id!(room_id.as_str()).map_err(|e: matrix_sdk::ruma::IdParseError| e.to_string())?; let token = s.auth_token.clone().ok_or("No auth token")?;
let _room = client.get_room(&rid).ok_or("Room not found")?; 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())
} }

View File

@@ -1,68 +1,152 @@
use serde::Serialize;
use tauri::State;
use crate::state::AppState; use crate::state::AppState;
use serde::{Deserialize, Serialize};
use tauri::State;
#[derive(Serialize)] #[derive(Serialize, Deserialize)]
pub struct VoiceState { pub struct VoiceJoinResult {
room_id: String, pub room_id: String,
muted: bool, pub livekit_url: String,
deafened: bool, pub livekit_token: String,
streaming: bool, }
#[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] #[tauri::command]
pub async fn join_voice_channel( pub async fn join_voice_channel(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
) -> Result<VoiceState, String> { ) -> Result<VoiceJoinResult, String> {
let s = state.read().await; 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; drop(s);
s.voice_manager.join_channel(room_id.clone(), user_id);
Ok(VoiceState { let client = reqwest::Client::new();
room_id, let res = client
muted: false, .post(format!(
deafened: false, "{}/api/voice/join",
streaming: false, 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] #[tauri::command]
pub async fn leave_voice_channel( pub async fn leave_voice_channel(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
) -> Result<bool, String> { ) -> Result<bool, String> {
let s = state.read().await; 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; drop(s);
Ok(s.voice_manager.leave_channel(&room_id, &user_id))
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] #[tauri::command]
pub async fn toggle_mute( pub async fn toggle_mute(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
) -> Result<bool, String> { ) -> Result<VoiceToggleResult, String> {
let s = state.read().await; 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; drop(s);
s.voice_manager.toggle_mute(&room_id, &user_id)
.ok_or("Not in voice channel".to_string()) 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] #[tauri::command]
pub async fn toggle_deafen( pub async fn toggle_deafen(
state: State<'_, crate::state::AppState>, state: State<'_, AppState>,
room_id: String, room_id: String,
) -> Result<bool, String> { ) -> Result<VoiceToggleResult, String> {
let s = state.read().await; 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; drop(s);
s.voice_manager.toggle_deafen(&room_id, &user_id)
.ok_or("Not in voice channel".to_string()) 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())
} }

View File

@@ -1,12 +1,13 @@
pub mod matrix;
pub mod commands; pub mod commands;
pub mod state; pub mod state;
use state::AppState; use state::AppStateInner;
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.manage(AppState::new()) .manage(AppStateInner::default())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::auth::login, commands::auth::login,
commands::auth::logout, commands::auth::logout,
@@ -15,19 +16,29 @@ fn main() {
commands::rooms::get_joined_rooms, commands::rooms::get_joined_rooms,
commands::rooms::get_room_messages, commands::rooms::get_room_messages,
commands::rooms::send_message, 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::create_room,
commands::rooms::join_room, commands::rooms::join_room,
commands::rooms::leave_room, commands::rooms::leave_room,
commands::rooms::get_room_members, 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::get_thread_messages,
commands::threads::send_thread_reply, commands::threads::send_thread_reply,
commands::threads::create_thread, commands::threads::get_threads,
commands::threads::send_reply,
commands::presence::set_presence, commands::presence::set_presence,
commands::presence::get_presence, commands::presence::get_presence,
commands::voice::join_voice_channel, commands::voice::join_voice_channel,
commands::voice::leave_voice_channel, commands::voice::leave_voice_channel,
commands::voice::toggle_mute, commands::voice::toggle_mute,
commands::voice::toggle_deafen, commands::voice::toggle_deafen,
commands::voice::get_voice_participants,
commands::emoji::get_custom_emoji, commands::emoji::get_custom_emoji,
commands::emoji::upload_emoji, commands::emoji::upload_emoji,
commands::emoji::get_sticker_packs, commands::emoji::get_sticker_packs,
@@ -35,6 +46,10 @@ fn main() {
commands::roles::assign_role, commands::roles::assign_role,
commands::roles::remove_role, commands::roles::remove_role,
commands::roles::get_permissions, 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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(())
}
}

View File

@@ -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()
}
}

View File

@@ -1,21 +1,13 @@
use std::sync::Arc;
use tokio::sync::RwLock;
use matrix_sdk::Client;
use crate::matrix::voice::VoiceManager;
#[derive(Default)] #[derive(Default)]
pub struct AppStateInner { pub struct AppStateInner {
pub client: Option<Client>,
pub logged_in: bool, pub logged_in: bool,
pub user_id: Option<String>, 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 get_client() -> reqwest::Client {
pub fn new() -> Self { reqwest::Client::new()
Arc::new(RwLock::new(AppStateInner::default()))
}
} }

55
client/tauri.conf.json Normal file
View 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
}
}
}

View File

@@ -3,3 +3,6 @@ POSTGRES_PASSWORD=changeme_postgres_password
TURN_SECRET=changeme_turn_secret TURN_SECRET=changeme_turn_secret
MACAROON_SECRET=changeme_macaroon_secret MACAROON_SECRET=changeme_macaroon_secret
FORM_SECRET=changeme_form_secret FORM_SECRET=changeme_form_secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_SECRET=devsecret
LIVEKIT_NODE_IP=127.0.0.1

View File

@@ -1,5 +1,3 @@
version: "3.8"
services: services:
eifeldc: eifeldc:
build: build:
@@ -12,12 +10,21 @@ services:
environment: environment:
- EIFELDC_STATIC_DIR=/usr/share/eifeldc/client - EIFELDC_STATIC_DIR=/usr/share/eifeldc/client
- RUST_LOG=eifeldc_server=info,tower_http=info - 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: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/api/current-user"] test: ["CMD", "curl", "-f", "http://localhost:3000/api/current-user"]
interval: 15s interval: 15s
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 10s start_period: 10s
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -48,6 +55,11 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 30s start_period: 30s
deploy:
resources:
limits:
memory: 1G
cpus: "2.0"
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -72,6 +84,11 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
deploy:
resources:
limits:
memory: 512M
cpus: "1.0"
logging: logging:
driver: json-file driver: json-file
options: options:
@@ -95,6 +112,32 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" 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: nginx:
image: nginx:alpine image: nginx:alpine
container_name: eifeldc-nginx container_name: eifeldc-nginx
@@ -110,6 +153,8 @@ services:
condition: service_healthy condition: service_healthy
synapse: synapse:
condition: service_healthy condition: service_healthy
livekit:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80/"] test: ["CMD", "curl", "-f", "http://localhost:80/"]
interval: 15s interval: 15s

View File

@@ -6,6 +6,10 @@ upstream synapse_server {
server synapse:8008; server synapse:8008;
} }
upstream livekit_server {
server livekit:7880;
}
server { server {
listen 80; listen 80;
server_name eifeldc.local; server_name eifeldc.local;
@@ -60,6 +64,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme; 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/ { location /api/ {
proxy_pass http://eifeldc_server; proxy_pass http://eifeldc_server;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -15,8 +15,17 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
reqwest = { workspace = true } reqwest = { workspace = true }
url = { workspace = true } url = { workspace = true }
axum = { version = "0.7", features = ["ws"] } axum = { version = "0.7", features = ["ws", "multipart"] }
tower = "0.4" tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "fs", "trace"] } tower-http = { version = "0.5", features = ["cors", "fs", "trace"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] } 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"

View File

@@ -1,4 +1,6 @@
pub mod routes; pub mod routes;
pub mod session_store;
pub mod state; pub mod state;
pub use session_store::SessionStore;
pub use state::ServerState; pub use state::ServerState;

View File

@@ -1,20 +1,66 @@
use eifeldc_server::routes::api_router; 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 eifeldc_server::ServerState;
use tower_http::services::ServeDir;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::services::ServeDir;
#[tokio::main] #[tokio::main]
async fn 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 api = api_router(state);
let static_dir = std::env::var("EIFELDC_STATIC_DIR") let static_dir =
.unwrap_or_else(|_| "client/src-ui/dist".to_string()); std::env::var("EIFELDC_STATIC_DIR").unwrap_or_else(|_| "client/src-ui/dist".to_string());
let app = api let app = api.fallback_service(ServeDir::new(&static_dir));
.fallback_service(ServeDir::new(&static_dir));
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into(); let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
tracing::info!("EifelDC Web Server listening on http://{}", addr); 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(); let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).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();
}
}
}

View File

@@ -1,11 +1,9 @@
use axum::{ use crate::session_store::StoredSession;
extract::State, use crate::state::{Session, WsEvent};
http::{HeaderMap, StatusCode}, use axum::{extract::State, http::HeaderMap, Json};
Json, use matrix_sdk::config::SyncSettings;
};
use matrix_sdk::Client; use matrix_sdk::Client;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::state::VoiceManager;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LoginRequest { pub struct LoginRequest {
@@ -14,6 +12,21 @@ pub struct LoginRequest {
pub password: String, 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)] #[derive(Serialize)]
pub struct LoginResult { pub struct LoginResult {
pub success: bool, pub success: bool,
@@ -29,36 +42,83 @@ pub struct RegisterRequest {
pub password: String, 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( pub async fn login(
State(state): State<crate::state::ServerState>, State(state): State<crate::state::ServerState>,
Json(req): Json<LoginRequest>, 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() let client = Client::builder()
.homeserver_url(&req.homeserver) .homeserver_url(&req.homeserver)
.build() .build()
.await .await
.map_err(|_| StatusCode::BAD_REQUEST)?; .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
client client
.matrix_auth() .matrix_auth()
.login_username(&req.username, &req.password) .login_username(&req.username, &req.password)
.send() .send()
.await .await
.map_err(|_| StatusCode::UNAUTHORIZED)?; .map_err(|_| axum::http::StatusCode::UNAUTHORIZED)?;
let user_id = client let user_id = client.user_id().map(|u| u.to_string()).unwrap_or_default();
.user_id()
.map(|u| u.to_string())
.unwrap_or_default();
let token = uuid::Uuid::new_v4().to_string(); let token = uuid::Uuid::new_v4().to_string();
let mut s = state.write().await; let matrix_session = client
s.sessions.insert(token.clone(), crate::state::Session { .matrix_auth()
client, .session()
user_id: user_id.clone(), .ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
voice_manager: VoiceManager::new(),
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 { Ok(Json(LoginResult {
success: true, success: true,
@@ -71,12 +131,14 @@ pub async fn login(
pub async fn register( pub async fn register(
State(state): State<crate::state::ServerState>, State(state): State<crate::state::ServerState>,
Json(req): Json<RegisterRequest>, 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() let client = Client::builder()
.homeserver_url(&req.homeserver) .homeserver_url(&req.homeserver)
.build() .build()
.await .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(); let mut request = matrix_sdk::ruma::api::client::account::register::v3::Request::new();
request.username = Some(req.username); request.username = Some(req.username);
@@ -86,21 +148,51 @@ pub async fn register(
.matrix_auth() .matrix_auth()
.register(request) .register(request)
.await .await
.map_err(|_| StatusCode::BAD_REQUEST)?; .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
let user_id = client let user_id = client.user_id().map(|u| u.to_string()).unwrap_or_default();
.user_id()
.map(|u| u.to_string())
.unwrap_or_default();
let token = uuid::Uuid::new_v4().to_string(); let token = uuid::Uuid::new_v4().to_string();
let mut s = state.write().await; let matrix_session = client
s.sessions.insert(token.clone(), crate::state::Session { .matrix_auth()
client, .session()
user_id: user_id.clone(), .ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
voice_manager: VoiceManager::new(),
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 { Ok(Json(LoginResult {
success: true, success: true,
@@ -113,22 +205,34 @@ pub async fn register(
pub async fn logout( pub async fn logout(
State(state): State<crate::state::ServerState>, State(state): State<crate::state::ServerState>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Json<bool>, StatusCode> { ) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let mut s = state.write().await; 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 _ = 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)) Ok(Json(true))
} }
pub async fn get_current_user( pub async fn get_current_user(
State(state): State<crate::state::ServerState>, State(state): State<crate::state::ServerState>,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Json<Option<String>>, StatusCode> { ) -> Result<Json<Option<String>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; let s = state.read().await;
let user_id = s.sessions.get(&token).map(|s| s.user_id.clone()); let user_id = s.sessions.get(&token).map(|s| s.user_id.clone());
@@ -147,14 +251,377 @@ pub async fn auth_middleware(
headers: HeaderMap, headers: HeaderMap,
State(state): State<crate::state::ServerState>, State(state): State<crate::state::ServerState>,
request: axum::extract::Request, request: axum::extract::Request,
next: middleware::Next, next: axum::middleware::Next,
) -> Result<axum::response::Response, StatusCode> { ) -> Result<axum::response::Response, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; let expired = {
if !s.sessions.contains_key(&token) { let s = state.read().await;
return Err(StatusCode::UNAUTHORIZED); 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) 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);
}
}

View File

@@ -1,13 +1,13 @@
use super::auth::extract_token;
use crate::ServerState;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::HeaderMap, http::HeaderMap,
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Serialize)] #[derive(Serialize, Clone)]
pub struct CustomEmoji { pub struct CustomEmoji {
pub id: String, pub id: String,
pub name: String, pub name: String,
@@ -33,10 +33,69 @@ pub struct Sticker {
pub async fn get_custom_emoji( pub async fn get_custom_emoji(
State(state): State<ServerState>, State(state): State<ServerState>,
headers: HeaderMap, headers: HeaderMap,
Path(_room_id): Path<String>, Path(room_id): Path<String>,
) -> Result<Json<Vec<CustomEmoji>>, axum::http::StatusCode> { ) -> Result<Json<Vec<CustomEmoji>>, axum::http::StatusCode> {
let _token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
Ok(Json(Vec::new())) 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)] #[derive(Deserialize)]
@@ -53,34 +112,50 @@ pub async fn upload_emoji(
) -> Result<Json<CustomEmoji>, axum::http::StatusCode> { ) -> Result<Json<CustomEmoji>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let _room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .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); let path = std::path::Path::new(&req.image_path);
if !path.exists() { if !path.exists() {
return Err(axum::http::StatusCode::BAD_REQUEST); 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("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg", Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif", Some("gif") => "image/gif",
Some("webp") => "image/webp", Some("webp") => "image/webp",
_ => "image/png", _ => "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 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 { Ok(Json(CustomEmoji {
id: format!("emoji_{}", chrono::Utc::now().timestamp()), id: format!("emoji_{}", chrono::Utc::now().timestamp()),
name: req.name, name: req.name,
url: response.content_uri.to_string(), url: response.content_uri.to_string(),
category: "custom".to_string(), category: "custom".to_string(),
animated: mime_type == "image/gif", animated: is_animated,
})) }))
} }

View 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())
}

View File

@@ -1,11 +1,13 @@
use super::auth::extract_token;
use crate::routes::metrics::MESSAGES_SENT_TOTAL;
use crate::ServerState;
use axum::{ use axum::{
extract::{Path, State, Query}, extract::{Path, Query, State},
http::HeaderMap, http::HeaderMap,
Json, Json,
}; };
use serde::{Serialize, Deserialize}; use matrix_sdk::room::MessagesOptions;
use crate::ServerState; use serde::{Deserialize, Serialize};
use super::auth::extract_token;
#[derive(Serialize)] #[derive(Serialize)]
pub struct MessageInfo { pub struct MessageInfo {
@@ -14,6 +16,15 @@ pub struct MessageInfo {
pub body: String, pub body: String,
pub timestamp: u64, pub timestamp: u64,
pub reply_to: Option<String>, 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)] #[derive(Deserialize)]
@@ -30,29 +41,196 @@ pub async fn get_room_messages(
) -> Result<Json<Vec<MessageInfo>>, axum::http::StatusCode> { ) -> Result<Json<Vec<MessageInfo>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .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();
let mut options = matrix_sdk::ruma::api::client::message::get_message_events::v3::Request::new(); options.limit = matrix_sdk::ruma::uint!(50);
options.limit = limit.into(); if let Some(from) = query.from {
options.from = query.from.map(|t| t.into()); 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(); let mut result = Vec::new();
for msg in messages.chunk {
if let matrix_sdk::ruma::events::AnySyncMessageLikeEvent::RoomMessage(ev) = msg { for msg in &messages.chunk {
result.push(MessageInfo { let event_value: serde_json::Value = match msg.event.deserialize_as() {
event_id: ev.event_id().to_string(), Ok(v) => v,
sender: ev.sender().to_string(), Err(_) => continue,
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()), 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)) Ok(Json(result))
@@ -71,14 +249,192 @@ pub async fn send_message(
) -> Result<Json<String>, axum::http::StatusCode> { ) -> Result<Json<String>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .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 content =
let txn_id = matrix_sdk::ruma::TransactionId::new(); matrix_sdk::ruma::events::room::message::RoomMessageEventContent::text_plain(&req.message);
let response = room.send(content, Some(&txn_id)).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; 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())) 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))
}

View 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()
}

View File

@@ -1,37 +1,79 @@
pub mod auth; pub mod auth;
pub mod rooms;
pub mod messages;
pub mod presence;
pub mod voice;
pub mod emoji; 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 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::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 { pub fn api_router(state: ServerState) -> Router {
let cors = CorsLayer::new() let allowed_origins = std::env::var("EIFELDC_CORS_ORIGINS").unwrap_or_else(|_| "*".to_string());
.allow_origin(Any)
.allow_methods(Any) let cors = if allowed_origins == "*" {
.allow_headers(Any); 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() Router::new()
.nest("/api", api_routes(state)) .nest("/api", api_routes(state))
.layer(cors) .layer(cors)
.layer(axum::middleware::from_fn_with_state(
rate_limiter,
rate_limit::rate_limit_middleware,
))
} }
fn api_routes(state: ServerState) -> Router { 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() let public = Router::new()
.route("/login", post(auth::login)) .route("/login", post(auth::login))
.route("/register", post(auth::register)) .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() let protected = Router::new()
.route("/logout", post(auth::logout)) .route("/logout", post(auth::logout))
@@ -40,24 +82,63 @@ fn api_routes(state: ServerState) -> Router {
.route("/rooms/join", post(rooms::join_room)) .route("/rooms/join", post(rooms::join_room))
.route("/rooms/{room_id}/leave", post(rooms::leave_room)) .route("/rooms/{room_id}/leave", post(rooms::leave_room))
.route("/rooms/{room_id}/members", get(rooms::get_room_members)) .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}/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/set", post(presence::set_presence))
.route("/presence/{user_id}", get(presence::get_presence)) .route("/presence/{user_id}", get(presence::get_presence))
.route("/voice/join", post(voice::join_voice_channel)) .route("/voice/join", post(voice::join_voice_channel))
.route("/voice/leave", post(voice::leave_voice_channel)) .route("/voice/leave", post(voice::leave_voice_channel))
.route("/voice/toggle-mute", post(voice::toggle_mute)) .route("/voice/toggle-mute", post(voice::toggle_mute))
.route("/voice/toggle-deafen", post(voice::toggle_deafen)) .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", get(roles::get_roles))
.route("/rooms/{room_id}/roles/assign", post(roles::assign_role)) .route("/rooms/{room_id}/roles/assign", post(roles::assign_role))
.route("/rooms/{room_id}/roles/remove", post(roles::remove_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", get(emoji::get_custom_emoji))
.route("/rooms/{room_id}/emoji/upload", post(emoji::upload_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() Router::new()
.merge(public) .merge(public)
.merge(protected) .merge(protected)
.with_state(state) .with_state(state)
.layer(default_body_limit)
} }

View File

@@ -1,11 +1,11 @@
use super::auth::extract_token;
use crate::ServerState;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::HeaderMap, http::HeaderMap,
Json, Json,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetPresenceRequest { pub struct SetPresenceRequest {
@@ -28,21 +28,31 @@ pub async fn set_presence(
) -> Result<Json<bool>, axum::http::StatusCode> { ) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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() { let presence_state = match req.status.as_str() {
"online" => matrix_sdk::ruma::presence::PresenceState::Online, "online" => matrix_sdk::ruma::presence::PresenceState::Online,
"away" => matrix_sdk::ruma::presence::PresenceState::Away,
"unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable, "unavailable" => matrix_sdk::ruma::presence::PresenceState::Unavailable,
_ => matrix_sdk::ruma::presence::PresenceState::Online, _ => matrix_sdk::ruma::presence::PresenceState::Online,
}; };
let user_id = session.client.user_id().ok_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; let user_id = session
let mut request = matrix_sdk::ruma::api::client::presence::set_presence::v3::Request::new(user_id.to_owned()); .client
request.presence = presence_state; .user_id()
request.status_msg = req.status_msg; .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)) Ok(Json(true))
} }
@@ -54,16 +64,26 @@ pub async fn get_presence(
) -> Result<Json<PresenceInfo>, axum::http::StatusCode> { ) -> Result<Json<PresenceInfo>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 uid: matrix_sdk::ruma::OwnedUserId = user_id
let request = matrix_sdk::ruma::api::client::presence::get_presence::v3::Request::new(uid.to_owned()); .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 { let status_str = match response.presence {
matrix_sdk::ruma::presence::PresenceState::Online => "online", matrix_sdk::ruma::presence::PresenceState::Online => "online",
matrix_sdk::ruma::presence::PresenceState::Away => "away",
matrix_sdk::ruma::presence::PresenceState::Unavailable => "unavailable", matrix_sdk::ruma::presence::PresenceState::Unavailable => "unavailable",
_ => "offline", _ => "offline",
}; };

View 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()),
}))
}

View 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);
}
}

View File

@@ -1,11 +1,12 @@
use super::auth::extract_token;
use crate::ServerState;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, State},
http::HeaderMap, http::HeaderMap,
Json, Json,
}; };
use matrix_sdk::ruma::Int;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token;
#[derive(Serialize, Deserialize, Clone)] #[derive(Serialize, Deserialize, Clone)]
pub struct Role { pub struct Role {
@@ -52,22 +53,65 @@ pub async fn get_roles(
) -> Result<Json<Vec<Role>>, axum::http::StatusCode> { ) -> Result<Json<Vec<Role>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .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(); let mut roles = Vec::new();
if let Ok(power_levels) = room.power_levels().await { let members = room
for (uid, power_level) in &power_levels.users { .members(matrix_sdk::RoomMemberships::JOIN)
roles.push(Role { .await
id: format!("role_{}", uid), .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
name: uid.to_string(),
color: if *power_level >= 100 { "#ed4245".to_string() } else if *power_level >= 50 { "#fee75c".to_string() } else { "#5865f2".to_string() }, let power_levels_content: Option<
permissions: vec![], matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent,
position: *power_level as i32, > = 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)) Ok(Json(roles))
@@ -87,22 +131,34 @@ pub async fn assign_role(
) -> Result<Json<bool>, axum::http::StatusCode> { ) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .as_str()
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; .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, "admin" => 100,
"moderator" => 50, "moderator" => 50,
_ => 0, _ => 0,
}; };
let mut content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::new(); room.update_power_levels(vec![(uid.as_ref(), Int::from(power_level))])
content.users.insert(uid.to_owned(), power_level.into()); .await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(true)) Ok(Json(true))
} }
@@ -121,17 +177,28 @@ pub async fn remove_role(
) -> Result<Json<bool>, axum::http::StatusCode> { ) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .as_str()
let uid: matrix_sdk::ruma::OwnedUserId = req.user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; .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 { room.update_power_levels(vec![(uid.as_ref(), Int::from(0))])
power_levels.users.remove(&uid); .await
let content = matrix_sdk::ruma::events::room::power_levels::RoomPowerLevelsEventContent::from(power_levels); .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
room.send_state_event(content).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
}
Ok(Json(true)) Ok(Json(true))
} }
@@ -143,14 +210,38 @@ pub async fn get_permissions(
) -> Result<Json<Permissions>, axum::http::StatusCode> { ) -> Result<Json<Permissions>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .as_str()
let uid: matrix_sdk::ruma::OwnedUserId = user_id.as_str().try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?; .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 { let power_levels_content: Option<
power_levels.users.get(&uid).copied().map(|p| p.into()).unwrap_or(power_levels.users_default as i64) 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 { } else {
0 0
}; };

View File

@@ -1,11 +1,15 @@
use super::auth::extract_token;
use crate::routes::metrics::ROOMS_JOINED_TOTAL;
use crate::ServerState;
use axum::{ use axum::{
extract::{Path, State}, extract::{Multipart, Path, State},
http::HeaderMap, http::HeaderMap,
Json, Json,
}; };
use serde::Serialize; use matrix_sdk::ruma::api::client::receipt::create_receipt::v3::ReceiptType;
use crate::ServerState; use matrix_sdk::ruma::events::receipt::ReceiptThread;
use super::auth::extract_token; use matrix_sdk::RoomMemberships;
use serde::{Deserialize, Serialize};
#[derive(Serialize)] #[derive(Serialize)]
pub struct RoomInfo { pub struct RoomInfo {
@@ -15,6 +19,8 @@ pub struct RoomInfo {
pub is_encrypted: bool, pub is_encrypted: bool,
pub member_count: u64, pub member_count: u64,
pub topic: Option<String>, pub topic: Option<String>,
pub unread_notifications: u64,
pub unread_messages: u64,
} }
pub async fn get_joined_rooms( pub async fn get_joined_rooms(
@@ -23,16 +29,29 @@ pub async fn get_joined_rooms(
) -> Result<Json<Vec<RoomInfo>>, axum::http::StatusCode> { ) -> Result<Json<Vec<RoomInfo>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rooms = session.client.joined_rooms();
let mut result = Vec::new(); let mut result = Vec::new();
for room in rooms { 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 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 topic = room.topic().map(|t| t.to_string());
let is_encrypted = room.is_encrypted().await.unwrap_or(false); 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 { result.push(RoomInfo {
room_id: room.room_id().to_string(), room_id: room.room_id().to_string(),
name, name,
@@ -40,6 +59,8 @@ pub async fn get_joined_rooms(
is_encrypted, is_encrypted,
member_count, member_count,
topic, topic,
unread_notifications,
unread_messages,
}); });
} }
@@ -60,11 +81,14 @@ pub async fn create_room(
) -> Result<Json<String>, axum::http::StatusCode> { ) -> Result<Json<String>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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() { let vis = match req.visibility.as_deref() {
Some("public") => matrix_sdk::ruma::Space::Public, Some("public") => matrix_sdk::ruma::api::client::room::Visibility::Public,
_ => matrix_sdk::ruma::Space::Private, _ => matrix_sdk::ruma::api::client::room::Visibility::Private,
}; };
let mut request = matrix_sdk::ruma::api::client::room::create_room::v3::Request::new(); 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.topic = req.topic;
request.visibility = vis; request.visibility = vis;
let response = session.client.create_room(request).await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; let response = session
Ok(Json(response.room_id.to_string())) .client
.create_room(request)
.await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(response.room_id().to_string()))
} }
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
@@ -88,17 +116,24 @@ pub async fn join_room(
) -> Result<Json<String>, axum::http::StatusCode> { ) -> Result<Json<String>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 let alias: matrix_sdk::ruma::OwnedRoomOrAliasId = req
.join_room_by_id_or_alias( .room_id_or_alias
&req.room_id_or_alias.try_into().map_err(|_| axum::http::StatusCode::BAD_REQUEST)?, .parse()
&[], .map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
)
let room = session
.client
.join_room_by_id_or_alias(&alias, &[])
.await .await
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; .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( pub async fn leave_room(
@@ -108,11 +143,22 @@ pub async fn leave_room(
) -> Result<Json<bool>, axum::http::StatusCode> { ) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .as_str()
room.leave().await.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; .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)) Ok(Json(true))
} }
@@ -124,11 +170,219 @@ pub async fn get_room_members(
) -> Result<Json<Vec<String>>, axum::http::StatusCode> { ) -> Result<Json<Vec<String>>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let s = state.read().await; 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 rid: matrix_sdk::ruma::OwnedRoomId = room_id
let room = session.client.get_room(&rid).ok_or(axum::http::StatusCode::NOT_FOUND)?; .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(); let members = room
Ok(Json(members.iter().map(|m| m.user_id().to_string()).collect())) .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))
} }

View 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()))
}

View 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),
}))
}

View File

@@ -1,80 +1,318 @@
use axum::{
extract::State,
http::HeaderMap,
Json,
};
use serde::{Deserialize, Serialize};
use crate::ServerState;
use super::auth::extract_token; 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)] #[derive(Deserialize)]
pub struct VoiceRequest { pub struct VoiceJoinRequest {
pub room_id: String, pub room_id: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct VoiceStateInfo { pub struct VoiceJoinResponse {
pub room_id: String, pub room_id: String,
pub livekit_url: String,
pub livekit_token: String,
}
#[derive(Serialize)]
pub struct VoiceToggleResponse {
pub muted: bool, pub muted: bool,
pub deafened: 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( pub async fn join_voice_channel(
State(state): State<ServerState>, State(state): State<ServerState>,
headers: HeaderMap, headers: HeaderMap,
Json(req): Json<VoiceRequest>, Json(req): Json<VoiceJoinRequest>,
) -> Result<Json<VoiceStateInfo>, axum::http::StatusCode> { ) -> Result<Json<VoiceJoinResponse>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; 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, room_id: req.room_id,
muted: false, livekit_url,
deafened: false, livekit_token: lk_token,
streaming: false,
})) }))
} }
pub async fn leave_voice_channel( pub async fn leave_voice_channel(
State(state): State<ServerState>, State(state): State<ServerState>,
headers: HeaderMap, headers: HeaderMap,
Json(req): Json<VoiceRequest>, Json(req): Json<VoiceJoinRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> { ) -> Result<Json<bool>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; 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( pub async fn toggle_mute(
State(state): State<ServerState>, State(state): State<ServerState>,
headers: HeaderMap, headers: HeaderMap,
Json(req): Json<VoiceRequest>, Json(_req): Json<VoiceJoinRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> { ) -> Result<Json<VoiceToggleResponse>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; 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) let (user_id, room_id, new_muted, deafened);
.ok_or(axum::http::StatusCode::BAD_REQUEST) {
.map(Json) 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( pub async fn toggle_deafen(
State(state): State<ServerState>, State(state): State<ServerState>,
headers: HeaderMap, headers: HeaderMap,
Json(req): Json<VoiceRequest>, Json(_req): Json<VoiceJoinRequest>,
) -> Result<Json<bool>, axum::http::StatusCode> { ) -> Result<Json<VoiceToggleResponse>, axum::http::StatusCode> {
let token = extract_token(&headers).ok_or(axum::http::StatusCode::UNAUTHORIZED)?; 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) let (user_id, room_id, muted, new_deafened);
.ok_or(axum::http::StatusCode::BAD_REQUEST) {
.map(Json) 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
View 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
View 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);
}
}

View File

@@ -1,117 +1,381 @@
use std::sync::Arc; use crate::session_store::SessionStore;
use tokio::sync::RwLock;
use matrix_sdk::Client; use matrix_sdk::Client;
use std::collections::HashMap; 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 struct Session {
pub client: Client, pub client: Client,
pub user_id: String, pub user_id: String,
pub homeserver: String,
pub voice_manager: VoiceManager, 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 { pub struct VoiceManager {
channels: HashMap<String, VoiceChannel>, pub active_channel: Option<String>,
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 muted: bool,
pub deafened: bool, pub deafened: bool,
pub streaming: bool,
} }
impl VoiceManager { impl VoiceManager {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
channels: HashMap::new(),
active_channel: None, 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, muted: false,
deafened: 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 { pub fn join_channel(&mut self, room_id: &str) {
self.leave_channel_internal(room_id, user_id); self.active_channel = Some(room_id.to_string());
if self.active_channel.as_deref() == Some(room_id) { self.muted = false;
self.active_channel = None; self.deafened = false;
}
true
} }
pub fn toggle_mute(&mut self, room_id: &str, user_id: &str) -> Option<bool> { pub fn leave_channel(&mut self) {
if let Some(channel) = self.channels.get_mut(room_id) { self.active_channel = None;
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) { self.muted = false;
participant.muted = !participant.muted; self.deafened = false;
return Some(participant.muted);
}
}
None
} }
pub fn toggle_deafen(&mut self, room_id: &str, user_id: &str) -> Option<bool> { pub fn toggle_mute(&mut self) -> bool {
if let Some(channel) = self.channels.get_mut(room_id) { self.muted = !self.muted;
if let Some(participant) = channel.participants.iter_mut().find(|p| p.user_id == user_id) { self.muted
participant.deafened = !participant.deafened; }
if participant.deafened {
participant.muted = true; pub fn toggle_deafen(&mut self) -> bool {
} self.deafened = !self.deafened;
return Some(participant.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 struct ServerStateInner {
pub sessions: HashMap<String, Session>, 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 { impl ServerStateInner {
pub fn new() -> Self { pub fn new(
session_store: Arc<SessionStore>,
livekit: LiveKitConfig,
session_ttl: Option<std::time::Duration>,
) -> Self {
Self { Self {
sessions: HashMap::new(), 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 { impl ServerState {
pub fn new() -> Self { pub fn new(
Arc::new(RwLock::new(ServerStateInner::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
} }
} }