Author SHA1 Message Date
mytheclipsebotreview[bot] 4b58a159d0 Auto-merge PR #25
build(deps-dev): bump @types/node from 26.2.0 to 26.3.0 in /services/discord-gateway in the development group
2026-08-28 14:29:21 +00:00
mytheclipsebotreview[bot] f53a2d4f4a Auto-merge PR #24
build(deps-dev): bump the development group in /services/frontend with 2 updates
2026-08-28 14:29:12 +00:00
mytheclipsebotreview[bot] 76daa7026c Auto-merge PR #23
build(deps-dev): bump @types/node from 26.2.0 to 26.3.0 in /services/backend in the development group
2026-08-28 14:29:04 +00:00
dependabot[bot] 430e3d137c build(deps-dev): bump @types/node
Bumps the development group in /services/discord-gateway with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 26.2.0 to 26.3.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.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 14:25:31 +00:00
dependabot[bot] de8b521bb4 build(deps-dev): bump the development group
Bumps the development group in /services/frontend with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [puppeteer-core](https://github.com/puppeteer/puppeteer).


Updates `@types/node` from 26.2.0 to 26.3.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `puppeteer-core` from 25.8.0 to 25.9.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.8.0...puppeteer-core-v25.9.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: puppeteer-core
  dependency-version: 25.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 14:25:12 +00:00
dependabot[bot] 8bd7f5a98f build(deps-dev): bump @types/node
Bumps the development group in /services/backend with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 26.2.0 to 26.3.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.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 14:25:12 +00:00
asepharyana ffbe9959ab chore: migrate AI LLM router from 9router to omniroute
Switch GMW's AI LLM base URL from 9router (https://9router.asepharyana.my.id/v1)
to omniroute on imrnes (http://100.121.180.82:20128/api/v1).

- Update default AI_LLM_BASE_URL in discord-gateway + backend config schemas
- Update .env.example documentation
- Update all 9router references in comments/docs/tests to omniroute
- Production BWS secret gmw_ai_llm_base_url already updated

Omniroute uses /api/v1 prefix (not /v1 like 9router), so the base URL
now correctly points at the right API path for the OpenAI SDK.
2026-08-28 20:18:48 +07:00
asepharyana 14bd20f072 fix(frontend): mobile navbar navigation + SSR hydration mismatch
Root cause of 'navbar mobile tak bisa pindah halaman': Next <Link>
client-side navigation is dead app-wide. A React hydration mismatch
(#418: 'server rendered text didn't match the client') is thrown by the
SSR-seeded live feeds — relative times (formatRelativeTime(e.edited_at) /
m.created_at) computed with Date.now() render slightly differently on
server vs client, which breaks the Next client router (router.push is a
no-op). The desktop NavRail worked only because it uses plain <a href>
(hard navigation bypasses the broken router).

Fixes:
- mobile-nav.tsx: use plain <a href> (NOT Next <Link>), identical to the
  working sidebar NavRail, so mobile nav always navigates regardless of
  router state ('ikuti cara kerja sidebar').
- Add suppressHydrationWarning to the SSR-seeded relative-time spans so
  server/client drift no longer throws #418 (EditHistory, LiveModerationFeed,
  messages/results + detail rows, recordings, TermGlossary,
  ChannelCultureGlossary, CategoryDrilldown).

Verified on non-prod :4024 @375px: Voice/Media/Search all navigate, no #418
in console. Plan: .hermes/plans/mobile-nav-hydration-fix.md
2026-08-28 09:57:49 +07:00
asepharyana 4d0bbed596 fix(frontend): mobile nav pills fixed width so labels don't squeeze
Use fixed w-[72px] shrink-0 pills (not flex-1) so the 7 bottom-nav items
keep their shape and scroll horizontally instead of compressing labels to
overlap; add truncating 10px mobile label (12px >=sm).
2026-08-28 01:18:23 +07:00
asepharyana ae41f64e46 fix(frontend): mobile navbar reachable to all pages + responsive form controls
- Root cause of 'navbar mobile tak bisa pindah halaman': the chatbot FAB
  (fixed right-4 bottom-5 z-50) overlapped the rightmost mobile bottom-nav
  items (z-40) and intercepted taps. Raise the FAB above the nav on mobile
  (bottom above nav, md:bottom-5 on desktop) so it never blocks nav taps.
- Mobile bottom nav now mirrors the FULL desktop sidebar (all 7 items:
  dashboard, messages, voice, media, recordings, moderation, analysis);
  horizontally scrollable + snap-to-active on narrow screens.
- Select dropdown: clamp portal position within viewport + min-width so it
  never overflows off-screen on mobile triggers near the right edge; larger
  tap targets on touch.
- Input/Textarea: text-base (16px) on touch to prevent iOS auto-zoom on
  focus, text-sm on >=sm; comfortable mobile min-height for chat input.
- Add .hermes/plans/mobile-nav-form-responsive.md spec.
2026-08-28 01:07:37 +07:00
asepharyana e7b0f02620 Merge pull request #22 from asepharyana/dependabot/npm_and_yarn/services/frontend/development-beb2655a33
build(deps-dev): bump @types/react-dom from 19.2.4 to 19.2.5 in /services/frontend in the development group
2026-08-28 00:34:13 +07:00
mytheclipsebotreview[bot] 120aa95818 Auto-merge PR #21
build(deps): bump lucide-react from 1.33.0 to 1.34.0 in /services/frontend in the production group
2026-08-27 14:27:50 +00:00
dependabot[bot] 870f7546fb build(deps-dev): bump @types/react-dom
Bumps the development group in /services/frontend with 1 update: [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


Updates `@types/react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 14:25:18 +00:00
dependabot[bot] 565b3da086 build(deps): bump lucide-react
Bumps the production group in /services/frontend with 1 update: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react).


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

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 14:24:50 +00:00
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
117 changed files with 6589 additions and 4055 deletions

No files matched your search

+2 -2
View File
@@ -85,8 +85,8 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
# === AI Analysis === # === AI Analysis ===
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=http://100.121.180.82:20128/api/v1 # LLM API base URL (omniroute — OpenAI-compatible router on imrnes, Tailscale 100.121.180.82)
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
+61
View File
@@ -0,0 +1,61 @@
# GMW — Fix mobile navbar "tidak bisa pindah halaman" (root cause)
## Context / symptom
- User (phone, mobile viewport <md): pressing the bottom mobile nav items does NOT
navigate ("Masih sama, walau sudah ditekan tidak pindah halaman").
- Previous fix (commits ae41f64e, 4d0bbed5) repositioned the chatbot FAB above the
nav and expanded nav to all 7 items — but the bug persisted.
## Root cause (verified live 2026-08-28)
Reproduced on the LIVE site (`imphnen.asepharyana.my.id`) with Playwright @375px:
1. **Next `<Link>` client-side navigation is dead app-wide.** Clicking a mobile nav
`<Link>` fires the click (event reaches document, `inLink=true`,
`defaultPrevented=false`) but **the URL never changes**. `window.next.router.push('/voice')`
also does nothing. The desktop NavRail navigates only because it uses a **plain
`<a href>`** (full browser navigation bypasses the broken router).
2. **A React hydration failure (#418 "server rendered text didn't match the client")**
is thrown on live pages — the only console error. Cause: relative-time text
(`formatRelativeTime(e.edited_at)` / `formatRelativeTime(m.created_at)` in the
SSR-seeded messages/edit-history feed uses `Date.now()`; server and client
render slightly different text → hydration mismatch → React re-renders, and the
Next client router ends up non-functional.
## Why desktop "worked" / mobile didn't
- `NavRail` = plain `<a href>` → **hard** navigation (works).
- `MobileNav` = Next `<Link>` → **client** navigation → dead router.
User's instruction "ikuti cara kerja sidebar" = make mobile nav behave like the
sidebar (plain anchors).
## Changes
### 1. `src/components/shell/mobile-nav.tsx`
Replace Next `<Link>` with a **plain `<a href>`** (mirrors NavRail). Keep:
`usePathname` for active-state styling + `scrollIntoView` for snap-to-active.
Drop the now-unused `Link` import (biome import-order — run biome check --write).
### 2. Hydration root cause — `formatRelativeTime` in SSR-seeded feeds
Add `suppressHydrationWarning` to the timestamp elements in the SSR-seeded
components that render live-relative text so server/client text drift no longer
throws #418:
- `src/components/EditHistory.tsx` (line ~111 `edited {formatRelativeTime(...)}`)
- `src/app/(dashboard)/messages/view.tsx` MessageRow channel/time (line ~580) +
semantic row (line ~364)
- `src/components/LiveModerationFeed.tsx` (line ~139)
- (recordings/view.tsx, TermGlossary, ChannelCultureGlossary, CategoryDrilldown,
chatbot — chatbot is client-after-mount; add where SSR-seeded.)
NOTE: `suppressHydrationWarning` is safe for these single-text spans; the values
are cosmetic and self-correct on the next interval/render.
## Verification (non-prod, NEVER 4017)
- `pnpm format` + `pnpm lint` (biome) + `pnpm build` clean.
- Smoke on :4024 (fresh `next dev`): mobile click on nav item actually navigates
(URL changes) AND no React #418 in console.
- After push: `gh run watch`, then live `imphnen.asepharyana.my.id` @375px: nav tap
navigates, console free of #418.
## Files
- services/frontend/src/components/shell/mobile-nav.tsx
- services/frontend/src/components/EditHistory.tsx
- services/frontend/src/app/(dashboard)/messages/view.tsx
- services/frontend/src/components/LiveModerationFeed.tsx
- (others only if #418 persists)
+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.3.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"
} }
} }
+486 -287
View File
File diff suppressed because it is too large. Load diff
@@ -158,7 +158,7 @@ Gaya ngobrol:
tool_choice: "auto", tool_choice: "auto",
max_tokens: 600, max_tokens: 600,
temperature: 0.4, temperature: 0.4,
// Non-streaming: request a single complete response. 9router may // Non-streaming: request a single complete response. omniroute may
// still emit SSE even with stream:false, so the parser below // still emit SSE even with stream:false, so the parser below
// handle both raw-JSON and SSE bodies. // handle both raw-JSON and SSE bodies.
stream: false, stream: false,
@@ -176,7 +176,7 @@ Gaya ngobrol:
}, },
); );
// Parse the body into content + tool_calls. 9router may return either // Parse the body into content + tool_calls. omniroute may return either
// a single JSON object (stream:false honored) or SSE text (stream // a single JSON object (stream:false honored) or SSE text (stream
// implied) — parseResponse handles both. // implied) — parseResponse handles both.
const { content, toolCalls } = this.parseResponse( const { content, toolCalls } = this.parseResponse(
@@ -252,7 +252,7 @@ Gaya ngobrol:
/** /**
* Parse an LLM HTTP body into content + tool_calls. Handles both shapes * Parse an LLM HTTP body into content + tool_calls. Handles both shapes
* 9router can return: a single JSON object (stream:false honored) or SSE * omniroute can return: a single JSON object (stream:false honored) or SSE
* text (stream implied). For SSE we delegate to parseSse. * text (stream implied). For SSE we delegate to parseSse.
*/ */
private parseResponse(body: string): { private parseResponse(body: string): {
@@ -306,7 +306,7 @@ Gaya ngobrol:
/** /**
* Parse an SSE stream body into accumulated content + any tool_calls. * Parse an SSE stream body into accumulated content + any tool_calls.
* 9router (and most OpenAI-compatible routers) emit `data: {json}` lines * omniroute (and most OpenAI-compatible routers) emit `data: {json}` lines
* even when stream is only implied; we must collect deltas manually. * even when stream is only implied; we must collect deltas manually.
*/ */
private parseSse(body: string): { private parseSse(body: string): {
@@ -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 -1
View File
@@ -131,7 +131,7 @@ export const configSchema = z
AI_LLM_BASE_URL: z AI_LLM_BASE_URL: z
.string() .string()
.url() .url()
.default("https://9router.asepharyana.my.id/v1"), .default("http://100.121.180.82:20128/api/v1"),
AI_LLM_MODEL: z.string().default("text"), AI_LLM_MODEL: z.string().default("text"),
AI_LLM_VISION_MODEL: z.string().optional(), AI_LLM_VISION_MODEL: z.string().optional(),
AI_LLM_EMBEDDING_MODEL: z.string().optional(), AI_LLM_EMBEDDING_MODEL: z.string().optional(),
@@ -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);
} }
+1 -1
View File
@@ -139,5 +139,5 @@ pipeline gauges — `ai_analysis_queued_conversations`,
numeric snowflake IDs never trigger false positives. numeric snowflake IDs never trigger false positives.
- **Semantic cache is batched** (one embed call + one Qdrant batch search), - **Semantic cache is batched** (one embed call + one Qdrant batch search),
not N sequential round-trips. `ensureQdrantCollection` is memoized. not N sequential round-trips. `ensureQdrantCollection` is memoized.
- **Streaming is mandatory** against the 9router base URL (non-stream waits for - **Streaming is mandatory** against the omniroute base URL (non-stream waits for
the full body and times out). `llmClient` aggregates SSE chunks. the full body and times out). `llmClient` aggregates SSE chunks.
@@ -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.3.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,11 +80,12 @@ 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,
// Router (9router / formerly omniroute) always streams SSE even when the // Router (omniroute) always streams SSE even when the
// request omits `stream`. In non-stream mode the OpenAI SDK waits // request omits `stream`. In non-stream mode the OpenAI SDK waits
// for the FULL body before parsing, so slow/long upstream streams // for the FULL body before parsing, so slow/long upstream streams
// hit the 30s/60s timeout and abort mid-generation. Streaming mode // hit the 30s/60s timeout and abort mid-generation. Streaming mode
@@ -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,
@@ -94,7 +94,7 @@ type LLMResponseChunk = {
* `delta.content`; falls back to reasoning fields so reasoning-only models * `delta.content`; falls back to reasoning fields so reasoning-only models
* still produce usable aggregated text. Providers differ in the field name: * still produce usable aggregated text. Providers differ in the field name:
* - DeepSeek-style / Cloudflare gemma → `delta.reasoning_content` * - DeepSeek-style / Cloudflare gemma → `delta.reasoning_content`
* - mimo (via 9router) streams reasoning in `delta.reasoning` + * - mimo (via omniroute) streams reasoning in `delta.reasoning` +
* `delta.reasoning_details[].text` (content:"") — without these fallbacks * `delta.reasoning_details[].text` (content:"") — without these fallbacks
* vision aggregation came back empty ("Vision API null response"). * vision aggregation came back empty ("Vision API null response").
* Exported for unit tests. * Exported for unit tests.
@@ -225,7 +225,7 @@ export function buildLlmParams(
reasoning: { enabled: false }, reasoning: { enabled: false },
// vLLM / Qwen / litellm // vLLM / Qwen / litellm
chat_template_kwargs: { enable_thinking: false }, chat_template_kwargs: { enable_thinking: false },
// Anthropic / Claude-format (9router exposes thinkingFormat // Anthropic / Claude-format (omniroute exposes thinkingFormat
// "claude-adaptive" / "claude-budget" on its reasoning models) // "claude-adaptive" / "claude-budget" on its reasoning models)
thinking: { type: "disabled" }, thinking: { type: "disabled" },
} as Record<string, unknown>); } as Record<string, unknown>);
@@ -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) {
@@ -149,8 +149,8 @@ export const configSchema = z
AI_LLM_BASE_URL: z AI_LLM_BASE_URL: z
.string() .string()
.url() .url()
.default("https://9router.asepharyana.my.id/v1"), .default("http://100.121.180.82:20128/api/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;
@@ -1,7 +1,7 @@
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// llmClient chunk extraction — reasoning_content fallback (pure, no network) // llmClient chunk extraction — reasoning_content fallback (pure, no network)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// Regression: 9router "multimodal" combo routed to cloudflare gemma-4-26b // Regression: omniroute "multimodal" combo routed to cloudflare gemma-4-26b
// which streams ALL output in delta.reasoning_content with content:"" — the // which streams ALL output in delta.reasoning_content with content:"" — the
// old extractor returned empty text → llmVision reported "Vision API null // old extractor returned empty text → llmVision reported "Vision API null
// response" → every image moderation batch fell back to text-only analysis // response" → every image moderation batch fell back to text-only analysis
@@ -19,7 +19,7 @@ describe("extractChunkText — streaming chunk text extraction", () => {
}); });
it("falls back to delta.reasoning_content when content is empty — reasoning-only models (cloudflare gemma)", () => { it("falls back to delta.reasoning_content when content is empty — reasoning-only models (cloudflare gemma)", () => {
// Exact shape seen from 9router → cloudflare-ai/@cf/google/gemma-4-26b: // Exact shape seen from omniroute → cloudflare-ai/@cf/google/gemma-4-26b:
// {"choices":[{"delta":{"content":"","reasoning_content":"Task","role":"assistant"},"finish_reason":null,...}]} // {"choices":[{"delta":{"content":"","reasoning_content":"Task","role":"assistant"},"finish_reason":null,...}]}
expect( expect(
extractChunkText({ extractChunkText({
@@ -33,8 +33,8 @@ describe("extractChunkText — streaming chunk text extraction", () => {
).toBe("Task"); ).toBe("Task");
}); });
it('falls back to delta.reasoning — mimo via 9router streams reasoning there with content:""', () => { it('falls back to delta.reasoning — mimo via omniroute streams reasoning there with content:""', () => {
// Exact shape seen from 9router → mimo-v2.5-free (2026-08-11): // Exact shape seen from omniroute → mimo-v2.5-free (2026-08-11):
// {"choices":[{"delta":{"content":"","reasoning":"The user wants a","role":"assistant"},"finish_reason":null,...}]} // {"choices":[{"delta":{"content":"","reasoning":"The user wants a","role":"assistant"},"finish_reason":null,...}]}
expect( expect(
extractChunkText({ extractChunkText({
@@ -6,7 +6,7 @@
// a VALID vision_llm result. Every later analysis of the same image (same // a VALID vision_llm result. Every later analysis of the same image (same
// hash / phash) then hit the poisoned cache and the moderation LLM wrote // hash / phash) then hit the poisoned cache and the moderation LLM wrote
// "lampiran yang gagal terbaca" — image analysis seemed permanently broken // "lampiran yang gagal terbaca" — image analysis seemed permanently broken
// even though 9router was responding fine. // even though omniroute was responding fine.
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { isNoImageSeenText } from "../src/modules/ai-moderation/visionAnalyzer.js"; import { isNoImageSeenText } from "../src/modules/ai-moderation/visionAnalyzer.js";
@@ -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.
@@ -0,0 +1,53 @@
# GMW Frontend — Mobile Nav + Form Controls Responsive Pass
## Problem
Two mobile UX defects reported by user:
1. **Mobile navbar can't navigate between pages** (`tidak bisa pindah halaman`).
- **Root cause A (click-block):** the floating chatbot FAB
(`chatbot.tsx` line ~125: `fixed right-4 bottom-5 z-50 size-11`) overlaps the
mobile bottom nav (`MobileNav` is `fixed bottom-0 z-40`). Because the FAB is
`z-50` and sits at `bottom-5` on the right, it intercepts taps on the
rightmost bottom-nav items (Voice/Media). Verified in Playwright: click on
`Media` nav item is blocked by `button[aria-label="Open neural HUD assistant"]`.
- **Root cause B (missing destinations):** `mobileNavItems` in
`src/lib/navigation.ts` filters `navItems` to only
`['/dashboard','/messages','/voice','/media']`, so `/recordings`,
`/moderation`, `/analysis` are unreachable from the mobile bottom bar.
- **Chosen design (user directive):** extend the mobile bottom bar to **all 7
nav items**, horizontally **scrollable/flex-wrap** so they fit on narrow
screens, following the desktop sidebar's items (same set as `navItems`).
2. **Form controls not responsive on mobile** (`perbagus textfield, dropdown,
lainnya agar responsif di mobile`).
- `Select` dropdown (`primitives/select.tsx`) is portalled w/ `fixed`
positioning computed from trigger rect only (top/left/width). On a narrow
viewport a dropdown anchored near the right edge overflows past the screen's
right edge and can be clipped/clickable-outside the visible area. Clamp
horizontally within viewport.
- `Input`/`Textarea` (`primitives/input.tsx`) use `text-sm` (14px) which on
iOS triggers auto-zoom on focus (a known mobile annoyance) and can be small
touch targets. Give inputs a comfortable mobile baseline.
## Files touched
- `src/components/shell/mobile-nav.tsx` — use `navItems` (all 7), make the bar
scrollable on narrow widths (overflow-x-auto + snap), keep active indicator.
- `src/components/chatbot/chatbot.tsx` — raise FAB above the mobile bottom nav on
`md:hidden` (e.g. `bottom-[calc(4.75rem+env(safe-area-inset-bottom))]`),
keep `bottom-5` on `md+` so it never overlaps the nav.
- `src/lib/navigation.ts` — drop `mobileNavItems` special-case (or repoint to
`navItems`); keep `navItems` as the single source.
- `src/components/primitives/select.tsx` — clamp dropdown `left` so it stays
within viewport; cap width on narrow screens; keep max-height.
- `src/components/primitives/input.tsx` — mobile-friendly font size / touch
height (16px inputs to avoid iOS zoom; keep desktop as-is via responsive
classes).
## Verification
- `pnpm format`, `pnpm lint` (biome clean), `pnpm build` compiles.
- Smoke on NON-PROD port (4024) — confirm free first via `ss -ltnp`.
- Playwright mobile viewport (390px): ALL 7 nav items present in bottom bar;
tapping each navigates (esp. Media rightmost item NOT blocked by FAB); FAB is
raised above the nav and no longer intercepts taps.
- Select dropdown on a 390px viewport stays fully on-screen (no right overflow).
- Commit (no trailer), push, `gh run watch`, verify all 7 live routes 200.
+10
View File
@@ -51,3 +51,13 @@ Discord → discord-gateway → Redis pub/sub → backend (Express :4001) ←→
not top-level. not top-level.
- `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared - `GET /api/voice/status` now includes `activeSpeakers` (authoritative shared
snapshot from `src/modules/voice/live-speaker.ts` on the backend). snapshot from `src/modules/voice/live-speaker.ts` on the backend).
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+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.34.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.3.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.9.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>
);
} }
Loaded 50 of 117 files, more files were not shown because too many files have changed in this diff. Show more