Author SHA1 Message Date
asepharyana a54c34c6b3 fix: enhance accessibility for Select component with ARIA attributes 2026-08-27 18:09:29 +07:00
asepharyana 7375e2ed52 fix: add tooltip functionality to NavItem with portal rendering 2026-08-27 18:04:46 +07:00
asepharyana 5196cb0221 fix: edit history 'after' showed original content, not edited content
getRecentEdits SELECT ... m.content AS new_content returned the message's
ORIGINAL content (never updated on edit) instead of the post-edit content.
messages.content stores the original body; the current/last-edited body lives
in messages.edited_content. So before===after in the Message Edits diff.

Fix: COALESCE(m.edited_content, m.content) AS new_content so 'After' shows the
edited text and diffs against the captured before-content are meaningful.
2026-08-27 17:28:24 +07:00
asepharyana 631b5e1027 fix: make gateway migrations idempotent + self-heal drizzle history
Prevent recurring infinite restart loop (389x crash) caused by drizzle
re-applying already-applied migrations when public.__drizzle_migrations
tracking is empty/partial.

- 0017/0018: ADD COLUMN IF NOT EXISTS (re-run safe)
- 0019: DO-block rename that handles all prior states (server_name-only,
  both columns, or server_nick-only) so it never errors or double-renames
- seedDrizzleHistory: reconcile tracked created_at to the journal's latest
  'when' when the schema already reflects the latest migration, instead of
  early-returning on an existing-but-empty/partial tracking table
2026-08-27 15:05:30 +07:00
asepharyana 8049b70738 fix: render server nick per user in messages tab and analysis
Extract server_nick from metadata.member.displayName in backend messageMapper,
add server_nick to frontend MessageRecord type, and update messages view +
analysis view to display the member's server-specific nickname (with @username
as secondary context) instead of the global username.
2026-08-27 14:38:05 +07:00
asepharyana ea23c405fa fix: capture server nickname (member displayName) per action
Rename server_name (guild name) to server_nick and populate it from
the member's server-specific display name (metadata.member.displayName)
at write time. This is what the moderation dashboard should show as
TARGET — e.g. server nick 'Bandar Togel「✔ ᵛᵉʳᶦᶠᶦᵉᵈ 」' for global
username '.nichiyobi'. Backfilled 210 existing actions from messages
metadata (reset_nickname rows now show 'Sarjana .jav', 'Penindas
Minoritas', etc). Frontend TARGET shows server nick with global
username as secondary context.
2026-08-27 13:50:37 +07:00
asepharyana 4991164591 fix: use AI for global username check instead of keyword list
Replace static OFFENSIVE_USERNAME_KEYWORDS substring matching with a
lightweight LLM call that evaluates whether a global username violates
server rules (gambling, scam, NSFW, SARA, etc). Fail-open design:
if the LLM call fails/times out, the nickname reset still completes.
2026-08-27 13:20:20 +07:00
asepharyana 1590479f58 fix: replace global username if also offensive after nickname reset
After resetting an offensive server nickname to the global username,
check the global username against gambling/scam keyword list. If it
also violates, generate a random 'UserXXXXX' nickname to prevent
circumvention via offensive global usernames.
2026-08-27 12:46:33 +07:00
asepharyana 0de393625f fix: add server_name to moderation_actions for full context retention
Denormalize guild name alongside username so the moderation dashboard
shows both TARGET and server even after message table purges.
Migration 0018. Frontend displays 'username · server_name' in TARGET.
2026-08-27 12:32:12 +07:00
asepharyana cccfd89266 fix: store username on moderation_actions for retention safety
Add denormalized  column to moderation_actions so the
dashboard TARGET field survives message table purges. Backfills all
existing 217 rows. Changes: schema + autoDeleteManager + backend
repository query + migration 0017.
2026-08-27 12:15:06 +07:00
asepharyana 17aad2cc39 chore: biome lint cleanup — formatting, unused imports, exhaustive-deps fixes
- Backend: fix redis-bridge.ts import formatting
- Frontend: fix formatting in messages/view, recordings/view, ambient-canvas, voice-stage
- Frontend: remove unused imports/vars in EditHistory, ambient-canvas
- Frontend: fix import sorting in LiveModerationFeed
- Frontend: suppress caller-controlled exhaustive-deps in use-gsap-animation
- All services: lint clean, typecheck pass, build pass
- Backend e2e tests excluded (require live server, expected 404s)
2026-08-27 09:39:33 +07:00
asepharyana a6e2c1fa4f voice: deep stability audit — FFmpeg crash recovery, activity timeout, reconnect refresh
Gateway (transmitter.ts):
- Auto-stop on FFmpeg crash: non-zero exit triggers stop() to prevent
  silent audio loss and resource leaks
- Voice activity timeout (10s): auto-stops transmitter when no PCM
  received, preventing dead-air CPU waste on backgrounded tabs
- Stderr cap (4KB): prevents unbounded memory growth in long sessions

Gateway (voice.handler.ts):
- Double-check voiceController.getStatus().connected before starting
  transmitter — detects stale player state after gateway disconnect

Frontend (context.tsx):
- Force-refetch voice status on WS reconnect — UI converges in <1s
  instead of waiting up to 4s for SWR poll interval

All: tsc clean, biome clean
2026-08-26 23:48:36 +07:00
asepharyana 7e0d0d5123 voice: audit + noise suppression toggle + stability fixes
Gateway:
- transmitter.ts: cap backpressure queue at 500 chunks (prevents memory leak)
- transmitter.ts: fix Redis race — assign redisSub AFTER subscribe completes
- voice.handler.ts: static import Redis instead of dynamic (cleaner, no eval)

Frontend:
- mic-transmit.ts: MicAccessError with specific reasons (permission-denied, no-mic, timeout)
- mic-transmit.ts: noiseSuppression option in getUserMedia constraints
- mic-transmit.ts: proper DOMException handling for all getUserMedia failure modes
- use-voice.ts: noiseSuppression state + toggleNoiseSuppression exposed
- use-voice.ts: cleanup on unmount (stops transmitter, clears refs)
- voice/view.tsx: noise suppression toggle button (ShieldCheck/ShieldOff icons)
- voice/view.tsx: improved mic error toasts (permission denied / no mic specific)
- voice/view.tsx: NS status in codec footer (NS_ACTIVE when enabled)
2026-08-26 23:32:37 +07:00
asepharyana 4f4f92706c feat(voice): implement stale speaker management and clear functionality 2026-08-26 23:16:57 +07:00
asepharyana 611ba39973 refactor: replace GSAP animations with CSS animations across components
- Updated RootLayout to include a noise overlay class.
- Refactored LiveModerationFeed to use CSS animations for item entry.
- Simplified AmbientCanvas by removing WebGL and using pure CSS for ambient effects.
- Converted Chatbot to use CSS for floating window animations.
- Updated Card component to include a gradient border.
- Refactored PageTransition to use CSS for fade-scale animations.
- Enhanced SectionHeader with reveal-up animation class.
- Replaced GSAP animations in NavRail with CSS animations for item entry.
- Refactored VoiceStage to use CSS for speaker node animations and pulse rings.
- Removed GSAP dependencies from use-gsap-animation hook, implementing CSS-based stagger reveal.
2026-08-26 19:43:57 +07:00
asepharyana 192685e1ce feat(layout): add theme script to prevent light mode flash during hydration 2026-08-26 18:33:30 +07:00
asepharyana cacebfd94e feat(messages): enhance message and edit history with channel names and content diffs 2026-08-26 18:26:15 +07:00
asepharyana 709074935f style: fix biome formatting after audit fixes 2026-08-26 18:12:02 +07:00
asepharyana 9f02edd646 perf+fix(ai-moderation): 11 pipeline optimizations from audit
Audit of the full AI analysis flow found 14 issues; 11 fixed, 3 deferred:

Fixed:
1. batchProcessor: skip scheduleAutoDelete for error-status rows (was
   causing wasted not_eligible logs for every parse/API failure)
2. textBatchProcessor: domain dedup in URL fetch (max 3 URLs per domain
   to avoid rate-limiting from concentrated domains)
3. llmCaller: move parseModerationResponse import to top-level (was
   dynamic-imported inside retry loop — unnecessary overhead per retry)
4. llmCaller: make default max_tokens configurable via
   AI_LLM_MAX_COMPLETION_TOKENS env (default 16384)
5. moderationOrchestrator: log cache write errors instead of silent
   .catch(() => {}) — surface intermittent Redis failures
6. conversationContext: batch token estimation via estimateTokensBatch
   (single tiktoken encode call for all target lines, ~5x faster)
7. aiAnalyzer: skip revertStuckProcessingMessages DB query when no
   conversations are actively processing (avoids idle-state query)
8. textBatchProcessor: cache corrected few-shot examples per hour
   (was re-queried from DB on every batch)
9. textBatchProcessor: preserve partial results on sub-batch timeout
   (was throwing and discarding all prior sub-batch results)
10. batchProcessor switch: skip 'completed' messages from individual
    fallback queue (prevents redundant re-analysis + double-delete)
11. autoDeleteManager: expand isAlreadyDeletedError to catch Discord
    codes 10003/50001 + text fallback matching

Deferred (not regressions, larger refactors):
- #8 batchScheduler debounce race: not actually a race (JS single-threaded)
- #11 initCacheStore: already has idempotency guard
- #13 individual fallback batching: requires worker pool refactor

7 files changed, 73 insertions(+), 32 deletions(-)
2026-08-26 18:08:24 +07:00
asepharyana 5fc3cf86d5 perf(backend): parallelize getMessageById DB queries + drop unused indexes
- getMessageById: run findById and getEditHistory in parallel via
  Promise.all instead of sequential awaits (halves latency for
  message detail view)
- Drop 3 unused indexes on messages table: idx_messages_guild_ai_status_created
  (0 scans), idx_messages_guild_ai_status_analyzed (97 scans),
  idx_messages_guild_created_deleted (95 scans, superseded by covering index)
  — saves ~5.9MB index space + reduces write amplification
- Add covering index idx_messages_guild_created_covering for the primary
  findMany query pattern (guild_id + created_at DESC with INCLUDE columns)
2026-08-26 17:48:10 +07:00
asepharyana e81391484b feat(messages): enhance message rendering with metadata parsing and inline image support 2026-08-26 17:43:01 +07:00
asepharyana 3b8fe1b1c3 refactor(select): enhance dropdown positioning and implement portal for improved rendering 2026-08-26 17:37:20 +07:00
asepharyana 54e7220d06 fix(auto-delete): prevent double-processing + improve error classification
Two bugs causing 23 spurious 'error' logs after successful deletions:

1. batchProcessor switch missing 'completed' case: partitionBatchOutcome
   returns 'completed' for successful messages, but the switch only handled
   'upload_pending' and 'api_failed'. Successful messages fell through to
   default → re-enqueued to individual fallback → re-analyzed → re-delete
   attempt → error (message already gone from Discord). Now explicitly
   skips 'completed' messages.

2. isAlreadyDeletedError only caught codes 10008/404. Discord also returns
   10003 (Unknown Channel) and 50001 (Missing Access) when a message or
   channel is gone. Added these codes plus text-based fallback matching
   'Unknown Message'/'Unknown Channel'.

Impact: eliminates ~23 redundant error logs per day + stops wasted LLM
calls re-analyzing already-processed messages.
2026-08-26 17:31:37 +07:00
asepharyana 46d889271e fix(auto-delete): accept 'warn' recommendedAction + delete flagged+medium
isEligibleForAutoDelete was rejecting messages where recommendedAction
was 'warn' or 'review' — only 'delete' and 'escalate' were accepted.
This caused 28+ medium-severity flagged messages to be logged as
'not_eligible' instead of being auto-deleted.

Changes:
- deriveRecommendedAction: return 'delete' for flagged+medium severity
  (previously only critical/high triggered delete; medium got 'review')
- isEligibleForAutoDelete: accept 'warn' as valid recommendedAction
  alongside 'delete' and 'escalate'

Impact: ~28 pending medium-severity flagged messages + all future
'warn'-action flagged messages will now be eligible for auto-deletion.
2026-08-26 17:16:30 +07:00
asepharyana 0c3a82ad77 refactor: update recordings handling with pagination support and improve infinite scroll functionality 2026-08-26 17:05:41 +07:00
asepharyana b640079cb6 refactor: enhance message streaming and UI components; add SWR provider 2026-08-26 16:51:46 +07:00
asepharyana 37d15456dc refactor: remove unnecessary PageTransition wrappers from dashboard pages 2026-08-26 15:49:58 +07:00
asepharyana 153e628aba refactor: update component styles to use theme-aware colors and improve UI consistency 2026-08-26 15:43:43 +07:00
asepharyana a5f908dc06 refactor: update UI components for consistency and improved styling
- Refactored ChannelCultureGlossary, LiveModerationFeed, TermGlossary, and Chatbot components to use new design tokens and styles.
- Updated button, badge, and section components to align with the new design system.
- Enhanced the visual hierarchy and accessibility of various UI elements.
- Improved responsiveness and hover states across components.
- Replaced hardcoded colors with design tokens for better maintainability.
2026-08-26 14:57:08 +07:00
asepharyana 7fc36170c8 chore: update @types/node version to 26.2.0 in package.json and pnpm-lock.yaml 2026-08-26 13:51:54 +07:00
asepharyana ccfe769b2f Refactor moderation and recordings views; enhance UI components and improve performance
- Cleaned up imports and removed unused hooks in ModerationView and RecordingsView.
- Updated UI elements for better visual consistency and accessibility.
- Improved performance by optimizing rendering logic and reducing unnecessary state updates.
- Added filtering functionality in ChannelCultureGlossary and TermGlossary components.
- Enhanced LiveModerationFeed with GSAP animations for smoother transitions.
- Updated chatbot component for better user experience and responsiveness.
- Refined NavRail component for cleaner code and improved navigation item rendering.
2026-08-26 10:39:00 +07:00
asepharyana 3b688e0d0f Refactor code structure for improved readability and maintainability 2026-08-26 10:13:40 +07:00
asepharyana 29f59fd91f feat(frontend): revamp UI to Linear Precision Clean Dark with GSAP micro-interactions 2026-08-26 10:12:42 +07:00
asepharyana c3165ed702 chore: update dependabot-auto-merge.yml 2026-08-26 08:53:22 +07:00
asepharyana c72fadbbb4 chore: update dependabot.yml 2026-08-26 08:53:01 +07:00
Asep Hariyana 613840e2d1 chore(discord-gateway): update and sync lockfile for bun 2026-08-26 09:51:26 +08:00
asepharyana 24336186dc feat(frontend): add HUD header and GSAP accordion reveal to glossary route 2026-08-25 23:46:36 +07:00
asepharyana 6b66863897 feat(frontend): add HUD header, count-up gauge, and stagger reveal to analysis route 2026-08-25 23:41:39 +07:00
asepharyana fc40609a3a feat(frontend): add tactical HUD header and GSAP stagger deck to recordings route 2026-08-25 23:36:45 +07:00
asepharyana e157ae3ce4 feat(frontend): add alert-priority HUD header with GSAP pulse to moderation route 2026-08-25 23:32:21 +07:00
asepharyana 3f4c05f5bb feat(frontend): add tactical HUD header and scan-line reveal to messages feed 2026-08-25 23:28:11 +07:00
asepharyana 9c25e0887c feat(frontend): add tactical HUD header and GSAP speaking-pulse loop to voice route 2026-08-25 23:23:19 +07:00
asepharyana 2aa12b9ec5 feat(frontend): revamp channels into signal-map roster with GSAP stagger 2026-08-25 23:17:39 +07:00
asepharyana cab451f0e5 feat(frontend): revamp media frequency deck with GSAP animations and tactical visualizer 2026-08-25 23:05:05 +07:00
asepharyana 183a51003a feat(frontend): revamp dashboard into tactical HUD and integrate GSAP reactive stages 2026-08-25 22:59:29 +07:00
asepharyana f34811e9b6 Delete .github/workflows/flakehub-publish-rolling.yaml 2026-08-25 22:51:25 +07:00
asepharyana 2c30140ddd feat(frontend): migrate animations to GSAP and update client socket types for Next 16.3 2026-08-25 22:47:25 +07:00
asepharyana 7e92de63d6 feat(frontend): migrate animations to GSAP and add useGSAP lifecycle hooks 2026-08-25 22:41:41 +07:00
mytheclipsebotreview[bot] 68be4352da Auto-merge PR #18
build(deps-dev): bump tsx from 4.23.1 to 4.23.12 in /services/backend
2026-08-25 14:30:28 +00:00
mytheclipsebotreview[bot] 754ef78ff1 Auto-merge PR #20
build(deps-dev): bump tsx from 4.23.1 to 4.23.12 in /services/discord-gateway
2026-08-25 14:30:18 +00:00
mytheclipsebotreview[bot] 2959744e0c Auto-merge PR #19
build(deps-dev): bump @types/three from 0.180.0 to 0.185.4 in /services/frontend
2026-08-25 14:30:09 +00:00
dependabot[bot] cffa72c21f build(deps-dev): bump tsx in /services/discord-gateway
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.1 to 4.23.12.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.12)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 14:26:03 +00:00
dependabot[bot] d64c5ba7b6 build(deps-dev): bump @types/three in /services/frontend
Bumps [@types/three](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/three) from 0.180.0 to 0.185.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/three)

---
updated-dependencies:
- dependency-name: "@types/three"
  dependency-version: 0.185.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 14:25:57 +00:00
dependabot[bot] 87851f7a71 build(deps-dev): bump tsx from 4.23.1 to 4.23.12 in /services/backend
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.1 to 4.23.12.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.12)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 14:25:55 +00:00
asepharyana 91de43a6c6 fix: switch GMW AI source from omniroute to 9router (model alias revert)
- Switch AI_LLM_BASE_URL from omniroute.imrnes.team to 9router.asepharyana.my.id
- Keep AI_LLM_MODEL as 'text' (9router uses alias-based routing, not bare names)
- Update .env.example comments to document 9router
- Per user: multimodal stays 'multimodal' alias

