Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d0bc76ef2 | |||
| 088b27f9c8 | |||
| 7894d1f796 | |||
| 2332e5a21c | |||
| 478e90d305 | |||
| 062bdfddc5 | |||
| c5be9cea52 | |||
| afd1bec53e | |||
| f4590f28ea | |||
| 40ce05b8ce | |||
| fb9f06468f | |||
| ec5cb80844 | |||
| 27342e727d | |||
| ddead767ae | |||
| 09a59c6124 | |||
| f20f4aa6fa | |||
| 9491f3a831 | |||
| 08e5d92059 | |||
| 92be2a30d1 | |||
| f4f6e8560b | |||
| 1900dd9cb1 | |||
| 1226bd28aa | |||
| 7cdee73542 | |||
| e8ba8ffdba | |||
| 52298d1d46 | |||
| 7fc5d66b8c | |||
| 7a6b4f961a | |||
| 71ee9c59c4 | |||
| 3e343a9c9b | |||
| 9371616508 | |||
| 5951c91102 | |||
| ff431d7f81 | |||
| aa5fdbe8c4 | |||
| 4da08d91bc | |||
| 11b1089126 | |||
| fd7fa4a147 | |||
| 7bf1eaf845 | |||
| 13bd4478f6 | |||
| 53530ce6dd | |||
| f215f000b8 | |||
| 191fe2a89f | |||
| eb5f38de1c | |||
| 51eb2ed310 | |||
| bd73d79b56 | |||
| 4b32655e67 | |||
| c839e67c47 | |||
| 5bdb758d23 | |||
| 56af584ede | |||
| 87d7345155 | |||
| a7646481a4 | |||
| 5d37fb899d | |||
| af913b9923 | |||
| 9a6f15662b | |||
| 7d1bd02e31 | |||
| 8542243745 | |||
| 90bddc65d4 | |||
| 67a5a54244 | |||
| 33c9dc4f15 | |||
| e6dfe43926 | |||
| 62e8354d03 | |||
| 1139e90fc3 | |||
| 60edc0b5b4 |
@@ -11,9 +11,22 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: 20 }
|
with: { node-version: 20 }
|
||||||
|
- name: Cache npm
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Cache Rust target and registry
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
web/src-tauri/target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('web/src-tauri/Cargo.lock') }}
|
||||||
- name: Install system deps
|
- name: Install system deps
|
||||||
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf
|
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf rpm
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: cd web && npm ci && npm run build
|
run: cd web && npm ci && npm run build
|
||||||
- name: Build Tauri bundles
|
- name: Build Tauri bundles
|
||||||
@@ -21,7 +34,7 @@ jobs:
|
|||||||
NO_STRIP: "true"
|
NO_STRIP: "true"
|
||||||
run: cd web && npx tauri build
|
run: cd web && npx tauri build
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: linux-bundles
|
name: linux-bundles
|
||||||
path: |
|
path: |
|
||||||
@@ -35,22 +48,33 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with: { node-version: 20 }
|
with: { node-version: 20 }
|
||||||
|
- name: Cache npm
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: ${{ runner.os }}-npm-${{ hashFiles('web/package-lock.json') }}
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
run: |
|
run: |
|
||||||
if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {
|
curl.exe -sLo rustup-init.exe https://win.rustup.rs/x86_64
|
||||||
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
|
rustup-init.exe -y --default-toolchain stable --profile minimal
|
||||||
.\rustup-init.exe -y --default-toolchain stable
|
set PATH=%USERPROFILE%\.cargo\bin;%PATH%
|
||||||
$env:Path += ";$env:USERPROFILE\.cargo\bin"
|
|
||||||
}
|
|
||||||
rustc --version
|
rustc --version
|
||||||
shell: powershell
|
shell: cmd
|
||||||
|
- name: Cache Rust target and registry
|
||||||
|
uses: Swatinem/rust-cache@v2
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
web/src-tauri
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
run: cd web; npm ci; npm run build
|
run: cd web && npm ci && npm run build
|
||||||
|
shell: cmd
|
||||||
- name: Build Tauri bundles
|
- name: Build Tauri bundles
|
||||||
run: cd web; $env:Path += ";$env:USERPROFILE\.cargo\bin"; npx tauri build
|
env:
|
||||||
shell: powershell
|
CI: "true"
|
||||||
|
run: cd web && set PATH=%USERPROFILE%\.cargo\bin;%PATH% && npx tauri build
|
||||||
|
shell: cmd
|
||||||
- name: Upload artifacts
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: windows-bundles
|
name: windows-bundles
|
||||||
path: |
|
path: |
|
||||||
@@ -61,13 +85,16 @@ jobs:
|
|||||||
needs: [build-linux, build-windows]
|
needs: [build-linux, build-windows]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v3
|
||||||
- name: Create release
|
- name: Create release
|
||||||
uses: actions/gitea-release-action@v3
|
uses: softprops/action-gh-release@v2
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ github.ref_name }}
|
tag_name: ${{ github.ref_name }}
|
||||||
files: |
|
files: |
|
||||||
linux-bundles/*
|
linux-bundles/**/*.AppImage
|
||||||
windows-bundles/*
|
linux-bundles/**/*.deb
|
||||||
|
linux-bundles/**/*.rpm
|
||||||
|
windows-bundles/**/*.msi
|
||||||
|
windows-bundles/**/*.exe
|
||||||
|
|||||||
@@ -41,3 +41,8 @@ minio_data/
|
|||||||
/migrate
|
/migrate
|
||||||
/dumpster-server
|
/dumpster-server
|
||||||
keygen
|
keygen
|
||||||
|
steamfree
|
||||||
|
|
||||||
|
# Tauri Android keystore (contains signing passwords)
|
||||||
|
web/src-tauri/gen/android/keystore.properties
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
## vs Discord, Guilded (historical), TeamSpeak 6, Fluxer
|
||||||
|
|
||||||
Compiled 2026-06-30. Updated through Phase 7 completion.
|
Compiled 2026-06-30. **Status refreshed 2026-07-15** (not a full re-audit of every row).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,22 +21,23 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
|
|
||||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
|| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Text channels | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Direct messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
| Markdown support | ✅ | ✅ full | ✅ full | ❌ basic | ✅ full |
|
||||||
|| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Reactions | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Replies | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Threads | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Forum channels | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Pinned messages | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
| Message search | ✅ | ✅ full | ✅ | ❌ | ✅ Meilisearch |
|
||||||
|| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Edit / delete messages | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Rich embeds / link unfurling | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
| File uploads | ⚠️ MinIO | ✅ | ✅ | ✅ | ✅ S3-backed |
|
||||||
|| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| GIF picker (Giphy) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Typing indicators | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Message history (pagination) | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Read receipts | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
|
| @everyone / @channel | ✅ (perm gated) | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -68,7 +69,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Badges | ❌ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
| Usernames + discriminators | ✅ | ⚠️ handles | ❌ | ✅ UID | ✅ #0000 |
|
||||||
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
| Friend requests | ❌ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
| Block list | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||||
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
| Activity / game status | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,11 +81,12 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Roles | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
| Hierarchical roles | ⚠️ basic | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
| Permission bitflags | ✅ | ✅ | ✅ | ✅ granular | ✅ |
|
||||||
|| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Per-channel permission overrides | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| @everyone default role | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
| Role colors | ⚠️ DB ready | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
| Role icons | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||||
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Administrator bypass | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Client-side permission gates | ✅ (user roles + @everyone) | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
### dumpsterChat Permissions (current)
|
### dumpsterChat Permissions (current)
|
||||||
|
|
||||||
@@ -123,7 +125,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Server invites | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
| Vanity URLs | ❌ | ✅ Nitro | ❌ | ❌ | ❌ |
|
||||||
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Webhooks | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Bots / API | ⚠️ slash cmds | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
| Bots / API | ⚠️ store + runner | ✅ huge | ✅ Flow Bots | ❌ plugins | 🔄 |
|
||||||
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
| Server templates | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
| Server discovery | ❌ | ✅ | ✅ | ✅ | 🔄 |
|
||||||
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
| Server analytics | ❌ | ✅ | ✅ | ❌ | ❌ |
|
||||||
@@ -139,10 +141,10 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
| Desktop notifications | ⚠️ possible via SW | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Web push notifications | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| @mention push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Channel-wide push | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Channel-wide push (@everyone/@channel) | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Email notifications | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Mobile apps | ❌ | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
| Mobile apps | ❌ (PWA is target) | ✅ iOS/Android | ✅ | ✅ | 🔄 Flutter alpha |
|
||||||
| Per-channel notification settings | ❌ | ✅ | ✅ | ✅ | ✅ |
|
| Per-channel notification settings | ⚠️ partial | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
| Do Not Disturb schedule | ❌ | ✅ | ❌ | ❌ | ❌ |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -156,6 +158,7 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Slash commands | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
| Command options / JSON schema | ✅ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
| Bot mentions | ✅ | ✅ | ❌ | ❌ | 🔄 |
|
||||||
|
| Built-in bot runner (anonConfess, leaderboard, steamfree) | ✅ | ❌ | ⚠️ | ❌ | ❌ |
|
||||||
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
| Third-party integrations (Twitch, YouTube, GitHub) | ❌ | ✅ | ✅ | ❌ | 🔄 |
|
||||||
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| Webhook-driven bots | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
|
|
||||||
@@ -168,40 +171,11 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
- Terminal/Gruvbox aesthetic
|
- Terminal/Gruvbox aesthetic
|
||||||
- WebAuthn / passkey auth
|
- WebAuthn / passkey auth
|
||||||
- LiveKit voice integration
|
- LiveKit voice integration
|
||||||
- Built-in webhook execution for simple integrations
|
- Built-in bot store + managed runner
|
||||||
|
- PWA-first mobile (no native app planned)
|
||||||
|
|
||||||
### Discord
|
### Discord / Guilded / TeamSpeak / Fluxer
|
||||||
- Massive network effect (200M+ MAU)
|
See historical notes in git history if needed. Not the product roadmap.
|
||||||
- Nitro subscription perks (animated avatars, HD streaming, larger uploads)
|
|
||||||
- Activities / embedded apps in voice channels
|
|
||||||
- Server boosting tiers
|
|
||||||
- Stage channels
|
|
||||||
- Activities marketplace
|
|
||||||
|
|
||||||
### Guilded (historical)
|
|
||||||
- Built-in calendar with RSVP
|
|
||||||
- Scheduling + availability system
|
|
||||||
- Docs / forms
|
|
||||||
- Lists (task management)
|
|
||||||
- Tournaments
|
|
||||||
- Server Subs monetization
|
|
||||||
- Server Groups (sub-servers)
|
|
||||||
|
|
||||||
### TeamSpeak 6
|
|
||||||
- Self-hosted by design, free up to 32 slots
|
|
||||||
- Granular Power/Needed Power permission system
|
|
||||||
- Low resource client
|
|
||||||
- Plugin ecosystem
|
|
||||||
- Virtual servers
|
|
||||||
- Whisper / poke / channel commander
|
|
||||||
|
|
||||||
### Fluxer
|
|
||||||
- Fully open-source AGPL-3, Docker Compose deploy
|
|
||||||
- No paywalls / license keys
|
|
||||||
- Planned federation
|
|
||||||
- Multi-backend switching
|
|
||||||
- Erlang/OTP gateway for scale
|
|
||||||
- 34 locales
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -210,38 +184,37 @@ Compiled 2026-06-30. Updated through Phase 7 completion.
|
|||||||
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
| Feature | dumpsterChat | Discord | Guilded | TeamSpeak 6 | Fluxer |
|
||||||
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
|---------|:------------:|:-------:|:-------:|:-----------:|:------:|
|
||||||
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
| Self-hostable | ✅ | ❌ | ❌ | ✅ | ✅ |
|
||||||
| Open source | ❌ | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
| Open source | ⚠️ private self-host | ❌ | ❌ | ❌ | ✅ AGPL-3 |
|
||||||
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| PWA support | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
| REST API | ✅ | ✅ | ✅ | ❌ | ✅ |
|
||||||
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| WebSocket gateway | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| Swagger docs | ✅ localhost | ✅ | ✅ | ❌ | ✅ |
|
| Swagger docs | ✅ /docs | ✅ | ✅ | ❌ | ✅ |
|
||||||
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
|
| Docker Compose | ✅ | ❌ | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Priority Recommendations
|
## Priority Recommendations (updated)
|
||||||
|
|
||||||
### Critical gaps (would block most Discord/Guilded users)
|
**Do not treat this table as a todo list.** For a ~12 person friend server, parity rows are optional.
|
||||||
|
|
||||||
1. **Direct Messages** — no way to message users outside servers
|
### Actual next polish (product)
|
||||||
2. **Mobile app** — major adoption blocker
|
1. Mobile PWA pain (input, notifs, safe areas)
|
||||||
3. **Voice push-to-talk** — important for voice-heavy communities
|
2. Role color UI / hierarchy polish
|
||||||
4. **Email notifications** — needed for async engagement
|
3. Voice PTT / screen share only if voice is used
|
||||||
5. **Read receipts / unread state** — channel-level read tracking
|
4. Small slash toys (`/roll`, `/choose`) if wanted
|
||||||
|
|
||||||
### High-value next features
|
### Real tech debt
|
||||||
|
1. More tests on hot paths (permissions DB checker, bot auth)
|
||||||
|
2. Keep client permission cache in sync after role edits (partially done)
|
||||||
|
3. Deploy docs must name the real unit: `dumpster.service`
|
||||||
|
|
||||||
1. **Screen share** — LiveKit supports it; mostly frontend work
|
### Explicitly not critical
|
||||||
2. **Custom emoji / reactions beyond unicode** — core Discord behavior
|
- Friend requests (DMs already exist among members)
|
||||||
3. **Server groups (sub-servers)** — channel organization
|
- Native mobile apps
|
||||||
4. **Do Not Disturb schedule** — notification control
|
- Discord bot ecosystem compatibility
|
||||||
5. **Third-party integrations** — Twitch, YouTube, GitHub
|
- Server discovery / monetization / federation
|
||||||
6. **Stage channels** — presentation-style voice
|
- AutoMod / Flow Bots
|
||||||
|
|
||||||
### Nice-to-have differentiators
|
### Stale claims removed
|
||||||
|
- ~~"Direct Messages missing"~~ — DMs exist
|
||||||
1. **No-code Flow Bots** (Guilded-style automations)
|
- ~~"Mobile app is the only path"~~ — PWA is the target client
|
||||||
2. **Server discovery / directory**
|
|
||||||
3. **Activities / embedded games**
|
|
||||||
4. **Federation** (Fluxer-style)
|
|
||||||
5. **Server analytics**
|
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ build-tui:
|
|||||||
build-tauri:
|
build-tauri:
|
||||||
cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm
|
cd web && NODE_ENV=development NO_STRIP=true npx tauri build --bundles appimage,deb,rpm
|
||||||
|
|
||||||
|
# Build signed Android APK
|
||||||
|
build-android:
|
||||||
|
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --apk
|
||||||
|
@echo "APK: web/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
|
||||||
|
|
||||||
|
# Build signed Android AAB (Google Play bundle)
|
||||||
|
build-android-aab:
|
||||||
|
cd web && ANDROID_HOME=$(HOME)/Android/Sdk NDK_HOME=$(HOME)/Android/Sdk/ndk/27.2.12479018 JAVA_HOME=/usr/lib/jvm/java-17-openjdk npx tauri android build --aab
|
||||||
|
@echo "AAB: web/src-tauri/gen/android/app/build/outputs/bundle/universalRelease/app-universal-release.aab"
|
||||||
|
|
||||||
# Generate Swagger docs
|
# Generate Swagger docs
|
||||||
docs:
|
docs:
|
||||||
~/go/bin/swag init -g cmd/server/main.go -o docs
|
~/go/bin/swag init -g cmd/server/main.go -o docs
|
||||||
|
|||||||
@@ -58,10 +58,13 @@ For optional features, copy `.env.example` to `.env` and set Giphy, MinIO, LiveK
|
|||||||
- [x] Blocks (user-level blocking)
|
- [x] Blocks (user-level blocking)
|
||||||
- [x] Dark/light mode toggle
|
- [x] Dark/light mode toggle
|
||||||
- [x] Mobile-responsive layout (bottom nav, drawer)
|
- [x] Mobile-responsive layout (bottom nav, drawer)
|
||||||
- [x] Bot framework (token auth, CRUD, WebSocket gateway)
|
- [x] Bot framework (token auth, CRUD, WebSocket gateway, bot store)
|
||||||
|
- [x] Built-in bot runner (server-managed bots, no SSH needed)
|
||||||
|
- [x] Built-in bot: Steam Free Games (polls Steam API, auto-posts)
|
||||||
|
- [x] Bot message badges (green name + BOT tag in chat)
|
||||||
- [x] Slash commands (registration, autocomplete)
|
- [x] Slash commands (registration, autocomplete)
|
||||||
- [x] Incoming webhooks (create, execute)
|
- [x] Incoming webhooks (create, execute)
|
||||||
- [x] Example bots (modbot, welcome bot)
|
- [x] Example bots (modbot, welcome bot, steamfree)
|
||||||
- [x] TUI client (Bubbletea, vim-style, voice support)
|
- [x] TUI client (Bubbletea, vim-style, voice support)
|
||||||
- [x] Roles & permissions system
|
- [x] Roles & permissions system
|
||||||
- [x] Push notification backend (VAPID)
|
- [x] Push notification backend (VAPID)
|
||||||
@@ -97,7 +100,7 @@ dumpsterChat/
|
|||||||
│ ├── voice/ # LiveKit voice/video integration
|
│ ├── voice/ # LiveKit voice/video integration
|
||||||
│ ├── reaction/ # Message reactions
|
│ ├── reaction/ # Message reactions
|
||||||
│ ├── invite/ # Server invite links
|
│ ├── invite/ # Server invite links
|
||||||
│ ├── bot/ # Bot framework, auth, commands
|
│ ├── bot/ # Bot framework, auth, commands, runner
|
||||||
│ ├── webhook/ # Incoming webhooks
|
│ ├── webhook/ # Incoming webhooks
|
||||||
│ ├── dm/ # Direct messages (conversations)
|
│ ├── dm/ # Direct messages (conversations)
|
||||||
│ ├── push/ # Push notification sender (VAPID)
|
│ ├── push/ # Push notification sender (VAPID)
|
||||||
@@ -112,7 +115,8 @@ dumpsterChat/
|
|||||||
│ └── block/ # User blocking
|
│ └── block/ # User blocking
|
||||||
├── examples/ # Example bots
|
├── examples/ # Example bots
|
||||||
│ ├── modbot/ # Moderation bot
|
│ ├── modbot/ # Moderation bot
|
||||||
│ └── welcome/ # Welcome message bot
|
│ ├── welcome/ # Welcome message bot
|
||||||
|
│ └── steamfree/ # Steam free games bot (standalone)
|
||||||
├── web/ # React frontend (PWA)
|
├── web/ # React frontend (PWA)
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # Layout, ChatArea, LoginForm, UserSettings, GiphyPicker, VoiceChannel, VoicePanel, VoiceControls, TypingIndicator, ReactionBar, EmojiPicker, ReplyBar, MentionPopup, InviteModal, JoinServer, MobileNav, MobileDrawer, ThemeToggle, InstallPrompt, BotManager, CommandManager, SlashCommandPopup
|
│ │ ├── components/ # Layout, ChatArea, LoginForm, UserSettings, GiphyPicker, VoiceChannel, VoicePanel, VoiceControls, TypingIndicator, ReactionBar, EmojiPicker, ReplyBar, MentionPopup, InviteModal, JoinServer, MobileNav, MobileDrawer, ThemeToggle, InstallPrompt, BotManager, CommandManager, SlashCommandPopup
|
||||||
@@ -157,6 +161,30 @@ Users are prompted to enable notifications on login. On iOS, the prompt requires
|
|||||||
|
|
||||||
Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git)
|
Full documentation: [dumpsterChat wiki](ssh://git@git.dustin.coffee:2222/hobokenchicken/dumpsterChat.wiki.git)
|
||||||
|
|
||||||
|
## Production Deploy (SBC / 172.20.0.125)
|
||||||
|
|
||||||
|
App lives at `/opt/dumpsterChat`. systemd unit name is **`dumpster.service`** (not `dumpsterChat`).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build locally
|
||||||
|
CGO_ENABLED=0 go build -o dumpster-server ./cmd/server
|
||||||
|
(cd web && npm run build)
|
||||||
|
|
||||||
|
# Ship binary + web assets
|
||||||
|
scp dumpster-server root@172.20.0.125:/tmp/dumpster-server-new
|
||||||
|
rsync -av --delete web/dist/ root@172.20.0.125:/opt/dumpsterChat/web/dist/
|
||||||
|
|
||||||
|
ssh root@172.20.0.125 '
|
||||||
|
install -m 755 /tmp/dumpster-server-new /opt/dumpsterChat/dumpster-server
|
||||||
|
systemctl restart dumpster
|
||||||
|
systemctl is-active dumpster
|
||||||
|
'
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs: `journalctl -u dumpster -f`
|
||||||
|
Health: `curl -s http://127.0.0.1:8080/` (or your API health route)
|
||||||
|
Public: Caddy → `dumpster.dustin.coffee` → `172.20.0.125:8080`
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
AGPLv3
|
AGPLv3
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import (
|
|||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/webhook"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/go-chi/cors"
|
||||||
httpSwagger "github.com/swaggo/http-swagger"
|
httpSwagger "github.com/swaggo/http-swagger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ func main() {
|
|||||||
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
sessionStore := auth.NewSessionStore(database.DB, cfg)
|
||||||
|
|
||||||
// WebSocket origin allowlist
|
// WebSocket origin allowlist
|
||||||
wsOrigins := []string{"https://" + cfg.Host}
|
wsOrigins := []string{"https://" + cfg.Host, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"}
|
||||||
if cfg.Host == "localhost" {
|
if cfg.Host == "localhost" {
|
||||||
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
wsOrigins = append(wsOrigins, "http://localhost:"+cfg.Port)
|
||||||
}
|
}
|
||||||
@@ -80,6 +81,13 @@ func main() {
|
|||||||
hub := gateway.NewHub(database.DB, logger)
|
hub := gateway.NewHub(database.DB, logger)
|
||||||
go hub.Run()
|
go hub.Run()
|
||||||
|
|
||||||
|
// Built-in bot runner
|
||||||
|
botRunner := bot.NewRunner(database.DB, hub, logger)
|
||||||
|
botRunner.Register("steamfree", bot.SteamFreeBot)
|
||||||
|
botRunner.Register("confess", bot.ConfessBot)
|
||||||
|
botRunner.Register("leaderboard", bot.LeaderboardBot)
|
||||||
|
go botRunner.StartAll()
|
||||||
|
|
||||||
// Giphy client (nil if no API key)
|
// Giphy client (nil if no API key)
|
||||||
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
giphyClient := giphy.NewClient(cfg.Giphy.APIKey)
|
||||||
|
|
||||||
@@ -112,6 +120,16 @@ func main() {
|
|||||||
memberHandler := server.NewMemberHandler(database.DB)
|
memberHandler := server.NewMemberHandler(database.DB)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(cors.Handler(cors.Options{
|
||||||
|
AllowedOrigins: []string{"https://" + cfg.Host, "http://localhost:" + cfg.Port, "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost"},
|
||||||
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"},
|
||||||
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
|
||||||
|
ExposedHeaders: []string{"Link", "X-Session-Token"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 300,
|
||||||
|
}))
|
||||||
|
|
||||||
r.Use(chimw.Logger)
|
r.Use(chimw.Logger)
|
||||||
r.Use(chimw.Recoverer)
|
r.Use(chimw.Recoverer)
|
||||||
r.Use(chimw.RequestID)
|
r.Use(chimw.RequestID)
|
||||||
@@ -128,6 +146,11 @@ func main() {
|
|||||||
gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName)
|
gateway.ServeWS(database.DB, hub, logger, w, r, cfg.Session.CookieName)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Bot WebSocket endpoint (auth via ?token= query param)
|
||||||
|
r.Get("/ws/bot", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gateway.ServeBotWS(database.DB, hub, logger, w, r)
|
||||||
|
})
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
// Auth (public: register, login, logout) with strict rate limiting
|
// Auth (public: register, login, logout) with strict rate limiting
|
||||||
@@ -140,7 +163,10 @@ func main() {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.Session(sessionStore, cfg))
|
r.Use(middleware.Session(sessionStore, cfg))
|
||||||
r.Use(middleware.RequireAuth)
|
r.Use(middleware.RequireAuth)
|
||||||
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ",")))
|
r.Use(middleware.CSRFProtect(cfg.Host, cfg.Port, append(
|
||||||
|
strings.Split(os.Getenv("DUMPSTER_CSRF_ORIGINS"), ","),
|
||||||
|
"tauri://localhost", "http://tauri.localhost", "https://tauri.localhost",
|
||||||
|
)))
|
||||||
|
|
||||||
// Auth (protected: me, update profile)
|
// Auth (protected: me, update profile)
|
||||||
authHandler.RegisterProtectedRoutes(r)
|
authHandler.RegisterProtectedRoutes(r)
|
||||||
@@ -223,7 +249,9 @@ func main() {
|
|||||||
|
|
||||||
// Messages (under channels)
|
// Messages (under channels)
|
||||||
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
r.Route("/channels/{channelID}/messages", func(r chi.Router) {
|
||||||
message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker).RegisterRoutes(r)
|
msgHandler := message.NewHandler(database.DB, hub, pushHandler, logger, permissionsChecker)
|
||||||
|
msgHandler.SetConfessHandler(botRunner)
|
||||||
|
msgHandler.RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Polls
|
// Polls
|
||||||
@@ -243,6 +271,26 @@ func main() {
|
|||||||
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
notification.NewHandler(database.DB, logger).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calendar events
|
||||||
|
calHandler := channel.NewHandler(database.DB, permissionsChecker)
|
||||||
|
r.Route("/channels/{channelID}/events", func(r chi.Router) {
|
||||||
|
r.Get("/", calHandler.ListEvents)
|
||||||
|
r.Post("/", calHandler.CreateEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Threads (forum posts)
|
||||||
|
threadHandler := channel.NewHandler(database.DB, permissionsChecker)
|
||||||
|
r.Route("/channels/{channelID}/threads", func(r chi.Router) {
|
||||||
|
r.Get("/", threadHandler.ListThreads)
|
||||||
|
r.Post("/", threadHandler.CreateThread)
|
||||||
|
})
|
||||||
|
r.Patch("/threads/{threadID}", threadHandler.UpdateThread)
|
||||||
|
|
||||||
|
// Forum tags
|
||||||
|
r.Get("/channels/{channelID}/forum-tags", threadHandler.ListForumTags)
|
||||||
|
r.Post("/channels/{channelID}/forum-tags", threadHandler.CreateForumTag)
|
||||||
|
r.Delete("/forum-tags/{tagID}", threadHandler.DeleteForumTag)
|
||||||
|
|
||||||
// Push notifications
|
// Push notifications
|
||||||
pushHandler.RegisterRoutes(r)
|
pushHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
@@ -323,13 +371,9 @@ func main() {
|
|||||||
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
reaction.NewHandler(database.DB, hub).RegisterRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bots
|
// Bots + slash commands
|
||||||
r.Route("/bots", func(r chi.Router) {
|
r.Route("/bots", func(r chi.Router) {
|
||||||
bot.NewHandler(database.DB).RegisterRoutes(r)
|
bot.NewHandler(database.DB, botRunner).RegisterRoutes(r)
|
||||||
})
|
|
||||||
|
|
||||||
// Bot slash commands
|
|
||||||
r.Route("/bots/{botID}/commands", func(r chi.Router) {
|
|
||||||
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
bot.NewCommandHandler(database.DB).RegisterCommandRoutes(r)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -387,6 +431,13 @@ func main() {
|
|||||||
))
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Privacy policy page (served before SPA catch-all)
|
||||||
|
r.HandleFunc("/privacy", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
w.Write([]byte(privacyPage))
|
||||||
|
})
|
||||||
|
|
||||||
// Static file serving for production (SPA)
|
// Static file serving for production (SPA)
|
||||||
staticDir := "web/dist"
|
staticDir := "web/dist"
|
||||||
if _, err := os.Stat(staticDir); err == nil {
|
if _, err := os.Stat(staticDir); err == nil {
|
||||||
@@ -409,3 +460,104 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const privacyPage = `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Privacy Policy — dumpsterChat</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: #1d2021; color: #ebdbb2; line-height: 1.7; padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
main { max-width: 720px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 1.8rem; margin-bottom: 0.25rem; color: #fabd2f; }
|
||||||
|
.subtitle { color: #a89984; font-size: 0.85rem; margin-bottom: 2rem; }
|
||||||
|
h2 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; color: #83a598; }
|
||||||
|
p, li { margin-bottom: 0.6rem; }
|
||||||
|
ul { padding-left: 1.25rem; }
|
||||||
|
li { margin-bottom: 0.3rem; }
|
||||||
|
a { color: #8ec07c; }
|
||||||
|
.footer { margin-top: 2.5rem; padding-top: 1rem; border-top: 1px solid #3c3836; font-size: 0.8rem; color: #928374; }
|
||||||
|
.update { color: #928374; font-size: 0.8rem; margin-top: 1.5rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="subtitle"><strong>dumpsterChat</strong> — Last updated: July 17, 2026</p>
|
||||||
|
|
||||||
|
<h2>Overview</h2>
|
||||||
|
<p>dumpsterChat is a self-hosted messaging platform. This privacy policy describes how your data is handled when you use the app. Because dumpsterChat is <strong>self-hosted</strong>, your data is stored on the server instance you connect to, which is operated by the server owner — not by us.</p>
|
||||||
|
|
||||||
|
<h2>Data We Collect</h2>
|
||||||
|
<p>When you use dumpsterChat, the following data is stored on the server:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Account information:</strong> username, email address, avatar, and password hash (Argon2id, not reversible).</li>
|
||||||
|
<li><strong>Messages and content:</strong> text messages, reactions, uploaded files, voice activity metadata, and poll votes.</li>
|
||||||
|
<li><strong>Session data:</strong> login sessions stored in encrypted cookies.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>How We Use Your Data</h2>
|
||||||
|
<p>Your data is used solely to operate the chat platform:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Deliver messages and notifications to the intended recipients.</li>
|
||||||
|
<li>Sync read states and presence (online/offline) across your devices.</li>
|
||||||
|
<li>Provide moderation tools (kicks, bans, mutes) per server rules.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>No Third-Party Analytics</h2>
|
||||||
|
<p>dumpsterChat does <strong>not</strong> include any analytics SDKs, tracking pixels, or telemetry. No usage data is sent to us or to any third party for advertising, profiling, or analytics purposes.</p>
|
||||||
|
|
||||||
|
<h2>Third-Party Integrations</h2>
|
||||||
|
<p>If enabled by the server owner, optional integrations may be used:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Giphy:</strong> GIF search queries are proxied through the server to Giphy's API. No user data is shared with Giphy.</li>
|
||||||
|
<li><strong>LiveKit:</strong> Voice and video calls use a self-hosted LiveKit server. Media streams are processed in real time and are not recorded or stored by default.</li>
|
||||||
|
</ul>
|
||||||
|
<p>These integrations are optional and controlled entirely by the server owner.</p>
|
||||||
|
|
||||||
|
<h2>Data Retention</h2>
|
||||||
|
<p>Data is retained for as long as the server owner maintains the database. You can delete your messages or account at any time through the app. Server owners may also set message retention limits. Uploaded files persist until explicitly removed.</p>
|
||||||
|
|
||||||
|
<h2>Your Rights</h2>
|
||||||
|
<p>Depending on your jurisdiction, you may have the right to:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Request a copy of your stored data.</li>
|
||||||
|
<li>Delete your account and associated data.</li>
|
||||||
|
<li>Correct inaccurate personal information.</li>
|
||||||
|
</ul>
|
||||||
|
<p>To exercise these rights, contact the operator of the dumpsterChat instance you use, or use the account management tools available within the app.</p>
|
||||||
|
|
||||||
|
<h2>Account Deletion Requests</h2>
|
||||||
|
<p>To request deletion of your account and all associated data:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Use the <strong>Delete Account</strong> option in your account settings within the app.</li>
|
||||||
|
<li>Or email <a href="mailto:account-deletion@dustin.coffee">account-deletion@dustin.coffee</a> from the email address associated with your account.</li>
|
||||||
|
</ul>
|
||||||
|
<p>We will process your request within 30 days. Deletion removes your account, messages, uploaded files, and all personal data from the server.</p>
|
||||||
|
|
||||||
|
<h2>Security</h2>
|
||||||
|
<p>We take reasonable measures to protect your data:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Passwords are hashed with Argon2id.</li>
|
||||||
|
<li>Session tokens use httpOnly cookies.</li>
|
||||||
|
<li>All communications are encrypted over HTTPS and WSS where available.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Children's Privacy</h2>
|
||||||
|
<p>dumpsterChat is not directed at children under 13. We do not knowingly collect personal information from children.</p>
|
||||||
|
|
||||||
|
<h2>Changes to This Policy</h2>
|
||||||
|
<p>We may update this privacy policy from time to time. Changes will be posted at this URL. Continued use of the app after changes constitutes acceptance of the updated policy.</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>If you have questions about this privacy policy, contact the operator of the dumpsterChat instance you use, or open an issue on our project repository.</p>
|
||||||
|
|
||||||
|
<div class="update">This privacy policy applies to the dumpsterChat mobile app and web app. The specific data practices of each instance may vary based on the server operator's configuration.</div>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# Capacitor Android App — Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Wrap the existing dumpsterChat Vite/React PWA in a Capacitor shell and publish to Google Play.
|
||||||
|
|
||||||
|
**Architecture:** Capacitor loads the Vite build output as local assets in an Android WebView. `@capacitor/core` bridges native APIs (push, status bar, etc.). No UI rewrite — the web app IS the app.
|
||||||
|
|
||||||
|
**Tech Stack:** Vite, React 18, Capacitor 6, FCM (push), Gradle (Android build)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 1: Capacitor Init
|
||||||
|
|
||||||
|
#### Task 1: Add Capacitor dependencies
|
||||||
|
|
||||||
|
**Objective:** Install Capacitor core + CLI in the web project.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/package.json`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install @capacitor/core @capacitor/cli @capacitor/android
|
||||||
|
```
|
||||||
|
|
||||||
|
Then init Capacitor:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx cap init "Dumpster Chat" "coffee.dustin.dumpster" --web-dir dist
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates `capacitor.config.ts` at the web root.
|
||||||
|
|
||||||
|
**Verify:** `cat capacitor.config.ts` shows appId `coffee.dustin.dumpster`, webDir `dist`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 2: Configure capacitor.config.ts
|
||||||
|
|
||||||
|
**Objective:** Set server URL for dev, configure Android-specific settings.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/capacitor.config.ts`
|
||||||
|
|
||||||
|
**Content:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'coffee.dustin.dumpster',
|
||||||
|
appName: 'Dumpster Chat',
|
||||||
|
webDir: 'dist',
|
||||||
|
server: {
|
||||||
|
// ponytail: no server.url — serve local assets. API calls go to absolute URL from api.ts.
|
||||||
|
androidScheme: 'https', // cookies work over https scheme in WebView
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
PushNotifications: {
|
||||||
|
presentationOptions: ['badge', 'sound', 'alert'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key decisions:**
|
||||||
|
- `androidScheme: 'https'` makes `credentials: 'include'` cookies work in the WebView (http scheme blocks them).
|
||||||
|
- No `server.url` — local assets load from the APK, not from the web. Faster, works offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 3: Add Android platform
|
||||||
|
|
||||||
|
**Objective:** Generate the native Android project.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `web/android/` (generated by Capacitor)
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npx cap add android
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify:** `ls web/android/app/src/main/AndroidManifest.xml` exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: API Client Fix
|
||||||
|
|
||||||
|
#### Task 4: Update API base URL for native
|
||||||
|
|
||||||
|
**Objective:** When running in Capacitor, API calls need an absolute URL (no origin in a WebView). Keep relative paths for web/PWA.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/src/lib/api.ts`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
// ponytail: single switch. native = absolute URL, web = relative (Caddy same-origin).
|
||||||
|
const API_BASE = Capacitor.isNativePlatform()
|
||||||
|
? 'https://dumpster.dustin.coffee/api/v1'
|
||||||
|
: '/api/v1';
|
||||||
|
```
|
||||||
|
|
||||||
|
The rest of the file stays unchanged. `Capacitor.isNativePlatform()` returns `false` in browsers and `true` in the Android WebView.
|
||||||
|
|
||||||
|
**Skipped:** `@capacitor/http` plugin. Not needed — `androidScheme: 'https'` + absolute URL + `credentials: 'include'` works. Add the HTTP plugin only if cookies break.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Push Notifications (FCM)
|
||||||
|
|
||||||
|
This is the only non-trivial part. VAPID web push does not work in Android WebViews. Need FCM.
|
||||||
|
|
||||||
|
#### Task 5: Create Firebase project
|
||||||
|
|
||||||
|
**Objective:** Set up FCM credentials for native push.
|
||||||
|
|
||||||
|
**Steps (manual, one-time):**
|
||||||
|
1. Go to https://console.firebase.google.com
|
||||||
|
2. Create project (or use existing) named `dumpster-chat`
|
||||||
|
3. Add Android app with package name `coffee.dustin.dumpster`
|
||||||
|
4. Download `google-services.json` → place in `web/android/app/google-services.json`
|
||||||
|
5. In Firebase Console → Project Settings → Cloud Messaging → note the **Server Key** (legacy) or set up **Firebase Admin SDK** service account
|
||||||
|
|
||||||
|
**Verify:** `google-services.json` exists in `web/android/app/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 6: Add Capacitor Push Notifications plugin
|
||||||
|
|
||||||
|
**Objective:** Register for FCM token on Android, send it to the server.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/package.json` (install plugin)
|
||||||
|
- Modify: `web/src/stores/push.ts` (add native branch)
|
||||||
|
|
||||||
|
**Install:**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm install @capacitor/push-notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modify `push.ts`** — add a native registration path alongside the existing web push:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
// Existing web push subscribe stays as-is for PWA.
|
||||||
|
// Add native branch:
|
||||||
|
async function subscribeNative() {
|
||||||
|
const { PushNotifications } = await import('@capacitor/push-notifications');
|
||||||
|
|
||||||
|
const permStatus = await PushNotifications.requestPermissions();
|
||||||
|
if (permStatus.receive !== 'granted') return;
|
||||||
|
|
||||||
|
await PushNotifications.register();
|
||||||
|
|
||||||
|
// Server sends us the FCM token via this event
|
||||||
|
PushNotifications.addListener('registration', async (token) => {
|
||||||
|
await api.post('/push/subscribe', {
|
||||||
|
endpoint: 'fcm:' + token.value, // ponytail: prefix to distinguish from web push endpoints
|
||||||
|
keys: { p256dh: '', auth: '' }, // not used for FCM, but server expects the shape
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
PushNotifications.addListener('pushNotificationReceived', (notification) => {
|
||||||
|
// Foreground notification — show in-app toast or badge
|
||||||
|
// ponytail: handled by existing in-app notification system
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the existing `subscribe()` function, branch:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (Capacitor.isNativePlatform()) {
|
||||||
|
return subscribeNative();
|
||||||
|
}
|
||||||
|
// ... existing web push logic
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 7: Server-side FCM send support
|
||||||
|
|
||||||
|
**Objective:** When a push subscription's endpoint starts with `fcm:`, send via FCM HTTP v1 API instead of VAPID.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/push/handlers.go`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
1. In `Subscribe()`: detect `fcm:` prefix on endpoint, store differently (or store as-is, the prefix distinguishes it).
|
||||||
|
2. In the send functions (`Send`, `SendToUser`): check if subscription endpoint starts with `fcm:` → use Firebase Admin SDK to send.
|
||||||
|
|
||||||
|
**Install Go Firebase Admin:**
|
||||||
|
```bash
|
||||||
|
go get firebase.google.com/go/v4
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern:**
|
||||||
|
```go
|
||||||
|
// ponytail: one if/else in the send loop. endpoint prefix = routing key.
|
||||||
|
if strings.HasPrefix(sub.Endpoint, "fcm:") {
|
||||||
|
token := strings.TrimPrefix(sub.Endpoint, "fcm:")
|
||||||
|
msg := &messaging.Message{
|
||||||
|
Token: token,
|
||||||
|
Notification: &messaging.Notification{
|
||||||
|
Title: title,
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
Data: map[string]string{"url": url},
|
||||||
|
}
|
||||||
|
_, err = fcmClient.Send(ctx, msg)
|
||||||
|
} else {
|
||||||
|
// existing VAPID webpush send
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config:** Add `FIREBASE_CREDENTIALS_FILE` env var (path to service account JSON) to the systemd unit / Docker compose.
|
||||||
|
|
||||||
|
**Skipped:** Topic-based broadcast. Per-device tokens is fine for now. Add topics when channel count grows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Gradle / Build Config
|
||||||
|
|
||||||
|
#### Task 8: Configure Android build
|
||||||
|
|
||||||
|
**Objective:** Set minimum SDK, app icon, theme.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/android/app/build.gradle`
|
||||||
|
- Modify: `web/android/app/src/main/res/values/strings.xml`
|
||||||
|
|
||||||
|
**Changes in `build.gradle`:**
|
||||||
|
```gradle
|
||||||
|
minSdkVersion = 24 // ponytail: Android 7+ covers 99% of Play Store. lower = more compat bugs.
|
||||||
|
```
|
||||||
|
|
||||||
|
**App name in `strings.xml`:**
|
||||||
|
```xml
|
||||||
|
<string name="app_name">Dumpster Chat</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
**App icon:** Copy existing PWA icons into Android mipmap directories:
|
||||||
|
```bash
|
||||||
|
# Capacitor can sync icons automatically if placed at web/public/icon.png (1024x1024)
|
||||||
|
# or manually: web/android/app/src/main/res/mipmap-*/
|
||||||
|
npx cap assets generate # if a 1024x1024 source icon exists
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: Build & Publish
|
||||||
|
|
||||||
|
#### Task 9: Sync and build debug APK
|
||||||
|
|
||||||
|
**Objective:** Verify the app runs on a real device or emulator.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm run build # builds Vite → dist/
|
||||||
|
npx cap sync android # copies dist/ into android/assets, syncs plugins
|
||||||
|
cd android
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** `web/android/app/build/outputs/apk/debug/app-debug.apk`
|
||||||
|
|
||||||
|
**Verify:** Install on Android device:
|
||||||
|
```bash
|
||||||
|
adb install app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 10: Build signed release AAB for Play Store
|
||||||
|
|
||||||
|
**Objective:** Create a signed Android App Bundle (.aab) for Google Play upload.
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. Generate keystore (one-time):
|
||||||
|
```bash
|
||||||
|
keytool -genkey -v -keystore dumpster-release.jks -keyalg RSA -keysize 2048 -validity 10000 -alias dumpster
|
||||||
|
```
|
||||||
|
Store `dumpster-release.jks` securely. Back it up. Lose it = can't update the app.
|
||||||
|
|
||||||
|
2. Add signing config to `web/android/app/build.gradle`:
|
||||||
|
```gradle
|
||||||
|
android {
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file('dumpster-release.jks')
|
||||||
|
storePassword System.getenv('KEYSTORE_PASSWORD')
|
||||||
|
keyAlias 'dumpster'
|
||||||
|
keyPassword System.getenv('KEY_PASSWORD')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
minifyEnabled true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Build AAB:
|
||||||
|
```bash
|
||||||
|
cd web/android
|
||||||
|
KEYSTORE_PASSWORD=xxx KEY_PASSWORD=xxx ./gradlew bundleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** `web/android/app/build/outputs/bundle/release/app-release.aab`
|
||||||
|
|
||||||
|
4. Upload to Google Play Console → your new developer account → Create app → Upload AAB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Task 11: Clean up Tauri dependencies
|
||||||
|
|
||||||
|
**Objective:** Remove unused Tauri packages (pivoted away from Tauri).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `web/package.json`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
npm uninstall @tauri-apps/api @tauri-apps/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
| Phase | What | Time estimate |
|
||||||
|
|-------|------|---------------|
|
||||||
|
| 1 | Capacitor init + Android platform | 10 min |
|
||||||
|
| 2 | API base URL native branch | 5 min |
|
||||||
|
| 3 | FCM push (plugin + server) | 1-2 hrs (incl. Firebase setup) |
|
||||||
|
| 4 | Gradle config / icons | 15 min |
|
||||||
|
| 5 | Build, test, publish | 30 min |
|
||||||
|
|
||||||
|
**Total:** ~2-3 hours end-to-end. Phase 3 is the only real work.
|
||||||
|
|
||||||
|
**Dependencies between tasks:**
|
||||||
|
- Tasks 1-3 sequential (Capacitor init)
|
||||||
|
- Task 4 independent of 5-7
|
||||||
|
- Tasks 5-7 sequential (FCM chain)
|
||||||
|
- Task 8 depends on 1-3
|
||||||
|
- Task 9 depends on all above
|
||||||
|
- Task 10 depends on 9
|
||||||
|
- Task 11 independent, do anytime
|
||||||
|
|
||||||
|
**After this plan:** Update the Makefile `build` target to include `npm run build && npx cap sync android` so deploys sync native assets too.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- config from env ----
|
||||||
|
|
||||||
|
var (
|
||||||
|
botToken string
|
||||||
|
host string
|
||||||
|
channelID string
|
||||||
|
pollMinutes int
|
||||||
|
seen = map[string]bool{} // appid -> posted
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- dumpsterChat WS protocol ----
|
||||||
|
|
||||||
|
type Event struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Payload json.RawMessage `json:"payload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Steam API response ----
|
||||||
|
|
||||||
|
type FeaturedCategories struct {
|
||||||
|
Specials FeaturedList `json:"specials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeaturedList struct {
|
||||||
|
Items []FeaturedItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FeaturedItem struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"` // in cents
|
||||||
|
OriginalPrice int `json:"original_price"` // in cents
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
botToken = os.Getenv("BOT_TOKEN")
|
||||||
|
if botToken == "" {
|
||||||
|
log.Fatal("BOT_TOKEN required (create a bot in the dumpsterChat store)")
|
||||||
|
}
|
||||||
|
host = os.Getenv("DUMPSTER_HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "localhost:8080"
|
||||||
|
}
|
||||||
|
channelID = os.Getenv("CHANNEL_ID")
|
||||||
|
if channelID == "" {
|
||||||
|
log.Fatal("CHANNEL_ID required (the channel to post free games to)")
|
||||||
|
}
|
||||||
|
pollMinutes = 30
|
||||||
|
if m := os.Getenv("POLL_MINUTES"); m != "" {
|
||||||
|
if v, err := strconv.Atoi(m); err == nil && v > 0 {
|
||||||
|
pollMinutes = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to dumpsterChat
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: "ws",
|
||||||
|
Host: host,
|
||||||
|
Path: "/ws/bot",
|
||||||
|
RawQuery: "token=" + botToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("SteamFree bot connecting to %s", u.String())
|
||||||
|
|
||||||
|
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("dial:", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
log.Println("SteamFree bot connected!")
|
||||||
|
|
||||||
|
// First poll immediately, then on ticker
|
||||||
|
poll(c)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(pollMinutes) * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Keep connection alive by reading (we don't need to react to events)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
_, _, err := c.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("ws read:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
poll(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func poll(c *websocket.Conn) {
|
||||||
|
log.Println("Polling Steam for free games...")
|
||||||
|
|
||||||
|
games, err := fetchFreeGames()
|
||||||
|
if err != nil {
|
||||||
|
log.Println("fetch error:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newCount := 0
|
||||||
|
for _, g := range games {
|
||||||
|
if seen[strconv.Itoa(g.ID)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[strconv.Itoa(g.ID)] = true
|
||||||
|
newCount++
|
||||||
|
|
||||||
|
msg := formatGame(g)
|
||||||
|
sendMessage(c, channelID, msg)
|
||||||
|
log.Printf("Posted: %s (was $%.2f, now FREE)", g.Name, float64(g.OriginalPrice)/100)
|
||||||
|
time.Sleep(500 * time.Millisecond) // be polite to the WS
|
||||||
|
}
|
||||||
|
|
||||||
|
if newCount == 0 {
|
||||||
|
log.Println("No new free games found")
|
||||||
|
} else {
|
||||||
|
log.Printf("Posted %d new free games", newCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFreeGames() ([]FeaturedItem, error) {
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("steam api: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("steam api: status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cats FeaturedCategories
|
||||||
|
if err := json.Unmarshal(body, &cats); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: 100% discount and actually had a price (not permanent free-to-play)
|
||||||
|
var free []FeaturedItem
|
||||||
|
for _, item := range cats.Specials.Items {
|
||||||
|
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||||
|
free = append(free, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return free, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatGame(g FeaturedItem) string {
|
||||||
|
storeURL := fmt.Sprintf("https://store.steampowered.com/app/%d", g.ID)
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
fmt.Fprintf(&b, "🎮 **FREE ON STEAM** 🎮\n")
|
||||||
|
fmt.Fprintf(&b, "**%s**\n", g.Name)
|
||||||
|
fmt.Fprintf(&b, "~~$%.2f~~ → **FREE**\n", float64(g.OriginalPrice)/100)
|
||||||
|
fmt.Fprintf(&b, "%s", storeURL)
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(c *websocket.Conn, channelID, content string) {
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"type": "SEND_MESSAGE",
|
||||||
|
"payload": map[string]string{
|
||||||
|
"channel_id": channelID,
|
||||||
|
"content": content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(msg)
|
||||||
|
c.WriteMessage(websocket.TextMessage, data)
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ require (
|
|||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||||
github.com/gammazero/deque v1.2.1 // indirect
|
github.com/gammazero/deque v1.2.1 // indirect
|
||||||
|
github.com/go-chi/cors v1.2.2 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ
|
|||||||
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g=
|
||||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||||
|
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ func SetSessionCookie(w http.ResponseWriter, cookieName, token string, duration
|
|||||||
MaxAge: int(duration.Seconds()),
|
MaxAge: int(duration.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ func (h *Handler) RegisterProtectedRoutes(r chi.Router) {
|
|||||||
r.Get("/auth/me", h.Me)
|
r.Get("/auth/me", h.Me)
|
||||||
r.Patch("/auth/me", h.UpdateProfile)
|
r.Patch("/auth/me", h.UpdateProfile)
|
||||||
r.Put("/auth/me/password", h.ChangePassword)
|
r.Put("/auth/me/password", h.ChangePassword)
|
||||||
|
r.Delete("/auth/me", h.DeleteAccount)
|
||||||
r.Get("/users/{userID}/profile", h.GetPublicProfile)
|
r.Get("/users/{userID}/profile", h.GetPublicProfile)
|
||||||
r.Get("/users/me/blocks", h.ListBlocks)
|
r.Get("/users/me/blocks", h.ListBlocks)
|
||||||
r.Post("/users/me/blocks", h.BlockUser)
|
r.Post("/users/me/blocks", h.BlockUser)
|
||||||
@@ -331,6 +332,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.setSessionCookie(w, token)
|
h.setSessionCookie(w, token)
|
||||||
|
w.Header().Set("X-Session-Token", token)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||||
}
|
}
|
||||||
@@ -383,6 +385,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.setSessionCookie(w, token)
|
h.setSessionCookie(w, token)
|
||||||
|
w.Header().Set("X-Session-Token", token)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
json.NewEncoder(w).Encode(map[string]string{"id": userID})
|
||||||
}
|
}
|
||||||
@@ -660,3 +663,32 @@ func (h *Handler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
|
json.NewEncoder(w).Encode(map[string]string{"message": "password updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteAccount deletes the authenticated user and all associated data.
|
||||||
|
// ponytail: cascading FK handles all DB cleanup — push subscriptions cleaned manually.
|
||||||
|
func (h *Handler) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up push subscriptions
|
||||||
|
h.db.ExecContext(r.Context(), `DELETE FROM push_subscriptions WHERE user_id = $1`, userID)
|
||||||
|
|
||||||
|
// ON DELETE CASCADE handles everything else: sessions, tokens, messages,
|
||||||
|
// reactions, memberships, invites, bots, etc.
|
||||||
|
result, err := h.db.ExecContext(r.Context(), `DELETE FROM users WHERE id = $1`, userID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, `{"error":"failed to delete account"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
http.Error(w, `{"error":"user not found"}`, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"message": "account deleted"})
|
||||||
|
}
|
||||||
|
|||||||
@@ -234,7 +234,8 @@ func (h *WebAuthnHandler) LoginBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
MaxAge: 300, // 5 minutes
|
MaxAge: 300, // 5 minutes
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@@ -341,9 +342,11 @@ func (h *WebAuthnHandler) LoginFinish(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
SameSite: http.SameSiteStrictMode,
|
SameSite: http.SameSiteNoneMode,
|
||||||
|
Secure: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
w.Header().Set("X-Session-Token", token)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "authenticated"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfessConfig: "channel_id" is where anonymous confessions land.
|
||||||
|
type ConfessConfig struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConfessContent returns the confession body if content is a /confess command.
|
||||||
|
func parseConfessContent(content string) (string, bool) {
|
||||||
|
trimmed := strings.TrimSpace(content)
|
||||||
|
if len(trimmed) < 8 || !strings.EqualFold(trimmed[:8], "/confess") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Require word boundary after the command (space, end, or more text after optional space).
|
||||||
|
rest := strings.TrimSpace(trimmed[8:])
|
||||||
|
if rest == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfessBot is a safety-net poller for any /confess that slipped past intercept.
|
||||||
|
// Primary path is Runner.TryConfess at message create (no original ever stored).
|
||||||
|
func ConfessBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||||
|
// Polling path is intentionally inert for normal operation when intercept works.
|
||||||
|
// Keep a lightweight no-op loop so the runner lifecycle stays consistent.
|
||||||
|
// Real cleanup of any leaked /confess rows is handled if deleteMsg is wired later.
|
||||||
|
_ = db
|
||||||
|
_ = raw
|
||||||
|
_ = send
|
||||||
|
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
// no-op: intercept handles live confessions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// confessFormat is shared with TryConfess.
|
||||||
|
func confessFormat(text string) string {
|
||||||
|
return fmt.Sprintf("🕵️ **anonymous confession:** %s", text)
|
||||||
|
}
|
||||||
@@ -13,15 +13,18 @@ import (
|
|||||||
// Handler handles bot CRUD and server-assignment routes.
|
// Handler handles bot CRUD and server-assignment routes.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
|
runner *Runner
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new bot Handler.
|
// NewHandler creates a new bot Handler.
|
||||||
func NewHandler(db *sql.DB) *Handler {
|
func NewHandler(db *sql.DB, runner *Runner) *Handler {
|
||||||
return &Handler{db: db}
|
return &Handler{db: db, runner: runner}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterRoutes registers authenticated bot routes under the given router.
|
// RegisterRoutes registers authenticated bot routes under the given router.
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
|
r.Get("/store", h.Store)
|
||||||
|
r.Get("/types", h.ListTypes)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Get("/{botID}", h.Get)
|
r.Get("/{botID}", h.Get)
|
||||||
@@ -39,6 +42,8 @@ type botResponse struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
BotType string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
OwnerID string `json:"owner_id"`
|
OwnerID string `json:"owner_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
@@ -52,12 +57,16 @@ type botWithToken struct {
|
|||||||
type createBotRequest struct {
|
type createBotRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
BotType string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type updateBotRequest struct {
|
type updateBotRequest struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Avatar *string `json:"avatar"`
|
Avatar *string `json:"avatar"`
|
||||||
|
BotType *string `json:"bot_type"`
|
||||||
|
Config json.RawMessage `json:"config"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type addToServerRequest struct {
|
type addToServerRequest struct {
|
||||||
@@ -109,15 +118,21 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
token := GenerateToken()
|
token := GenerateToken()
|
||||||
tokenHash := HashToken(token)
|
tokenHash := HashToken(token)
|
||||||
|
|
||||||
|
configJSON := req.Config
|
||||||
|
if configJSON == nil {
|
||||||
|
configJSON = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
var bot botWithToken
|
var bot botWithToken
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err := h.db.QueryRowContext(r.Context(), `
|
err := h.db.QueryRowContext(r.Context(), `
|
||||||
INSERT INTO bots (name, description, owner_id, token)
|
INSERT INTO bots (name, description, owner_id, token, bot_type, config)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, userID, tokenHash).Scan(
|
`, req.Name, req.Description, userID, tokenHash, req.BotType, string(configJSON)).Scan(
|
||||||
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.OwnerID, &createdAt,
|
&bot.ID, &bot.Name, &avatar, &bot.Description, &bot.BotType, &configOut, &bot.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
writeErr(w, http.StatusInternalServerError, "failed to create bot")
|
||||||
@@ -126,9 +141,17 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
if avatar.Valid {
|
if avatar.Valid {
|
||||||
bot.Avatar = &avatar.String
|
bot.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
bot.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
bot.CreatedAt = createdAt.String
|
bot.CreatedAt = createdAt.String
|
||||||
bot.Token = token
|
bot.Token = token
|
||||||
|
|
||||||
|
// Start built-in bot if type is set
|
||||||
|
if req.BotType != "" && h.runner != nil {
|
||||||
|
h.runner.Start(bot.ID, req.BotType, bot.Config)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusCreated, bot)
|
writeJSON(w, http.StatusCreated, bot)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,15 +304,18 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
var b botResponse
|
var b botResponse
|
||||||
var avatar sql.NullString
|
var avatar sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
|
var configOut sql.NullString
|
||||||
err = h.db.QueryRowContext(r.Context(), `
|
err = h.db.QueryRowContext(r.Context(), `
|
||||||
UPDATE bots
|
UPDATE bots
|
||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
description = COALESCE($2, description),
|
description = COALESCE($2, description),
|
||||||
avatar = COALESCE($3, avatar)
|
avatar = COALESCE($3, avatar),
|
||||||
|
bot_type = COALESCE($5, bot_type),
|
||||||
|
config = COALESCE($6::jsonb, config)
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
RETURNING id, name, avatar, description, owner_id, created_at::text
|
RETURNING id, name, avatar, description, bot_type, config::text, owner_id, created_at::text
|
||||||
`, req.Name, req.Description, req.Avatar, botID).Scan(
|
`, req.Name, req.Description, req.Avatar, botID, req.BotType, string(req.Config)).Scan(
|
||||||
&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt,
|
&b.ID, &b.Name, &avatar, &b.Description, &b.BotType, &configOut, &b.OwnerID, &createdAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
writeErr(w, http.StatusInternalServerError, "failed to update bot")
|
||||||
@@ -298,8 +324,22 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
|||||||
if avatar.Valid {
|
if avatar.Valid {
|
||||||
b.Avatar = &avatar.String
|
b.Avatar = &avatar.String
|
||||||
}
|
}
|
||||||
|
if configOut.Valid {
|
||||||
|
b.Config = json.RawMessage(configOut.String)
|
||||||
|
}
|
||||||
b.CreatedAt = createdAt.String
|
b.CreatedAt = createdAt.String
|
||||||
|
|
||||||
|
// Restart built-in bot if type/config changed
|
||||||
|
if req.BotType != nil && h.runner != nil {
|
||||||
|
h.runner.Stop(b.ID)
|
||||||
|
if *req.BotType != "" {
|
||||||
|
h.runner.Start(b.ID, *req.BotType, b.Config)
|
||||||
|
}
|
||||||
|
} else if req.Config != nil && h.runner != nil && b.BotType != "" {
|
||||||
|
h.runner.Stop(b.ID)
|
||||||
|
h.runner.Start(b.ID, b.BotType, b.Config)
|
||||||
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, b)
|
writeJSON(w, http.StatusOK, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +385,11 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop built-in bot if running
|
||||||
|
if h.runner != nil {
|
||||||
|
h.runner.Stop(botID)
|
||||||
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,3 +592,64 @@ func (h *Handler) RegenerateToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
writeJSON(w, http.StatusOK, b)
|
writeJSON(w, http.StatusOK, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Store (public listing) ----
|
||||||
|
|
||||||
|
type storeBotResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Avatar *string `json:"avatar"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
OwnerID string `json:"owner_id"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store returns all bots with their server count (visible to any authenticated user).
|
||||||
|
func (h *Handler) Store(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at::text,
|
||||||
|
COUNT(bs.server_id) AS server_count
|
||||||
|
FROM bots b
|
||||||
|
LEFT JOIN bot_servers bs ON bs.bot_id = b.id
|
||||||
|
GROUP BY b.id, b.name, b.avatar, b.description, b.owner_id, b.created_at
|
||||||
|
ORDER BY server_count DESC, b.name
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
bots := make([]storeBotResponse, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var b storeBotResponse
|
||||||
|
var avatar sql.NullString
|
||||||
|
var createdAt sql.NullString
|
||||||
|
if err := rows.Scan(&b.ID, &b.Name, &avatar, &b.Description, &b.OwnerID, &createdAt, &b.ServerCount); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if avatar.Valid {
|
||||||
|
b.Avatar = &avatar.String
|
||||||
|
}
|
||||||
|
b.CreatedAt = createdAt.String
|
||||||
|
bots = append(bots, b)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
writeErr(w, http.StatusInternalServerError, "server error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, bots)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTypes returns available built-in bot types.
|
||||||
|
func (h *Handler) ListTypes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.runner == nil {
|
||||||
|
writeJSON(w, http.StatusOK, []string{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.runner.RegisteredTypes())
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LeaderboardConfig: "channel_id" is where the daily recap lands.
|
||||||
|
type LeaderboardConfig struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LeaderboardBot posts a daily shitpost recap.
|
||||||
|
func LeaderboardBot(ctx context.Context, db *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||||
|
var cfg LeaderboardConfig
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(24 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Post immediately on start, then daily
|
||||||
|
postLeaderboard(db, cfg.ChannelID, send)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
postLeaderboard(db, cfg.ChannelID, send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func postLeaderboard(db *sql.DB, channelID string, send SendMessageFunc) {
|
||||||
|
// ponytail: global lock on count. per-user aggregates if throughput matters.
|
||||||
|
since := time.Now().Add(-24 * time.Hour)
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(context.Background(), `
|
||||||
|
SELECT u.username, COUNT(*) AS msg_count
|
||||||
|
FROM messages m
|
||||||
|
JOIN users u ON m.author_id = u.id
|
||||||
|
WHERE m.created_at > $1 AND m.bot_id IS NULL
|
||||||
|
GROUP BY u.username
|
||||||
|
ORDER BY msg_count DESC
|
||||||
|
LIMIT 10
|
||||||
|
`, since)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("🏆 **24H SHITPOST LEADERBOARD** 🏆\n")
|
||||||
|
|
||||||
|
rank := 1
|
||||||
|
for rows.Next() {
|
||||||
|
var username string
|
||||||
|
var count int
|
||||||
|
if err := rows.Scan(&username, &count); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
medal := ""
|
||||||
|
switch rank {
|
||||||
|
case 1:
|
||||||
|
medal = "🥇"
|
||||||
|
case 2:
|
||||||
|
medal = "🥈"
|
||||||
|
case 3:
|
||||||
|
medal = "🥉"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s #%d **%s** — %d msgs\n", medal, rank, username, count)
|
||||||
|
rank++
|
||||||
|
}
|
||||||
|
if rank == 1 {
|
||||||
|
b.WriteString("*crickets*\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
send(channelID, strings.TrimSpace(b.String()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BotFunc is the signature for a built-in bot type's run function.
|
||||||
|
// It blocks until ctx is cancelled. Use send to post messages, db to read.
|
||||||
|
type BotFunc func(ctx context.Context, db *sql.DB, config json.RawMessage, send SendMessageFunc)
|
||||||
|
|
||||||
|
// SendMessageFunc posts a message to a channel as this bot.
|
||||||
|
type SendMessageFunc func(channelID, content string)
|
||||||
|
|
||||||
|
// Runner manages server-side bot goroutines.
|
||||||
|
type Runner struct {
|
||||||
|
db *sql.DB
|
||||||
|
hub *gateway.Hub
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
bots map[string]context.CancelFunc // botID -> cancel
|
||||||
|
types map[string]BotFunc // type name -> runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRunner(db *sql.DB, hub *gateway.Hub, logger *slog.Logger) *Runner {
|
||||||
|
return &Runner{
|
||||||
|
db: db,
|
||||||
|
hub: hub,
|
||||||
|
logger: logger,
|
||||||
|
bots: make(map[string]context.CancelFunc),
|
||||||
|
types: make(map[string]BotFunc),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a built-in bot type.
|
||||||
|
func (r *Runner) Register(name string, fn BotFunc) {
|
||||||
|
r.types[name] = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartAll loads all bots with a bot_type set and starts them.
|
||||||
|
func (r *Runner) StartAll() {
|
||||||
|
rows, err := r.db.QueryContext(context.Background(),
|
||||||
|
`SELECT id, bot_type, config::text FROM bots WHERE bot_type != ''`)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("failed to load bot configs", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var id, botType, configStr string
|
||||||
|
if err := rows.Scan(&id, &botType, &configStr); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.Start(id, botType, json.RawMessage(configStr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start starts a single bot by ID. Safe to call multiple times (restarts).
|
||||||
|
func (r *Runner) Start(botID, botType string, config json.RawMessage) {
|
||||||
|
r.mu.Lock()
|
||||||
|
// Stop existing if running
|
||||||
|
if cancel, ok := r.bots[botID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(r.bots, botID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn, ok := r.types[botType]
|
||||||
|
if !ok {
|
||||||
|
r.mu.Unlock()
|
||||||
|
r.logger.Warn("unknown bot type", "type", botType, "bot_id", botID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
r.bots[botID] = cancel
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
// Built-in bots pick a channel in config but users often skip "Add to Server".
|
||||||
|
// Resolve channel_id → server and ensure bot_servers so send() doesn't no-op.
|
||||||
|
r.ensureBotServerFromConfig(botID, config)
|
||||||
|
|
||||||
|
send := r.makeSender(botID)
|
||||||
|
r.logger.Info("starting built-in bot", "bot_id", botID, "type", botType)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
r.logger.Error("bot panic", "bot_id", botID, "type", botType, "panic", rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn(ctx, r.db, config, send)
|
||||||
|
r.logger.Info("bot stopped", "bot_id", botID, "type", botType)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureBotServerFromConfig joins the bot to the server that owns config.channel_id.
|
||||||
|
func (r *Runner) ensureBotServerFromConfig(botID string, config json.RawMessage) {
|
||||||
|
var cfg struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(config, &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := r.hub.ServerIDForChannel(context.Background(), cfg.ChannelID)
|
||||||
|
if err != nil || serverID == "" {
|
||||||
|
r.logger.Warn("bot start: channel not found for auto-join", "bot_id", botID, "channel_id", cfg.ChannelID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ownerID string
|
||||||
|
if err := r.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT owner_id FROM bots WHERE id = $1`, botID,
|
||||||
|
).Scan(&ownerID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := r.db.ExecContext(context.Background(), `
|
||||||
|
INSERT INTO bot_servers (bot_id, server_id, added_by)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
`, botID, serverID, ownerID); err != nil {
|
||||||
|
r.logger.Warn("bot start: auto-join server failed", "bot_id", botID, "server_id", serverID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.logger.Info("bot auto-joined server", "bot_id", botID, "server_id", serverID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stops a single bot by ID.
|
||||||
|
func (r *Runner) Stop(botID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if cancel, ok := r.bots[botID]; ok {
|
||||||
|
cancel()
|
||||||
|
delete(r.bots, botID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning checks if a bot is currently running.
|
||||||
|
func (r *Runner) IsRunning(botID string) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, ok := r.bots[botID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeSender returns a SendMessageFunc that inserts into DB and broadcasts.
|
||||||
|
func (r *Runner) makeSender(botID string) SendMessageFunc {
|
||||||
|
return func(channelID, content string) {
|
||||||
|
if len(content) > 4000 {
|
||||||
|
content = content[:4000]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up bot info + server
|
||||||
|
var botName, ownerID string
|
||||||
|
err := r.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT name, owner_id FROM bots WHERE id = $1`, botID,
|
||||||
|
).Scan(&botName, &ownerID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("send: bot not found", "bot_id", botID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("send: channel not found", "channel_id", channelID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bot is in this server
|
||||||
|
var inServer bool
|
||||||
|
err = r.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||||
|
botID, serverID,
|
||||||
|
).Scan(&inServer)
|
||||||
|
if err != nil || !inServer {
|
||||||
|
r.logger.Warn("send: bot not in server", "bot_id", botID, "server_id", serverID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgID, createdAt string
|
||||||
|
err = r.db.QueryRowContext(context.Background(),
|
||||||
|
`INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at::text`,
|
||||||
|
channelID, ownerID, content, botID,
|
||||||
|
).Scan(&msgID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("send: insert failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hub.BroadcastToServer(serverID, gateway.Event{
|
||||||
|
Type: gateway.EventMessageCreate,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
"author_id": ownerID,
|
||||||
|
"author_username": botName,
|
||||||
|
"author_display_name": nil,
|
||||||
|
"author_bot": true,
|
||||||
|
"bot_id": botID,
|
||||||
|
"bot_name": botName,
|
||||||
|
"content": content,
|
||||||
|
"reply_to": nil,
|
||||||
|
"edited_at": nil,
|
||||||
|
"pinned": false,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"embeds": []interface{}{},
|
||||||
|
"reactions": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisteredTypes returns the list of available bot type names.
|
||||||
|
func (r *Runner) RegisteredTypes() []string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
names := make([]string, 0, len(r.types))
|
||||||
|
for k := range r.types {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// TryConfess intercepts "/confess …" at message create time.
|
||||||
|
// On success the original is never stored or broadcast (true anonymity).
|
||||||
|
// Returns the anonymous bot message payload for the HTTP response when handled.
|
||||||
|
func (r *Runner) TryConfess(ctx context.Context, serverID, _authorID, content string) (map[string]interface{}, bool) {
|
||||||
|
text, ok := parseConfessContent(content)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var botID, botName, ownerID string
|
||||||
|
var rawConfig string
|
||||||
|
err := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT b.id, b.name, b.owner_id, COALESCE(b.config::text, '{}')
|
||||||
|
FROM bots b
|
||||||
|
JOIN bot_servers bs ON bs.bot_id = b.id
|
||||||
|
WHERE bs.server_id = $1 AND b.bot_type = 'confess'
|
||||||
|
LIMIT 1
|
||||||
|
`, serverID).Scan(&botID, &botName, &ownerID, &rawConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg ConfessConfig
|
||||||
|
if err := json.Unmarshal([]byte(rawConfig), &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bot can post to the confession channel's server.
|
||||||
|
r.ensureBotServerFromConfig(botID, json.RawMessage(rawConfig))
|
||||||
|
|
||||||
|
anonContent := fmt.Sprintf("🕵️ **anonymous confession:** %s", text)
|
||||||
|
if len(anonContent) > 4000 {
|
||||||
|
anonContent = anonContent[:4000]
|
||||||
|
}
|
||||||
|
|
||||||
|
var msgID, createdAt string
|
||||||
|
err = r.db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at::text
|
||||||
|
`, cfg.ChannelID, ownerID, anonContent, botID).Scan(&msgID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("confess: insert failed", "error", err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
targetServerID, err := r.hub.ServerIDForChannel(ctx, cfg.ChannelID)
|
||||||
|
if err != nil || targetServerID == "" {
|
||||||
|
targetServerID = serverID
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": cfg.ChannelID,
|
||||||
|
"author_id": ownerID,
|
||||||
|
"author_username": botName,
|
||||||
|
"author_display_name": nil,
|
||||||
|
"author_bot": true,
|
||||||
|
"bot_id": botID,
|
||||||
|
"bot_name": botName,
|
||||||
|
"content": anonContent,
|
||||||
|
"reply_to": nil,
|
||||||
|
"edited_at": nil,
|
||||||
|
"pinned": false,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"embeds": []interface{}{},
|
||||||
|
"reactions": []interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
r.hub.BroadcastToServer(targetServerID, gateway.Event{
|
||||||
|
Type: gateway.EventMessageCreate,
|
||||||
|
Data: payload,
|
||||||
|
})
|
||||||
|
|
||||||
|
return payload, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage removes a message and broadcasts MESSAGE_DELETE to live clients.
|
||||||
|
func (r *Runner) DeleteMessage(messageID string) {
|
||||||
|
var channelID string
|
||||||
|
err := r.db.QueryRowContext(context.Background(),
|
||||||
|
`DELETE FROM messages WHERE id = $1::uuid RETURNING channel_id`, messageID,
|
||||||
|
).Scan(&channelID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
serverID, err := r.hub.ServerIDForChannel(context.Background(), channelID)
|
||||||
|
if err != nil || serverID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.hub.BroadcastToServer(serverID, gateway.Event{
|
||||||
|
Type: gateway.EventMessageDelete,
|
||||||
|
Data: map[string]string{
|
||||||
|
"id": messageID,
|
||||||
|
"message_id": messageID,
|
||||||
|
"channel_id": channelID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SteamFreeConfig is the config shape for bot_type "steamfree".
|
||||||
|
type SteamFreeConfig struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
PollMinutes int `json:"poll_minutes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SteamFreeBot polls Steam's featured categories for 100%-off games.
|
||||||
|
func SteamFreeBot(ctx context.Context, _ *sql.DB, raw json.RawMessage, send SendMessageFunc) {
|
||||||
|
var cfg SteamFreeConfig
|
||||||
|
if err := json.Unmarshal(raw, &cfg); err != nil || cfg.ChannelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.PollMinutes <= 0 {
|
||||||
|
cfg.PollMinutes = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[int]bool{}
|
||||||
|
ticker := time.NewTicker(time.Duration(cfg.PollMinutes) * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Poll immediately on start
|
||||||
|
pollSteam(cfg.ChannelID, seen, send)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
pollSteam(cfg.ChannelID, seen, send)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pollSteam(channelID string, seen map[int]bool, send SendMessageFunc) {
|
||||||
|
games, err := fetchFreeGames()
|
||||||
|
if err != nil {
|
||||||
|
return // ponytail: silent on error, logs add noise
|
||||||
|
}
|
||||||
|
for _, g := range games {
|
||||||
|
if seen[g.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[g.ID] = true
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"🎮 **FREE ON STEAM** 🎮\n**%s**\n~~$%.2f~~ → **FREE**\nhttps://store.steampowered.com/app/%d",
|
||||||
|
g.Name, float64(g.OriginalPrice)/100, g.ID,
|
||||||
|
)
|
||||||
|
send(channelID, msg)
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type featuredCategories struct {
|
||||||
|
Specials struct {
|
||||||
|
Items []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"`
|
||||||
|
OriginalPrice int `json:"original_price"`
|
||||||
|
} `json:"items"`
|
||||||
|
} `json:"specials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchFreeGames() ([]struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"`
|
||||||
|
OriginalPrice int `json:"original_price"`
|
||||||
|
}, error) {
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Get("https://store.steampowered.com/api/featuredcategories?cc=us&l=english")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("steam: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cats featuredCategories
|
||||||
|
if err := json.Unmarshal(body, &cats); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var free []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DiscountPct int `json:"discount_percent"`
|
||||||
|
FinalPrice int `json:"final_price"`
|
||||||
|
OriginalPrice int `json:"original_price"`
|
||||||
|
}
|
||||||
|
for _, item := range cats.Specials.Items {
|
||||||
|
if item.DiscountPct == 100 && item.OriginalPrice > 0 {
|
||||||
|
free = append(free, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return free, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ type createEventRequest struct {
|
|||||||
Color *string `json:"color"`
|
Color *string `json:"color"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerCalendarRoutes(r chi.Router) {
|
func (h *Handler) RegisterCalendarRoutes(r chi.Router) {
|
||||||
r.Get("/{channelID}/events", h.ListEvents)
|
r.Get("/{channelID}/events", h.ListEvents)
|
||||||
r.Post("/{channelID}/events", h.CreateEvent)
|
r.Post("/{channelID}/events", h.CreateEvent)
|
||||||
r.Patch("/events/{eventID}", h.UpdateEvent)
|
r.Patch("/events/{eventID}", h.UpdateEvent)
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Get("/{channelID}", h.Get)
|
r.Get("/{channelID}", h.Get)
|
||||||
r.Patch("/{channelID}", h.Update)
|
r.Patch("/{channelID}", h.Update)
|
||||||
r.Delete("/{channelID}", h.Delete)
|
r.Delete("/{channelID}", h.Delete)
|
||||||
r.Post("/{channelID}/threads", h.CreateThread)
|
// ponytail: thread + forum routes registered at top level in main.go to match frontend paths
|
||||||
r.Get("/{channelID}/threads", h.ListThreads)
|
h.RegisterCalendarRoutes(r)
|
||||||
r.Patch("/threads/{threadID}", h.UpdateThread)
|
|
||||||
h.registerForumRoutes(r)
|
|
||||||
h.registerCalendarRoutes(r)
|
|
||||||
h.registerDocRoutes(r)
|
h.registerDocRoutes(r)
|
||||||
h.registerListRoutes(r)
|
h.registerListRoutes(r)
|
||||||
h.registerOverrideRoutes(r)
|
h.registerOverrideRoutes(r)
|
||||||
|
|||||||
@@ -565,5 +565,14 @@ CREATE TABLE IF NOT EXISTS feature_request_votes (
|
|||||||
PRIMARY KEY (feature_request_id, user_id)
|
PRIMARY KEY (feature_request_id, user_id)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
|
CREATE INDEX IF NOT EXISTS idx_feature_request_votes_fr ON feature_request_votes(feature_request_id);
|
||||||
|
|
||||||
|
-- Bot messages: track which bot authored a message
|
||||||
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS bot_id UUID REFERENCES bots(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_bot ON messages(bot_id) WHERE bot_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Built-in bot types: bot_type + config for server-managed bots
|
||||||
|
ALTER TABLE bots ADD COLUMN IF NOT EXISTS bot_type VARCHAR(32) DEFAULT '';
|
||||||
|
ALTER TABLE bots ADD COLUMN IF NOT EXISTS config JSONB DEFAULT '{}';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bots_type ON bots(bot_type) WHERE bot_type != '';
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package gateway
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -106,6 +108,9 @@ type Client struct {
|
|||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
UserID string
|
UserID string
|
||||||
Username string
|
Username string
|
||||||
|
IsBot bool
|
||||||
|
BotID string
|
||||||
|
BotName string
|
||||||
send chan []byte
|
send chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +160,18 @@ func (c *Client) readPump() {
|
|||||||
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
|
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: data})
|
||||||
case EventPresenceUpdate:
|
case EventPresenceUpdate:
|
||||||
c.Hub.BroadcastEvent(event)
|
c.Hub.BroadcastEvent(event)
|
||||||
|
case EventVoiceJoin, EventVoiceLeave, EventVoiceMute, EventVoiceDeafen:
|
||||||
|
// Broadcast voice state changes to all clients with sender info
|
||||||
|
var vData map[string]interface{}
|
||||||
|
if raw, ok := event.Data.(json.RawMessage); ok {
|
||||||
|
json.Unmarshal(raw, &vData)
|
||||||
|
}
|
||||||
|
if vData == nil {
|
||||||
|
vData = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
vData["user_id"] = c.UserID
|
||||||
|
vData["username"] = c.Username
|
||||||
|
c.Hub.BroadcastEvent(Event{Type: event.Type, Data: vData})
|
||||||
case EventVoiceWhisper:
|
case EventVoiceWhisper:
|
||||||
// Forward voice whispers only to the target user, not broadcast
|
// Forward voice whispers only to the target user, not broadcast
|
||||||
var whisperData struct {
|
var whisperData struct {
|
||||||
@@ -172,6 +189,18 @@ func (c *Client) readPump() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case BotSendMessage:
|
||||||
|
if !c.IsBot {
|
||||||
|
c.Hub.logger.Warn("non-bot client sent SEND_MESSAGE", "user_id", c.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.handleBotSendMessage(event.Data)
|
||||||
|
case BotDeleteMessage:
|
||||||
|
if !c.IsBot {
|
||||||
|
c.Hub.logger.Warn("non-bot client sent DELETE_MESSAGE", "user_id", c.UserID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.handleBotDeleteMessage(event.Data)
|
||||||
default:
|
default:
|
||||||
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
c.Hub.logger.Info("received event from client", "type", event.Type, "user_id", c.UserID)
|
||||||
}
|
}
|
||||||
@@ -288,3 +317,195 @@ func ServeWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r
|
|||||||
go client.writePump()
|
go client.writePump()
|
||||||
go client.readPump()
|
go client.readPump()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ServeBotWS handles websocket requests from bot clients.
|
||||||
|
// Authenticates via ?token= query param (bot token, hashed lookup).
|
||||||
|
func ServeBotWS(db *sql.DB, hub *Hub, logger *slog.Logger, w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, `{"error":"token query param required"}`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the token and look up the bot.
|
||||||
|
tokenHash := hashToken(token)
|
||||||
|
|
||||||
|
var botID, botName, ownerID string
|
||||||
|
err := db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT id, name, owner_id FROM bots WHERE token = $1`,
|
||||||
|
tokenHash,
|
||||||
|
).Scan(&botID, &botName, &ownerID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("bot ws auth: invalid token")
|
||||||
|
http.Error(w, `{"error":"invalid bot token"}`, http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the bot is added to at least one server.
|
||||||
|
var serverCount int
|
||||||
|
err = db.QueryRowContext(r.Context(),
|
||||||
|
`SELECT COUNT(*) FROM bot_servers WHERE bot_id = $1`, botID,
|
||||||
|
).Scan(&serverCount)
|
||||||
|
if err != nil || serverCount == 0 {
|
||||||
|
logger.Warn("bot ws auth: bot not added to any server", "bot_id", botID)
|
||||||
|
http.Error(w, `{"error":"bot not added to any server"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("bot websocket upgrade failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load bot server memberships into hub so BroadcastToServer works.
|
||||||
|
hub.RefreshUserServers(ownerID)
|
||||||
|
|
||||||
|
conn.SetReadDeadline(time.Time{})
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"ready","bot_id":"`+botID+`"}`))
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
Hub: hub,
|
||||||
|
Conn: conn,
|
||||||
|
UserID: ownerID,
|
||||||
|
Username: botName,
|
||||||
|
IsBot: true,
|
||||||
|
BotID: botID,
|
||||||
|
BotName: botName,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Register(client)
|
||||||
|
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashToken returns the SHA-256 hex digest of a token.
|
||||||
|
func hashToken(token string) string {
|
||||||
|
h := sha256.Sum256([]byte(token))
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBotSendMessage processes a SEND_MESSAGE action from a bot client.
|
||||||
|
func (c *Client) handleBotSendMessage(data interface{}) {
|
||||||
|
var payload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
raw, ok := data.(json.RawMessage)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.Content == "" {
|
||||||
|
c.Hub.logger.Warn("bot SEND_MESSAGE: invalid payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(payload.Content) > 4000 {
|
||||||
|
payload.Content = payload.Content[:4000]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the bot is in the server that owns this channel.
|
||||||
|
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
c.Hub.logger.Warn("bot SEND_MESSAGE: channel not found", "channel_id", payload.ChannelID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var inServer bool
|
||||||
|
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||||
|
c.BotID, serverID,
|
||||||
|
).Scan(&inServer)
|
||||||
|
if err != nil || !inServer {
|
||||||
|
c.Hub.logger.Warn("bot SEND_MESSAGE: bot not in server", "bot_id", c.BotID, "server_id", serverID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the message with bot_id set.
|
||||||
|
var msgID, createdAt string
|
||||||
|
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||||
|
`INSERT INTO messages (channel_id, author_id, content, bot_id)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
RETURNING id, created_at::text`,
|
||||||
|
payload.ChannelID, c.UserID, payload.Content, c.BotID,
|
||||||
|
).Scan(&msgID, &createdAt)
|
||||||
|
if err != nil {
|
||||||
|
c.Hub.logger.Error("bot SEND_MESSAGE: insert failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast MESSAGE_CREATE to the server.
|
||||||
|
c.Hub.BroadcastToServer(serverID, Event{
|
||||||
|
Type: EventMessageCreate,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"id": msgID,
|
||||||
|
"channel_id": payload.ChannelID,
|
||||||
|
"author_id": c.UserID,
|
||||||
|
"author_username": c.BotName,
|
||||||
|
"author_display_name": nil,
|
||||||
|
"author_bot": true,
|
||||||
|
"bot_id": c.BotID,
|
||||||
|
"bot_name": c.BotName,
|
||||||
|
"content": payload.Content,
|
||||||
|
"reply_to": nil,
|
||||||
|
"edited_at": nil,
|
||||||
|
"pinned": false,
|
||||||
|
"created_at": createdAt,
|
||||||
|
"embeds": []interface{}{},
|
||||||
|
"reactions": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBotDeleteMessage processes a DELETE_MESSAGE action from a bot client.
|
||||||
|
func (c *Client) handleBotDeleteMessage(data interface{}) {
|
||||||
|
var payload struct {
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
MessageID string `json:"message_id"`
|
||||||
|
}
|
||||||
|
raw, ok := data.(json.RawMessage)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil || payload.ChannelID == "" || payload.MessageID == "" {
|
||||||
|
c.Hub.logger.Warn("bot DELETE_MESSAGE: invalid payload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the bot is in the server that owns this channel.
|
||||||
|
serverID, err := c.Hub.ServerIDForChannel(context.Background(), payload.ChannelID)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var inServer bool
|
||||||
|
err = c.Hub.db.QueryRowContext(context.Background(),
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM bot_servers WHERE bot_id = $1 AND server_id = $2)`,
|
||||||
|
c.BotID, serverID,
|
||||||
|
).Scan(&inServer)
|
||||||
|
if err != nil || !inServer {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the message (only if it exists in this channel).
|
||||||
|
result, err := c.Hub.db.ExecContext(context.Background(),
|
||||||
|
`DELETE FROM messages WHERE id = $1 AND channel_id = $2`,
|
||||||
|
payload.MessageID, payload.ChannelID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.Hub.logger.Error("bot DELETE_MESSAGE: delete failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, _ := result.RowsAffected()
|
||||||
|
if rows == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast MESSAGE_DELETE.
|
||||||
|
c.Hub.BroadcastToServer(serverID, Event{
|
||||||
|
Type: EventMessageDelete,
|
||||||
|
Data: map[string]string{
|
||||||
|
"id": payload.MessageID,
|
||||||
|
"channel_id": payload.ChannelID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ const (
|
|||||||
EventVoiceMute = "VOICE_MUTE"
|
EventVoiceMute = "VOICE_MUTE"
|
||||||
EventVoiceDeafen = "VOICE_DEAFEN"
|
EventVoiceDeafen = "VOICE_DEAFEN"
|
||||||
EventVoiceWhisper = "VOICE_WHISPER"
|
EventVoiceWhisper = "VOICE_WHISPER"
|
||||||
|
|
||||||
|
// Bot action events (sent by bot clients)
|
||||||
|
BotSendMessage = "SEND_MESSAGE"
|
||||||
|
BotDeleteMessage = "DELETE_MESSAGE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a WebSocket event sent to clients.
|
// Event represents a WebSocket event sent to clients.
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ type Handler struct {
|
|||||||
pushHandler *push.Handler
|
pushHandler *push.Handler
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
checker *permissions.Checker
|
checker *permissions.Checker
|
||||||
|
// Optional: intercepts /confess so the original message is never stored/broadcast.
|
||||||
|
confess ConfessHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfessHandler posts an anonymous confession and returns the bot message payload.
|
||||||
|
type ConfessHandler interface {
|
||||||
|
TryConfess(ctx context.Context, serverID, authorID, content string) (payload map[string]interface{}, handled bool)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler {
|
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger, checker *permissions.Checker) *Handler {
|
||||||
@@ -40,6 +47,11 @@ func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetConfessHandler wires the built-in confess interceptor (optional).
|
||||||
|
func (h *Handler) SetConfessHandler(c ConfessHandler) {
|
||||||
|
h.confess = c
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||||
r.Get("/", h.List)
|
r.Get("/", h.List)
|
||||||
r.Post("/", h.Create)
|
r.Post("/", h.Create)
|
||||||
@@ -119,6 +131,7 @@ func (h *Handler) BulkDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
Type: gateway.EventMessageDelete,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"id": id,
|
"id": id,
|
||||||
|
"message_id": id,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -159,6 +172,9 @@ type messageResponse struct {
|
|||||||
AuthorID string `json:"author_id"`
|
AuthorID string `json:"author_id"`
|
||||||
AuthorName string `json:"author_username"`
|
AuthorName string `json:"author_username"`
|
||||||
DisplayName *string `json:"author_display_name"`
|
DisplayName *string `json:"author_display_name"`
|
||||||
|
AuthorBot bool `json:"author_bot"`
|
||||||
|
BotID *string `json:"bot_id,omitempty"`
|
||||||
|
BotName *string `json:"bot_name,omitempty"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReplyTo *string `json:"reply_to,omitempty"`
|
ReplyTo *string `json:"reply_to,omitempty"`
|
||||||
EditedAt *string `json:"edited_at"`
|
EditedAt *string `json:"edited_at"`
|
||||||
@@ -240,6 +256,29 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gate @everyone / @channel on MENTION_EVERYONE (owner/admin always pass).
|
||||||
|
if hasBroadcastToken(req.Content, "everyone") || hasBroadcastToken(req.Content, "channel") {
|
||||||
|
allowed, permErr := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MENTION_EVERYONE)
|
||||||
|
if permErr != nil {
|
||||||
|
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
http.Error(w, `{"error":"missing permission: MENTION_EVERYONE"}`, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anonymous confessions: never store/broadcast the original /confess message.
|
||||||
|
if h.confess != nil {
|
||||||
|
if payload, handled := h.confess.TryConfess(r.Context(), serverID, userID, req.Content); handled {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var msg messageResponse
|
var msg messageResponse
|
||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
@@ -464,6 +503,7 @@ func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
|||||||
Type: gateway.EventMessageDelete,
|
Type: gateway.EventMessageDelete,
|
||||||
Data: map[string]string{
|
Data: map[string]string{
|
||||||
"id": messageID,
|
"id": messageID,
|
||||||
|
"message_id": messageID,
|
||||||
"channel_id": channelID,
|
"channel_id": channelID,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -505,18 +545,20 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var err error
|
var err error
|
||||||
if before != "" {
|
if before != "" {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
`, channelID, before, limit)
|
`, channelID, before, limit)
|
||||||
} else {
|
} else {
|
||||||
rows, err = h.db.QueryContext(r.Context(), `
|
rows, err = h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1
|
WHERE m.channel_id = $1
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
@@ -534,7 +576,9 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo sql.NullString
|
var replyTo sql.NullString
|
||||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
var botID sql.NullString
|
||||||
|
var botName sql.NullString
|
||||||
|
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -544,6 +588,13 @@ func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
|
if botID.Valid {
|
||||||
|
msg.BotID = &botID.String
|
||||||
|
msg.AuthorBot = true
|
||||||
|
}
|
||||||
|
if botName.Valid {
|
||||||
|
msg.BotName = &botName.String
|
||||||
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
@@ -802,9 +853,10 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
rows, err := h.db.QueryContext(r.Context(), `
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text,
|
||||||
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank
|
ts_rank(m.search_vector, plainto_tsquery('english', $2)) AS rank, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
|
WHERE m.channel_id = $1 AND m.search_vector @@ plainto_tsquery('english', $2)
|
||||||
ORDER BY rank DESC, m.created_at DESC
|
ORDER BY rank DESC, m.created_at DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
@@ -822,7 +874,9 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo sql.NullString
|
var replyTo sql.NullString
|
||||||
var rank float64
|
var rank float64
|
||||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank)
|
var botID sql.NullString
|
||||||
|
var botName sql.NullString
|
||||||
|
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &rank, &botID, &botName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -832,6 +886,13 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
|
if botID.Valid {
|
||||||
|
msg.BotID = &botID.String
|
||||||
|
msg.AuthorBot = true
|
||||||
|
}
|
||||||
|
if botName.Valid {
|
||||||
|
msg.BotName = &botName.String
|
||||||
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
@@ -1059,9 +1120,10 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows, err := h.db.QueryContext(r.Context(), `
|
rows, err := h.db.QueryContext(r.Context(), `
|
||||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text
|
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.reply_to::text, m.edited_at::text, m.pinned, m.created_at::text, m.bot_id, b.name
|
||||||
FROM messages m
|
FROM messages m
|
||||||
JOIN users u ON m.author_id = u.id
|
JOIN users u ON m.author_id = u.id
|
||||||
|
LEFT JOIN bots b ON m.bot_id = b.id
|
||||||
WHERE m.channel_id = $1 AND m.pinned = TRUE
|
WHERE m.channel_id = $1 AND m.pinned = TRUE
|
||||||
ORDER BY m.created_at DESC
|
ORDER BY m.created_at DESC
|
||||||
`, channelID)
|
`, channelID)
|
||||||
@@ -1077,7 +1139,9 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
|||||||
var editedAt sql.NullString
|
var editedAt sql.NullString
|
||||||
var createdAt sql.NullString
|
var createdAt sql.NullString
|
||||||
var replyTo sql.NullString
|
var replyTo sql.NullString
|
||||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt)
|
var botID sql.NullString
|
||||||
|
var botName sql.NullString
|
||||||
|
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &replyTo, &editedAt, &msg.Pinned, &createdAt, &botID, &botName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1087,6 +1151,13 @@ func (h *Handler) ListPinned(w http.ResponseWriter, r *http.Request) {
|
|||||||
if editedAt.Valid {
|
if editedAt.Valid {
|
||||||
msg.EditedAt = &editedAt.String
|
msg.EditedAt = &editedAt.String
|
||||||
}
|
}
|
||||||
|
if botID.Valid {
|
||||||
|
msg.BotID = &botID.String
|
||||||
|
msg.AuthorBot = true
|
||||||
|
}
|
||||||
|
if botName.Valid {
|
||||||
|
msg.BotName = &botName.String
|
||||||
|
}
|
||||||
msg.CreatedAt = createdAt.String
|
msg.CreatedAt = createdAt.String
|
||||||
messages = append(messages, msg)
|
messages = append(messages, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||||
)
|
)
|
||||||
|
|
||||||
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||||
var everyoneMention = "@everyone"
|
|
||||||
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||||
|
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
|
||||||
|
|
||||||
// MentionHandler dispatches push notifications for @mentions.
|
// MentionHandler dispatches push notifications for @mentions.
|
||||||
type MentionHandler struct {
|
type MentionHandler struct {
|
||||||
@@ -29,20 +30,40 @@ func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasBroadcastToken reports whether content contains @everyone / @channel as a whole token.
|
||||||
|
func hasBroadcastToken(content, token string) bool {
|
||||||
|
// token like "everyone" or "channel" (without @)
|
||||||
|
needle := "@" + token
|
||||||
|
idx := 0
|
||||||
|
for {
|
||||||
|
i := strings.Index(strings.ToLower(content[idx:]), needle)
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
i += idx
|
||||||
|
end := i + len(needle)
|
||||||
|
if end >= len(content) || !isUsernameChar(rune(content[end])) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
idx = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUsernameChar(r rune) bool {
|
||||||
|
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' || r == '-'
|
||||||
|
}
|
||||||
|
|
||||||
// ParseAndNotify parses message content for mentions and sends push notifications.
|
// ParseAndNotify parses message content for mentions and sends push notifications.
|
||||||
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
||||||
// Find individual user mentions
|
|
||||||
userMatches := mentionRegex.FindAllStringSubmatch(content, -1)
|
|
||||||
mentionedUsers := make(map[string]bool)
|
mentionedUsers := make(map[string]bool)
|
||||||
for _, match := range userMatches {
|
|
||||||
|
// Discord-style ID mentions
|
||||||
|
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
mentionedUsers[match[1]] = true
|
mentionedUsers[match[1]] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for @everyone
|
|
||||||
isEveryone := strings.Contains(content, everyoneMention)
|
|
||||||
|
|
||||||
// Get channel info for notification
|
// Get channel info for notification
|
||||||
var serverID, channelName string
|
var serverID, channelName string
|
||||||
err := m.db.QueryRowContext(ctx,
|
err := m.db.QueryRowContext(ctx,
|
||||||
@@ -75,8 +96,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
"url": "/channels/" + channelID,
|
"url": "/channels/" + channelID,
|
||||||
}
|
}
|
||||||
|
|
||||||
if isEveryone {
|
// @everyone / @channel — fan out to server members (permission gated at create).
|
||||||
// Send to all server members except those who muted this channel
|
// ponytail: both use the same fanout; UI labels differ. Split if channel-private members matter.
|
||||||
|
if hasBroadcastToken(content, "everyone") || hasBroadcastToken(content, "channel") {
|
||||||
rows, err := m.db.QueryContext(ctx,
|
rows, err := m.db.QueryContext(ctx,
|
||||||
`SELECT m.user_id FROM members m
|
`SELECT m.user_id FROM members m
|
||||||
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
||||||
@@ -85,7 +107,7 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
serverID, authorID, channelID,
|
serverID, authorID, channelID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.logger.Error("failed to query server members for @everyone", "error", err)
|
m.logger.Error("failed to query server members for broadcast mention", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
@@ -100,13 +122,43 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for role mentions
|
// Plain @username mentions (what the frontend actually stores)
|
||||||
|
usernames := make([]string, 0)
|
||||||
|
seenUsernames := make(map[string]bool)
|
||||||
|
for _, match := range plainUsernameMention.FindAllStringSubmatch(content, -1) {
|
||||||
|
if len(match) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
u := strings.ToLower(match[1])
|
||||||
|
if u == "everyone" || u == "channel" || u == "here" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seenUsernames[u] {
|
||||||
|
seenUsernames[u] = true
|
||||||
|
usernames = append(usernames, match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(usernames) > 0 {
|
||||||
|
// Resolve usernames that are members of this server.
|
||||||
|
for _, uname := range usernames {
|
||||||
|
var uid string
|
||||||
|
err := m.db.QueryRowContext(ctx, `
|
||||||
|
SELECT u.id FROM users u
|
||||||
|
JOIN members m ON m.user_id = u.id
|
||||||
|
WHERE m.server_id = $1 AND LOWER(u.username) = LOWER($2)
|
||||||
|
`, serverID, uname).Scan(&uid)
|
||||||
|
if err == nil {
|
||||||
|
mentionedUsers[uid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role mentions
|
||||||
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||||
if len(roleMatches) > 0 {
|
if len(roleMatches) > 0 {
|
||||||
for _, match := range roleMatches {
|
for _, match := range roleMatches {
|
||||||
if len(match) > 1 {
|
if len(match) > 1 {
|
||||||
roleID := match[1]
|
roleID := match[1]
|
||||||
// Get users with this role
|
|
||||||
rows, err := m.db.QueryContext(ctx,
|
rows, err := m.db.QueryContext(ctx,
|
||||||
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||||
roleID, authorID,
|
roleID, authorID,
|
||||||
@@ -127,12 +179,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the author from mentions
|
|
||||||
delete(mentionedUsers, authorID)
|
delete(mentionedUsers, authorID)
|
||||||
|
|
||||||
// Send push to individually mentioned users
|
|
||||||
for userID := range mentionedUsers {
|
for userID := range mentionedUsers {
|
||||||
// Check if user is in DND status
|
|
||||||
var status string
|
var status string
|
||||||
err := m.db.QueryRowContext(ctx,
|
err := m.db.QueryRowContext(ctx,
|
||||||
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||||
@@ -144,7 +193,6 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user muted this channel
|
|
||||||
var level string
|
var level string
|
||||||
err = m.db.QueryRowContext(ctx,
|
err = m.db.QueryRowContext(ctx,
|
||||||
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package message
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHasBroadcastToken(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
content string
|
||||||
|
token string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"hello @everyone", "everyone", true},
|
||||||
|
{"@everyone hi", "everyone", true},
|
||||||
|
{"@EVERYONE", "everyone", true},
|
||||||
|
{"@everyone!", "everyone", true},
|
||||||
|
{"@everyoneelse", "everyone", false},
|
||||||
|
{"noteveryone", "everyone", false},
|
||||||
|
{"@channel", "channel", true},
|
||||||
|
{"ping @channel please", "channel", true},
|
||||||
|
{"@channeling", "channel", false},
|
||||||
|
{"", "everyone", false},
|
||||||
|
{"@", "everyone", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := hasBroadcastToken(tc.content, tc.token)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("hasBroadcastToken(%q, %q) = %v, want %v", tc.content, tc.token, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsUsernameChar(t *testing.T) {
|
||||||
|
if !isUsernameChar('a') || !isUsernameChar('9') || !isUsernameChar('_') {
|
||||||
|
t.Fatal("expected alnum/_")
|
||||||
|
}
|
||||||
|
if isUsernameChar(' ') || isUsernameChar('!') || isUsernameChar('@') {
|
||||||
|
t.Fatal("unexpected username chars")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,13 +18,23 @@ type SessionStore interface {
|
|||||||
func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler {
|
func Session(store SessionStore, cfg *config.Config) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var token string
|
||||||
cookie, err := r.Cookie(cfg.Session.CookieName)
|
cookie, err := r.Cookie(cfg.Session.CookieName)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
|
token = cookie.Value
|
||||||
|
} else {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||||
|
token = authHeader[7:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, err := store.GetUserIDByToken(r.Context(), cookie.Value)
|
userID, err := store.GetUserIDByToken(r.Context(), token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package permissions
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHas(t *testing.T) {
|
||||||
|
set := VIEW_CHANNEL | SEND_MESSAGES | MENTION_EVERYONE
|
||||||
|
|
||||||
|
if !Has(set, VIEW_CHANNEL) {
|
||||||
|
t.Fatal("expected VIEW_CHANNEL")
|
||||||
|
}
|
||||||
|
if !Has(set, SEND_MESSAGES) {
|
||||||
|
t.Fatal("expected SEND_MESSAGES")
|
||||||
|
}
|
||||||
|
if Has(set, KICK_MEMBERS) {
|
||||||
|
t.Fatal("did not expect KICK_MEMBERS")
|
||||||
|
}
|
||||||
|
if !Has(set, VIEW_CHANNEL|SEND_MESSAGES) {
|
||||||
|
t.Fatal("expected multi-bit all-present")
|
||||||
|
}
|
||||||
|
if Has(set, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||||
|
t.Fatal("multi-bit should require all bits")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdministratorBypassSemantics(t *testing.T) {
|
||||||
|
// Client/backend convention: ADMINISTRATOR implies all gates when checked separately.
|
||||||
|
if !Has(ADMINISTRATOR, ADMINISTRATOR) {
|
||||||
|
t.Fatal("admin flag self")
|
||||||
|
}
|
||||||
|
// ADMINISTRATOR alone does not set other bits; Has is pure bit check.
|
||||||
|
if Has(ADMINISTRATOR, KICK_MEMBERS) {
|
||||||
|
t.Fatal("Has is not an admin-implies-all helper; CheckPermission does that")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultEveryoneDoesNotIncludeMentionEveryone(t *testing.T) {
|
||||||
|
if Has(DefaultEveryonePermissions, MENTION_EVERYONE) {
|
||||||
|
t.Fatal("@everyone default must not grant MENTION_EVERYONE")
|
||||||
|
}
|
||||||
|
if !Has(DefaultEveryonePermissions, SEND_MESSAGES) {
|
||||||
|
t.Fatal("@everyone default should grant SEND_MESSAGES")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddRemove(t *testing.T) {
|
||||||
|
p := int64(0)
|
||||||
|
p = Add(p, VIEW_CHANNEL)
|
||||||
|
p = Add(p, KICK_MEMBERS)
|
||||||
|
if !Has(p, VIEW_CHANNEL|KICK_MEMBERS) {
|
||||||
|
t.Fatal("Add failed")
|
||||||
|
}
|
||||||
|
p = Remove(p, KICK_MEMBERS)
|
||||||
|
if Has(p, KICK_MEMBERS) {
|
||||||
|
t.Fatal("Remove failed")
|
||||||
|
}
|
||||||
|
if !Has(p, VIEW_CHANNEL) {
|
||||||
|
t.Fatal("Remove cleared wrong bit")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ type memberResponse struct {
|
|||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
StatusText string `json:"status_text"`
|
StatusText string `json:"status_text"`
|
||||||
|
IsBot bool `json:"is_bot,omitempty"`
|
||||||
|
BotType string `json:"bot_type,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -82,6 +84,28 @@ func (h *MemberHandler) ListMembers(w http.ResponseWriter, r *http.Request) {
|
|||||||
members = append(members, m)
|
members = append(members, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bots added to this server (separate BOTS section in the member list).
|
||||||
|
botRows, err := h.db.QueryContext(r.Context(), `
|
||||||
|
SELECT b.id, b.name, COALESCE(b.avatar, ''), COALESCE(b.bot_type, '')
|
||||||
|
FROM bot_servers bs
|
||||||
|
JOIN bots b ON b.id = bs.bot_id
|
||||||
|
WHERE bs.server_id = $1
|
||||||
|
ORDER BY b.name
|
||||||
|
`, serverID)
|
||||||
|
if err == nil {
|
||||||
|
defer botRows.Close()
|
||||||
|
for botRows.Next() {
|
||||||
|
var m memberResponse
|
||||||
|
if err := botRows.Scan(&m.ID, &m.Username, &m.Avatar, &m.BotType); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.DisplayName = m.Username
|
||||||
|
m.Status = "online" // ponytail: no bot presence yet; always show online
|
||||||
|
m.IsBot = true
|
||||||
|
members = append(members, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(members)
|
json.NewEncoder(w).Encode(members)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "629643353973",
|
||||||
|
"project_id": "dumpster-chat",
|
||||||
|
"storage_bucket": "dumpster-chat.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:629643353973:android:29dcc703959dd306c0fd3c",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "coffee.dustin.dumpster"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyDt3h4G-imzD7IedRqYOUBIv8CtZQT2YyA"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
@@ -1,13 +1,40 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="gruvbox">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<script>
|
||||||
|
// Apply saved theme before paint (migrates legacy light/dark).
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('dumpster-theme');
|
||||||
|
if (t === 'light') t = 'gruvbox-light';
|
||||||
|
else if (t === 'dark' || !t) t = 'gruvbox';
|
||||||
|
var ok = {
|
||||||
|
gruvbox: 1,
|
||||||
|
'gruvbox-light': 1,
|
||||||
|
'one-dark': 1,
|
||||||
|
dracula: 1,
|
||||||
|
nord: 1,
|
||||||
|
'tokyo-night': 1,
|
||||||
|
catppuccin: 1,
|
||||||
|
'solarized-dark': 1,
|
||||||
|
monokai: 1,
|
||||||
|
'github-dark': 1,
|
||||||
|
};
|
||||||
|
if (!ok[t]) t = 'gruvbox';
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
document.documentElement.classList.add(t === 'gruvbox-light' ? 'light' : 'dark');
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'gruvbox');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- PWA Meta Tags -->
|
<!-- PWA Meta Tags -->
|
||||||
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#282828" />
|
<meta name="theme-color" content="#282828" />
|
||||||
<meta name="color-scheme" content="dark" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
<meta name="description" content="A chaotic, self-hosted Discord-like platform" />
|
||||||
|
|
||||||
<!-- Apple -->
|
<!-- Apple -->
|
||||||
@@ -33,11 +60,20 @@
|
|||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script>
|
<script>
|
||||||
// Register service worker
|
// Register service worker (only for web, avoid in Tauri to prevent 404 cache bugs)
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
|
if (!('__TAURI_INTERNALS__' in window) && !('__TAURI__' in window)) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// In Tauri, aggressively unregister any old service workers that might be causing 404s
|
||||||
|
navigator.serviceWorker.getRegistrations().then((registrations) => {
|
||||||
|
for (let registration of registrations) {
|
||||||
|
registration.unregister();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 502 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# EditorConfig is awesome: https://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
insert_final_newline = false
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
key.properties
|
||||||
|
keystore.properties
|
||||||
|
|
||||||
|
/.tauri
|
||||||
|
/tauri.settings.gradle
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/src/main/**/generated
|
||||||
|
/src/main/jniLibs/**/*.so
|
||||||
|
/src/main/assets/tauri.conf.json
|
||||||
|
/tauri.build.gradle.kts
|
||||||
|
/proguard-tauri.pro
|
||||||
|
/tauri.properties
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("rust")
|
||||||
|
}
|
||||||
|
|
||||||
|
val tauriProperties = Properties().apply {
|
||||||
|
val propFile = file("tauri.properties")
|
||||||
|
if (propFile.exists()) {
|
||||||
|
propFile.inputStream().use { load(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val keystorePropertiesFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(keystorePropertiesFile.inputStream())
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 36
|
||||||
|
namespace = "coffee.dustin.dumpster"
|
||||||
|
defaultConfig {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||||
|
applicationId = "coffee.dustin.dumpster"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||||
|
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("debug") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
|
isDebuggable = true
|
||||||
|
isJniDebuggable = true
|
||||||
|
isMinifyEnabled = false
|
||||||
|
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||||
|
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getByName("release") {
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
*fileTree(".") { include("**/*.pro") }
|
||||||
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
.toList().toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rust {
|
||||||
|
rootDirRel = "../../../"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("androidx.webkit:webkit:1.14.0")
|
||||||
|
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||||
|
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||||
|
implementation("com.google.android.material:material:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||||
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(from = "tauri.build.gradle.kts")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.app"
|
||||||
|
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:label="@string/main_activity_title"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<!-- AndroidTV support -->
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package coffee.dustin.dumpster
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
|
||||||
|
class MainActivity : TauriActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:context=".MainActivity">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Hello World!"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintLeft_toLeftOf="parent"
|
||||||
|
app:layout_constraintRight_toRightOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. Forces dark mode since our CSS themes are always dark. -->
|
||||||
|
<style name="Theme.app" parent="Theme.MaterialComponents.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<!-- Force light status bar icons (white) against our dark background -->
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">"dumpsterChat"</string>
|
||||||
|
<string name="main_activity_title">"dumpsterChat"</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<!-- Base application theme. Forces dark mode since our CSS themes are always dark. -->
|
||||||
|
<style name="Theme.app" parent="Theme.MaterialComponents.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<!-- Force light status bar icons (white) against our dark background -->
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
<item name="android:windowLightNavigationBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-path name="my_images" path="." />
|
||||||
|
<cache-path name="my_cache_images" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
buildscript {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
dependencies {
|
||||||
|
classpath("com.android.tools.build:gradle:8.11.0")
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register("clean").configure {
|
||||||
|
delete("build")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
plugins {
|
||||||
|
`kotlin-dsl`
|
||||||
|
}
|
||||||
|
|
||||||
|
gradlePlugin {
|
||||||
|
plugins {
|
||||||
|
create("pluginsForCoolKids") {
|
||||||
|
id = "rust"
|
||||||
|
implementationClass = "RustPlugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
compileOnly(gradleApi())
|
||||||
|
implementation("com.android.tools.build:gradle:8.11.0")
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import java.io.File
|
||||||
|
import org.apache.tools.ant.taskdefs.condition.Os
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.GradleException
|
||||||
|
import org.gradle.api.logging.LogLevel
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
|
||||||
|
open class BuildTask : DefaultTask() {
|
||||||
|
@Input
|
||||||
|
var rootDirRel: String? = null
|
||||||
|
@Input
|
||||||
|
var target: String? = null
|
||||||
|
@Input
|
||||||
|
var release: Boolean? = null
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun assemble() {
|
||||||
|
val executable = """npm""";
|
||||||
|
try {
|
||||||
|
runTauriCli(executable)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||||
|
// Try different Windows-specific extensions
|
||||||
|
val fallbacks = listOf(
|
||||||
|
"$executable.exe",
|
||||||
|
"$executable.cmd",
|
||||||
|
"$executable.bat",
|
||||||
|
)
|
||||||
|
|
||||||
|
var lastException: Exception = e
|
||||||
|
for (fallback in fallbacks) {
|
||||||
|
try {
|
||||||
|
runTauriCli(fallback)
|
||||||
|
return
|
||||||
|
} catch (fallbackException: Exception) {
|
||||||
|
lastException = fallbackException
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastException
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun runTauriCli(executable: String) {
|
||||||
|
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||||
|
val target = target ?: throw GradleException("target cannot be null")
|
||||||
|
val release = release ?: throw GradleException("release cannot be null")
|
||||||
|
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
|
||||||
|
|
||||||
|
project.exec {
|
||||||
|
workingDir(File(project.projectDir, rootDirRel))
|
||||||
|
executable(executable)
|
||||||
|
args(args)
|
||||||
|
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||||
|
args("-vv")
|
||||||
|
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||||
|
args("-v")
|
||||||
|
}
|
||||||
|
if (release) {
|
||||||
|
args("--release")
|
||||||
|
}
|
||||||
|
args(listOf("--target", target))
|
||||||
|
}.assertNormalExitValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.Plugin
|
||||||
|
import org.gradle.api.Project
|
||||||
|
import org.gradle.kotlin.dsl.configure
|
||||||
|
import org.gradle.kotlin.dsl.get
|
||||||
|
|
||||||
|
const val TASK_GROUP = "rust"
|
||||||
|
|
||||||
|
open class Config {
|
||||||
|
lateinit var rootDirRel: String
|
||||||
|
}
|
||||||
|
|
||||||
|
open class RustPlugin : Plugin<Project> {
|
||||||
|
private lateinit var config: Config
|
||||||
|
|
||||||
|
override fun apply(project: Project) = with(project) {
|
||||||
|
config = extensions.create("rust", Config::class.java)
|
||||||
|
|
||||||
|
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||||
|
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||||
|
|
||||||
|
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||||
|
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||||
|
|
||||||
|
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||||
|
|
||||||
|
extensions.configure<ApplicationExtension> {
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
flavorDimensions.add("abi")
|
||||||
|
productFlavors {
|
||||||
|
create("universal") {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters += abiList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaultArchList.forEachIndexed { index, arch ->
|
||||||
|
create(arch) {
|
||||||
|
dimension = "abi"
|
||||||
|
ndk {
|
||||||
|
abiFilters.add(defaultAbiList[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
for (profile in listOf("debug", "release")) {
|
||||||
|
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||||
|
val buildTask = tasks.maybeCreate(
|
||||||
|
"rustBuildUniversal$profileCapitalized",
|
||||||
|
DefaultTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for all targets"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||||
|
|
||||||
|
for (targetPair in targetsList.withIndex()) {
|
||||||
|
val targetName = targetPair.value
|
||||||
|
val targetArch = archList[targetPair.index]
|
||||||
|
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||||
|
val targetBuildTask = project.tasks.maybeCreate(
|
||||||
|
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||||
|
BuildTask::class.java
|
||||||
|
).apply {
|
||||||
|
group = TASK_GROUP
|
||||||
|
description = "Build dynamic library in $profile mode for $targetArch"
|
||||||
|
rootDirRel = config.rootDirRel
|
||||||
|
target = targetName
|
||||||
|
release = profile == "release"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTask.dependsOn(targetBuildTask)
|
||||||
|
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||||
|
targetBuildTask
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app"s APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
android.nonFinalResIds=false
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#Tue May 10 19:22:52 CST 2022
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright 2015 the original author or authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=`expr $i + 1`
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
0) set -- ;;
|
||||||
|
1) set -- "$args0" ;;
|
||||||
|
2) set -- "$args0" "$args1" ;;
|
||||||
|
3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=`save "$@"`
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
include ':app'
|
||||||
|
|
||||||
|
apply from: 'tauri.settings.gradle'
|
||||||
@@ -2,5 +2,13 @@
|
|||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS", "1");
|
||||||
|
}
|
||||||
|
|
||||||
app_lib::run();
|
app_lib::run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "dumpsterChat",
|
"productName": "dumpsterChat",
|
||||||
"version": "0.2.0",
|
"version": "0.2.9",
|
||||||
"identifier": "coffee.dustin.dumpsterchat",
|
"identifier": "coffee.dustin.dumpster",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Layout } from './components/Layout.tsx';
|
|||||||
import { ChatArea } from './components/ChatArea.tsx';
|
import { ChatArea } from './components/ChatArea.tsx';
|
||||||
import { UserSettings } from './components/UserSettings.tsx';
|
import { UserSettings } from './components/UserSettings.tsx';
|
||||||
import { BotManager } from './components/BotManager.tsx';
|
import { BotManager } from './components/BotManager.tsx';
|
||||||
|
import { BotStore } from './components/BotStore.tsx';
|
||||||
import { CommandManager } from './components/CommandManager.tsx';
|
import { CommandManager } from './components/CommandManager.tsx';
|
||||||
import { RoleManager } from './components/RoleManager.tsx';
|
import { RoleManager } from './components/RoleManager.tsx';
|
||||||
import { JoinServer } from './components/JoinServer.tsx';
|
import { JoinServer } from './components/JoinServer.tsx';
|
||||||
@@ -69,11 +70,23 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/bots"
|
path="/bots"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<BotStore />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/bots/manage"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div className="h-full w-full flex flex-col bg-gb-bg">
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
← [BACK]
|
← [BACK]
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gb-orange font-mono text-sm">BOT MANAGER</span>
|
<span className="text-gb-orange font-mono text-sm">BOT MANAGER</span>
|
||||||
@@ -91,7 +104,7 @@ function App() {
|
|||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<div className="h-full w-full flex flex-col bg-gb-bg">
|
<div className="h-full w-full flex flex-col bg-gb-bg">
|
||||||
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
<header className="flex items-center gap-4 px-4 py-2 border-b border-gb-bg-t bg-gb-bg-s">
|
||||||
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
<Link to="/bots/manage" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
← [BACK]
|
← [BACK]
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-gb-orange font-mono text-sm">SLASH COMMANDS</span>
|
<span className="text-gb-orange font-mono text-sm">SLASH COMMANDS</span>
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useVoiceStore } from '../stores/voice.ts';
|
import { useVoiceStore } from '../stores/voice.ts';
|
||||||
import { Track } from 'livekit-client';
|
import { Track, RoomEvent } from 'livekit-client';
|
||||||
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
import type { Participant, TrackPublication, Room } from 'livekit-client';
|
||||||
|
|
||||||
|
// ponytail: global set of audio elements blocked by autoplay policy
|
||||||
|
const pendingAudio = new Set<HTMLAudioElement>();
|
||||||
|
|
||||||
|
function flushPendingAudio() {
|
||||||
|
for (const el of pendingAudio) {
|
||||||
|
el.play().catch(() => {});
|
||||||
|
pendingAudio.delete(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let listenerAttached = false;
|
||||||
|
function ensureInteractionListener() {
|
||||||
|
if (listenerAttached) return;
|
||||||
|
listenerAttached = true;
|
||||||
|
document.addEventListener('click', flushPendingAudio, { once: false });
|
||||||
|
document.addEventListener('keydown', flushPendingAudio, { once: false });
|
||||||
|
}
|
||||||
|
|
||||||
function RemoteAudioTrack({ participant, room }: { participant: Participant; room: Room }) {
|
function RemoteAudioTrack({ participant, room }: { participant: Participant; room: Room }) {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
@@ -12,44 +30,35 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
|
|||||||
|
|
||||||
let pub: TrackPublication | undefined;
|
let pub: TrackPublication | undefined;
|
||||||
|
|
||||||
const attachTrack = () => {
|
const tryPlay = () => {
|
||||||
|
el.play().catch(() => {
|
||||||
|
pendingAudio.add(el);
|
||||||
|
ensureInteractionListener();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachIfReady = () => {
|
||||||
pub = participant.getTrackPublication(Track.Source.Microphone);
|
pub = participant.getTrackPublication(Track.Source.Microphone);
|
||||||
if (pub?.track && el) {
|
if (pub?.track && el) {
|
||||||
pub.track.attach(el);
|
pub.track.attach(el);
|
||||||
el.play().catch(() => {});
|
tryPlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const detachTrack = () => {
|
attachIfReady();
|
||||||
if (pub?.track && el) {
|
|
||||||
pub.track.detach(el);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
attachTrack();
|
const handleSubscribed = (track: Track, _pub: TrackPublication, p: Participant) => {
|
||||||
|
if (p.identity === participant.identity && _pub.source === Track.Source.Microphone) {
|
||||||
const handlePublished = (publication: TrackPublication) => {
|
|
||||||
if (publication.source === Track.Source.Microphone && publication.track) {
|
|
||||||
publication.track.attach(el);
|
|
||||||
el.play().catch(() => {});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
room.on('trackPublished' as any, handlePublished);
|
|
||||||
|
|
||||||
// If a track is subscribed later
|
|
||||||
const handleSubscribed = (track: Track, publication: TrackPublication, trackParticipant: Participant) => {
|
|
||||||
if (trackParticipant.identity === participant.identity && publication.source === Track.Source.Microphone) {
|
|
||||||
track.attach(el);
|
track.attach(el);
|
||||||
el.play().catch(() => {});
|
tryPlay();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
room.on('trackSubscribed' as any, handleSubscribed);
|
room.on(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
detachTrack();
|
if (pub?.track) pub.track.detach(el);
|
||||||
room.off('trackPublished' as any, handlePublished);
|
pendingAudio.delete(el);
|
||||||
room.off('trackSubscribed' as any, handleSubscribed);
|
room.off(RoomEvent.TrackSubscribed, handleSubscribed);
|
||||||
};
|
};
|
||||||
}, [participant, room]);
|
}, [participant, room]);
|
||||||
|
|
||||||
@@ -58,6 +67,24 @@ function RemoteAudioTrack({ participant, room }: { participant: Participant; roo
|
|||||||
|
|
||||||
export function AudioRenderers() {
|
export function AudioRenderers() {
|
||||||
const room = useVoiceStore((s) => s._room);
|
const room = useVoiceStore((s) => s._room);
|
||||||
|
// ponytail: use a counter that increments on every participant change
|
||||||
|
// zustand's Object.is check on the participants array wasn't triggering re-renders
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room) return;
|
||||||
|
const bump = () => setTick((t) => t + 1);
|
||||||
|
room.on(RoomEvent.ParticipantConnected, bump);
|
||||||
|
room.on(RoomEvent.ParticipantDisconnected, bump);
|
||||||
|
room.on(RoomEvent.TrackSubscribed, bump);
|
||||||
|
room.on(RoomEvent.TrackUnsubscribed, bump);
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantConnected, bump);
|
||||||
|
room.off(RoomEvent.ParticipantDisconnected, bump);
|
||||||
|
room.off(RoomEvent.TrackSubscribed, bump);
|
||||||
|
room.off(RoomEvent.TrackUnsubscribed, bump);
|
||||||
|
};
|
||||||
|
}, [room]);
|
||||||
|
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,30 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useBotStore, type Bot } from '../stores/bot.ts';
|
import { useBotStore, type Bot } from '../stores/bot.ts';
|
||||||
import { useServerStore } from '../stores/server.ts';
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
import { useChannelStore } from '../stores/channel.ts';
|
||||||
|
|
||||||
|
// ponytail: config fields per bot type, add new types here
|
||||||
|
const BOT_TYPE_CONFIGS: Record<string, { label: string; fields: { key: string; label: string; type: 'text' | 'number' | 'channel'; placeholder: string }[] }> = {
|
||||||
|
steamfree: {
|
||||||
|
label: 'Steam Free Games',
|
||||||
|
fields: [
|
||||||
|
{ key: 'channel_id', label: 'CHANNEL', type: 'channel', placeholder: 'select channel' },
|
||||||
|
{ key: 'poll_minutes', label: 'POLL INTERVAL (min)', type: 'number', placeholder: '30' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
confess: {
|
||||||
|
label: 'Anonymous Confessions',
|
||||||
|
fields: [
|
||||||
|
{ key: 'channel_id', label: 'CONFESSIONS CHANNEL', type: 'channel', placeholder: 'where confessions land' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
leaderboard: {
|
||||||
|
label: 'Shitpost Leaderboard',
|
||||||
|
fields: [
|
||||||
|
{ key: 'channel_id', label: 'CHANNEL', type: 'channel', placeholder: 'where recaps land' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export function BotManager() {
|
export function BotManager() {
|
||||||
const bots = useBotStore((s) => s.bots);
|
const bots = useBotStore((s) => s.bots);
|
||||||
@@ -16,10 +40,15 @@ export function BotManager() {
|
|||||||
|
|
||||||
const servers = useServerStore((s) => s.servers);
|
const servers = useServerStore((s) => s.servers);
|
||||||
const fetchServers = useServerStore((s) => s.fetchServers);
|
const fetchServers = useServerStore((s) => s.fetchServers);
|
||||||
|
const channelsByServer = useChannelStore((s) => s.channelsByServer);
|
||||||
|
const fetchChannels = useChannelStore((s) => s.fetchChannels);
|
||||||
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [createName, setCreateName] = useState('');
|
const [createName, setCreateName] = useState('');
|
||||||
const [createDesc, setCreateDesc] = useState('');
|
const [createDesc, setCreateDesc] = useState('');
|
||||||
|
const [createType, setCreateType] = useState('');
|
||||||
|
const [createConfig, setCreateConfig] = useState<Record<string, string>>({});
|
||||||
|
const [channelServerId, setChannelServerId] = useState('');
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [editDesc, setEditDesc] = useState('');
|
const [editDesc, setEditDesc] = useState('');
|
||||||
@@ -37,10 +66,20 @@ export function BotManager() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createName.trim()) return;
|
if (!createName.trim()) return;
|
||||||
try {
|
try {
|
||||||
const result = await createBot(createName.trim(), createDesc.trim());
|
// Build config object with proper types
|
||||||
|
const cfg: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(createConfig)) {
|
||||||
|
if (v === '') continue;
|
||||||
|
const fieldDef = BOT_TYPE_CONFIGS[createType]?.fields.find((f) => f.key === k);
|
||||||
|
cfg[k] = fieldDef?.type === 'number' ? parseInt(v, 10) : v;
|
||||||
|
}
|
||||||
|
const result = await createBot(createName.trim(), createDesc.trim(), createType || undefined, Object.keys(cfg).length ? cfg : undefined);
|
||||||
setTokenDisplay({ botId: result.id, token: result.token });
|
setTokenDisplay({ botId: result.id, token: result.token });
|
||||||
setCreateName('');
|
setCreateName('');
|
||||||
setCreateDesc('');
|
setCreateDesc('');
|
||||||
|
setCreateType('');
|
||||||
|
setCreateConfig({});
|
||||||
|
setChannelServerId('');
|
||||||
setShowCreate(false);
|
setShowCreate(false);
|
||||||
} catch {
|
} catch {
|
||||||
// error handled in store
|
// error handled in store
|
||||||
@@ -102,6 +141,8 @@ export function BotManager() {
|
|||||||
setEditDesc(bot.description);
|
setEditDesc(bot.description);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const typeConfig = createType ? BOT_TYPE_CONFIGS[createType] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
@@ -180,13 +221,70 @@ export function BotManager() {
|
|||||||
placeholder="what does this bot do?"
|
placeholder="what does this bot do?"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">TYPE:</label>
|
||||||
|
<select
|
||||||
|
value={createType}
|
||||||
|
onChange={(e) => { setCreateType(e.target.value); setCreateConfig({}); }}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
>
|
||||||
|
<option value="">External (connects via WebSocket)</option>
|
||||||
|
{Object.entries(BOT_TYPE_CONFIGS).map(([key, cfg]) => (
|
||||||
|
<option key={key} value={key}>{cfg.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{typeConfig && typeConfig.fields.map((field) => (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="block text-gb-fg-f mb-1 font-mono text-xs">{field.label}:</label>
|
||||||
|
{field.type === 'channel' ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<select
|
||||||
|
value={channelServerId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setChannelServerId(e.target.value);
|
||||||
|
setCreateConfig((prev) => ({ ...prev, [field.key]: '' }));
|
||||||
|
if (e.target.value) fetchChannels(e.target.value);
|
||||||
|
}}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
>
|
||||||
|
<option value="">-- select server first --</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={createConfig[field.key] || ''}
|
||||||
|
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
disabled={!channelServerId}
|
||||||
|
>
|
||||||
|
<option value="">-- select channel --</option>
|
||||||
|
{(channelsByServer[channelServerId] || [])
|
||||||
|
.filter((c) => c.type === 'text')
|
||||||
|
.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>#{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.type}
|
||||||
|
value={createConfig[field.key] || ''}
|
||||||
|
onChange={(e) => setCreateConfig((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
<button type="submit" className="terminal-button" disabled={loading || !createName.trim()}>
|
||||||
{loading ? '[CREATING...]' : '[SAVE]'}
|
{loading ? '[CREATING...]' : '[SAVE]'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); }}
|
onClick={() => { setShowCreate(false); setCreateName(''); setCreateDesc(''); setCreateType(''); setCreateConfig({}); setChannelServerId(''); }}
|
||||||
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm"
|
||||||
>
|
>
|
||||||
[CANCEL]
|
[CANCEL]
|
||||||
@@ -255,6 +353,9 @@ export function BotManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
[{bot.name}]
|
[{bot.name}]
|
||||||
|
{bot.bot_type && (
|
||||||
|
<span className="text-gb-aqua text-xs ml-2">({BOT_TYPE_CONFIGS[bot.bot_type]?.label || bot.bot_type})</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
{bot.description && (
|
{bot.description && (
|
||||||
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
<p className="text-gb-fg-f font-mono text-xs mt-1 truncate">
|
||||||
@@ -348,8 +449,8 @@ export function BotManager() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
<div className="mt-6 pt-4 border-t border-gb-bg-t">
|
||||||
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
<Link to="/bots" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
{'<'} [BACK TO CHAT]
|
{'<'} [BACK TO STORE]
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useBotStore, type StoreBot } from '../stores/bot.ts';
|
||||||
|
import { useServerStore } from '../stores/server.ts';
|
||||||
|
import { useAuthStore } from '../stores/auth.ts';
|
||||||
|
|
||||||
|
export function BotStore() {
|
||||||
|
const storeBots = useBotStore((s) => s.storeBots);
|
||||||
|
const loading = useBotStore((s) => s.loading);
|
||||||
|
const error = useBotStore((s) => s.error);
|
||||||
|
const fetchStoreBots = useBotStore((s) => s.fetchStoreBots);
|
||||||
|
const addToServer = useBotStore((s) => s.addToServer);
|
||||||
|
|
||||||
|
const servers = useServerStore((s) => s.servers);
|
||||||
|
const fetchServers = useServerStore((s) => s.fetchServers);
|
||||||
|
const currentUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
|
||||||
|
const [addBotId, setAddBotId] = useState<string | null>(null);
|
||||||
|
const [selectedServer, setSelectedServer] = useState('');
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStoreBots();
|
||||||
|
fetchServers();
|
||||||
|
}, [fetchStoreBots, fetchServers]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!addBotId || !selectedServer) return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await addToServer(addBotId, selectedServer);
|
||||||
|
setAddBotId(null);
|
||||||
|
setSelectedServer('');
|
||||||
|
fetchStoreBots(); // refresh counts
|
||||||
|
} catch {
|
||||||
|
// error in store
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = storeBots.filter((b) =>
|
||||||
|
b.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
b.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-gb-bg p-4 md:p-8 overflow-y-auto">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<div className="border border-gb-bg-t p-6">
|
||||||
|
<pre className="text-gb-orange font-mono text-center mb-2">
|
||||||
|
{'┌──────────────────────────────────┐\n'}
|
||||||
|
{'│ === BOT STORE === │\n'}
|
||||||
|
{'└──────────────────────────────────┘'}
|
||||||
|
</pre>
|
||||||
|
<p className="text-gb-fg-f font-mono text-xs text-center mb-6">
|
||||||
|
browse and add bots to your servers
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-gb-red text-sm font-mono mb-4">ERR: {error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="search bots..."
|
||||||
|
className="terminal-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bot list */}
|
||||||
|
{loading && storeBots.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-sm">[loading...]</p>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-sm">[no bots found]</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((bot) => (
|
||||||
|
<StoreBotCard
|
||||||
|
key={bot.id}
|
||||||
|
bot={bot}
|
||||||
|
isOwner={bot.owner_id === currentUserId}
|
||||||
|
onAdd={() => { setAddBotId(bot.id); setSelectedServer(''); }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add to server modal */}
|
||||||
|
{addBotId && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<div className="border border-gb-bg-t bg-gb-bg p-6 max-w-md w-full mx-4">
|
||||||
|
<p className="text-gb-orange font-mono text-sm mb-4">
|
||||||
|
{'>'} ADD BOT TO SERVER
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={selectedServer}
|
||||||
|
onChange={(e) => setSelectedServer(e.target.value)}
|
||||||
|
className="terminal-input w-full mb-4"
|
||||||
|
>
|
||||||
|
<option value="">-- select server --</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
disabled={!selectedServer || adding}
|
||||||
|
>
|
||||||
|
{adding ? '[ADDING...]' : '[ADD]'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAddBotId(null); setSelectedServer(''); }}
|
||||||
|
className="text-gb-fg-f hover:text-gb-aqua font-mono text-xs"
|
||||||
|
>
|
||||||
|
[CANCEL]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-gb-bg-t flex justify-between">
|
||||||
|
<Link to="/" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
|
{'<'} [BACK TO CHAT]
|
||||||
|
</Link>
|
||||||
|
<Link to="/bots/manage" className="text-gb-fg-f hover:text-gb-aqua font-mono text-sm">
|
||||||
|
[MY BOTS]
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StoreBotCard({ bot, isOwner, onAdd }: { bot: StoreBot; isOwner: boolean; onAdd: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="border border-gb-bg-t p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-gb-green font-mono text-sm">
|
||||||
|
{bot.avatar && (
|
||||||
|
<img
|
||||||
|
src={bot.avatar}
|
||||||
|
alt=""
|
||||||
|
className="inline w-5 h-5 mr-1 align-middle border border-gb-bg-t"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
[{bot.name}]
|
||||||
|
{isOwner && (
|
||||||
|
<span className="text-gb-fg-f text-xs ml-2">(yours)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{bot.description && (
|
||||||
|
<p className="text-gb-fg-f font-mono text-xs mt-1">{bot.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-gb-aqua font-mono text-xs">
|
||||||
|
{bot.server_count} {bot.server_count === 1 ? 'server' : 'servers'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
className="terminal-button text-xs"
|
||||||
|
>
|
||||||
|
[ADD TO SERVER]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
const [end, setEnd] = useState('');
|
const [end, setEnd] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||||
|
const [view, setView] = useState<'grid' | 'list'>('list');
|
||||||
|
|
||||||
const monthStart = useMemo(() => new Date(date.getFullYear(), date.getMonth(), 1), [date]);
|
const monthStart = useMemo(() => new Date(date.getFullYear(), date.getMonth(), 1), [date]);
|
||||||
const monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]);
|
const monthEnd = useMemo(() => new Date(date.getFullYear(), date.getMonth() + 1, 0), [date]);
|
||||||
@@ -49,7 +50,11 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`;
|
const to = `${date.getFullYear()}-${String(date.getMonth() + 2).padStart(2, '0')}-01T00:00:00Z`;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
api.get<CalendarEvent[]>(`/channels/${channelId}/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`)
|
||||||
.then((data) => setEvents(Array.isArray(data) ? data : []))
|
.then((data) => {
|
||||||
|
console.log('[calendar] loaded', data?.length, 'events for channel', channelId);
|
||||||
|
setEvents(Array.isArray(data) ? data : []);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('[calendar] fetch error:', err))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [channelId, date]);
|
}, [channelId, date]);
|
||||||
|
|
||||||
@@ -61,6 +66,29 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const weeks = useMemo(() => {
|
||||||
|
const sorted = [...events].sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
|
const grouped: { label: string; events: CalendarEvent[] }[] = [];
|
||||||
|
let currentKey = '';
|
||||||
|
for (const ev of sorted) {
|
||||||
|
const d = new Date(ev.start_time);
|
||||||
|
// week key = Monday of that week
|
||||||
|
const dayOfWeek = d.getDay();
|
||||||
|
const monday = new Date(d);
|
||||||
|
monday.setDate(d.getDate() - ((dayOfWeek + 6) % 7));
|
||||||
|
const key = monday.toISOString().slice(0, 10);
|
||||||
|
if (key !== currentKey) {
|
||||||
|
const sun = new Date(monday);
|
||||||
|
sun.setDate(monday.getDate() + 6);
|
||||||
|
const label = `${monday.toLocaleDateString('default', { month: 'short', day: 'numeric' })} \u2013 ${sun.toLocaleDateString('default', { month: 'short', day: 'numeric' })}`;
|
||||||
|
grouped.push({ label, events: [] });
|
||||||
|
currentKey = key;
|
||||||
|
}
|
||||||
|
grouped[grouped.length - 1].events.push(ev);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!title || !start) return;
|
if (!title || !start) return;
|
||||||
const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined };
|
const payload = { title, start_time: start, end_time: end || undefined, description: description || undefined };
|
||||||
@@ -85,8 +113,12 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
<div className="flex flex-col h-full bg-gb-bg">
|
<div className="flex flex-col h-full bg-gb-bg">
|
||||||
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
<div className="terminal-border border-t-0 border-x-0 px-3 py-2 text-gb-fg-s flex items-center justify-between">
|
||||||
<span>○ {channelName}</span>
|
<span>○ {channelName}</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setView('list')} className={`text-xs font-mono ${view === 'list' ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}>[LIST]</button>
|
||||||
|
<button onClick={() => setView('grid')} className={`text-xs font-mono ${view === 'grid' ? 'text-gb-orange' : 'text-gb-fg-f hover:text-gb-orange'}`}>[GRID]</button>
|
||||||
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
|
<button onClick={() => setShowForm((p) => !p)} className="text-xs text-gb-fg-f hover:text-gb-orange font-mono">[NEW EVENT]</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
|
<div className="p-3 border-b border-gb-bg-t space-y-2 font-mono text-xs">
|
||||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" className="terminal-input w-full" />
|
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" className="terminal-input w-full" />
|
||||||
@@ -103,6 +135,8 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
{loading && <div className="p-3 text-gb-fg-f text-xs font-mono">[loading...]</div>}
|
{loading && <div className="p-3 text-gb-fg-f text-xs font-mono">[loading...]</div>}
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{view === 'grid' ? (
|
||||||
|
<>
|
||||||
<div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1">
|
<div className="grid grid-cols-7 gap-1 text-center text-xs font-mono text-gb-fg-s mb-1">
|
||||||
{['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)}
|
{['S','M','T','W','T','F','S'].map((d) => <div key={d}>{d}</div>)}
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +161,40 @@ export function CalendarView({ channelId, channelName }: CalendarViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 font-mono text-xs">
|
||||||
|
{weeks.length === 0 && !loading && (
|
||||||
|
<div className="text-gb-fg-f">No events this month.</div>
|
||||||
|
)}
|
||||||
|
{weeks.map((week) => (
|
||||||
|
<div key={week.label}>
|
||||||
|
<div className="text-gb-orange font-bold mb-1 border-b border-gb-bg-t pb-1">
|
||||||
|
{week.label}
|
||||||
|
</div>
|
||||||
|
{week.events.map((ev) => {
|
||||||
|
const s = new Date(ev.start_time);
|
||||||
|
const e = ev.end_time ? new Date(ev.end_time) : null;
|
||||||
|
const dayName = s.toLocaleDateString('default', { weekday: 'short' });
|
||||||
|
const dateStr = s.toLocaleDateString('default', { month: 'short', day: 'numeric' });
|
||||||
|
const startStr = s.toLocaleTimeString('default', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
const endStr = e ? e.toLocaleTimeString('default', { hour: 'numeric', minute: '2-digit' }) : '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={ev.id}
|
||||||
|
onClick={() => setSelectedEvent(ev)}
|
||||||
|
className="w-full text-left px-2 py-1 hover:bg-gb-bg-t rounded-sm flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-gb-fg-f w-20 shrink-0">{dayName} {dateStr}</span>
|
||||||
|
<span className="text-gb-fg-s w-28 shrink-0">{startStr}{endStr ? ` \u2013 ${endStr}` : ''}</span>
|
||||||
|
<span className="text-gb-fg truncate">{ev.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedEvent && (
|
{selectedEvent && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gb-bg/90 p-4" onClick={() => setSelectedEvent(null)}>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { usePermissionStore, PERMISSION_LABELS, PERMS, type ChannelOverride, hasPermission } from "../stores/permissions.ts";
|
import { api } from '../lib/api.ts';
|
||||||
import { useRoleStore } from "../stores/role.ts";
|
import {
|
||||||
import { useMemberStore } from "../stores/member.ts";
|
usePermissionStore,
|
||||||
import { useChannelStore } from "../stores/channel.ts";
|
PERMS,
|
||||||
import { api } from "../lib/api.ts";
|
PERM_CATEGORIES,
|
||||||
|
PERMISSION_LABELS,
|
||||||
|
type ChannelOverride,
|
||||||
|
type PermissionKey,
|
||||||
|
} from '../stores/permissions.ts';
|
||||||
|
import { useRoleStore, type Role } from '../stores/role.ts';
|
||||||
|
import { useMemberStore, type Member } from '../stores/member.ts';
|
||||||
|
|
||||||
interface ChannelSettingsModalProps {
|
interface ChannelSettingsModalProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -12,227 +18,626 @@ interface ChannelSettingsModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'overview' | 'permissions';
|
type TargetType = 'role' | 'user';
|
||||||
type TargetMode = 'role' | 'user';
|
type TriState = 'allow' | 'deny' | 'inherit';
|
||||||
|
|
||||||
interface OverrideFormData {
|
interface DraftOverride {
|
||||||
targetType: TargetMode;
|
target_type: TargetType;
|
||||||
targetId: string;
|
target_id: string;
|
||||||
allow: number;
|
allow: number;
|
||||||
deny: number;
|
deny: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverrideMatrix({ allow, deny, onChange }: { allow: number; deny: number; onChange: (allow: number, deny: number) => void }) {
|
// Channel-scoped perms only (admin / manage-server stay at role level).
|
||||||
const toggle = (flag: number, state: 'allow' | 'deny' | 'inherit') => {
|
const CHANNEL_PERM_KEYS = new Set<PermissionKey>([
|
||||||
let nextAllow = allow;
|
'VIEW_CHANNEL',
|
||||||
let nextDeny = deny;
|
'SEND_MESSAGES',
|
||||||
if (state === 'allow') {
|
'MANAGE_MESSAGES',
|
||||||
nextAllow |= flag;
|
'ADD_REACTIONS',
|
||||||
nextDeny &= ~flag;
|
'EMBED_LINKS',
|
||||||
} else if (state === 'deny') {
|
'ATTACH_FILES',
|
||||||
nextAllow &= ~flag;
|
'MENTION_EVERYONE',
|
||||||
nextDeny |= flag;
|
'USE_EXTERNAL_EMOJIS',
|
||||||
} else {
|
'CREATE_INSTANT_INVITE',
|
||||||
nextAllow &= ~flag;
|
'CONNECT_VOICE',
|
||||||
nextDeny &= ~flag;
|
'SPEAK_VOICE',
|
||||||
}
|
'SHARE_SCREEN',
|
||||||
onChange(nextAllow, nextDeny);
|
'MUTE_MEMBERS',
|
||||||
};
|
]);
|
||||||
|
|
||||||
const state = (flag: number): 'allow' | 'deny' | 'inherit' => {
|
const CHANNEL_PERM_CATEGORIES = PERM_CATEGORIES.map((cat) => ({
|
||||||
if (hasPermission(allow, flag)) return 'allow';
|
...cat,
|
||||||
if (hasPermission(deny, flag)) return 'deny';
|
keys: cat.keys.filter((k) => CHANNEL_PERM_KEYS.has(k)),
|
||||||
return 'inherit';
|
})).filter((cat) => cat.keys.length > 0);
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
function targetKey(type: TargetType, id: string) {
|
||||||
<div className="space-y-1 max-h-64 overflow-y-auto border border-gb-bg-t p-2">
|
return `${type}:${id}`;
|
||||||
{(Object.keys(PERMISSION_LABELS) as (keyof typeof PERMISSION_LABELS)[]).map((key) => {
|
|
||||||
const flag = PERMS[key];
|
|
||||||
const s = state(flag);
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center justify-between text-xs font-mono py-1">
|
|
||||||
<span className="text-gb-fg">{PERMISSION_LABELS[key]}</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(['inherit', 'allow', 'deny'] as const).map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt}
|
|
||||||
onClick={() => toggle(flag, opt)}
|
|
||||||
className={`px-2 py-0.5 border ${
|
|
||||||
s === opt
|
|
||||||
? opt === 'allow' ? 'bg-gb-green text-gb-bg border-gb-green' :
|
|
||||||
opt === 'deny' ? 'bg-gb-red text-gb-bg border-gb-red' :
|
|
||||||
'bg-gb-orange text-gb-bg border-gb-orange'
|
|
||||||
: 'border-gb-bg-t text-gb-fg-f hover:border-gb-fg-t'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{opt.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelSettingsModal({ serverId, channelId, channelName, onClose }: ChannelSettingsModalProps) {
|
function draftFromOverride(o: ChannelOverride): DraftOverride {
|
||||||
const [tab, setTab] = useState<Tab>('overview');
|
return {
|
||||||
const [form, setForm] = useState<OverrideFormData>({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
target_type: o.target_type,
|
||||||
const [saving, setSaving] = useState(false);
|
target_id: o.target_id,
|
||||||
const [error, setError] = useState<string | null>(null);
|
allow: o.allow_bitflags,
|
||||||
const [editName, setEditName] = useState(channelName);
|
deny: o.deny_bitflags,
|
||||||
const [savingName, setSavingName] = useState(false);
|
};
|
||||||
const updateChannel = useChannelStore((s) => s.updateChannel);
|
}
|
||||||
|
|
||||||
const overrides = usePermissionStore((s) => s.overridesByChannel[channelId] || []);
|
function emptyDraft(type: TargetType, id: string): DraftOverride {
|
||||||
|
return { target_type: type, target_id: id, allow: 0, deny: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirty(saved: ChannelOverride | undefined, draft: DraftOverride): boolean {
|
||||||
|
if (!saved) return draft.allow !== 0 || draft.deny !== 0;
|
||||||
|
return saved.allow_bitflags !== draft.allow || saved.deny_bitflags !== draft.deny;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triState(allow: number, deny: number, flag: number): TriState {
|
||||||
|
if ((deny & flag) === flag) return 'deny';
|
||||||
|
if ((allow & flag) === flag) return 'allow';
|
||||||
|
return 'inherit';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTri(draft: DraftOverride, flag: number, state: TriState): DraftOverride {
|
||||||
|
let allow = draft.allow & ~flag;
|
||||||
|
let deny = draft.deny & ~flag;
|
||||||
|
if (state === 'allow') allow |= flag;
|
||||||
|
if (state === 'deny') deny |= flag;
|
||||||
|
return { ...draft, allow, deny };
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(member: Member): string {
|
||||||
|
return member.nickname || member.display_name || member.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChannelSettingsModal({
|
||||||
|
serverId,
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
onClose,
|
||||||
|
}: ChannelSettingsModalProps) {
|
||||||
|
const [name, setName] = useState(channelName);
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
const [nameError, setNameError] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<'general' | 'permissions'>('general');
|
||||||
|
|
||||||
|
const overridesByChannel = usePermissionStore((s) => s.overridesByChannel);
|
||||||
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
|
const fetchOverrides = usePermissionStore((s) => s.fetchOverrides);
|
||||||
const setOverride = usePermissionStore((s) => s.setOverride);
|
const setOverride = usePermissionStore((s) => s.setOverride);
|
||||||
const deleteOverride = usePermissionStore((s) => s.deleteOverride);
|
const deleteOverride = usePermissionStore((s) => s.deleteOverride);
|
||||||
|
|
||||||
const roles = useRoleStore((s) => s.roles);
|
const allRoles = useRoleStore((s) => s.roles);
|
||||||
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
const fetchRoles = useRoleStore((s) => s.fetchRoles);
|
||||||
const members = useMemberStore((s) => s.membersByServer[serverId] || []);
|
const membersByServer = useMemberStore((s) => s.membersByServer);
|
||||||
const fetchMembers = useMemberStore((s) => s.fetchMembers);
|
const fetchMembers = useMemberStore((s) => s.fetchMembers);
|
||||||
|
|
||||||
|
const overrides = overridesByChannel[channelId] || [];
|
||||||
|
const roles = useMemo(
|
||||||
|
() =>
|
||||||
|
allRoles
|
||||||
|
.filter((r) => r.server_id === serverId)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.position - a.position || a.name.localeCompare(b.name)),
|
||||||
|
[allRoles, serverId],
|
||||||
|
);
|
||||||
|
const members = useMemo(
|
||||||
|
() => (membersByServer[serverId] || []).filter((m) => !m.is_bot),
|
||||||
|
[membersByServer, serverId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<DraftOverride | null>(null);
|
||||||
|
const [savingPerms, setSavingPerms] = useState(false);
|
||||||
|
const [permError, setPermError] = useState<string | null>(null);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addTab, setAddTab] = useState<'role' | 'user'>('role');
|
||||||
|
const [addQuery, setAddQuery] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchOverrides(channelId).catch(() => setError('Failed to load overrides'));
|
void fetchOverrides(channelId);
|
||||||
fetchRoles(serverId).catch(() => {});
|
void fetchRoles(serverId);
|
||||||
fetchMembers(serverId).catch(() => {});
|
void fetchMembers(serverId);
|
||||||
}, [channelId, serverId, fetchOverrides, fetchRoles, fetchMembers]);
|
}, [channelId, serverId, fetchOverrides, fetchRoles, fetchMembers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (addOpen) {
|
||||||
|
setAddOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose]);
|
}, [onClose, addOpen]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
// Auto-select first override when list loads / selection invalid.
|
||||||
if (!form.targetId) {
|
useEffect(() => {
|
||||||
setError('Select a target');
|
if (tab !== 'permissions') return;
|
||||||
|
if (overrides.length === 0) {
|
||||||
|
if (selectedKey && !draft) {
|
||||||
|
// local-only draft without saved row is fine
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
if (!selectedKey) {
|
||||||
setError(null);
|
setSelectedKey(null);
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stillThere =
|
||||||
|
selectedKey &&
|
||||||
|
(overrides.some((o) => targetKey(o.target_type, o.target_id) === selectedKey) ||
|
||||||
|
(draft && targetKey(draft.target_type, draft.target_id) === selectedKey));
|
||||||
|
if (!stillThere) {
|
||||||
|
const first = overrides[0];
|
||||||
|
setSelectedKey(targetKey(first.target_type, first.target_id));
|
||||||
|
setDraft(draftFromOverride(first));
|
||||||
|
}
|
||||||
|
}, [overrides, selectedKey, draft, tab]);
|
||||||
|
|
||||||
|
const savedForDraft = draft
|
||||||
|
? overrides.find(
|
||||||
|
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const dirty = draft ? isDirty(savedForDraft, draft) : false;
|
||||||
|
|
||||||
|
const resolveLabel = (type: TargetType, id: string): { label: string; color?: string; kind: string } => {
|
||||||
|
if (type === 'role') {
|
||||||
|
const role = roles.find((r) => r.id === id);
|
||||||
|
return {
|
||||||
|
label: role?.name ?? id.slice(0, 8),
|
||||||
|
color: role?.color || '#ebdbb2',
|
||||||
|
kind: role?.is_default ? 'everyone' : 'role',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const m = members.find((x) => x.id === id);
|
||||||
|
return {
|
||||||
|
label: m ? displayName(m) : id.slice(0, 8),
|
||||||
|
kind: 'member',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectTarget = (type: TargetType, id: string, fromOverride?: ChannelOverride) => {
|
||||||
|
if (dirty && !window.confirm('Discard unsaved permission changes?')) return;
|
||||||
|
setSelectedKey(targetKey(type, id));
|
||||||
|
setDraft(fromOverride ? draftFromOverride(fromOverride) : emptyDraft(type, id));
|
||||||
|
setPermError(null);
|
||||||
|
setAddOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingKeys = useMemo(() => {
|
||||||
|
const set = new Set(overrides.map((o) => targetKey(o.target_type, o.target_id)));
|
||||||
|
if (draft) set.add(targetKey(draft.target_type, draft.target_id));
|
||||||
|
return set;
|
||||||
|
}, [overrides, draft]);
|
||||||
|
|
||||||
|
const availableRoles = roles.filter((r) => !existingKeys.has(targetKey('role', r.id)));
|
||||||
|
const availableMembers = members.filter((m) => !existingKeys.has(targetKey('user', m.id)));
|
||||||
|
|
||||||
|
const filteredRoles = availableRoles.filter((r) =>
|
||||||
|
r.name.toLowerCase().includes(addQuery.toLowerCase()),
|
||||||
|
);
|
||||||
|
const filteredMembers = availableMembers.filter((m) => {
|
||||||
|
const q = addQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
m.username.toLowerCase().includes(q) ||
|
||||||
|
displayName(m).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAddRole = (role: Role) => {
|
||||||
|
selectTarget('role', role.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddMember = (member: Member) => {
|
||||||
|
selectTarget('user', member.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePerms = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
setSavingPerms(true);
|
||||||
|
setPermError(null);
|
||||||
try {
|
try {
|
||||||
await setOverride(channelId, form.targetType, form.targetId, form.allow, form.deny);
|
await setOverride(channelId, draft.target_type, draft.target_id, draft.allow, draft.deny);
|
||||||
setForm({ targetType: 'role', targetId: '', allow: 0, deny: 0 });
|
// Re-sync draft from store after save.
|
||||||
|
const list = usePermissionStore.getState().overridesByChannel[channelId] || [];
|
||||||
|
const saved = list.find(
|
||||||
|
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||||
|
);
|
||||||
|
if (saved) setDraft(draftFromOverride(saved));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to save override');
|
setPermError(err instanceof Error ? err.message : 'Failed to save overrides');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSavingPerms(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
const handleRemove = async () => {
|
||||||
const name = editName.trim();
|
if (!draft) return;
|
||||||
if (!name || name === channelName) return;
|
const { label } = resolveLabel(draft.target_type, draft.target_id);
|
||||||
setSavingName(true);
|
if (!window.confirm(`Remove permission overrides for ${label}?`)) return;
|
||||||
setError(null);
|
setPermError(null);
|
||||||
try {
|
try {
|
||||||
const updated = await api.patch<{ id: string; server_id: string; name: string; type: string; category: string | null; position: number; group_id?: string | null }>(
|
const exists = overrides.some(
|
||||||
`/servers/${serverId}/channels/${channelId}`,
|
(o) => o.target_type === draft.target_type && o.target_id === draft.target_id,
|
||||||
{ name }
|
|
||||||
);
|
);
|
||||||
updateChannel(updated as any);
|
if (exists) {
|
||||||
|
await deleteOverride(channelId, draft.target_type, draft.target_id);
|
||||||
|
}
|
||||||
|
setSelectedKey(null);
|
||||||
|
setDraft(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to rename channel');
|
setPermError(err instanceof Error ? err.message : 'Failed to remove override');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveName = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim() || name.trim() === channelName) return;
|
||||||
|
setSavingName(true);
|
||||||
|
setNameError(null);
|
||||||
|
try {
|
||||||
|
await api.patch(`/servers/${serverId}/channels/${channelId}`, { name: name.trim() });
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setNameError(err instanceof Error ? err.message : 'Failed to update channel');
|
||||||
} finally {
|
} finally {
|
||||||
setSavingName(false);
|
setSavingName(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const targets = form.targetType === 'role'
|
// Sidebar entries: all saved overrides + unsaved new draft not yet in list.
|
||||||
? roles.filter((r) => !r.is_default).map((r) => ({ id: r.id, label: r.name }))
|
const sidebarEntries = useMemo(() => {
|
||||||
: members.map((m) => ({ id: m.id, label: m.username }));
|
const keys = new Set<string>();
|
||||||
|
const items: { type: TargetType; id: string; override?: ChannelOverride }[] = [];
|
||||||
|
for (const o of overrides) {
|
||||||
|
const k = targetKey(o.target_type, o.target_id);
|
||||||
|
keys.add(k);
|
||||||
|
items.push({ type: o.target_type, id: o.target_id, override: o });
|
||||||
|
}
|
||||||
|
if (draft) {
|
||||||
|
const k = targetKey(draft.target_type, draft.target_id);
|
||||||
|
if (!keys.has(k)) {
|
||||||
|
items.push({ type: draft.target_type, id: draft.target_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Roles first (everyone, then by name), then members.
|
||||||
|
return items.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === 'role' ? -1 : 1;
|
||||||
|
const la = resolveLabel(a.type, a.id).label.toLowerCase();
|
||||||
|
const lb = resolveLabel(b.type, b.id).label.toLowerCase();
|
||||||
|
return la.localeCompare(lb);
|
||||||
|
});
|
||||||
|
// resolveLabel depends on roles/members; include those
|
||||||
|
}, [overrides, draft, roles, members]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
<div
|
||||||
<div className="bg-gb-bg border border-gb-bg-t w-[600px] max-h-[80vh] flex flex-col font-mono" onClick={(e) => e.stopPropagation()}>
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-gb-bg border border-gb-bg-t w-[min(920px,95vw)] max-h-[85vh] flex flex-col font-mono"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gb-bg-t">
|
||||||
<span className="text-sm text-gb-orange">CHANNEL SETTINGS: #{channelName}</span>
|
<span className="text-sm text-gb-orange">CHANNEL SETTINGS: #{channelName}</span>
|
||||||
<button onClick={onClose} className="text-xs text-gb-red">[x]</button>
|
<button type="button" onClick={onClose} className="text-xs text-gb-red">
|
||||||
|
[x]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex border-b border-gb-bg-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('general')}
|
||||||
|
className={`px-4 py-2 text-xs ${
|
||||||
|
tab === 'general' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f hover:text-gb-orange'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[GENERAL]
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('permissions')}
|
||||||
|
className={`px-4 py-2 text-xs ${
|
||||||
|
tab === 'permissions'
|
||||||
|
? 'text-gb-orange bg-gb-bg-s'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-orange'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[PERMISSIONS]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'general' && (
|
||||||
|
<div className="p-4 overflow-y-auto">
|
||||||
|
<form onSubmit={handleSaveName} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-gb-fg-f text-xs mb-1">CHANNEL NAME</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="terminal-input w-full"
|
||||||
|
maxLength={100}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{nameError && <p className="text-gb-red text-xs">ERR: {nameError}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingName || !name.trim() || name.trim() === channelName}
|
||||||
|
className="terminal-button disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingName ? '[SAVING...]' : '[SAVE]'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onClose} className="text-xs text-gb-fg-f hover:text-gb-red">
|
||||||
|
[CANCEL]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'permissions' && (
|
||||||
|
<div className="flex flex-1 min-h-[55vh] max-h-[70vh]">
|
||||||
|
{/* Left: targets */}
|
||||||
|
<div className="w-52 md:w-60 shrink-0 border-r border-gb-bg-t flex flex-col bg-gb-bg-s">
|
||||||
|
<div className="p-2 border-b border-gb-bg-t space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAddOpen(true);
|
||||||
|
setAddQuery('');
|
||||||
|
setAddTab('role');
|
||||||
|
}}
|
||||||
|
className="w-full terminal-button text-xs"
|
||||||
|
>
|
||||||
|
[+ ADD ROLE / MEMBER]
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-gb-fg-f px-0.5">
|
||||||
|
tip: deny View for @everyone, allow a role for private channels
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{sidebarEntries.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f text-xs p-3">[no overrides yet]</p>
|
||||||
|
)}
|
||||||
|
{sidebarEntries.map((entry) => {
|
||||||
|
const k = targetKey(entry.type, entry.id);
|
||||||
|
const active = k === selectedKey;
|
||||||
|
const { label, color, kind } = resolveLabel(entry.type, entry.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectTarget(entry.type, entry.id, entry.override)}
|
||||||
|
className={`w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 ${
|
||||||
|
active ? 'bg-gb-bg text-gb-fg' : 'text-gb-fg-f hover:bg-gb-bg hover:text-gb-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.type === 'role' ? (
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gb-aqua shrink-0">@</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate" style={entry.type === 'role' && active ? { color } : undefined}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gb-fg-f shrink-0">
|
||||||
|
{kind === 'everyone' ? '@' : entry.type === 'role' ? 'R' : 'U'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: tri-state editor */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col relative">
|
||||||
|
{!draft ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-gb-fg-f text-xs p-6">
|
||||||
|
[add a role or member to set channel permissions]
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{permError && <p className="text-gb-red text-xs">ERR: {permError}</p>}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-gb-fg-f">editing:</span>
|
||||||
|
<span className="text-gb-aqua">
|
||||||
|
{resolveLabel(draft.target_type, draft.target_id).label}
|
||||||
|
</span>
|
||||||
|
<span className="text-gb-fg-f">
|
||||||
|
({draft.target_type === 'role' ? 'role' : 'member'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gb-fg-f">
|
||||||
|
✓ allow · ✗ deny · – inherit from roles
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{CHANNEL_PERM_CATEGORIES.map((cat) => (
|
||||||
|
<div key={cat.name} className="border border-gb-bg-t">
|
||||||
|
<div className="px-3 py-1.5 bg-gb-bg-s text-gb-aqua text-xs border-b border-gb-bg-t">
|
||||||
|
[{cat.name.toUpperCase()}]
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gb-bg-t">
|
||||||
|
{cat.keys.map((key) => {
|
||||||
|
const flag = PERMS[key];
|
||||||
|
const state = triState(draft.allow, draft.deny, flag);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 text-xs"
|
||||||
|
>
|
||||||
|
<span className="text-gb-fg truncate">{PERMISSION_LABELS[key]}</span>
|
||||||
|
<div className="flex shrink-0 border border-gb-bg-t">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Deny"
|
||||||
|
onClick={() => setDraft(applyTri(draft, flag, 'deny'))}
|
||||||
|
className={`px-2 py-0.5 ${
|
||||||
|
state === 'deny'
|
||||||
|
? 'bg-gb-red text-gb-bg'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-red'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
✗
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Inherit"
|
||||||
|
onClick={() => setDraft(applyTri(draft, flag, 'inherit'))}
|
||||||
|
className={`px-2 py-0.5 border-x border-gb-bg-t ${
|
||||||
|
state === 'inherit'
|
||||||
|
? 'bg-gb-bg-s text-gb-fg'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-fg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Allow"
|
||||||
|
onClick={() => setDraft(applyTri(draft, flag, 'allow'))}
|
||||||
|
className={`px-2 py-0.5 ${
|
||||||
|
state === 'allow'
|
||||||
|
? 'bg-gb-green text-gb-bg'
|
||||||
|
: 'text-gb-fg-f hover:text-gb-green'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 border-t border-gb-bg-t px-4 py-3 flex items-center gap-2 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSavePerms()}
|
||||||
|
disabled={savingPerms || !dirty}
|
||||||
|
className="terminal-button text-xs disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{savingPerms ? '[SAVING...]' : '[SAVE CHANGES]'}
|
||||||
|
</button>
|
||||||
|
{dirty && <span className="text-gb-orange text-[10px]">unsaved</span>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleRemove()}
|
||||||
|
className="ml-auto text-xs text-gb-fg-f hover:text-gb-red"
|
||||||
|
>
|
||||||
|
[REMOVE]
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add role/member picker overlay */}
|
||||||
|
{addOpen && (
|
||||||
|
<div className="absolute inset-0 bg-gb-bg/95 z-10 flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-gb-bg-t">
|
||||||
|
<span className="text-xs text-gb-orange">ADD TO CHANNEL</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddOpen(false)}
|
||||||
|
className="text-xs text-gb-fg-f hover:text-gb-red"
|
||||||
|
>
|
||||||
|
[close]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex border-b border-gb-bg-t">
|
<div className="flex border-b border-gb-bg-t">
|
||||||
<button onClick={() => setTab('overview')} className={`px-4 py-2 text-xs ${tab === 'overview' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[OVERVIEW]</button>
|
<button
|
||||||
<button onClick={() => setTab('permissions')} className={`px-4 py-2 text-xs ${tab === 'permissions' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-s hover:text-gb-fg'}`}>[PERMISSIONS]</button>
|
type="button"
|
||||||
|
onClick={() => setAddTab('role')}
|
||||||
|
className={`px-3 py-1.5 text-xs ${
|
||||||
|
addTab === 'role' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[ROLES / GROUPS]
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddTab('user')}
|
||||||
|
className={`px-3 py-1.5 text-xs ${
|
||||||
|
addTab === 'user' ? 'text-gb-orange bg-gb-bg-s' : 'text-gb-fg-f'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
[MEMBERS]
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="p-2 border-b border-gb-bg-t">
|
||||||
{tab === 'overview' && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="text-xs text-gb-fg-f">Channel name</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
<input
|
||||||
value={editName}
|
type="text"
|
||||||
onChange={(e) => setEditName(e.target.value)}
|
value={addQuery}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSaveName(); }}
|
onChange={(e) => setAddQuery(e.target.value)}
|
||||||
className="terminal-input text-sm flex-1"
|
placeholder={addTab === 'role' ? 'filter roles...' : 'filter members...'}
|
||||||
|
className="terminal-input w-full text-xs"
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
onClick={handleSaveName}
|
|
||||||
disabled={savingName || !editName.trim() || editName.trim() === channelName}
|
|
||||||
className="px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{savingName ? 'SAVING...' : '[SAVE]'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{error && <div className="text-xs text-gb-red">ERR: {error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{addTab === 'role' && filteredRoles.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f text-xs p-3">[no roles left to add]</p>
|
||||||
)}
|
)}
|
||||||
{tab === 'permissions' && (
|
{addTab === 'role' &&
|
||||||
<div className="space-y-3">
|
filteredRoles.map((role) => (
|
||||||
<div className="text-xs text-gb-fg-f">Existing overrides</div>
|
|
||||||
{overrides.length === 0 && <div className="text-xs text-gb-fg-f">[none]</div>}
|
|
||||||
{overrides.map((o: ChannelOverride) => (
|
|
||||||
<div key={o.id} className="flex items-center justify-between border border-gb-bg-t p-2 text-xs">
|
|
||||||
<span className="text-gb-fg">{o.target_type}:{o.target_id}</span>
|
|
||||||
<div className="flex gap-2 text-gb-fg-f">
|
|
||||||
<span>allow:{o.allow_bitflags}</span>
|
|
||||||
<span>deny:{o.deny_bitflags}</span>
|
|
||||||
<button onClick={() => deleteOverride(channelId, o.target_type, o.target_id)} className="text-gb-red hover:text-gb-orange">[x]</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="border-t border-gb-bg-t pt-3">
|
|
||||||
<div className="text-xs text-gb-fg-f mb-2">New override</div>
|
|
||||||
<div className="flex gap-2 mb-2">
|
|
||||||
<select
|
|
||||||
value={form.targetType}
|
|
||||||
onChange={(e) => setForm({ ...form, targetType: e.target.value as TargetMode, targetId: '' })}
|
|
||||||
className="terminal-input text-xs"
|
|
||||||
>
|
|
||||||
<option value="role">role</option>
|
|
||||||
<option value="user">user</option>
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={form.targetId}
|
|
||||||
onChange={(e) => setForm({ ...form, targetId: e.target.value })}
|
|
||||||
className="terminal-input text-xs flex-1"
|
|
||||||
>
|
|
||||||
<option value="">-- select --</option>
|
|
||||||
{targets.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>{t.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<OverrideMatrix allow={form.allow} deny={form.deny} onChange={(a, d) => setForm({ ...form, allow: a, deny: d })} />
|
|
||||||
{error && <div className="text-xs text-gb-red mt-2">ERR: {error}</div>}
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
key={role.id}
|
||||||
disabled={saving || !form.targetId}
|
type="button"
|
||||||
className="mt-2 px-3 py-1 bg-gb-orange text-gb-bg text-xs disabled:opacity-50"
|
onClick={() => handleAddRole(role)}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 text-gb-fg-f hover:bg-gb-bg-s hover:text-gb-fg"
|
||||||
>
|
>
|
||||||
{saving ? 'SAVING...' : '[SAVE OVERRIDE]'}
|
<span
|
||||||
|
className="w-2.5 h-2.5 shrink-0 border border-gb-bg-t"
|
||||||
|
style={{ backgroundColor: role.color || '#ebdbb2' }}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{role.name}</span>
|
||||||
|
{role.is_default && (
|
||||||
|
<span className="ml-auto text-[10px] text-gb-fg-f">@everyone</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
|
{addTab === 'user' && filteredMembers.length === 0 && (
|
||||||
|
<p className="text-gb-fg-f text-xs p-3">[no members left to add]</p>
|
||||||
|
)}
|
||||||
|
{addTab === 'user' &&
|
||||||
|
filteredMembers.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddMember(m)}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs border-b border-gb-bg-t flex items-center gap-2 text-gb-fg-f hover:bg-gb-bg-s hover:text-gb-fg"
|
||||||
|
>
|
||||||
|
<span className="text-gb-aqua">@</span>
|
||||||
|
<span className="truncate">{displayName(m)}</span>
|
||||||
|
<span className="ml-auto text-[10px] text-gb-fg-f truncate max-w-[40%]">
|
||||||
|
{m.username}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import Picker, { Theme } from 'emoji-picker-react';
|
|||||||
import { CommandDropdown } from "./CommandDropdown";
|
import { CommandDropdown } from "./CommandDropdown";
|
||||||
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
|
import { findCommand, SLASH_COMMANDS } from "../lib/slashCommands";
|
||||||
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
|
import { PollDisplay, CreatePollModal } from "./Poll.tsx";
|
||||||
import { MentionDropdown } from "./MentionDropdown";
|
import { MentionDropdown, buildMentionOptions } from "./MentionDropdown";
|
||||||
|
import { usePermissions } from "../lib/usePermissions.ts";
|
||||||
import { useReadStatesStore } from "../stores/readStates.ts";
|
import { useReadStatesStore } from "../stores/readStates.ts";
|
||||||
import { MessageSearch } from "./MessageSearch";
|
import { MessageSearch } from "./MessageSearch";
|
||||||
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
import { ThreadListPanel } from "./ThreadListPanel.tsx";
|
||||||
@@ -45,7 +46,7 @@ function formatTime(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(content: string, memberUsernames: Set<string>) {
|
function renderContent(content: string, memberUsernames: Set<string>) {
|
||||||
const segments: { type: "text" | "mention"; value: string }[] = [];
|
const segments: { type: "text" | "mention"; value: string; special?: boolean }[] = [];
|
||||||
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
const mentionRe = /@([a-zA-Z0-9_.-]+)/g;
|
||||||
let last = 0;
|
let last = 0;
|
||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
@@ -54,8 +55,13 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
|||||||
segments.push({ type: "text", value: content.slice(last, match.index) });
|
segments.push({ type: "text", value: content.slice(last, match.index) });
|
||||||
}
|
}
|
||||||
const username = match[1];
|
const username = match[1];
|
||||||
if (memberUsernames.has(username)) {
|
const lower = username.toLowerCase();
|
||||||
segments.push({ type: "mention", value: username });
|
if (lower === "everyone" || lower === "channel" || lower === "here" || memberUsernames.has(username)) {
|
||||||
|
segments.push({
|
||||||
|
type: "mention",
|
||||||
|
value: username,
|
||||||
|
special: lower === "everyone" || lower === "channel" || lower === "here",
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
segments.push({ type: "text", value: match[0] });
|
segments.push({ type: "text", value: match[0] });
|
||||||
}
|
}
|
||||||
@@ -71,7 +77,10 @@ function renderContent(content: string, memberUsernames: Set<string>) {
|
|||||||
const nextSeg = segments[idx + 1];
|
const nextSeg = segments[idx + 1];
|
||||||
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
|
const needsSpace = !nextSeg || (nextSeg.type === "text" && !nextSeg.value.startsWith(" "));
|
||||||
return (
|
return (
|
||||||
<span key={idx} className="text-gb-aqua">
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={seg.special ? "text-gb-orange font-bold bg-gb-orange/15 px-0.5 rounded-sm" : "text-gb-aqua"}
|
||||||
|
>
|
||||||
@{seg.value}{needsSpace ? " " : ""}
|
@{seg.value}{needsSpace ? " " : ""}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -216,12 +225,14 @@ const MessageItem = memo(({
|
|||||||
)}
|
)}
|
||||||
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
<span className="text-gb-fg-f">{formatTime(message.created_at)}</span>{' '}
|
||||||
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
|
{message.pinned && <span className="text-gb-orange font-bold mr-1">[PIN]</span>}
|
||||||
<span className="text-gb-aqua hover:text-gb-orange cursor-pointer" onClick={(e) => {
|
<span className={`${message.author_bot ? 'text-gb-green' : 'text-gb-aqua'} hover:text-gb-orange cursor-pointer`} onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAuthorClick(message.author_id);
|
if (!message.author_bot) onAuthorClick(message.author_id);
|
||||||
}}>
|
}}>
|
||||||
<{members.find((m) => m.id === message.author_id)?.nickname || message.author_username}>
|
<{message.author_bot ? (message.bot_name || message.author_username) : (members.find((m) => m.id === message.author_id)?.nickname || message.author_username)}>
|
||||||
</span>{" "}
|
</span>
|
||||||
|
{message.author_bot && <span className="text-gb-bg bg-gb-green px-0.5 font-mono text-[10px] ml-0.5 align-middle">BOT</span>}
|
||||||
|
{" "}
|
||||||
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
<span className="text-gb-fg">{renderContent(message.content, memberUsernames)}</span>
|
||||||
{renderEmbeds(message.embeds)}
|
{renderEmbeds(message.embeds)}
|
||||||
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
|
{message.poll && <PollDisplay poll={message.poll} channelId={message.channel_id} />}
|
||||||
@@ -324,7 +335,10 @@ export function ChatArea() {
|
|||||||
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
const channels = activeServerId ? channelsByServer[activeServerId] || [] : [];
|
||||||
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
const activeChannel = channels.find((c) => c.id === activeChannelId);
|
||||||
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
const members = activeServerId ? membersByServer[activeServerId] || [] : [];
|
||||||
const memberUsernames = useMemo(() => new Set(members.map((m) => m.username)), [members]);
|
// Humans only for mentions / nickname lookup (bots live in member list separately).
|
||||||
|
const humanMembers = useMemo(() => members.filter((m) => !m.is_bot), [members]);
|
||||||
|
const memberUsernames = useMemo(() => new Set(humanMembers.map((m) => m.username)), [humanMembers]);
|
||||||
|
const { canMentionEveryone } = usePermissions(activeServerId);
|
||||||
const markRead = useReadStatesStore((s) => s.markRead);
|
const markRead = useReadStatesStore((s) => s.markRead);
|
||||||
const readStates = useReadStatesStore((s) => s.states);
|
const readStates = useReadStatesStore((s) => s.states);
|
||||||
|
|
||||||
@@ -551,10 +565,7 @@ export function ChatArea() {
|
|||||||
if (!isDropdownOpen) return;
|
if (!isDropdownOpen) return;
|
||||||
|
|
||||||
const itemCount = mq !== null
|
const itemCount = mq !== null
|
||||||
? members.filter((m) =>
|
? buildMentionOptions(mq, humanMembers, canMentionEveryone).length
|
||||||
m.username.toLowerCase().includes(mq.toLowerCase()) ||
|
|
||||||
m.display_name?.toLowerCase().includes(mq.toLowerCase())
|
|
||||||
).slice(0, 6).length
|
|
||||||
: cq !== null
|
: cq !== null
|
||||||
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
|
? SLASH_COMMANDS.filter((c) => c.name.startsWith(cq.toLowerCase())).slice(0, 8).length
|
||||||
: 0;
|
: 0;
|
||||||
@@ -571,13 +582,14 @@ export function ChatArea() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (mq !== null) {
|
if (mq !== null) {
|
||||||
const q = mq.toLowerCase();
|
const options = buildMentionOptions(mq, humanMembers, canMentionEveryone);
|
||||||
const filtered = members.filter((m) =>
|
const selected = options[di];
|
||||||
m.username.toLowerCase().includes(q) ||
|
if (selected) {
|
||||||
m.display_name?.toLowerCase().includes(q)
|
if (selected.kind === "special") {
|
||||||
).slice(0, 6);
|
handleMentionSelect(selected.label);
|
||||||
if (filtered[di]) {
|
} else {
|
||||||
handleMentionSelect(filtered[di].username);
|
handleMentionSelect(selected.member.username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (cq !== null) {
|
} else if (cq !== null) {
|
||||||
const q = cq.toLowerCase();
|
const q = cq.toLowerCase();
|
||||||
@@ -616,7 +628,7 @@ export function ChatArea() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('keydown', handler);
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [members, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
}, [humanMembers, canMentionEveryone, currentUser, activeChannelId, sendMessage, replyToMessage, handleMentionSelect]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (mentionQuery !== null || commandQuery !== null) return;
|
if (mentionQuery !== null || commandQuery !== null) return;
|
||||||
@@ -792,7 +804,7 @@ export function ChatArea() {
|
|||||||
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
<ListView channelId={activeChannelId} channelName={activeChannel.name} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex-1 flex flex-col min-w-0 relative">
|
<div className="flex-1 flex flex-col min-w-0 min-h-0 relative">
|
||||||
{showSearch && activeChannelId && activeChannel && (
|
{showSearch && activeChannelId && activeChannel && (
|
||||||
<MessageSearch
|
<MessageSearch
|
||||||
channelId={activeChannelId}
|
channelId={activeChannelId}
|
||||||
@@ -900,7 +912,13 @@ export function ChatArea() {
|
|||||||
)}
|
)}
|
||||||
<div className="p-3 relative">
|
<div className="p-3 relative">
|
||||||
{mentionQuery !== null && (
|
{mentionQuery !== null && (
|
||||||
<MentionDropdown query={mentionQuery} members={members} selectedIndex={dropdownIndex} onSelect={handleMentionSelect} />
|
<MentionDropdown
|
||||||
|
query={mentionQuery}
|
||||||
|
members={humanMembers}
|
||||||
|
selectedIndex={dropdownIndex}
|
||||||
|
onSelect={handleMentionSelect}
|
||||||
|
canMentionEveryone={canMentionEveryone}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{commandQuery !== null && (
|
{commandQuery !== null && (
|
||||||
<CommandDropdown
|
<CommandDropdown
|
||||||
|
|||||||