Compare commits
98
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b58a159d0 | ||
|
|
f53a2d4f4a | ||
|
|
76daa7026c | ||
|
|
430e3d137c | ||
|
|
de8b521bb4 | ||
|
|
8bd7f5a98f | ||
|
|
ffbe9959ab | ||
|
|
14bd20f072 | ||
|
|
4d0bbed596 | ||
|
|
ae41f64e46 | ||
|
|
e7b0f02620 | ||
|
|
120aa95818 | ||
|
|
870f7546fb | ||
|
|
565b3da086 | ||
|
|
a54c34c6b3 | ||
|
|
7375e2ed52 | ||
|
|
5196cb0221 | ||
|
|
631b5e1027 | ||
|
|
8049b70738 | ||
|
|
ea23c405fa | ||
|
|
4991164591 | ||
|
|
1590479f58 | ||
|
|
0de393625f | ||
|
|
cccfd89266 | ||
|
|
17aad2cc39 | ||
|
|
a6e2c1fa4f | ||
|
|
7e0d0d5123 | ||
|
|
4f4f92706c | ||
|
|
611ba39973 | ||
|
|
192685e1ce | ||
|
|
cacebfd94e | ||
|
|
709074935f | ||
|
|
9f02edd646 | ||
|
|
5fc3cf86d5 | ||
|
|
e81391484b | ||
|
|
3b8fe1b1c3 | ||
|
|
54e7220d06 | ||
|
|
46d889271e | ||
|
|
0c3a82ad77 | ||
|
|
b640079cb6 | ||
|
|
37d15456dc | ||
|
|
153e628aba | ||
|
|
a5f908dc06 | ||
|
|
7fc36170c8 | ||
|
|
ccfe769b2f | ||
|
|
3b688e0d0f | ||
|
|
29f59fd91f | ||
|
|
c3165ed702 | ||
|
|
c72fadbbb4 | ||
|
|
613840e2d1 | ||
|
|
24336186dc | ||
|
|
6b66863897 | ||
|
|
fc40609a3a | ||
|
|
e157ae3ce4 | ||
|
|
3f4c05f5bb | ||
|
|
9c25e0887c | ||
|
|
2aa12b9ec5 | ||
|
|
cab451f0e5 | ||
|
|
183a51003a | ||
|
|
f34811e9b6 | ||
|
|
2c30140ddd | ||
|
|
7e92de63d6 | ||
|
|
68be4352da | ||
|
|
754ef78ff1 | ||
|
|
2959744e0c | ||
|
|
cffa72c21f | ||
|
|
d64c5ba7b6 | ||
|
|
87851f7a71 | ||
|
|
91de43a6c6 | ||
|
|
f8fc08de41 | ||
|
|
dfa69e9f45 | ||
|
|
0bd4b4075e | ||
|
|
b679a02cb9 | ||
|
|
4ed1d3c053 | ||
|
|
8dbcc2b527 | ||
|
|
bda210475d | ||
|
|
9b78abaf3d | ||
|
|
53c4158511 | ||
|
|
a2a8c824bf | ||
|
|
dec548fa2f | ||
|
|
12033e5537 | ||
|
|
d06caa7e58 | ||
|
|
173ae3c61c | ||
|
|
2637bbc549 | ||
|
|
6fc68126ac | ||
|
|
28d09a24c9 | ||
|
|
f2ec642a0e | ||
|
|
8a491b9ec2 | ||
|
|
7d54d3a660 | ||
|
|
21509c25ff | ||
|
|
09724287cb | ||
|
|
1ac3d50315 | ||
|
|
9c48d50d25 | ||
|
|
8bd3ec8e83 | ||
|
|
5a4a203f01 | ||
|
|
7f0aa5a7ad | ||
|
|
eb0be981c6 | ||
|
|
466e117bd5 |
No files matched your search
+2
-2
@@ -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)
|
||||||
|
|||||||
@@ -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 }}
|
||||||
@@ -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
|
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+486
-287
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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
+9
@@ -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;
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "server_name" text;
|
||||||
+26
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+660
-435
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.
|
||||||
@@ -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,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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+537
-376
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
Reference in new issue
Block a user