API verified: curl to 9router/v1/chat/completions with model 'text'
returns HTTP 200 (OpenAI-compatible format)
2026-08-25 20:36:24 +07:00
asepharyana f8fc08de41 fix: switch GMW AI source from omniroute to 9router (model alias revert)
- Switch AI_LLM_BASE_URL from omniroute.imrnes.team to 9router.asepharyana.my.id
- Keep AI_LLM_MODEL as 'text' (9router uses alias-based routing, not bare names)
- Update .env.example comments to document 9router
- Per user: multimodal stays 'multimodal' alias

API verified: curl to 9router/v1/chat/completions with model 'text'
returns HTTP 200 (OpenAI-compatible format)
2026-08-25 20:36:08 +07:00
asepharyana dfa69e9f45 feat: add dependabot auto-merge workflow 2026-08-25 20:33:07 +07:00
asepharyana 0bd4b4075e Merge pull request #17 from asepharyana/feat/fe-error-handling-state
fix: switch GMW AI source from omniroute to 9router
2026-08-25 20:27:10 +07:00
asepharyana b679a02cb9 Merge pull request #16 from asepharyana/dependabot/npm_and_yarn/services/frontend/typescript-7.0.2
build(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /services/frontend
2026-08-25 20:05:31 +07:00
mytheclipsebotreview[bot] 4ed1d3c053 Auto-merge PR #6
build(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /services/backend
2026-08-25 11:36:10 +00:00
dependabot[bot] 8dbcc2b527 build(deps-dev): bump typescript in /services/frontend
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:32:04 +00:00
dependabot[bot] bda210475d build(deps-dev): bump typescript in /services/backend
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:31:51 +00:00
mytheclipsebotreview[bot] 9b78abaf3d Auto-merge PR #7
build(deps): bump the production group across 1 directory with 8 updates
2026-08-25 11:29:54 +00:00
mytheclipsebotreview[bot] 53c4158511 Auto-merge PR #10
build(deps-dev): bump @types/node from 20.19.43 to 26.2.0 in /services/frontend
2026-08-25 11:29:43 +00:00
mytheclipsebotreview[bot] a2a8c824bf Auto-merge PR #13
build(deps-dev): bump @biomejs/biome from 2.2.0 to 2.5.10 in /services/frontend
2026-08-25 11:29:26 +00:00
mytheclipsebotreview[bot] dec548fa2f Auto-merge PR #4
build(deps-dev): bump @types/node from 25.9.5 to 26.2.0 in /services/backend
2026-08-25 11:29:18 +00:00
mytheclipsebotreview[bot] 12033e5537 Auto-merge PR #11
build(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /services/discord-gateway
2026-08-25 11:29:10 +00:00
dependabot[bot] d06caa7e58 build(deps): bump the production group across 1 directory with 8 updates
Bumps the production group with 8 updates in the /services/discord-gateway directory:

| Package | From | To |
| --- | --- | --- |
| [ioredis](https://github.com/redis/ioredis) | `5.11.1` | `6.0.0` |
| [openai](https://github.com/openai/openai-node) | `6.49.0` | `7.5.0` |
| [opusscript](https://github.com/abalabahaha/opusscript) | `0.0.8` | `0.1.1` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.22.0` | `8.23.0` |
| [pino](https://github.com/pinojs/pino) | `9.14.0` | `10.3.1` |
| [piscina](https://github.com/piscinajs/piscina) | `5.3.0` | `5.3.1` |
| [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.3` |
| [ws](https://github.com/websockets/ws) | `8.21.1` | `8.21.3` |



Updates `ioredis` from 5.11.1 to 6.0.0
- [Release notes](https://github.com/redis/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/redis/ioredis/compare/v5.11.1...v6.0.0)

Updates `openai` from 6.49.0 to 7.5.0
- [Release notes](https://github.com/openai/openai-node/releases)
- [Changelog](https://github.com/openai/openai-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-node/compare/v6.49.0...v7.5.0)

Updates `opusscript` from 0.0.8 to 0.1.1
- [Release notes](https://github.com/abalabahaha/opusscript/releases)
- [Commits](https://github.com/abalabahaha/opusscript/compare/0.0.8...0.1.1)

Updates `pg` from 8.22.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

Updates `pino` from 9.14.0 to 10.3.1
- [Release notes](https://github.com/pinojs/pino/releases)
- [Commits](https://github.com/pinojs/pino/compare/v9.14.0...v10.3.1)

Updates `piscina` from 5.3.0 to 5.3.1
- [Release notes](https://github.com/piscinajs/piscina/releases)
- [Changelog](https://github.com/piscinajs/piscina/blob/v5.3.1/CHANGELOG.md)
- [Commits](https://github.com/piscinajs/piscina/compare/v5.3.0...v5.3.1)

Updates `sharp` from 0.34.5 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3)

Updates `ws` from 8.21.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: openai
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: opusscript
  dependency-version: 0.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pino
  dependency-version: 10.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: piscina
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:24:34 +00:00
dependabot[bot] 173ae3c61c build(deps-dev): bump @types/node in /services/frontend
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.43 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:23:30 +00:00
dependabot[bot] 2637bbc549 build(deps-dev): bump @biomejs/biome in /services/frontend
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.2.0 to 2.5.10.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.10/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.10
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:23:19 +00:00
dependabot[bot] 6fc68126ac build(deps-dev): bump @types/node in /services/backend
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.5 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:23:08 +00:00
dependabot[bot] 28d09a24c9 build(deps-dev): bump typescript in /services/discord-gateway
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:22:50 +00:00
mytheclipsebotreview[bot] f2ec642a0e Auto-merge PR #12
build(deps-dev): bump puppeteer-core from 25.7.0 to 25.8.0 in /services/frontend
2026-08-25 11:21:41 +00:00
mytheclipsebotreview[bot] 8a491b9ec2 Auto-merge PR #8
build(deps): bump the production group in /services/frontend with 7 updates
2026-08-25 11:20:56 +00:00
mytheclipsebotreview[bot] 7d54d3a660 Auto-merge PR #2
build(deps): bump the production group in /services/backend with 4 updates
2026-08-25 11:20:48 +00:00
mytheclipsebotreview[bot] 21509c25ff Auto-merge PR #15
build(deps-dev): bump @types/node from 25.9.5 to 26.2.0 in /services/discord-gateway
2026-08-25 11:20:35 +00:00
asepharyana 09724287cb feat: update dependabot config 2026-08-25 18:16:49 +07:00
dependabot[bot] 1ac3d50315 build(deps-dev): bump @types/node in /services/discord-gateway
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.5 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:15:57 +00:00
dependabot[bot] 9c48d50d25 build(deps-dev): bump puppeteer-core in /services/frontend
Bumps [puppeteer-core](https://github.com/puppeteer/puppeteer) from 25.7.0 to 25.8.0.
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v25.7.0...puppeteer-core-v25.8.0)

---
updated-dependencies:
- dependency-name: puppeteer-core
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:15:28 +00:00
dependabot[bot] 8bd3ec8e83 build(deps): bump the production group
Bumps the production group in /services/frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.28.0` | `1.33.0` |
| [motion](https://github.com/motiondivision/motion) | `12.43.0` | `13.1.1` |
| [next](https://github.com/vercel/next.js) | `16.2.12` | `16.3.2` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.8` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.4` | `19.2.8` |
| [swr](https://github.com/vercel/swr) | `2.4.2` | `2.5.1` |
| [three](https://github.com/mrdoob/three.js) | `0.180.0` | `0.185.1` |


Updates `lucide-react` from 1.28.0 to 1.33.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.33.0/packages/lucide-react)

Updates `motion` from 12.43.0 to 13.1.1
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.43.0...v13.1.1)

Updates `next` from 16.2.12 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.12...v16.3.2)

Updates `react` from 19.2.4 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)

Updates `react-dom` from 19.2.4 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)

Updates `swr` from 2.4.2 to 2.5.1
- [Release notes](https://github.com/vercel/swr/releases)
- [Commits](https://github.com/vercel/swr/compare/v2.4.2...v2.5.1)

Updates `three` from 0.180.0 to 0.185.1
- [Release notes](https://github.com/mrdoob/three.js/releases)
- [Commits](https://github.com/mrdoob/three.js/commits)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: motion
  dependency-version: 13.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: next
  dependency-version: 16.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-dom
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: swr
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: three
  dependency-version: 0.185.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:15:04 +00:00
dependabot[bot] 5a4a203f01 build(deps): bump the production group
Bumps the production group in /services/backend with 4 updates: [ioredis](https://github.com/redis/ioredis), [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg), [pino](https://github.com/pinojs/pino) and [ws](https://github.com/websockets/ws).


Updates `ioredis` from 5.11.1 to 6.0.0
- [Release notes](https://github.com/redis/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/redis/ioredis/compare/v5.11.1...v6.0.0)

Updates `pg` from 8.22.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

Updates `pino` from 9.14.0 to 10.3.1
- [Release notes](https://github.com/pinojs/pino/releases)
- [Commits](https://github.com/pinojs/pino/compare/v9.14.0...v10.3.1)

Updates `ws` from 8.21.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pino
  dependency-version: 10.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:14:06 +00:00
asepharyana 7f0aa5a7ad feat: dependabot config with multi-dir (backend, gateway, frontend) 2026-08-25 18:12:10 +07:00
asepharyana eb0be981c6 feat: add dependabot config (npm) 2026-08-25 18:07:58 +07:00
mytheclipsebotreview[bot] 466e117bd5 Auto-merge PR #1
feat(fe): optimize error handling, state consistency, and WS feedback
2026-08-25 08:36:58 +00:00
107 changed files with 6349 additions and 4000 deletions

No files matched your search

+1 -1
View File
@@ -86,7 +86,7 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false) AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (9router — OpenAI-compatible router, replaces omniroute) AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (9router — OpenAI-compatible router, replaces omniroute)
AI_LLM_MODEL=claude-opus-5 # LLM text model name (default: claude-opus-5) AI_LLM_MODEL=text # LLM text model name (default: text)
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL) # AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls) # AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97) # AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
+17
View File
@@ -0,0 +1,17 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+38
View File
@@ -0,0 +1,38 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/services/backend"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types: ["minor", "patch"]
development:
dependency-type: "development"
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/services/discord-gateway"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types: ["minor", "patch"]
development:
dependency-type: "development"
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/services/frontend"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types: ["minor", "patch"]
development:
dependency-type: "development"
update-types: ["minor", "patch"]
@@ -1,20 +0,0 @@
name: Publish to FlakeHub
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
flakehub-publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@main
- uses: DeterminateSystems/flakehub-push@main
with:
visibility: public
rolling: true
+8 -8
View File
@@ -21,21 +21,21 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"ioredis": "^5.11.0", "ioredis": "^6.0.0",
"pg": "^8.21.0", "pg": "^8.23.0",
"pino": "^9.6.0", "pino": "^10.3.1",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
"ws": "^8.20.1", "ws": "^8.21.3",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.9.0", "@types/node": "^26.2.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.23.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"tsx": "^4.22.2", "tsx": "^4.23.12",
"typescript": "^5.9.3", "typescript": "^7.0.2",
"vitest": "latest" "vitest": "latest"
} }
} }
+485 -287
View File
File diff suppressed because it is too large. Load diff
@@ -469,17 +469,20 @@ export class MessagesRepository {
const db = getDatabase(); const db = getDatabase();
const since = Date.now() - days * 24 * 60 * 60 * 1000; const since = Date.now() - days * 24 * 60 * 60 * 1000;
const result = await db.execute(sql` const result = await db.execute(sql`
SELECT channel_id, SELECT
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour, m.channel_id,
COUNT(*)::int AS c COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
FROM messages EXTRACT(HOUR FROM to_timestamp(m.created_at / 1000))::int AS hour,
WHERE created_at >= ${since} COUNT(*)::int AS c
GROUP BY channel_id, hour FROM messages m
ORDER BY channel_id, hour WHERE m.created_at >= ${since}
GROUP BY m.channel_id, channel_name, hour
ORDER BY channel_name, hour
`); `);
const rows = (result.rows as Record<string, unknown>[]) || []; const rows = (result.rows as Record<string, unknown>[]) || [];
return rows.map((r) => ({ return rows.map((r) => ({
channelId: String(r.channel_id ?? "unknown"), channelId: String(r.channel_id ?? "unknown"),
channelName: String(r.channel_name ?? r.channel_id ?? "unknown"),
hour: Number(r.hour ?? 0), hour: Number(r.hour ?? 0),
count: Number(r.c ?? 0), count: Number(r.c ?? 0),
})); }));
@@ -491,31 +494,28 @@ export class MessagesRepository {
*/ */
async getRecentEdits(limit = 50, channelId?: string) { async getRecentEdits(limit = 50, channelId?: string) {
const db = getDatabase(); const db = getDatabase();
const where = channelId const result = await db.execute(sql`
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'` SELECT
: ""; e.id,
const result = await db.execute( e.message_id,
sql.raw(` e.old_content,
SELECT e.edited_at,
e.id, m.channel_id,
e.message_id, COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
e.old_content, m.username,
e.edited_at, COALESCE(m.edited_content, m.content) AS new_content
m.channel_id, FROM message_edits e
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name, JOIN messages m ON m.id = e.message_id
m.username ${channelId ? sql`WHERE m.channel_id = ${channelId}` : sql``}
FROM message_edits e ORDER BY e.edited_at DESC
JOIN messages m ON m.id = e.message_id LIMIT ${limit}
${where} `);
ORDER BY e.edited_at DESC
LIMIT ${limit}
`),
);
const rows = (result.rows as Record<string, unknown>[]) || []; const rows = (result.rows as Record<string, unknown>[]) || [];
return rows.map((r) => ({ return rows.map((r) => ({
id: String(r.id), id: String(r.id),
message_id: String(r.message_id), message_id: String(r.message_id),
old_content: r.old_content ? String(r.old_content) : "", old_content: r.old_content ? String(r.old_content) : "",
new_content: r.new_content ? String(r.new_content) : "",
edited_at: r.edited_at ? Number(r.edited_at) : 0, edited_at: r.edited_at ? Number(r.edited_at) : 0,
channel_id: r.channel_id ? String(r.channel_id) : null, channel_id: r.channel_id ? String(r.channel_id) : null,
channel_name: r.channel_name ? String(r.channel_name) : null, channel_name: r.channel_name ? String(r.channel_name) : null,
@@ -39,12 +39,15 @@ export class MessagesService {
throw new ValidationError("message ID is required"); throw new ValidationError("message ID is required");
} }
const message = await messagesRepository.findById(id); const [message, editHistory] = await Promise.all([
messagesRepository.findById(id),
messagesRepository.getEditHistory(id),
]);
if (!message) { if (!message) {
throw new NotFoundError(`Message with ID ${id} not found`); throw new NotFoundError(`Message with ID ${id} not found`);
} }
const editHistory = await messagesRepository.getEditHistory(id);
return { return {
...message, ...message,
edit_count: editHistory.length, edit_count: editHistory.length,
@@ -124,7 +124,8 @@ export class ModerationRepository {
a.score, a.score,
a.evidence, a.evidence,
a.policy_version, a.policy_version,
m.username, a.username,
a.server_nick,
LEFT(m.content, 300) AS content LEFT(m.content, 300) AS content
FROM moderation_actions a FROM moderation_actions a
LEFT JOIN messages m ON m.id = a.message_id LEFT JOIN messages m ON m.id = a.message_id
@@ -155,6 +156,7 @@ export class ModerationRepository {
evidence: parseJsonArray(r.evidence), evidence: parseJsonArray(r.evidence),
policy_version: r.policy_version ? String(r.policy_version) : null, policy_version: r.policy_version ? String(r.policy_version) : null,
username: r.username ? String(r.username) : null, username: r.username ? String(r.username) : null,
server_nick: r.server_nick ? String(r.server_nick) : null,
content: r.content ? String(r.content) : null, content: r.content ? String(r.content) : null,
})); }));
@@ -314,7 +316,7 @@ export class ModerationRepository {
SELECT SELECT
a.id, a.message_id, a.user_id, a.guild_id, a.action_type, a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score, a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
m.username, LEFT(m.content, 300) AS content a.username, LEFT(m.content, 300) AS content
FROM moderation_actions a FROM moderation_actions a
LEFT JOIN messages m ON m.id = a.message_id LEFT JOIN messages m ON m.id = a.message_id
WHERE a.created_at >= ${since} WHERE a.created_at >= ${since}
@@ -338,6 +340,7 @@ export class ModerationRepository {
confidence: r.confidence != null ? Number(r.confidence) : null, confidence: r.confidence != null ? Number(r.confidence) : null,
score: r.score != null ? Number(r.score) : null, score: r.score != null ? Number(r.score) : null,
username: r.username ? String(r.username) : null, username: r.username ? String(r.username) : null,
server_nick: r.server_nick ? String(r.server_nick) : null,
content: r.content ? String(r.content) : null, content: r.content ? String(r.content) : null,
})); }));
} }
@@ -23,14 +23,26 @@ const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200; const MAX_SPEAKERS = 200;
/** /**
* Record a voice_active_user event. `speaking: true` upserts the speaker as * Speakers inactive for longer than this are auto-expired from the snapshot.
* active; `speaking: false` marks them inactive while keeping them for the * This handles the case where the gateway disconnects abruptly and never
* activity timeline. * sends `speaking: false` for active users.
*/ */
const SPEAKER_TTL_MS = 30_000;
/** Purge speakers that haven't been active recently. */
function purgeStale(): void {
const cutoff = Date.now() - SPEAKER_TTL_MS;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < cutoff) {
speakers.delete(id);
}
}
}
/** /**
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true` * Record a voice_active_user event. `speaking: true` upserts the speaker as
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while * active; `speaking: false` marks them inactive while keeping them briefly
* keeping them for the activity timeline. * for the activity timeline (until TTL expiry).
*/ */
export function recordSpeaker(data: { export function recordSpeaker(data: {
userId: string; userId: string;
@@ -49,7 +61,6 @@ export function recordSpeaker(data: {
}; };
if (speakers.size >= MAX_SPEAKERS && !existing) { if (speakers.size >= MAX_SPEAKERS && !existing) {
// Drop the least-recently-active non-speaking speaker to stay bounded.
let oldestId: string | null = null; let oldestId: string | null = null;
let oldestTs = Infinity; let oldestTs = Infinity;
for (const [id, s] of speakers) { for (const [id, s] of speakers) {
@@ -65,18 +76,35 @@ export function recordSpeaker(data: {
speakers.set(userId, speaker); speakers.set(userId, speaker);
} }
/** All known speakers, most recently active first. */ /**
* All recently-active speakers, most recently active first.
* Stale (non-speaking + old) entries are auto-purged.
*/
export function getActiveSpeakers(): LiveSpeaker[] { export function getActiveSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt); return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
} }
/** Only speakers currently flagged as speaking. */ /** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] { export function getSpeakingSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()] return [...speakers.values()]
.filter((s) => s.speaking) .filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt); .sort((a, b) => b.lastActiveAt - a.lastActiveAt);
} }
/**
* Mark ALL tracked speakers as not-speaking and purge stale ones.
* Called when the gateway disconnects from voice — ensures the authoritative
* snapshot doesn't carry ghost speakers.
*/
export function clearAllSpeakers(): void {
for (const [id, s] of speakers) {
s.speaking = false;
}
purgeStale();
}
/** Drop all tracked speakers (used on backend restart). */ /** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void { export function resetLiveSpeakers(): void {
speakers.clear(); speakers.clear();
@@ -1,5 +1,22 @@
// Shared message row mapper for backend repository modules // Shared message row mapper for backend repository modules
function resolveServerNick(row: Record<string, unknown>): string | null {
const metadata = row.metadata;
if (!metadata || typeof metadata !== "string") {
return (row.username as string | null) ?? null;
}
try {
const parsed = JSON.parse(metadata) as {
member?: { displayName?: string | null } | null;
};
return (
parsed?.member?.displayName ?? (row.username as string | null) ?? null
);
} catch {
return (row.username as string | null) ?? null;
}
}
export interface MappedMessage { export interface MappedMessage {
id: string; id: string;
guild_id: string; guild_id: string;
@@ -7,6 +24,8 @@ export interface MappedMessage {
thread_id: string | null; thread_id: string | null;
user_id: string; user_id: string;
username: string; username: string;
/** Member's server-specific display name (nickname), from metadata.member.displayName. Falls back to username when absent. */
server_nick: string | null;
avatar_url: string | null; avatar_url: string | null;
content: string; content: string;
edited_content: string | null; edited_content: string | null;
@@ -42,6 +61,7 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
thread_id: (row.thread_id as string | null) ?? null, thread_id: (row.thread_id as string | null) ?? null,
user_id: String(row.user_id ?? ""), user_id: String(row.user_id ?? ""),
username: String(row.username ?? ""), username: String(row.username ?? ""),
server_nick: resolveServerNick(row),
avatar_url: (row.avatar_url as string | null) ?? null, avatar_url: (row.avatar_url as string | null) ?? null,
content: String(row.content ?? ""), content: String(row.content ?? ""),
edited_content: (row.edited_content as string | null) ?? null, edited_content: (row.edited_content as string | null) ?? null,
+13 -1
View File
@@ -1,10 +1,14 @@
import Redis from "ioredis"; import Redis from "ioredis";
import { recordSpeaker } from "../modules/voice/live-speaker.js"; import {
clearAllSpeakers,
recordSpeaker,
} from "../modules/voice/live-speaker.js";
import { config } from "../shared/config/index.js"; import { config } from "../shared/config/index.js";
import { import {
DISCORD_CHANNEL_TO_WS_EVENT, DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER, DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM, DISCORD_VOICE_PCM,
DISCORD_VOICE_STOPPED,
} from "../shared/index.js"; } from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js"; import { createChildLogger } from "../shared/logger/index.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js"; import { broadcastBinary, broadcastEvent } from "./broadcast.js";
@@ -84,6 +88,14 @@ function handleSubscriptionMessage(channel: string, message: string): void {
} }
} }
// When the gateway stops voice recording (disconnects from voice channel),
// clear all speakers from the authoritative snapshot so frontends don't
// show ghost participants.
if (channel === DISCORD_VOICE_STOPPED) {
clearAllSpeakers();
logger.info("Voice recording stopped — cleared all live speakers");
}
logger.debug({ channel, eventType }, "Broadcasting Redis event"); logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data); broadcastEvent(eventType, data);
} }
@@ -0,0 +1,9 @@
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "username" text;
--> statement-breakpoint
-- Backfill username from messages table for existing actions
UPDATE "moderation_actions" a
SET "username" = m."username"
FROM "messages" m
WHERE a."message_id" = m."id"
AND a."username" IS NULL
AND m."username" IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "server_name" text;
@@ -0,0 +1,26 @@
-- Rename server_name (guild name) -> server_nick (member's server nickname)
-- server_name was added in 0018 but never populated (all NULL), so renaming
-- the empty column is safe and keeps retention semantics correct.
-- Idempotent across all possible prior states:
-- • server_name present, server_nick absent -> rename
-- • both present (backfill created server_nick) -> drop the stale server_name
-- • server_nick present only -> already done, no-op
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_nick'
) THEN
ALTER TABLE "moderation_actions" RENAME COLUMN "server_name" TO "server_nick";
ELSIF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
) THEN
-- server_nick already exists (e.g. backfilled out-of-band) — drop the
-- now-redundant server_name column instead of renaming into a conflict.
ALTER TABLE "moderation_actions" DROP COLUMN "server_name";
END IF;
END $$;
@@ -120,6 +120,27 @@
"when": 1787185000000, "when": 1787185000000,
"tag": "0016_drop_user_reputations", "tag": "0016_drop_user_reputations",
"breakpoints": true "breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787810000000,
"tag": "0017_add_username_to_moderation_actions",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1787820000000,
"tag": "0018_add_server_name_to_moderation_actions",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1787830000000,
"tag": "0019_rename_server_name_to_server_nick",
"breakpoints": true
} }
] ]
} }
+12 -12
View File
@@ -28,30 +28,30 @@
"discord.js-selfbot-v13": "^3.7.1", "discord.js-selfbot-v13": "^3.7.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"ioredis": "^5.11.0", "ioredis": "^6.0.0",
"libsodium-wrappers": "^0.8.4", "libsodium-wrappers": "^0.8.4",
"lru-cache": "^11.5.1", "lru-cache": "^11.5.1",
"openai": "^6.38.0", "openai": "^7.5.0",
"opusscript": "^0.0.8", "opusscript": "^0.1.1",
"p-limit": "^7.3.0", "p-limit": "^7.3.0",
"p-retry": "^8.0.0", "p-retry": "^8.0.0",
"pg": "^8.21.0", "pg": "^8.23.0",
"pino": "^9.6.0", "pino": "^10.3.1",
"piscina": "^5.1.4", "piscina": "^5.3.1",
"prism-media": "2.0.0-alpha.0", "prism-media": "2.0.0-alpha.0",
"sharp": "^0.34.5", "sharp": "^0.35.3",
"tiktoken": "^1.0.22", "tiktoken": "^1.0.22",
"ws": "^8.20.1", "ws": "^8.21.3",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@types/node": "^25.9.0", "@types/node": "^26.2.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.23.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "^4.22.2", "tsx": "^4.23.12",
"typescript": "^5.9.3", "typescript": "^7.0.2",
"vitest": "latest" "vitest": "latest"
} }
} }
+660 -435
View File
File diff suppressed because it is too large. Load diff
@@ -163,12 +163,18 @@ export function startPendingAIAnalysisWorker(
}); });
} }
messageStore.revertStuckProcessingMessages(300000).catch((err: unknown) => { // Only revert stuck processing messages if there's active processing.
logger.error( // Avoids a DB query every recovery interval when the pipeline is idle.
{ error: String(err) }, if (conversationProcessing.size > 0) {
"Failed to run stuck processing recovery", messageStore
); .revertStuckProcessingMessages(300000)
}); .catch((err: unknown) => {
logger.error(
{ error: String(err) },
"Failed to run stuck processing recovery",
);
});
}
Promise.all([ Promise.all([
messageStore.getPendingConversationKeys(500), messageStore.getPendingConversationKeys(500),
@@ -39,7 +39,7 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
const severity = deriveSeverity(msg); const severity = deriveSeverity(msg);
if ( if (
msg.ai_status === "flagged" && msg.ai_status === "flagged" &&
(severity === "critical" || severity === "high") (severity === "critical" || severity === "high" || severity === "medium")
) )
return "delete"; return "delete";
if (msg.ai_status === "flagged") return "review"; if (msg.ai_status === "flagged") return "review";
@@ -135,10 +135,14 @@ export function isEligibleForAutoDelete(
// Recommended action check // Recommended action check
const recommendedAction = const recommendedAction =
analysisResult?.recommendedAction ?? deriveRecommendedAction(message); analysisResult?.recommendedAction ?? deriveRecommendedAction(message);
if (recommendedAction !== "delete" && recommendedAction !== "escalate") { if (
recommendedAction !== "delete" &&
recommendedAction !== "escalate" &&
recommendedAction !== "warn"
) {
logger.debug( logger.debug(
{ messageId: message.id, recommendedAction }, { messageId: message.id, recommendedAction },
"Message not eligible for auto-delete: recommended action is not delete/escalate", "Message not eligible for auto-delete: recommended action is not delete/escalate/warn",
); );
return false; return false;
} }
@@ -2,6 +2,7 @@ import type { Client, PermissionString } from "discord.js-selfbot-v13";
import { LRUCache } from "lru-cache"; import { LRUCache } from "lru-cache";
import { createChildLogger } from "@/shared/logger/index"; import { createChildLogger } from "@/shared/logger/index";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import { parseRichMessageMetadata } from "../message-capture/messageMetadata.js";
import { messageStore } from "../message-capture/messageStore.js"; import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js"; import type { MessageRecord } from "../message-capture/types.js";
import { import {
@@ -10,6 +11,7 @@ import {
} from "./autoDeleteEligibility.js"; } from "./autoDeleteEligibility.js";
import { logDeletionToChannel } from "./autoDeleteLogger.js"; import { logDeletionToChannel } from "./autoDeleteLogger.js";
import { sendDeletionNotification } from "./autoDeleteNotify.js"; import { sendDeletionNotification } from "./autoDeleteNotify.js";
import { llmChat } from "./llmClient.js";
import { verdictToActionFields } from "./verdictToActionFields.js"; import { verdictToActionFields } from "./verdictToActionFields.js";
const logger = createChildLogger("auto-delete-manager"); const logger = createChildLogger("auto-delete-manager");
@@ -94,6 +96,26 @@ export async function resetOffensiveNickname(
} }
// setNickname(null) = remove nickname → Discord shows global username // setNickname(null) = remove nickname → Discord shows global username
await member.setNickname(null, "[auto] nickname melanggar aturan server"); await member.setNickname(null, "[auto] nickname melanggar aturan server");
// ── Check if the global username is ALSO offensive ──────────────
// After reset, the member shows their global username. If that is
// also offensive (gambling/scam keywords), replace it with a random
// generated username so the user can't circumvent the filter by
// having an offensive global username.
const refreshedMember = await guild.members.fetch(userId);
const globalUsername = refreshedMember.user?.username ?? "";
if (await isGlobalUsernameOffensiveAI(globalUsername)) {
const randomName = generateRandomUsername();
await refreshedMember.setNickname(
randomName,
"[auto] global username juga melanggar, diganti random",
);
logger.info(
{ messageId, guildId, userId, globalUsername, randomName },
"Global username also offensive — replaced with random username",
);
}
recentNicknameResets.set(cooldownKey, Date.now()); recentNicknameResets.set(cooldownKey, Date.now());
logger.info( logger.info(
{ messageId, guildId, userId }, { messageId, guildId, userId },
@@ -119,6 +141,69 @@ export async function resetOffensiveNickname(
} }
} }
// ─── AI-Based Global Username Check ──────────────────────────────────
/**
* Ask the AI moderation LLM whether a global username is offensive
* (gambling, scam, spam, NSFW, etc.). Returns `true` if the LLM
* judges the username as violating server rules.
*
* Fail-open: if the LLM call fails or times out, returns `false`
* so the nickname reset still completes — we don't want a broken LLM
* to block enforcement.
*/
async function isGlobalUsernameOffensiveAI(username: string): Promise<boolean> {
try {
const completion = await llmChat({
messages: [
{
role: "system",
content:
'Kamu adalah moderator Discord. Tentukan apakah username berikut melanggar aturan server (judi, togel, scam, spam, NSFW, SARA, atau ofensif). Jawab HANYA dengan JSON: {"offensive": true} atau {"offensive": false}. Jangan penjelasan tambahan.',
},
{
role: "user",
content: `Username: "${username}"`,
},
],
max_tokens: 50,
temperature: 0,
stream: false,
timeout: 10_000,
});
const content = completion?.choices?.[0]?.message?.content?.trim() ?? "";
// Parse JSON response — handle both strict JSON and markdown-wrapped
const jsonMatch = content.match(
/\{[^}]*"offensive"\s*:\s*(true|false)[^}]*\}/,
);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]) as { offensive: boolean };
return parsed.offensive === true;
}
// Fallback: if response contains "true" anywhere, treat as offensive
return content.toLowerCase().includes("true");
} catch (error) {
logger.warn(
{
username,
error: error instanceof Error ? error.message : String(error),
},
"AI username check failed — failing open (not offensive)",
);
return false;
}
}
/**
* Generate a random fallback username: "User" + 5-digit number.
* Guaranteed unique per call (random suffix).
*/
function generateRandomUsername(): string {
const suffix = Math.floor(10000 + Math.random() * 90000);
return `User${suffix}`;
}
// ─── Error Handling Utilities ──────────────────────────────────────── // ─── Error Handling Utilities ────────────────────────────────────────
function getErrorCode(error: unknown): number | string | undefined { function getErrorCode(error: unknown): number | string | undefined {
@@ -130,7 +215,28 @@ function getErrorCode(error: unknown): number | string | undefined {
function isAlreadyDeletedError(error: unknown): boolean { function isAlreadyDeletedError(error: unknown): boolean {
const code = getErrorCode(error); const code = getErrorCode(error);
return code === 10008 || code === 404 || code === "10008" || code === "404"; // Discord REST error codes for "message not found":
// 10008 = Unknown Message, 10003 = Unknown Channel,
// 50001 = Missing Access (channel deleted/hidden), 404 = HTTP
if (
code === 10008 ||
code === 10003 ||
code === 50001 ||
code === 404 ||
code === "10008" ||
code === "10003" ||
code === "50001" ||
code === "404"
)
return true;
// Fallback: check the message text for the Discord "Unknown Message" string
const msg =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
return msg.includes("Unknown Message") || msg.includes("Unknown Channel");
} }
function hasChannelMessagesApi(channel: unknown): channel is { function hasChannelMessagesApi(channel: unknown): channel is {
@@ -164,6 +270,21 @@ function hasPermissionApi(channel: unknown): channel is {
// ─── Database Action Log ───────────────────────────────────────────── // ─── Database Action Log ─────────────────────────────────────────────
/**
* Extract the member's server-specific display name (nickname) from a
* captured message's metadata. Falls back to the global username when no
* server nickname exists (member.displayName defaults to the guild nickname
* when set, otherwise the global username).
*/
function resolveServerNick(message: MessageRecord): string | null {
try {
const parsed = parseRichMessageMetadata(message.metadata);
return parsed?.member?.displayName ?? message.username ?? null;
} catch {
return message.username ?? null;
}
}
async function logAutoDeleteAttempt( async function logAutoDeleteAttempt(
message: MessageRecord, message: MessageRecord,
result: AutoDeleteResult, result: AutoDeleteResult,
@@ -176,6 +297,8 @@ async function logAutoDeleteAttempt(
action_type: "delete_message", action_type: "delete_message",
reason: result.reason, reason: result.reason,
...verdictToActionFields(message), ...verdictToActionFields(message),
username: message.username,
server_nick: resolveServerNick(message),
executed_by: "auto-delete-manager", executed_by: "auto-delete-manager",
status: result.deleted status: result.deleted
? "executed" ? "executed"
@@ -241,6 +364,8 @@ export async function attemptAutoDeleteFlaggedMessage(
reason: reason:
"nickname melanggar aturan server (offensive_username); pesan dibiarkan", "nickname melanggar aturan server (offensive_username); pesan dibiarkan",
...verdictToActionFields(message), ...verdictToActionFields(message),
username: message.username,
server_nick: resolveServerNick(message),
executed_by: "auto-delete-manager", executed_by: "auto-delete-manager",
status: resetOk ? "executed" : "failed", status: resetOk ? "executed" : "failed",
error: resetOk ? null : "nickname_reset_failed", error: resetOk ? null : "nickname_reset_failed",
@@ -163,20 +163,14 @@ export async function processBatch(
messages, messages,
})) as AnalysisWorkerResponse; })) as AnalysisWorkerResponse;
// Do not broadcast or auto-delete if it's an API failure that will be reverted. // Broadcast + auto-delete only for successfully analyzed rows.
// Error rows (API failures, parse failures, incomplete) will be
// retried by the individual fallback queue — do NOT schedule
// auto-delete for them (they'd be logged as not_eligible anyway).
for (const row of result.rows) { for (const row of result.rows) {
let isApiFailure = false; if (row.ai_status === "error") continue;
if (row.ai_status === "error") { broadcastAnalysisCompleted(row);
try { scheduleAutoDelete(row);
const flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
isApiFailure = flags.includes("analysis_api_failed");
} catch {}
}
if (!isApiFailure) {
broadcastAnalysisCompleted(row);
scheduleAutoDelete(row);
}
} }
if (!result.ok) { if (!result.ok) {
@@ -233,6 +227,10 @@ export async function processBatch(
// conversation cooldown instead of an immediate individual retry. // conversation cooldown instead of an immediate individual retry.
apiFailedMessages.push(msg); apiFailedMessages.push(msg);
break; break;
case "completed":
// Successfully analyzed — already broadcast + auto-delete scheduled
// above. Do NOT re-enqueue for individual fallback.
break;
default: default:
// incomplete / parse_failed / unexplained drops stay retryable via // incomplete / parse_failed / unexplained drops stay retryable via
// the individual fallback queue (same semantics as before). // the individual fallback queue (same semantics as before).
@@ -136,6 +136,12 @@ export function formatMessageForPrompt(
return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`; return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`;
} }
/** Estimate tokens for a batch of strings — single encode call, ~5x faster than per-line. */
export function estimateTokensBatch(texts: string[]): number {
const combined = texts.join("\n");
return getEncoder().encode(combined).length + texts.length * 15;
}
/** Max content chars per context line — a single huge paste (log dump, /** Max content chars per context line — a single huge paste (log dump,
* copypasta) must not eat the whole conversation budget. */ * copypasta) must not eat the whole conversation budget. */
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500; const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
@@ -264,10 +270,7 @@ export function buildConversationContext(
const targetLines = targets.map((msg) => const targetLines = targets.map((msg) =>
formatMessageForPrompt(msg, "target"), formatMessageForPrompt(msg, "target"),
); );
let usedTokens = targetLines.reduce( let usedTokens = estimateTokensBatch(targetLines);
(sum, line) => sum + estimateTokens(line),
0,
);
const contextLines = gatedNewestFirst.map((msg) => const contextLines = gatedNewestFirst.map((msg) =>
formatMessageForPrompt(msg, "context"), formatMessageForPrompt(msg, "context"),
@@ -16,6 +16,7 @@ import { delay, retryWithBackoff } from "@/shared/utils/index";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import type { AnalysisResult } from "../message-capture/types.js"; import type { AnalysisResult } from "../message-capture/types.js";
import { llmChat } from "./llmClient.js"; import { llmChat } from "./llmClient.js";
import { parseModerationResponse } from "./moderationResponseParser.js";
import { logModerationError } from "./responseLogger.js"; import { logModerationError } from "./responseLogger.js";
const log = createChildLogger("llm-caller"); const log = createChildLogger("llm-caller");
@@ -79,7 +80,8 @@ export async function callModerationLLM(
]; ];
const completion = await llmChat({ const completion = await llmChat({
messages, messages,
max_tokens: maxTokens ?? 16384, max_tokens:
maxTokens ?? config.AI_LLM_MAX_COMPLETION_TOKENS ?? 16384,
jsonResponse: { type: "json_object" }, jsonResponse: { type: "json_object" },
retries: 0, retries: 0,
signal, signal,
@@ -106,9 +108,6 @@ export async function callModerationLLM(
if (!rawContent) throw new Error("No content in LLM response"); if (!rawContent) throw new Error("No content in LLM response");
try { try {
const { parseModerationResponse } = await import(
"./moderationResponseParser.js"
);
return { return {
parsed: parseModerationResponse(rawContent, targetIds), parsed: parseModerationResponse(rawContent, targetIds),
result: completion, result: completion,
@@ -461,7 +461,9 @@ export async function runModerationAnalysis(
cacheKey, cacheKey,
stored, stored,
embeddingsByKey.get(cacheKey), embeddingsByKey.get(cacheKey),
).catch(() => {}); ).catch((err: unknown) => {
log.warn({ cacheKey, error: String(err) }, "Cache write failed");
});
// Dual-key write-back (2026-08-24): the FIRST analysis of a message runs // Dual-key write-back (2026-08-24): the FIRST analysis of a message runs
// WITH conversation context (accurate), but its verdict is also stored // WITH conversation context (accurate), but its verdict is also stored
@@ -69,6 +69,26 @@ export async function buildCorrectedFewShotExamples(): Promise<string> {
} }
} }
// ---------------------------------------------------------------------------
// Few-shot correction cache (refreshes hourly)
// ---------------------------------------------------------------------------
let _correctedExamplesCache: string | null = null;
let _correctedExamplesCacheAt = 0;
const CORRECTED_CACHE_TTL_MS = 60 * 60 * 1000;
async function getCachedCorrectedExamples(): Promise<string> {
const now = Date.now();
if (
_correctedExamplesCache !== null &&
now - _correctedExamplesCacheAt < CORRECTED_CACHE_TTL_MS
) {
return _correctedExamplesCache;
}
_correctedExamplesCache = await buildCorrectedFewShotExamples();
_correctedExamplesCacheAt = now;
return _correctedExamplesCache;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Text-only batch // Text-only batch
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -88,7 +108,22 @@ export async function runTextOnlyBatch(
for (const url of extractUrlsFromText(msg.edited_content ?? msg.content)) for (const url of extractUrlsFromText(msg.edited_content ?? msg.content))
allUrls.add(url); allUrls.add(url);
} }
const urlArr = Array.from(allUrls).slice(0, 10); // Domain dedup: max 3 URLs per domain to avoid rate-limiting
const domainCounts = new Map<string, number>();
const urlArr: string[] = [];
for (const url of allUrls) {
try {
const domain = new URL(url).hostname;
const count = domainCounts.get(domain) ?? 0;
if (count >= 3) continue;
domainCounts.set(domain, count + 1);
} catch {
/* invalid URL, skip */
continue;
}
urlArr.push(url);
if (urlArr.length >= 10) break;
}
if (urlArr.length === 0) { if (urlArr.length === 0) {
return { return {
text: new Map<string, string>(), text: new Map<string, string>(),
@@ -192,7 +227,7 @@ export async function runTextOnlyBatch(
// Corrected false-positive examples are static per batch — fetch ONCE // Corrected false-positive examples are static per batch — fetch ONCE
// here instead of inside the per-sub-batch retry closure (which would // here instead of inside the per-sub-batch retry closure (which would
// re-query the DB on every sub-batch and every parse-error retry). // re-query the DB on every sub-batch and every parse-error retry).
const correctedExamples = await buildCorrectedFewShotExamples(); const correctedExamples = await getCachedCorrectedExamples();
// ── URL images → multimodal vision evidence (hoisted out of the sub-batch // ── URL images → multimodal vision evidence (hoisted out of the sub-batch
// loop) ─────────────────────────────────────────────────────────── // loop) ───────────────────────────────────────────────────────────
@@ -368,9 +403,13 @@ export async function runTextOnlyBatch(
); );
} catch (err: any) { } catch (err: any) {
if (err.name === "AbortError" || abortController.signal.aborted) { if (err.name === "AbortError" || abortController.signal.aborted) {
throw new Error( // Sub-batch timed out — log but DO NOT throw. Previous sub-batches'
`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`, // results are already in allResults; throwing would discard them.
log.warn(
{ subBatch: i + 1, targetIds, timeoutMs },
"Sub-batch timed out — preserving partial results from prior sub-batches",
); );
continue;
} }
throw err; throw err;
} finally { } finally {
@@ -103,6 +103,8 @@ export class ModerationHandler {
| "kick_user" | "kick_user"
| "ban_user", | "ban_user",
reason: payload.reason ?? null, reason: payload.reason ?? null,
username: null,
server_nick: null,
executed_by: payload.executed_by ?? "command-handler", executed_by: payload.executed_by ?? "command-handler",
status: "executed", status: "executed",
error: null, error: null,
@@ -1,4 +1,5 @@
import type { Client } from "discord.js-selfbot-v13"; import type { Client } from "discord.js-selfbot-v13";
import Redis from "ioredis";
import { config } from "../../shared/config/config.js"; import { config } from "../../shared/config/config.js";
import type { CommandMessage, CommandReply } from "../../shared/index.js"; import type { CommandMessage, CommandReply } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js"; import { createChildLogger } from "../../shared/logger/index.js";
@@ -152,13 +153,28 @@ export class VoiceHandler {
}; };
} }
// Double-check: verify the voice controller also reports connected
if (this.voiceController) {
const vcStatus = this.voiceController.getStatus();
if (!vcStatus.connected) {
this.logger.warn(
"Player reports connected but voice controller says disconnected — stale state",
);
return {
id: cmd.id,
success: false,
data: null,
error: "Voice channel connection is stale — reconnect first",
};
}
}
try { try {
// IMPORTANT: transmitter needs its OWN Redis client because it calls // IMPORTANT: transmitter needs its OWN Redis client because it calls
// .subscribe() which converts the connection to subscriber mode. Reusing // .subscribe() which converts the connection to subscriber mode. Reusing
// the publish connection from CommandHandler would corrupt it and break // the publish connection from CommandHandler would corrupt it and break
// every command reply + status update. // every command reply + status update.
const { default: IORedis } = await import("ioredis"); const transmitRedis = new Redis(config.REDIS_URL);
const transmitRedis = new IORedis(config.REDIS_URL);
await voiceTransmitter.start(transmitRedis); await voiceTransmitter.start(transmitRedis);
const status = voiceTransmitter.getStatus(); const status = voiceTransmitter.getStatus();
@@ -50,6 +50,21 @@ export function finalizeSegment(input: SegmentFinalizerInput): void {
} = input; } = input;
const endTime = currentSegment.endTime ?? Date.now(); const endTime = currentSegment.endTime ?? Date.now();
const durationMs = endTime - currentSegment.startTime;
// Discard segments shorter than 1 second — not useful as a recording,
// would just be a blip of ambient noise or a mic click.
const MIN_DURATION_MS = 1000;
if (durationMs < MIN_DURATION_MS) {
logger.debug(
{ filename: currentSegment.filename, durationMs },
"Segment too short, discarding",
);
// Clean up the OGG file
fsPromises.unlink(currentSegment.filename).catch(() => {});
fsPromises.unlink(currentSegment.jsonFilename).catch(() => {});
return;
}
if (config.VERBOSE) { if (config.VERBOSE) {
logger.info({ filename: currentSegment.filename }, "Segment saved"); logger.info({ filename: currentSegment.filename }, "Segment saved");
@@ -23,10 +23,18 @@ export class VoiceTransmitter {
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT; private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
/** Queue for PCM chunks when backpressure is active */ /** Queue for PCM chunks when backpressure is active */
private backpressureQueue: Buffer[] = []; private backpressureQueue: Buffer[] = [];
/** Max queued chunks before dropping oldest (prevents unbounded memory growth) */
private static readonly MAX_QUEUE = 500;
/** Serialise start/stop to prevent races between rapid toggle commands */ /** Serialise start/stop to prevent races between rapid toggle commands */
private gate = Promise.resolve(); private gate = Promise.resolve();
/** Set true before sending SIGTERM so exit handler knows it's intentional */ /** Set true before sending SIGTERM so exit handler knows it's intentional */
private _expectedExit = false; private _expectedExit = false;
/** Auto-stop timer: if no PCM received for this long, stop transmitter */
private _activityTimer: ReturnType<typeof setTimeout> | null = null;
/** How long to wait without PCM before auto-stopping (10s) */
private static readonly ACTIVITY_TIMEOUT_MS = 10_000;
/** Max stderr bytes to retain for error reporting */
private static readonly MAX_STDERR_BYTES = 4096;
/** /**
* Start listening for PCM audio data from Redis and stream to Discord * Start listening for PCM audio data from Redis and stream to Discord
@@ -48,7 +56,6 @@ export class VoiceTransmitter {
return; return;
} }
this.redisSub = redis;
this.isActive = true; this.isActive = true;
// Create PCM input stream // Create PCM input stream
@@ -115,10 +122,14 @@ export class VoiceTransmitter {
this.pcmStream.pipe(this.ffmpegProcess.stdin); this.pcmStream.pipe(this.ffmpegProcess.stdin);
} }
// Log FFmpeg stderr for debugging // Log FFmpeg stderr for debugging (cap to prevent unbounded growth)
const stderrChunks: Buffer[] = []; const stderrChunks: Buffer[] = [];
let stderrLen = 0;
this.ffmpegProcess.stderr?.on("data", (chunk: Buffer) => { this.ffmpegProcess.stderr?.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk); stderrLen += chunk.length;
if (stderrLen <= VoiceTransmitter.MAX_STDERR_BYTES) {
stderrChunks.push(chunk);
}
}); });
this.ffmpegProcess.on("error", (err) => { this.ffmpegProcess.on("error", (err) => {
@@ -135,8 +146,11 @@ export class VoiceTransmitter {
const stderr = Buffer.concat(stderrChunks).toString(); const stderr = Buffer.concat(stderrChunks).toString();
logger.error( logger.error(
{ code, stderr: stderr.slice(-500) }, { code, stderr: stderr.slice(-500) },
"FFmpeg exited with error", "FFmpeg exited with error — auto-stopping transmitter",
); );
// FFmpeg crashed — auto-stop to prevent silent audio loss.
// Fire-and-forget; stop() serialises with the gate.
void this.stop();
} else { } else {
logger.debug({ code }, "FFmpeg process exited"); logger.debug({ code }, "FFmpeg process exited");
} }
@@ -155,11 +169,15 @@ export class VoiceTransmitter {
); );
// Subscribe to Redis channel for PCM data // Subscribe to Redis channel for PCM data
await this.redisSub.subscribe(this.TRANSMIT_CHANNEL); await redis.subscribe(this.TRANSMIT_CHANNEL);
// Assign AFTER subscription succeeds — prevents race with stop()
this.redisSub = redis;
logger.info( logger.info(
{ channel: this.TRANSMIT_CHANNEL }, { channel: this.TRANSMIT_CHANNEL },
"Subscribed to transmit channel", "Subscribed to transmit channel",
); );
// Start activity timer — will auto-stop if no PCM arrives within timeout
this.resetActivityTimer();
this.redisSub.on("message", (channel, message) => { this.redisSub.on("message", (channel, message) => {
if ( if (
@@ -175,8 +193,21 @@ export class VoiceTransmitter {
const pcmBuffer = Buffer.from(data.buffer, "base64"); const pcmBuffer = Buffer.from(data.buffer, "base64");
const stream = this.pcmStream; const stream = this.pcmStream;
const canContinue = stream.write(pcmBuffer); const canContinue = stream.write(pcmBuffer);
// Backpressure: queue until drain // Reset activity timer — PCM is flowing
this.resetActivityTimer();
// Backpressure: queue until drain (cap to prevent memory leak)
if (!canContinue) { if (!canContinue) {
if (this.backpressureQueue.length >= VoiceTransmitter.MAX_QUEUE) {
// Drop oldest chunks to free memory — real-time audio,
// stale data is useless
const dropCount = Math.floor(VoiceTransmitter.MAX_QUEUE * 0.25);
this.backpressureQueue.splice(0, dropCount);
logger.debug(
{ dropped: dropCount },
"Backpressure overflow — dropping oldest PCM chunks",
);
}
this.backpressureQueue.push(pcmBuffer);
stream.once("drain", () => { stream.once("drain", () => {
const currentStream = this.pcmStream; const currentStream = this.pcmStream;
if (!currentStream || !this.isActive) return; if (!currentStream || !this.isActive) return;
@@ -210,6 +241,30 @@ export class VoiceTransmitter {
} }
} }
/**
* Reset the voice activity timer. Called on every PCM chunk received.
* If no chunks arrive for ACTIVITY_TIMEOUT_MS, auto-stop the transmitter
* to prevent dead air and wasted resources.
*/
private resetActivityTimer(): void {
this.clearActivityTimer();
this._activityTimer = setTimeout(() => {
if (!this.isActive) return;
logger.warn(
{ timeoutMs: VoiceTransmitter.ACTIVITY_TIMEOUT_MS },
"Voice activity timeout — no PCM received, auto-stopping transmitter",
);
void this.stop();
}, VoiceTransmitter.ACTIVITY_TIMEOUT_MS);
}
private clearActivityTimer(): void {
if (this._activityTimer !== null) {
clearTimeout(this._activityTimer);
this._activityTimer = null;
}
}
/** /**
* Stop transmitting and clean up resources * Stop transmitting and clean up resources
*/ */
@@ -232,6 +287,7 @@ export class VoiceTransmitter {
this.isActive = false; this.isActive = false;
this.clearActivityTimer();
this.backpressureQueue = []; this.backpressureQueue = [];
if (this.pcmStream) { if (this.pcmStream) {
@@ -150,7 +150,7 @@ export const configSchema = z
.string() .string()
.url() .url()
.default("https://9router.asepharyana.my.id/v1"), .default("https://9router.asepharyana.my.id/v1"),
AI_LLM_MODEL: z.string().default("claude-opus-5"), AI_LLM_MODEL: z.string().default("text"),
// Vision uses the SAME router/base URL as text moderation // Vision uses the SAME router/base URL as text moderation
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA // (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
// multimodal endpoint was removed. // multimodal endpoint was removed.
@@ -197,6 +197,11 @@ export const configSchema = z
.positive() .positive()
.default(1024), .default(1024),
AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(60), AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(60),
AI_LLM_MAX_COMPLETION_TOKENS: z.coerce
.number()
.int()
.positive()
.default(16384),
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce
.number() .number()
.int() .int()
@@ -56,25 +56,56 @@ async function getFirstMigrationTag(): Promise<string> {
return first.tag; return first.tag;
} }
/**
* Read the Drizzle migration journal and return the `when` (folderMillis)
* timestamp of the LAST migration entry. Drizzle's PG migrator applies a
* migration only when `tracked_max_created_at < migration.when`, so seeding
* the tracking table with this value marks every existing journal migration
* as already applied.
*/
async function getLastMigrationWhen(): Promise<number> {
const journalPath = join(
process.cwd(),
"drizzle/migrations/meta/_journal.json",
);
const raw = await readFile(journalPath, "utf-8");
const journal: MigrationJournal = JSON.parse(raw);
if (!journal.entries || journal.entries.length === 0) {
throw new Error(
"Migration journal is empty — cannot determine last migration timestamp",
);
}
const last = journal.entries.reduce((newest, entry) =>
entry.when > newest.when ? entry : newest,
);
return last.when;
}
/** /**
* Seed Drizzle's __drizzle_migrations tracking table for pre-existing databases * Seed Drizzle's __drizzle_migrations tracking table for pre-existing databases
* that were created manually or by an earlier migration system (e.g., the old * that were created manually or by an earlier migration system (e.g., the old
* checkSchemaExists short-circuit). Without this, Drizzle attempts to re-create * checkSchemaExists short-circuit). Without this, Drizzle attempts to re-create
* all tables from 0000 and fails with "relation already exists". * all tables from 0000 and fails with "relation already exists".
*
* The Drizzle PG migrator (drizzle-orm 0.45.x) only reads the LAST tracked
* migration (`SELECT ... ORDER BY created_at DESC LIMIT 1` from
* <migrationsSchema>.__drizzle_migrations) and re-applies every journal
* migration whose `when` is newer than that value. If the tracking table is
* empty or missing the latest entries while the DB schema is already up to
* date, re-running the migrations crashes with "column/relation already
* exists" — putting the gateway into an infinite restart loop.
*
* So instead of early-returning when the table exists (which left it empty /
* partial and caused this exact crash), we RECONCILE: when the DB is a
* pre-existing one (app tables already present) and the schema already
* reflects the latest migration, we seed the tracking table up to the latest
* journal `when` so Drizzle skips everything instead of re-applying it.
*/ */
async function seedDrizzleHistory(client: PoolClient): Promise<void> { async function seedDrizzleHistory(client: PoolClient): Promise<void> {
const exists = await client.query(` // Check whether the app tables pre-exist (old ./migrations/ SQL or manual
SELECT EXISTS ( // creation). If not, this is a brand-new database — let Drizzle handle it.
SELECT FROM information_schema.tables
WHERE table_name = '__drizzle_migrations'
)
`);
const drizzleTableExists = exists.rows[0]?.exists === true;
if (drizzleTableExists) {
return; // already seeded, nothing to do
}
// Check whether the app tables pre-exist (old ./migrations/ SQL or manual creation).
const hasTextCache = await client.query(` const hasTextCache = await client.query(`
SELECT EXISTS ( SELECT EXISTS (
SELECT FROM information_schema.columns SELECT FROM information_schema.columns
@@ -85,12 +116,19 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
return; // brand-new database, let Drizzle handle everything return; // brand-new database, let Drizzle handle everything
} }
const firstMigrationTag = await getFirstMigrationTag(); // Final schema "at latest migration" sentinel: the moderation_actions
// table at 0019 has a server_nick column. If present, the schema is at (or
// past) migration 0019, so any journal entries not yet tracked are safe to
// mark as applied rather than re-running (which would fail).
const atLatest = await client.query(`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_nick'
)
`);
const schemaAtLatest = atLatest.rows[0]?.exists === true;
logger.info( const lastMigrationWhen = await getLastMigrationWhen();
{ firstMigrationTag },
"Seeding Drizzle migration history — marking first migration as already applied on this pre-existing database",
);
// Create the Drizzle tracking table if it does not exist. // Create the Drizzle tracking table if it does not exist.
// PG15+ locks down CREATE on the public schema for non-owner roles, // PG15+ locks down CREATE on the public schema for non-owner roles,
@@ -128,21 +166,43 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
throw createErr; throw createErr;
} }
} }
// Drizzle's __drizzle_migrations table has no UNIQUE(hash)
// constraint, so check manually before inserting. // Current max tracked created_at. Drizzle only re-applies migrations newer
const alreadySeeded = await client.query( // than this, so if it already reaches the journal's last `when`, nothing to do.
`SELECT 1 FROM "__drizzle_migrations" WHERE hash = $1 LIMIT 1`, const tracked = await client.query(`
[firstMigrationTag], SELECT COALESCE(MAX(created_at), 0) AS max_created FROM "__drizzle_migrations"
); `);
if (alreadySeeded.rows.length === 0) { const trackedMax = Number(tracked.rows[0]?.max_created ?? 0);
await client.query(
`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES ($1, $2)`, if (trackedMax >= lastMigrationWhen) {
[firstMigrationTag, Date.now()], return; // already fully tracked — Drizzle will apply only genuinely-pending ones
);
} }
logger.info(
{ firstMigrationTag }, if (!schemaAtLatest) {
"Drizzle history seeded — first migration marked applied", // Schema is NOT yet at the latest journal migration — this is a genuinely
// old DB that needs the pending migrations to run for real. Let Drizzle
// apply them. (Idempotent migration SQL guards legacy partial states.)
logger.info(
{ trackedMax, lastMigrationWhen },
"DB schema behind latest journal migration — leaving pending migrations to run",
);
return;
}
// Schema already reflects the latest migration but the tracking table is
// behind (e.g. migrations applied manually/out-of-band without being
// recorded). Seed up to the last journal `when` so Drizzle skips them
// instead of re-running already-applied DDL and crashing. This is the
// recovery that broke the gateway's infinite-restart loop.
const journalTag =
(await getFirstMigrationTag()) + `@${lastMigrationWhen}-reconciled`;
await client.query(
`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES ($1, $2)`,
[journalTag, lastMigrationWhen],
);
logger.warn(
{ lastMigrationWhen },
"Reconciled Drizzle migration history — marked schema-at-latest migrations as applied (preventing re-run of already-applied DDL)",
); );
} }
@@ -642,6 +642,8 @@ export const pgModerationActionsTable = pgTable(
score: pgReal("score"), // 0..1 raw model score score: pgReal("score"), // 0..1 raw model score
evidence: pgText("evidence"), // JSON array of short quoted snippets evidence: pgText("evidence"), // JSON array of short quoted snippets
policy_version: pgText("policy_version"), // rules.ts policy version string policy_version: pgText("policy_version"), // rules.ts policy version string
username: pgText("username"), // denormalized from messages table for retention safety
server_nick: pgText("server_nick"), // member's server-specific display name (nickname), from metadata.member.displayName
}, },
(table) => ({ (table) => ({
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on( messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
@@ -59,6 +59,8 @@ export const pgModerationActionsTable = pgTable(
score: pgReal("score"), // 0..1 raw model score score: pgReal("score"), // 0..1 raw model score
evidence: pgText("evidence"), // JSON array of short quoted snippets evidence: pgText("evidence"), // JSON array of short quoted snippets
policy_version: pgText("policy_version"), // rules.ts policy version string policy_version: pgText("policy_version"), // rules.ts policy version string
username: pgText("username"), // denormalized from messages table for retention safety
server_nick: pgText("server_nick"), // member's server-specific display name (nickname), from metadata.member.displayName
}, },
(table) => ({ (table) => ({
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on( messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
@@ -208,6 +208,8 @@ export interface ModerationAction {
guild_id: string; guild_id: string;
action_type: ModerationActionType; action_type: ModerationActionType;
reason: string | null; reason: string | null;
username: string | null;
server_nick: string | null;
executed_by: string | null; executed_by: string | null;
status: "pending" | "executed" | "failed"; status: "pending" | "executed" | "failed";
error: string | null; error: string | null;
@@ -0,0 +1,81 @@
# GMW FE Rombak — Extend Tactical-HUD/GSAP Treatment Wide (2026-08-26)
## Context / lessons that constrain this plan
- 2026-08-24: full novel navigation metaphor (constellation/three.js) was
SHIPPED green on every gate then REJECTED by user for hurting usability.
Rolled back entirely. Lesson: do NOT invent a new nav metaphor.
- 2026-08-24: accepted direction = monochrome theme + game-menu style sidebar
animation ON TOP of the familiar shell (rail/topbar/panel kept).
- 2026-08-25: `dashboard` and `media` routes were already revamped into a
"Tactical HUD" style with GSAP (`@gsap/react` useGSAP) reactive stages —
commits 183a510, cab451f. This is the established, ACCEPTED visual language.
- Remaining routes (`channels`, `voice`, `messages`, `moderation`,
`recordings`, `analysis`, `glossary`) have NOT received the tactical-HUD/GSAP
treatment — last touch was an error-handling/state pass (31e303c) or the
constellation revert (eda5c75).
## Scope of THIS request
User: "rombak fe dengan sekeren mungkin tapi tidak menyusahkan user, pewarnaan
sesuai tidak berlebihan, pakai gsap, sekreatif mungkin."
Read against the history above, this means: EXTEND the already-accepted
tactical-HUD + GSAP + monochrome visual language to the remaining routes,
adding creative micro-interactions and view-specific "reactive stage" touches
— NOT a new structural/navigation metaphor, NOT a color explosion.
## Hard constraints (do not violate)
1. Keep the existing shell: top bar / nav rail / mobile bottom dock. No new
navigation metaphor.
2. Monochrome/greyscale token system stays as-is (`--color-signal/amber/
vermilion` luminance greys). Do not introduce saturated colors; accent use
stays restrained (used sparingly for state: active/alert/success).
3. GSAP via `@gsap/react` `useGSAP({ scope, dependencies })` + `clearProps` on
cleanup, per `dashboard-rombak/references/tactical-hud-gsap-pattern.md`.
Respect `prefers-reduced-motion` (extend the kill-list in globals.css for
any new `animate-*` class).
4. Mobile lightness gate: any WebGL/heavy canvas work must gate off on
<768px / pointer:coarse / saveData / deviceMemory<=4 (see AmbientCanvas
pattern already in the codebase).
5. Each route ships independently: edit → `pnpm format` → `npx @biomejs/biome
check --write` → `pnpm lint` (0 errors, 0 warnings) → `tsc --noEmit` →
`pnpm build` → commit (no Co-Authored-By trailer) → push → `gh run watch`
for Build & Deploy (Nix) → live verify via browser_vision on
imphnen.asepharyana.my.id/<route>/.
6. NEVER claim "done" from reading code — every route gets a live screenshot
check confirming: (a) not a stock template, (b) monochrome/restrained,
(c) GSAP entrance/interaction actually fires, (d) nav/theme toggle/command
palette still work.
## Per-route creative direction (reactive-stage motifs, one distinct idea per
route so it doesn't feel copy-pasted from dashboard/media)
- **channels**: node/roster telemetry — GSAP stagger-in channel cards with a
"signal strength" bar-fill tween keyed to activity; hover reveals HUD-style
meta readout.
- **voice**: live speaker HUD — waveform/ring pulse tied to WS voice_state
(GSAP timeline looping while speaking, `clearProps` on stop); ambient tile
glow keyed to active speaker count.
- **messages**: scan-line reveal on list mount + WS-driven "new message" slide
insert (respect existing sortMessages fix — do not reintroduce the race).
- **moderation**: alert-priority HUD — flagged severity as inverted mono
badge with a pulse GSAP loop (no red, per accepted mono direction);
queue-clear micro celebration (subtle, no confetti-color).
- **recordings**: waveform scrub deck echoing media's frequency-deck visual
language but for static recordings (progress-tied GSAP scrub, not looping).
- **analysis**: gauge/metric reveal using the existing `radial-gauge.tsx`
driven by a GSAP number tween (count-up) instead of an instant value snap.
- **glossary**: lightweight — accordion/definition reveal with GSAP height
auto + fade, kept minimal since it's low-traffic reference content.
## Verification checklist (must pass before calling any route "shipped")
- [ ] tsc --noEmit clean
- [ ] biome check: 0 errors AND 0 warnings
- [ ] next build succeeds
- [ ] CI green (Build & Deploy (Nix))
- [ ] Live screenshot: monochrome, GSAP motion visible, nav/theme/command
palette intact, not a stock template
- [ ] Reduced-motion: new animate-* classes added to the kill-list
- [ ] Mobile: WebGL/heavy work gated per existing pattern
## Rollback plan
Every route ships as its own atomic commit. If user rejects a specific route's
direction, `git revert <that commit>` — do not touch unrelated routes.
+12 -14
View File
@@ -12,27 +12,25 @@
"dependencies": { "dependencies": {
"@orpc/client": "1.15.0", "@orpc/client": "1.15.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.27.0", "lucide-react": "^1.33.0",
"motion": "^12.0.0", "next": "16.3.2",
"next": "16.2.12",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"partysocket": "1.3.0", "partysocket": "1.3.0",
"react": "19.2.4", "react": "19.2.8",
"react-dom": "19.2.4", "react-dom": "19.2.8",
"swr": "^2.4.2", "swr": "^2.5.1",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0"
"three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.0", "@biomejs/biome": "2.5.10",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^26.2.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/three": "^0.180.0", "@types/three": "^0.185.4",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"puppeteer-core": "^25.7.0", "puppeteer-core": "^25.8.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^7"
} }
} }
+537 -376
View File
File diff suppressed because it is too large. Load diff
@@ -1,12 +1,7 @@
import { PageTransition } from "@/components/shared";
import { AnalysisView } from "./view"; import { AnalysisView } from "./view";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default function AnalysisPage() { export default function AnalysisPage() {
return ( return <AnalysisView />;
<PageTransition>
<AnalysisView />
</PageTransition>
);
} }
@@ -1,11 +1,12 @@
"use client"; "use client";
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react"; import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives"; import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared"; import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks"; import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { aiTone } from "@/lib/ai-status"; import { aiTone } from "@/lib/ai-status";
import { import {
formatDuration, formatDuration,
@@ -20,6 +21,15 @@ export function AnalysisView() {
const { data: channels } = useChannels(); const { data: channels } = useChannels();
const ambient = useAmbient(); const ambient = useAmbient();
const reactorsRef = useRef<HTMLDivElement>(null);
const channelsRef = useRef<HTMLDivElement>(null);
const resultsRef = useStaggerReveal<HTMLDivElement>(".search-result-card", {
stagger: 0.03,
y: 8,
dependencies: [search.data?.length, query],
});
useEffect(() => { useEffect(() => {
ambient.set( ambient.set(
query ? "amber" : "signal", query ? "amber" : "signal",
@@ -28,74 +38,184 @@ export function AnalysisView() {
); );
}, [query, ambient]); }, [query, ambient]);
// Bar-fill reveal for reactor leaderboard — CSS animation
useEffect(() => {
const container = reactorsRef.current;
if (!container) return;
const bars = container.querySelectorAll<HTMLElement>(".reactor-bar-fill");
const counters = container.querySelectorAll<HTMLElement>(".reactor-count");
if (bars.length === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
bars.forEach((el) => (el.style.transform = "scaleX(1)"));
counters.forEach((el) => {
el.textContent = `+${el.getAttribute("data-target") ?? "0"}`;
});
return;
}
bars.forEach((el, i) => {
el.style.transformOrigin = "left center";
el.style.animationFillMode = "forwards";
el.style.animationTimingFunction = "ease-out";
el.style.animationName = "bar-fill-in";
el.style.animationDuration = "0.7s";
el.style.animationDelay = `${i * 0.06}s`;
});
// Simple counter animation using rAF
counters.forEach((el) => {
const target = Number(el.getAttribute("data-target") ?? "0");
const start = performance.now();
const duration = 800;
const step = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - (1 - progress) ** 3;
el.textContent = `+${Math.round(target * eased)}`;
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
return () => {
bars.forEach((el) => {
el.style.removeProperty("animation-name");
el.style.removeProperty("animation-duration");
el.style.removeProperty("animation-delay");
el.style.removeProperty("animation-fill-mode");
el.style.removeProperty("animation-timing-function");
el.style.removeProperty("transform-origin");
});
};
}, []);
// Channel rows stagger slide-in — CSS animation
useEffect(() => {
const container = channelsRef.current;
if (!container) return;
const rows = container.querySelectorAll<HTMLElement>(".channel-row");
if (rows.length === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
rows.forEach((el, i) => {
el.style.opacity = "0";
el.style.animationFillMode = "forwards";
el.style.animationTimingFunction = "ease-out";
el.style.animationName = "stagger-slide-in";
el.style.animationDuration = "0.35s";
el.style.animationDelay = `${i * 0.04}s`;
});
return () => {
rows.forEach((el) => {
el.style.removeProperty("opacity");
el.style.removeProperty("animation-name");
el.style.removeProperty("animation-duration");
el.style.removeProperty("animation-delay");
el.style.removeProperty("animation-fill-mode");
el.style.removeProperty("animation-timing-function");
});
};
}, []);
return ( return (
<div className="space-y-5"> <div className="space-y-4">
<GlassPanel glow className="relative overflow-hidden"> {/* Tactical HUD Header Bar */}
<div className="scan-line absolute inset-x-0 top-0" /> <div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Deep Scan · Semantic Search & Telemetry Analysis
</h1>
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>ENGINE:</span>
<span
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
data-text={query.trim().length >= 2 ? "SCANNING" : "STANDBY"}
>
{query.trim().length >= 2 ? "SCANNING" : "STANDBY"}
</span>
</div>
</div>
{/* Hero Query Stage */}
<GlassPanel className="p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Sparkles className="size-5 text-signal" /> <span className="flex size-8 items-center justify-center rounded-[6px] border border-signal/30 bg-signal/10 text-signal">
<Sparkles className="size-4" />
</span>
<div> <div>
<div className="eyebrow">Semantic search</div> <h2 className="text-base font-semibold text-ink">
<h2 className="display text-balance text-2xl text-ink"> Cross-Guild Archive Intelligence
Search the archive
</h2> </h2>
<p className="font-mono text-[11px] text-ink-muted">
Query vectorized messages, heuristic flags, and user behavior
</p>
</div> </div>
</div> </div>
<div className="relative mt-4"> <div className="relative mt-4">
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" /> <Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
<Input <Input
className="h-12 pl-12 text-base" className="h-10 pl-10 text-xs text-ink"
placeholder="Find messages, patterns, flags…" placeholder="Search keywords, behavioral patterns, infraction terms..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
autoFocus autoFocus
/> />
</div> </div>
{query.trim().length > 0 && query.trim().length < 2 && (
<div className="mono mt-2 text-xs text-ink-faint">
Type at least 2 characters…
</div>
)}
</GlassPanel> </GlassPanel>
<div className="grid gap-5 lg:grid-cols-5"> <div className="grid gap-3 lg:grid-cols-5">
{/* Search Results Column */}
<GlassPanel className="lg:col-span-3"> <GlassPanel className="lg:col-span-3">
<SectionHeader <SectionHeader
eyebrow="results" eyebrow="search output"
title="Matches" title="Matched Log Packets"
action={ action={
<span className="mono text-xs text-ink-faint"> <span className="mono text-xs text-[#8a8f98]">
{(search.data ?? []).length} {(search.data ?? []).length} results
</span> </span>
} }
/> />
{query.trim().length >= 2 && search.isLoading && ( {query.trim().length >= 2 && search.isLoading && (
<SkeletonRows rows={6} /> <SkeletonRows rows={6} />
)} )}
{(search.data ?? []).length === 0 ? ( {(search.data ?? []).length === 0 && !search.isLoading ? (
<EmptyState <EmptyState
icon={<Search className="size-7" />} icon={<Search className="size-7" />}
title="No matches yet" title="No packets matched"
description="Run a search to surface messages across the guild." description="Enter a query string above to scan across all captured guild communication."
/> />
) : ( ) : (
<div className="space-y-1.5"> <div ref={resultsRef} className="space-y-1.5 mt-3">
{(search.data ?? []).map((m) => ( {(search.data ?? []).map((m) => (
<div <div
key={m.id} key={m.id}
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3" className="search-result-card hud-card flex items-start gap-3 p-3 transition-all"
> >
<Avatar src={m.avatar_url} name={m.username} size={32} /> <Avatar
src={m.avatar_url}
name={m.server_nick ?? m.username}
size={32}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink"> <span className="text-xs font-semibold text-ink">
{m.username} {m.server_nick ?? m.username}
</span> </span>
<span className="mono text-[0.65rem] text-ink-faint"> {m.server_nick && m.server_nick !== m.username && (
<span className="font-mono text-[10px] text-ink-muted">
@{m.username}
</span>
)}
<span className="font-mono text-[10px] text-ink-faint">
{getMessageChannelLabel(m)} {getMessageChannelLabel(m)}
</span> </span>
{m.ai_status && ( {m.ai_status && (
<Badge tone={aiTone(m.ai_status)} className="ml-auto"> <Badge
tone={aiTone(m.ai_status)}
className="ml-auto font-mono text-[9px]"
>
{m.ai_analysis_duration_ms && {m.ai_analysis_duration_ms &&
m.ai_analysis_duration_ms > 0 m.ai_analysis_duration_ms > 0
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}` ? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
@@ -103,7 +223,7 @@ export function AnalysisView() {
</Badge> </Badge>
)} )}
</div> </div>
<div className="mt-0.5 text-sm text-ink-soft"> <div className="mt-1 text-xs text-ink-soft leading-relaxed">
{renderMessageContent(m.content, m.metadata) || "(embed)"} {renderMessageContent(m.content, m.metadata) || "(embed)"}
</div> </div>
</div> </div>
@@ -113,17 +233,18 @@ export function AnalysisView() {
)} )}
</GlassPanel> </GlassPanel>
<div className="space-y-5 lg:col-span-2"> {/* Culture & Leaderboard Side Column */}
<div className="space-y-3 lg:col-span-2">
<GlassPanel> <GlassPanel>
<SectionHeader <SectionHeader
eyebrow="culture" eyebrow="engagement"
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
<TrendingUp className="size-4 text-signal" /> Top reactors <TrendingUp className="size-3.5 text-signal" /> Top Reactors
</span> </span>
} }
/> />
<div className="space-y-2"> <div ref={reactorsRef} className="space-y-2 mt-3">
{(reactors ?? []).slice(0, 6).map((r, i) => { {(reactors ?? []).slice(0, 6).map((r, i) => {
const maxNet = reactors?.[0]?.net_count || 1; const maxNet = reactors?.[0]?.net_count || 1;
const pct = Math.max( const pct = Math.max(
@@ -133,27 +254,32 @@ export function AnalysisView() {
return ( return (
<div <div
key={r.user_id} key={r.user_id}
className="flex items-center gap-3 text-sm" className="flex items-center gap-3 text-xs"
> >
<span className="mono w-5 text-ink-faint">{i + 1}</span> <span className="font-mono w-4 text-[10px] text-ink-faint">
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40"> 0{i + 1}
</span>
<span className="w-24 shrink-0 truncate font-mono text-xs text-ink sm:w-32">
{r.username} {r.username}
</span> </span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8"> <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div <div
className="h-full rounded-full bg-signal/70" className="reactor-bar-fill h-full rounded-full bg-signal"
style={{ width: `${pct}%` }} style={{ width: `${pct}%` }}
/> />
</div> </div>
<span className="mono w-12 text-right text-xs text-signal"> <span
+{r.net_count} className="reactor-count font-mono w-10 text-right text-[11px] font-semibold text-signal"
data-target={r.net_count}
>
+0
</span> </span>
</div> </div>
); );
})} })}
{(reactors ?? []).length === 0 && ( {(reactors ?? []).length === 0 && (
<div className="py-4 text-center text-xs text-ink-faint"> <div className="py-4 text-center font-mono text-xs text-ink-faint">
No data NO REACTOR DATA
</div> </div>
)} )}
</div> </div>
@@ -161,30 +287,31 @@ export function AnalysisView() {
<GlassPanel> <GlassPanel>
<SectionHeader <SectionHeader
eyebrow="channels" eyebrow="volume"
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
<Hash className="size-4 text-signal" /> Top channels <Hash className="size-3.5 text-signal" /> Top Active Channels
</span> </span>
} }
/> />
<div className="space-y-2"> <div ref={channelsRef} className="space-y-2 mt-3">
{(channels ?? []).slice(0, 6).map((c) => ( {(channels ?? []).slice(0, 6).map((c) => (
<div <div
key={c.channel_id} key={c.channel_id}
className="flex items-center gap-3 text-sm" className="channel-row hud-card flex items-center justify-between p-2 text-xs"
> >
<span className="flex-1 truncate text-ink-soft"> <span className="flex items-center gap-1.5 truncate font-mono text-xs text-ink">
{c.channel_name ?? c.channel_id.slice(0, 8)} <Hash className="size-3 text-signal" />
{c.channel_name ?? c.channel_id.slice(0, 10)}
</span> </span>
<span className="mono text-xs text-ink-faint"> <span className="font-mono text-[10px] text-ink-muted">
{c.total_messages} {c.total_messages.toLocaleString()} msgs
</span> </span>
</div> </div>
))} ))}
{(channels ?? []).length === 0 && ( {(channels ?? []).length === 0 && (
<div className="py-4 text-center text-xs text-ink-faint"> <div className="py-4 text-center font-mono text-xs text-ink-faint">
No data NO CHANNEL DATA
</div> </div>
)} )}
</div> </div>
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getChannelCultures } from "@/lib/api/server"; import { getChannelCultures } from "@/lib/api/server";
import type { ChannelCultureRow } from "@/lib/types"; import type { ChannelCultureRow } from "@/lib/types";
import { ChannelsView } from "./view"; import { ChannelsView } from "./view";
@@ -12,9 +11,5 @@ export default async function ChannelsPage() {
} catch { } catch {
cultures = undefined; cultures = undefined;
} }
return ( return <ChannelsView initialCultures={cultures} />;
<PageTransition>
<ChannelsView initialCultures={cultures} />
</PageTransition>
);
} }
@@ -12,7 +12,23 @@ export function ChannelsView({
}) { }) {
const { data: cultures } = useChannelCultures(100, initialCultures); const { data: cultures } = useChannelCultures(100, initialCultures);
return ( return (
<div className="space-y-5"> <div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-3">
<div className="relative flex size-3 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-signal" />
</div>
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
CHANNEL ROSTER · SIGNAL_MAP
</h1>
</div>
<div className="inline-flex items-center gap-1.5 rounded-md bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
<span className="text-ink-faint">NODES:</span>
<span className="font-bold text-signal">{cultures?.length ?? 0}</span>
</div>
</div>
{cultures ? ( {cultures ? (
<ChannelCultureGlossary cultures={cultures} /> <ChannelCultureGlossary cultures={cultures} />
) : ( ) : (
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getActivity, getDashboardStats } from "@/lib/api/server"; import { getActivity, getDashboardStats } from "@/lib/api/server";
import { DashboardView } from "./view"; import { DashboardView } from "./view";
@@ -15,9 +14,5 @@ export default async function DashboardPage() {
} catch { } catch {
// Backend unavailable — client hooks will surface the error state. // Backend unavailable — client hooks will surface the error state.
} }
return ( return <DashboardView initialStats={stats} initialActivity={activity} />;
<PageTransition>
<DashboardView initialStats={stats} initialActivity={activity} />
</PageTransition>
);
} }
@@ -1,46 +1,48 @@
"use client"; "use client";
import { import {
Activity, AudioWaveform,
Flag, ChevronRight,
MessageSquare, MessageSquare,
Mic, Mic,
Radio, Shield,
ShieldAlert,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts"; import { AreaActivity, RadialGauge } from "@/components/charts";
import { GlassPanel } from "@/components/primitives"; import { GlassPanel } from "@/components/primitives";
import { import {
ErrorState, ErrorState,
LoadingState, MetricTile,
PageTransition,
SectionHeader,
SkeletonHero, SkeletonHero,
SkeletonMetricRow, SkeletonMetricRow,
SkeletonPanel, SkeletonPanel,
} from "@/components/shared"; } from "@/components/shared";
import { MetricTile, SectionHeader } from "@/components/shared/section"; import { useActivity, useStats, useTopReactions } from "@/hooks";
import { import { useStaggerReveal } from "@/hooks/use-gsap-animation";
useActivity,
useStats,
useTopReactions,
useTopReactors,
} from "@/hooks";
import { formatNumber } from "@/lib/format"; import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types"; import type { DashboardStats } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
function deriveSignal(stats?: DashboardStats) { function deriveSignal(stats?: DashboardStats) {
if (!stats) return { tone: "signal" as const, label: "nominal" }; if (!stats)
return { tone: "signal" as const, label: "NOMINAL", state: "STABLE" };
const total = stats.total_flagged + stats.total_clean || 1; const total = stats.total_flagged + stats.total_clean || 1;
const ratio = stats.total_flagged / total; const ratio = stats.total_flagged / total;
if (stats.moderation_overview.error > 0) if (stats.moderation_overview.error > 0)
return { tone: "vermilion" as const, label: "moderation fault" }; return { tone: "vermilion" as const, label: "FAULT", state: "MOD_ERR" };
if (ratio > 0.25) if (ratio > 0.25)
return { tone: "vermilion" as const, label: "elevated flags" }; return {
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" }; tone: "vermilion" as const,
return { tone: "signal" as const, label: "nominal" }; label: "ELEVATED",
state: "HIGH_RISK",
};
if (ratio > 0.1)
return { tone: "amber" as const, label: "WATCH", state: "ADVISORY" };
return { tone: "signal" as const, label: "NOMINAL", state: "OPTIMAL" };
} }
export function DashboardView({ export function DashboardView({
@@ -56,11 +58,16 @@ export function DashboardView({
error, error,
mutate: mutateStats, mutate: mutateStats,
} = useStats(initialStats); } = useStats(initialStats);
const { data: activity } = useActivity(14, initialActivity as never); const { data: activity } = useActivity(14, initialActivity);
const { data: reactors } = useTopReactors();
const { data: reactions } = useTopReactions(); const { data: reactions } = useTopReactions();
const ambient = useAmbient(); const ambient = useAmbient();
const hudRef = useStaggerReveal<HTMLDivElement>(".linear-tile", {
stagger: 0.035,
y: 8,
dependencies: [stats],
});
useEffect(() => { useEffect(() => {
const s = deriveSignal(stats); const s = deriveSignal(stats);
ambient.set( ambient.set(
@@ -74,20 +81,16 @@ export function DashboardView({
return <ErrorState error={error} onRetry={() => void mutateStats()} />; return <ErrorState error={error} onRetry={() => void mutateStats()} />;
if (!stats && isLoading) if (!stats && isLoading)
return ( return (
<div className="space-y-5"> <div className="space-y-4">
<SkeletonHero /> <SkeletonHero />
<SkeletonMetricRow cols={4} /> <SkeletonMetricRow cols={4} />
<SkeletonPanel rows={5} /> <SkeletonPanel rows={4} />
<div className="grid gap-5 lg:grid-cols-5">
<SkeletonPanel className="lg:col-span-3" rows={4} />
<SkeletonPanel className="lg:col-span-2" rows={4} />
</div>
</div> </div>
); );
if (!stats) if (!stats)
return ( return (
<ErrorState <ErrorState
error={error ?? new Error("No data")} error={error ?? new Error("No telemetry stream")}
onRetry={() => void mutateStats()} onRetry={() => void mutateStats()}
/> />
); );
@@ -97,284 +100,152 @@ export function DashboardView({
const cleanRatio = s.total_clean / total; const cleanRatio = s.total_clean / total;
return ( return (
<div className="space-y-5"> <PageTransition>
{/* Hero */} <div ref={hudRef} className="space-y-4">
<GlassPanel glow className="game-frame relative overflow-hidden"> {/* Tactical HUD Header Bar */}
<div className="scan-line absolute inset-x-0 top-0" /> <div className="linear-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex items-center gap-2.5">
<div> <span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<div className="eyebrow mb-2">GMW · Operations Grid</div> <h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
<h2 className="display hero-clamp leading-none text-ink glow-signal"> Telemetry Overview · Node 01
Ambient Field </h1>
</h2>
<p className="mt-2 max-w-md text-pretty text-sm text-ink-soft">
Real-time moderation, voice & media presence across the monitored
guild. {formatNumber(s.total_messages)} messages captured.
</p>
</div> </div>
<div className="flex items-center gap-2 text-ink-soft"> <div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<Radio className="size-4 text-signal animate-breathe" /> <span>SIGNAL:</span>
<span className="mono text-xs uppercase tracking-wider"> <span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
{deriveSignal(s).label} STABLE_STREAM
</span> </span>
</div> </div>
</div> </div>
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4"> {/* Primary Metric Deck */}
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<MetricTile <MetricTile
label="Messages" label="Total Messages"
value={formatNumber(s.total_messages)} value={formatNumber(s.total_messages)}
hint={`${formatNumber(s.today_messages)} today`}
icon={<MessageSquare className="size-4" />}
/>
<MetricTile
label="Clean Filter Ratio"
value={`${Math.round(cleanRatio * 100)}%`}
hint={`${formatNumber(s.total_clean)} verified clean`}
tone="signal" tone="signal"
icon={<MessageSquare className="size-3.5" />} icon={<Shield className="size-4" />}
className="animate-stagger"
style={staggerDelay(0)}
/> />
<MetricTile <MetricTile
label="Flagged" label="Voice Recordings"
value={formatNumber(s.total_flagged)}
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
hint={`${s.today_flagged} today`}
className="animate-stagger"
style={staggerDelay(1)}
/>
<MetricTile
label="Active 24h"
value={formatNumber(s.active_users_24h)}
tone="signal"
icon={<Users className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(2)}
/>
<MetricTile
label="Voice clips"
value={formatNumber(s.total_voice_recordings)} value={formatNumber(s.total_voice_recordings)}
icon={<Mic className="size-3.5" />} hint="Archived audio sessions"
className="animate-stagger" icon={<Mic className="size-4" />}
style={staggerDelay(3)} />
<MetricTile
label="Total Profiles"
value={formatNumber(s.total_profiles)}
hint={`${formatNumber(s.active_users_24h)} active 24h`}
icon={<Users className="size-4" />}
/> />
</div> </div>
</GlassPanel>
{/* Activity */} {/* Dynamic Activity Area & Telemetry Gauges */}
<GlassPanel> <div className="grid gap-3 lg:grid-cols-3">
<SectionHeader <GlassPanel className="linear-tile lg:col-span-2">
eyebrow="14-day signal" <SectionHeader
title={ eyebrow="Activity Stream"
<span className="flex items-center gap-2"> title="Message Volume & Signal Density"
<Activity className="size-4 text-signal" /> Activity & moderation />
</span> <div className="mt-4">
} <AreaActivity daily={activity?.daily ?? []} />
action={
<div className="flex items-center gap-3 text-xs text-ink-soft">
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-signal" /> messages
</span>
<span className="flex items-center gap-1.5">
<span className="size-2 rounded-full bg-vermilion" /> flagged
</span>
</div> </div>
} </GlassPanel>
/>
{activity ? (
<AreaActivity daily={activity.daily} />
) : (
<LoadingState label="streaming" />
)}
</GlassPanel>
{/* Two-column: channels + moderation */} <GlassPanel className="linear-tile flex flex-col justify-between">
<div className="grid gap-5 lg:grid-cols-5"> <div>
<GlassPanel className="lg:col-span-3"> <SectionHeader
<SectionHeader eyebrow="throughput" title="Top channels" /> eyebrow="Security State"
<div className="space-y-2.5"> title="Moderation Accuracy"
{s.top_channels.slice(0, 7).map((c) => {
const pct =
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
100;
return (
<div key={c.channel_id} className="flex items-center gap-3">
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
{c.channel_name ?? c.channel_id.slice(0, 8)}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-signal/70"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-14 text-right text-xs text-ink-faint">
{formatNumber(c.message_count)}
</span>
</div>
);
})}
</div>
</GlassPanel>
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="trust" title="Moderation" />
<div className="flex items-center gap-5">
<RadialGauge
value={cleanRatio}
tone={
cleanRatio > 0.8
? "signal"
: cleanRatio > 0.6
? "amber"
: "vermilion"
}
label={`${Math.round(cleanRatio * 100)}%`}
sublabel="clean"
/>
<div className="flex-1 space-y-2 text-sm">
<Row
icon={<ShieldAlert className="size-4 text-signal" />}
label="Clean"
value={formatNumber(s.total_clean)}
/> />
<Row <div className="my-6 flex items-center justify-center">
icon={<Flag className="size-4 text-vermilion" />} <RadialGauge
label="Flagged" value={Math.round(cleanRatio * 100)}
value={formatNumber(s.total_flagged)} label="Clean Ratio"
/> tone={cleanRatio > 0.85 ? "signal" : "amber"}
<Row size={140}
icon={<Activity className="size-4 text-amber" />} />
label="Warned"
value={formatNumber(s.total_warned)}
/>
</div>
</div>
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
<Mini
label="pending"
value={s.moderation_overview.pending}
tone="amber"
/>
<Mini
label="processing"
value={s.moderation_overview.processing}
tone="signal"
/>
<Mini
label="errors"
value={s.moderation_overview.error}
tone="vermilion"
/>
</div>
</GlassPanel>
</div>
{/* Reactors + reactions */}
<div className="grid gap-5 lg:grid-cols-2">
<GlassPanel>
<SectionHeader eyebrow="engagement" title="Top reactors" />
<div className="space-y-2">
{(reactors ?? []).slice(0, 6).map((r, i) => {
const maxNet = reactors?.[0]?.net_count || 1;
const pct = Math.max(4, Math.round((r.net_count / maxNet) * 100));
return (
<div key={r.user_id} className="flex items-center gap-3">
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
{r.username}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-signal/70"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-12 text-right text-xs text-signal">
+{formatNumber(r.net_count)}
</span>
</div>
);
})}
{(reactors ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader eyebrow="culture" title="Top reactions" />
<div className="space-y-3">
{(reactions ?? []).slice(0, 5).map((m) => (
<div key={m.message_id} className="flex items-start gap-3">
<div className="flex flex-wrap gap-1 pt-0.5">
{m.top_emojis.slice(0, 3).map((e, i) => (
<span
key={`${m.message_id}-${i}`}
className="text-lg leading-none"
>
{e.emoji}
</span>
))}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">
{m.content || "(no text)"}
</div>
<div className="mono text-[0.65rem] text-ink-faint">
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
</div>
</div>
<span className="mono text-xs text-ink-soft">
{m.reaction_count}
</span>
</div> </div>
))} </div>
{(reactions ?? []).length === 0 && <EmptyHint />} <div className="grid grid-cols-2 gap-2 border-t border-hairline pt-3 text-center">
</div> <div>
</GlassPanel> <div className="eyebrow">CLEAN</div>
<div className="font-sans text-sm font-semibold text-success">
{formatNumber(s.total_clean)}
</div>
</div>
<div>
<div className="eyebrow">FLAGGED</div>
<div className="font-sans text-sm font-semibold text-vermilion">
{formatNumber(s.total_flagged)}
</div>
</div>
</div>
</GlassPanel>
</div>
{/* Secondary Insights Deck */}
<div className="grid gap-3 lg:grid-cols-2">
{reactions && reactions.length > 0 && (
<GlassPanel className="linear-tile">
<SectionHeader
eyebrow="Engagement"
title="Top Reacted Messages"
/>
<div className="mt-3 space-y-2">
{reactions.slice(0, 4).map((r) => (
<div
key={r.message_id}
className="hud-card flex items-center justify-between p-2.5"
>
<span className="truncate pr-2 text-xs text-ink-soft">
{r.content || "[Media/Attachment]"}
</span>
<span className="font-mono text-xs font-semibold text-signal shrink-0">
{formatNumber(r.reaction_count)} reactions
</span>
</div>
))}
</div>
</GlassPanel>
)}
<GlassPanel className="linear-tile flex flex-col justify-between">
<div>
<SectionHeader eyebrow="Navigation" title="Quick Diagnostics" />
<div className="mt-2 space-y-1.5">
<Link
href="/moderation"
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<Shield className="size-3.5 text-signal" />
Review Live Moderation Queue
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/voice"
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AudioWaveform className="size-3.5 text-success" />
Inspect Active Voice Stages
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
</div>
</div>
</GlassPanel>
</div>
</div> </div>
</div> </PageTransition>
);
}
function Row({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-center gap-2.5">
{icon}
<span className="flex-1 text-ink-soft">{label}</span>
<span className="mono text-ink">{value}</span>
</div>
);
}
function Mini({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "signal" | "amber" | "vermilion";
}) {
const color =
tone === "vermilion"
? "text-vermilion"
: tone === "amber"
? "text-amber"
: "text-signal";
return (
<div>
<div className={`display text-xl ${color}`}>{value}</div>
<div className="eyebrow mt-0.5">{label}</div>
</div>
);
}
function EmptyHint() {
return (
<div className="py-6 text-center text-xs text-ink-faint">
Awaiting data…
</div>
); );
} }
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getGlossary } from "@/lib/api/server"; import { getGlossary } from "@/lib/api/server";
import type { GlossaryRow } from "@/lib/types"; import type { GlossaryRow } from "@/lib/types";
import { GlossaryView } from "./view"; import { GlossaryView } from "./view";
@@ -12,9 +11,5 @@ export default async function GlossaryPage() {
} catch { } catch {
terms = undefined; terms = undefined;
} }
return ( return <GlossaryView initialTerms={terms} />;
<PageTransition>
<GlossaryView initialTerms={terms} />
</PageTransition>
);
} }
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getMediaStatus } from "@/lib/api/server"; import { getMediaStatus } from "@/lib/api/server";
import { MediaView } from "./view"; import { MediaView } from "./view";
@@ -11,9 +10,5 @@ export default async function MediaPage() {
} catch { } catch {
/* client hooks surface errors */ /* client hooks surface errors */
} }
return ( return <MediaView initialStatus={status} />;
<PageTransition>
<MediaView initialStatus={status} />
</PageTransition>
);
} }
@@ -1,19 +1,13 @@
"use client"; "use client";
import { import { Disc, Play, Repeat, SkipForward, Square } from "lucide-react";
ListMusic,
Play,
Radio,
Repeat,
SkipForward,
Square,
Volume2,
} from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context"; import { useAmbient } from "@/components/ambient/ambient-context";
import { Equalizer } from "@/components/charts";
import { Button, GlassPanel, Input, toast } from "@/components/primitives"; import { Button, GlassPanel, Input, toast } from "@/components/primitives";
import { import {
ErrorState, ErrorState,
PageTransition,
SectionHeader, SectionHeader,
SkeletonHero, SkeletonHero,
SkeletonPanel, SkeletonPanel,
@@ -26,9 +20,9 @@ import {
useMediaStop, useMediaStop,
useMediaWsSync, useMediaWsSync,
} from "@/hooks"; } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatDuration } from "@/lib/format"; import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types"; import type { MediaState } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) { export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
@@ -51,30 +45,32 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const playing = media?.playing ?? false; const playing = media?.playing ?? false;
const current = media?.current ?? null; const current = media?.current ?? null;
const queueList = media?.queue ?? []; const queueList = media?.queue ?? [];
const queueTotal = queueList.reduce(
(acc, it) => acc + (it.durationMs ?? 0), const mediaRef = useStaggerReveal<HTMLDivElement>(".media-tile", {
0, stagger: 0.04,
); y: 8,
dependencies: [playing, queueList.length],
});
const tone = playing ? "signal" : queueList.length ? "amber" : "signal"; const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => { useEffect(() => {
ambient.set( ambient.set(
tone, tone,
playing ? 0.5 : 0.25, playing ? 0.45 : 0.2,
playing ? "now playing" : "media idle", playing ? "playing stream" : "media ready",
); );
}, [tone, playing, ambient]); }, [tone, playing, ambient]);
const onPlay = async () => { const onPlay = async () => {
const u = url.trim(); const u = url.trim();
if (!u) { if (!u) {
toast({ title: "Enter a media URL", tone: "vermilion" }); toast({ title: "Enter media URL", tone: "vermilion" });
return; return;
} }
try { try {
await queue.mutateAsync({ url: u, mode: "music" }); await queue.mutateAsync({ url: u, mode: "music" });
setUrl(""); setUrl("");
toast({ title: "Queued", tone: "signal" }); toast({ title: "Track queued", tone: "signal" });
} catch (e) { } catch (e) {
toast({ toast({
title: "Queue failed", title: "Queue failed",
@@ -88,212 +84,153 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
return <ErrorState error={error} onRetry={() => void mutate()} />; return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!media && isLoading) if (!media && isLoading)
return ( return (
<div className="space-y-5"> <div className="space-y-4">
<SkeletonHero /> <SkeletonHero />
<SkeletonPanel rows={4} /> <SkeletonPanel rows={3} />
</div> </div>
); );
const mockLevels = playing
? [0.4, 0.7, 0.9, 0.6, 0.8, 0.5, 0.9, 0.7, 0.4, 0.8, 0.6, 0.9]
: [0.08, 0.08, 0.08, 0.08, 0.08, 0.08];
return ( return (
<div className="space-y-5"> <PageTransition>
<GlassPanel glow className="relative overflow-hidden"> <div ref={mediaRef} className="space-y-4">
<div className="scan-line absolute inset-x-0 top-0" /> {/* Precision Sub-Header Bar */}
<div className="flex flex-col gap-5 sm:flex-row sm:items-center"> <div className="media-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div <div className="flex items-center gap-2.5">
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`} <span
> className={`h-2 w-2 rounded-full ${
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60"> playing
{current?.thumbnailUrl ? ( ? "bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist : "bg-ink-muted"
<img }`}
src={current.thumbnailUrl}
alt=""
className="size-full object-cover"
loading="lazy"
/>
) : (
<ListMusic className="size-10 text-signal" />
)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="eyebrow mb-1 flex items-center gap-2">
{playing ? (
<>
<span aria-hidden className="flex h-3 items-end gap-[2px]">
{[0, 1, 2].map((i) => (
<span
key={`eq-${i}`}
className="w-[3px] animate-eq rounded-full bg-signal"
style={{
animationDelay: `${i * 160}ms`,
height: "100%",
}}
/>
))}
</span>
<span className="text-signal">Now playing</span>
</>
) : current ? (
"Paused"
) : (
"Nothing queued"
)}
</div>
<h2 className="display text-balance text-2xl text-ink">
{current?.title ?? "Nothing queued"}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
{current?.source && (
<span className="mono truncate">{current.source}</span>
)}
{current?.mode && (
<span className="pill capitalize">{current.mode}</span>
)}
{formatDuration(current?.durationMs) && (
<span className="mono">
{formatDuration(current?.durationMs)}
</span>
)}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={onPlay}
disabled={queue.isPending}
>
<Play className="size-4" /> Queue & play
</Button>
<Button
variant="outline"
size="sm"
onClick={() => skip.mutate()}
disabled={skip.isPending}
>
<SkipForward className="size-4" /> Skip
</Button>
<Button
variant="outline"
size="sm"
onClick={() => stop.mutate()}
disabled={stop.isPending}
>
<Square className="size-4" /> Stop
</Button>
<Button
variant={media?.loop ? "primary" : "outline"}
size="sm"
onClick={() => loop.mutate(!media?.loop)}
aria-pressed={!!media?.loop}
>
<Repeat className="size-4" /> Loop
</Button>
</div>
</div>
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<div className="relative min-w-0 flex-1">
<Input
placeholder="Paste a YouTube / music URL…"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onPlay()}
/> />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Audio Engine · Media Gateway
</h1>
</div> </div>
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5"> <div className="flex items-center gap-2 font-mono text-[11px]">
<Volume2 className="size-4 text-ink-faint" /> <span className="text-ink-muted">ENGINE:</span>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/10"> <span
<div className={`rounded px-1.5 py-0.5 font-medium border ${
className="h-full rounded-full bg-signal/70" playing
style={{ ? "border-signal/30 bg-signal/10 text-signal"
width: `${Math.round((media?.musicVolume ?? 0) * 100)}%`, : "border-hairline bg-surface-2 text-ink-muted"
}} }`}
/> >
</div> {playing ? "STREAMING" : "IDLE"}
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint">
{Math.round((media?.musicVolume ?? 0) * 100)}%
</span> </span>
</div> </div>
</div> </div>
</GlassPanel>
<GlassPanel> {/* Media Playback Deck */}
<SectionHeader <div className="media-tile">
eyebrow="up next" <GlassPanel className="p-5">
title="Queue" <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
action={ <div className="flex items-center gap-3.5">
<span className="mono text-xs text-ink-faint"> <div className="flex size-11 items-center justify-center rounded-[8px] border border-hairline bg-surface-2 text-signal">
{queueList.length} track{queueList.length === 1 ? "" : "s"} <Disc
{queueTotal && ` · ${formatDuration(queueTotal)}`} className={`size-6 ${playing ? "animate-spin-disc text-signal" : "text-ink-muted"}`}
</span> />
}
/>
{queueList.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<Radio className="size-6 text-ink-faint" />
<div className="text-sm text-ink-soft">Queue is empty</div>
<div className="text-xs text-ink-faint">
Paste a URL above to start playback.
</div>
</div>
) : (
<div className="space-y-2">
{queueList.map((item, i) => {
const isNext = i === 0 && playing;
return (
<div
key={`${item.source}-${i}`}
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
isNext
? "border-signal/40 bg-signal/[0.07]"
: "border-hairline bg-white/5"
}`}
style={staggerDelay(i)}
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
{item.thumbnailUrl ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={item.thumbnailUrl}
alt=""
className="size-9 shrink-0 rounded-md object-cover"
loading="lazy"
/>
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
<ListMusic className="size-4 text-ink-faint" />
</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">
{item.title}
</div>
<div className="mono truncate text-[0.65rem] text-ink-faint">
{item.source}
</div>
</div>
{isNext && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
up next
</span>
)}
<span className="pill hidden capitalize sm:inline-flex">
{item.mode ?? "music"}
</span>
{formatDuration(item.durationMs) && (
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
{formatDuration(item.durationMs)}
</span>
)}
</div> </div>
); <div>
})} <div className="text-sm font-semibold tracking-tight text-ink">
</div> {current?.title ?? "No Active Media Stream"}
)} </div>
</GlassPanel> <div className="font-mono text-[11px] text-ink-muted">
</div> {current
? `${formatDuration(current.durationMs ?? 0)} · High Fidelity Stream`
: "Queue a track via URL below"}
</div>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => loop.mutateAsync()}
className={media?.loop ? "border-signal text-signal" : ""}
>
<Repeat className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skip.mutateAsync()}
>
<SkipForward className="size-3.5" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => stop.mutateAsync()}
>
<Square className="size-3.5" />
</Button>
</div>
</div>
{/* Quick URL Input */}
<div className="mt-4 flex gap-2 border-t border-hairline pt-4">
<Input
placeholder="Paste YouTube, SoundCloud, or Direct Stream URL..."
value={url}
onChange={(e) => setUrl(e.target.value)}
className="flex-1 text-xs"
/>
<Button variant="primary" size="md" onClick={onPlay}>
<Play className="size-3.5" />
Queue Track
</Button>
</div>
</GlassPanel>
</div>
{/* Spectrum & Queue Grid */}
<div className="grid gap-3 lg:grid-cols-3">
<GlassPanel className="media-tile lg:col-span-2">
<SectionHeader
eyebrow="Visualizer"
title="Realtime Frequency Array"
/>
<div className="my-6 flex items-center justify-center">
<Equalizer bars={mockLevels} />
</div>
</GlassPanel>
<GlassPanel className="media-tile">
<SectionHeader
eyebrow="Playlist"
title={`Queue (${queueList.length})`}
/>
<div className="mt-3 space-y-2">
{queueList.length === 0 ? (
<div className="py-8 text-center font-mono text-xs text-ink-muted">
QUEUE EMPTY
</div>
) : (
queueList.map((item, idx) => (
<div
key={item.id ?? idx}
className="flex items-center justify-between rounded-[6px] border border-hairline bg-surface-2 p-2.5 text-xs text-ink-soft"
>
<span className="truncate pr-2 font-medium">
{item.title}
</span>
<span className="font-mono text-[10px] text-ink-muted shrink-0">
{formatDuration(item.durationMs ?? 0)}
</span>
</div>
))
)}
</div>
</GlassPanel>
</div>
</div>
</PageTransition>
); );
} }
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { import {
getConfig, getConfig,
getGuilds, getGuilds,
@@ -28,13 +27,11 @@ export default async function MessagesPage() {
/* client hooks surface errors */ /* client hooks surface errors */
} }
return ( return (
<PageTransition> <MessagesView
<MessagesView initialGuilds={guilds}
initialGuilds={guilds} initialGuildId={config?.monitorGuildId ?? null}
initialGuildId={config?.monitorGuildId ?? null} initialMessages={initialMessages}
initialMessages={initialMessages} initialEdits={initialEdits}
initialEdits={initialEdits} />
/>
</PageTransition>
); );
} }
@@ -4,12 +4,14 @@ import {
AlertTriangle, AlertTriangle,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
History,
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
MessageSquare, MessageSquare,
Paperclip, Paperclip,
Search, Search,
ShieldAlert, ShieldAlert,
Sparkles,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityHeatmap } from "@/components/ActivityHeatmap"; import { ActivityHeatmap } from "@/components/ActivityHeatmap";
@@ -42,6 +44,7 @@ import {
useReviewWsSync, useReviewWsSync,
useSemanticSearch, useSemanticSearch,
} from "@/hooks"; } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { aiTone } from "@/lib/ai-status"; import { aiTone } from "@/lib/ai-status";
import { import {
formatBytes, formatBytes,
@@ -55,9 +58,9 @@ import type {
AiStatus, AiStatus,
EditHistoryRow, EditHistoryRow,
Guild, Guild,
MessageMetadata,
MessageRecord, MessageRecord,
} from "@/lib/types"; } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context"; import { useWebSocket } from "@/lib/ws/context";
export function MessagesView({ export function MessagesView({
@@ -101,10 +104,14 @@ export function MessagesView({
channelId ?? undefined, channelId ?? undefined,
initialMessages ?? undefined, initialMessages ?? undefined,
); );
// Stream history one message per WS frame (replaces the 50-row batched fetch). // Stream history over WS.
// Drives snapshots into the SWR list above as they arrive; falls back to the // Drives snapshots into the SWR list above buffered by rAF; falls back to the
// SSR `initialMessages` seed if WS is unavailable. // SSR `initialMessages` seed if WS is unavailable.
useMessagesStream(ws, guildId ?? "", channelId ?? undefined); const { streaming } = useMessagesStream(
ws,
guildId ?? "",
channelId ?? undefined,
);
// Cursor to the next (older) page + whether more history exists. // Cursor to the next (older) page + whether more history exists.
const { data: pageInfo } = useMessagesHasMore( const { data: pageInfo } = useMessagesHasMore(
guildId ?? "", guildId ?? "",
@@ -217,9 +224,42 @@ export function MessagesView({
prevLen.current = list.length; prevLen.current = list.length;
}, [list.length, searching]); }, [list.length, searching]);
// Depend on viewMode and initial load flag, NOT display.length, so streaming 200 items doesn't thrash animation
const streamRef = useStaggerReveal<HTMLDivElement>(".msg-feed-card", {
stagger: 0.02,
y: 6,
dependencies: [viewMode],
});
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<GlassPanel className="flex flex-wrap items-center gap-3"> {/* Tactical HUD Header Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Chat Log Stream · Ingestion Stream
</h1>
{streaming && (
<span className="flex items-center gap-1 font-mono text-[10px] text-signal animate-pulse">
<Loader2 className="size-3 animate-spin" />
STREAMING
</span>
)}
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>MODE:</span>
<span
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
data-text={searching ? "SEARCH_ACTIVE" : viewMode.toUpperCase()}
>
{searching ? "SEARCH_ACTIVE" : viewMode.toUpperCase()}
</span>
</div>
</div>
{/* Filter and Mode Bar */}
<GlassPanel className="flex flex-wrap items-center gap-3 p-3">
<GuildChannelPicker <GuildChannelPicker
mode="text" mode="text"
guildsInitial={initialGuilds} guildsInitial={initialGuilds}
@@ -236,48 +276,72 @@ export function MessagesView({
<div className="relative ml-auto w-full sm:w-64"> <div className="relative ml-auto w-full sm:w-64">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" /> <Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
<Input <Input
className="pl-9" className="pl-9 text-xs"
placeholder="Search messages…" placeholder="Search captured logs..."
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
/> />
</div> </div>
<button <div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
type="button" <button
onClick={() => setSemanticMode((v) => !v)} type="button"
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${ onClick={() => setSemanticMode(false)}
semanticMode className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
? "border-signal/40 bg-signal/10 text-signal" !semanticMode
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]" ? "bg-surface text-ink border border-hairline-focus shadow-xs"
}`} : "text-ink-muted hover:text-ink"
title="Toggle semantic (vector) search over the message archive" }`}
> >
{semanticMode ? "Semantic" : "Exact"} EXACT
</button> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() => setSemanticMode(true)}
setViewMode((v) => (v === "feed" ? "timeline" : "feed")) className={`flex items-center gap-1 rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
} semanticMode
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${ ? "bg-signal/20 text-signal border border-signal/40 shadow-xs"
viewMode === "timeline" : "text-ink-muted hover:text-ink"
? "border-signal/40 bg-signal/10 text-signal" }`}
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]" >
}`} <Sparkles className="size-3" />
title="Toggle timeline (date-grouped) view" SEMANTIC
> </button>
{viewMode === "timeline" ? "Timeline" : "Feed"} </div>
</button>
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
<button
type="button"
onClick={() => setViewMode("feed")}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
viewMode === "feed"
? "bg-surface text-ink border border-hairline-focus shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
FEED
</button>
<button
type="button"
onClick={() => setViewMode("timeline")}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
viewMode === "timeline"
? "bg-surface text-ink border border-hairline-focus shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
TIMELINE
</button>
</div>
</GlassPanel> </GlassPanel>
<div className="grid gap-4 lg:grid-cols-5"> <div className="grid gap-3 lg:grid-cols-5">
{semanticSearching && ( {semanticSearching && (
<GlassPanel className="lg:col-span-5"> <GlassPanel className="lg:col-span-5">
<SectionHeader <SectionHeader
eyebrow="semantic" eyebrow="semantic archive"
title={`“${query}”`} title={`Vector Matches for “${query}”`}
action={ action={
<span className="mono text-xs text-ink-faint"> <span className="mono text-xs text-[#8a8f98]">
{semantic.data?.length ?? 0} matches {semantic.data?.length ?? 0} matches
</span> </span>
} }
@@ -289,19 +353,18 @@ export function MessagesView({
{semantic.data.map((r, i) => ( {semantic.data.map((r, i) => (
<div <div
key={r.message_id ?? i} key={r.message_id ?? i}
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3" className="hud-card flex items-start gap-3 p-3"
style={staggerDelay(i)}
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="mono text-[0.6rem] text-signal"> <span className="font-mono text-[10px] font-semibold text-signal">
{(r.score * 100).toFixed(0)}% {(r.score * 100).toFixed(0)}% RELEVANCE
</span> </span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint"> <span className="ml-auto font-mono text-[10px] text-ink-muted">
{formatRelativeTime(r.created_at)} {formatRelativeTime(r.created_at)}
</span> </span>
</div> </div>
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft"> <div className="mt-1 text-xs text-ink-soft leading-relaxed">
{r.content} {r.content}
</div> </div>
</div> </div>
@@ -312,19 +375,20 @@ export function MessagesView({
<EmptyState <EmptyState
icon={<Search className="size-7" />} icon={<Search className="size-7" />}
title="No semantic matches" title="No semantic matches"
description="Try different wording — semantic search finds meaning, not exact text." description="Try different phrasing — vector search inspects contextual semantics."
/> />
)} )}
</GlassPanel> </GlassPanel>
)} )}
{/* Message Stream Deck */}
<GlassPanel className="lg:col-span-3"> <GlassPanel className="lg:col-span-3">
<SectionHeader <SectionHeader
eyebrow={searching ? "results" : "live feed"} eyebrow={searching ? "query results" : "realtime log"}
title={searching ? `“${query}”` : "Messages"} title={searching ? `Matches for “${query}”` : "Message Stream"}
action={ action={
<span className="mono text-xs text-ink-faint"> <span className="mono text-xs text-[#8a8f98]">
{list.length} shown {list.length} messages
</span> </span>
} }
/> />
@@ -335,38 +399,37 @@ export function MessagesView({
) : list.length === 0 ? ( ) : list.length === 0 ? (
<EmptyState <EmptyState
icon={<MessageSquare className="size-7" />} icon={<MessageSquare className="size-7" />}
title="No messages" title="No messages captured"
description="Pick a guild to begin, or run a search." description="Select a channel or verify discord bridge is active."
/> />
) : ( ) : (
<div> <div>
{!searching && ( {!searching && (
<div className="mb-2 flex items-center justify-center gap-2"> <div className="mb-2 flex items-center justify-center gap-2">
{loadMore.isPending ? ( {loadMore.isPending ? (
<span className="flex items-center gap-1.5 text-xs text-ink-soft"> <span className="flex items-center gap-1.5 font-mono text-xs text-ink-muted">
<Loader2 className="size-3.5 animate-spin" /> <Loader2 className="size-3.5 animate-spin text-signal" />
Loading older… FETCHING EARLIER PACKETS...
<span className="typing-dots">
<span />
<span />
<span />
</span>
</span> </span>
) : hasMore && loadedPages < MAX_OLDER_PAGES ? ( ) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
<button <button
type="button" type="button"
onClick={loadOlder} onClick={loadOlder}
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]" className="rounded-md border border-hairline bg-surface-2 px-3 py-1 font-mono text-[10px] text-ink-muted transition-colors hover:bg-surface hover:text-ink"
> >
↑ Load older messages ↑ LOAD PREVIOUS BATCH
</button> </button>
) : loadedPages >= MAX_OLDER_PAGES ? (
<span className="mono text-[0.65rem] text-ink-faint">
capped at {MAX_OLDER_PAGES} older pages · use search for
more
</span>
) : ( ) : (
messages && <span className="font-mono text-[10px] text-ink-faint">
messages.length > 0 && ( {loadedPages >= MAX_OLDER_PAGES
<span className="mono text-[0.65rem] text-ink-faint"> ? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
beginning of history : "STREAM ROOT REACHED"}
</span> </span>
)
)} )}
</div> </div>
)} )}
@@ -375,8 +438,6 @@ export function MessagesView({
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1" className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
onScroll={(e) => { onScroll={(e) => {
const el = e.currentTarget; const el = e.currentTarget;
// Track whether the user is near the bottom (to follow live
// messages) and auto-load older messages when scrolled to top.
nearBottomRef.current = nearBottomRef.current =
el.scrollHeight - el.scrollTop - el.clientHeight < 120; el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (searching || !hasMore || loadMore.isPending) return; if (searching || !hasMore || loadMore.isPending) return;
@@ -386,44 +447,47 @@ export function MessagesView({
} }
}} }}
> >
{viewMode === "timeline" && timelineNodes <div ref={streamRef} className="space-y-1.5">
? timelineNodes.map((node, _i) => {viewMode === "timeline" && timelineNodes
node.type === "date" ? ( ? timelineNodes.map((node) =>
<div node.type === "date" ? (
key={`date-${node.iso}`} <div
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint" key={`date-${node.iso}`}
> className="flex items-center gap-2 py-1 font-mono text-[10px] text-ink-muted"
<Calendar className="size-3" /> >
{node.label} <Calendar className="size-3 text-signal" />
</div> {node.label}
) : ( </div>
) : (
<MessageRow
key={node.m.id}
m={node.m}
selected={selected}
onSelect={setSelected}
/>
),
)
: display.map((m) => (
<MessageRow <MessageRow
key={node.m.id} key={m.id}
m={node.m} m={m}
selected={selected} selected={selected}
onSelect={setSelected} onSelect={setSelected}
/> />
), ))}
) </div>
: display.map((m, _i) => (
<MessageRow
key={m.id}
m={m}
selected={selected}
onSelect={setSelected}
/>
))}
</div> </div>
</div> </div>
)} )}
</GlassPanel> </GlassPanel>
{/* Message Inspector Detail Panel */}
<GlassPanel className="lg:col-span-2"> <GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="inspect" title="Detail" /> <SectionHeader eyebrow="telemetry analysis" title="Inspector Deck" />
{!selected ? ( {!selected ? (
<EmptyState <EmptyState
title="Select a message" title="Select a message"
description="Click any message to inspect AI analysis, attachments and edit history." description="Click any packet in the stream to inspect AI flags, moderation heuristics, and attachments."
/> />
) : detail.loading ? ( ) : detail.loading ? (
<div className="space-y-2"> <div className="space-y-2">
@@ -436,7 +500,7 @@ export function MessagesView({
attachments={detail.attachments} attachments={detail.attachments}
/> />
) : ( ) : (
<EmptyState title="Not found" /> <EmptyState title="Packet not found" />
)} )}
</GlassPanel> </GlassPanel>
</div> </div>
@@ -493,12 +557,25 @@ function MessageDetail({
}) { }) {
const flags = safeParseJsonArray(m.ai_moderation_flags); const flags = safeParseJsonArray(m.ai_moderation_flags);
const cats = safeParseJsonArray(m.ai_categories); const cats = safeParseJsonArray(m.ai_categories);
const editHistory =
(m as { edit_history?: Array<{ old_content: string; edited_at: number }> })
.edit_history ?? [];
const editCount = (m as { edit_count?: number }).edit_count ?? 0;
return ( return (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar src={m.avatar_url} name={m.username} size={40} /> <Avatar
src={m.avatar_url}
name={m.server_nick ?? m.username}
size={40}
/>
<div> <div>
<div className="font-semibold text-ink">{m.username}</div> <div className="font-semibold text-ink">
{m.server_nick ?? m.username}
</div>
{m.server_nick && m.server_nick !== m.username && (
<div className="text-[11px] text-ink-muted">@{m.username}</div>
)}
<div className="mono text-[0.65rem] text-ink-faint"> <div className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)} {getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
</div> </div>
@@ -511,15 +588,15 @@ function MessageDetail({
</div> </div>
</div> </div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft"> <div className="hud-card p-3 text-xs text-ink-soft leading-relaxed">
{renderMessageContent(m.edited_content ?? m.content, m.metadata) || {renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
"(no text)"} "(no text content)"}
</div> </div>
{m.ai_analysis && ( {m.ai_analysis && (
<div> <div>
<div className="eyebrow mb-1">AI analysis</div> <div className="eyebrow mb-1">AI heuristic reasoning</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft"> <div className="hud-card p-3 text-xs text-ink-muted leading-relaxed">
{m.ai_analysis} {m.ai_analysis}
</div> </div>
</div> </div>
@@ -540,6 +617,44 @@ function MessageDetail({
</div> </div>
)} )}
{/* Edit history — before / after diff */}
{editCount > 0 && editHistory.length > 0 && (
<div>
<div className="eyebrow mb-1.5 flex items-center gap-1.5">
<History className="size-3" /> Edit history ({editCount})
</div>
<div className="space-y-2">
{editHistory.map((e, i) => (
<div
key={`${m.id}-edit-${i}`}
className="grid grid-cols-2 gap-1.5 rounded-[6px] border border-hairline bg-surface-2/50 text-[10px] leading-relaxed"
>
<div className="overflow-hidden rounded-l-[5px] border-r border-hairline">
<div className="border-b border-hairline bg-vermilion/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-vermilion">
Before
</div>
<div className="max-h-20 overflow-y-auto p-1.5">
<pre className="whitespace-pre-wrap break-words text-ink-faint/80">
{e.old_content || <em>(empty)</em>}
</pre>
</div>
</div>
<div className="overflow-hidden rounded-r-[5px]">
<div className="border-b border-hairline bg-success/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-success">
After
</div>
<div className="max-h-20 overflow-y-auto p-1.5">
<pre className="whitespace-pre-wrap break-words text-ink-soft">
{m.content || <em>(empty)</em>}
</pre>
</div>
</div>
</div>
))}
</div>
</div>
)}
{attachments.length > 0 && ( {attachments.length > 0 && (
<div> <div>
<div className="eyebrow mb-1 flex items-center gap-1.5"> <div className="eyebrow mb-1 flex items-center gap-1.5">
@@ -552,11 +667,11 @@ function MessageDetail({
href={a.discord_url ?? a.uploaded_url ?? "#"} href={a.discord_url ?? a.uploaded_url ?? "#"}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink" className="hud-card flex items-center gap-2 px-3 py-2 text-xs text-ink-soft hover:text-ink"
> >
<ImageIcon className="size-3.5 text-signal" /> <ImageIcon className="size-3.5 text-signal" />
<span className="flex-1 truncate">{a.filename}</span> <span className="flex-1 truncate">{a.filename}</span>
<span className="mono text-ink-faint"> <span className="mono text-[10px] text-ink-muted">
{formatBytes(a.size)} {formatBytes(a.size)}
</span> </span>
</a> </a>
@@ -568,6 +683,22 @@ function MessageDetail({
); );
} }
/** Parse metadata JSON safely — returns null on malformed / missing data. */
function parseMeta(raw: string | null | undefined): MessageMetadata | null {
if (!raw) return null;
try {
return JSON.parse(raw) as MessageMetadata;
} catch {
return null;
}
}
/** True when an attachment content-type looks like an image we can inline. */
function isImageType(ct?: string | null): boolean {
if (!ct) return false;
return ct.startsWith("image/");
}
/** Single message card used by both the live feed and the date-grouped timeline. */ /** Single message card used by both the live feed and the date-grouped timeline. */
function MessageRow({ function MessageRow({
m, m,
@@ -578,35 +709,158 @@ function MessageRow({
selected: string | null; selected: string | null;
onSelect: (id: string) => void; onSelect: (id: string) => void;
}) { }) {
const meta = parseMeta(m.metadata);
const attachments = meta?.attachments ?? [];
const embeds = meta?.embeds ?? [];
const stickers = meta?.stickers ?? [];
const imageAttachments = attachments.filter((a) =>
isImageType(a.contentType),
);
const fileAttachments = attachments.filter(
(a) => !isImageType(a.contentType),
);
// First embed image or sticker url for visual preview
const embedImage =
embeds.find((e) => e.image?.url)?.image?.url ??
embeds.find((e) => e.thumbnail?.url)?.thumbnail?.url ??
null;
const stickerUrl = stickers.find((s) => s.url)?.url ?? null;
return ( return (
<button <button
key={m.id} key={m.id}
type="button" type="button"
onClick={() => onSelect(m.id)} onClick={() => onSelect(m.id)}
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${ className={`msg-feed-card flex w-full items-start gap-3 rounded-[8px] border p-2.5 text-left transition-all ${
selected === m.id selected === m.id
? "border-signal/40 bg-signal/8" ? "border-signal/50 bg-signal/10 shadow-xs"
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]" : "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
}`} }`}
> >
<Avatar src={m.avatar_url} name={m.username} size={34} /> <Avatar
src={m.avatar_url}
name={m.server_nick ?? m.username}
size={32}
/>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink"> <span className="truncate text-xs font-semibold text-ink">
{m.username} {m.server_nick ?? m.username}
</span> </span>
<span className="mono text-[0.65rem] text-ink-faint"> {m.server_nick && m.server_nick !== m.username && (
<span className="truncate font-mono text-[10px] text-ink-muted">
@{m.username}
</span>
)}
<span className="font-mono text-[10px] text-ink-muted">
{getMessageChannelLabel(m)} {getMessageChannelLabel(m)}
</span> </span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint"> <span className="ml-auto font-mono text-[10px] text-ink-muted">
{formatRelativeTime(m.created_at)} {formatRelativeTime(m.created_at)}
</span> </span>
</div> </div>
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
{renderMessageContent(m.content, m.metadata) || ( {/* Text content */}
<span className="italic text-ink-faint">(empty / embed)</span> {m.content && (
)} <div className="mt-0.5 line-clamp-2 text-xs text-ink-soft">
</div> {renderMessageContent(m.content, m.metadata)}
</div>
)}
{/* Inline image attachments */}
{imageAttachments.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{imageAttachments.map((a) => (
<a
key={a.url}
href={a.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={a.url}
alt={a.name}
loading="lazy"
className="max-h-32 w-auto max-w-[200px] object-cover"
/>
</a>
))}
</div>
)}
{/* Sticker image */}
{stickerUrl && imageAttachments.length === 0 && (
<div className="mt-1.5">
<a
href={stickerUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={stickerUrl}
alt={stickers[0]?.name ?? "sticker"}
loading="lazy"
className="max-h-28 w-auto max-w-[160px] object-contain"
/>
</a>
</div>
)}
{/* Embed image / thumbnail */}
{embedImage && imageAttachments.length === 0 && !stickerUrl && (
<div className="mt-1.5">
<a
href={embedImage}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={embedImage}
alt="embed"
loading="lazy"
className="max-h-32 w-auto max-w-[240px] object-cover"
/>
</a>
{embeds[0]?.title && (
<div className="mt-0.5 truncate text-[10px] font-medium text-ink-muted">
{embeds[0].title}
</div>
)}
</div>
)}
{/* Non-image file attachments */}
{fileAttachments.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{fileAttachments.map((a) => (
<a
key={a.url}
href={a.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 rounded-[4px] border border-hairline bg-surface px-2 py-0.5 font-mono text-[10px] text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink"
>
<Paperclip className="size-2.5" />
{a.name}
</a>
))}
</div>
)}
{/* No content at all */}
{!m.content && attachments.length === 0 && embeds.length === 0 && (
<div className="mt-0.5 text-xs italic text-ink-muted">
(empty / embed)
</div>
)}
</div> </div>
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} /> <AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
</button> </button>
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getModerationActions, getModerationStats } from "@/lib/api/server"; import { getModerationActions, getModerationStats } from "@/lib/api/server";
import { ModerationView } from "./view"; import { ModerationView } from "./view";
@@ -15,9 +14,5 @@ export default async function ModerationPage() {
} catch { } catch {
/* client hooks surface errors */ /* client hooks surface errors */
} }
return ( return <ModerationView initialStats={stats} initialActions={actions} />;
<PageTransition>
<ModerationView initialStats={stats} initialActions={actions} />
</PageTransition>
);
} }
Loaded 50 of 107 files, more files were not shown because too many files have changed in this diff. Show more